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
13 changes: 9 additions & 4 deletions docs/AGENT_VIEW_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ the result and stays a pure memory layer.
```text
source: <vault>/systems/<system>/hot.md
<vault>/systems/<system>/index.md
<vault>/memory/learn/repos/<system>.md (lessons, optional)
<vault>/memory/learn/repos/<system>.md (lessons, optional, local views only)
output: <vault>/memory/learn/agent/<system>.md
```

Expand All @@ -33,7 +33,12 @@ Vault path: `MQ_OBSIDIAN_DIR` env var, else `~/mqobsidian`.
content differs.
5. **Confined output.** Only write inside `<vault>/memory/learn/agent/`. Any path
escaping that directory is refused and reported as an error.
6. **Short.** Target ≈120–180 words of body. No raw logs, no long quote blocks,
6. **Reproducible when versioned.** A system whose `hot.md` or `index.md`
frontmatter has `agent_view: versioned` has a committed view. That view is built from
`hot.md` + `index.md` only and never quotes `memory/learn/repos/`, which is
local to each vault and absent from a clean clone. Local lessons stay in
local views.
7. **Short.** Target ≈120–180 words of body. No raw logs, no long quote blocks,
no full validation text. When over budget, shed lowest-value content first
(lessons, then the priorities tail) — never the source.

Expand All @@ -43,7 +48,7 @@ Vault path: `MQ_OBSIDIAN_DIR` env var, else `~/mqobsidian`.
---
type: agent-view
system: <system>
generated: <YYYY-MM-DD>
generated: <YYYY-MM-DD> # newest `updated:` of the sources; today only if none has one
generator: mq-agent agent-views rebuild
sources: [systems/<system>/hot.md, systems/<system>/index.md, ...]
---
Expand All @@ -60,7 +65,7 @@ One compact paragraph (hot mission/status first, index state as fallback).
- hot blockers + index risks, or "none"

## Relevant lessons
- up to 3 short lesson references (from memory/learn/repos/<system>.md; omitted if absent)
- up to 3 short lesson references (from memory/learn/repos/<system>.md; omitted if absent or the view is versioned)

## Read next
- [[systems/<system>/hot]]
Expand Down
62 changes: 53 additions & 9 deletions mq_agent/tools/agent_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
index.md produces no view (the gate — no source, no card).
* Cards are short (≈120–180 words) with a stable heading structure.
* Output is confined to ``<vault>/memory/learn/agent/`` (safety guard).
* ``agent_view: versioned`` in a system's ``hot.md`` or ``index.md`` marks a committed view:
it is built from hot/index only, never from local lessons.
* ``generated`` is the newest source ``updated:`` date, so a rebuild is a
pure function of the sources.

Vault path follows the standard mq-agent contract: ``MQ_OBSIDIAN_DIR`` env var,
else ``~/mqobsidian`` (same as tools/vault_structure.py).
Expand Down Expand Up @@ -52,6 +56,30 @@ def _strip_frontmatter(text: str) -> str:
return text


def _frontmatter_value(text: str, key: str) -> str:
"""Value of a top-level ``key:`` in the leading frontmatter, or ""."""
if not text.startswith("---"):
return ""
end = text.find("\n---", 3)
if end == -1:
return ""
m = re.search(rf"^{re.escape(key)}:\s*(.*?)\s*$", text[3:end], re.MULTILINE)
return m.group(1).strip("'\"") if m else ""


_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


def _source_date(raw_sources: list[str]) -> str:
"""Newest ``updated:`` date across the sources, else today.

Dating the view from its sources keeps it a pure function of them: the
same notes rebuild to the same bytes on any day, in any checkout.
"""
dates = [d for d in (_frontmatter_value(t, "updated") for t in raw_sources) if _DATE_RE.match(d)]
return max(dates) if dates else datetime.date.today().isoformat()


def _section(text: str, header: str) -> str:
"""Body under ``## <header>`` up to the next ``## ``, trimmed.

Expand Down Expand Up @@ -151,12 +179,19 @@ def _body_word_count(state: str, priorities: list[str], blockers: list[str], les
return sum(len(c.split()) for c in chunks)


def _render(vault: Path, system: str, sources: dict[str, str]) -> str | None:
def _render(
vault: Path,
system: str,
sources: dict[str, str],
*,
generated: str,
use_local_lessons: bool,
) -> str | None:
hot, index = sources.get("hot", ""), sources.get("index", "")
state = _state(hot, index)
priorities = _priorities(hot, index)
blockers = _blockers(hot, index)
lessons = _lessons(vault, system)
lessons = _lessons(vault, system) if use_local_lessons else []
if not state and not priorities:
return None # nothing meaningful to compress

Expand All @@ -167,15 +202,14 @@ def _render(vault: Path, system: str, sources: dict[str, str]) -> str | None:
priorities.pop()

src_list = [f"systems/{system}/{k}.md" for k in sources]
if (vault / "memory" / "learn" / "repos" / f"{system}.md").exists():
if use_local_lessons and (vault / "memory" / "learn" / "repos" / f"{system}.md").exists():
src_list.append(f"memory/learn/repos/{system}.md")

today = datetime.date.today().isoformat()
lines = [
"---",
"type: agent-view",
f"system: {system}",
f"generated: {today}",
f"generated: {generated}",
"generator: mq-agent agent-views rebuild",
f"sources: [{', '.join(src_list)}]",
"---",
Expand All @@ -201,14 +235,24 @@ def _render(vault: Path, system: str, sources: dict[str, str]) -> str | None:

def _build_view(vault: Path, system: str) -> str | None:
sysdir = vault / "systems" / system
sources: dict[str, str] = {}
raw: dict[str, str] = {}
for kind in ("hot", "index"):
p = sysdir / f"{kind}.md"
if p.exists():
sources[kind] = _strip_frontmatter(p.read_text(encoding="utf-8"))
if not sources:
raw[kind] = p.read_text(encoding="utf-8")
if not raw:
return None
return _render(vault, system, sources)
# A versioned view is committed, so it may only quote committed sources.
# memory/learn/repos/ is local to each vault and absent from a clean clone.
versioned = any(_frontmatter_value(text, "agent_view") == "versioned" for text in raw.values())
sources = {kind: _strip_frontmatter(text) for kind, text in raw.items()}
return _render(
vault,
system,
sources,
generated=_source_date(list(raw.values())),
use_local_lessons=not versioned,
)


# ── orchestration ────────────────────────────────────────────────────────────
Expand Down
139 changes: 139 additions & 0 deletions tests/test_agent_views_reproducible.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""A committed agent view must be reproducible from versioned sources alone.

mqobsidian tracks `memory/learn/agent/mqobsidian.md` as a required front door,
but `memory/learn/repos/` is gitignored. A view that quoted those local
lessons, and stamped the rebuild day as `generated`, differed between the
operator's vault and a clean clone, and `agent-views check` called it stale
the day after every rebuild.

A system opts in with `agent_view: versioned` in its own `hot.md` or `index.md`. Its view is
then built from `hot.md` + `index.md` only. Every view dates itself from its
sources' `updated:` frontmatter rather than from the clock.
"""
from __future__ import annotations

import datetime as real_datetime
from pathlib import Path

from mq_agent.tools import agent_views
from mq_agent.tools.agent_views import rebuild_agent_views


def _vault(tmp_path: Path, *, versioned: bool, lessons: bool) -> Path:
vault = tmp_path / "vault"
sysd = vault / "systems" / "mqobsidian"
sysd.mkdir(parents=True)
marker = "agent_view: versioned\n" if versioned else ""
(sysd / "hot.md").write_text(
f"---\ntype: hot-cache\n{marker}updated: 2026-09-17\n---\n\n"
"## Current mission\nKeep durable memory thin.\n\n"
"## Active blockers\n- none open\n",
encoding="utf-8",
)
(sysd / "index.md").write_text(
"---\ntype: index\nupdated: 2026-09-20\n---\n\n"
"## Current priorities\n1. public-safe exports\n",
encoding="utf-8",
)
if lessons:
repos = vault / "memory" / "learn" / "repos"
repos.mkdir(parents=True)
(repos / "mqobsidian.md").write_text(
"# Learn — mqobsidian\n\n- [[memory/learn/patterns/a]] — a local-only lesson\n",
encoding="utf-8",
)
return vault


def _view(vault: Path) -> str:
return (vault / "memory" / "learn" / "agent" / "mqobsidian.md").read_text("utf-8")


def _set_today(monkeypatch, day: real_datetime.date) -> None:
class _Date(real_datetime.date):
@classmethod
def today(cls):
return day

class _Datetime:
date = _Date

monkeypatch.setattr(agent_views, "datetime", _Datetime)


def test_versioned_view_ignores_local_lessons(tmp_path: Path) -> None:
vault = _vault(tmp_path, versioned=True, lessons=True)

rebuild_agent_views(vault=vault)

view = _view(vault)
assert "Relevant lessons" not in view
assert "a local-only lesson" not in view
assert "memory/learn/repos" not in view


def test_versioned_view_is_identical_with_and_without_local_data(tmp_path: Path) -> None:
operator = _vault(tmp_path / "operator", versioned=True, lessons=True)
clean_clone = _vault(tmp_path / "clone", versioned=True, lessons=False)

rebuild_agent_views(vault=operator)
rebuild_agent_views(vault=clean_clone)

assert _view(operator) == _view(clean_clone)


def test_local_view_still_uses_local_lessons(tmp_path: Path) -> None:
vault = _vault(tmp_path, versioned=False, lessons=True)

rebuild_agent_views(vault=vault)

view = _view(vault)
assert "- a local-only lesson" in view
assert "memory/learn/repos/mqobsidian.md" in view


def test_generated_is_the_newest_source_date(tmp_path: Path) -> None:
vault = _vault(tmp_path, versioned=True, lessons=False)

rebuild_agent_views(vault=vault)

assert "\ngenerated: 2026-09-20\n" in _view(vault)


def test_view_stays_fresh_on_a_later_day(tmp_path: Path, monkeypatch) -> None:
vault = _vault(tmp_path, versioned=True, lessons=False)
_set_today(monkeypatch, real_datetime.date(2026, 10, 1))
rebuild_agent_views(vault=vault)

_set_today(monkeypatch, real_datetime.date(2026, 10, 2))
report = rebuild_agent_views(vault=vault, dry_run=True)

assert report["views_unchanged"] == ["mqobsidian"]


def test_undated_sources_fall_back_to_today(tmp_path: Path, monkeypatch) -> None:
vault = _vault(tmp_path, versioned=False, lessons=False)
for name in ("hot.md", "index.md"):
path = vault / "systems" / "mqobsidian" / name
path.write_text(
"\n".join(ln for ln in path.read_text("utf-8").splitlines() if not ln.startswith("updated:")),
encoding="utf-8",
)
_set_today(monkeypatch, real_datetime.date(2026, 10, 1))

rebuild_agent_views(vault=vault)

assert "\ngenerated: 2026-10-01\n" in _view(vault)


def test_the_marker_may_sit_in_index(tmp_path: Path) -> None:
vault = _vault(tmp_path, versioned=False, lessons=True)
index = vault / "systems" / "mqobsidian" / "index.md"
index.write_text(
index.read_text("utf-8").replace("type: index\n", "type: index\nagent_view: versioned\n", 1),
encoding="utf-8",
)

rebuild_agent_views(vault=vault)

assert "a local-only lesson" not in _view(vault)
Loading