I've been running Andrej Karpathy's LLM Wiki pattern for several months — reading sources, compiling them into a compounding, git-diffable wiki instead of re-deriving everything from scratch every session — and I'm a genuine convert. But two things kept nagging at me: query salience (finding the right page reliably, not just something plausible) and token economy (not re-reading half the wiki to answer one question).
The missing piece turned out to be the Open Knowledge Format (OKF): structured YAML frontmatter an agent can use to record the graph it's already implicitly building in prose, as typed subject–predicate–object triples rather than untyped markdown links. To keep retrieval fast and deterministic — not another LLM call on every question — that frontmatter compiles into a disposable SQLite index. What follows specs that redesign for someone to actually build — an implementation spec, not a blog post, where every section states what to build rather than just the idea. It ships with a working TypeScript/SQLite reference implementation (Appendix) and pitfalls validated by building and running it, not guessed at (§10).
The original gist frames the wiki as a human-browsable, compounding artifact: an agent reads sources, updates markdown pages, a person reads the results in Obsidian. That's still true here, but it is not the primary purpose of this version. The primary purpose is: given a question posed to an LLM, retrieve and assemble the right slice of the graph into that LLM's context window. Human browsing is a secondary, free side benefit of keeping everything as linked markdown. Every design decision below is made in service of the retrieval goal first.
Concretely, that reframing changed three things versus the original gist: the file format is now OKF (structured provenance/trust/lifecycle fields, not just prose), relationships are typed subject-predicate-object triples in frontmatter (not just untyped markdown links), and there is a retrieval pipeline spec (section 6) that did not exist in the original idea at all.
- Maintain a persistent, compounding knowledge base as a directory of plain markdown files, agent-written and agent-maintained.
- Represent facts as typed, sourced, subject-predicate-object triples that a query engine can traverse, not just prose an LLM re-reads from scratch each time.
- At question time, retrieve a bounded, relevance-ranked, trust-ranked set of facts and background prose that fits a context budget — this is the deliverable, not an afterthought.
- Stay diffable, portable, and toolable: git-diffable markdown as the
only source of truth; every other artifact (SQLite index,
timeline.md
,index.md
entries) is a regenerable build product, never hand-edited.
Not a general agent-memory system. This is not session memory, preference tracking, or conversational recall — see §9.3 for why an existing tool in that space (mnemon-dev/mnemon
) was evaluated and rejected as the storage engine.Not a fixed ontology. No upfront RDF/OWL schema. Both OKF'stype
and this spec's predicate vocabulary are open and grow by convention, documented as they're coined (§8).Not a graph database. SQLite with JSON1 functions is sufficient at the scale this pattern is meant for (hundreds to low thousands of concepts); this spec does not call for Neo4j or similar.
Considered a fourth layer, router.md
(a hand-curated dispatch table
from question domain to subdirectory), and dropped it: context_for()
(§6) queries the whole bundle in one hybrid-retrieval pass and doesn't
benefit from a coarse pre-filter, and the one audience that would have
needed file-based dispatch — a human browsing the vault in Obsidian —
already has direct SQL access to wiki.db
, either via Datasette or a Obsidian SQLite-query plugin (SQLite Explorer, SQLite DB, and similar all support embedding live query results in a note). The only scenario where a router file would still earn its keep is an agent given pure filesystem read access with no shell or SQL tool at all; not designed for by default.
bundle/
CLAUDE.md / AGENTS.md # schema doc: conventions, predicate vocabulary, ingest rules
wiki.db # SQLite index, regenerated from frontmatter, gitignored or snapshotted
log.md # OKF-native edit history (when the wiki changed)
timeline.md # world-event chronology (when things happened), regenerated
wiki-trigger-keywords.txt # concept titles + tags, regenerated — optional harness-hook pre-filter (§12)
entities/
index.md # OKF-native per-directory catalog
<concept>.md # OKF concept files, one per entity/topic
computations/
... # optional: OKF Attested Computation concepts, if the bundle needs sanctioned metrics
references/
... # OKF convention: mirrored external material, executor/attester code
| File | Role | Maintained how |
|---|---|---|
index.md |
||
| Per-directory catalog (OKF §8): concept list with one-line descriptions, for progressive disclosure. | Regenerable from concept frontmatter; may also be hand-edited. | |
| Concept files | The atomic units of knowledge: frontmatter + prose body. Source of truth. | |
| Agent-written during ingest (§7.1). | ||
timeline.md |
||
Chronological ledger of world events (as opposed to log.md , which is edit history), built from every date-qualified relations entry across the bundle. |
||
| Fully regenerated, never hand-edited. | ||
wiki.db |
||
| SQLite index over all concept frontmatter, the substrate the retrieval pipeline (§6) queries. | Fully regenerated on every ingest. | |
wiki-trigger-keywords.txt |
||
Flat, deduped list of every concept's title and tags — a deterministic pre-filter an optional harness hook can grep against, to nudge querying the wiki without paying context_for() 's cost on every turn. Not wired to anything by default; see §12. |
||
| Fully regenerated on every Reindex. |
Every concept is one markdown file: YAML frontmatter + prose body, per
OKF v0.2.
Concept identity is its bundle-relative path with .md
removed (OKF §2) — no separate ID scheme needed.
The original gist names the content a wiki should hold: "Summaries,
entity pages, concept pages, comparisons, an overview, a synthesis."
This spec keeps that taxonomy — it does not collapse into one generic
file kind. It maps onto OKF's open type
field (§4.2) plus the derived timeline (§3):
| Page kind | type value |
Carries relations ? |
What it's for |
|---|---|---|---|
| Entity page | Person , Organization , Product , Place , Country , or Entity as a fallback |
Yes — the primary source of typed triples | A specific named person, organization, product, place, country (§4.5 worked example). Use the most specific value that fits — a person's file gets type: Person , not generic Entity ; fall back to Entity only when none of the starter set applies. |
| Concept page | Concept |
Optional — abstract topics rarely assert their own triples, but link and synthesize several entities' relations in prose | An idea, a trend, a comparison, a synthesis, an overview — the original gist's "comparisons, an overview, a synthesis." |
| World event chronology | (not a page type) |
n/a — derived view, not authored content | timeline.md (§3), built from every date-qualified relations entry across Entity and Concept pages. Not its own type — deliberately: it stays a regenerated aggregate, never hand-authored, per the source-of-truth rule in §1. |
Concept pages are where a synthesized answer worth keeping gets filed back into the wiki (§7.2) — this is the mechanism that keeps the bundle compounding beyond raw source ingest, not just entity extraction.
| Field | OKF section | Required? | Purpose |
|---|---|---|---|
type |
|||
| §4.1 | Required | ||
e.g. Person , Entity , Playbook , Metric . Open vocabulary — see §8. |
|||
title , description , tags |
|||
| §4.1 | Recommended | Display, seed-matching, and index generation. | |
sources |
|||
| §5.1 | Optional | Provenance: what this concept was derived from. Each relations entry cites into this by id . |
|
generated |
|||
| §5.2 | Recommended | { by, at } — who/what wrote the current content, actor convention (§7 of OKF). |
|
verified |
|||
| §5.2–5.3 | Optional | Drives the trust tier used in retrieval ranking (§6.4): no verified → unverified; non-human: actor → machine-confirmed; any human: actor → human-reviewed. |
|
status , stale_after |
|||
| §5.4–5.5 | Optional | Lifecycle. stale_after is checked at retrieval time (§6.4), not just for display. |
|
superseded_by , supersedes |
|||
| extension, not in OKF core | |||
| Optional | Concept-file-level supersession — a whole concept file explicitly replaced by another. See §7.7. Distinct from stale_after (time-based expiry, no replacement recorded) and from a per-fact contradiction (§7.3's contradiction check, which flags but does not resolve). |
||
relations |
|||
| extension, not in OKF core | |||
| Optional | Typed triples. See §4.3. Valid per OKF §4.1 ("producers MAY include any additional keys... consumers MUST NOT reject documents with unrecognized fields") — this bundle stays OKF v0.2 conformant. |
Provenance convention for locally-mirrored sources. When a sources[]
entry's material was saved locally into references/
during ingest
(§7.1), record the bundle-relative path there as resource
— e.g.
resource: references/tc-profile-2026.html
— rather than just the
original external URL. A sources[]
entry whose resource
is not a
references/...
path (a bare external URL, or the field absent entirely) marks a concept as not traceable to a locally mirrored document — the signal §7.6's Rebuilding from references operation uses to distinguish an ingest-derived concept file from a hand-assembled one (a filed-back Concept page built from discussion and general web search, for instance) without needing a separate flag.
relations:
- { predicate: invested_in, object: /entities/openai.md, date: "2019-03-01", source: tc-profile-2026 }
- { predicate: married_to, object: /entities/laura-arrillaga-andreessen.md }
- { predicate: born_on, object: "1971-07-16" }
- The concept file itself is the implicit
subject— no separate subject field, identity is the file path (OKF §2).
predicate
: free-text string. Open vocabulary, governed by §8.object
: either a bundle-relative path beginning with/
(an entity-to-entity edge — the target should resolve to another concept, but per OKF §6.1 consumers MUST tolerate a broken target, since it may represent not-yet-written knowledge) or a literal value (an entity-to-value fact, e.g. a birth date).date
(optional): an ISO 8601 date,the field this spec uses for temporal qualification of a fact — do not name this field See §10.1 for why.on
.source
(optional): joins to asources[].id
on the same concept, reusing OKF's existing per-claim attribution mechanism (§5.1) rather than inventing a second citation scheme.
Prose links in the body ([OpenAI](/entities/openai.md)
) still work as
OKF intends — untyped, human-narrative traversal. relations
is the machine layer for typed graph queries; it does not replace prose links, it sits alongside them.
---
type: Person
title: Antony Blinken
description: United States Secretary of State (2021-2025).
tags: [person, united-states, foreign-policy]
relations:
- { predicate: secretary_of, object: /entities/us-department-of-state.md, date: "2021-01-26", source: state-dept-bio }
- { predicate: represents, object: /entities/united-states.md, source: state-dept-bio }
generated: { by: "reference_agent/claude-sonnet-5", at: "2026-08-09T10:00:00Z" }
verified: { by: "human:reviewer", at: "2026-08-09T11:00:00Z" }
sources:
- { id: state-dept-bio, resource: "https://2021-2025.state.gov/biographies/antony-j-blinken/", title: "U.S. Department of State — Antony J. Blinken" }
---
Antony Blinken served as [the United States](/entities/united-states.md)'s
Secretary of State from January 2021, heading the
[Department of State](/entities/us-department-of-state.md).[^state-dept-bio]
[^state-dept-bio]: U.S. Department of State — Antony J. Blinken
No relations
block required; it links to and synthesizes existing Entity pages instead of asserting its own triples. This is what a filed- back query answer (§7.2) typically looks like.
---
type: Concept
title: The U.S. Department of State under Antony Blinken
description: Synthesis of continuity in U.S. foreign-policy leadership during Antony Blinken's tenure as Secretary of State.
tags: [synthesis, united-states, foreign-policy]
generated: { by: "reference_agent/claude-sonnet-5", at: "2026-08-09T12:00:00Z" }
---
From January 2021, [Antony Blinken](/entities/antony-blinken.md)
represented [the United States](/entities/united-states.md) as Secretary
of State — see [the timeline](/timeline.md#2021-01-26) for when the
appointment began relative to other tracked events.
Do not build a bespoke database engine. Load every concept file's
frontmatter into a concepts
table with a small — the reference
implementation's build-index.ts
does this in well under a hundred
lines using the yaml
npm package for parsing and bun:sqlite
for the database; any TypeScript/JS equivalent works the same way.
List-valued fields (relations
, sources
, tags
) land as JSON text
columns automatically — query them with SQLite's built-in json_each
/
json_extract
, no separate relations
table or ETL step required. The
auto-generated _path
column doubles as concept identity, matching OKF's own file-path-is-identity rule for free.
Add on top of the base concepts
table:
: an FTS5 virtual table overconcepts_fts
title
,description
,tags
, and body text, for BM25 keyword search (§6.1).:embeddings
(path TEXT PRIMARY KEY, vector TEXT)
— one row per concept, vector as a JSON float array, produced by whichever embedding backend is configured (§6.1, §9.2).
Rebuild wiki.db
(and concepts_fts
/embeddings
) as the last step of every ingest (§7.1). It is a cache, never a second source of truth.
An existing SQLite-backed, four-graph (temporal/entity/causal/semantic) agent memory tool was evaluated and rejected as the storage layer for this bundle. Recorded here so the implementing agent doesn't re-derive or re-litigate this:
- Its SQLite DB
is the source of truth — no per-node markdown files exist to keep git-diffable, which inverts this spec's core principle (§1, §3). - Its
insights
schema is a flat memory-snippet model with no room for OKF'stype
/sources
/verified
/status
/stale_after
fields. - Its edges are inferred heuristically (embedding similarity thresholds, keyword regex, temporal proximity) from a fixed four-type enum, not deliberately asserted, sourced, open-vocabulary predicates.
- It has importance-decay and auto-pruning by design, which works against the "nothing gets silently dropped" compounding-wiki goal.
This is the actual deliverable. Given a question, produce a context block for the LLM answering it.
Three signals, fused with Reciprocal Rank Fusion (score = Σ 1/(k + rank + 1)
,
k ≈ 60
), exact-title match weighted 2x by being included in the fusion twice:
Exact title match— the concept's fulltitle
appears in the question at a whole-word/phrase boundary, not as a bare substring. Boundary-checked is load-bearing: a concept titled "AI" must not seed on the word "explain", nor "Go" on "ago". Cheap, high precision, andnecessary: on a cross-linked wiki, a concept is routinely mentioned on other concepts' pages, so term-frequency methods alone cannot distinguish "the question is about X" from "X is merely mentioned here" (see §10.3 — this is a validated finding, not a hypothetical).FTS5 BM25 overconcepts_fts
— keyword recall for partial or non-exact mentions. Used forrankingcandidates as-is; used forconfidenceonly after a word-coverage check, not on raw presence alone — validated, not hypothetical (§10.4).Vector cosine similarity overembeddings
— semantic recall for paraphrased questions with no lexical overlap.Embedding backend:(nomic-embed-text
via a local Ollama endpointlocalhost:11434
), withsearch_document:
/search_query:
prefixing per its convention — decided, not a placeholder. See §9.2 for why a TF-IDF stand-in was used during prototyping and is not carried forward.
Lightweight keyword classification into WHY | WHEN | ENTITY | GENERAL
(extend as needed). Drives predicate weighting in §6.3 — a pattern worth keeping from Mnemon's intent-adaptive traversal even though its storage engine was rejected (§5.1): a WHY question should weight causally-loaded predicates higher, a WHEN question should weight the date-qualified subset higher, and so on.
BFS from the seed concept(s), hop limit 2 by default. Score every traversed relation:
score = predicate_weight(intent, predicate) * hop_decay^hop * trust_multiplier(subject_trust_tier)
predicate_weight
: a small hand-maintained table per intent (grows with the predicate vocabulary, §8).hop_decay
: constant < 1 (e.g. 0.6) — relevance decays with distance from the seed.trust_multiplier
:{human-reviewed: 1.0, machine-confirmed: 0.8, unverified: 0.6}
, derived fromverified
per OKF §5.3.
Do not return the whole neighborhood — this is what makes the retrieval bounded rather than a graph dump.
Sort scored triples descending, take the top N by context budget.
Exclude or down-rank facts where today >= stale_after
(OKF §5.5).
Trust tier is a first-class ranking input here, not just display
metadata — when more candidates exist than the budget allows, prefer
human-reviewed, non-stale facts over unverified ones at equal predicate
weight. A concept file with status: superseded
gets its own filtering
pass, distinct from stale_after
's — see §7.7.
Render for the LLM prompt as compact fact lines plus prose background from the seed concepts' bodies, not raw JSON:
- Andreessen Horowitz invested_in OpenAI, as of 2021-04-01 (trust: human-reviewed, source: a16z-portfolio)
Triples answer that a fact holds; the seed concepts' prose bodies are included for facts that need more than a triple to convey correctly (why, not just that).
- Agent reads a new source, extracts facts, discusses with the user
(or proceeds unsupervised, per the bundle's own policy in
CLAUDE.md
/AGENTS.md
). - Creates or updates the relevant concept file(s): body prose,
relations
,sources
,generated
. A single source may touch several concepts. Hard rule — assert both directions of a relation in the same pass. When Antony Blinken's concept file gets{ predicate: secretary_of, object: /entities/us-department-of-state.md }
, the Department's concept file gets the matching{ predicate: secretary, object: /entities/antony-blinken.md }
in the same ingest step. Chosen over computing inverses at query time from a predicate-inverse lookup table: it keeps the query layer (§6) simple, at the cost of ingest needing to touch both concepts. §7.3's asymmetric-relations lint check exists specifically to catch a violation of this rule.- Regenerates
wiki.db
(§5) andtimeline.md
(§3) unconditionally — every ingest, no exceptions, regardless of how small the change — and anyindex.md
entries affected. - Appends an entry to
log.md
(OKF §9). Never hand-editstimeline.md
,wiki.db
, or auto-generatedindex.md
entries directly — they are step-4 outputs, not inputs.
The context_for(question)
pipeline, §6. This is the hot path and should be fast — it's a handful of indexed SQLite queries, not a full-bundle scan.
A synthesized answer worth keeping — a comparison, an analysis, a
connection the retrieval surfaced — should be filed back as a new
Concept page (§4.1) rather than left in chat history, the same
compounding behavior the original gist specified ("good answers can be
filed back into the wiki as new pages... your explorations compound in
the knowledge base just like ingested sources do"). Filing back runs the
same steps as §7.1: write the page, regenerate wiki.db
/timeline.md
,
update index.md
, log it.
Periodic health-check pass over wiki.db
, expressible as SQL:
- Orphan concepts: no inbound
relations
and no inbound prose links. - Broken relation targets:
object
paths that don't resolve to an existing concept (tolerate per OKF §6.1, but surface for review). - Stale concepts:
stale_after
in the past. - Predicate drift: near-duplicate predicates that should be merged
(
invested_in
vsinvested-in
vsbacks
) — prune during lint, don't let the vocabulary fork. - Contradictions: conflicting
relations
asserted about the same subject/predicate pair from different sources. Exempts a small, hand-maintained set of predicates that are inherently multi-valued (has_part
— a subject can genuinely have several parts at once), so the check only fires on predicates expected to hold one object. - Asymmetric relations: a
relations
entry with no corresponding reverse entry on the object concept — see the hard rule in §7.1; this is the check that catches a violation of it.
Lint detects; it does not itself rewrite files. A predicate-synonym finding, for example, gets fixed by a §7.4 Reindex-triggering bulk edit or a normal ingest-style edit, not by the lint pass itself.
A full rebuild of wiki.db
(§5), timeline.md
(§3), and
wiki-trigger-keywords.txt
(§3, §12 — every concept's title
and
tags
, deduped) from every concept file, independent of any single content change. Idempotent — running it twice with no intervening edits produces the same output. Needed for:
Embedding backend changes(§9.2): switching the embedding model (e.g. upgradingnomic-embed-text
to a newer version) requires re-embedding every concept, not just recently-touched ones.Recovery:wiki.db
is a disposable cache (§5); if it's lost or corrupted, Reindex is how it comes back, with no dependency on ingest history.Bulk migrations: executing a predicate rename or OKF-version upgrade that lint (§7.3) flagged, across every affected file, then rebuilding the index in one pass rather than file-by-file ingest edits.
Ingest, Query, and Lint have no feedback loop on retrieval quality —
only on data integrity. This matters specifically because context_for()
feeds an LLM automatically rather than a human reading the wiki directly, so a ranking regression can go unnoticed. A commenter on the original Karpathy gist measured this failure mode directly: a compiled wiki answered confidently even for questions from a domain the wiki had never covered, with no signal anywhere that the answer wasn't grounded — every query returned a full set of "relevant" hits regardless.
Maintain a fixed regression set of questions with expected seed concepts
and/or expected facts, split between in-bundle and deliberately
out-of-bundle questions. Periodically (or on every Reindex) run
context_for()
against the set and check: does the right concept still seed first (catches the exact-title/BM25/vector-ranking regressions from §10.3), does an out-of-bundle question return a clear "no confident match" rather than a plausible-looking wrong answer, and has trust-tier ranking (§6.4) held. This is the retrieval-quality analog of §7.3's data-integrity checks — both are lint in spirit, but this one needs a labeled question set to run against, not just a schema/frontmatter scan.
A recovery/refresh operation, distinct from Ingest (§7.1) and Reindex
(§7.4): discards concept files that were produced by ingesting a source
now sitting in references/
, and reconstructs them by re-running Ingest
against those same references — e.g. after fixing a bad extraction,
adopting a new type
convention (§8), or wanting a later ingest
process's quality applied to older pages. Unlike Reindex, this does
touch concept files, deliberately — the goal is regenerating them, not
just the derived wiki.db
/timeline.md
cache.
Scope, using the §4.2 provenance convention. A concept file is
ingest-derived — and therefore in scope for replacement — only if its
sources
frontmatter cites at least one references/...
-rooted
resource
. A concept file with no sources
block, or whose sources are all external URLs never mirrored locally, is out of scope by construction: that is the shape of a filed-back Concept page (§4.1, §7.2) built from discussion and general web search rather than a raw ingest of a saved document, and this operation must never touch it.
Procedure:
- Identify candidates by scanning every concept's
sources
for areferences/...
-rootedresource
— deterministic, run through the CLI (rebuild-candidates
, reads fromwiki.db
) rather than a raw shell scan, same rationale as the rest of §7's operations. - Confirm scope with the user before deleting anything — an
ingest-derived file may since have accumulated a
verified: {by: "human:..."}
stamp or hand edits worth preserving; a passage worth keeping that isn't grounded in a reference should be moved into its own Concept page first, since Concept pages stay out of scope here by design. - Delete the confirmed concept files.
references/
itself,log.md
, and every out-of-scope concept file are untouched. - Re-run Ingest (§7.1) over the same
references/
material, one source at a time — this is what actually reconstructs the concept files, now under the bundle's current conventions (§8). - Reindex (§7.4) once every deleted concept file has been recreated.
- Run Lint (§7.3), then manually confirm prose links inside preserved
Concept pages still resolve — Lint's broken-target check covers typed
relations
only, not markdown prose links, so a concept recreated under a slightly different path won't necessarily surface there. - Append one
log.md
entry for the whole rebuild, not one per re-ingested reference — this is a single deliberate operation, not N separate ingests.
Lint (extends §7.3). One check, using the same provenance scan as
step 1 above, run in the opposite direction: unreviewed-reference — a
file under references/
that no concept's sources[].resource
cites
anywhere in the bundle. This is a deterministic set difference
(references/
directory listing minus every cited resource
path), not
a freshness or mtime check — there is no ingestion-tracking state
anywhere in this design (§9.3: regenerate, never persist-and-drift), so
this is the closest thing to "what's new in references/
" the pipeline offers. It is a prompt to look, not a verdict: a reference can legitimately have zero citing concepts because it was reviewed and deliberately never promoted to a concept page — out of the bundle's domain, or mirrored for context only. Don't treat every finding as missed work; check the reference before assuming it needs ingesting.
A concept-file-level mechanism for recording that one whole concept file has been replaced by another — not a per-fact confidence score with continuous decay (that's out of scope; see the note at the end of this section). Distinct from two things already in this spec that look similar but do less:
stale_after
(§4.2, §6.4) marks a fact as expired bydate, with no record of what, if anything, replaced it.- Lint's
contradiction
check (§7.3)detectsthat two relations conflict; it does not resolve which one is current or link them.
Supersession closes that gap for the concept-file granularity: an explicit, dated, bidirectional pointer from an outdated concept file to the one that replaced it, with the old file preserved (not deleted) and excluded from default retrieval rather than silently left ambiguous.
When to use it. Only when the old concept file remains independently
meaningful as a historical record — a former officeholder, a superseded
org structure, a deprecated architecture decision. If the old content was
simply wrong (a bad extraction, a typo, a source that turned out to be
inaccurate), fix it in place via a normal Ingest edit (§7.1) — no
supersession chain. Rule of thumb: if "what did we believe before
<date>
?" is a legitimate question about this concept, use supersession; if it isn't, just correct the file.
Frontmatter fields (extension, like relations
— not OKF core):
status: superseded
superseded_by: /entities/bill-clinton-presidency.md
superseded_at: "1993-01-20" # optional; defaults to the successor's generated.at if absent
supersedes: [/entities/george-hw-bush-presidency.md]
superseded_by
: bundle-relative path (with.md
, like arelations
object
— §4.3) to the one concept file that replaces this one. A concept has at most onesuperseded_by
at a time.supersedes
: array — a successor may replace more than one prior concept file (e.g. a merge).- Setting
superseded_by
without also settingstatus: superseded
is invalid —status
is what §6.4's default filtering and §7.3's lint actually key off;superseded_by
supplies the destination.
Hard rule — assert both directions in the same edit, exactly the
discipline §7.1 requires for relations
: when A gets superseded_by: B
,
B gets supersedes: [..., A]
in the same pass. §7.7's
asymmetric-supersession
lint check below exists specifically to catch
a violation of this rule, mirroring §7.3's existing
asymmetric-relation
check.
Chains. Supersession may chain (A → B → C — e.g. George H. W. Bush
→ Bill Clinton → George W. Bush) — no automatic mid-chain
resolution is attempted; a query-time chain walk (below) always resolves
to the end of the chain. Bound the walk (e.g. 5 hops) to survive an
accidentally-introduced cycle without hanging — the supersession-cycle
lint check below is what's supposed to catch that case before it ever reaches query time.
Retrieval integration (extends §6.4 and §6.3):
- A concept file with
status: superseded
is excluded from seed matching by default (§6.1) — exact-title, BM25, and vector search must not surface it as a top-level seed. Instead, resolve transparently to the end of itssuperseded_by
chain, the concept-file analogue of howstale_after
already excludes a fact rather than presenting it as current. - This is exclude-from-seeding, not delete-from-graph: a superseded
concept remains a valid
relations
object
on other, still-active concepts (e.g.{ predicate: preceded, object: /entities/george-hw-bush-presidency.md }
) and must resolve normally, not be flagged asbroken-target
. - During bounded graph expansion (§6.3), a hop that lands on a superseded
concept is down-weighted, not excluded outright — a
SUPERSEDED_MULTIPLIER
(e.g.0.15
) alongside the existingtrust_multiplier
in the scoring formula, preserving "deprioritized, not deleted" rather than adding a second binary include/exclude gate next tostale_after
's. - An explicitly retrospective question ("what was true before
<date>
?", "who was President before Bill Clinton?") is exactly what a superseded concept still needs to answer — treat this as a new case for §6.2's intent detection (aHISTORICAL
bucket, or a modifier onWHEN
) that lifts the default seeding exclusion for that query only, rather than ever deleting the content. - Serialization (§6.5): a fact drawn from a superseded concept renders with an explicit marker so the answering LLM can never present it as current without the reader being able to tell:
- George H. W. Bush inaugurated President, as of 1989-01-20 (superseded by /entities/bill-clinton-presidency.md as of 1993-01-20)
Lint (extends §7.3) — two new checks, same "detects, does not rewrite" discipline as the rest of §7.3:
dangling-supersession:superseded_by
doesn't resolve to an existing concept file. Parallel tobroken-target
, scoped to this field.asymmetric-supersession:superseded_by
set on A with no matchingsupersedes
entry on the target, or vice versa. Parallel toasymmetric-relation
; catches a violation of the hard rule above.supersession-cycle: walkingsuperseded_by
from any concept revisits a concept already in the chain. Catches an accidental loop before it breaks the bounded query-time walk above.
** entities/index.md.** A superseded concept's entry is not removed — it's still real prior content, worth being able to find by browsing — but gets annotated with the successor path, generated the same non-hand-edited way as every other index entry (§3, §7.1 step 5).
Interaction with §7.6. Orthogonal: being status: superseded
does not exempt a concept file from Rebuilding from references — scope there is decided purely by the provenance convention (§4.2), independent of lifecycle status.
Non-goal, explicitly. This is a discrete, binary predecessor→successor pointer at the concept-file granularity — not the broader "continuous confidence score with time-decay and reinforcement" some wiki-pattern writeups propose at the individual-fact level. That's a materially different, heavier mechanism (per-fact provenance counting, a decay function, a reinforcement event); nothing here should be read as a first step toward it without a separate design pass.
Both type
(OKF-native) and predicate
(this spec's extension) are
open vocabularies, not fixed enums — consistent with OKF's own
"producers pick descriptive values, consumers tolerate unknown ones"
stance (§4.2). Concept
, plus the Entity-page starter set — Person
,
Organization
, Product
, Place
, Country
— are the type
values every bundle
should expect to use from day one (§4.1); use the specific value whenever
a page clearly fits one — a person's file gets type: Person
, not
generic Entity
— and reach for plain Entity
only as a fallback when
none of the starter set applies. Everything else (Playbook
, Metric
,
Attested Computation
, or a bundle-specific type) is genuinely open and
grows as needed. Document predicates as they're
coined during ingest in CLAUDE.md
/AGENTS.md
, prune synonyms during lint (§7.3). Do not pre-design an ontology before there's a bundle to observe.
Surfaced as open questions during design, since closed. Recorded with rationale rather than deleted, so the implementing agent knows why, not just what:
founded
/ founded_by
, invested_in
/ backed_by
: hard ingest rule, both directions asserted in the same pass (§7.1 step 3), enforced by the asymmetric-relations lint check (§7.3). Rejected alternative: computing inverses automatically from a predicate-inverse lookup table at query time, which would need only one direction hand-asserted. Chosen against, because it pushes complexity into the query layer (§6) for every read, in exchange for saving one extra frontmatter edit at ingest — the wrong trade given queries vastly outnumber ingests over the bundle's life.
Decided (§6.1): nomic-embed-text
via a local Ollama endpoint
(localhost:11434
) — the same convention Mnemon uses, evaluated and
kept even though Mnemon's storage engine was not (§5.1). During early
prototyping, a TF-IDF vector stood in for the mechanism
(RRF fusion, ranking) because the sandbox used to build the first
prototype couldn't reach Hugging Face Hub to pull model weights — an
environment constraint, not a design choice. TF-IDF was never carried
forward: it's corpus-relative (needs refitting as the bundle grows,
unlike static per-document embeddings) and, being pure term-frequency,
won't generalize to paraphrased questions the way dense embeddings
should. The finished reference implementation calls Ollama directly for
every embedding (ollama_embed
, wired at both index-build and query time) — there is no TF-IDF fallback in the production path.
wiki.db
and timeline.md
are regenerated on every ingest,
unconditionally (§7.1 step 4) — never hand-edited, never treated as a
second source of truth. This is the property the rest of the design
leans on (§1, §3): concept frontmatter is authoritative, everything else
is a rebuildable cache. Dropping router.md
(§3) removed the one file in the bundle that didn't follow this rule.
These were hit while prototyping this exact design and cost real debugging time — treat as hard rules, not suggestions.
YAML 1.1 parses bare on
/ off
/ yes
/ no
as booleans (the
"Norway problem"). on: 2009-07-06
silently becomes key true
. Use
date
(§4.2).
An unquoted 2026-08-09
-style scalar risks being auto-parsed into a
native date object under some YAML parsers' default schema, and naive
downstream serialization can then produce a broken, non-ISO string
instead of clean ISO text — silently breaking OKF's own ISO-8601
requirement for generated.at
(§5.2). The reference implementation's
frontmatter reader (lib/frontmatter.ts
) uses the yaml
npm package's YAML 1.2 core schema, which does not have this failure mode — verified directly, not assumed. Quote every date/datetime frontmatter value as a string regardless of which parser is in use anyway — it's the portable convention for any other tooling (a YAML 1.1 parser, an editor plugin) that might read this bundle, and it costs nothing.
Validated directly: on a 4-concept test bundle, both FTS5 BM25 and a TF-IDF vector both ranked the wrong entity first for "Who is Marc Andreessen?" — because a related concept's page happened to mention "Marc Andreessen" in its own body text. Term-frequency and even embedding similarity alone cannot reliably distinguish "this page is about X" from "this page merely references X" on a cross-linked wiki, which is the normal case, not an edge case. §6.1's exact-title signal exists specifically to fix this and must not be dropped as a "redundant" simplification later.
Also validated directly: a naive substring check for this signal is
wrong, not just imprecise — a concept titled "AI" seeded on any question
containing "explain" (expl-AI-n
), and "Go" seeded on "ago". Match at a
whole-word/phrase boundary (Unicode letter/number boundaries, not ASCII
\b
, so this also holds for non-English titles), never plain
.includes()
.
Validated directly against a 66-concept real-world bundle (Northlight, Aug
2026), not a synthetic one: "How do I fix a flat bicycle tire?" was
gated as confident because FTS5 MATCH
returned a nonempty result set — the original rule — and its top-ranked bm25() result outscored a genuinely relevant question's top-ranked result by raw score magnitude. The match was a coincidence: "flat" hit the target concept's "apartment" sense inside an unrelated housing-policy essay, "fix" is a generic word, and neither "bicycle" nor "tire" appeared anywhere in that document at all — 2 of 4 query words genuinely present, covering half the question, yet it out-scored a real match by bm25()'s raw number.
The mechanism: bm25()'s IDF term rewards rarity. A document containing one coincidentally rare query word scores higher than a document covering several common ones, regardless of how much of the question it actually accounts for — this is not a tunable edge case, it is what IDF does by design, so no threshold on the raw score fixes it.
Measured across this same bundle: every genuinely relevant question
tested had 100% of its extracted content words present in its
correct top-ranked match; every deliberately off-domain natural-English
question tested had ≤50%. Gate BM25 confidence on word coverage of
the top match — the fraction of the question's content words that
actually appear in it, checked independently of bm25()'s score — not on
score magnitude and not on mere nonemptiness. >0.5
(strictly more than
half) separates the two clusters with margin on the cases tested; retune
per-bundle via evaluate.json
(§7.5) the same way the vector threshold is retuned. This does not change which candidates get ranked by BM25 — only whether a BM25-only match is trusted enough to answer at all.
Everything in §1–§9 targets the scale this pattern is meant for — hundreds to low thousands of concepts (§2's non-goal: this is deliberately not a graph database). Nothing in this section should be built now. It's recorded so the trigger conditions are explicit if the bundle ever outgrows that range, rather than discovered as a production incident.
Document count alone is not the right scaling variable to watch — it's a proxy that tracks some components and not others.
| Component | Degrades along | Not along | Rough threshold | Fix |
|---|---|---|---|---|
| FTS5 keyword search | — | doc count | Scales to millions; no revisit needed. | — |
relations JSON column |
||||
Edge count (concepts × avg relations/concept) — JSON1 has no index, so json_each is scanned per query. |
||||
| Doc count in isolation | Tens of thousands of edges, once §6.3's multi-hop expansion starts self-joining over an unindexed view. | §11.2 | ||
embeddings table |
||||
| Vector row count — the reference implementation's cosine-similarity scorer is a brute-force in-process loop over every row today. | Doc count in the abstract, though it tracks 1:1 (one vector per concept) | Low thousands of vectors, where per-query linear scan starts adding real latency. | §11.3 | |
| Graph traversal | Edge density and required traversal depth. | Doc count | Only if requirements shift to unbounded-depth or whole-graph algorithms — not reached by §6.3's bounded 1–2 hop retrieval. | §11.4 |
When: multi-hop queries (§6.3) measurably slow down doing self-joins
over json_each
— in practice, tens of thousands of edges (a few thousand concepts at typical relation density).
What: a build step folded into Reindex (§7.4) that flattens every
concept's relations
array into rows of a normalized, indexed table:
create table edges (
subject_path text not null,
predicate text not null,
object_path text, -- null if the object is a literal, not a concept
object_literal text, -- populated when the object is not a concept path
date text,
source_id text
);
create index idx_edges_subject on edges(subject_path);
create index idx_edges_object on edges(object_path);
Concept frontmatter's relations
field (§4.3) is unchanged — this is a
query-layer materialization, not a file-format change. context_for()
's
expansion query swaps json_each
/json_extract
calls for plain indexed
joins against edges
; same logic, faster execution.
When: cosine-similarity retrieval (§6.1) adds noticeable per-query latency — roughly low thousands of concepts, since the current scorer is an unindexed linear scan over every embedding row, run in-process on each query.
What: replace the plain embeddings(path, vector)
table with a
sqlite-vec
vec0
virtual table
and query via SQL KNN instead of an in-process loop. It's real and actively
maintained — a single dependency-free C extension — but still pre-v1
(0.1.10-alpha at time of writing): pin a version and expect this
migration to need revisiting before it hits 1.0. Embedding generation
itself (Ollama + nomic-embed-text
, §9.2) is unaffected; only storage and query of the resulting vectors change.
Not triggered by concept count. Triggered by either:
- Edge density growing far past a sparse wiki-style graph, dense enough that indexed-table joins (§11.2) stop being fast even bounded to 1–2 hops.
- A requirement change: if
context_for()
ever needs unbounded-depth traversal or whole-graph algorithms (shortest path across the entire bundle, centrality, community detection) rather than the bounded, budget-constrained expansion specified in §6.3 — a different query shape that SQL joins express poorly regardless of indexing, where Neo4j/Cypher-class engines earn their keep.
If neither condition holds, §11.2's indexed edge table keeps SQLite adequate indefinitely. §6's retrieval design was built to never need more than a bounded hop count in the first place, which is exactly the case a relational engine handles well.
§7.2 assumes the calling agent judges when a question is in-bundle and
invokes context_for()
deliberately. That judgment can silently fail to
fire in practice — a full stretch of clearly in-domain conversation can
pass with zero automatic queries, because nothing forces the check. This
section specs an optional mitigation, external to this bundle's own CLI:
a harness-level hook (Claude Code's settings.json
hooks —
UserPromptSubmit
) that nudges the agent to query when the incoming
message plausibly touches the bundle. Not installed by
/add-llm-okf-graph-wiki-vb
by default — a per-group opt-in, since it
adds a script and a settings.json
entry the operator should choose to add, not something every bundle needs.
The hook's only job is a cheap, deterministic yes/no on whether the
incoming message is in-bundle. It must not run full context_for()
(§6)
itself — that pipeline (FTS + vector embedding + bounded graph expansion)
is comparatively expensive, and would be paid on every turn regardless of
topic if the hook triggered it directly, including the majority of turns
that have nothing to do with the bundle's domain. On a match, the hook
should only emit a short nudge string reminding the agent to consider
running query
— the agent still pays for context_for()
only if and when it actually acts on the nudge.
wiki-trigger-keywords.txt
(§3, §7.4) is the input: every concept's
title
and tags
, deduped and regenerated on every Reindex — same
"cache, never hand-edited" treatment as wiki.db
/timeline.md
(§9.3). Deliberately narrower than a full free-text scan: proper nouns and specific tags have a much lower false-positive rate than generic topic words matched against arbitrary prose (validated directly — see §12.4).
A plain shell script registered as an additional entry under the
harness's hooks.UserPromptSubmit
array (alongside any hook already registered there, not replacing it):
- Read the harness's stdin JSON payload, extract the prompt text
(
.prompt
, falling back to other field names in case the harness's schema differs — fail silent-open, i.e. no nudge, on a schema mismatch rather than erroring). - Loop
grep -qiFw
(fixed-string, whole-word, case-insensitive) over each non-comment, non-blank line ofwiki-trigger-keywords.txt
. - On any match, print one line naming the matched concept(s) and
suggesting the
query
op. Exit 0 either way — this hook must never block the turn.
The keywords file lives inside the bundle (§3); the hook script itself
lives in the group's own workspace (/workspace/agent/hooks/
by
convention), not under the shared, read-only skill directories
(/app/skills/...
) — those are mounted read-only to the agent and can't host a script that needs per-group editing.
Concretely, the registration:
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "<existing-hook-if-any>" }] },
{
"hooks": [
{
"type": "command",
"command": "/workspace/agent/hooks/wiki-trigger.sh",
"timeout": 5
}
]
}
]
}
}
A short timeout
matters: this hook must never meaningfully delay a turn — if it hangs, the harness should cut it off well before it becomes noticeable.
Three were hit building this pattern, worth carrying forward as hard rules rather than rediscovering:
Whole-word matching, not substring. A trigger list sourced fromtags
(§7.4) routinely includes short slugs (ai
,ft
, and similar). Plain substringgrep -F
matches those inside unrelated words (ai
inside "again,"ft
inside "often"), producing near-constant false positives. Use-w
(grep -qiFw
).Harness-injected wrapper text can collide with keywords. Some harnesses prepend boilerplate to every prompt (e.g. a context/metadata tag carrying the install timezone) before the hook ever sees it. A keyword that happens to match a token inside that boilerplate (a country or city name embedded in a timezone string, for instance) fires on every single turn regardless of what the user actually typed. Strip known wrapper markup from the extracted prompt text before matching.The reference container doesn't install it by default — a script that assumes it's onjq
is not guaranteed present in the agent image.PATH
fails silently (empty prompt extracted, hook effectively always a no-op) rather than erroring loudly. Either addjq
via the bundle's owninstall_packages
self-mod path (§ container skill's self-mod docs) before relying on it, or parse the stdin JSON with something already guaranteed available (e.g. a one-linenode -e
fallback) so the hook degrades to "no nudge" only on a genuine schema mismatch, not on a missing binary.
The gate itself is free relative to context_for()
— a loop of grep
calls over a short keyword list, a few milliseconds, no LLM or embedding call. Token cost only appears on an actual match, and even then is just the fixed reminder string (no retrieved facts injected).
A finished TypeScript implementation (runs on Bun) demonstrates every mechanism above end-to-end, one file per concern:
build-index.ts
— reindex: walksentities/
/computations/
, rebuildsconcepts
,concepts_fts
(FTS5), andembeddings
from scratch (§5, §7.1 step 4).context-for.ts
—context_for(question)
: hybrid seed matching (exact title + FTS5 + vector cosine, fused), bounded graph expansion, trust- and budget-aware filtering (§6).lib/ollama-embed.ts
— realnomic-embed-text
embeddings via a local Ollama endpoint, plus the cosine-similarity scorer (§9.2). No TF-IDF or other placeholder in this path.lib/rrf.ts
— reciprocal rank fusion across the three seed signals.lib/graph-score.ts
— bounded multi-hop expansion and predicate-aware scoring (§6.2–§6.3).lib/frontmatter.ts
— the YAML 1.2 core-schema frontmatter reader/writer (see §10.2's note on the YAML 1.1 date-coercion pitfall it sidesteps).lint.ts
,evaluate.ts
,timeline.ts
,keywords.ts
— the remaining CLI operations (§7.3–§7.4);keywords.ts
regenerateswiki-trigger-keywords.txt
(§12's optional hook input) from every concept'stitle
andtags
, skipping non-string tag values (frontmatter documentstags
as strings, but a bare unquoted number in YAML — e.g. a year written as2009
instead of"2009"
— parses as a JSON number; caught in practice, not hypothetical, and also the right call independent of the parse-safety angle, since a bare number is a high-false-positive trigger keyword regardless of type).
Each lib/
module has unit tests alongside it. Treat this as a direct, runnable reference for shape and query patterns in any language/runtime — nothing in it is a placeholder standing in for a not-yet-built mechanism.