Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
Zac Plischka avatar

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@tense

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Last updatedJune 11, 2026
RepositoryZacplischka/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

CI

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
Valid-time timeline: recall(as_of='2024-03-01') returns Alice — the Fact that was true then — while a live recall() returns the Current Fact, Bob. A recency-sorted vector store returns Bob for both.

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.

Grouped bar chart of offline temporal-QA accuracy: on the 5 point-in-time questions Tense scores 100% and the fair vector baseline 0%; across all 11 questions Tense scores 100% and the baseline 45.5% — the baseline competes on the 'now' questions and loses precisely the 5 whose answer changed over time.

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."

Live grey-out: a superseded edge dashes while the new one lights up

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:

The bi-temporal plane. Valid time runs left-to-right (when a Fact was true in the world); transaction time runs bottom-to-top (when the system learned it). The superseded 'Zach reports-to Alice' Fact occupies valid 2024-01-01→2024-06-01 and transaction t1→t2 (a closed dashed-grey box); the Current 'reports-to Bob' Fact occupies valid 2024-06-01→now and transaction t2→open (a solid indigo box, open at the top because expired_at IS NULL). A recall(as_of='2024-03-01') reads down the valid-time axis and returns Alice; a live recall() returns Bob; history and changes walk the transaction-time axis. Nothing is deleted — Alice stays on the plane.

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) &mdash; write path"]
        direction TB
        T["Source text"] --> EX["Extractor<br/>(LLM &middot; stub double)"]
        EX -->|"Entities + Facts<br/>with valid_at"| ER["Entity Resolver<br/>exact &rarr; 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 &mdash; relational graph + vectors + fuzzy match"]
        direction TB
        ENT[("entities &middot; sources<br/>fact_sources provenance")]
        FACTS[("facts &mdash; bi-temporal<br/>valid_at / invalid_at<br/>created_at / expired_at<br/>Current = expired_at IS NULL")]
        VEC[("pgvector embeddings &middot; pg_trgm")]
    end

    subgraph read["recall(query, as_of?) &mdash; read path"]
        direction TB
        Q["Query"] --> TF{"Temporal filter in SQL<br/>Current &middot; or valid-at as_of"}
        TF --> HYB["Hybrid rank<br/>pgvector cosine + full-text &rarr; 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 Sourcetext: "[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 changetext: "[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 nowquery: "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. historyentity: "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 cardinalitysingle (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 Tense viewer after the org-change demo: the graph shows Zach with solid edges to Bob (reports-to), Carol (knows) and Berlin (lives-in), plus a dashed grey edge to Alice — the superseded reports-to Fact. The legend reads Current (3) / Superseded (1) beside an 'as of' date picker. Zach's detail panel lists all four Facts with their valid intervals; the superseded reports-to Alice is greyed and dated valid 2024-01-01 → 2024-06-01. The Entity index runs along the bottom.

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.

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.