Architecture

August 31, 2026 · View on GitHub

For contributors. Read How it works first for the behaviour; this page maps it onto files, harness hooks, routes and storage. Tacit is plain ES modules — no bundler, no TypeScript step.

Modules

FileLinesResponsibilityTests
lib/index.js~90plugin entry: legacy storage rename, service + store wiring, registers projection, system-prompt section, agent/pre-step listener and routestest/integration.test.mjs
lib/fold.js~300the tacitTimeline projection: session events → per-turn digests (pure fold, bounded, masked, stateVersion: 4)test/fold.test.mjs
lib/redact.js~50SECRET_PATTERNS and redactSecrets(text): the credential shapes masked as [redacted:…] at capturetest/redact.test.mjs
lib/analyze.js~900prompts, tool schemas, heuristics (messy / correction / continuation), report normalisation, trust score, steering renderer, the single callCoachModel transporttest/analyze.test.mjs, calls, trust
lib/service.js~1450the host service: auto triggers, trials, verification, bootstrap, distillations, steering freeze, pre-send, every route handlertest/integration.test.mjs (stubbed harness)
lib/routes.js~150/api/tacit/* on the harness web server, JSON body limit, cross-site guard, status mappingtest/integration.test.mjs
lib/schema.js~460zod schemas: config, digest, report, profile, usage ledger, route argument codecs, model allowlisttest/schema.test.mjs
lib/store.js~370~/.dsh/storages/tacit/ — atomic JSON writes, reports, profile, usage day files, expiry, cleartest/store.test.mjs
lib/usage.js~700the usage/cost ledger: runs and attempts (synchronous, content-free), the rolling summary, day files, and the report/run/clear read sidetest/usage.test.mjs
lib/pricing.js~290pure price arithmetic: the bundled DeepSeek list prices, off-peak/peak tiers, costOf, costMeter state normalisationtest/pricing.test.mjs
lib/pricing-source.js~130the live price source: the optional costMeter sibling when it answers, the bundled table otherwise; refresh() never throws and never blocks a calltest/pricing.test.mjs
client/src/*.js~3400the whole browser UI, one file per section (10-i18n, 20-api, 30-session-store, 40-root-store, 50-format, 55-usage-format, 60-components, 65-feedback-strip, 68-section-card, 69-confirm-dialog, 70-panel, 72-usage-panel, 80-css, 90-plugin); scripts/build-client.mjs concatenates them into the shipped client/client.js (one classic script per plugin is all the harness loads)test/client.test.mjs (SSR render of the built file), pnpm check:client
scripts/smoke.mjslive HTTP smoke against a running dsh web

Harness hooks (host side)

HookPlain languageWhere
ctx.sessionProjections unit tacitTimelineTacit's per-turn digest, derived from the session log; the browser reads it with useProjection('tacitTimeline')fold.js, index.js
sessionProjections.onChangedthe only trigger source — on every change: maybeAutoAnalyze → recordTrialTurns → handleVerification; no pollingservice.js
ctx.systemPrompt.section({ name: 'tacit:steering', order: 60 })the directives block for that session's workspace (session.header.cwd: its own scoped directives first, then global); text and the injected directive ids captured once per session object (WeakMap, plus a bounded id map by session id for trials)index.js, service.js
agent/pre-step (waterfall)opt-in enrichPrompts: append one plugin-sourced user message on step 1service.js
ctx.llm.streamevery model call: allowlisted model, session's provider route, reasoningEffort: 'low' (one retry without it if unsupported), tool schema, maxTokens, timeout, sessionIdanalyze.js
webServer routesthe JSON API belowroutes.js

Fork guard: a forked session's log starts with events copied from the parent, carrying the parent's timestamps. The fold skips events older than the session's own createdAt so they are not counted twice.

Browser slots (client/src/90-plugin.js)

SlotidorderComponent
conversation.viewtacit30Tacit tab
conversation.input.lefttacit-improve100✨ Improve button
conversation.input.overlaytacit-preview10before/after preview
conversation.input.docktacit-feedback10👍/👎 strip (the row above the composer; composer.dock is hidden by the harness until a conversation has content)
settings.sectiontacit32Settings → Tacit

Vanilla React.createElement via window.__ModuleLoader__; locale namespace dsh-tacit, zh and en dictionaries must have identical key sets (a test enforces it).

Slots unchanged. The usage/cost dashboard (Usage and Pricing cards, the Data & privacy card's retention/warning controls) is entirely new content inside the existing settings.section slot's own DOM — it registers no new slot and does not touch the four conversation-view slots above.

HTTP routes

All POST, JSON in / JSON out, on the harness web server (no extra port), body ≤ 256 KiB, every route (including read-only ones) passes the cross-site guard.

RouteBodyReturns
/api/tacit/stateconfig, profile, auto {today, budget}, steering {enabled, text}, bootstrap progress
/api/tacit/reports{sessionId}reports of that session keyed by turn
/api/tacit/history{limit?} (≤ 500)latest reports across sessions
/api/tacit/analyze{sessionId, turn, force?}report + profile; codes no-session, not-retained, continuation, already-analyzed (the turn already has a report; no model call and no ledger run unless force: true), busy, empty-response, timeout, no-api-key, no-credit, rate-limited, call-failed
/api/tacit/analyze-batch{sessionId, turns, force?} (1–50 turns, deduped and sorted)one analysis-batch run over the picked turns: results [{turn, ok, code, report}] (a turn already being analyzed reports busy, one that already has a report reports already-analyzed unless force: true; both cost nothing and count as skipped, and a batch of nothing else closes as success), profile, run summary
/api/tacit/improve{sessionId, draft}improved, rationale, rewriteId, patternsUsed
/api/tacit/applied{sessionId, rewriteId}ok (starts free verification)
/api/tacit/feedback{rewriteId, verdict: up|down, reason?}profile
/api/tacit/directives{action: toggle|add|rescope|remove|start-trial, …} (rescope takes id and a workspace, '' for global)profile + steering
/api/tacit/directive-receipt{id}that directive's receipt: text, scope, status, source, enabled, createdAt/updatedAt/evaluatedAt/approvedAt, version, its trial counters and baselines, retiredReason, the trigger counts and evidence ids derived from its evidence, and cost {runId, calls, usd} read from the ledger (usd is null when the run is unknown or none of its attempts is priced); never prompt text. unknown-directive when no directive has that id
/api/tacit/stats{window?} (3–200, default 20)trend early vs. recent
/api/tacit/bootstrap{sessionId?, limit?} (1–50, default 20)analyzed / skipped / directives; busy if one is running
/api/tacit/bootstrap-preview{sessionId?, limit?} (1–50, default 20)what a bootstrap would do: eligible / skipped counts and estimate {usd, basis: measured|doc, samples, perAnalysisUsd} (the ledger's median once 3 priced analyses exist in 30 days, the doc figure otherwise). No model call, no run, never busy
/api/tacit/config{patch}effective config; non-allowlisted model → 400
/api/tacit/clearremoved count
/api/tacit/usage{range?, type?, status?, model?, workspace?, sessionId?, page?, pageSize?} (range today|7d|30d|month|all, default 30d; status running|success|partial|failed; page ≥ 1, pageSize ≤ 100)today/month/last7/last30/lifetime totals, series7/series30, byType/byModel/byProvider/byTrigger, auto {today, budget}, warnings, one page of runs, price-source status
/api/tacit/usage-run{runId}that run with its attempt rows (a live run included); unknown-run once its day expired
/api/tacit/usage-clearremoved day files + the new trackingSince
/api/tacit/pricing-refreshprice-source status + both models' off-peak/peak rates

Status codes: wrong method → 405; cross-site guard (Sec-Fetch-Site not same-origin/none, Origin host ≠ Host, or non-JSON content type) → 403; bad JSON → 400; handler bad-request / unknown-rewrite → 400; every other ok: false → 200 with a code; thrown → 500.

Storage

~/.dsh/storages/tacit/ (root = $DSH_HOME or ~/.dsh): config.patch.json, profile.json, auto.json, reports/<sessionId>/<turn>.json (a report records the conversation's absolute workspace directory as cwd; a directive may carry a workspace it is limited to), usage/<YYYY-MM-DD>.json (one day of ledger runs and their attempts) and usage/summary.json (the rolling lifetime / byType / byModel / byProvider / byTrigger / per-day totals, so a report never re-scans the day files; day buckets are kept for 400 days, far past the 30 any range reads). Each directive in profile.json carries its provenance next to its text: updatedAt, version, the ids of the reports its distillation read, distillationRunId, evaluatedAt and approvedAt. A directive you removed stays in the profile, so the distiller can be told not to propose it again. Day files are parsed once and served from an in-memory memo keyed on the file's mtime and size, bounded by total size, so the ten-second poll behind the cost panel re-reads nothing that has not changed. The ledger is content-free: ids, counts, tokens and money only — no prompts, no responses, no tool arguments, and workspace is the directory's last segment, never the full path. Session ids are sanitised to [A-Za-z0-9._-]{1,128}. Writes go to a .tmp-<pid>-<time> file then rename. Deletion is limited to three paths, all over the plugin's own files: clearReports() (reports/*/<n>.json), day-file expiry after costHistoryDays (7–365), and usage-clear — the last two unlink only files matching ^\d{4}-\d{2}-\d{2}\.json$ inside usage/ and never remove the directory itself. On first start a legacy storages/prompt-coach directory is renamed to tacit if the new one does not exist (nothing is deleted; safe to drop after 0.3).

Development

Official @deepseek-ai/* packages are peer dependencies: an installed Tacit uses the copies the running harness provides (dsh links them into ~/.dsh/profiles/node_modules when it boots), never a private nested copy, so the objects it hands to the host are built by the host's own version. They are also devDependencies so a checkout can run the tests without a harness. scripts/check-install.mjs <profile dir> asserts this against a real install; the weekly compatibility.yml runs it against @deepseek-ai/dsh@latest.

See CONTRIBUTING.md for the checkout/link workflow, the restart-vs-refresh rule, tests, smoke and the ground rules.


Docs index