From 9ab194aed6d944452f682558a598215a81844c0c Mon Sep 17 00:00:00 2001 From: Thomas Connally Date: Thu, 25 Jun 2026 12:08:06 -0500 Subject: [PATCH] feat(mimir): per-workspace pack config + auto_inject; version-less @perseus header Resolves three filed issues. #441: pack.yaml mimir overrides. load_config only layers global and workspace config.yaml, so a pack manifest's mimir: block was ignored. cmd_render now deep-merges pack.yaml mimir settings over the loaded config, letting a workspace override context_limit/enabled/auto_inject per render target. #442: mimir.auto_inject flag (default true). When false, _mimir_context_inject skips the automatic Persistent Memory block so memories are only added via an explicit @memory/@mimir directive. Also fixes context_limit=0 being ignored: the old `int(... or 10)` treated 0 as falsy and fell back to 10; 0 now means inject nothing. #443: stop embedding a hardcoded @perseus version in scaffolded context.md. The init/install/quickstart/profile templates now write a version-less @perseus header, which can't go stale on upgrade (the rendered output already carries the installed version via wrap_rendered). perseus doctor treats a version-less header as ok and reframes the stale-pin warning to suggest dropping the version. Adds regression tests for each: auto_inject suppression, context_limit=0, pack deep-merge, and the doctor version-header cases. Rebuilt perseus.py. --- perseus.py | 82 +++++++++++++++++++++++++++------- src/perseus/config.py | 1 + src/perseus/doctor.py | 20 ++++++--- src/perseus/install.py | 9 ++-- src/perseus/mimir_connector.py | 11 ++++- src/perseus/quickstart.py | 2 +- src/perseus/serve.py | 39 +++++++++++++++- tests/test_doctor.py | 53 ++++++++++++++++++++++ tests/test_mimir.py | 82 ++++++++++++++++++++++++++++++++++ tests/test_quickstart.py | 9 ++-- 10 files changed, 275 insertions(+), 33 deletions(-) diff --git a/perseus.py b/perseus.py index 2a6391f4..3483f2d8 100644 --- a/perseus.py +++ b/perseus.py @@ -209,6 +209,7 @@ def __dict__(self): }, "mimir": { # Project Synapse — Mimir persistent memory (MCP binary, formerly "mneme") "enabled": True, + "auto_inject": True, # Append the Persistent Memory block to every render; set False to require an explicit @memory/@mimir directive (#442) "transport": "stdio", # "stdio" (local binary) or "sse" (remote endpoint) "command": ["mimir", "--db", "~/.mimir/data/mimir.db"], "endpoint": "", # SSE endpoint URL (when transport=sse) @@ -16169,6 +16170,10 @@ def _mimir_context_inject(cfg: dict) -> str | None: mcfg = (cfg or {}).get("mimir", {}) if isinstance(cfg, dict) else {} if not mcfg.get("enabled", True): return None + # #442: auto_inject=False suppresses the automatic block so memories are + # only included via an explicit @memory/@mimir directive in the source. + if not mcfg.get("auto_inject", True): + return None try: connector = _get_connector(cfg) @@ -16184,7 +16189,12 @@ def _mimir_context_inject(cfg: dict) -> str | None: # for automatic context injection (a category-name keyword query would # only match entities whose *body text* contains those words, which is # not what context_categories are meant to filter). - limit = int(mcfg.get("context_limit", 10) or 10) + # context_limit=0 means "inject nothing". Use an explicit None check so + # 0 is honored rather than falling back to the default via `or` (#442). + raw_limit = mcfg.get("context_limit", 10) + limit = 10 if raw_limit is None else int(raw_limit) + if limit <= 0: + return None segment = connector.recall(query="", max_results=limit) if not segment or not getattr(segment, "items", None): return None @@ -19028,17 +19038,18 @@ def _ensure_context_md(workspace: Path, cfg: dict) -> Path: if not perseus_dir.exists(): perseus_dir.mkdir(parents=True, exist_ok=True) - # Scaffold a minimal context.md + # Scaffold a minimal context.md. The @perseus header is intentionally + # version-less: pinning a version here goes stale on upgrade (#443). The + # rendered output already carries the installed version via wrap_rendered, + # and `perseus doctor` flags an explicit header that drifts from installed. content = ( - "@perseus v{version}\n" + "@perseus\n" "\n" "# Project Context\n" "\n" "@query git branch --show-current\n" "@query git log --oneline -5\n" "@waypoint\n" - ).format( - version=cfg.get("version", "1.0.0") ) context_file.write_text(content, encoding="utf-8") print(f" ✓ Created {context_file}") @@ -19830,22 +19841,28 @@ def _doctor_check_version_header(cfg: dict, workspace: Path) -> DoctorResult: return DoctorResult("version_header", "ok", "@perseus version header", "could not read context.md", "") + installed_ver = _PERSEUS_VERSION v_match = re.match(r'@perseus\s+v?([\d.]+)', first_line, re.IGNORECASE) if not v_match: + # A version-less @perseus header is the recommended form (#443): there is + # nothing to go stale, and the rendered output already carries the + # installed version. Only flag a line that isn't a @perseus header at all. + if re.match(r'@perseus\b', first_line, re.IGNORECASE): + return DoctorResult("version_header", "ok", "@perseus version header", + f"version-less @perseus header (resolves to installed v{installed_ver})", "") return DoctorResult("version_header", "warn", "@perseus version header", - f"no @perseus version found in context.md (first line: {first_line[:60]})", - "Add @perseus v" + _PERSEUS_VERSION + " as the first line of .perseus/context.md") - + f"no @perseus header found in context.md (first line: {first_line[:60]})", + "Start .perseus/context.md with a @perseus line") + header_ver = v_match.group(1) - installed_ver = _PERSEUS_VERSION - + if header_ver == installed_ver: return DoctorResult("version_header", "ok", "@perseus version header", f"v{header_ver} matches installed v{installed_ver}", "") else: return DoctorResult("version_header", "warn", "@perseus version header", - f"context.md has v{header_ver} but perseus is v{installed_ver}", - f"Update @perseus header to v{installed_ver} in .perseus/context.md") + f"context.md pins v{header_ver} but perseus is v{installed_ver}", + f"Drop the version from the @perseus header so it can't go stale, or update it to v{installed_ver}") def _doctor_check_stale_shim(cfg: dict, workspace: Path) -> DoctorResult: @@ -21221,7 +21238,7 @@ def _toggle_auto_update(value, cfg): # ──────────────────────────────── Quickstart ────────────────────────────────── QUICKSTART_CONTEXT_TEMPLATE = """\ -@perseus v{version} +@perseus @prompt This document was rendered live by Perseus. All values below are current — @@ -21583,6 +21600,7 @@ def cmd_render(args, cfg): workspace = _infer_workspace(source_path) cfg = load_config(workspace) + _merge_pack_mimir_config(cfg, workspace) # #441: per-workspace mimir overrides text = source_path.read_text(errors="replace", encoding="utf-8") fmt = getattr(args, "format", "md") @@ -22084,7 +22102,7 @@ def cmd_prefetch(args, cfg) -> int: def _profile_context_template(profile_name: str, profile: dict) -> str: label = profile["label"] - return f"""@perseus v{_PERSEUS_VERSION} + return f"""@perseus @prompt This document was rendered live by Perseus for the {label} profile. Treat the @@ -22167,6 +22185,40 @@ def _load_pack_manifest(workspace: Path, manifest: str | None = None) -> tuple[d return data, path, [] +def _deep_merge_into(base: dict, overrides: dict) -> None: + """Recursively merge `overrides` into `base` in place (override wins).""" + for key, val in overrides.items(): + if isinstance(val, dict) and isinstance(base.get(key), dict): + _deep_merge_into(base[key], val) + else: + base[key] = val + + +def _merge_pack_mimir_config(cfg: dict, workspace: Path) -> None: + """Deep-merge a pack.yaml `mimir:` block over the loaded config (#441). + + `load_config` only layers the global and workspace `config.yaml` files, so a + pack manifest's `mimir:` settings (context_limit, enabled, auto_inject, ...) + were previously ignored. Merging them here lets a workspace override Mimir + behavior per render target. Best-effort: a missing or malformed pack never + breaks a render. + """ + try: + data, _path, errors = _load_pack_manifest(workspace) + except Exception: + return + if errors or not isinstance(data, dict): + return + pack_mimir = data.get("mimir") + if not isinstance(pack_mimir, dict) or not pack_mimir: + return + base = cfg.get("mimir") + if not isinstance(base, dict): + base = {} + cfg["mimir"] = base + _deep_merge_into(base, pack_mimir) + + def _pack_rel(path: Path, workspace: Path) -> str: try: return str(path.relative_to(workspace)) @@ -22469,7 +22521,7 @@ def _context_appropriate_memory_query(workspace: Path) -> str: # ──────────────────────────────── cmd_init ──────────────────────────────────── INIT_CONTEXT_TEMPLATE = """\ -@perseus v{version} +@perseus @prompt This document was rendered live by Perseus. All values below are current — diff --git a/src/perseus/config.py b/src/perseus/config.py index 0512429e..c253e1ac 100644 --- a/src/perseus/config.py +++ b/src/perseus/config.py @@ -125,6 +125,7 @@ }, "mimir": { # Project Synapse — Mimir persistent memory (MCP binary, formerly "mneme") "enabled": True, + "auto_inject": True, # Append the Persistent Memory block to every render; set False to require an explicit @memory/@mimir directive (#442) "transport": "stdio", # "stdio" (local binary) or "sse" (remote endpoint) "command": ["mimir", "--db", "~/.mimir/data/mimir.db"], "endpoint": "", # SSE endpoint URL (when transport=sse) diff --git a/src/perseus/doctor.py b/src/perseus/doctor.py index 8dd454bf..5f0ca7ec 100644 --- a/src/perseus/doctor.py +++ b/src/perseus/doctor.py @@ -592,22 +592,28 @@ def _doctor_check_version_header(cfg: dict, workspace: Path) -> DoctorResult: return DoctorResult("version_header", "ok", "@perseus version header", "could not read context.md", "") + installed_ver = _PERSEUS_VERSION v_match = re.match(r'@perseus\s+v?([\d.]+)', first_line, re.IGNORECASE) if not v_match: + # A version-less @perseus header is the recommended form (#443): there is + # nothing to go stale, and the rendered output already carries the + # installed version. Only flag a line that isn't a @perseus header at all. + if re.match(r'@perseus\b', first_line, re.IGNORECASE): + return DoctorResult("version_header", "ok", "@perseus version header", + f"version-less @perseus header (resolves to installed v{installed_ver})", "") return DoctorResult("version_header", "warn", "@perseus version header", - f"no @perseus version found in context.md (first line: {first_line[:60]})", - "Add @perseus v" + _PERSEUS_VERSION + " as the first line of .perseus/context.md") - + f"no @perseus header found in context.md (first line: {first_line[:60]})", + "Start .perseus/context.md with a @perseus line") + header_ver = v_match.group(1) - installed_ver = _PERSEUS_VERSION - + if header_ver == installed_ver: return DoctorResult("version_header", "ok", "@perseus version header", f"v{header_ver} matches installed v{installed_ver}", "") else: return DoctorResult("version_header", "warn", "@perseus version header", - f"context.md has v{header_ver} but perseus is v{installed_ver}", - f"Update @perseus header to v{installed_ver} in .perseus/context.md") + f"context.md pins v{header_ver} but perseus is v{installed_ver}", + f"Drop the version from the @perseus header so it can't go stale, or update it to v{installed_ver}") def _doctor_check_stale_shim(cfg: dict, workspace: Path) -> DoctorResult: diff --git a/src/perseus/install.py b/src/perseus/install.py index 476e430e..ba0c0f93 100644 --- a/src/perseus/install.py +++ b/src/perseus/install.py @@ -82,17 +82,18 @@ def _ensure_context_md(workspace: Path, cfg: dict) -> Path: if not perseus_dir.exists(): perseus_dir.mkdir(parents=True, exist_ok=True) - # Scaffold a minimal context.md + # Scaffold a minimal context.md. The @perseus header is intentionally + # version-less: pinning a version here goes stale on upgrade (#443). The + # rendered output already carries the installed version via wrap_rendered, + # and `perseus doctor` flags an explicit header that drifts from installed. content = ( - "@perseus v{version}\n" + "@perseus\n" "\n" "# Project Context\n" "\n" "@query git branch --show-current\n" "@query git log --oneline -5\n" "@waypoint\n" - ).format( - version=cfg.get("version", "1.0.0") ) context_file.write_text(content, encoding="utf-8") print(f" ✓ Created {context_file}") diff --git a/src/perseus/mimir_connector.py b/src/perseus/mimir_connector.py index d6bcd9d7..4557cd1b 100755 --- a/src/perseus/mimir_connector.py +++ b/src/perseus/mimir_connector.py @@ -1399,6 +1399,10 @@ def _mimir_context_inject(cfg: dict) -> str | None: mcfg = (cfg or {}).get("mimir", {}) if isinstance(cfg, dict) else {} if not mcfg.get("enabled", True): return None + # #442: auto_inject=False suppresses the automatic block so memories are + # only included via an explicit @memory/@mimir directive in the source. + if not mcfg.get("auto_inject", True): + return None try: connector = _get_connector(cfg) @@ -1414,7 +1418,12 @@ def _mimir_context_inject(cfg: dict) -> str | None: # for automatic context injection (a category-name keyword query would # only match entities whose *body text* contains those words, which is # not what context_categories are meant to filter). - limit = int(mcfg.get("context_limit", 10) or 10) + # context_limit=0 means "inject nothing". Use an explicit None check so + # 0 is honored rather than falling back to the default via `or` (#442). + raw_limit = mcfg.get("context_limit", 10) + limit = 10 if raw_limit is None else int(raw_limit) + if limit <= 0: + return None segment = connector.recall(query="", max_results=limit) if not segment or not getattr(segment, "items", None): return None diff --git a/src/perseus/quickstart.py b/src/perseus/quickstart.py index 32b69e9b..16826075 100755 --- a/src/perseus/quickstart.py +++ b/src/perseus/quickstart.py @@ -1,7 +1,7 @@ # ──────────────────────────────── Quickstart ────────────────────────────────── QUICKSTART_CONTEXT_TEMPLATE = """\ -@perseus v{version} +@perseus @prompt This document was rendered live by Perseus. All values below are current — diff --git a/src/perseus/serve.py b/src/perseus/serve.py index e9d600ea..c47852a6 100755 --- a/src/perseus/serve.py +++ b/src/perseus/serve.py @@ -20,6 +20,7 @@ def cmd_render(args, cfg): workspace = _infer_workspace(source_path) cfg = load_config(workspace) + _merge_pack_mimir_config(cfg, workspace) # #441: per-workspace mimir overrides text = source_path.read_text(errors="replace", encoding="utf-8") fmt = getattr(args, "format", "md") @@ -521,7 +522,7 @@ def cmd_prefetch(args, cfg) -> int: def _profile_context_template(profile_name: str, profile: dict) -> str: label = profile["label"] - return f"""@perseus v{_PERSEUS_VERSION} + return f"""@perseus @prompt This document was rendered live by Perseus for the {label} profile. Treat the @@ -604,6 +605,40 @@ def _load_pack_manifest(workspace: Path, manifest: str | None = None) -> tuple[d return data, path, [] +def _deep_merge_into(base: dict, overrides: dict) -> None: + """Recursively merge `overrides` into `base` in place (override wins).""" + for key, val in overrides.items(): + if isinstance(val, dict) and isinstance(base.get(key), dict): + _deep_merge_into(base[key], val) + else: + base[key] = val + + +def _merge_pack_mimir_config(cfg: dict, workspace: Path) -> None: + """Deep-merge a pack.yaml `mimir:` block over the loaded config (#441). + + `load_config` only layers the global and workspace `config.yaml` files, so a + pack manifest's `mimir:` settings (context_limit, enabled, auto_inject, ...) + were previously ignored. Merging them here lets a workspace override Mimir + behavior per render target. Best-effort: a missing or malformed pack never + breaks a render. + """ + try: + data, _path, errors = _load_pack_manifest(workspace) + except Exception: + return + if errors or not isinstance(data, dict): + return + pack_mimir = data.get("mimir") + if not isinstance(pack_mimir, dict) or not pack_mimir: + return + base = cfg.get("mimir") + if not isinstance(base, dict): + base = {} + cfg["mimir"] = base + _deep_merge_into(base, pack_mimir) + + def _pack_rel(path: Path, workspace: Path) -> str: try: return str(path.relative_to(workspace)) @@ -906,7 +941,7 @@ def _context_appropriate_memory_query(workspace: Path) -> str: # ──────────────────────────────── cmd_init ──────────────────────────────────── INIT_CONTEXT_TEMPLATE = """\ -@perseus v{version} +@perseus @prompt This document was rendered live by Perseus. All values below are current — diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 6fc50a8d..26c15ee7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -173,3 +173,56 @@ def test_doctor_error_exits_1(tmp_path, monkeypatch): monkeypatch.setattr("builtins.print", lambda *a, **k: captured.append(" ".join(str(x) for x in a))) rc = perseus.cmd_doctor(ns, cfg()) assert rc == 1 + + +# ════════════════════════════════════════════════════════════════════════════ +# #443 — @perseus version header should not require a hardcoded version +# ════════════════════════════════════════════════════════════════════════════ + + +def _write_ctx(tmp_path, first_line: str) -> Path: + pdir = tmp_path / ".perseus" + pdir.mkdir(parents=True, exist_ok=True) + (pdir / "context.md").write_text(first_line + "\n\n# Context\n", encoding="utf-8") + return tmp_path + + +def test_version_header_bare_perseus_is_ok(tmp_path): + """A version-less @perseus header is the recommended, upgrade-safe form.""" + ws = _write_ctx(tmp_path, "@perseus") + result = perseus._doctor_check_version_header(cfg(), ws) + assert result.status == "ok" + + +def test_version_header_matching_version_is_ok(tmp_path): + ws = _write_ctx(tmp_path, f"@perseus v{perseus._PERSEUS_VERSION}") + result = perseus._doctor_check_version_header(cfg(), ws) + assert result.status == "ok" + + +def test_version_header_stale_pin_warns(tmp_path): + """An explicit version that drifts from installed is flagged.""" + ws = _write_ctx(tmp_path, "@perseus v0.0.1") + result = perseus._doctor_check_version_header(cfg(), ws) + assert result.status == "warn" + assert "0.0.1" in result.value + # Recommendation steers toward dropping the pin. + assert "drop" in result.remediation.lower() or "update" in result.remediation.lower() + + +def test_version_header_non_perseus_first_line_warns(tmp_path): + ws = _write_ctx(tmp_path, "# Just a heading") + result = perseus._doctor_check_version_header(cfg(), ws) + assert result.status == "warn" + + +def test_init_context_template_is_versionless(): + """#443: the scaffolding template must not pin a version that goes stale.""" + assert perseus.INIT_CONTEXT_TEMPLATE.startswith("@perseus\n") + assert "@perseus v" not in perseus.INIT_CONTEXT_TEMPLATE + + +def test_scaffolded_context_has_versionless_header(tmp_path): + ctx = perseus._ensure_context_md(tmp_path, cfg()) + first_line = ctx.read_text(encoding="utf-8").splitlines()[0] + assert first_line == "@perseus" diff --git a/tests/test_mimir.py b/tests/test_mimir.py index a595cf26..dde482e8 100644 --- a/tests/test_mimir.py +++ b/tests/test_mimir.py @@ -6,6 +6,7 @@ - resolve_memory() backend routing (file vs mneme) """ +import copy import json import sys from pathlib import Path @@ -360,3 +361,84 @@ def test_memory_doctor_migrate_skips_when_destination_exists(tmp_path): assert legacy_fp.exists() assert sha_fp.exists() assert sha_fp.read_text(encoding="utf-8").endswith("current\n") + + +# ════════════════════════════════════════════════════════════════════════════ +# #442 — mimir.auto_inject flag + context_limit=0 suppression +# #441 — per-workspace pack.yaml mimir override +# ════════════════════════════════════════════════════════════════════════════ + + +class TestMimirAutoInject: + def _cfg(self, **mimir): + c = cfg() + c["mimir"].update(mimir) + return c + + def test_auto_inject_false_suppresses_block(self): + """auto_inject=False returns None without ever consulting the connector.""" + c = self._cfg(enabled=True, auto_inject=False) + with patch.object(perseus, "_get_connector") as gc: + assert perseus._mimir_context_inject(c) is None + gc.assert_not_called() + + def test_context_limit_zero_suppresses_block(self): + """context_limit=0 means 'inject nothing'; recall is never called.""" + c = self._cfg(enabled=True, auto_inject=True, context_limit=0) + connector = MagicMock(available=True) + with patch.object(perseus, "_get_connector", return_value=connector): + assert perseus._mimir_context_inject(c) is None + connector.recall.assert_not_called() + + def test_auto_inject_true_injects_block(self): + """Default path still appends the Persistent Memory block when enabled.""" + c = self._cfg(enabled=True, auto_inject=True, context_limit=5) + segment = MagicMock(items=[object()], as_markdown="- a durable memory") + connector = MagicMock(available=True) + connector.recall.return_value = segment + with patch.object(perseus, "_get_connector", return_value=connector): + out = perseus._mimir_context_inject(c) + assert out is not None + assert out.startswith("## Persistent Memory (Mimir)") + assert "a durable memory" in out + + +class TestPackMimirMerge: + def _ws(self, tmp_path, pack_yaml: str) -> Path: + pdir = tmp_path / ".perseus" + pdir.mkdir(parents=True, exist_ok=True) + (pdir / "pack.yaml").write_text(pack_yaml, encoding="utf-8") + return tmp_path + + def test_pack_mimir_overrides_global(self, tmp_path): + ws = self._ws(tmp_path, "mimir:\n enabled: false\n context_limit: 0\n") + c = cfg() + c["mimir"]["enabled"] = True + c["mimir"]["context_limit"] = 10 + perseus._merge_pack_mimir_config(c, ws) + assert c["mimir"]["enabled"] is False + assert c["mimir"]["context_limit"] == 0 + # Unrelated global keys survive the merge. + assert "command" in c["mimir"] + + def test_pack_without_mimir_block_is_noop(self, tmp_path): + ws = self._ws(tmp_path, "renders:\n - source: a.md\n output: b.md\n") + c = cfg() + before = copy.deepcopy(c["mimir"]) + perseus._merge_pack_mimir_config(c, ws) + assert c["mimir"] == before + + def test_pack_deep_merges_nested_dicts(self, tmp_path): + ws = self._ws(tmp_path, "mimir:\n circuit_breaker:\n threshold: 9\n") + c = cfg() + c["mimir"].setdefault("circuit_breaker", {"threshold": 3, "cooldown": 120}) + perseus._merge_pack_mimir_config(c, ws) + assert c["mimir"]["circuit_breaker"]["threshold"] == 9 + # cooldown is preserved (deep merge, not wholesale replace). + assert c["mimir"]["circuit_breaker"]["cooldown"] == 120 + + def test_missing_pack_is_noop(self, tmp_path): + c = cfg() + before = copy.deepcopy(c["mimir"]) + perseus._merge_pack_mimir_config(c, tmp_path) # no .perseus/pack.yaml + assert c["mimir"] == before diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index f6f8125a..86ad0e5a 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -36,9 +36,11 @@ def test_creates_context_and_config(self, tmp_path, monkeypatch): assert context_file.exists() assert config_file.exists() - # Context file has the @perseus header + # Context file has a version-less @perseus header (#443: no hardcoded + # version that goes stale on upgrade). content = context_file.read_text(encoding="utf-8") - assert "@perseus v" in content + assert content.startswith("@perseus") + assert "@perseus v" not in content assert "@skills" in content assert "@services" in content @@ -67,7 +69,8 @@ def test_idempotent(self, tmp_path, monkeypatch): # Context file wasn't replaced (still has same content) context = (tmp_path / ".perseus" / "context.md").read_text(encoding="utf-8") - assert "@perseus v" in context + assert context.startswith("@perseus") + assert "@perseus v" not in context def test_creates_config_only_when_missing(self, tmp_path, monkeypatch): """quickstart doesn't overwrite existing config."""