feat(distill): close the MemPalace loop — conversations → wiki pages

Add wiki-distill.py as Phase 1a of the maintenance pipeline. This is
the 8th extension memex adds to Karpathy's pattern and the one that
makes the MemPalace integration a real ingest pipeline instead of
just a searchable archive beside the wiki.

## The gap distill closes

The mining layer was extracting Claude Code sessions, classifying
bullets into halls (fact/discovery/preference/advice/event/tooling),
and tagging topics. The URL harvester scanned conversations for cited
links. Hygiene refreshed last_verified on wiki pages referenced in
related: fields. But none of those steps compiled the knowledge
*inside* the conversations themselves into wiki pages. Decisions,
root causes, and patterns stayed in the summaries forever — findable
via qmd but never synthesized into canonical pages.

## What distill does

Narrow today-filter with historical rollup:

  1. Find all summarized conversations dated TODAY
  2. Extract their topics: — this is the "topics of today" set
  3. For each topic in that set, pull ALL summarized conversations
     across history that share that topic (full historical context)
  4. Extract hall_facts + hall_discoveries + hall_advice bullets
     (the high-signal hall types — skips event/preference/tooling)
  5. Send topic group + wiki index.md to claude -p
  6. Model emits JSON actions[]: new_page / update_page / skip
  7. Write each action to staging/<type>/ with distill provenance
     frontmatter (staged_by: wiki-distill, distill_topic,
     distill_source_conversations, compilation_notes)

First-run bootstrap: uses 7-day lookback instead of today-only so
the state file gets seeded reasonably. After that, daily runs stay
narrow.

Self-triggering: dormant topics that resurface in a new conversation
automatically pull in all historical conversations on that topic via
the rollup. Old knowledge gets distilled when it becomes relevant
again without manual intervention.

## Orchestration — distill BEFORE harvest

wiki-maintain.sh now has Phase 1a (distill) + Phase 1b (harvest):

  1a. wiki-distill.py    — conversations → staging (PRIORITY)
  1b. wiki-harvest.py    — URLs → raw/harvested → staging (supplement)
  2.  wiki-hygiene.py    — decay, archive, repair, checks
  3.  qmd reindex

Conversation content drives the page shape; URL harvesting fills
gaps for external references conversations don't cover. New flags:
--distill-only, --no-distill, --distill-first-run.

## Verified on real wiki

Tested end-to-end on the production wiki with 611 summarized
conversations across 14 wings. First-run dry-run found 116 topic
groups worth distilling (+ 3 too-thin). Tested single-topic compile
with --topic zoho-api: the LLM rolled up 2 conversations (34
bullets), synthesized a proper pattern page with "What / Why /
Known Limitations" structure, linked it to existing wiki pages,
and landed it in staging with full distill provenance. LLM
correctly rejected claude-code-statusline (already well-covered
by an existing live page) — so the "skip" path works.

## Code additions

- scripts/wiki-distill.py (new, ~530 lines)
- scripts/wiki_lib.py: HIGH_SIGNAL_HALLS + parse_conversation_halls
  + high_signal_halls + _flatten_bullet helpers
- scripts/wiki-maintain.sh: Phase 1a distill, new flags
- tests/test_wiki_distill.py (21 new tests — hall parsing, rollup,
  state management, CLI smoke tests)
- tests/test_shell_scripts.py: updated phase-name assertion for
  the Phase 1a/1b split

## Docs additions

- README.md: 8th row in extensions table, updated compounding-loop
  diagram, new wiki-distill.py reference in architecture overview
- docs/DESIGN-RATIONALE.md: new section 8 "Closing the MemPalace
  loop" with full mempalace taxonomy mapping
- docs/ARCHITECTURE.md: wiki-distill.py section, updated phase
  order, updated state file table, updated dep graph
- docs/SETUP.md: updated cron comment, first-run distill guidance,
  verify section test count
- .gitignore: note distill-state.json is committed (sync across
  machines), not gitignored
- docs/artifacts/signal-and-noise.html: new "Distill ⬣" top-level
  tab with flow diagram, hall filter table, narrow-today/wide-
  history explanation, staging provenance example

## Tests

192 tests total (+21 new, +1 regression fix), all green in ~1.5s.
This commit is contained in:
Eric Turner
2026-04-12 22:34:33 -06:00
parent 4c6b7609a1
commit 997aa837de
11 changed files with 1732 additions and 66 deletions

446
tests/test_wiki_distill.py Normal file
View File

@@ -0,0 +1,446 @@
"""Unit + integration tests for scripts/wiki-distill.py.
Mocks claude -p; no real LLM calls during tests.
"""
from __future__ import annotations
import json
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import pytest
from conftest import make_conversation
# ---------------------------------------------------------------------------
# wiki_lib hall parsing helpers
# ---------------------------------------------------------------------------
class TestParseConversationHalls:
def _make_conv_with_halls(self, tmp_wiki: Path, body: str) -> Path:
return make_conversation(
tmp_wiki,
"test",
"2026-04-12-halls.md",
status="summarized",
body=body,
)
def test_extracts_fact_bullets(self, wiki_lib: Any, tmp_wiki: Path) -> None:
body = (
"## Summary\n\nsome summary text.\n\n"
"## Decisions (hall: fact)\n\n"
"- First decision made\n"
"- Second decision\n\n"
"## Other section\n\nunrelated.\n"
)
path = self._make_conv_with_halls(tmp_wiki, body)
page = wiki_lib.parse_page(path)
halls = wiki_lib.parse_conversation_halls(page)
assert "fact" in halls
assert halls["fact"] == ["First decision made", "Second decision"]
def test_extracts_multiple_hall_types(
self, wiki_lib: Any, tmp_wiki: Path
) -> None:
body = (
"## Decisions (hall: fact)\n\n- A\n- B\n\n"
"## Discoveries (hall: discovery)\n\n- root cause X\n\n"
"## Advice (hall: advice)\n\n- try Y\n- consider Z\n"
)
path = self._make_conv_with_halls(tmp_wiki, body)
page = wiki_lib.parse_page(path)
halls = wiki_lib.parse_conversation_halls(page)
assert halls["fact"] == ["A", "B"]
assert halls["discovery"] == ["root cause X"]
assert halls["advice"] == ["try Y", "consider Z"]
def test_ignores_sections_without_hall_marker(
self, wiki_lib: Any, tmp_wiki: Path
) -> None:
body = (
"## Summary\n\n- not a hall bullet\n\n"
"## Decisions (hall: fact)\n\n- real bullet\n"
)
path = self._make_conv_with_halls(tmp_wiki, body)
page = wiki_lib.parse_page(path)
halls = wiki_lib.parse_conversation_halls(page)
assert halls == {"fact": ["real bullet"]}
def test_flattens_multiline_bullets(
self, wiki_lib: Any, tmp_wiki: Path
) -> None:
body = (
"## Decisions (hall: fact)\n\n"
"- A bullet that goes on\n and continues here\n"
"- Second bullet\n"
)
path = self._make_conv_with_halls(tmp_wiki, body)
page = wiki_lib.parse_page(path)
halls = wiki_lib.parse_conversation_halls(page)
# The simple regex captures each "- " line separately; continuation
# lines are not part of the bullet. This matches the current behavior.
assert halls["fact"][0].startswith("A bullet")
assert "Second bullet" in halls["fact"]
def test_empty_body_returns_empty(
self, wiki_lib: Any, tmp_wiki: Path
) -> None:
path = self._make_conv_with_halls(tmp_wiki, "## Summary\n\ntext.\n")
page = wiki_lib.parse_page(path)
assert wiki_lib.parse_conversation_halls(page) == {}
def test_high_signal_halls_filters_out_preference_event_tooling(
self, wiki_lib: Any, tmp_wiki: Path
) -> None:
body = (
"## Decisions (hall: fact)\n- f\n"
"## Preferences (hall: preference)\n- p\n"
"## Events (hall: event)\n- e\n"
"## Tooling (hall: tooling)\n- t\n"
"## Advice (hall: advice)\n- a\n"
)
path = self._make_conv_with_halls(tmp_wiki, body)
page = wiki_lib.parse_page(path)
halls = wiki_lib.high_signal_halls(page)
assert set(halls.keys()) == {"fact", "advice"}
# ---------------------------------------------------------------------------
# wiki-distill.py module fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def wiki_distill(tmp_wiki: Path) -> Any:
from conftest import SCRIPTS_DIR, _load_script_module
_load_script_module("wiki_lib", SCRIPTS_DIR / "wiki_lib.py")
return _load_script_module("wiki_distill", SCRIPTS_DIR / "wiki-distill.py")
# ---------------------------------------------------------------------------
# Topic rollup logic
# ---------------------------------------------------------------------------
class TestTopicRollup:
def _make_summarized_conv(
self,
tmp_wiki: Path,
project: str,
filename: str,
conv_date: str,
topics: list[str],
fact_bullets: list[str] | None = None,
) -> Path:
fact_section = ""
if fact_bullets:
fact_section = "## Decisions (hall: fact)\n\n" + "\n".join(
f"- {b}" for b in fact_bullets
)
return make_conversation(
tmp_wiki,
project,
filename,
date=conv_date,
status="summarized",
related=[f"topic:{t}" for t in []],
body=f"## Summary\n\ntest.\n\n{fact_section}\n",
)
def test_extract_topics_from_today_only(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
today_date = wiki_distill.today()
yesterday = today_date - timedelta(days=1)
# Today's conversation with topics
_write_conv_with_topics(
tmp_wiki, "test", "today.md",
date_str=today_date.isoformat(), topics=["alpha", "beta"],
)
# Yesterday's conversation — should be excluded at lookback=0
_write_conv_with_topics(
tmp_wiki, "test", "yesterday.md",
date_str=yesterday.isoformat(), topics=["gamma"],
)
all_convs = wiki_distill.iter_summarized_conversations()
topics = wiki_distill.extract_topics_from_today(all_convs, today_date, 0)
assert topics == {"alpha", "beta"}
def test_extract_topics_with_lookback(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
today_date = wiki_distill.today()
day3 = today_date - timedelta(days=3)
day10 = today_date - timedelta(days=10)
_write_conv_with_topics(
tmp_wiki, "test", "today.md",
date_str=today_date.isoformat(), topics=["a"],
)
_write_conv_with_topics(
tmp_wiki, "test", "day3.md",
date_str=day3.isoformat(), topics=["b"],
)
_write_conv_with_topics(
tmp_wiki, "test", "day10.md",
date_str=day10.isoformat(), topics=["c"],
)
all_convs = wiki_distill.iter_summarized_conversations()
topics_7 = wiki_distill.extract_topics_from_today(all_convs, today_date, 7)
assert topics_7 == {"a", "b"} # day10 excluded by 7-day lookback
def test_rollup_by_topic_across_history(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
today_date = wiki_distill.today()
# Three conversations all tagged with "shared-topic", different dates
_write_conv_with_topics(
tmp_wiki, "test", "a.md",
date_str=today_date.isoformat(), topics=["shared-topic"],
)
_write_conv_with_topics(
tmp_wiki, "test", "b.md",
date_str=(today_date - timedelta(days=30)).isoformat(),
topics=["shared-topic", "other"],
)
_write_conv_with_topics(
tmp_wiki, "test", "c.md",
date_str=(today_date - timedelta(days=90)).isoformat(),
topics=["shared-topic"],
)
# One unrelated
_write_conv_with_topics(
tmp_wiki, "test", "d.md",
date_str=today_date.isoformat(), topics=["unrelated"],
)
all_convs = wiki_distill.iter_summarized_conversations()
rollup = wiki_distill.rollup_conversations_by_topic(
"shared-topic", all_convs
)
assert len(rollup) == 3
stems = [c.path.stem for c in rollup]
# Most recent first
assert stems[0] == "a"
def _write_conv_with_topics(
tmp_wiki: Path,
project: str,
filename: str,
*,
date_str: str,
topics: list[str],
) -> Path:
"""Helper — write a summarized conversation with topic frontmatter."""
proj_dir = tmp_wiki / "conversations" / project
proj_dir.mkdir(parents=True, exist_ok=True)
path = proj_dir / filename
topic_yaml = "topics: [" + ", ".join(topics) + "]"
content = (
f"---\n"
f"title: Test Conv\n"
f"type: conversation\n"
f"project: {project}\n"
f"date: {date_str}\n"
f"status: summarized\n"
f"messages: 50\n"
f"{topic_yaml}\n"
f"---\n"
f"## Summary\n\ntest.\n\n"
f"## Decisions (hall: fact)\n\n"
f"- Fact one for these topics\n"
f"- Fact two\n"
)
path.write_text(content)
return path
# ---------------------------------------------------------------------------
# Topic group building
# ---------------------------------------------------------------------------
class TestTopicGroupBuild:
def test_counts_total_bullets(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
_write_conv_with_topics(
tmp_wiki, "test", "one.md",
date_str="2026-04-12", topics=["foo"],
)
all_convs = wiki_distill.iter_summarized_conversations()
rollup = wiki_distill.rollup_conversations_by_topic("foo", all_convs)
group = wiki_distill.build_topic_group("foo", rollup)
assert group.topic == "foo"
assert group.total_bullets == 2 # the helper writes 2 fact bullets
def test_format_for_llm_includes_topic_and_sections(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
_write_conv_with_topics(
tmp_wiki, "test", "one.md",
date_str="2026-04-12", topics=["bar"],
)
all_convs = wiki_distill.iter_summarized_conversations()
rollup = wiki_distill.rollup_conversations_by_topic("bar", all_convs)
group = wiki_distill.build_topic_group("bar", rollup)
text = wiki_distill.format_topic_group_for_llm(group)
assert "# Topic: bar" in text
assert "Fact one" in text
assert "Decisions:" in text
# ---------------------------------------------------------------------------
# State management
# ---------------------------------------------------------------------------
class TestDistillState:
def test_load_returns_defaults(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
state = wiki_distill.load_state()
assert state["processed_convs"] == {}
assert state["processed_topics"] == {}
assert state["first_run_complete"] is False
def test_save_and_reload(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
state = wiki_distill.load_state()
state["first_run_complete"] = True
state["processed_topics"]["foo"] = {"distilled_date": "2026-04-12"}
wiki_distill.save_state(state)
reloaded = wiki_distill.load_state()
assert reloaded["first_run_complete"] is True
assert "foo" in reloaded["processed_topics"]
def test_conv_needs_distill_first_time(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
path = _write_conv_with_topics(
tmp_wiki, "test", "fresh.md",
date_str="2026-04-12", topics=["x"],
)
conv = wiki_distill.parse_page(path)
state = wiki_distill.load_state()
assert wiki_distill.conv_needs_distill(state, conv) is True
def test_conv_needs_distill_detects_content_change(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
path = _write_conv_with_topics(
tmp_wiki, "test", "mut.md",
date_str="2026-04-12", topics=["x"],
)
conv = wiki_distill.parse_page(path)
state = wiki_distill.load_state()
wiki_distill.mark_conv_distilled(state, conv, ["staging/patterns/x.md"])
assert wiki_distill.conv_needs_distill(state, conv) is False
# Mutate the body
text = path.read_text()
path.write_text(text + "\n- Another bullet\n")
conv2 = wiki_distill.parse_page(path)
assert wiki_distill.conv_needs_distill(state, conv2) is True
def test_conv_needs_distill_detects_new_topic(
self, wiki_distill: Any, tmp_wiki: Path
) -> None:
path = _write_conv_with_topics(
tmp_wiki, "test", "new-topic.md",
date_str="2026-04-12", topics=["original"],
)
conv = wiki_distill.parse_page(path)
state = wiki_distill.load_state()
wiki_distill.mark_conv_distilled(state, conv, [])
assert wiki_distill.conv_needs_distill(state, conv) is False
# Rewrite with a new topic added
_write_conv_with_topics(
tmp_wiki, "test", "new-topic.md",
date_str="2026-04-12", topics=["original", "freshly-added"],
)
conv2 = wiki_distill.parse_page(path)
assert wiki_distill.conv_needs_distill(state, conv2) is True
# ---------------------------------------------------------------------------
# CLI smoke tests (no real LLM calls — uses --dry-run)
# ---------------------------------------------------------------------------
class TestDistillCli:
def test_help_flag(self, run_script) -> None:
result = run_script("wiki-distill.py", "--help")
assert result.returncode == 0
assert "--first-run" in result.stdout
assert "--topic" in result.stdout
assert "--dry-run" in result.stdout
def test_dry_run_empty_wiki(self, run_script, tmp_wiki: Path) -> None:
result = run_script("wiki-distill.py", "--dry-run", "--first-run")
assert result.returncode == 0
def test_dry_run_with_topic_rollup(
self, run_script, tmp_wiki: Path
) -> None:
_write_conv_with_topics(
tmp_wiki, "test", "convA.md",
date_str="2026-04-12", topics=["rollup-test"],
)
_write_conv_with_topics(
tmp_wiki, "test", "convB.md",
date_str="2026-04-11", topics=["rollup-test"],
)
result = run_script(
"wiki-distill.py", "--dry-run", "--first-run",
)
assert result.returncode == 0
# Should mention the rollup topic
assert "rollup-test" in result.stdout
def test_topic_flag_narrow_mode(
self, run_script, tmp_wiki: Path
) -> None:
_write_conv_with_topics(
tmp_wiki, "test", "a.md",
date_str="2026-04-12", topics=["explicit-topic"],
)
result = run_script(
"wiki-distill.py", "--dry-run", "--topic", "explicit-topic",
)
assert result.returncode == 0
assert "Explicit topic mode" in result.stdout
assert "explicit-topic" in result.stdout
def test_too_thin_topic_is_skipped(
self, run_script, tmp_wiki: Path, wiki_distill: Any
) -> None:
# Write a conversation with only ONE hall bullet on this topic
proj_dir = tmp_wiki / "conversations" / "test"
proj_dir.mkdir(parents=True, exist_ok=True)
(proj_dir / "thin.md").write_text(
"---\n"
"title: Thin\n"
"type: conversation\n"
"project: test\n"
"date: 2026-04-12\n"
"status: summarized\n"
"messages: 5\n"
"topics: [thin-topic]\n"
"---\n"
"## Summary\n\n\n"
"## Decisions (hall: fact)\n\n"
"- Single bullet\n"
)
result = run_script(
"wiki-distill.py", "--dry-run", "--topic", "thin-topic",
)
assert result.returncode == 0
assert "too-thin" in result.stdout or "too-thin" in result.stderr