Quick start

Requires Python 3.12+ and uv. The observer talks to any OpenAI-compatible chat-completions endpoint with tool calling; there is no LLM SDK, so switching providers is configuration only.

git clone https://github.com/kannch8765/tacit && cd tacit
uv tool install .            # puts `tacit` on your PATH

# any model behind an OpenAI-compatible endpoint
export TACIT_LLM_MODEL=deepseek-v4-flash
export TACIT_LLM_API_KEY_FILE=~/.config/tacit/key   # or TACIT_LLM_API_KEY

# optional
export TACIT_LLM_BASE_URL=https://opencode.ai/zen/v1   # the default
export TACIT_HOME=~/.tacit                              # the default

Add the two hooks to Claude Code (~/.claude/settings.json, or install the repo as a plugin with hooks/hooks.json):

{
  "hooks": {
    "Stop": [{"hooks": [
      {"type": "command", "command": "tacit hook stop", "timeout": 10}
    ]}],
    "UserPromptSubmit": [{"hooks": [
      {"type": "command", "command": "tacit hook prompt", "timeout": 10}
    ]}]
  }
}
CommandWhat it does
tacit search <words>Current cards, best match first (Japanese and Chinese work too)
tacit show <card>One card with its fields, evidence quotes and links
tacit history <card>Every version of a card, oldest first
tacit reviewPending link suggestions; tacit review accept|dismiss <id> decides one
tacit stats [--days N]Daily numbers as JSON
tacit healthok / warn / alert per check, exit code 0 / 1 / 2
tacit report [--out DIR]A self-contained report.html with charts, plus metrics.json
tacit workerDrain queued and held batches by hand (the next Stop hook normally does it)

How it works

1. Observe, without blocking

The Stop hook only records the session and its transcript path, then starts a detached worker if none is running (a file lock keeps it to one). The worker reads the transcript from a committed byte offset, takes only complete JSONL records (a half-written last line waits for the next run), and cuts them into batches of up to 200 lines.

2. Turn transcript lines into trusted evidence

Each text block, tool call and tool result becomes an evidence event with a backend-owned evidence_id, its role (user, assistant, tool) and its line number. Secrets are redacted on the whole block at this point, before anything is stored or sent. The observer model sees a catalog of these events.

3. Ground every write

The observer can only cite evidence ids; tacit copies the quote from the transcript itself, so the model never types a quote it could make up. A proposal whose ids don't resolve in the batch, or that breaks its bucket's rules, is recorded as rejected with a reason code. It is never silently saved and never silently dropped.

outcome: rejected   evidence_role_not_allowed
         world_update can't be grounded by assistant evidence
outcome: accepted   c_755179245877   (same fact, re-proposed with the tool result as evidence)

From the live smoke test: the model first grounded a world_update in its own words, was told why, and fixed it in the same turn.

4. Settle into a versioned ledger

A grounded proposal becomes one of: a new card; a reinforcement of an existing card (the same knowledge seen again attaches new evidence instead of duplicating); or a new version that supersedes the old one. Cards are never edited in place (SCD type 2): superseding closes the old version (superseded_at, superseded_by) and links the new one with supersedes. Each card keeps valid_from (when it became true) and recorded_at (when tacit learned it), so "what do we believe now?" and "what did we believe on 10-10?" are both plain queries.

5. Whisper it back

On the next UserPromptSubmit, tacit hands Claude Code the relevant cards as context, with their dates, versions and exact quotes. See Grounded whispers.

6. Ask a human when it matters

Relations the observer suggests between cards (same_as, supersedes, relates_to, contradicts) wait in tacit review. Accepting one is a ledger write too, held to the same bucket rules as the observer's: a review can't merge or replace cards across buckets.

Buckets

Every card belongs to one of five buckets. Required fields and evidence rules are enforced in code (tacit/buckets.py), not in the prompt: a proposal that breaks them is rejected with a reason code. Whether new evidence reinforces a card or replaces it is the observer's call, and a replacement is always an explicit supersedes (from the observer, or accepted in tacit review). tacit doesn't match a changed preference to the old one on its own.

BucketExampleRequired fieldsUsual update
preference"answers short, bullets first"whoReplaced by an explicit new version
workflow_habit"branch before committing; run tests first"triggerReinforced on repeat
world_update"Mika is now the PM for payments" (was Ken)source = user | tool; optional replaces, valid_fromReplaced by an explicit new version; evidence must come from the user or a tool result, never the assistant (enforced)
decision"pin pydantic < 3"rationaleStays until superseded
gotcha"staging deploy hangs without the VPN"symptom, fixReinforced when it bites again

Data schema

One SQLite file ($TACIT_HOME/tacit.db), stdlib sqlite3 only. Three groups of tables: the knowledge itself, the processing trail, and the conversation with the next prompt.

Knowledge

cards

one row per version

  • card PK
  • lineage Β· version UQ
  • bucket Β· title Β· body Β· fields (JSON)
  • status: active | superseded | in_review | retired
  • valid_from Β· recorded_at
  • superseded_at Β· superseded_by
  • reinforcements Β· last_seen_at Β· dedupe_key
evidence

the exact quotes behind a card

  • card β†’ cards
  • batch Β· line Β· role
  • quote Β· evidence_id Β· kind
links

directed relations between cards

  • src Β· dst Β· kind PK
  • kind: supersedes | relates_to | contradicts
  • created_at
card_search

FTS5 virtual table

  • card Β· terms
  • Latin words + CJK character bigrams, so any two-character piece of Japanese or Chinese matches

Processing trail

sessions
  • session PK
  • transcript_path
  • cursor (line) Β· byte_offset
  • updated_at
batches
  • batch PK = hash(session, start, end)
  • start_line Β· end_line Β· end_offset
  • status: pending | settled
attempts
  • attempt PK Β· batch
  • started_at Β· finished_at
  • status: running | completed | retryable
  • retry_reasons
outcomes

one row per proposal per attempt

  • attempt Β· batch Β· proposal Β· card
  • outcome: accepted | duplicate | reinforced | superseded | review | rejected | retryable
  • code Β· reason
intents Β· intent_outcomes

"remember that …" and its answer

  • intent PK Β· session Β· batch Β· evidence_id Β· text
  • outcome: accepted | … | declined | deferred
diagnostics

problems that hold nothing

  • session Β· batch Β· line Β· code
  • e.g. malformed_record, transcript_shrank
  • codes and line numbers only, never text

Back to the prompt

whispers
  • whisper PK Β· session Β· batch Β· card
  • text (rendered) Β· related
  • created_at Β· delivered_at
reviews
  • review PK Β· relation Β· cards
  • reason Β· batch
  • status: pending | accepted | dismissed

Batch outcome model

The hard part of a background observer isn't the model call; it's deciding when a batch is done. tacit decides by ownership: a batch is held (its cursor doesn't move) only for work the batch itself owns. Everything optional is feedback, never a verdict.

observer turn over a batch
  β”‚
  β”œβ”€ tool error (bad args, unknown id) ─▢ fed back to the model; turn goes on
  β”œβ”€ proposal breaks a rule ────────────▢ outcome = rejected + reason code
  β”‚                                       (settled: a recorded "no")
  β”œβ”€ whisper / link suggestion fails ───▢ optional: never holds the batch
  β–Ό
finalize(): hold the batch only if owned work is unsettled
  β”œβ”€ turn_failed         the model turn itself didn't complete
  β”œβ”€ attempt_retryable   a storage failure on an owned write
  └─ intent_blocking     a "remember that …" not answered yet
                         (at most 3 completed turns, then released)
  β”‚
  β”œβ”€ nothing held ─▢ batch settled; cursor + byte offset advance
  └─ held ─────────▢ retried later from the same cursor; new lines
                     make a new, wider batch (id = hash(session,
                     start, end))

Grounded whispers

Two lanes hand knowledge back to the next prompt. Neither makes the prompt wait on a model.

LaneWhenHow it picks
Queued (async)Written by the observer after a batch, delivered on the next promptThe observer may choose only a card its own purpose=whisper search returned in this turn, and 1-3 of that card's real snippet ids
Fast lane (sync)On every prompt, no model callFull-text search; a card needs at least two meaningful terms in common with the prompt (one, for a one-word prompt); at most 2 cards

The model only selects. tacit renders the text itself: bucket, date, version, fields, the exact quotes, what the card replaced (following supersedes links), and up to two linked cards. Contradictions always come first and are marked ⚠; related cards are chosen by lexical MMR (relevant to the prompt, not redundant with each other).

tacit Β· decision (as of 2026-10-15, v1, c_43e68aaf97e5): Pin pydantic to < 3
  rationale: Pydantic 3 broke request validators.
  [user] β€œLet's pin pydantic < 3 for now, we can revisit when 3.2 ships the fix for custom validators.”

A real queued whisper from the smoke test. Before it, the model tried to whisper without searching first and got this feedback, then searched and retried:

deliver_whisper may select only a card and snippets surfaced by a purpose=whisper search in this turn.
Nothing was surfaced yet; call search with purpose=whisper first.

Privacy & redaction

KindCatches
Token shapesAnthropic, OpenAI, GitHub, AWS, Google, Slack keys; JWTs; Bearer …
url_credentialsscheme://user:password@host
private_keyPEM private keys; a BEGIN marker with no END redacts to the end of the text
assignmentDEPLOY_TOKEN=…, "api_key": "…", password: …, also nested inside other values (note: TOKEN=…, ?access_token=…)
fieldStructured fields with a sensitive name (password, apiKey, x-api-key, client_secret, …)
evidence [tool line 2]: connect ETIMEDOUT 10.8.0.12:443
export DEPLOY_TOKEN=[redacted:assignment]

Observability

Health and failure rates come straight from the ledger. tacit stats rolls it up per day (batches, retryable share and reasons, proposal outcomes, rejections by code, observer turn time, whispers, intents). tacit health turns that into checks:

Checkwarnalert
batch_retryable_share_24h0.10.25
proposal_rejected_share_24h0.30.6
observer_turn_p90_s_24h120 s300 s
held_sessions13
unresolved_intent_age_h1 h24 h
pending_reviews1050

tacit report writes a self-contained report.html (inline SVG charts, no external assets) and the metrics.json behind it.

Repository structure

tacit/
β”œβ”€β”€ tacit/
β”‚   β”œβ”€β”€ cli.py         command line + the two hook entry points
β”‚   β”œβ”€β”€ worker.py      detached worker, one at a time (flock)
β”‚   β”œβ”€β”€ transcript.py  JSONL records β†’ trusted evidence events + catalog
β”‚   β”œβ”€β”€ redact.py      secret redaction (tokens, assignments, URLs, keys)
β”‚   β”œβ”€β”€ observer.py    one model turn per batch, purpose-built tools
β”‚   β”œβ”€β”€ runtime.py     write path: proposals, reinforce, intents, verdict
β”‚   β”œβ”€β”€ buckets.py     the five buckets: fields, values, evidence rules
β”‚   β”œβ”€β”€ store.py       the ledger: versioned cards β†’ outcomes
β”‚   β”œβ”€β”€ whisper.py     selection, weaving (history + related), fast lane
β”‚   β”œβ”€β”€ text.py        search tokens: Latin words + CJK bigrams
β”‚   β”œβ”€β”€ llm.py         stdlib OpenAI-compatible client, tool calling
β”‚   β”œβ”€β”€ stats.py       daily rollup + healthcheck (numbers only)
β”‚   └── report.py      report.html (inline SVG) + metrics.json
β”œβ”€β”€ hooks/hooks.json   Stop β†’ tacit hook stop
β”‚                      UserPromptSubmit β†’ tacit hook prompt
β”œβ”€β”€ .claude-plugin/    plugin manifest
β”œβ”€β”€ tests/             scripted fake model: no network, no tokens
β”œβ”€β”€ demo/              made-up sessions for the live smoke test + GIF
β”œβ”€β”€ docs/              DESIGN.md, demo.gif, this page
└── site/              source of this page (python site/build.py)

The design and its decisions, including a point-by-point comparison with the upstream idea, are in docs/DESIGN.md.

Live smoke test

Four short, made-up sessions run through the real observer on Gemini flash-lite (its OpenAI-compatible endpoint, no code changes): a decision, a gotcha with a fake secret in a tool result, a "remember that" confirmed by a tool result, and a later question that should bring a card back.

4 / 4
batches settled
4
cards accepted
1
proposal rejected, with a reason
1
secret redacted

Run it yourself with your own key; see demo/. The offline test suite uses a scripted fake model instead: no network, no tokens.

uv sync
uv run pytest

Credits & license