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:
commit
d637d27fa7
4 changed files with 387 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
176
api/routes.py
176
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(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
169
tests/test_3866_project_context_memory_section.py
Normal file
169
tests/test_3866_project_context_memory_section.py
Normal 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"]
|
||||
Loading…
Reference in a new issue