Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 67 additions & 15 deletions perseus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 —
Expand Down
1 change: 1 addition & 0 deletions src/perseus/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 13 additions & 7 deletions src/perseus/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions src/perseus/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
11 changes: 10 additions & 1 deletion src/perseus/mimir_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/perseus/quickstart.py
Original file line number Diff line number Diff line change
@@ -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 —
Expand Down
39 changes: 37 additions & 2 deletions src/perseus/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 —
Expand Down
53 changes: 53 additions & 0 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading