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 diff --git a/api/routes.py b/api/routes.py index 041d2650..cc6d860d 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,172 @@ 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") +# 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: + """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) + + # 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 + + +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: + # 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 + + 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") + # 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( + { + "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 +12278,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..83b4e6a0 --- /dev/null +++ b/tests/test_3866_project_context_memory_section.py @@ -0,0 +1,169 @@ +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 + + +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"]