tacit
A team knowledge base that writes itself from your Claude Code sessions, and only says what it can prove.
Teams make decisions in chat with their coding agent every day: "we pin pydantic < 3 because v3 breaks our validators", "staging deploys need the VPN". Those live in transcripts nobody re-reads. tacit watches your sessions in the background, keeps that knowledge as typed, versioned cards in a local SQLite file, and hands the relevant ones back to the next prompt, each one with the exact words it came from.
A decision from one session comes back, with its quotes, when a teammate asks in Japanese days later. Every tacit line is real CLI output.
Claude Code tacit (local, SQLite)
βββββββββββ βββββββββββββββββββββ
Stop hook ββ queue session βββββΆ worker (detached, one at a time)
β transcript β evidence events
β (secrets redacted)
βΌ
observer turn ββ OpenAI-compatible model
β search Β· propose_<bucket> Β· reinforce
β suggest_link Β· decline_intent
β deliver_whisper
βΌ
ledger ββ cards (versioned) Β· evidence
β links Β· batches Β· attempts
β outcomes Β· intents Β· reviews
ββββββββββββββββββββββββββββΌββββββββββββββββββββ
βΌ βΌ βΌ
UserPromptSubmit βββ whispers tacit review tacit stats
(queued + fast lane, no LLM) (human inbox) health Β· report
(numbers only)
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 defaultAdd 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}
]}]
}
}| Command | What 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 review | Pending link suggestions; tacit review accept|dismiss <id> decides one |
tacit stats [--days N] | Daily numbers as JSON |
tacit health | ok / warn / alert per check, exit code 0 / 1 / 2 |
tacit report [--out DIR] | A self-contained report.html with charts, plus metrics.json |
tacit worker | Drain 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.
| Bucket | Example | Required fields | Usual update |
|---|---|---|---|
preference | "answers short, bullets first" | who | Replaced by an explicit new version |
workflow_habit | "branch before committing; run tests first" | trigger | Reinforced on repeat |
world_update | "Mika is now the PM for payments" (was Ken) | source = user | tool; optional replaces, valid_from | Replaced by an explicit new version; evidence must come from the user or a tool result, never the assistant (enforced) |
decision | "pin pydantic < 3" | rationale | Stays until superseded |
gotcha | "staging deploy hangs without the VPN" | symptom, fix | Reinforced 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
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
the exact quotes behind a card
- card β cards
- batch Β· line Β· role
- quote Β· evidence_id Β· kind
directed relations between cards
- src Β· dst Β· kind PK
- kind: supersedes | relates_to | contradicts
- created_at
FTS5 virtual table
- card Β· terms
- Latin words + CJK character bigrams, so any two-character piece of Japanese or Chinese matches
Processing trail
- session PK
- transcript_path
- cursor (line) Β· byte_offset
- updated_at
- batch PK = hash(session, start, end)
- start_line Β· end_line Β· end_offset
- status: pending | settled
- attempt PK Β· batch
- started_at Β· finished_at
- status: running | completed | retryable
- retry_reasons
one row per proposal per attempt
- attempt Β· batch Β· proposal Β· card
- outcome: accepted | duplicate | reinforced | superseded | review | rejected | retryable
- code Β· reason
"remember that β¦" and its answer
- intent PK Β· session Β· batch Β· evidence_id Β· text
- outcome: accepted | β¦ | declined | deferred
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
- whisper PK Β· session Β· batch Β· card
- text (rendered) Β· related
- created_at Β· delivered_at
- 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))- Idempotent writes. Every write has a source key (batch + proposal), so replaying a proposal after a crash or retry doesn't duplicate it.
- Trusted intents. When the user says "remember that β¦", the observer must answer it with a card or an explicit
decline_intentand a reason. A silent drop isn't an option. - Everything is recorded. Batches, attempts with their retry reasons, and every proposal's outcome, so failure rates are measured, not guessed.
Grounded whispers
Two lanes hand knowledge back to the next prompt. Neither makes the prompt wait on a model.
| Lane | When | How it picks |
|---|---|---|
| Queued (async) | Written by the observer after a batch, delivered on the next prompt | The 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 call | Full-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
- Local ledger. Everything is stored in one SQLite file on your machine.
- What the model sees. The model you configure receives the batch's evidence and the cards its searches return, after redaction.
- Redaction runs once, early. It runs on each whole block when evidence events are built, before the block is cut to size, so the ledger, the observer prompt and every whisper see the same scrubbed text. Structured tool input is redacted leaf by leaf before it is serialized, so JSON quoting can't hide a secret.
- Numbers-only observability.
stats,healthandreportoutput numbers and fixed labels, never card text.
| Kind | Catches |
|---|---|
| Token shapes | Anthropic, OpenAI, GitHub, AWS, Google, Slack keys; JWTs; Bearer β¦ |
url_credentials | scheme://user:password@host |
private_key | PEM private keys; a BEGIN marker with no END redacts to the end of the text |
assignment | DEPLOY_TOKEN=β¦, "api_key": "β¦", password: β¦, also nested inside other values (note: TOKEN=β¦, ?access_token=β¦) |
field | Structured 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:
| Check | warn | alert |
|---|---|---|
batch_retryable_share_24h | 0.1 | 0.25 |
proposal_rejected_share_24h | 0.3 | 0.6 |
observer_turn_p90_s_24h | 120 s | 300 s |
held_sessions | 1 | 3 |
unresolved_intent_age_h | 1 h | 24 h |
pending_reviews | 10 | 50 |
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.
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 pytestCredits & license
- Inspired by letta-ai/claude-subconscious (MIT): the hook-driven "background agent that whispers" idea. tacit is a separate rewrite with no shared code, no Letta dependency and a different focus (grounded team knowledge).
- Co-authored with Claude Opus 5.5 and GPT 6.1 Sol.
- MIT licensed. Not affiliated with Anthropic. Claude, Claude Code and Clawd are trademarks of Anthropic.