From 6215b4535a531e5e4b4bc4c5d23e247776e1ad25 Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Sat, 13 Jun 2026 14:51:39 -0700
Subject: [PATCH 1/3] feat: show the active workspace project-context file
(AGENTS.md / HERMES.md / CLAUDE.md) in the Memory tab
Fixes #3866
---
api/routes.py | 126 ++++++++++++++++-
static/panels.js | 47 +++++--
...est_3866_project_context_memory_section.py | 129 ++++++++++++++++++
3 files changed, 291 insertions(+), 11 deletions(-)
create mode 100644 tests/test_3866_project_context_memory_section.py
diff --git a/api/routes.py b/api/routes.py
index 041d2650..a5aee9af 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -7034,7 +7034,7 @@ def handle_get(handler, parsed) -> bool:
# ── Memory API (GET) ──
if parsed.path == "/api/memory":
- return _handle_memory_read(handler)
+ return _handle_memory_read(handler, parsed)
# ── Profile API (GET) ──
if parsed.path == "/api/profiles":
@@ -12086,7 +12086,122 @@ def _handle_cron_recent(handler, parsed):
return j(handler, {"completions": [], "since": since})
-def _handle_memory_read(handler):
+_PROJECT_CONTEXT_HERMES_NAMES = (".hermes.md", "HERMES.md")
+_PROJECT_CONTEXT_CWD_NAMES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
+
+
+def _project_context_git_root(start: Path) -> Path | None:
+ """Return the nearest git root for project context discovery."""
+ current = start.resolve()
+ for parent in [current, *current.parents]:
+ if (parent / ".git").exists():
+ return parent
+ return None
+
+
+def _project_context_candidates(workspace: Path) -> list[Path]:
+ """Mirror the agent's first-match project context file priority."""
+ cwd = workspace.resolve()
+ candidates: list[Path] = []
+ stop_at = _project_context_git_root(cwd)
+
+ for directory in [cwd, *cwd.parents]:
+ for name in _PROJECT_CONTEXT_HERMES_NAMES:
+ candidates.append(directory / name)
+ if stop_at and directory == stop_at:
+ break
+
+ for name in _PROJECT_CONTEXT_CWD_NAMES:
+ candidates.append(cwd / name)
+ return candidates
+
+
+def _memory_project_context_workspace(parsed) -> Path | None:
+ qs = parse_qs(parsed.query or "") if parsed is not None else {}
+ sid = qs.get("session_id", [""])[0]
+ if sid:
+ try:
+ return Path(get_session(sid).workspace).expanduser().resolve()
+ except Exception:
+ return None
+
+ raw_workspace = qs.get("workspace", [""])[0] or os.environ.get("TERMINAL_CWD", "") or get_last_workspace()
+ if not raw_workspace:
+ return None
+ try:
+ return Path(resolve_trusted_workspace(raw_workspace)).expanduser().resolve()
+ except Exception:
+ logger.debug("Skipping project context for untrusted workspace %s", raw_workspace, exc_info=True)
+ return None
+
+
+def _read_active_project_context(workspace: Path | None) -> dict:
+ payload = {
+ "content": "",
+ "path": "",
+ "mtime": None,
+ "workspace": str(workspace) if workspace else "",
+ "shadowed": [],
+ }
+ if not workspace:
+ return payload
+ try:
+ if not workspace.exists() or not workspace.is_dir():
+ return payload
+ except OSError:
+ return payload
+
+ seen: set[str] = set()
+ readable: list[dict] = []
+ for candidate in _project_context_candidates(workspace):
+ try:
+ if not candidate.is_file():
+ continue
+ resolved = candidate.resolve()
+ key = os.path.normcase(str(resolved)).casefold()
+ if key in seen:
+ continue
+ seen.add(key)
+ content = resolved.read_text(encoding="utf-8", errors="replace")
+ if not content.strip():
+ continue
+ readable.append(
+ {
+ "name": resolved.name,
+ "path": str(resolved),
+ "content": content,
+ "mtime": resolved.stat().st_mtime,
+ }
+ )
+ except Exception:
+ logger.debug("Could not read project context candidate %s", candidate, exc_info=True)
+
+ if not readable:
+ return payload
+
+ active = readable[0]
+ payload.update(
+ {
+ "content": active["content"],
+ "path": active["path"],
+ "mtime": active["mtime"],
+ "name": active["name"],
+ "shadowed": [
+ {
+ "name": item["name"],
+ "path": item["path"],
+ "mtime": item["mtime"],
+ "shadowed_by": active["name"],
+ "shadowed_by_path": active["path"],
+ }
+ for item in readable[1:]
+ ],
+ }
+ )
+ return payload
+
+
+def _handle_memory_read(handler, parsed=None):
try:
from api.profiles import get_active_hermes_home
@@ -12113,18 +12228,25 @@ def _handle_memory_read(handler):
if soul_file.exists()
else ""
)
+ project_context = _read_active_project_context(_memory_project_context_workspace(parsed))
return j(
handler,
{
"memory": _redact_text(memory),
"user": _redact_text(user),
"soul": _redact_text(soul),
+ "project_context": _redact_text(project_context["content"]),
"memory_path": str(mem_file),
"user_path": str(user_file),
"soul_path": str(soul_file),
+ "project_context_path": project_context["path"],
+ "project_context_name": project_context.get("name", ""),
+ "project_context_workspace": project_context["workspace"],
"memory_mtime": mem_file.stat().st_mtime if mem_file.exists() else None,
"user_mtime": user_file.stat().st_mtime if user_file.exists() else None,
"soul_mtime": soul_file.stat().st_mtime if soul_file.exists() else None,
+ "project_context_mtime": project_context["mtime"],
+ "project_context_shadowed": project_context["shadowed"],
"external_notes_enabled": _external_notes_sources_enabled(),
},
)
diff --git a/static/panels.js b/static/panels.js
index 1b747a9b..05ee0abd 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -4182,13 +4182,14 @@ let _notesSelectedSource = 'joplin';
let _notesPreviewNote = null;
let _notesSearchError = '';
let _notesSearchLoading = false;
-let _currentMemorySection = null; // 'memory' | 'user' | 'soul' | 'external_notes'
+let _currentMemorySection = null; // 'memory' | 'user' | 'soul' | 'project_context' | 'external_notes'
let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit'
const MEMORY_SECTIONS = [
{ key: 'memory', labelKey: 'my_notes', emptyKey: 'no_notes_yet', iconKey: 'brain' },
{ key: 'user', labelKey: 'user_profile', emptyKey: 'no_profile_yet', iconKey: 'user' },
{ key: 'soul', labelKey: 'agent_soul', emptyKey: 'no_soul_yet', iconKey: 'sparkles' },
+ { key: 'project_context', label: 'Project Context', empty: 'No project context file found for this workspace.', iconKey: 'file-text', readOnly: true },
{ key: 'external_notes', labelKey: 'external_notes_sources', emptyKey: 'external_notes_empty', iconKey: 'book-open' },
];
@@ -4196,10 +4197,21 @@ function _memorySectionMeta(key) {
return MEMORY_SECTIONS.find(s => s.key === key) || MEMORY_SECTIONS[0];
}
+function _memorySectionLabel(meta) {
+ if (meta.label) return meta.label;
+ return t(meta.labelKey);
+}
+
+function _memorySectionEmpty(meta) {
+ if (meta.empty) return meta.empty;
+ return t(meta.emptyKey);
+}
+
function _memorySectionContent(key) {
if (!_memoryData) return '';
if (key === 'user') return _memoryData.user || '';
if (key === 'soul') return _memoryData.soul || '';
+ if (key === 'project_context') return _memoryData.project_context || '';
return _memoryData.memory || '';
}
@@ -4207,6 +4219,7 @@ function _memorySectionMtime(key) {
if (!_memoryData) return 0;
if (key === 'user') return _memoryData.user_mtime || 0;
if (key === 'soul') return _memoryData.soul_mtime || 0;
+ if (key === 'project_context') return _memoryData.project_context_mtime || 0;
return _memoryData.memory_mtime || 0;
}
@@ -4216,7 +4229,8 @@ function _setMemoryHeaderButtons(mode) {
const editBtn = $('btnEditMemoryDetail');
const cancelBtn = $('btnCancelMemoryDetail');
const saveBtn = $('btnSaveMemoryDetail');
- if (mode === 'read' && _currentMemorySection !== 'external_notes') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
+ const meta = _memorySectionMeta(_currentMemorySection);
+ if (mode === 'read' && _currentMemorySection !== 'external_notes' && !meta.readOnly) { show(editBtn); hide(cancelBtn); hide(saveBtn); }
else if (mode === 'edit') { hide(editBtn); show(cancelBtn); show(saveBtn); }
else { hide(editBtn); hide(cancelBtn); hide(saveBtn); }
}
@@ -4299,15 +4313,24 @@ function _renderMemoryDetail(section) {
const body = $('memoryDetailBody');
const empty = $('memoryDetailEmpty');
if (!title || !body) return;
- title.textContent = t(meta.labelKey);
+ title.textContent = _memorySectionLabel(meta);
const content = _memorySectionContent(section);
const mtime = _memorySectionMtime(section);
const mtimeStr = mtime ? new Date(mtime * 1000).toLocaleString() : '';
const mtimeHtml = mtimeStr ? `
${esc(mtimeStr)}
` : '';
+ const path = section === 'project_context' && _memoryData ? (_memoryData.project_context_path || '') : '';
+ const fileName = section === 'project_context' && _memoryData ? (_memoryData.project_context_name || (path.split(/[\\/]/).pop() || '')) : '';
+ const pathHtml = path ? `${esc(fileName)} · ${esc(path)}
` : '';
+ const shadowed = section === 'project_context' && _memoryData && Array.isArray(_memoryData.project_context_shadowed)
+ ? _memoryData.project_context_shadowed
+ : [];
+ const shadowedHtml = shadowed.length
+ ? `${esc(shadowed.map(item => `${item.name || 'Context file'} present, shadowed by ${item.shadowed_by || fileName || 'active context'}`).join('; '))}
`
+ : '';
const inner = content
? `${renderMd(content)}
`
- : `${esc(t(meta.emptyKey))}
`;
- body.innerHTML = `${mtimeHtml}${inner}
`;
+ : `${esc(_memorySectionEmpty(meta))}
`;
+ body.innerHTML = `${pathHtml}${mtimeHtml}${shadowedHtml}${inner}
`;
body.style.display = '';
if (empty) empty.style.display = 'none';
_memoryMode = 'read';
@@ -4320,7 +4343,7 @@ function _renderMemoryEdit(section) {
const body = $('memoryDetailBody');
const empty = $('memoryDetailEmpty');
if (!title || !body) return;
- title.textContent = t(meta.labelKey);
+ title.textContent = _memorySectionLabel(meta);
const content = _memorySectionContent(section);
body.innerHTML = `
@@ -4411,7 +4434,8 @@ async function openMemorySection(section, el) {
}
function editCurrentMemory() {
- if (!_currentMemorySection || _currentMemorySection === 'external_notes') return;
+ const meta = _memorySectionMeta(_currentMemorySection);
+ if (!_currentMemorySection || _currentMemorySection === 'external_notes' || meta.readOnly) return;
_renderMemoryEdit(_currentMemorySection);
}
@@ -4426,6 +4450,7 @@ function closeMemoryEdit() { cancelMemoryEdit(); }
async function submitMemorySave() {
if (!_currentMemorySection) return;
+ if (_memorySectionMeta(_currentMemorySection).readOnly) return;
const ta = $('memEditContent');
const errEl = $('memEditError');
if (!ta) return;
@@ -5213,6 +5238,7 @@ async function switchToWorkspace(path,name){
S._profileSwitchWorkspace=null;
syncTopbar();
await loadDir('.');
+ if (_currentPanel === 'memory') await loadMemory(true);
showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path)));
}catch(e){setStatus(t('switch_failed')+e.message);}
}
@@ -5809,7 +5835,10 @@ async function deleteProfile(name) {
async function loadMemory(force) {
const panel = $('memoryPanel');
try {
- const data = await api('/api/memory');
+ const memoryUrl = S.session && S.session.session_id
+ ? `/api/memory?session_id=${encodeURIComponent(S.session.session_id)}`
+ : '/api/memory';
+ const data = await api(memoryUrl);
_memoryData = data;
if (_currentMemorySection === 'external_notes' && !data.external_notes_enabled) {
_currentMemorySection = null;
@@ -5825,7 +5854,7 @@ async function loadMemory(force) {
el.type = 'button';
el.className = 'side-menu-item';
if (_currentMemorySection === s.key) el.classList.add('active');
- el.innerHTML = `${li(s.iconKey,16)}
${esc(t(s.labelKey))}`;
+ el.innerHTML = `${li(s.iconKey,16)}
${esc(_memorySectionLabel(s))}`;
el.onclick = () => openMemorySection(s.key, el);
panel.appendChild(el);
}
diff --git a/tests/test_3866_project_context_memory_section.py b/tests/test_3866_project_context_memory_section.py
new file mode 100644
index 00000000..42efab7b
--- /dev/null
+++ b/tests/test_3866_project_context_memory_section.py
@@ -0,0 +1,129 @@
+import json
+import pathlib
+from types import SimpleNamespace
+from urllib.parse import urlencode
+
+import api.profiles
+import api.routes as routes
+
+
+REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
+
+
+def project_context_for(workspace):
+ return routes._read_active_project_context(pathlib.Path(workspace))
+
+
+def test_project_context_reads_agents_md_from_active_workspace(tmp_path):
+ workspace = tmp_path / "agents-only"
+ workspace.mkdir()
+ (workspace / "AGENTS.md").write_text("# Agent Rules\n\nUse pytest here.", encoding="utf-8")
+
+ data = project_context_for(workspace)
+
+ assert "Use pytest here." in data["content"]
+ assert data["path"].endswith("AGENTS.md")
+ assert data["name"] == "AGENTS.md"
+ assert data["shadowed"] == []
+
+
+def test_project_context_prefers_hermes_md_and_reports_shadowed_agents(tmp_path):
+ workspace = tmp_path / "priority"
+ workspace.mkdir()
+ (workspace / "HERMES.md").write_text("# Hermes Rules\n\nHermes wins.", encoding="utf-8")
+ (workspace / "AGENTS.md").write_text("# Agent Rules\n\nAgents lose.", encoding="utf-8")
+
+ data = project_context_for(workspace)
+
+ assert "Hermes wins." in data["content"]
+ assert "Agents lose." not in data["content"]
+ assert data["path"].endswith("HERMES.md")
+ assert [item["name"] for item in data["shadowed"]] == ["AGENTS.md"]
+ assert data["shadowed"][0]["shadowed_by"] == "HERMES.md"
+
+
+def test_project_context_walks_hermes_md_to_git_root_but_not_agents_md(tmp_path):
+ root = tmp_path / "repo"
+ child = root / "src" / "pkg"
+ child.mkdir(parents=True)
+ (root / ".git").mkdir()
+ (root / ".hermes.md").write_text("# Root Hermes\n\nRoot project rules.", encoding="utf-8")
+ (root / "AGENTS.md").write_text("# Root Agents\n\nRoot AGENTS should not be cwd-loaded.", encoding="utf-8")
+
+ data = project_context_for(child)
+
+ assert "Root project rules." in data["content"]
+ assert data["path"].endswith(".hermes.md")
+ assert data["path"].startswith(str(root))
+ assert data["shadowed"] == []
+
+
+def test_project_context_workspace_switch_re_resolves_same_session(tmp_path, monkeypatch):
+ first = tmp_path / "first"
+ second = tmp_path / "second"
+ first.mkdir()
+ second.mkdir()
+ (first / "AGENTS.md").write_text("First workspace rules.", encoding="utf-8")
+ (second / "AGENTS.md").write_text("Second workspace rules.", encoding="utf-8")
+
+ current = {"workspace": str(first)}
+
+ def fake_get_session(_sid):
+ return SimpleNamespace(workspace=current["workspace"])
+
+ monkeypatch.setattr(routes, "get_session", fake_get_session)
+ parsed = SimpleNamespace(query=urlencode({"session_id": "sid"}))
+
+ before = routes._read_active_project_context(routes._memory_project_context_workspace(parsed))
+ assert "First workspace rules." in before["content"]
+
+ current["workspace"] = str(second)
+ after = routes._read_active_project_context(routes._memory_project_context_workspace(parsed))
+ assert "Second workspace rules." in after["content"]
+ assert "First workspace rules." not in after["content"]
+ assert after["workspace"] == str(second.resolve())
+
+
+def test_project_context_absent_returns_empty_fields(tmp_path):
+ workspace = tmp_path / "empty"
+ workspace.mkdir()
+ (workspace / ".git").mkdir()
+
+ data = project_context_for(workspace)
+
+ assert data["content"] == ""
+ assert data["path"] == ""
+ assert data["mtime"] is None
+ assert data["shadowed"] == []
+
+
+def test_project_context_content_is_redacted_in_memory_response(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ workspace = tmp_path / "redacted"
+ (home / "memories").mkdir(parents=True)
+ workspace.mkdir()
+ secret = "ghp_TestFakeCredential1234567890ab"
+ (workspace / "AGENTS.md").write_text(
+ f"# Agent Rules\n\nGitHub PAT: {secret}\nNormal note: keep me.",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: home)
+ monkeypatch.setattr(routes, "_memory_project_context_workspace", lambda _parsed: workspace)
+ monkeypatch.setattr(routes, "_external_notes_sources_enabled", lambda: False)
+ monkeypatch.setattr(routes, "j", lambda _handler, payload, **_kwargs: payload)
+
+ payload = routes._handle_memory_read(object(), SimpleNamespace(query=""))
+ dumped = json.dumps(payload)
+
+ assert secret not in dumped
+ assert "Normal note: keep me." in payload["project_context"]
+
+
+def test_memory_panel_defines_read_only_project_context_section():
+ panels = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
+
+ assert "key: 'project_context'" in panels
+ assert "readOnly: true" in panels
+ assert "project_context_shadowed" in panels
+ assert "/api/memory?session_id=" in panels
From b139404cdc57f204e6024e0279877e1ee4599534 Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Sat, 13 Jun 2026 18:45:41 -0700
Subject: [PATCH 2/3] fix: guard blank session workspace + agent parity for
project-context tab
- Return None for blank session.workspace instead of Path('').resolve()
(which resolved to the server CWD and could surface the install's own
AGENTS.md/HERMES.md as project context).
- Match the agent's lowercase filename variants (agents.md / claude.md).
- Read .cursor/rules/*.mdc, not just the legacy .cursorrules file.
- Strip YAML frontmatter and cap each source at 20k, mirroring what the
agent actually injects.
- Regression tests for the guard, lowercase match, and frontmatter strip.
---
api/routes.py | 54 ++++++++++++++++++-
...est_3866_project_context_memory_section.py | 40 ++++++++++++++
2 files changed, 92 insertions(+), 2 deletions(-)
diff --git a/api/routes.py b/api/routes.py
index a5aee9af..cc6d860d 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -12087,7 +12087,36 @@ def _handle_cron_recent(handler, parsed):
_PROJECT_CONTEXT_HERMES_NAMES = (".hermes.md", "HERMES.md")
-_PROJECT_CONTEXT_CWD_NAMES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
+# Mirror the agent's lowercase filename variants (agents.md / claude.md) so the
+# tab does not under-report on case-sensitive filesystems.
+_PROJECT_CONTEXT_CWD_NAMES = (
+ "AGENTS.md",
+ "agents.md",
+ "CLAUDE.md",
+ "claude.md",
+ ".cursorrules",
+)
+# Modern Cursor rules live in a directory of .mdc files, which the agent also loads.
+_PROJECT_CONTEXT_CURSOR_RULES_GLOB = ".cursor/rules/*.mdc"
+_PROJECT_CONTEXT_MAX_BYTES = 20_000
+
+
+def _strip_project_context_frontmatter(content: str) -> str:
+ """Strip a leading YAML frontmatter block, mirroring the agent's loader.
+
+ The agent removes a leading ``---\\n ... \\n---`` block before injecting a
+ context file. Without this the tab would display frontmatter the agent never
+ sends to the model.
+ """
+ if not content.startswith("---"):
+ return content
+ lines = content.splitlines(keepends=True)
+ if not lines or lines[0].strip() != "---":
+ return content
+ for idx in range(1, len(lines)):
+ if lines[idx].strip() in ("---", "..."):
+ return "".join(lines[idx + 1:]).lstrip("\n")
+ return content
def _project_context_git_root(start: Path) -> Path | None:
@@ -12113,6 +12142,14 @@ def _project_context_candidates(workspace: Path) -> list[Path]:
for name in _PROJECT_CONTEXT_CWD_NAMES:
candidates.append(cwd / name)
+
+ # Modern Cursor rules: the agent reads .cursor/rules/*.mdc in addition to
+ # the legacy .cursorrules file. Sort for deterministic ordering.
+ try:
+ candidates.extend(sorted(cwd.glob(_PROJECT_CONTEXT_CURSOR_RULES_GLOB)))
+ except OSError:
+ pass
+
return candidates
@@ -12121,7 +12158,14 @@ def _memory_project_context_workspace(parsed) -> Path | None:
sid = qs.get("session_id", [""])[0]
if sid:
try:
- return Path(get_session(sid).workspace).expanduser().resolve()
+ # A blank session workspace (freshly-created/draft sessions) must not
+ # fall through to Path("").resolve(), which returns the server's own
+ # CWD and would surface the install's AGENTS.md/HERMES.md as if it
+ # were the user's project context.
+ ws = (get_session(sid).workspace or "").strip()
+ if not ws:
+ return None
+ return Path(ws).expanduser().resolve()
except Exception:
return None
@@ -12163,6 +12207,12 @@ def _read_active_project_context(workspace: Path | None) -> dict:
continue
seen.add(key)
content = resolved.read_text(encoding="utf-8", errors="replace")
+ # Mirror what the agent actually injects: strip YAML frontmatter and
+ # cap each source, so the tab reports the effective context rather
+ # than raw file bytes the agent never sends to the model.
+ content = _strip_project_context_frontmatter(content)
+ if len(content) > _PROJECT_CONTEXT_MAX_BYTES:
+ content = content[:_PROJECT_CONTEXT_MAX_BYTES]
if not content.strip():
continue
readable.append(
diff --git a/tests/test_3866_project_context_memory_section.py b/tests/test_3866_project_context_memory_section.py
index 42efab7b..83b4e6a0 100644
--- a/tests/test_3866_project_context_memory_section.py
+++ b/tests/test_3866_project_context_memory_section.py
@@ -127,3 +127,43 @@ def test_memory_panel_defines_read_only_project_context_section():
assert "readOnly: true" in panels
assert "project_context_shadowed" in panels
assert "/api/memory?session_id=" in panels
+
+
+def test_blank_session_workspace_does_not_resolve_to_server_cwd(monkeypatch):
+ # Regression for the empty-workspace guard: a session with a blank workspace
+ # (freshly-created/draft sessions) must return None rather than letting
+ # Path("").resolve() surface the server's own CWD as project context.
+ monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(workspace=""))
+ parsed = SimpleNamespace(query="session_id=draft-session")
+
+ assert routes._memory_project_context_workspace(parsed) is None
+
+
+def test_project_context_reads_lowercase_agents_md(tmp_path):
+ # Parity with the agent, which matches lowercase filename variants.
+ workspace = tmp_path / "lowercase"
+ workspace.mkdir()
+ (workspace / "agents.md").write_text("# lower agents\n\nlowercase loads.", encoding="utf-8")
+
+ data = project_context_for(workspace)
+
+ # On case-insensitive filesystems the resolved name may normalize to the
+ # uppercase candidate; the point of the fix is that the content loads at all
+ # (on case-sensitive Linux CI, only the lowercase candidate matches).
+ assert "lowercase loads." in data["content"]
+ assert data["path"].lower().endswith("agents.md")
+
+
+def test_project_context_strips_yaml_frontmatter(tmp_path):
+ # Parity with the agent, which strips frontmatter before injecting context.
+ workspace = tmp_path / "frontmatter"
+ workspace.mkdir()
+ (workspace / "AGENTS.md").write_text(
+ "---\ntitle: Rules\nowner: me\n---\n\nActual body the agent injects.",
+ encoding="utf-8",
+ )
+
+ data = project_context_for(workspace)
+
+ assert "Actual body the agent injects." in data["content"]
+ assert "title: Rules" not in data["content"]
From c73da4475743333d90a90e63371bb7aece95a949 Mon Sep 17 00:00:00 2001
From: nesquena-hermes
Date: Sun, 14 Jun 2026 03:49:17 +0000
Subject: [PATCH 3/3] docs(changelog): stamp #3866 project-context Memory tab
as v0.51.406 (Release NS)
---
CHANGELOG.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3594af08..e16b1069 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,12 @@
## [Unreleased]
+## [v0.51.406] — 2026-06-14 — Release NS (project-context file in the Memory tab, #3866)
+
+### Added
+
+- **The Memory tab now shows the active workspace's project-context file (AGENTS.md / HERMES.md / CLAUDE.md / .cursorrules) read-only (#3866).** A new "Project Context" section surfaces whatever context file the agent actually loads for the current workspace, with the filename and path shown. It mirrors the agent's own discovery logic — case variants, `.cursor/rules/*.mdc`, YAML-frontmatter stripping, and the 20k per-source cap — so what you see matches what the agent injects, rather than raw file bytes. A blank/draft session workspace correctly shows no context (it does not fall back to the server's working directory). The section is read-only (no edit affordance). (#3866)
+
## [v0.51.405] — 2026-06-14 — Release NR (Transparent Stream live prose stays chronological, #4096)
### Fixed