
Tense
- Updated June 11, 2026
- Zacplischka/tense
tense is a Claude Code skill in the AI & Agent Building category. Temporal memory for coding agents — an MCP server that knows which version of a fact is true, and what was true as of any past date.
Key points
- tense
- AI & Agent Building
- AI-coding skill
Tense by the numbers
- Data as of Jul 7, 2026 (Skillselion catalog sync)
/plugin marketplace add Zacplischka/tense/plugin install tense@tenseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Last updated | June 11, 2026 |
|---|---|
| Repository | Zacplischka/tense ↗ |
What it does
Temporal memory for coding agents — an MCP server that knows which version of a fact is true, and what was true as of any past date.
README.md
Tense
Temporal memory for AI agents — knows which version is true.
Tense is an MCP server that gives an AI agent a memory which tracks not just what it was told, but when each thing was true. It stores knowledge as a hand-built bi-temporal graph on Postgres and answers which version is current — or what was true as of any past date — something a plain vector store cannot.
A vector store indexes "Zach reports to Alice" and "Zach reports to Bob" with near-identical embeddings and happily returns both. It has no principled way to know the second superseded the first, when that happened, or who Zach reported to last quarter. Tense does.
Short on time? docs/CASE-STUDY.md is the 2-minute
narrative — the problem, the bet, the decision I had to defend, and how it's proven
— with every claim linked to the code, eval, or ADR that backs it.
Install in your agent
Tense is just an MCP stdio server, so any coding agent with MCP support can use it.
For Claude Code, set TENSE_DATABASE_URL and OPENROUTER_API_KEY, then add the
server in one line:
claude mcp add tense -e TENSE_DATABASE_URL="$TENSE_DATABASE_URL" -e OPENROUTER_API_KEY="$OPENROUTER_API_KEY" -- npx -y github:Zacplischka/tense
For Cursor, Windsurf, Goose, Cline, Continue, or any other MCP client, run one line
and paste the generated mcpServers.tense block:
npx -y github:Zacplischka/tense init
Claude plugin route: this repo also carries a Claude Code plugin manifest. Add the repo as a marketplace, then install the plugin:
claude plugin marketplace add github:Zacplischka/tense
claude plugin install tense@tense
The result
On point-in-time questions whose answer changed over time — the one place a recency-sorted vector store cannot win — measured against a fair vector baseline (same Sources, same embeddings, recency tiebreak allowed):
| Metric (10-scenario gold set, live extraction) | Tense | Fair vector baseline |
|---|---|---|
| Temporal-QA — point-in-time (5 questions) | 100% | 0% |
| Temporal-QA — all questions (11) | 100% | 55% |
| Supersession precision / recall | 100% / 100% | — |
| False-supersession rate | 0% | — |
| Extraction triple-F1 / valid_at accuracy | 100% / 100% | — |
The point-in-time row is the headline: 5 questions whose answer changed over time, where a recency-sorted vector store is structurally wrong. The baseline still gets 6/11 overall (the "now" questions) — it loses precisely on the 5 it cannot model. The rows below it are the supporting evidence (extraction and supersession quality), measured over all 10 scenarios.
Reproduce with pnpm eval — it prints those same denominators (all 11,
point-in-time (5)), so every number above reconciles against a live run. No API
key needed: pnpm eval:offline reproduces the same 5/5 point-in-time win
(100% vs 0%) with no spend, byte-identical every run. The fairness of the
baseline, the offline path, and the gold-set size are detailed below.
Reproduce it — and why the baseline is fair, not a strawman
Run it live. pnpm eval prints those same denominators (all 11,
point-in-time (5)), so every number above reconciles against a live run. Want
the numbers without running anything? eval/RESULTS.md is a
committed, byte-identical snapshot with a per-question breakdown — every
point-in-time question, its as_of, and gold vs Tense vs baseline, so you can see
which answers the baseline gets wrong and why (it returns the most-recent value).
No API key? pnpm eval:offline reproduces the headline row with no spend —
stub extraction plus hashed bag-of-words embeddings, Postgres only — printing the
same 5/5 point-in-time win (100% vs 0%), byte-identical every run.
That chart is regenerated by the same pnpm eval:report run that writes
eval/RESULTS.md, and a drift-guard test
(test/eval-results-snapshot.integration.test.ts)
fails if either falls out of sync with a live offline run — so the bars cannot
quietly drift from the numbers. It runs 9
of the 10 scenarios (skipping the lone LLM-judged cross-Predicate case, which
needs a model) over the same 11 QA questions — none of which touch the skipped
scenario, so Tense stays 100%. The baseline dips to 45.5% (from 55% live) not
because of that scenario but because the hashed embeddings are weaker: on the one
tied-valid_at "now" question (Tess), where both Facts share a valid_at so
recency can't break the tie, the weaker embedding ranks the wrong Fact first — a
question real embeddings get right. The headline point-in-time row is identical
either way.
The baseline is the strongest naive version, not a strawman. Its candidate pool
includes the superseded Fact, so the historically-correct answer is right in
front of it — it just has no bi-temporal model, so for a past as_of its recency
tiebreak ranks the most-recent answer first and is wrong. That its miss is an
honest ranking choice (not blindness to the old Fact) is regression-tested in
test/eval-baseline-fairness.integration.test.ts.
Small, but adversarial by design. The gold set is small — expansion to ~30
scenarios is tracked in eval/gold.ts — but it is built to
break Tense, not flatter it: every scenario carries a labelled edge case
(out-of-order ingestion, tied valid_at, null valid_at, and "still-true" Facts
that must not be superseded). eval/RESULTS.md renders
that coverage as a matrix straight from the gold-set tags, so a 100% reads as
"passed the hard cases," not "only tested the easy ones."

See the win the way the agent does
The eval scores the answers; pnpm demo:agent shows why — the literal tool
result a model receives. For one question whose answer changed
(who does Zach report to? asked as_of=2024-03-01), it prints what a naive
vector memory hands the agent next to what Tense hands it, through the same
recall / baseline code paths the eval and the MCP server use — no key, no
network, byte-stable:
✗ Naive vector memory — top-k cosine, recency tiebreak, no as_of
candidate Facts retrieved: Alice (valid 2024-01-01), Bob (valid 2024-06-01)
→ hands the agent: Bob [WRONG — returns the *current* value, blind to as_of]
✓ Tense — temporal filter in SQL, then hybrid rank (the recall() the MCP server serves)
{ "object": "Alice", "validAt": "2024-01-01", "invalidAt": "2024-06-01",
"current": false, "source": "org-2024q1" }
→ hands the agent: Alice [RIGHT — who was Current then, + validity interval + Source to cite]
Both Facts are in the vector store's candidate pool (it isn't blind to history) —
it just has no as_of to choose between them, so recency wins and the agent is
told the wrong thing with no signal that it's wrong. That gap is the product. The
"now" control in the same run shows both memories agree (the baseline is fair, not
a strawman), and the comparison is locked by
test/demo-agent.integration.test.ts.
Fast enough for the agent loop
Accuracy is half the question for memory that sits in an agent's hot path; latency
is the other half. pnpm bench seeds a deterministic synthetic org graph and times
the real read path — temporal filter in SQL → pgvector cosine + full-text → RRF,
the same recall() the MCP server serves:
| Recall over a 734-Current-Fact graph (260 superseded Facts the SQL filter must exclude) | Latency |
|---|---|
| p50 | ~4.5 ms |
| p95 | ~6 ms |
| p99 | ~7 ms |
Measured offline — no API key, hashed embeddings, Postgres only — on an Apple M4
(200 recalls mixing Current, point-in-time as_of, and lives-in queries). The
corpus shape is deterministic (734 Current Facts every run); the latency is
machine-dependent, so reproduce it on your own hardware:
pnpm bench # 400-subject corpus, 200 timed recalls
pnpm bench 1000 500 # <subjects> <iterations>
Point-in-time recall stays low-single-digit milliseconds with hundreds of superseded Facts in the graph for the temporal filter to exclude — comfortably inside an agent's tool-call budget, not a batch job.
pnpm bench breaks those percentiles out by query class, so the obvious
skeptical question — does the bi-temporal as_of filter cost more than a plain
Current recall? — is answered against the same reports-to query shape with and
without as_of. It doesn't: excluding the superseded Facts in SQL narrows the
candidate set rather than taxing it, so point-in-time recall lands at or below the
Current latency, not above it. The temporal model is not a latency tax.
How it works
- Fact — a directed, typed relationship
subject → predicate → object(Zach → reports-to → Alice), the only thing that can be superseded. Every Fact is bi-temporal: valid time (valid_at/invalid_at— when it was true in the world) and transaction time (created_at/expired_at— when the system held it as Current). - Current =
expired_at IS NULL, backed by a partial index — the single definition every reader (recall, viewer) uses. - Supersession closes the prior Fact (never deletes it) and opens the new one in one transaction. Two trigger paths share one valid-time direction rule: deterministic cardinality (a single-valued Predicate gets a new value — the demo path) and LLM-judged contradiction (cross-Predicate "works-at" vs "left" — the general path).
- Point-in-time recall filters on valid time
(
valid_at <= T AND (invalid_at IS NULL OR invalid_at > T)), so the agent can ask both "who does Zach report to now?" and "who did he report to last quarter?" — each answer cites the Source it came from.
That model isn't prose layered over a generic store — it is the table. The four
time columns and the partial index that defines Current are the schema itself
(migrations/0001_init.sql):
CREATE TABLE facts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
subject_id uuid NOT NULL REFERENCES entities (id),
predicate text NOT NULL,
object_id uuid NOT NULL REFERENCES entities (id),
source_id uuid NOT NULL REFERENCES sources (id),
valid_at timestamptz, -- valid time: when it was true IN THE WORLD
invalid_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), -- transaction time: when the
expired_at timestamptz -- SYSTEM held it Current
);
-- "Which version is true now" is one partial index, not application logic.
-- Every Current read (recall, the viewer, the supersession lookup) goes through it.
CREATE INDEX idx_facts_current ON facts (subject_id, predicate)
WHERE expired_at IS NULL;
A Fact is Current iff expired_at IS NULL — supersession sets that column and
opens a new row in the same transaction, so the prior value is closed, never
deleted, and still queryable by as_of. The two time pairs are independent on
purpose: that is what lets one row answer both what was true then and when the
system learned it.
"Bi-temporal" is the whole game, so it's worth seeing rather than just reading. Every Fact is a region on two independent axes — valid time (when it was true) and transaction time (when the system knew it). One reads left-to-right, the other bottom-to-top; conflating them is the signature mistake the model is built to avoid:
recall(as_of=…) filters the valid-time axis (what was true then);
history and changes walk the transaction-time
axis via learnedAt / retiredAt (when the system learned or retired a Fact).
Both axes are first-class, and a superseded Fact is closed on both — never deleted.
See CONTEXT.md for the full domain glossary.
Architecture
One write path, one read path, one Postgres. Ingestion extracts Facts, resolves
their Entities, and lets decideFact route each one — reaffirm, supersede, or
insert — before an atomic write; recall applies the temporal filter in SQL first
(filter-then-fuse), so superseded Facts never enter the ranking that RRF then fuses.
flowchart TB
subgraph write["remember(text, source) — write path"]
direction TB
T["Source text"] --> EX["Extractor<br/>(LLM · stub double)"]
EX -->|"Entities + Facts<br/>with valid_at"| ER["Entity Resolver<br/>exact → pg_trgm fuzzy"]
ER --> DEC{"decideFact<br/>per Fact"}
DEC -->|"already Current"| RA["Reaffirm<br/>add Source provenance"]
DEC -->|"single-valued got<br/>a new object"| SUP["Supersession<br/>close prior + open new<br/>one transaction"]
DEC -->|"genuinely new"| NEW["Insert Fact"]
SUP -.->|"best-effort"| EMB["Embed (pgvector)"]
NEW -.->|"best-effort"| EMB
SUP -.->|"optional LLM judge"| CON["Contradiction<br/>cross-Predicate"]
end
subgraph pg["One Postgres — relational graph + vectors + fuzzy match"]
direction TB
ENT[("entities · sources<br/>fact_sources provenance")]
FACTS[("facts — bi-temporal<br/>valid_at / invalid_at<br/>created_at / expired_at<br/>Current = expired_at IS NULL")]
VEC[("pgvector embeddings · pg_trgm")]
end
subgraph read["recall(query, as_of?) — read path"]
direction TB
Q["Query"] --> TF{"Temporal filter in SQL<br/>Current · or valid-at as_of"}
TF --> HYB["Hybrid rank<br/>pgvector cosine + full-text → RRF"]
HYB --> OUT["Ranked Facts<br/>+ Source + validity interval"]
end
RA --> ENT
NEW --> FACTS
SUP --> FACTS
CON --> FACTS
EMB --> VEC
FACTS --> TF
VEC --> HYB
classDef store fill:#0d3b66,stroke:#0a2a4a,color:#fff
classDef decide fill:#f4d35e,stroke:#b8962e,color:#1a1a1a
class ENT,FACTS,VEC store
class DEC,TF decide
The supersession write is one transaction (close prior + open new) and embedding
is best-effort — a down embedding provider degrades semantic recall but never
fails a write. The decision diamonds (decideFact, Temporal filter) map
directly to src/supersession/decide.ts and
src/retrieval/recall.ts. Why the temporal filter
runs in SQL before the two rankers fuse — and why fusion is RRF, not a tuned
weighted blend — is ADR 0008.
Why Postgres — not a graph database, not Graphiti
The project's thesis is building a temporal knowledge platform from first principles, so the bi-temporal supersession engine is built in-house:
- No separate graph database. At demo scale (hundreds–thousands of Facts),
recursive CTEs cover any multi-hop traversal, so a graph DB earns nothing while
adding a second store to operate — "overlapping stores" is the first thing a
reviewer attacks. One Postgres holds the relational graph and the embeddings
(
pgvector) and fuzzy entity resolution (pg_trgm). - No Graphiti. Graphiti is excellent, but delegating the differentiator to a library weakens the "I built it" story and forces a Python sidecar, conflicting with the TypeScript stack. Tense keeps Graphiti's best ideas (LLM-nominate → temporal-gate contradiction) and adds a deterministic cardinality path on top, so the filmed demo is reproducible: deterministic where it must be, Graphiti-grade where it counts.
Full reasoning: ADR 0001, ADR 0002, ADR 0003.
Decision records
The non-obvious calls — and the alternatives rejected — are written down. Full
index with the crux of each: docs/adr/.
| ADR | Decision |
|---|---|
| 0001 | Hand-built temporal graph on Postgres — no Graphiti, no graph DB |
| 0002 | Bi-temporal Facts with two-path supersession (cardinality + LLM-judged) |
| 0003 | DSPy as an offline prompt optimizer; ship static compiled prompts |
| 0004 | Viewer hosts the ingestion write-path (one process, one remember seam) |
| 0005 | Reaffirmation: a Fact may cite multiple Sources instead of duplicating |
| 0006 | Read-only introspection + dry-run preview surface |
| 0007 | Ingest assumes a single writer — concurrency model documented up front |
| 0008 | Hybrid recall — filter-then-fuse, two rankers fused by RRF (no score normalization) |
Quickstart
Tense needs Postgres/pgvector. The local developer setup below starts the same container image CI uses and runs migrations.
Local development
Requires Docker and Node ≥ 20 (with pnpm).
pnpm install
pnpm db:setup # start Postgres (pgvector + pg_trgm) and run migrations
cp .env.example .env # add your OPENROUTER_API_KEY for extraction/recall
pnpm test # logic + integration tests against real Postgres
pnpm build # compile to dist/
pnpm check # the full verify gate: typecheck · lint · build · test, plus the viewer (typecheck · build)
CI runs that exact pnpm check gate on every push and
PR, against a real pgvector Postgres service — the same image as
docker-compose.yml, so the integration tests run on the real schema, not a mock.
Tested against a real database, not mocks
pnpm test runs 237 tests across 47 spec files (~15s locally, all green;
3 environment-gated tests skip without their keys). The number
isn't the point — what they run against is: 28 of those files are integration
tests that exercise a real pgvector Postgres, created and migrated per run by
test/globalSetup.ts, not an in-memory shim or a mocked
client. The atomic supersession write (close prior + open new in one transaction),
the point-in-time SQL filter, the RRF rank, and the MCP isError contract are all
asserted end-to-end on the same schema the server serves — and on the same
Postgres image CI runs, so a green local run and a green CI
run mean the same thing. The other 18 are pure-logic unit tests with no I/O — the
supersession resolver and its invariants,
RRF fusion, and the predicate registry — fast to run and exhaustive on the rules
that decide whether a Fact supersedes, reaffirms, or inserts.
Connect it to an MCP client (Claude Code / Cursor / any coding agent)
Tense speaks MCP over stdio. tense init prints this block with the installed
absolute path already filled in. Point your client at the built server:
{
"mcpServers": {
"tense": {
"command": "node",
"args": ["/absolute/path/to/tense/dist/server.js"],
"env": {
"TENSE_DATABASE_URL": "postgres://postgres:tense@localhost:5432/tense",
"OPENROUTER_API_KEY": "sk-or-...",
"TENSE_EXTRACTION_MODEL": "openai/gpt-4o-mini",
"TENSE_EMBEDDING_MODEL": "openai/text-embedding-3-small"
}
}
}
}
Or drive it directly with the MCP Inspector. Launching dist/server.js builds the
real ingest pipeline, which fails fast if OPENROUTER_API_KEY is unset — so
export it (and TENSE_DATABASE_URL) before any command below, including the
read-only tools/list, which otherwise dies at startup before it can answer. Want
a keyless tour instead? pnpm eval:offline and
pnpm seed:demo need no key at all.
export TENSE_DATABASE_URL=postgres://postgres:tense@localhost:5432/tense
export OPENROUTER_API_KEY=sk-or-...
npx @modelcontextprotocol/inspector --cli node dist/server.js --method tools/list
npx @modelcontextprotocol/inspector --cli node dist/server.js \
--method tools/call --tool-name remember \
--tool-arg 'text=On 2024-01-01, Zach started reporting to Alice.'
npx @modelcontextprotocol/inspector --cli node dist/server.js \
--method tools/call --tool-name recall \
--tool-arg 'query=who does Zach report to' --tool-arg 'as_of=2024-03-01'
MCP tools
| Tool | Signature | Returns |
|---|---|---|
remember |
(text, source?) |
Facts created / superseded / reaffirmed after extraction + supersession (each superseded Fact tagged reason: cardinality / contradiction), plus how each name resolved (entitiesResolved: new / exact / fuzzy) |
preview |
(text) |
Dry-run of remember — what it would create / supersede / reaffirm (and how names resolve), writing nothing |
recall |
(query, as_of?, predicate?, limit?, min_reinforced?, include_sources?, include_source_text?) |
Ranked Facts — Current by default, or valid-at-as_of; optionally scoped to a Predicate, capped, or filtered to Facts confirmed by ≥min_reinforced Sources — each with Source, validity interval, reinforcedBy, and learnedAt (transaction time). include_sources adds citedBy (which Sources assert each Fact); include_source_text: false drops full Source text for a token-lean result |
history |
(entity, predicate?) |
The full Supersession chain for a subject, chronological — each Fact with its valid interval, learnedAt, and retiredAt (when the system closed it) |
changes |
(since, limit?) |
Transaction-time change feed — Facts learned or retired since a date (incremental sync), each with learnedAt/retiredAt |
stats |
() |
A read-only snapshot: Entity/Source counts, Facts split Current vs superseded, and a per-Predicate breakdown — each with its cardinality (single supersedes / multi accumulates) |
entities |
(query?, limit?) |
List/search Entities, each with its Current-Fact count (degree) and the distinct predicates touching it (relationship shape), most-connected first |
sources |
(limit?) |
List ingested Sources newest-first — label, preview, ingest time, and how many Facts cite each |
Worked example
The org-change story end to end. (id/sourceId are UUIDs, abbreviated here,
and learnedAt is a wall-clock ingest time — yours will differ; every other
value is exactly what the tools return.)
1. remember the first Source — text: "[2024-01-01] Zach reports to Alice.", source: "org-2024q1":
{ "sourceId": "0d67…", "factsReaffirmed": [],
"factsCreated": [{ "id": "c08d…", "subject": "Zach", "predicate": "reports-to", "object": "Alice" }],
"factsSuperseded": [],
"entitiesResolved": [{ "input": "Zach", "resolvedTo": "Zach", "reason": "new" },
{ "input": "Alice", "resolvedTo": "Alice", "reason": "new" }] }
entitiesResolved shows how each name was placed — new, exact, or fuzzy
(a variant merged into an existing Entity, with its similarity) — so a wrong
merge is visible rather than silent.
2. remember the change — text: "[2024-06-01] Zach reports to Bob.", source: "org-2024q2".
reports-to is single-valued, so the Alice Fact is superseded (closed, not deleted):
{ "sourceId": "7deb…", "factsReaffirmed": [],
"factsCreated": [{ "id": "3d7b…", "subject": "Zach", "predicate": "reports-to", "object": "Bob" }],
"factsSuperseded": [{ "id": "c08d…", "subject": "Zach", "predicate": "reports-to", "object": "Alice", "reason": "cardinality" }],
"entitiesResolved": [{ "input": "Zach", "resolvedTo": "Zach", "reason": "exact" },
{ "input": "Bob", "resolvedTo": "Bob", "reason": "new" }] }
Each superseded Fact carries a reason for why it closed — cardinality
(a single-valued Predicate got a new object, as here) or contradiction (an
LLM-judged cross-Predicate conflict, e.g. "works-at" retired by "left"), which
closes a Fact under a different predicate than the one just stated.
3. recall now — query: "Zach reports to" returns only the Current Fact, with its Source and open validity interval:
[{ "id": "3d7b…", "subject": "Zach", "predicate": "reports-to", "object": "Bob",
"validAt": "2024-06-01T00:00:00.000Z", "invalidAt": null, "current": true,
"learnedAt": "2025-09-12T18:04:53.217Z",
"source": { "id": "7deb…", "label": "org-2024q2", "text": "[2024-06-01] Zach reports to Bob." },
"reinforcedBy": 1 }]
reinforcedBy is how many distinct Sources assert the Fact (origin + any
Reaffirmations); a higher count means a more-confirmed Fact.
learnedAt is the transaction time — when the system learned the Fact — the
other bi-temporal axis from validAt/invalidAt (when it was true): Zach has
reported to Bob since 2024-06-01, but the system only learned it at ingest.
current says whether a Fact has been retired; learnedAt says when it
entered memory.
4. recall point-in-time — same query with as_of: "2024-03-01" returns who was Current then — Alice — closed off at the moment Bob took over:
[{ "id": "c08d…", "subject": "Zach", "predicate": "reports-to", "object": "Alice",
"validAt": "2024-01-01T00:00:00.000Z", "invalidAt": "2024-06-01T00:00:00.000Z", "current": false,
"learnedAt": "2025-09-12T18:04:51.880Z",
"source": { "id": "0d67…", "label": "org-2024q1", "text": "[2024-01-01] Zach reports to Alice." },
"reinforcedBy": 1 }]
5. history — entity: "Zach", predicate: "reports-to" returns the whole chain chronologically: the closed Alice Fact, then the Current Bob Fact — the recall row shape plus retiredAt (the transaction time each Fact was closed; null for the Current one), so the chain shows both when each was true and when it was retired.
6. stats — the graph at a glance:
{ "entities": 3, "sources": 2,
"facts": { "total": 2, "current": 1, "superseded": 1 },
"predicates": [{ "predicate": "reports-to", "current": 1, "total": 2, "cardinality": "single" }] }
Each Predicate carries its cardinality — single (a new value supersedes the
prior, e.g. reports-to) or multi (values accumulate, e.g. knows) — the rule
that governs whether ingesting supersedes or adds, so an agent can predict it.
7. preview — a dry-run of step 2 before committing it. text: "[2024-09-01] Zach reports to Dana." reports what remember would do — create the Dana Fact and supersede Bob — writing nothing:
{ "factsToCreate": [{ "subject": "Zach", "predicate": "reports-to", "object": "Dana" }],
"factsToSupersede": [{ "subject": "Zach", "predicate": "reports-to", "object": "Bob" }],
"factsToReaffirm": [],
"entitiesResolved": [{ "input": "Zach", "resolvedTo": "Zach", "reason": "exact" },
{ "input": "Dana", "resolvedTo": "Dana", "reason": "new" }] }
8. changes — the transaction-time feed (since: "1970-01-01" for "everything"), newest change first. Each Fact carries the recall fields plus learnedAt / retiredAt (wall-clock — when the system knew it); validAt/invalidAt/reinforcedBy are omitted below for brevity. An agent can sync incrementally:
[{ "id": "845d…", "subject": "Zach", "predicate": "reports-to", "object": "Bob",
"current": true, "learnedAt": "2026-06-06T16:32:58.351Z", "retiredAt": null,
"source": { "id": "3c2f…", "label": "org-2024q2", "text": "[2024-06-01] Zach reports to Bob." } },
{ "id": "ad57…", "subject": "Zach", "predicate": "reports-to", "object": "Alice",
"current": false, "learnedAt": "2026-06-06T16:32:58.345Z", "retiredAt": "2026-06-06T16:32:58.351Z",
"source": { "id": "5515…", "label": "org-2024q1", "text": "[2024-01-01] Zach reports to Alice." } }]
Note learnedAt/retiredAt (transaction time — when the system learned/retired the
Fact) are distinct from validAt/invalidAt (valid time — when it was true in the
world); that bi-temporal split is the whole point.
The live viewer
cd viewer && pnpm install && pnpm dev # http://localhost:3000
Renders the graph from Postgres and animates Supersession: Current Facts solid, superseded Facts greyed/dashed, updating live as Facts change. New Entities and Facts glow in on a stable layout (existing nodes never move), so you can watch the graph grow.

The still above is the seeded demo at rest: solid Current edges, the dashed grey
reports-to → Alice edge that was superseded, and the detail panel spelling out
each Fact's valid interval — including Alice's closed 2024-01-01 → 2024-06-01. Set
the as-of date picker to a past date and the graph rewinds to whatever was
Current then (the header flips to Valid then with a one-click Live reset).
Reproduce the grey-out demo
The GIF above is one command per beat — and it needs no API key: the seed
pins extraction to the deterministic StubExtractor, so the only things live on
camera are the supersession resolver and the viewer. With the viewer open in one
terminal, run these in another (project root):
pnpm seed:demo # Beat 1: Zach → Alice, knows Carol, lives in Berlin
pnpm seed:demo beat2 # Beat 2: Zach → Bob — watch the reports-to edge grey out live
Beat 1 truncates first and asserts the subject resolved to exactly one Entity
(a forked subject would add a parallel edge instead of greying the old one), so a
broken seed fails loudly rather than filming wrong. Use pnpm seed:demo all for a
non-interactive run of both beats. To replay, just re-run pnpm seed:demo.
Beyond watching it grow, the viewer is interactive:
- As-of scrubber — set a past date and the graph rewinds to whatever was Current then (the bi-temporal model made visual); clear it to return to live.
- Entity index — a name-sorted, filterable list of every Entity; select one (by click or keyboard) to open a side panel.
- Detail panel — lists every Fact touching the selected Entity with its valid interval, when the system learned it, and its Source count; each counterpart Entity is a link, so you can walk the graph Fact by Fact. Escape closes it.
- Staleness banner — if the live poll drops, a banner flags that the graph may be out of date rather than silently showing frozen state.
The viewer is read-mostly: alongside the read path it exposes one local
ingestion endpoint, POST /api/remember, behind a drop-text box — paste text and
watch the graph react. The same endpoint is what the Claude Code session hook
posts to (ADR 0004). Ingestion needs OPENROUTER_API_KEY (and TENSE_DATABASE_URL)
in the viewer's environment; it reads the project-root .env by default.
Models
OpenRouter is the sole gateway for completions and embeddings; both models are
user-configurable. The recorded demo runs on a frontier model; Gemma 3 4B is one
env line away (TENSE_EXTRACTION_MODEL=google/gemma-3-4b-it).
Layout
migrations/ SQL migrations (one-command bootstrap)
src/db/ Postgres pool, migration runner, temporal graph store + atomic supersession
src/supersession/ pure resolver (cardinality + valid-time direction rule), predicate registry
src/resolution/ entity resolution (exact -> pg_trgm fuzzy -> short-name guard)
src/extraction/ LLM extractor + static prompt assets (stub double for tests)
src/contradiction/ LLM-judged contradiction (reuses the resolver's direction rule)
src/retrieval/ hybrid recall (RRF + temporal filter) and history
src/mcp/, server.ts MCP stdio adapter and entry point
viewer/ read-mostly Next.js viewer (live grey-out + growth, drop-text ingestion)
eval/ gold set, metrics, fair baseline, harness (pnpm eval), latency bench (pnpm bench)
test/ logic unit tests + integration tests (real Postgres)
docs/adr/ architecture decisions · CONTEXT.md domain glossary
Scope
Single-tenant, stdio transport, local Postgres. Out of scope (deliberately): source-contradiction/trust-ranking (two Sources disagreeing at the same time), hosting/multi-tenancy/auth, and a draggable, animated timeline slider — the viewer ships point-in-time as-of as a date picker (rewind to any past date, above), not a dragged-through animation. See the PRD.
The trust boundaries this scope does defend — untrusted Source text into the
graph, agent inputs into SQL, secret handling — and the ones it deliberately
doesn't, are stated with the backing code in SECURITY.md.
License
MIT — see LICENSE.