Skip to content

v0.4.0 — last release with the marimo interface

Choose a tag to compare

@Clod Clod released this 06 Oct 15:52
· 12 commits to master since this release

The last release with the marimo interface: the next one replaces it with a web
interface.

Changed

  • The application logic moved out of the marimo cells into base/services/.
    services/wiki.py opens a wiki, lists and reads its pages, resolves their
    links, and saves an edited page (front-matter kept, references rebuilt, one
    git commit, refused when the page changed since it was opened).
    services/chat.py builds a wiki's two agents and answers one turn in the
    pre-retrieval, strict or streaming mode. services/ingest.py holds the upload
    path with its reconciliation and cross-links, the maintenance passes, and the
    views of the sources. The three marimo apps now call these functions; their
    behaviour is unchanged. This is the layer a web interface will call.

Changed

  • Every pre-retrieval refusal states its cause. The single text "Eso no
    está en mi base de conocimiento." is replaced by one message per cause,
    built by the code (base/domain/chat/refusal.py): the blacklist term, a
    question that names no topic of the wiki, a topic with no text found, an
    answer not grounded in its source document, an answer with no evidence, and
    the model's own refusal. Every message except the blacklist's lists up to
    five related page titles, or points to the wiki's index when there are none.
    The chat system prompt of every language now asks the model to open its own
    refusal with "No encontré la respuesta en los documentos." / "I did not
    find the answer in the documents."
    , and the three demo prompts use that
    sentence. The strict and streaming modes are unchanged.

Changed

  • Search, the coverage gate and the Tier-2 check stem words in the wiki's
    language.
    One Snowball stemmer per wiki language (snowballstemmer,
    base/domain/text/stemming.py) serves the FTS index, the queries, the roster
    and the Tier-2 overlap. "¿Cuánto rinden los plazos fijos?" now enters the
    roster through the category plazo_fijo; before, the plural was refused.
    The blacklist and the dataset keys still compare by whole word, so ceder
    is no longer at risk of matching cedear. document_chunks gains
    content_stemmed, which chunks_fts indexes with unicode61 (no more
    porter); an existing index is rebuilt on first open, with no model call.
    The pre-retrieval turn now checks the blacklist first and searches the wiki
    only for a question in the roster. The Tier-2 threshold is configurable as
    [pre_retrieval] min_coverage (default 0.2).

Fixed

  • Pre-retrieval Tier 1 now carries the dataset the question names. A data
    question that also matches wiki pages (for example "¿Qué tasa de plazo fijo
    ofrece el Banco Galicia?"
    ) was answered from the pages alone: the pages hold
    no rates, and the model called query_dataset only when the bank matched the
    example in the tool's docstring (0 calls in 3 runs for Banco Galicia). The
    code now injects the named key's rows next to the pages, or only the
    category's key names when the question names no key, and cites the injected
    rows (dataset_injection, base/domain/chat/preretrieval.py; spans Q7a–Q7d).
    When the model asks query_dataset for a key the category lacks, such as
    Banco Comafi, the code appends the keys that have data to the answer
    (offer_available_keys).

Changed

  • The chat trace and the ingestion trace are replaced by one OpenTelemetry
    span stream.
    base/domain/chat/trace.py (WIKI_CHAT_TRACE=1,
    <workspace>/.llmwiki/chat_trace.jsonl) and
    base/domain/ingestion/trace.py (per-run trace.jsonl under
    <workspace>/.llmwiki/traces/<run_id>/) are removed. WIKI_TRACE=1 now
    activates a single module, base/domain/tracing.py: every step of a chat
    turn or an ingest opens a span named by its identifier in the diagrams of
    design_stemming.md (Q5, I4, W1, …), so the span names of one trace
    are the path the run took through the diagram. Spans append to
    <workspace>/.llmwiki/traces/spans.jsonl, one JSON object per line
    (ReadableSpan.to_json); heavy payloads still go to content-addressed
    files under .llmwiki/traces/payloads/, gated by the unchanged
    WIKI_TRACE_CAPTURE. Model calls are spans with gen_ai.* attributes,
    written by Pydantic AI's own instrumentation for the chat agent and by a
    proxied OpenAI client (tracing.wrap_openai) for ingestion.
    scripts/render_trace.py is rewritten to render the span file — one
    timeline per trace, filterable by --trace, --conversation, or --doc —
    and marimo/trace_report_app.py is removed with no replacement viewer.
    WIKI_CHAT_TRACE no longer has any effect.

Fixed

  • "See also" links now survive an accent. slugify strips combining marks
    when it builds a page's file name, so a page titled "Panel Líder" is filed as
    panel-lider.md, and inject_see_also looks for the slug — "panel lider" —
    in the text of every other page. The text keeps its accents, so prose that
    spells "Panel Líder" never matched, and the link was never offered. Counted on
    the bundled finanzas-argentinas wiki: thirty page pairs in that shape, among
    them the summary of Acciones Locales, which names both "Panel Líder" and
    "Riesgos estructurales de inversión" without linking either. The bundled
    English wiki has none, which is why the gap stayed invisible. The comparison
    now strips the accents from the text as well, through strip_accents in
    wiki_generator, and _crosslink_candidates strips them the same way — that
    candidate set is a superset of the pages that end up linked only while both
    halves compare the same string.

Added

  • An index built with an older schema is reported instead of crashing. The
    schema in database/sqlite_schema.sql is applied with
    CREATE TABLE IF NOT EXISTS every time a database is opened, and no
    ALTER TABLE ever runs, so a table created by an earlier version keeps the
    columns that version declared. The mismatch used to surface later, as a raw
    sqlite3.OperationalError — "no such column: relative_path" — thrown by
    whatever query happened to run first, which named neither the cause nor a way
    out. open_db now compares, before applying the schema, the columns of every
    existing table against the columns the current schema declares, and raises
    SchemaMismatchError naming the table, the missing columns, the file, and the
    recovery: the wiki pages are markdown under git and are unaffected, so
    deleting the index and re-ingesting sources/ rebuilds it. The reference is
    built by applying the schema to an in-memory database rather than by parsing
    SQL, so the comparison stays exact as the schema evolves, and the reference is
    computed once per process. A table the schema declares and the database lacks
    is not a mismatch: that table is created, as before. A table the schema never
    declares is left alone.

  • Ingestion checks for a Java runtime before it starts. opendataloader-pdf
    runs a bundled .jar through the java command, and a DOCX is converted to
    PDF before that same extractor reads it, so both supported file types need a
    Java runtime. Nothing said so: no document mentioned Java, and there was no
    check matching the check_libreoffice() that has always guarded the DOCX
    path, so a machine without a Java runtime failed inside a subprocess call in
    a dependency. check_java() now mirrors check_libreoffice(), honouring
    JAVA_HOME for a runtime that was never added to PATH; extract raises
    JavaNotInstalledError with installation commands for the three platforms;
    the pipeline reports the same message as a failed ingest rather than an
    exception; and the ingest app shows the runtime's status next to LibreOffice's.
    The prerequisite is now stated in both READMEs, in the format table of both,
    in docs/manual/workflows.md, and by quickstart.py, which warns without
    blocking — the bundled demos ship pre-ingested and open without a Java runtime.

Fixed

  • Concept pages of a Spanish wiki recorded no citation edge.
    references.py looked for the literal ## Sources heading when parsing the
    plain - file.pdf bullets a concept page lists its sources with, while
    build_concept_page writes that heading from the wiki's locale — ## Fuentes
    for language = "es". Every concept page of a Spanish wiki therefore had zero
    cites edges, and everything downstream read that silence as "nothing to do":
    contradiction_check and missing_xref_check build their pairs by joining on
    those edges and found none, staleness_check could never mark a concept page
    when its source changed, and delete_source left the concept pages of a
    deleted source unmarked. Both shipped Spanish demos measure it — 0 concept
    cites edges in examples/cuentos-de-hadas and in
    examples/finanzas-argentinas, against 14 in the English
    examples/fairy-tales. The pattern now accepts the h_sources of every
    registered locale, so adding a language keeps adding one Locale entry.
    Existing Spanish wikis repopulate their graph by re-running
    update_references over their pages, which calls no model.

  • A Java runtime installed only under JAVA_HOME still failed the ingest.
    check_java accepts a runtime found through JAVA_HOME for an installation
    that was never added to PATH, but the extractor it clears the way for runs
    the bare java command through subprocess, which resolves against PATH
    alone. That configuration therefore passed the preflight added last month and
    failed a few frames later with PDF extraction failed: [Errno 2] No such file or directory: 'java' — the raw dependency error the preflight exists to
    replace. extract now prepends the found runtime's bin directory to PATH
    for the duration of the extraction and restores it afterwards; when java is
    already on PATH, nothing changes.

  • quickstart.py died at step 5 on a stock Debian or Ubuntu. Those
    distributions ship python3 without ensurepip, so python3 -m venv fails
    with "ensurepip is not available" — after the demo choice, the provider wizard
    and the .env write, with a message that names neither the cause nor the fix.
    The installer now checks for ensurepip before creating the environment and
    names python3-venv. The README's "all you need is Python 3.12+ and git" was
    wrong there and nothing said so.

  • The E2E_FULL=1 sweep test could not pass. Its first recorded run failed:
    after confirming the widget it polled the Activity Log for total:, but the
    panel still showed the previous scan, which ends in total: 0.0s, so the
    first read matched the stale marker and the assertions ran against the wrong
    log while the sweep was still working. The test now waits for the sweep's own
    opening line first. The sweep itself is sound — driven directly against the
    same workspace it makes 31 model calls in 34 s, reports 7 findings and repairs
    6 of them.

  • Documentation figures that had gone stale. Both READMEs said "nine lint
    checks" and listed nine; lint_wiki runs ten (unpaged_source_check was the
    missing one). Both said "a 72 KB programmer manual", a size from before that
    manual was split into docs/manual/; the figure is replaced by what the
    documentation is rather than how big it was. ROADMAP.md linked PR #7 as
    github.com/Clod/llmwiki/pull/7, which returns 404 — the repository is
    llmwiki-marimo, so the only pointer to the rollback design was broken.
    CONTRIBUTING.md and docs/manual/apps.md still told a reader to run
    marimo/read_app.py and test test_read_app.py, the three-column app the
    rest of the documentation has moved off.

  • The installer launched the app the documentation does not show.
    quickstart.py ended with marimo run marimo/read_app.py, the three-column
    app, while the README screenshots, the demo video and the rest of the
    documentation show the tabbed app. A first run therefore opened a different
    interface from the advertised one. The installer now launches
    marimo/read_app_tabs.py, and its repository-layout check looks for that same
    file.

  • Lint reports a source that produced no wiki page. Ingestion commits the
    source row as status='ready' at step 6, before the model writes anything, so
    that a failure in the steps after it cannot lose the document. The cost of that
    order is a state nothing reported: a source stored, indexed and searchable with
    no page written from it. Nothing recovered it either — needs_ingestion
    compares the file's modification time and its hash, never whether the document
    has pages, so every later scan called the file up to date and wrote nothing,
    and touching the file did not help because the hash still matched. No check
    covered it: orphan runs the other way round, flagging pages nothing links to
    and deleting them, and thin_page measures how well a source's pages cover it
    and skips a source that has none. Finding it meant noticing the absence in a
    page list. unpaged_source_check now lists every source with status='ready'
    that no wiki page cites. It is advisory rather than repairable: producing the
    pages means deleting the source and ingesting it again, which is a decision.
    Two of its four tests fix the boundary — a source marked failed is not
    reported, because there ingestion already said so, and a wiki page is not
    mistaken for a source.

  • Stale pages can be deleted from the ingest app. A page is marked stale when
    a source it cited is deleted: the page is kept, because it may still rest on
    other sources, and flagged for a human. Nothing surfaced that flag —
    find_stale_pages had been written for exactly this and was called from
    nowhere — so acting on it meant knowing it existed and deleting pages by hand.
    Two cells, per the project's rule against stacking controls in one: the first
    counts the marked pages, puts the count in the button label, names them in the
    confirmation (five, then "+N more") and disables itself when there are none;
    the second deletes each one, continuing past a failure so a single bad page
    cannot strand the rest. Offering this in bulk is only safe because of the two
    fixes below — inbound links are stripped, the catalogue entry is pruned, and
    the mark is cleared on regeneration, so the button sees pages that lost
    evidence and were never revisited rather than every page ever marked.

  • docs/from_idea_to_product.md, linked from the top of the README. Seventeen
    ways the LLM-wiki idea leaks when a generic agent is pointed at a folder, each
    with a real example and what this project does about it — the answer to the
    obvious question, "why not just use Claude Code on my notes?". It existed in
    Spanish since July, unlinked from either README, gated behind its own rule that
    it would not be published until every point was green. Reviewed against the
    code, and that gate turns out to be all but met: point 10 ("the configuration
    itself rots") shipped as designed, unbuilt and is now built — vocabulary is
    generated at ingest and watched by four lint checks — point 11 counted six
    checks where there are nine, the thin-page detector it listed as pending
    exists, and the live validation point 2 was waiting on happened on 2026-07-19.
    Rewritten in English with those corrections, plus two limits measured this week
    that the July text could not have known: the coverage roster matches page
    titles literally, and the blacklist does not stem. The Spanish original is
    marked stale and kept until it is re-translated from the English.

  • ROADMAP.md — what is planned next, and what is built but known to be
    imperfect. The second half is the point: the coverage gate matching page
    titles literally, a documentation check that cannot see a link cut in two, a
    guard written for a step that was never built, and the vocabulary lists having
    no interface by design. These were tracked in a working file outside version
    control, which meant the only copy lived on one machine. Four entries turned
    out to be already fixed and were closed rather than published, and one lost
    its rationale to an earlier PR and was dropped. Linked from both READMEs and
    covered by the docs link checker.

  • The pre-retrieval switch is documented where you would look for it. Both
    wiki_config.example.toml templates now carry the [pre_retrieval] section and
    its three scope lists, commented out, and both READMEs explain the trade in the
    chat-configuration section. The flag decides the entire shape of the read path,
    and until now it appeared only in the manual, the walkthrough and the finance
    demo's own config — so a user copying the template had no way to learn it exists.

Changed

  • The cross-link pass is scoped to the pages an ingest touched.
    crosslink_wiki_pages compared every wiki page against every other one, with
    a regular-expression search per pair, and ran in full after any scan that
    ingested a single file. Measured on synthetic corpora through the real
    pipeline: 0.1 s at 73 pages, 2.0 s at 460, 7.2 s at 601 — quadratic in the
    page count, in a step that runs on the way in. It now takes the set of pages
    the ingest wrote (from the new pages_touched_by) and rewrites those plus the
    pages whose text mentions them. On a 601-page wiki, ingesting one further
    document: 0.07 s and 5 pages linked, with a full pass straight afterwards
    finding 0 further pages to link, so the scoped pass loses nothing. The
    wiki-wide button and regeneration still run the full sweep.

  • Both walkthroughs were reviewed line by line by a first-time reader, and
    repaired.
    Twenty-seven commits of prose, no production code touched and the
    generated appendix unchanged. The review found errors that a check against the
    code could not: the compounding figure was 15 → 30 links_to edges rather than
    15 → 25, and the argument attached to it did not hold either, since six new
    pages produced fifteen new edges — the same ratio as the first act — so what
    supports the claim is that most new edges come from repair_missing_xref, not
    from the model writing pages. The capture script re-runs eleven questions
    through the gate, ten of them through a live model, not seven — a figure the
    same document stated correctly sixty lines earlier. ensure_citation appends
    two independently decided labels, Referencia: and Fuente:, not one.
    overlap.py was described as able to judge whether a fragment answers a
    question, when it measures whether an answer draws on a fragment; the ROADMAP
    entry repeating that claim is corrected too. "Either case ends in refuse" is
    false for a wiki with datasets, where has_data diverts the question to the
    tools.

    Terms used before being defined were introduced where the behaviour first
    appears — "positions", "agentic", the document's own division into parts, the
    coverage roster, and the three runs the summary table refers to — so every
    reference resolves backwards. Figurative phrasing was replaced with the
    mechanism it stood for throughout: "it nearly went the other way" does not say
    what the other way was, and "structurally blind" does not say what goes
    undetected.

  • The programmer manual is four files, and gained a layer map. It had grown
    to 1227 lines on top of the 1090 already split into workflows.md. Now:
    programmer_manual.md keeps orientation (§1 §2 §3 §10 §11 §13, 421 lines),
    manual/internals.md takes schema, tool layer and tracing (§4 §5 §14),
    manual/apps.md takes the Marimo apps, configuration, testing and datasets
    (§7 §8 §9 §15), and manual/workflows.md stays as §6. Section numbers stay
    global
    — a §N means the same section wherever cited, which is what keeps
    ~190 cross-references between these documents valid.

    §2 now carries the nine architectural layers, derived from the project's own
    knowledge graph (449 nodes, 975 edges) rather than drawn by hand, with the two
    boundaries that are decisions rather than folder consequences: lint and repair
    are one layer because they are a producer/consumer pair, and datasets plus the
    finance overlay sit outside the engine because they are inactive on most wikis.

    Two tests now guard the arrangement, both verified failing: every §N cited
    anywhere in the maintained docs must be defined by one of the four files, and
    no two files may define the same number. The first immediately found two
    CODEMAPS still citing §12, orphaned when that section moved to the roadmap.

  • Pending work lives in one place. The programmer manual carried §11 "Pending
    Work / Roadmap" and §12 "Future Enhancements"; ROADMAP.md arrived three days
    ago and made a third. Three lists of the same thing diverge, so §11 and §12
    moved wholesale into the roadmap — including the full five-step
    reindex_from_disk design, and the deliberate deferrals with their reasoning
    intact (web search at query time and as an ingest loop, two-step reviewed
    ingestion, image handling, output formats). The manual keeps a pointer and is
    178 lines shorter. Its "no open bugs" line was true of bugs and never of
    known limits, of which the roadmap now records five. 27 dangling §11.N / §12
    cross-references across both READMEs, workflows.md and the data dictionary
    were repointed.

  • The read app's interface is entirely in English. It was already English
    almost everywhere — "Refresh", "Save to wiki", "Category", "is not a
    directory" — but a handful of Spanish labels had drifted in with the tabs
    variant and stayed: the two chat checkboxes (Modo estricto,
    Pre-retrieval: el código recupera…), the tab names (📖 Lectura,
    💬 Diálogo), the chat heading and the save accordion. read_app.py already
    said "Chat with your Wiki" while read_app_tabs.py said "Chat con tu Wiki",
    which is what gave the drift away. Wiki content remains per-wiki
    multilingual and the Spanish system prompts are untouched — this is the
    chrome only.

  • Both walkthroughs are rewritten for a reader who has never seen the project.
    They no longer assume you can read a schema, and every term borrowed from the
    LLM and search worlds — token, embedding, RAG, chunk, agentic,
    corpus, system prompt — is defined where it first appears. The Spanish terms
    in the finance demo's captured answers are glossed in place rather than
    translated away, since the answers are evidence. Both documents now open on
    Karpathy's note and close on an honest account of what this implementation adds
    to it and where it falls short, and both gained diagrams: the workspace layout,
    the two chat checkboxes, and the chat-to-wiki save flow.

  • The query walkthrough describes the mode the app actually ships in. It was
    built around a single pre-retrieval toggle, but the read app has two independent
    checkboxes, and Strict mode is on by default — so the "unticked mode" it
    documented was a configuration nobody runs. Its two showcased failures are now
    measured against that default: a missing citation is repaired by
    ensure_citation, while a passage retrieved for a question it does not answer
    passes through untouched. capture_query_walkthrough.py records both outcomes
    by replaying the guardrail over the captured run, so the claim costs no second
    model call and cannot drift from the app.

  • Wiki-page front-matter is written by code, not by the model, and follows the
    Open Knowledge Format.

    The prompt templates used to show the LLM a tags:/sources: block and ask it to
    reproduce values the code already held; on update the block round-tripped through
    the model and could drift. create_page now renders it from what it is given, so
    every page carries a type (concept, summary, overview — OKF's one mandatory
    field), a title, its tags, and sources as OKF provenance mappings rather than
    bare strings. Reading tolerates the old string form, so existing wikis keep working.

Fixed

  • Deleting a wiki page left broken links and a catalogue entry behind. Two
    cleanups that were supposed to run on delete did not. _strip_dead_links built
    its pattern from the page's full path (wiki/concepts/x.md), while neighbours
    link as [Title](x.md) and across folders as [Title](../summaries/x.md) —
    what inject_see_also and repair_missing_xref actually emit, and what all 26
    links in the fairy-tale demo look like. The pattern matched nothing, so
    deleting a page left a broken link in every page pointing at it. It now matches
    every markdown link and resolves each href against the directory of the page
    containing it, the workspace-root-relative form included, since older pages may
    carry it. delete_page also never touched wiki/index.md: that file has no row
    in documents, so no cascade reaches it, and the deleted page stayed listed in
    the one page whose job is to list what exists. remove_index_entry already
    existed for this and is now called.
  • A page marked stale stayed marked forever. stale_since means "a source
    this page cited was deleted; it may now be under-supported, review it". Nothing
    ever cleared it, so any list built from the flag filled with pages long since
    dealt with. Clearing is opt-in rather than tied to overwrite=True, because
    only four of the eight overwrite call sites are regenerations — ingesting a
    concept or summary page, regenerate_wiki_pages and repair_stale clear it;
    rollback after a failed ingest, crosslink_wiki_pages, repair_gap_filled and
    save_to_wiki keep it. None of those four revisits the page's prose, which is
    what the mark asks about, so clearing on any overwrite would erase the signal
    on a See-also append and make the flag meaningless by the opposite route.
  • Wiki pages were being recorded as citation sources. A cites record means
    "this page was written from that source document", and deletion, lint and
    provenance all assume the target is a source — delete_source decides what to
    destroy by following them. Two defects stored page-to-page relationships as
    citations: repair_missing_xref appended its "See also" link with
    append_to_page, which writes to the end of the file, so on a generated page —
    where "See also" precedes "Sources" — the link landed under ## Sources; and
    update_references matches a citation against every document by filename and
    by title, both of which a wiki page has, so the misplaced bullet resolved to it.
    Fixed at both levels: the repair now writes into the See also section, opening
    one above Sources when absent, and the parser refuses to point a citation at a
    wiki page whatever the markdown says.
  • Two Spanish column headers survived the interface translation. The read
    app's page table rendered Título / Ruta against an English wiki — missed by
    the sweep in this release because they are dictionary keys built in
    _page_row, not label= arguments, so the grep that found the checkboxes and
    tab names did not reach them. Found by looking at a screenshot.
  • The read-app E2E was broken by this release's own label translation. The
    strict-mode toggle was located by get_by_text("Strict mode"), and the
    pre-retrieval checkbox beside it now reads "…(supersedes strict mode)" — a
    case-insensitive match hits both, so the locator failed with a strict-mode
    violation. Invisible until someone ran it, which the parity work below finally
    did. Both suites now anchor on the unique tail of the label
    (answer only from wiki sources). The Spanish labels did not collide only
    because the second said "modo estricto" in lower case.

Changed

  • The demo video is the new interface, and the README says what it shows.
    https://youtu.be/VLX5kLczQbk replaces a recording of the three-column app. The
    blurb changed with it, because the video did: the old one walked an ingest, the
    new one reads a generated wiki and then contrasts the two answering modes — a
    cited cross-document answer in 9s against the same kind of question refused in
    1.2s, which is the design argument and was not demonstrable before.
  • The README screenshots show the tabs read app, and there are two of them.
    The old hero image showed the three-column grid doing everything at once,
    which the tabs app cannot: it renders only the active tab. So one image per
    tab — the page table with a generated concept page, and a real chat answer
    making three claims with five citations, above an expanded Save to wiki
    form. Both READMEs' alt text rewritten to match, and with them the quick-start
    command, the project-structure listing, the E2E command and the "malleable UI"
    bullet, which still described three columns while the picture above it showed
    tabs. docs/assets/read_app.png is removed as superseded.

Added

  • scripts/record_demo.py — records the demo video by driving the read app,
    replacing a rig that lived in /tmp and did not survive. The previous demo
    outlived its accuracy with no way to regenerate it; this is one command in
    version control. It shows what the tabbed interface can and the three-column
    one could not: both tabs, both mode checkboxes, a real cited cross-document
    answer — and then the same off-corpus question under Pre-retrieval,
    refused in 1.2s against the 9s the model-backed answer took. Not because the
    model is fast: because it was never called. The waits are sped up rather than
    cut, so the work is still visibly work. A failed turn aborts instead of
    recording an error, same guard as the screenshots. The .mp4 is gitignored —
    the READMEs point at YouTube, which GitHub will not play from a repo path
    anyway.
  • scripts/capture_screenshots.py — regenerates the README screenshots by
    driving the running read app with Playwright. The images in docs/assets/ went
    stale because taking them was a manual ceremony; this makes it one command. Two
    images rather than one, because the tabs app renders only the active tab and no
    single frame shows both halves. The chat capture makes a real model call, so
    the answer in the picture has real citations — and a failed turn aborts
    rather than writing
    : the first run against an exhausted key produced a
    perfectly convincing screenshot of marimo's red error box, which quoted the
    provider's response back, including a key-management URL carrying the key's
    identifier. It was caught by looking, which is not a control. Now it is one.
  • tests/e2e/test_read_app_tabs.py — the parity gate for promoting the tabs
    read app.
    A copy of test_read_app.py with the assertions untouched:
    both files' assert lines and test names are byte-identical, so passing one
    and passing the other is the same claim. Every mechanical difference follows
    from one fact — the tabs app renders only the active tab — so chat tests
    switch to 💬 Chat first, where the grid app shows every panel at once. Both
    suites now pass 7/7 against a live LLM. That clears the gate ROADMAP.md set
    for deleting marimo/read_app.py and its suite.

Fixed

  • The "needs a model" skip message speaks to whoever reads it. When lint
    finds a stale or missing_concept issue and the repair pass was given no
    model — the default after every ingest — it logs a skip. That skip used to read
    "LLM client required for 'stale' repair — pass llm_client": accurate, and no
    use to its only audience, who is looking at the ingest app's Activity Log where
    there is no argument to pass and two buttons that do the job. It now names
    those buttons. Two tests pin it, one of them checking the button names against
    ingest_app.py itself, since nothing in code reads those strings and they
    could otherwise drift apart unnoticed.

  • Injected passages are labelled by page, and their front-matter is dropped.
    With pre-retrieval on, every curated block reaching the model was labelled
    [/wiki/concepts/] — the folder, identical for all six — so in the one mode
    whose prompt asks the model to cite, it could not tell the blocks apart.
    filename was already in the search row and unused. Each block also carried
    its YAML front-matter (type, tags, sources) as if it were prose: 8% of
    the injected context on the shipped demo, and metadata rather than text to
    answer from — the reason retrieve_collection_pages already stripped it on
    its own path. The two are fixed together on purpose: that front-matter's
    sources: line was in practice what the model cited from, so removing it
    before the label identified the page would have taken the attribution away and
    put nothing back. Four tests; the existing fixture had folded path and
    filename into one string, which is why the suite never saw the defect.

  • Stop words are per language, which unblocks the Tier-2 fallback. The list
    of ubiquitous words dropped before building the full-text query
    (preretrieval.py) held only Spanish entries. In an English wiki the — three
    characters, past the length filter — reached FTS5 and matched nearly every
    chunk, so wiki_hits was never empty; and because doc_hits is computed only
    when wiki_hits is empty, the raw-source tier could not be reached at all.
    An off-topic question also had six unrelated curated chunks injected rather
    than none. Now _STOPWORDS is keyed by ISO code and the wiki's language
    selects the set; adding a language means adding an entry, documented at the
    constant and in workflows.md §6.7. The sets stay separate rather than merged
    on purpose: Spanish son is an English content word appearing in six chunks of
    the fairy-tale corpus, and one shared list would drop the key word of "Who is
    the king's son?". Measured on the shipped demo: "What is the capital of
    France?"
    went from 6 injected chunks to 0, and "Tell me about Cinderwench" —
    an ingest-generated alias no curated page mentions — now reaches Tier 2 with 4
    source fragments, which was unreachable before.

  • thin_page findings were reported as Unknown check type. The check has no
    automatic repair on purpose — it says the wiki under-covers a source, and the
    choice between expanding the page and accepting the Tier-2 fallback is a human
    one — but it was missing from _ADVISORY_CHECKS, so every ingestion log that
    hit it printed what looked like an internal error, including the walkthrough
    appendix that ships with the project. A new test now asserts that every check
    lint can emit is either repairable or declared advisory, so the next one added
    cannot reopen the gap.

  • Ingestion-walkthrough figures can no longer go stale unnoticed. The appendix
    is regenerated by really running the pipeline, so the model picks different
    concept names and link counts every time and silently invalidates the prose
    quoting them. tests/unit/test_docs_ingestion_acts.py compares the two and
    names the figure to fix.

  • Summary pages had no front-matter at all. build_summary_page is pure code and
    never emitted a block, so every summary in every shipped wiki was missing one. They
    have one now, which also makes a generated wiki OKF-conformant end to end.

  • A rollback no longer claims a source it does not have. Restoring a page after a
    failed ingest used to keep the filename that ingest had added, because sources only
    ever accumulated. Restores are now authoritative.

  • Three prompts wrapped existing page content in --- while that content itself
    started with ---
    , leaving the model to guess where the delimiters ended.

  • A ticked wiki can answer questions about itself. With pre-retrieval on, a
    question about the collection as a whole — "what is in this wiki?", "compare all
    of them" — was refused, because coverage is derived from concept-page names and
    such a question names no concept. Widening that list does not help and was
    measured not to: no roster of item names can contain a question about the
    collection. wiki/overview.md and wiki/index.md exist to describe it, and are
    now injected directly when the question is shaped that way — they carry no
    documents row, so search could never have reached them. The branch cannot fire
    without a page to inject, so a wiki with neither refuses exactly as before.

What's Changed

  • Write wiki-page front-matter in code, in OKF shape by @Clod in #14
  • Make the pre-retrieval switch discoverable from the config templates by @Clod in #15
  • Answer questions about the collection under pre-retrieval by @Clod in #16
  • Page deletion, pre-retrieval fixes, and the two walkthroughs by @Clod in #17
  • Line-by-line review of both walkthroughs by @Clod in #18
  • Repair two broken passages in the ingestion walkthrough by @Clod in #19
  • Four repairs in the ingestion walkthrough, found by reading it cold by @Clod in #20
  • Nine smaller repairs in the ingestion walkthrough by @Clod in #21
  • Define "contract" where the word first appears by @Clod in #22
  • Spec the three contracts the page-deletion work introduced by @Clod in #23
  • Fix the three first-run defects a full review found by @Clod in #25
  • Five defects a review of master reproduced, with the tests that pin them by @Clod in #26
  • Wiki rollback: git markdown + DB snapshot cache + reindex floor (design) by @Clod in #7
  • Count a citation written as a markdown link once by @Clod in #27
  • docs: the manual in one directory, §6 one file per workflow by @Clod in #28
  • feat(tracing): one OpenTelemetry span per diagram node, for chat and ingestion by @Clod in #29
  • fix(chat): Tier 1 carries the dataset the question names by @Clod in #30
  • feat(search): Snowball stemming per wiki language for search, gate and Tier 2 by @Clod in #31
  • feat(chat): pre-retrieval refusals state their cause and suggest pages by @Clod in #32
  • refactor(app): move the application logic out of the marimo cells into base/services by @Clod in #33

Full Changelog: v0.3.0...v0.4.0