Merge pull request #4163 from nesquena/stage-4144

Release NS (v0.51.406): project-context file in the Memory tab (#3866)
This commit is contained in:
nesquena-hermes 2026-06-13 20:52:27 -07:00 committed by GitHub
commit d637d27fa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 387 additions and 11 deletions

View file

@ -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

View file

@ -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(),
},
)

View file

@ -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 ? `<div class="memory-detail-mtime">${esc(mtimeStr)}</div>` : '';
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 ? `<div class="memory-detail-mtime">${esc(fileName)} · ${esc(path)}</div>` : '';
const shadowed = section === 'project_context' && _memoryData && Array.isArray(_memoryData.project_context_shadowed)
? _memoryData.project_context_shadowed
: [];
const shadowedHtml = shadowed.length
? `<div class="memory-detail-mtime">${esc(shadowed.map(item => `${item.name || 'Context file'} present, shadowed by ${item.shadowed_by || fileName || 'active context'}`).join('; '))}</div>`
: '';
const inner = content
? `<div class="memory-content preview-md">${renderMd(content)}</div>`
: `<div class="memory-empty">${esc(t(meta.emptyKey))}</div>`;
body.innerHTML = `<div class="main-view-content">${mtimeHtml}${inner}</div>`;
: `<div class="memory-empty">${esc(_memorySectionEmpty(meta))}</div>`;
body.innerHTML = `<div class="main-view-content">${pathHtml}${mtimeHtml}${shadowedHtml}${inner}</div>`;
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 = `
<div class="main-view-content">
@ -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)}<span>${esc(t(s.labelKey))}</span>`;
el.innerHTML = `${li(s.iconKey,16)}<span>${esc(_memorySectionLabel(s))}</span>`;
el.onclick = () => openMemorySection(s.key, el);
panel.appendChild(el);
}

View file

@ -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"]