feat(wiki): sync upstream v2026.10.4 core maintenance

Selectively port source hashing, locked manifest/memory writes, path-aware
link lint and graph analysis, and revision-checked staging from upstream
4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions.

Preserve local archive/catalog citations, curated manifest metadata, config
location, file-index and MCP behavior. Wire maintenance guidance into four
skills without migrating vault contents or changing running services.

Validation: 609 tests passed, including existing MCP/file-asset regressions
and imported upstream maintenance tests. Read-only vault status left shared
metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
This commit is contained in:
Codex
2026-10-31 12:39:48 +00:00
parent 1dd5d8d753
commit 82890606aa
35 changed files with 12632 additions and 44 deletions
+943
View File
@@ -0,0 +1,943 @@
"""Tests for the shared memory writer (`obsidian_wiki.memory`).
These cover the guarantees the prose instructions could not make: that a
parallel writer cannot drop an update, that the hot cache honours its word cap,
that reconciling the index does not eat a human's own section, and that the
profile and todo tables round-trip content containing table syntax.
"""
from __future__ import annotations
import concurrent.futures
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from obsidian_wiki import memory as mem
def _page(vault: Path, rel: str, **fields: str) -> Path:
path = vault / rel
path.parent.mkdir(parents=True, exist_ok=True)
header = "\n".join(f"{key}: {value}" for key, value in fields.items())
path.write_text(f"---\n{header}\n---\n\n# {fields.get('title', rel)}\n\nBody.\n", encoding="utf-8")
return path
@pytest.fixture()
def vault(tmp_path: Path) -> Path:
for name in ("concepts", "entities", "projects", "_meta"):
(tmp_path / name).mkdir(parents=True)
_page(tmp_path, "concepts/attention.md", title="Attention", summary="Core transformer block", tags="[ml, nlp]", updated="2026-09-01")
_page(tmp_path, "entities/karpathy.md", title="Andrej Karpathy", summary="AI researcher", tags="[person]", updated="2026-09-10")
return tmp_path
def _cli(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "obsidian_wiki.cli", "memory", *args],
capture_output=True, text=True,
)
# --------------------------------------------------------------------------
# frontmatter
# --------------------------------------------------------------------------
@pytest.mark.parametrize("indicator", [">", ">-", ">+", "|", "|-", "|+", ">2", ">-2", ">+2", "|2", "|-2", "|+2"])
def test_frontmatter_folds_block_scalars(indicator: str) -> None:
values = mem.parse_frontmatter(
f"title: {indicator}\n A sample\n page title\n"
f"summary: {indicator}\n\n First line: details\n \n second line\n\n"
"tags: [example, test]\nupdated: 2026-09-26\n"
)
assert values == {
"title": "A sample page title",
"summary": "First line: details second line",
"tags": ["example", "test"],
"updated": "2026-09-26",
}
@pytest.mark.parametrize("indent", [" ", " ", " ", "\t"])
def test_frontmatter_block_scalar_at_end_of_input(indent: str) -> None:
assert mem.parse_frontmatter(f"summary: >-\n{indent}Last field") == {"summary": "Last field"}
@pytest.mark.parametrize("ending", ["", "\n", "\n \n\n", "\ntags: [test]\n"])
def test_frontmatter_empty_block_scalar(ending: str) -> None:
values = mem.parse_frontmatter("summary: >-" + ending)
assert values["summary"] == ""
if "tags:" in ending:
assert values["tags"] == ["test"]
def test_frontmatter_preserves_inline_scalars_and_lists() -> None:
assert mem.parse_frontmatter(
'title: ">-"\nsummary: > not a block scalar\n'
'tags: [one, "two"]\nsources:\n - first.md\n - second.md\n'
'relationships:\n - target: "[[concepts/example]]"\n type: contradicts\n'
) == {
"title": ">-",
"summary": "> not a block scalar",
"tags": ["one", "two"],
"sources": ["first.md", "second.md"],
"relationships": ['target: "[[concepts/example]]"', "type: contradicts"],
}
# --------------------------------------------------------------------------
# log.md
# --------------------------------------------------------------------------
def test_log_line_matches_the_documented_format(vault: Path) -> None:
line = mem.append_log(vault, "INGEST", {"source": "papers/attention.pdf", "pages_created": 3})
assert line.startswith("- [")
entry = mem.parse_log_line(line)
assert entry is not None
assert entry.verb == "INGEST"
assert entry.fields == {"source": "papers/attention.pdf", "pages_created": "3"}
def test_log_quotes_values_containing_spaces(vault: Path) -> None:
line = mem.append_log(vault, "QUERY", {"query": "how do transformers work", "result_pages": 4})
assert 'query="how do transformers work"' in line
assert mem.parse_log_line(line).fields["query"] == "how do transformers work"
def test_log_rejects_a_lowercase_verb(vault: Path) -> None:
with pytest.raises(mem.MemoryError_) as excinfo:
mem.append_log(vault, "ingest something")
assert excinfo.value.code == "bad_verb"
def test_log_creates_the_file_and_never_rewrites_history(vault: Path) -> None:
first = mem.append_log(vault, "INIT", {"pages": 2})
mem.append_log(vault, "LINT", {"issues_found": 0})
text = (vault / "log.md").read_text(encoding="utf-8")
assert text.startswith("---\ntitle: Wiki Log\n---")
assert first in text
assert len(mem.read_log(vault)) == 2
def test_log_filters_by_verb_and_limit(vault: Path) -> None:
for index in range(5):
mem.append_log(vault, "INGEST", {"n": index})
mem.append_log(vault, "LINT", {"issues_found": 1})
assert len(mem.read_log(vault, verbs=["INGEST"])) == 5
assert len(mem.read_log(vault, limit=2)) == 2
def test_parallel_writers_do_not_drop_a_log_line(vault: Path) -> None:
"""The regression the lock exists for: wholesale rewrites lost updates."""
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda n: mem.append_log(vault, "CAPTURE", {"n": n}), range(60)))
entries = mem.read_log(vault, verbs=["CAPTURE"])
assert len(entries) == 60
assert {entry.fields["n"] for entry in entries} == {str(n) for n in range(60)}
# --------------------------------------------------------------------------
# index.md
# --------------------------------------------------------------------------
def test_index_lists_every_page_under_its_category(vault: Path) -> None:
result = mem.rebuild_index(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
assert result.total == 2
assert "## Concepts" in text and "## Entities" in text
assert "[[concepts/attention]]" in text
assert set(result.added) == {"concepts/attention", "entities/karpathy"}
def test_index_keeps_the_space_after_the_opening_paren(vault: Path) -> None:
"""The documented format rule — `(#tag)` breaks Obsidian tag parsing."""
mem.rebuild_index(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
assert "( #ml #nlp)" in text
assert "(#ml" not in text
def test_index_honours_the_markdown_link_format(vault: Path) -> None:
mem.rebuild_index(vault, link_format="markdown")
text = (vault / "index.md").read_text(encoding="utf-8")
assert "[Attention](concepts/attention.md)" in text
assert "[[" not in text
def test_index_round_trips_names_a_wikilink_cannot_carry(vault: Path) -> None:
"""`]`, `#` and `|` cannot appear in a wikilink target, so such entries render
as markdown links and the parser has to read them back as themselves (#294)."""
_page(vault, "concepts/2026-06[final].md", title="2026-06[final]")
_page(vault, "concepts/notes#1.md", title="notes#1")
mem.rebuild_index(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
assert r"[2026-06[final\]](concepts/2026-06[final].md)" in text
assert "[notes#1](concepts/notes#1.md)" in text
assert "[[concepts/notes#1]]" not in text
result = mem.rebuild_index(vault, write=False)
assert result.added == ()
assert result.removed == ()
assert result.changed is False
def test_index_drops_a_deleted_page_but_keeps_a_human_section(vault: Path) -> None:
mem.rebuild_index(vault)
index = vault / "index.md"
index.write_text(index.read_text(encoding="utf-8") + "\n## Reading Queue\n\n- A book I mean to read\n", encoding="utf-8")
(vault / "concepts/attention.md").unlink()
result = mem.rebuild_index(vault)
text = index.read_text(encoding="utf-8")
assert result.removed == ("concepts/attention",)
assert "concepts/attention" not in text
assert "A book I mean to read" in text
def test_index_drops_a_stale_section_when_its_category_empties(vault: Path) -> None:
"""Deriving categories from pages alone stranded the emptied section."""
mem.rebuild_index(vault)
(vault / "concepts/attention.md").unlink()
mem.rebuild_index(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
assert "## Concepts" not in text
assert "## Entities" in text
def test_index_drops_a_stale_uncategorized_section_and_reports_the_removal(vault: Path) -> None:
"""`_heading_for(None)` renders "Uncategorized", but that heading was never
in the generated-heading set, so a root-level page's stale entry survived
as if hand-written and the removal never showed up in `removed`."""
_page(vault, "loose-note.md", title="Loose Note")
mem.rebuild_index(vault)
(vault / "loose-note.md").unlink()
result = mem.rebuild_index(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
assert "## Uncategorized" not in text
assert "loose-note" not in text
assert result.removed == ("loose-note",)
def test_index_check_mode_reports_drift_without_writing(vault: Path) -> None:
mem.rebuild_index(vault)
before = (vault / "index.md").read_text(encoding="utf-8")
_page(vault, "concepts/rag.md", title="RAG", summary="Retrieval augmented generation")
result = mem.rebuild_index(vault, write=False)
assert result.changed is True
assert result.added == ("concepts/rag",)
assert (vault / "index.md").read_text(encoding="utf-8") == before
def test_index_ignores_staging_and_memory_files(vault: Path) -> None:
_page(vault, "_raw/2026-09-21-scratch.md", title="Scratch")
_page(vault, "_staging/concepts/pending.md", title="Pending")
_page(vault, "_meta/taxonomy.md", title="Taxonomy")
result = mem.rebuild_index(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
assert result.total == 2
for excluded in ("scratch", "pending", "taxonomy"):
assert excluded not in text
def test_index_drift_ignores_links_inside_an_entry_summary(vault: Path) -> None:
"""Only an entry's own link says what the index lists. A wikilink in the
page's summary text was counted too, so a summary pointing at a short-form
or not-yet-written page reported a phantom removal on every rebuild."""
_page(vault, "concepts/rag.md", title="RAG", summary='"See [[overview|the overview]] first"')
mem.rebuild_index(vault)
result = mem.rebuild_index(vault)
assert result.removed == ()
assert result.added == ()
assert result.changed is False
def test_index_is_idempotent(vault: Path) -> None:
mem.rebuild_index(vault)
first = (vault / "index.md").read_text(encoding="utf-8")
second = mem.rebuild_index(vault)
assert second.changed is False
assert second.text == first
def _sub_wiki(vault: Path) -> None:
"""projects/alpha is a sub-wiki: its own index.md plus pages under it."""
hub = vault / "projects/alpha/index.md"
hub.parent.mkdir(parents=True, exist_ok=True)
hub.write_text("# Alpha Project\n\nEverything about alpha.\n", encoding="utf-8")
_page(vault, "projects/alpha/concepts/plan.md", title="Plan", summary="The plan")
_page(vault, "projects/alpha/entities/bob.md", title="Bob", summary="A person")
_page(vault, "projects/alpha/deep/index.md", title="Deep", summary="A nested hub")
def _enable_hubs(vault: Path) -> None:
(vault / "index.md").write_text(
f"---\ntitle: Wiki Index\n{mem.FOLDER_HUBS_KEY}: true\n"
f"generated_by: {mem.GENERATED_MARKER} index\n---\n\n# Wiki Index\n",
encoding="utf-8",
)
def test_index_lists_sub_wiki_pages_individually_by_default(vault: Path) -> None:
_sub_wiki(vault)
text = mem.rebuild_index(vault).text
assert "[[projects/alpha/concepts/plan]]" in text
assert "(3 pages)" not in text
def test_folder_hubs_link_a_sub_wiki_once_instead_of_inlining_it(vault: Path) -> None:
"""A vault of sub-wikis would otherwise inline every page of every one of
them into the root index, which every skill reads at startup."""
_sub_wiki(vault)
_enable_hubs(vault)
result = mem.rebuild_index(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
assert "- [[projects/alpha/index|Alpha Project]] — Everything about alpha. (3 pages)" in text
for inlined in ("projects/alpha/concepts/plan", "projects/alpha/entities/bob", "projects/alpha/deep"):
assert inlined not in text # including the nested hub: the outermost wins
assert "[[concepts/attention]]" in text # pages outside a hub are unaffected
assert result.total == 6
assert "projects/alpha/index" in result.added
assert f"{mem.FOLDER_HUBS_KEY}: true" in text # the switch survives the rebuild
assert mem.rebuild_index(vault).changed is False
def test_folder_hub_title_falls_back_to_the_folder_name(vault: Path) -> None:
hub = vault / "projects/beta-tool/index.md"
hub.parent.mkdir(parents=True)
hub.write_text("Just a list of links.\n", encoding="utf-8")
_enable_hubs(vault)
assert "[[projects/beta-tool/index|Beta Tool]]" in mem.rebuild_index(vault).text
# --------------------------------------------------------------------------
# hot.md
# --------------------------------------------------------------------------
def test_hot_has_the_four_documented_sections(vault: Path) -> None:
mem.rebuild_hot(vault)
text = (vault / "hot.md").read_text(encoding="utf-8")
for heading in ("Recent Activity", "Active Threads", "Key Takeaways", "Flagged Contradictions"):
assert f"## {heading}" in text
assert "generated_by: obsidian-wiki memory hot" in text
def test_hot_preserves_key_takeaways_across_a_rebuild(vault: Path) -> None:
"""The one LLM-owned slot: mechanical rebuilds must not erase it."""
mem.rebuild_hot(vault, takeaways="Retrieval is lexical only; precision is the weak metric.")
mem.append_log(vault, "INGEST", {"source": "x"})
mem.rebuild_hot(vault)
assert "Retrieval is lexical only" in (vault / "hot.md").read_text(encoding="utf-8")
def test_hot_replaces_takeaways_when_given_new_text(vault: Path) -> None:
mem.rebuild_hot(vault, takeaways="Old conclusion.")
mem.rebuild_hot(vault, takeaways="New conclusion.")
text = (vault / "hot.md").read_text(encoding="utf-8")
assert "New conclusion." in text and "Old conclusion." not in text
def test_hot_enforces_the_word_cap(vault: Path) -> None:
for index in range(50):
mem.append_log(vault, "INGEST", {"source": f"file-{index}.pdf", "note": "a fairly long note value"})
for index in range(20):
mem.add_todo(vault, f"Thread number {index} with a reasonably long description")
result = mem.rebuild_hot(vault, takeaways=" ".join(["takeaway"] * 400), max_words=200, activity_limit=40)
assert result.trimmed is True
assert result.words <= 200
assert mem.content_words((vault / "hot.md").read_text(encoding="utf-8")) <= 200
def test_hot_word_cap_excludes_frontmatter_and_boilerplate(vault: Path) -> None:
"""Counting the header against the cap put the floor within a few words of it."""
text = (vault / "hot.md")
result = mem.rebuild_hot(vault)
raw = text.read_text(encoding="utf-8")
assert result.words == mem.content_words(raw)
assert result.words < mem.word_count(raw)
def test_hot_respects_the_env_override(vault: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OBSIDIAN_HOT_MAX_WORDS", "80")
assert mem.hot_max_words() == 80
for index in range(30):
mem.append_log(vault, "INGEST", {"source": f"file-{index}.pdf"})
assert mem.rebuild_hot(vault, activity_limit=30).words <= 80
def test_hot_falls_back_when_the_env_override_is_junk(vault: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OBSIDIAN_HOT_MAX_WORDS", "not-a-number")
assert mem.hot_max_words() == mem.DEFAULT_HOT_MAX_WORDS
def test_hot_lists_open_threads_and_omits_closed_ones(vault: Path) -> None:
open_todo = mem.add_todo(vault, "Wire the systemd timer")
closed = mem.add_todo(vault, "Already finished")
mem.set_todo_status(vault, closed.id, "done")
mem.rebuild_hot(vault)
text = (vault / "hot.md").read_text(encoding="utf-8")
assert open_todo.text in text
assert "Already finished" not in text
def test_hot_flags_disputed_pages_and_contradiction_edges(vault: Path) -> None:
_page(vault, "concepts/scaling.md", title="Scaling", lifecycle="disputed", updated="2026-09-11")
(vault / "concepts/rebuttal.md").write_text(
"---\ntitle: Rebuttal\nupdated: 2026-09-12\nrelationships:\n"
' - target: "[[concepts/scaling]]"\n type: contradicts\n---\n\nBody.\n',
encoding="utf-8",
)
mem.rebuild_hot(vault)
text = (vault / "hot.md").read_text(encoding="utf-8")
assert "marked disputed" in text
assert "contradicts [[concepts/scaling]]" in text
# --------------------------------------------------------------------------
# profile
# --------------------------------------------------------------------------
def test_profile_round_trips_a_fact(vault: Path) -> None:
mem.set_fact(vault, "stack", "Python, FastAPI", confidence=0.85, source="session:abc")
facts = mem.load_profile(vault)
assert len(facts) == 1
assert facts[0].key == "stack"
assert facts[0].value == "Python, FastAPI"
assert facts[0].confidence == pytest.approx(0.85)
assert facts[0].source == "session:abc"
def test_profile_round_trips_a_value_containing_a_pipe(vault: Path) -> None:
"""A raw `|` would tear the markdown row into extra cells."""
mem.set_fact(vault, "shell", "bash | zsh")
assert mem.load_profile(vault)[0].value == "bash | zsh"
def test_profile_set_replaces_rather_than_duplicates(vault: Path) -> None:
mem.set_fact(vault, "editor", "vim", confidence=0.5)
mem.set_fact(vault, "EDITOR", "neovim", confidence=0.9)
facts = mem.load_profile(vault)
assert len(facts) == 1
assert facts[0].value == "neovim"
def test_profile_rejects_confidence_outside_the_unit_interval(vault: Path) -> None:
for bad in (-0.1, 1.5):
with pytest.raises(mem.MemoryError_) as excinfo:
mem.set_fact(vault, "k", "v", confidence=bad)
assert excinfo.value.code == "bad_confidence"
def test_profile_forget_reports_whether_it_removed_anything(vault: Path) -> None:
mem.set_fact(vault, "timezone", "Asia/Kolkata")
assert mem.forget_fact(vault, "timezone") is True
assert mem.forget_fact(vault, "timezone") is False
assert mem.load_profile(vault) == []
def test_profile_survives_a_hand_edit_in_obsidian(vault: Path) -> None:
mem.set_fact(vault, "stack", "Python")
path = vault / mem.PROFILE_REL
path.write_text(
path.read_text(encoding="utf-8") + "| timezone | Asia/Kolkata | 0.90 | manual | 2026-09-21 |\n",
encoding="utf-8",
)
assert {fact.key for fact in mem.load_profile(vault)} == {"stack", "timezone"}
# --------------------------------------------------------------------------
# todos
# --------------------------------------------------------------------------
def test_todo_add_assigns_sequential_ids(vault: Path) -> None:
assert mem.add_todo(vault, "First").id == "t1"
assert mem.add_todo(vault, "Second").id == "t2"
def test_todo_add_is_idempotent_for_an_open_thread(vault: Path) -> None:
first = mem.add_todo(vault, "Wire the systemd timer")
again = mem.add_todo(vault, "wire the SYSTEMD timer")
assert first.id == again.id
assert len(mem.load_todos(vault)) == 1
def test_todo_status_transitions_and_prune(vault: Path) -> None:
keep = mem.add_todo(vault, "Still open")
done = mem.add_todo(vault, "Finished")
dropped = mem.add_todo(vault, "Abandoned")
mem.set_todo_status(vault, done.id, "done")
mem.set_todo_status(vault, dropped.id, "dropped")
assert mem.prune_todos(vault) == 2
remaining = mem.load_todos(vault)
assert [todo.id for todo in remaining] == [keep.id]
def test_todo_rejects_an_unknown_id_and_status(vault: Path) -> None:
todo = mem.add_todo(vault, "Something")
with pytest.raises(mem.MemoryError_) as unknown:
mem.set_todo_status(vault, "t99", "done")
assert unknown.value.code == "no_such_todo"
with pytest.raises(mem.MemoryError_) as bad:
mem.set_todo_status(vault, todo.id, "maybe")
assert bad.value.code == "bad_status"
def test_todo_staleness_is_reported_not_enforced(vault: Path) -> None:
mem.add_todo(vault, "Old thread")
path = vault / mem.TODOS_REL
old = (datetime.now(timezone.utc) - timedelta(days=45)).date().isoformat()
path.write_text(path.read_text(encoding="utf-8").replace(mem.today(), old), encoding="utf-8")
todo = mem.load_todos(vault)[0]
assert todo.is_stale() is True
assert todo.status == "open" # still open; staleness is a signal, not a close
def test_closed_todos_are_never_stale(vault: Path) -> None:
todo = mem.add_todo(vault, "Done long ago")
mem.set_todo_status(vault, todo.id, "done")
assert mem.load_todos(vault)[0].is_stale(days=0) is False
# --------------------------------------------------------------------------
# recap + status
# --------------------------------------------------------------------------
def test_recap_gathers_profile_threads_and_activity(vault: Path) -> None:
mem.set_fact(vault, "stack", "Python")
mem.add_todo(vault, "Persist the retrieval index")
mem.append_log(vault, "INGEST", {"source": "papers/rag.pdf"})
recap = mem.build_recap(vault)
assert "**stack**: Python" in recap
assert "Persist the retrieval index" in recap
assert "INGEST" in recap
def test_recap_drops_a_time_bound_fact_once_it_is_over(vault: Path) -> None:
""""Exams this weekend" must not still be injected next month."""
mem.set_fact(vault, "exams", "this weekend", until="2000-01-02")
mem.set_fact(vault, "trip", "in Lisbon", until="2999-01-01")
facts = {f.key: f for f in mem.load_profile(vault)}
assert facts["exams"].until == "2000-01-02" and facts["exams"].is_expired()
recap = mem.build_recap(vault)
assert "exams" not in recap
assert "**trip**: in Lisbon" in recap
with pytest.raises(mem.MemoryError_) as excinfo:
mem.set_fact(vault, "k", "v", until="next friday")
assert excinfo.value.code == "bad_until"
def test_recap_is_honest_about_an_empty_vault(vault: Path) -> None:
assert "No vault memory recorded yet" in mem.build_recap(vault)
def test_recap_suggests_a_first_step_until_the_vault_is_used(vault: Path) -> None:
mem.append_log(vault, "INIT", {"vault_path": str(vault)})
recap = mem.build_recap(vault)
assert "## Getting started" in recap and "/wiki-capture" in recap
mem.append_log(vault, "INGEST", {"source": "notes.md"})
assert "Getting started" not in mem.build_recap(vault)
assert "Getting started" not in mem.build_recap(vault, project="nomatch")
def test_recap_filters_low_confidence_facts(vault: Path) -> None:
mem.set_fact(vault, "guess", "maybe Postgres", confidence=0.2)
mem.set_fact(vault, "certain", "Python", confidence=0.95)
recap = mem.build_recap(vault, min_confidence=0.5)
assert "certain" in recap and "guess" not in recap
def test_recap_respects_its_word_budget(vault: Path) -> None:
for index in range(60):
mem.set_fact(vault, f"fact-{index}", f"a reasonably long value number {index}")
assert mem.word_count(mem.build_recap(vault, max_words=100)) <= 101 # +1 for the ellipsis
def test_status_reports_index_drift_and_hot_budget(vault: Path) -> None:
status = mem.memory_status(vault)
assert status["pages"] == 2
assert status["index_drift"]["stale"] is True
assert status["hot"]["generated"] is False
mem.rebuild_index(vault)
mem.rebuild_hot(vault)
status = mem.memory_status(vault)
assert status["index_drift"]["stale"] is False
assert status["hot"]["generated"] is True
assert status["hot"]["over_budget"] is False
def test_operations_refuse_a_missing_vault(tmp_path: Path) -> None:
missing = tmp_path / "nope"
for call in (
lambda: mem.append_log(missing, "INGEST"),
lambda: mem.rebuild_index(missing),
lambda: mem.rebuild_hot(missing),
lambda: mem.build_recap(missing),
):
with pytest.raises(mem.MemoryError_) as excinfo:
call()
assert excinfo.value.code == "vault_not_found"
# --------------------------------------------------------------------------
# CLI surface
# --------------------------------------------------------------------------
def test_cli_status_and_sync_round_trip(vault: Path) -> None:
stale = _cli("status", "--vault", str(vault), "--json")
assert stale.returncode == 0
assert '"stale": true' in stale.stdout
synced = _cli("sync", "--verb", "INGEST", "--field", "source=papers/rag.pdf", "--vault", str(vault))
assert synced.returncode == 0
assert "INGEST" in synced.stdout
after = _cli("status", "--vault", str(vault), "--json")
assert '"stale": false' in after.stdout
assert (vault / "index.md").is_file()
assert (vault / "hot.md").is_file()
def test_cli_index_check_exits_two_on_drift(vault: Path) -> None:
assert _cli("index", "--vault", str(vault)).returncode == 0
assert _cli("index", "--check", "--vault", str(vault)).returncode == 0
_page(vault, "concepts/rag.md", title="RAG", summary="Retrieval augmented generation")
drifted = _cli("index", "--check", "--vault", str(vault))
assert drifted.returncode == 2
assert "concepts/rag" in drifted.stdout
@pytest.mark.parametrize("command", ["index", "sync"])
@pytest.mark.parametrize("link_format", ["markdown", "wikilink"])
def test_cli_indexes_block_scalar_titles_and_summaries(
vault: Path, command: str, link_format: str, monkeypatch: pytest.MonkeyPatch,
) -> None:
config_dir = vault / ".obsidian-wiki"
config_dir.mkdir(parents=True)
(config_dir / "config").write_text(f"OBSIDIAN_LINK_FORMAT={link_format}\n", encoding="utf-8")
monkeypatch.setenv("HOME", str(vault))
monkeypatch.setenv("XDG_CONFIG_HOME", str(config_dir.parent))
_page(
vault, "concepts/block-scalars.md",
title=">-\n A sample\n page title",
summary="|-\n A summary\n spanning two lines.",
tags="[example]",
)
result = _cli(command, "--vault", str(vault))
assert result.returncode == 0, result.stderr
text = (vault / "index.md").read_text(encoding="utf-8")
link = "[A sample page title](concepts/block-scalars.md)" if link_format == "markdown" else "[[concepts/block-scalars]]"
assert f"- {link} — A summary spanning two lines. ( #example)" in text
assert ">-" not in text and "|-" not in text
assert _cli("index", "--check", "--vault", str(vault)).returncode == 0
def test_cli_rejects_a_malformed_field(vault: Path) -> None:
result = _cli("log", "INGEST", "--field", "no-equals-sign", "--vault", str(vault))
assert result.returncode == 1
assert "key=value" in result.stderr
def test_cli_profile_and_todo_lifecycle(vault: Path) -> None:
assert _cli("profile", "set", "stack", "Python, FastAPI", "--confidence", "0.8", "--vault", str(vault)).returncode == 0
listed = _cli("profile", "list", "--vault", str(vault))
assert "stack: Python, FastAPI" in listed.stdout
added = _cli("todo", "add", "Persist the retrieval index", "--vault", str(vault))
assert added.returncode == 0
assert _cli("todo", "done", "t1", "--vault", str(vault)).returncode == 0
assert "no todos recorded" in _cli("todo", "list", "--vault", str(vault)).stdout
assert "done" in _cli("todo", "list", "--all", "--vault", str(vault)).stdout
def test_cli_forget_exits_one_when_nothing_was_removed(vault: Path) -> None:
assert _cli("profile", "forget", "absent", "--vault", str(vault)).returncode == 1
def test_cli_takeaways_can_be_piped_in(vault: Path) -> None:
result = subprocess.run(
[sys.executable, "-m", "obsidian_wiki.cli", "memory", "hot", "--takeaways", "-", "--vault", str(vault)],
input="Retrieval is lexical only.", capture_output=True, text=True,
)
assert result.returncode == 0
assert "Retrieval is lexical only." in (vault / "hot.md").read_text(encoding="utf-8")
def test_cli_recap_prints_the_injectable_block(vault: Path) -> None:
_cli("profile", "set", "stack", "Python", "--vault", str(vault))
recap = _cli("recap", "--vault", str(vault))
assert recap.returncode == 0
assert recap.stdout.startswith("# Vault memory")
# --------------------------------------------------------------------------
# migration — adopting a vault that predates the writer
# --------------------------------------------------------------------------
def _legacy(vault: Path) -> None:
"""A hand-curated vault: custom index sections, narrative hot cache."""
(vault / "index.md").write_text(
"# Project Brain\n\nCentral index.\n\n"
"## Project Index\n\n| Project | Repo |\n|---|---|\n| [[alpha]] | r |\n\n"
"## Quick Reference\n\n```bash\ngh repo list\n```\n",
encoding="utf-8",
)
(vault / "hot.md").write_text(
"---\nupdated: 2026-06-04T00:00:00Z\n---\n\n# Hot\n\n"
"## Recent Activity (last 3 ops)\n\n1. **2026-06-04** — researched karpathy\n\n"
"## Active Threads\n\n"
"- **karpathy-llm-resources** — Full coverage of the educational ecosystem.\n"
"- **tractorex** — domain-configurable document intelligence.\n\n"
"## Key Takeaways\n\n- Build the mechanism before the abstraction.\n",
encoding="utf-8",
)
def test_the_guard_refuses_to_clobber_a_hand_curated_vault(vault: Path) -> None:
_legacy(vault)
for call in (lambda: mem.rebuild_index(vault), lambda: mem.rebuild_hot(vault)):
with pytest.raises(mem.MemoryError_) as excinfo:
call()
assert excinfo.value.code == "unmigrated"
assert "Project Brain" in (vault / "index.md").read_text(encoding="utf-8")
def test_dry_runs_are_allowed_on_an_unmigrated_vault(vault: Path) -> None:
_legacy(vault)
assert mem.rebuild_index(vault, write=False, force=True).total == 2
assert mem.migration_status(vault)["migrated"] is False
def test_migration_preview_reports_what_would_change(vault: Path) -> None:
_legacy(vault)
status = mem.migration_status(vault)
assert status["index"]["sections_preserved"] == ["Project Index", "Quick Reference"]
assert status["hot"]["takeaways_carried"] is True
assert set(status["hot"]["sections_regenerated"]) == {"Active Threads", "Recent Activity"}
assert len(status["hot"]["threads_to_seed"]) == 2
def test_migration_backs_up_before_writing(vault: Path) -> None:
_legacy(vault)
original = (vault / "index.md").read_text(encoding="utf-8")
result = mem.migrate(vault)
assert len(result["backup"]) == 2
restored = Path(result["backup_dir"]) / "index.md"
assert restored.read_text(encoding="utf-8") == original
def test_migration_preserves_custom_sections_above_the_catalog(vault: Path) -> None:
_legacy(vault)
mem.migrate(vault)
text = (vault / "index.md").read_text(encoding="utf-8")
headings = re.findall(r"^##\s+(.+)$", text, re.MULTILINE)
assert headings[:2] == ["Project Index", "Quick Reference"]
assert "Concepts" in headings # catalog appended after
assert "gh repo list" in text # the author's code block survives
assert "# Project Brain" in text # and their preamble
def test_migration_converts_narrative_threads_into_todos(vault: Path) -> None:
"""The one thing the generator cannot reconstruct, so it is carried over."""
_legacy(vault)
result = mem.migrate(vault)
assert len(result["threads_seeded"]) == 2
texts = " ".join(todo.text for todo in mem.load_todos(vault))
assert "karpathy" in texts and "tractorex" in texts
assert "karpathy" in (vault / "hot.md").read_text(encoding="utf-8")
def test_migration_never_overwrites_existing_todos(vault: Path) -> None:
_legacy(vault)
mem.add_todo(vault, "A thread I already had")
result = mem.migrate(vault)
assert result["threads_seeded"] == []
assert [todo.text for todo in mem.load_todos(vault)] == ["A thread I already had"]
def test_migration_marks_a_custom_preamble_so_it_is_not_asked_again(vault: Path) -> None:
"""The marker has to land inside the author's own preamble, or the guard
refuses forever and sync silently skips index and hot on every run."""
_legacy(vault)
mem.migrate(vault)
assert mem.is_generated(vault / "index.md")
mem.rebuild_index(vault) # must not raise now
assert mem.rebuild_index(vault, write=False).changed is False
def test_migration_is_idempotent(vault: Path) -> None:
_legacy(vault)
mem.migrate(vault)
first = (vault / "index.md").read_text(encoding="utf-8")
second = mem.migrate(vault)
assert second["was_migrated"] is True
assert (vault / "index.md").read_text(encoding="utf-8") == first
# --------------------------------------------------------------------------
# hot cache: log lines must not blow the budget
# --------------------------------------------------------------------------
def test_a_huge_log_line_is_summarised_not_pasted(vault: Path) -> None:
"""A research ingest records every page it made in one field."""
mem.append_log(vault, "WIKI_RESEARCH", {
"source": "karpathy",
"pages_created": 18,
"note": " ".join(f"references/page-{n}" for n in range(40)),
"extra": "dropped",
})
mem.rebuild_hot(vault)
activity = mem._section_body((vault / "hot.md").read_text(encoding="utf-8"), "Recent Activity")
assert len(activity.split()) < 25
assert "page-39" not in activity
assert "WIKI_RESEARCH" in activity
def test_summarised_entries_keep_the_date_and_verb(vault: Path) -> None:
entry = mem.parse_log_line(mem.format_log_line("INGEST", {"source": "a.pdf"}, timestamp="2026-09-21T10:00:00Z"))
assert mem.summarize_log_entry(entry) == "- [2026-09-21] INGEST source=a.pdf"
# --------------------------------------------------------------------------
# recap scoping
# --------------------------------------------------------------------------
def test_recap_scopes_threads_to_a_project(vault: Path) -> None:
mem.add_todo(vault, "Ship the tractorex pipeline")
mem.add_todo(vault, "Read the karpathy series")
scoped = mem.build_recap(vault, project="tractorex")
assert "tractorex" in scoped
assert "karpathy" not in scoped
def test_recap_matches_a_project_named_in_the_origin(vault: Path) -> None:
mem.add_todo(vault, "Something unrelated in wording", origin="projects/tractorex.md")
assert "Something unrelated" in mem.build_recap(vault, project="tractorex")
def test_recap_falls_back_rather_than_claiming_no_history(vault: Path) -> None:
"""An unmatched filter must not look like an empty vault."""
mem.add_todo(vault, "Only thread")
mem.append_log(vault, "INGEST", {"source": "a.pdf"})
recap = mem.build_recap(vault, project="nothing-matches-this")
assert "Only thread" in recap
assert "INGEST" in recap
# --------------------------------------------------------------------------
# adoption record — survives a legacy skill rewriting hot.md
# --------------------------------------------------------------------------
def test_a_legacy_rewrite_of_hot_md_does_not_unadopt_the_vault(vault: Path) -> None:
"""Sixteen skills still rewrite hot.md by hand. If that dropped the
per-file marker and nothing else recorded adoption, the next sync would
refuse as though the vault had never been migrated."""
_legacy(vault)
mem.migrate(vault)
(vault / "hot.md").write_text(
"---\nupdated: now\n---\n# Hot\n\n## Key Takeaways\n\nWritten by an old skill.\n",
encoding="utf-8",
)
assert not mem.is_generated(vault / "hot.md")
assert mem.is_adopted(vault)
result = mem.rebuild_hot(vault) # must not raise
assert result.words > 0
assert "Written by an old skill" in (vault / "hot.md").read_text(encoding="utf-8")
def test_deleting_the_adoption_record_re_arms_the_guard(vault: Path) -> None:
"""The documented way back: restore a curated file from the backup and
delete the record. Both are needed — a regenerated file is legitimately
ours and carries its own marker."""
_legacy(vault)
result = mem.migrate(vault)
backup = Path(result["backup_dir"])
(vault / "index.md").write_text((backup / "index.md").read_text(encoding="utf-8"), encoding="utf-8")
(vault / mem.ADOPTED_REL).unlink()
with pytest.raises(mem.MemoryError_) as excinfo:
mem.rebuild_index(vault)
assert excinfo.value.code == "unmigrated"
def test_migrate_records_adoption(vault: Path) -> None:
_legacy(vault)
assert not mem.is_adopted(vault)
mem.migrate(vault)
assert mem.is_adopted(vault)
assert mem.memory_status(vault)["migrated"] is True
# --------------------------------------------------------------------------
# the short form — a verb and bare key=value pairs
# --------------------------------------------------------------------------
def test_sync_takes_a_positional_verb_and_fields(vault: Path) -> None:
"""The flag form ran to seven lines in the ingest skills."""
result = _cli("sync", "INGEST", "source=papers/attention.pdf", "pages_created=3",
"--vault", str(vault))
assert result.returncode == 0, result.stderr
entry = mem.read_log(vault, verbs=["INGEST"])[-1]
assert entry.fields == {"source": "papers/attention.pdf", "pages_created": "3"}
def test_log_takes_a_positional_verb_and_fields(vault: Path) -> None:
assert _cli("log", "LINT", "issues_found=2", "orphans=1", "--vault", str(vault)).returncode == 0
assert mem.read_log(vault, verbs=["LINT"])[-1].fields == {"issues_found": "2", "orphans": "1"}
def test_the_old_flag_form_still_works(vault: Path) -> None:
"""Back-compat: --verb/--field are documented as the older spelling."""
assert _cli("log", "--verb", "DEDUP", "--field", "merged=2", "--vault", str(vault)).returncode == 0
assert mem.read_log(vault, verbs=["DEDUP"])[-1].fields == {"merged": "2"}
def test_both_forms_can_be_mixed(vault: Path) -> None:
_cli("log", "QUERY", "query=how do transformers work", "--field", "result_pages=4",
"--vault", str(vault))
entry = mem.read_log(vault, verbs=["QUERY"])[-1]
assert entry.fields == {"query": "how do transformers work", "result_pages": "4"}
def test_a_value_may_contain_an_equals_sign(vault: Path) -> None:
_cli("log", "INGEST", "source=https://x.test/a?b=c", "--vault", str(vault))
assert mem.read_log(vault, verbs=["INGEST"])[-1].fields["source"] == "https://x.test/a?b=c"
def test_sync_without_a_verb_still_reconciles(vault: Path) -> None:
"""wiki-stage-commit's case: the log line was already written by the CLI."""
result = _cli("sync", "--takeaways", "Committed 3 staged pages.", "--vault", str(vault))
assert result.returncode == 0
assert mem.read_log(vault) == []
assert "Committed 3 staged pages." in (vault / "hot.md").read_text(encoding="utf-8")
def test_a_bare_word_after_the_verb_is_a_clear_error(vault: Path) -> None:
result = _cli("sync", "INGEST", "oops", "--vault", str(vault))
assert result.returncode == 1
assert "expected key=value" in result.stderr
assert "INGEST" in result.stderr # says which verb it already has