import Database from 'better-sqlite3'; import { Server } from 'node:http'; /** * The normalized session model — the contract every processor reads and every * adapter produces. Adapters translate a vendor's transcript into this shape; * processors and the store never need to know which harness a session came from. * * `raw` is always preserved as an escape hatch for processors that need * vendor-specific detail the canonical view doesn't capture. */ /** Vendor-neutral classification of a tool call. The per-vendor mapping lives in the adapter. */ type CanonicalAction = 'file_write' | 'file_read' | 'shell' | 'search' | 'task_spawn' | 'mcp_call' | 'web' | 'todo' | 'skill' | 'other'; /** * Cache creation is split by TTL because Anthropic bills the two differently: a * 1h write costs 2x input, a 5m write 1.25x. The two are DISJOINT — the total * cache-write is `cacheCreate5m + cacheCreate1h`, and neither contains the other. * Sources that expose no Anthropic TTL split report their whole write in the 5m * bucket. Their price table mirrors the provider's single write rate to both slots * (for example, OpenAI GPT-5.6 uses one write class with a 30m minimum lifetime). */ interface TokenUsage { input: number; output: number; cacheCreate5m: number; cacheCreate1h: number; cacheRead: number; } declare function emptyUsage(): TokenUsage; declare function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage; /** Total cache-creation tokens across both TTLs. */ declare function cacheCreateTotal(u: TokenUsage): number; type ContentBlock = { type: 'text'; text: string; } | { type: 'thinking'; text: string; } | { type: 'tool_use'; id: string; name: string; input: unknown; } | { type: 'tool_result'; toolUseId: string; isError: boolean; content: unknown; }; interface BaseEvent { uuid?: string; parentUuid?: string | null; ts?: string; isSidechain: boolean; /** * Dense ordinal over MAIN-THREAD events (sidechain events have none), assigned * post-merge by assignSeq() (core/blocks.ts). The coordinate the block partition * is defined in; persisted in the session blob. */ seq?: number; /** * For sidechain (subagent) events, the stable id of the subagent that emitted * them — Claude Code's per-subagent transcript id. Lets the viewer group a * subagent's turns into their own thread instead of interleaving them with the * main conversation. Undefined for main-thread events. */ agentId?: string; } interface UserMessage extends BaseEvent { kind: 'user'; text: string; blocks: ContentBlock[]; /** * The source marked this "user" turn as injected machinery rather than * something the human typed — Claude Code's `isMeta`. Slash-command and skill * bodies arrive this way: the harness expands them into a user-role message * because the API has no third role. Authoritative where present; adapters * whose format lacks the flag leave it undefined and fall back to the text * heuristic (see core/turns.ts). */ isMeta?: boolean; } interface AssistantMessage extends BaseEvent { kind: 'assistant'; model?: string; blocks: ContentBlock[]; usage: TokenUsage; /** * Native cost (USD) for this message as reported by the source, when the source * computes its own cost (e.g. OpenCode, which routes to many providers tuneloop's * rate table doesn't cover). Used by computeSessionCost as a fallback when the * model has no entry in models.json. Absent for sources priced from tokens. */ costUsd?: number; } interface SystemEvent extends BaseEvent { kind: 'system'; subtype?: string; text?: string; } type Event = UserMessage | AssistantMessage | SystemEvent; /** A tool_use joined to its tool_result, classified into a canonical action. */ interface ToolCall { id: string; /** * Raw event-level tool_use id when this semantic operation was recovered from * a transport envelope (for example Codex's JavaScript `exec` wrapper). */ parentId?: string; name: string; action: CanonicalAction; input: unknown; /** Normalized fields per action (paths for file ops, command for shell, etc.). */ target: { paths?: string[]; command?: string; }; result: { ok: boolean; isError: boolean; raw?: unknown; }; isSidechain: boolean; ts?: string; durationMs?: number; } /** * A subagent (sidechain) spawned within a session. Claude Code writes each * subagent's transcript to its own file with a sibling `.meta.json`; this is the * normalized view of that metadata. `toolUseId` is the id of the spawning tool * call (the `Task`/`Agent` tool_use) in the parent thread, which lets the viewer * link that call to the subagent's transcript. Workflow subagents have no * spawning tool call, so `toolUseId` is absent for them. */ interface SubagentMeta { agentId: string; agentType?: string; description?: string; toolUseId?: string; } interface Session { /** Namespaced id, e.g. `claude-code:` — unique across vendors. */ id: string; /** Raw vendor session id. */ sessionId: string; /** Adapter / harness id, e.g. `claude-code`. */ source: string; /** LLM vendor family for slicing, e.g. `anthropic`. */ provider: string; title?: string; /** * For a child transcript that lives in its own file (Codex sub-agent or `/fork`), * the parent session's raw id. Used to (a) fold sub-agents into the parent as * sidechains and (b) trim the replayed parent prefix both kinds inherit * (see analyze.ts / merge.ts, ADR-0005). Undefined for top-level sessions. */ forkedFromId?: string; /** * True only for a sub-agent (sidechain) child. Distinguishes it from a `/fork`, * which also carries `forkedFromId` but is its own top-level session: only * sub-agents fold into the parent group (ADR-0005). */ isSubagent?: boolean; project: { cwd?: string; repo?: string; branch?: string; }; startedAt?: string; endedAt?: string; /** Distinct models seen across assistant messages (model is per-message). */ models: string[]; /** Rolled-up token usage across all assistant messages (incl. sidechains). */ tokens: TokenUsage; events: Event[]; /** Flattened convenience view of every tool call, incl. sidechains. */ toolCalls: ToolCall[]; /** Subagents spawned in this session (one per sidechain transcript). */ subagents?: SubagentMeta[]; raw: { path: string; contentHash: string; }; } /** * Persistence-facing record shapes. Kept separate from both the normalized * model (core/model.ts) and the Store implementation so processors can import * these without pulling in better-sqlite3. */ type ArtifactKind = 'file' | 'commit' | 'pr' | 'ticket' | 'feature'; type LinkSource = 'explicit' | 'transitive' | 'derived' | 'user'; type ArtifactRelation = 'part_of' | 'resolves' | 'child_of' | 'caused_by'; type SessionArtifactRole = 'created' | 'edited' | 'contributed' | 'reviewed'; interface ArtifactInput { id: string; kind: ArtifactKind; repo?: string; ident?: string; externalId?: string; /** github | jira | linear | asana | user | codebase-inferred | transcript | ... */ source?: string; title?: string; /** PR author / ticket assignee / feature owner — the "team" artifact filter. */ owner?: string; complexity?: number; /** story_points | diff_size | equal_split */ complexityBasis?: string; status?: string; createdAt?: string; /** Merge / resolve / ship date. NULL until the artifact completes. */ completedAt?: string; parentArtifactId?: string; json?: unknown; } interface ArtifactLinkInput { fromId: string; toId: string; relation: ArtifactRelation; source: LinkSource; confidence?: number; } interface SessionArtifactInput { artifactId: string; role: SessionArtifactRole; source: LinkSource; confidence?: number; } /** * A reparent of an existing feature, for an enrichment processor that maintains * the feature hierarchy as it sees more sessions. Applied only to machine-derived * features — `user`-authored features are never touched. Auto-rename is * deliberately NOT supported: a bad rename retroactively mislabels every session * under the feature, so titles are fixed at creation (the dashboard can rename). */ interface FeatureRevisionInput { id: string; /** New parent id; `null` = make top-level; omit (`undefined`) = keep. */ parentId?: string | null; } interface OutcomeInput { type: string; /** NULL for session-level outcomes (session_success, plan_drafted, ...). */ artifactId?: string | null; ts?: string; } interface FileIndexInput { repo?: string; path: string; } interface AnnotationInput { key: string; value: unknown; } /** A contiguous deterministic slice of a session's main thread. */ interface BlockInput { idx: number; startSeq: number; endSeq: number; boundaryKind: string; tsStart?: string; tsEnd?: string; } /** usage_facts.idx -> block idx (a total partition; non-overlap is PK-enforced). */ interface BlockUsageInput { usageIdx: number; blockIdx: number; } /** tool_calls.idx -> block idx. */ interface BlockToolInput { toolIdx: number; blockIdx: number; } /** A label on one block (e.g. use_case), parallel to AnnotationInput. */ interface BlockAnnotationInput { blockIdx: number; key: string; value: unknown; } /** A block -> artifact link (block→PR/commit deterministic; block→feature derived). */ interface BlockArtifactInput { blockIdx: number; artifactId: string; role: SessionArtifactRole; source?: LinkSource; confidence?: number; } /** One assistant message's usage + cost — a row in the `usage_facts` table. */ interface UsageFactInput { idx: number; model: string; isSidechain: boolean; ts?: string; tokens: TokenUsage; usd: number; } interface SessionRow { id: string; sessionId: string; source: string; provider: string; title?: string; repo?: string; branch?: string; cwd?: string; startedAt?: string; endedAt?: string; nTurns: number; nToolCalls: number; models: string[]; tokens: TokenUsage; costUsd: number; priceTableVersion: string; contentHash: string; parseVersion: number; } interface ProcessorRunRow { version: number; inputHash: string; model: string | null; invalidated: boolean; } /** * Harness-neutral category vocabulary for config snapshots. Deliberately abstract, * not per-harness fields: the storage layer is shared, only the reader is * per-harness. A harness populates ONLY the categories it has — an absent category * simply produces no rows (Pi, e.g., ships no built-in MCP or sub-agents, so writes * neither `mcp` nor `agents`). * * settings — permissions / plugins / equivalent. Concept is universal; the * file format is not (CC=JSON, Codex=TOML, OpenCode=JSON). * mcp — MCP servers. The most universal — every supported harness has it. * agents — custom SUB-AGENT DEFINITIONS. NOTE: this is NOT `AGENTS.md`. * `AGENTS.md` is Codex/OpenCode's instructions file (their CLAUDE.md) * and belongs to `instructions`, despite the name. A harness reader * must never file AGENTS.md here. * skills — custom skills / commands. Ragged across harnesses: CC = SKILL.md * dirs, Codex = shell SKILL.md bundles, OpenCode = a skill tool, * Pi = SKILL.md dirs + root `.md` files. Same label, different * mechanism — reader is per-harness. * instructions — the project-instructions file: CLAUDE.md (CC) / AGENTS.md (Codex, * OpenCode). The generic name for "always-on instructions the user wrote". */ type EnvCategory = 'settings' | 'mcp' | 'agents' | 'skills' | 'instructions'; /** * One category's redacted, allowlisted config payload, as read by an adapter's * `readEnvironment`. `payload` is serialized to `snapshot_json`; only fields in * the design's allowlist are ever included (never env values, MCP secrets, etc.). */ interface EnvCategorySnapshot { category: EnvCategory; payload: unknown; } /** A snapshot write: one category, at one scope, for the store to append-on-change. */ interface EnvSnapshotInput { source: string; scope: 'global' | 'project'; /** '_global' for global scope; repo root for project scope. */ scopeKey: string; category: EnvCategory; payload: unknown; } /** One stored config state, as returned by the snapshot read methods. */ interface EnvSnapshotRow { payload: unknown; capturedAt: string; lastObservedAt: string; } /** * Result of a point-in-time (`asOf`) read. `stale` is true when no snapshot was * recorded at or before the requested time — i.e. we have no observation of the * config as it was then, so a caller should down-weight or abstain rather than * assert. `row` is null in that case only if nothing precedes the time at all. */ interface EnvSnapshotAsOf { row: EnvSnapshotRow | null; stale: boolean; } type InsightState = 'surfaced' | 'fix_issued' | 'adopted' | 'resolved' | 'dismissed'; interface InsightRow { id: string; detector: string; signalKey: string; repo: string; severity: 'high' | 'medium' | 'low'; state: InsightState; title: string; description: string; count: number; fix: { type: string; label: string; content: string; }; /** One-line recommended action shown beneath the signal; null when the detector produced none. */ recommendation: string | null; firstSeenAt: string; lastSeenAt: string; stateChangedAt: string | null; detectorVersion: number; /** Distinct sessions across ALL evidence (uncapped) — the true span, not the capped `evidence` sample. */ sessionCount: number; evidence: Array<{ sessionId: string; turnIdx: number | null; }>; /** Event time the fix was first applied in the current cycle (transcript timestamp), null if not adopted. */ adoptedAt: string | null; /** Sessions that ran this insight's fix-prompt, current cycle only (older cycles are history). */ fixSessions: Array<{ sessionId: string; seq: number; turnAt: string; }>; } interface DetectorRunRow { version: number; status: string | null; ranAt: string; /** LLM model the last run billed against; null for S-tier (no LLM spend). */ model: string | null; } /** One fix-marker sighting: a real user turn in this session carried `tuneloop-fix: `. */ interface FixMarkerSightingInput { insightId: string; /** Main-thread event seq of the sighted user turn. */ seq: number; /** Transcript timestamp of that turn — event time, the "fix applied" date. */ turnAt: string; } /** Frozen theme-type enum — gives the dashboard a stable facet (prototype DR-5). */ type ThemeType = 're-steer' | 'context-supply' | 'tool-gap' | 'rework' | 'preference' | 'other'; /** Remedy-class hint carried on a theme (not a fix itself — the fix is generated at surface time). */ type ThemeRemedy = 'add_doc' | 'add_skill' | 'add_tool' | 'model_or_prompt' | 'none'; /** What preceded the friction, for interpreting the event (never itself proof of friction). */ type ThemeTrigger = 'unprompted' | 'after_tool_error' | 'after_review' | 'agent_stated'; /** A theme referenced during extraction/merge — the existing-theme list fed into the prompt. */ interface ThemeRef { id: string; label: string; description?: string | null; type: string; repo: string | null; source?: string | null; } /** A theme to persist (INSERT OR IGNORE — minting an existing id never renames/retypes it). */ interface ThemeInput { id: string; label: string; description?: string; type: ThemeType; remedy?: ThemeRemedy; repo?: string | null; firstSeen?: string; } /** One extracted friction occurrence within a session. */ interface ThemeEventInput { idx: number; turnSeq?: number; type: ThemeType; trigger: ThemeTrigger; description: string; themeId?: string; /** Timestamp of the user message this event was extracted from (the real friction moment). */ occurredAt?: string; } /** * One kitchen-sink verdict to persist — the LLM's judgement of a single session, * positive OR negative (kitchen-sink detector, tier P). One row per judged session; * the card is a windowed projection of the positives (see `kitchen_sink_verdict`). */ interface KitchenSinkVerdictInput { sessionId: string; /** True = the session mixed unrelated objectives; false = coherent work. */ isKitchenSink: boolean; /** Block where the second (first unrelated) objective begins; null when coherent. */ splitBlockIdx: number | null; /** That block's opening main-thread seq — the evidence pointer; null when coherent/unknown. */ splitSeq: number | null; /** The LLM's one-sentence explanation. */ reason: string | null; /** Model that produced the verdict (provenance). */ model: string | null; /** Detector version at judge time. */ detectorVersion: number; } /** Translates a vendor's transcripts into the normalized session model. */ interface SourceAdapter { /** Stable adapter id, e.g. `claude-code`. */ id: string; /** LLM vendor family, e.g. `anthropic`. */ provider: string; /** * Version of THIS adapter's parse output. Bumped when the adapter extracts more * (or different) data from the same transcript bytes. Combined with the shared * `NORMALIZE_VERSION` into the stored `parse_version` (see analyze.ts), so a * per-vendor bump re-ingests only that vendor's sessions. */ parseVersion: number; /** Locations to scan when the user passes no directories. */ defaultRoots(): string[]; /** Find candidate session files under the given roots. */ discover(roots: string[]): Promise; /** Parse one file into a Session (or multiple for branched transcripts); null if it isn't a session this adapter owns. */ parse(path: string): Promise; /** * Store-backed alternative to discover/parse. Adapters whose sessions live in a * single database (not one file per session) implement this to yield sessions * directly; analyze.ts prefers it over the discover→parse file loop when present. */ discoverSessions?(roots: string[]): Promise; /** * Read this harness's config surface. Called once for the global scope * (`projectPath` undefined → read the harness home) and once per unique project * path (→ read that repo's project config). Returns one entry per category * present; the caller (analyze) stores each as an environment snapshot. The path * is always passed in — the adapter never explores for projects itself. Omitted * by adapters that don't yet read config (they contribute no snapshots). */ readEnvironment?(projectPath?: string): Promise; } /** * The facet registry: the single source of truth for the categorical dimensions * the dashboard charts, filters, and (later) compares by. * * A facet's `source` names WHERE its value lives — which also implies its grain. * `multi` is cardinality (array vs scalar). `type` is the element type. The query * builder (Store.facetDistribution / facetPredicate) derives the exact read shape * — raw column / json_extract / json_each / EXISTS — from `(source, multi)`, so * nothing above the store hardcodes which dimensions exist. * * Two sources of facets, both persisted to the `facets` table at analyze time so * the separate serve process can read them without importing processors: * - intrinsic facets (below): structural, present without any processor * - processor-declared facets: a processor's `facets` field (e.g. enrichment) */ /** Where a facet's value lives — implies its grain. */ type FacetSource = 'session' | 'annotation' | 'tool-call' | 'usage' | 'block'; type FacetType = 'string' | 'number' | 'boolean' | 'enum'; /** Where a facet may surface in the UI. */ type FacetRole = 'chart' | 'filter' | 'detail'; interface FacetSpec { key: string; label?: string; /** Element type (drives rendering); never 'array' — array-ness is `multi`. */ type: FacetType; source: FacetSource; /** * Physical column for session / tool-call / usage facets; defaults to `key`. * Unused for `annotation` (there `key` IS the annotation key). */ column?: string; /** Base predicate scoping rows for tool-call / usage facets, e.g. action='skill'. */ base?: string; /** * Array-valued (json_each) vs scalar. Only meaningful for session/annotation * storage; for tool-call/usage the to-many-ness is intrinsic to the grain. */ multi?: boolean; roles?: FacetRole[]; } /** * The measure registry: the "how much" axis, parallel to the facet registry. * A measure is an aggregation (`agg`) of an expression (`expr`) over the * population at its grain. Crossed with a facet (the "which" axis) by * Store.breakdown, it produces every " by " view. * * Like facets: intrinsic measures live here; processors add more via * Processor.measures; both persist to the `measures` table at analyze time so * the serve process discovers them without importing processors. * * `source` (reused from facets) says WHERE the value lives and implies the grain * (grainOf). `expr` is SQL over that source's anchor alias — s (sessions), * u (usage_facts), t (tool_calls). For `rate`, expr is a 0/1 (boolean) predicate. */ type MeasureAgg = 'sum' | 'count' | 'count_distinct' | 'avg' | 'rate'; interface MeasureSpec { key: string; label?: string; source: FacetSource; /** SQL over the anchor alias (s/u/t). For `rate`, a 0/1 boolean expression. */ expr: string; agg: MeasureAgg; /** Optional base predicate restricting the population. */ base?: string; format?: 'usd' | 'int' | 'pct'; } /** A JSON Schema object describing the structured output a completion must return. */ type JsonSchema = Record; interface StructuredRequest { system: string; user: string; /** JSON Schema for the result — the forced tool's input schema. */ schema: JsonSchema; /** Name of the single forced tool. */ toolName: string; maxTokens?: number; /** * Mark the (stable) system block as a prompt-cache breakpoint, for when the same * system prompt repeats across many calls in a run. Anthropic: adds cache_control; * OpenAI: no-op (auto-caches long prefixes). */ cacheSystem?: boolean; } interface LlmResult { /** The model's structured output (the forced tool's input), normalized by the caller. */ data: Record; usage: TokenUsage; } /** * Thin provider-neutral client. Enrichment uses a single structured completion * per session: the output schema is exposed as one forced tool call (the tool * input IS the result), which works identically across Anthropic and every * OpenAI-compatible endpoint — unlike provider-specific structured-output modes. * This requires a tool-call-capable model; non-tool models are unsupported. */ interface LlmClient { provider: string; model: string; completeStructured(req: StructuredRequest): Promise; } type DB = Database.Database; /** * Block-level attribution (handling_long_sessions). A long session is no longer * one unit of work — it ships several PRs, advances several features, moves * through several use-cases. We split each session's MAIN thread into a * deterministic partition of contiguous **blocks** so cost attributes at block * grain instead of being charged whole-session to every artifact it touched. * * Everything here is a pure function of the normalized `Session`, so it is * vendor-neutral (a new harness works once its adapter produces canonical * actions + sidechain links — see ARCHITECTURE.md). The partition is owned by the * `segment-blocks` processor, but `deterministicBlocks` is shared: outcomes-git * (block→PR) and enrich-session (block→use_case/feature) recompute it to know * which block their links/labels attach to, so all agree on `idx` without * cross-processor store reads. */ /** A tool action that closes a block (cost-attribution boundary). */ type BoundaryKind = 'commit' | 'pr_create' | 'pr_merge' | 'pr_review'; interface Block { idx: number; /** Inclusive main-thread seq of the block's first event. */ startSeq: number; /** Inclusive main-thread seq of the block's last event. */ endSeq: number; /** What closed the block. */ boundaryKind: BoundaryKind | 'user_turn' | 'session_end'; tsStart?: string; tsEnd?: string; } interface Logger { debug(msg: string): void; info(msg: string): void; warn(msg: string): void; error(msg: string): void; } interface EvidenceRef { sessionId: string; /** * Position within the session: the main-thread event `seq` assigned by * assignSeq() (core/blocks.ts) — the same coordinate blocks and the transcript * viewer use. Omit for session-level evidence */ turnIdx?: number; /** * Optional one-line, human-readable note for this occurrence (e.g. what * happened at this turn). Shown in the insight detail so each evidence row * reads as a specific occurrence, not just a session link. */ note?: string; } interface InsightInput { /** * Stable dedup key within this detector — same key on re-run updates the row, * not duplicates it. The key FORMAT is part of the detector's public contract: * the insight id is derived from it (see insightId), so changing the format * orphans past fix-prompt markers users already ran. Change it only with a * reason worth that cost. */ signalKey: string; /** * Scoping for this insight: * - repo name (e.g. 'tuneloop') — insight specific to that repo * - '*' — cross-repo insight (pattern spans multiple repos) * - cwd path — for sessions not in a git repo, use the working directory * - '_unknown' — fallback when neither repo nor cwd is available */ repo: string; severity: 'high' | 'medium' | 'low'; /** One-line card heading describing the problem. */ title: string; /** Longer explanation with evidence context — the "why should you care." */ description: string; /** Session (and optionally turn) pointers for drill-in links. Retained up to the store's EVIDENCE_CAP. */ evidence: EvidenceRef[]; /** Total occurrences — the real scale, independent of the evidence cap. */ count: number; /** * When the pattern was first/last actually observed (the real friction moments, * from the source events' timestamps). Optional: detectors that can't source a * real occurrence time omit them, and the store falls back to the analyze-run * time. Prefer supplying them — otherwise the dates read as "when we analyzed", * not "when it happened". */ firstSeenAt?: string; lastSeenAt?: string; fix: { /** Controls rendering: snippet gets a copy button, nudge gets plain prose, command gets a run prompt, fix-prompt gets a paste-into-agent-config prompt. */ type: 'config-snippet' | 'behavioral-nudge' | 'install-command' | 'fix-prompt'; /** Button/action text (short imperative, e.g. "Copy allowlist entry"). */ label: string; /** The deliverable: JSON config to paste, prose suggestion, or shell command. */ content: string; }; /** * One-line recommended action shown beneath the signal in the list — the "so do * this" that makes the tab read as recommendations, not just problems. Imperative, * verb-first, states what to change (not the problem). Optional: when a detector * can't produce one (e.g. recurring-themes' fix-generation fallback), the row shows * the signal alone. */ recommendation?: string; } /** A registry row: the spec plus who registered it ('user' marks editable fields). */ type RegisteredFacet = FacetSpec & { producer?: string; }; interface Dist { value: string; count: number; } /** One failed tool call in the "Errors by category" drill-down (see errorOccurrences). */ interface ErrorOccurrence { sessionId: string; title: string | null; idx: number; name: string; action: string; command: string | null; targetPath: string | null; message: string | null; ts: string | null; startedAt: string | null; /** * How many shell binaries the call's command involved — present only when the * query was scoped to one binary. `> 1` means this failure is listed under * several binaries and we can't say which segment failed, so the UI badges it * as compound and shows the whole command rather than guessing. */ binaryCount?: number; } /** * Which roster a tool_error_advice row belongs to — the `kind` column's only two * values. Spelled out here rather than imported from the server's `ToolKind`: the * store is the lower layer and never imports from `server/`. Narrow rather than * `string` so a transposed argument (`'sentry'` where a kind belongs) is a compile * error instead of a row that is written and then never read back. */ type ToolEntityKind = 'mcp' | 'builtin'; /** The cached LLM "Suggested fix" card for one tool/server (see tool_error_advice). */ interface ToolErrorAdviceRow { diagnosis: string; /** Paste-ready agent-instructions block; '' when the pass had nothing worth pasting. */ snippet: string; /** The failure set this was drafted from — the regenerate gate. */ evidenceHash: string; model: string | null; generatedAt: string | null; } interface Summary { sessions: number; costUsd: number; tokens: number; firstAt: string | null; lastAt: string | null; models: Array<{ model: string; count: number; }>; outcomes: Array<{ type: string; count: number; }>; topTools: Array<{ name: string; calls: number; errors: number; }>; costPerMergedPr: { count: number; costPerUnit: number | null; }; /** Spend on enrichment (the "cost of running the analysis itself"). */ analysisCostUsd: number; /** Whether LLM enrichment has run (any processor recorded an LLM model). */ enrichmentRan: boolean; /** ISO timestamp of the most recent `analyze` run (null if never recorded). */ lastAnalyzedAt: string | null; /** Source directories scanned, each with its own last-analyzed time (empty until an analyze runs on this schema). */ analyzedRoots: Array<{ source: string | null; path: string; lastAnalyzedAt: string | null; }>; /** Enrichment dimension distributions, empty when enrichment hasn't run. */ useCases: Dist[]; complexity: Dist[]; autonomy: Dist[]; features: { total: number; derived: number; linked: number; }; } /** One computed insight for the Highlights digest. The client maps `kind` to the * display sentence + its drill-in; the payload carries the data. */ interface Highlight { kind: string; [field: string]: unknown; } declare class Store { private db; private readonlyDb; constructor(db: DB); /** * Returns a readonly DB handle for detector queries. Opens lazily on first use. * SQLite enforces the read-only constraint at the engine level — any write attempt * (including DELETE...RETURNING, UPDATE...RETURNING, PRAGMA mutations) throws. * This is the handle detectors use via queryAll()/queryOne(). */ private getReadonlyDb; /** Read a value from the key-value `meta` table (undefined when absent). */ getMeta(key: string): string | undefined; /** Upsert a value into the key-value `meta` table. */ setMeta(key: string, value: string): void; /** * Stamp each source directory scanned this run with the run timestamp. Upsert, * so roots a scoped re-run didn't touch keep their prior stamp — the table then * answers "when was THIS directory last analyzed" per directory. */ recordAnalyzedRoots(roots: Array<{ source: string; path: string; }>, at: string): void; /** * Content hash + parse version for a session, if already ingested. Both feed * the re-ingest decision: content_hash catches changed transcripts, parse * version catches a smarter parser (new fields extracted from the same bytes). */ storedMeta(id: string): { hash: string; parseVersion: number; } | undefined; /** Set a session's resolved repo. Used to backfill repo without a full re-ingest. */ setSessionRepo(id: string, repo: string): void; ingestSession(session: Session, costUsd: number, facts: UsageFactInput[], priceTableVersion: string, parseVersion: number): void; /** * Token/cost rolled up by model from `usage_facts` — the honest cost-by-model * the `sessions.models` array can't give (exploding it double-counts cost). */ usageByModel(): Array<{ model: string; sessions: number; costUsd: number; tokens: number; }>; /** Prior run record for cache checks. */ processorRun(sessionId: string, processor: string): ProcessorRunRow | undefined; unresolvedArtifacts(producer: string): ArtifactInput[]; persistRefresh(producer: string, result: RefreshResult): void; /** * Persist one processor's output. Replaces this processor's prior rows for the * session (provenance via `producer`); never touches other processors' or * user-authored rows. Records the run for caching + analysis-cost accounting. */ persistResult(sessionId: string, processor: string, version: number, inputHash: string, model: string | null, result: ProcessorResult): void; /** * Apply an enrichment processor's hierarchy edits: REPARENT existing features. * (Auto-rename is intentionally unsupported — see FeatureRevisionInput.) Skips * user-authored features (locked) and any reparent that would form a cycle or * self-parent. Caller runs this inside a transaction. */ private applyFeatureRevisions; /** True if parenting `id` under `newParentId` would create a cycle (walks ancestors). */ private wouldCreateFeatureCycle; /** * The WHOLE feature hierarchy — what an enrichment processor needs to attach a * session to the most specific feature, slot a new feature under the right * parent, and refine the tree. The hierarchy is global and human-managed (a * single epic may span repos), so the processor sees everything; repo isolation * is enforced only on auto-derived *linkage* (see `repos`). `source` flags * user-authored features so the processor leaves them locked. * * `repos` = repos associated anywhere in a feature's subtree (itself + every * descendant), unioned from linked sessions and any explicit `repo` column. * Empty = unscoped/global. A feature is a safe auto-link target for a session * iff its `repos` is empty or already contains the session's repo. */ listFeatures(): Array<{ id: string; title: string; parentId: string | null; source: string | null; repos: string[]; }>; /** * Per-feature subtree repo set: the repos associated anywhere in a feature's * subtree (itself + every descendant), unioned from each feature's explicit * `repo` column and the repos of sessions linked to it. Shared by feature * extraction (linkage isolation) and the dashboard (the Features repo column). */ private featureRepoSets; /** Persist facets (intrinsic + processor-declared) so the dashboard discovers them generically. */ registerFacets(producer: string, specs: FacetSpec[]): void; summary(): Summary; /** The single most significant week-over-week move to lead the digest with: * compares this window's headline KPIs (spend, success rate, session count) to * the prior equal-length window and returns the biggest mover — normalized to * how many times over its own "notable" bar each one is, so one metric type * can't dominate just because its natural swings run larger. Null when nothing * clears its bar, or the prior window's base is too thin to trust the delta. * Only called for a bounded [from, to). */ private trendHeadline; /** The windowed "reliable facts" behind the Highlights digest — most-spend * shipped artifact, its stalled (not-yet-shipped) counterpart, converted spend, * and the busiest source file — all scoped to [from, to) (omit for all-time) so * the whole digest honors the dashboard window. */ private windowedFacts; /** The Highlights digest: a few reliably-interesting facts plus facet-WALKED * comparisons (spend concentration, outcome-rate spread) — for each, we go down * an ordered facet list and keep the FIRST facet whose breakdown clears an * interestingness threshold, so nothing is hardcoded to `repo` and a dominated * split (e.g. one harness at 99%) is skipped. Each insight is a typed payload; * the client renders the sentence + drill-in. `from`/`to` window everything; * omit for all-time. */ highlights(from?: string, to?: string): Highlight[]; /** Distribution of a scalar annotation value across sessions. */ private scalarDist; /** * Windowed cost-per-shipped-artifact KPI (no window = all time). The numerator is * the cost of the BLOCKS that produced each in-window completed artifact (block→PR * is deterministic; block→feature is the LLM feature_runs). Blocks partition the * session, so a session that also did unshipped/other work is NOT charged whole — * the old unique-session approximation dissolves (handling_long_sessions P1/P2). * Falls back to whole-session cost for any artifact with NO block links (a feature * the model never block-linked, or pre-block data). Both paths are at usage grain * and UNION-deduped, so a usage row shared across in-window artifacts counts once. */ costPerArtifact(kind: string, from?: string, to?: string, complexity?: string): { count: number; costPerUnit: number | null; }; /** * The headline KPI row for one time window. Session-grain metrics (count, * spend, outcome rate) window by session start; cost-per-artifact windows by * completion (see costPerArtifact). The API calls this twice — current and the * same-length prior period — to derive deltas. No window = all time. */ kpis(from?: string, to?: string, outcomes?: string[]): KpiSnapshot; /** * The two decomposition curves for the cost-per-artifact section * (cost_per_shipped_artifact.md). Both are PURE SUMS (0 is a real value, no * attribution): burn = AI spend per bucket dated at SESSION time (with a * `shippedSpend` sub-band = spend of sessions linked to a completed `kind` * artifact — the gap to `spend` is in-flight/never-shipped spend); throughput * = count of `kind` artifacts per bucket dated at COMPLETION. Both honor the * optional window (burn by session start, throughput by completion); no window * = full history. The `bucket` granularity is the caller's (day/week/month). */ costCurves(kind: string, bucket: Bucket, from?: string, to?: string, complexity?: string): { burn: Array<{ bucket: string; spend: number; shippedSpend: number; }>; throughput: Array<{ bucket: string; count: number; }>; /** PRs reviewed per bucket, dated at REVIEW time (the pr_reviewed outcome ts). PRs only. */ reviewed: Array<{ bucket: string; count: number; }>; buckets: string[]; }; /** * The complete ordered list of bucket labels spanning [from, to] at the given * granularity. Walks one calendar day at a time in SQL and buckets each with * the same expression as the data, so the labels match exactly (no JS attempt * to reproduce SQLite's %W week numbering). Used to give the cost curves a * continuous x-axis across the window. */ private bucketAxis; /** * The x-axis for a windowed time series: every bucket from `from` to `to` * (so the chart spans the whole window and empty periods show as gaps), * unioned with the data's own buckets as a safety net. No window → the data's * buckets as-is (all-time). Shared by the dashboard time-series endpoints. */ private fullAxis; /** * The "burn efficiency" lens for a window: Σ session spend in the window ÷ * count of `kind` artifacts completed in the window. Deliberately distinct * from the unit-cost KPI (whose numerator includes pre-window spend) — the doc * insists both be shown so dividing the curves doesn't read as a contradiction. * `throughput` here equals the KPI denominator exactly. No window = all time. */ costPeriod(kind: string, from?: string, to?: string, complexity?: string): { burn: number; throughput: number; efficiency: number | null; }; /** * Spend over time, optionally split into one series per facet value — the doc's * non-headline "total spend breakdown" / burn view (cost_per_shipped_artifact.md * §Separate). Anchored on usage_facts so cost-by-model splits HONESTLY (each * usage row attributed to its own model), not by charging a multi-model * session's whole cost to each model. Spend is dated at session start (matching * the burn curve). Only usage/session-grain facets are valid (the cost measure's * grain guard); tool-call facets (skill) are rejected. Multi-valued facets * (use_case) presence-inflate — flagged via `presenceInflated`. */ spendOverTime(q: SpendOverTimeQuery): SpendOverTimeResult | { error: string; }; /** * Session COUNT over time, optionally split into one series per COMPOSITE label * — the time-series form of the distribution cards. Each session is labeled by * the sorted set of its distinct values for the dimension (e.g. ) * and grouped by it, so every session lands in exactly one series and the * counts partition the total (honest to STACK) — no presence-inflation. The * tail past top-K collapses into "Other". */ sessionsOverTime(q: SessionsOverTimeQuery): SessionsOverTimeResult; /** * Outcome types present in the data, with the count of distinct sessions that * produced each — feeds the success-rate "what counts as success" selector. * (A first-class outcome-type registry, parallel to facets/measures, is a * deferred follow-up; for now the selector reflects what's actually in the DB.) */ outcomeTypes(): Array<{ type: string; sessions: number; }>; /** * Session Outcome Rate over time (headline_metrics.md): the fraction of * sessions — cohorted by START date — that produced any outcome in the * selected set. Numerator = sessions with an outcome in `outcomes`; denominator * = all sessions in the bucket. Session-level filters apply to BOTH (so the * rate is honest). With `by`, returns one series per COMPOSITE label (top-K by * volume, the tail collapsed into "Other"): each session is labeled by the * sorted set of its distinct values for the dimension (e.g. ), so * every session falls in exactly one series and the bars partition the * population — multi-valued sessions are counted once, not fanned out. */ successRate(q: SuccessRateQuery): SuccessRateResult; /** Spend, session count, and shipped-PR count per time bucket. */ timeseries(bucket: Bucket, from?: string, to?: string): TimePoint[]; /** The facet registry — drives dist cards, filters, and (later) breakdowns. */ facetList(): RegisteredFacet[]; facet(key: string): RegisteredFacet | undefined; /** * Sessions per value of a facet — the generic dist card. The read shape is * derived from (source, multi): raw column, json_each, json_extract, or a child * table. This is a COUNT, so exploding a multi-valued facet is safe (a session * present under two values is intended); SUM measures are a separate concern. */ facetDistribution(key: string): Dist[]; /** * Compile a facet + value into a session-scoped boolean SQL fragment (alias `s`). * One compiler, reused by session filters today and cohort splits later. Column * identifiers and `base` are registry-defined (trusted); the value is a bound param. */ private facetPredicate; /** * Correlated subquery (alias `s`) yielding a session's DISTINCT values for a * facet as one alpha-sorted, ", "-joined string — the composite label for the * success-rate breakdown; empty set → NULL. Mirrors facetPredicate's source * switch (identifiers/base are registry-defined and trusted, values are data). */ private comboExpr; /** Persist measures (intrinsic + processor-declared) for the dashboard. */ registerMeasures(producer: string, specs: MeasureSpec[]): void; measureList(): MeasureSpec[]; measure(key: string): MeasureSpec | undefined; /** * The breakdown engine: aggregate a measure, optionally grouped by a facet, * with session-scoped filters. The grain guard keeps SUM/AVG honest — a facet * is valid here only at the measure's grain or session-grain (the common * ancestor). Finer / sibling facets need the pre-reduction (cohort) path, not * built yet, and return an error rather than a silently double-counted number. */ breakdown(measureKey: string, byFacetKey?: string, filters?: Record, window?: { from?: string; to?: string; }, toolNames?: string[]): { rows: Array<{ bucket: string | null; value: number; }>; total: number; } | { error: string; }; /** * Every failed tool call of one error category — the occurrence list behind the * "Errors by category" drill-down. Newest session first; `idx` is the tool call's * position in its session, which the transcript anchors as `txerr-` so a row * deep-links to that exact error block. Windowed like breakdown. Capped at 50; the * widget shows the true total (the bar count) with a "+N more" note past the cap. */ errorOccurrences(category: string, window?: { from?: string; to?: string; }, toolNames?: string[], opts?: { /** * Also match calls whose shell command INVOLVED this binary (via * tool_call_commands) — the tools tab's per-binary drill-in, where a * compound `npm ci && npm test` must show up under `npm`. OR'd with * `toolNames` when both are given, since one entity can be both. */ shellBinary?: string; /** * Which clock windows the rows. 'session' (default) keeps the Ops widget's * long-standing behavior; 'tool' dates each row by when the call actually * ran, which is the clock the tools tab uses everywhere. */ clock?: 'session' | 'tool'; /** * Restrict to one harness. The tools tab reports one source at a time, so * without this its per-category bar counts (source-scoped) and this list * (all sources) disagree — a `git` failure in a Pi session showing up under * the Claude Code roster's count of 3 as a 4th row. */ source?: string; }): ErrorOccurrence[]; /** * WHERE + params for a session filter, shared by sessionList and sessionCount * so the page rows and the pager total always agree. */ private sessionWhere; /** Total sessions matching a filter — the denominator for sessionList's pager. */ sessionCount(f: SessionFilter): number; /** Filtered session list. Filter VALUES are bound params; keys are hardcoded. */ sessionList(f: SessionFilter): SessionListItem[]; /** Full detail for one session, including a viewer-ready transcript from the blob. */ /** Per-block labels (use_case / PR / feature) for the transcript filter bar. */ private blockLabels; sessionDetail(id: string): SessionDetail | null; /** * The session's value for every facet flagged for the `detail` role — the * registry-driven metadata list the drawer renders. Keeps the drawer from * hardcoding which dimensions exist: a new processor facet with a `detail` * role appears here with no store or client edits. Ordered by registration * (intrinsic first, then processors) rather than alphabetically. */ facetValues(id: string): FacetValue[]; /** Resolve one facet's value(s) for a session, branching on (source, multi) like facetPredicate. */ private facetValueFor; /** Gunzip + parse a session's stored blob (the full normalized Session), or null. */ private loadSession; /** * A numbered one-line-per-block digest of a session's main thread (see * blockSpine) plus the block partition it was rendered from: each block's * opening user turn, a compact action summary, and its boundary tag. * Reconstructs the partition from the blob at read time. Returns null when the * session's blob is missing or unreadable. * * The `blocks` ride along so a caller reads the count and each block's startSeq * from the same partition the digest was rendered from, rather than re-parsing * the string or re-querying the stored blocks table (which can lag the blob). * * Recomputed on demand rather than stored: it's cheap for the few sessions a * P-tier detector inspects, and hands a detector the block digest without * exposing the full transcript (loadSession stays private). */ blockDigest(id: string): { digest: string; blocks: Block[]; } | null; /** * The session's successful file edits as a flat, CHRONOLOGICAL list — the * Files-changed view. Each carries its raw before/after (Edit), full content * (Write), or hunks (MultiEdit), plus the transcript turn it happened in and * the preceding (non-synthetic) user turn, so the UI can group by file or by * prompt and link each change to its intent. Rejected / not-yet-read edits are * excluded (they changed nothing). Reconstructs from the blob at read time. */ fileChanges(id: string): FileEdit[]; /** * Shippable artifacts (PRs + features) with session count and fully-loaded * cost. Cost sums the UNIQUE sessions linked to each artifact; a session * spanning several artifacts is counted in each, so the column can exceed * total spend (per-artifact attribution, by design). */ artifactList(kind?: string, complexity?: string, from?: string, to?: string, shippedOnly?: boolean): ArtifactListItem[]; /** * Per-feature last session time: the most recent start of any session linked to * the feature OR any descendant (subtree max), so a parent reflects the latest * activity beneath it. Null when nothing under it has a dated session. */ private featureLastSession; /** * Per-feature spend in the window, rolled up over the feature hierarchy, for the * hierarchical cost-breakdown charts. `ownCost` is the spend attributed DIRECTLY * to a feature — block-attributed like the artifactList cost column, but bounded * to sessions STARTED in [from,to] (the same window basis as the headline KPIs); * `subtreeCost` adds every descendant's own cost, so a parent epic reflects the * total spent beneath it this window (subtreeCost − Σ children.subtreeCost = * ownCost). The window scopes the SPEND, not the feature set: a feature appears * iff it (or a descendant) has spend in the window, shipped or not — so the * breakdown answers "where did feature work go this window", deliberately NOT a * decomposition of the cost-per-shipped-feature KPI. Ancestors of active * features are kept (ownCost 0) so the hierarchy stays intact. No window = * all-time spend. Cycle-safe + memoized, mirroring featureLastSession. parentId * is normalized to null when the parent isn't in the result, so the client can * treat such rows as roots. */ featureCostTree(complexity?: string, from?: string, to?: string): Array<{ id: string; title: string | null; parentId: string | null; ownCost: number; subtreeCost: number; }>; /** * Build a SQL condition + params for artifact text search that handles plain * terms, `#N` (PR number with hash prefix), and `repo#N` (repo + number). */ private artifactSearchCond; /** * Typeahead suggestions for the session-list artifact search. Only artifacts * actually linked to a session (so a pick yields results), matched on the same * columns the filter uses (ident/title/external_id/repo). `value` is what to * put in the filter input (feature→title, pr→external_id|ident, file→path); * `label` is for display. Features/PRs rank above the many file rows. */ suggestArtifacts(q: string, kind: string | undefined, limit?: number): Array<{ kind: string; value: string; label: string; }>; /** Create a user-authored feature (source='user' — never clobbered by analyze). */ createFeature(title: string, parentId?: string, complexity?: number): { id: string; }; /** Mark complete/reopen, rename, reparent, or set complexity of a feature. */ updateFeature(id: string, patch: { completed?: boolean; parentId?: string | null; title?: string; complexity?: number | null; }): boolean; /** Delete a feature; promote its children to its parent and remove its links. */ deleteFeature(id: string): boolean; /** * Fold a derived feature `fromId` into `toId`: repoint every reference (session & * block links, artifact links, outcomes, reject tombstones, and child features' * parent) onto `toId`, keep the earliest mint time on the survivor, then delete the * `fromId` row. No-op (returns false) when either id is missing/not a feature, they * are equal, or `fromId` is user-authored (those are never absorbed). * * Mirrors applyThemeMerge, but features live in shared tables with composite PKs * (session_artifacts/block_artifacts include artifact_id), so a blind repoint can hit * a UNIQUE collision when the target already shares that (session/block, role) link — * hence UPDATE OR IGNORE to move the non-colliding rows, then DELETE the leftovers. */ mergeFeature(fromId: string, toId: string): boolean; /** * All derived (non-user) features with their mint time and session-usage count — * the reconcile pass's input. `createdAt` is the earliest minting session's start * time (see the feature upsert); `sessions` counts distinct linked sessions, a * canonical-choice tiebreaker. */ derivedFeaturesForReconcile(): Array<{ id: string; title: string; repo: string | null; parentId: string | null; createdAt: string | null; sessions: number; glosses: string[]; }>; /** * Reparent a derived feature under `parentId` (null = top-level). No-op (returns * false) for a missing/user feature, a self-parent, or an edge that would create a * cycle. Mirrors applyFeatureRevisions' guards for use from the reconcile pass. */ setFeatureParent(id: string, parentId: string | null): boolean; /** * Delete machine-derived artifacts no longer referenced by any session or * link (e.g. PRs whose false-positive links were removed on re-derivation). * Never touches user-authored artifacts. */ /** * Delete sessions whose parse_version is below the current version for their * source — these are sessions the parser now returns null for (e.g. synthetic-only). */ pruneStaleSessionsByVersion(versionBySource: Map): number; pruneOrphanedBranchSessions(prefix: string, currentIds: Set): number; pruneOrphanArtifacts(): number; /** * Define a user tag field: a session-grain annotation facet owned by producer * 'user'. Values live in `annotations` under processor 'user', so the whole * facet pipeline (distribution, filters, breakdowns) reads them with no new * query shapes, analyze-time processor wipes never touch them, and * registerFacets' per-producer sync never sweeps the registry row. Inserted * directly (not via registerFacets) because that method syncs a producer's * full set — registering one field through it would delete the others. */ createUserFacet(name: string, label?: string): { facet: FacetSpec; } | { error: string; }; /** * Set (or clear, with value null) a user tag on every session matching the * filter — the same filter the session list compiles, so "tag all N matching" * and the list the user is looking at can never disagree. Returns null for a * key that isn't a user-owned facet (intrinsic/processor facets are read-only). */ setUserTag(filter: SessionFilter, key: string, value: string | null): { updated: number; } | null; /** Remove a user-defined facet and every tag value stored under it. */ deleteUserFacet(key: string): boolean; /** Link an existing artifact to a session (user-authored, never overwritten by processors). Clears any prior rejection tombstone. */ addSessionLink(sessionId: string, artifactId: string, role?: SessionArtifactRole): boolean; /** Reject a session→artifact link: delete existing rows and insert a tombstone so re-enrichment won't recreate it. */ rejectSessionLink(sessionId: string, artifactId: string): boolean; /** * Mark every processor run for a session stale so the next analyze re-runs them. * * Called on any user link/unlink. The user-linked set feeds enrichment, and * unlink deletes block_artifacts across producers, so the deterministic * processors (outcomes-git) must re-derive too — enrich-only invalidation * would leave their wiped rows unregenerated. We flag rather than delete so * the row's cost_usd/tokens survive; persistResult resets the flag on the * next successful run. Cost: at most one extra analyze per explicit user * action (not on every analyze). */ private invalidateSessionProcessors; /** * The single writer for user-created session links. Every dashboard link path * funnels through here so the write and its cache invalidation can never drift * apart (that drift is how add-pr/create-feature previously skipped it). Call * within the caller's own transaction so the link and invalidation commit atomically. */ private linkUserArtifact; /** Titles of features the user rejected for this session (for LLM prompt context). */ rejectedFeatureTitles(sessionId: string): string[]; /** Blocks already attributed to PRs for a session (deterministic, from outcomes-git). */ prBlockAttributions(sessionId: string): Array<{ blockIdx: number; artifactId: string; title: string | null; }>; /** All user-linked PRs/features for a session, with a flag indicating deterministic block ownership. */ userLinkedArtifactsAll(sessionId: string): Array<{ artifactId: string; kind: 'pr' | 'feature'; title: string | null; ident: string | null; hasNonEnrichBlocks: boolean; }>; /** Create a new feature and link it to a session in one transaction. */ createAndLinkFeature(sessionId: string, title: string, parentId?: string): { id: string; } | null; /** Upsert a PR artifact and link it to a session. */ upsertAndLinkPr(sessionId: string, repo: string, prNumber: string, meta?: { title?: string; status?: string; externalId?: string; }): { id: string; } | null; /** Typeahead for linkable artifacts (excludes those already linked to the session). */ suggestLinkableArtifacts(sessionId: string, q: string, kind?: string, limit?: number): Array<{ id: string; kind: string; label: string; }>; /** * Read-only query helper for detectors. Uses a separate readonly DB handle so * writes are rejected at the SQLite engine level — not just by convention. * Detectors can ask any question across all sessions but cannot mutate data. */ queryAll(sql: string, ...params: unknown[]): unknown[]; queryOne(sql: string, ...params: unknown[]): unknown; /** * Hydrate a full `Session` from its stored blob for a P/X-tier detector — the * content SQL-only detectors can't reach. Null when the blob is absent/corrupt. */ hydrateSession(id: string): Session | null; detectorRun(detector: string): DetectorRunRow | undefined; /** * The model of this detector's last SUCCESSFUL run — the one whose extractions are * actually in the store. Distinct from `detectorRun().model`, which is the latest * run's and is null after an error: a failed run may have burned tokens, but it * persisted nothing, so it can't speak for what the stored insights were made with. * Null when the detector has never succeeded, or ran without an LLM (S-tier). */ detectorLastSuccessfulModel(detector: string): string | null; /** * Returns session IDs that a detector hasn't seen yet or whose content has changed * since the detector last processed them. Used by P/X-tier detectors to compute * the delta — only run expensive LLM analysis on new/changed sessions. */ detectorUnseen(detector: string): Array<{ sessionId: string; contentHash: string; }>; /** * Mark sessions as seen by a detector at their current content hash. * Called after a P/X-tier detector has processed a session's data. */ markDetectorSessionSeen(detector: string, sessions: Array<{ sessionId: string; contentHash: string; }>): void; /** * Forget a detector's per-session tracking so its whole corpus counts as unseen * again. The runner calls this when a P/X-tier detector's version changed since * its last run: a new prompt/schema must re-extract every session, not just the * content-hash delta. Themes themselves are untouched — re-extraction re-matches * against them (stable ids), it doesn't wipe the taxonomy. */ resetDetectorSessionRuns(detector: string): void; /** * Upsert this run's kitchen-sink verdicts (positive AND negative) into their * permanent home, keyed on session id. INSERT OR REPLACE so a session re-judged * after a content change (or a corrected verdict) overwrites its prior row — a * positive→negative flip is a plain upsert that drops it from the windowed card. */ recordKitchenSinkVerdicts(verdicts: KitchenSinkVerdictInput[]): void; /** * The kitchen-sink card's data, as a windowed projection of the verdict table: * every POSITIVE session whose `started_at` falls in the trailing window * (`windowStartIso` = now − WINDOW_DAYS), most-recent first — the evidence + count. * `lastSeenAt` is the max over that windowed set; `firstSeenAt` is the earliest * over ALL positives (whole history), so a chronic pattern keeps its true origin * date even though the count/evidence are windowed. Both null when * there are no positives at all. */ kitchenSinkPositives(windowStartIso: string): { positives: Array<{ sessionId: string; splitBlockIdx: number | null; splitSeq: number | null; reason: string | null; }>; firstSeenAt: string | null; lastSeenAt: string | null; }; /** * Themes visible to a session's extraction: its repo's themes + globals (repo * NULL). Fed into the prompt as the existing-theme list so the model matches * before minting (assign-at-extraction). Ordered oldest-first so the merge * pass's "keep the older id" rule has a stable reference. */ listThemes(repo: string | null): ThemeRef[]; /** Every theme (all repos + globals) — the merge pass's input. */ allThemes(): ThemeRef[]; /** * Persist one session's extraction: upsert referenced themes (OR IGNORE keeps * identity stable — re-minting an existing id never renames/retypes it), replace * that session's events, then prune derived themes left with no member events. * All-in-one transaction so a session's events and their themes commit together. */ persistThemeExtraction(sessionId: string, themes: ThemeInput[], events: ThemeEventInput[]): void; /** * Apply one theme merge: re-point every member event of `dropId` to `keepId`, * then delete the absorbed theme. Only derived themes are absorbed. Returns * false if either id is missing or they're equal. (Rewording the keeper is a * separate step — see retitleTheme.) */ applyThemeMerge(keepId: string, dropId: string): boolean; /** * Fold dropId into keepId AND retire the dropped theme's insight as one atomic unit. * Wrapping both in a single transaction (nested via savepoints) means a crash between * them can't leave the deleted theme's insight orphaned as a frozen surfaced duplicate. */ applyThemeMergeAndRetire(keepId: string, dropId: string, detector: string): boolean; /** * Friction events the extractor recorded but couldn't confidently attach to a * theme (varied wording, or a sibling session minted the theme concurrently so * it wasn't yet visible). Grouped by session repo so the reconcile pass can scope * a minted theme correctly. Most recent first. */ orphanThemeEvents(): Array<{ sessionId: string; idx: number; repo: string | null; type: string; description: string; }>; /** Attach a previously-orphaned event to a theme (the reconcile pass's write). */ assignThemeEvent(sessionId: string, idx: number, themeId: string): void; /** Mint a derived theme if absent (INSERT OR IGNORE — never renames an existing id). */ ensureTheme(input: ThemeInput): void; /** Rewrite a derived theme's wording (label/description). Never touches a user theme. */ retitleTheme(id: string, label?: string, description?: string): void; /** * Every theme with its member events aggregated — the surfacing step's input. * `sessionCount` and `eventCount` drive the recurrence threshold; `descriptions` * and `evidence` feed the insight's copy + drill-in pointers. */ themesWithEvents(): Array<{ id: string; label: string; description: string | null; type: string; remedy: string | null; repo: string | null; resolved: number; fixType: string | null; fixContent: string | null; fixRecommendation: string | null; fixHash: string | null; eventCount: number; sessionCount: number; evidence: Array<{ sessionId: string; turnSeq: number | null; description: string; }>; descriptions: string[]; firstSeenAt: string | null; lastSeenAt: string | null; }>; /** * The cached "Suggested fix" card for one tool/server, or null when the * tool-error-advice pass hasn't produced one (no LLM configured, the entity * never earned a high-error pill, or the pass declined it). The dashboard * hides the card entirely in that case rather than showing an empty section. */ toolErrorAdvice(source: string, kind: ToolEntityKind, name: string): ToolErrorAdviceRow | null; /** Cache (or refresh) one entity's advice card, keyed on the evidence it was drafted from. */ setToolErrorAdvice(source: string, kind: ToolEntityKind, name: string, a: { diagnosis: string; snippet: string; evidenceHash: string; model?: string; }): void; /** Cache a theme's LLM-generated fix (+ its one-line recommendation) and the hash of the occurrence set it was built from. */ setThemeFix(id: string, fixType: string, fixContent: string, fixRecommendation: string | null, fixHash: string): void; /** * The lifecycle state + last-persisted occurrence count of an existing insight, * or null if none exists yet. A detector reads this before re-surfacing a theme * so a dismissed insight stays gone and a resolved one only reopens on a GENUINE * recurrence (new occurrences) */ insightStatus(detector: string, repo: string, signalKey: string): { state: InsightState; count: number; } | null; /** * Retire a theme's insight so it stops showing — used when the theme was absorbed * by a merge (its id is gone) OR when the fix pass later vetoes it (no longer worth * surfacing). Marked resolved with a state-log entry, not deleted, so its history * and any adoption survive. No-op if there's no insight for that theme or it's * already terminal. */ retireInsightForTheme(detector: string, signalKey: string): void; /** * Resolve an insight by its (detector, repo, signalKey) triple — for a detector that * stops emitting a still-open insight, so no stale surfaced row lingers. No-op if absent or already terminal. */ resolveInsight(detector: string, repo: string, signalKey: string): void; /** * Fetch the actual user-turn text for a set of (sessionId, seq) evidence * pointers, live from the session blobs — so merge/fix prompts can show the * user's real words without storing a snippet copy. Hydrates each session once. * Missing/pruned turns are simply omitted. Text is returned verbatim (callers clip). */ turnTexts(refs: Array<{ sessionId: string; seq: number | null; }>): Map; persistInsights(detector: string, version: number, inputs: InsightInput[], cost?: { inTokens: number; outTokens: number; usd: number; model?: string; }): void; /** * Append a failed run to the log. Model and cost columns are explicitly NULL: * the run produced nothing, so it carries no accounting of its own — and because * this appends rather than upserts, it cannot blank the columns of the successful * run before it (which `detectorLastSuccessfulModel` and the spend total read). */ persistDetectorError(detector: string, version: number): void; /** * Write an insight's evidence rows (assumes any prior rows were cleared). * Capped generously: the insight card shows a few chips, but the detail view * lists every occurrence, so we keep enough to be useful without unbounded rows. */ private writeEvidence; /** * Every stored occurrence for an insight — the detail view's drill-in list. * Joins the session's display title so each row reads as "what happened, in * which session" and links to the transcript turn (turn_idx = main-thread seq). */ insightEvidence(insightId: string): Array<{ sessionId: string; turnIdx: number | null; note: string | null; sessionTitle: string | null; }>; /** * Distinct repos of the sessions in a cross-repo ('*') insight's evidence — the * repos that CONTRIBUTED to the last-surfaced card. A resolve sweep uses this to ask * whether each previously-contributing repo now has enough data to call clean, rather * than trusting a corpus-wide total that many sub-threshold repos could reach together * (or a different repo's data that says nothing about a still-quiet contributor). Uses * the same repo derivation the detectors do (repo ▸ cwd ▸ '_unknown'). Empty when the * insight has no evidence (e.g. never surfaced) or its evidence sessions were pruned. */ insightEvidenceRepos(insightId: string): string[]; insights(opts?: { state?: InsightState; detector?: string; repo?: string; limit?: number; }): InsightRow[]; dismissInsight(id: string): boolean; transitionInsight(id: string, newState: InsightState): boolean; /** Append one row to the lifecycle history. from = null means first surface. */ private logInsightState; /** Current-cycle adoption time from the state log — the fallback when the fix session's sightings were pruned with it. */ private adoptedAtFromLog; /** * Event-time lower bound of the insight's current cycle: the resolve that * preceded the latest reopen (null when never reopened). A fix applied after * that resolve can only belong to the current cycle; one applied before it is * a previous cycle's fix. Deliberately NOT the reopen timestamp — reopens are * logged at processing time, which can postdate a genuine re-fix's event time * (paste yesterday, analyze today). Shared by reconcile and the read path so * they can't disagree on what "current cycle" means. */ private insightCycleBoundary; /** * Persist the fix-marker sightings for one session. matched_at is intentionally NOT preserved * across replaces: reconcile re-stamps it, since "the claimed id exists" is re-checkable. */ recordFixMarkerSightings(sessionId: string, sightings: FixMarkerSightingInput[]): void; /** * Interpret unmatched sightings: a marker sighting whose claimed insight exists * means the user ran that insight's fix-prompt — walk the insight to `adopted` * (marker presence proves the fix was issued). Runs after the detector phase in * analyze so insights created in the same run are visible. Idempotent: sightings * on already-adopted/resolved/dismissed insights are matched without transitions; * unknown ids stay unmatched and are retried next run (self-heals after rebuilds). * Returns the number of insights newly flipped to adopted. */ reconcileFixSightings(): number; /** * Append-on-change write of one category's config snapshot. Hashes the payload * and compares to the latest stored state for (source, scope, scope_key, * category): an unchanged hash just bumps `last_observed_at` (no new row), so a * config that holds steady across many analyze runs stays one row; a changed * hash appends a new row, building the dated change timeline. `captured_at` marks * when a state first appeared; `last_observed_at` the most recent run that saw it. * * `now` defaults to the current time; callers (tests) may pass an explicit * timestamp to control the timeline and keep `captured_at` unique across writes. */ recordEnvSnapshot(input: EnvSnapshotInput, now?: string): void; /** The current config state for a key — the newest snapshot by captured_at, or null if none. */ envSnapshotCurrent(source: string, scope: string, scopeKey: string, category: string): EnvSnapshotRow | null; /** * Point-in-time read: the config state as of `at` (the newest snapshot with * captured_at <= at). Per-session detectors MUST use this rather than "current", * so an old session isn't judged against today's config. `stale` is true when no * snapshot precedes `at` — we never observed the config that early, so the caller * should abstain or down-weight rather than treat the (absent) result as fact. */ envSnapshotAsOf(source: string, scope: string, scopeKey: string, category: string, at: string): EnvSnapshotAsOf; /** * Full snapshot history for a key+category, oldest→newest. Each row is an * append-on-change state (a new row only when the whole-category payload changed), * so diffing consecutive rows' per-item content recovers the edit timeline. Used by * the skill drift feature to reconstruct per-skill version boundaries. */ envSnapshotHistory(source: string, scope: string, scopeKey: string, category: string): EnvSnapshotRow[]; /** * Distinct categories with any stored snapshot for a key. Used by capture to * detect deletions: a category with history that a successful read no longer * returns has been removed from disk and gets a null tombstone snapshot. */ envSnapshotCategories(source: string, scope: string, scopeKey: string): string[]; close(): void; } interface ArtifactListItem { id: string; kind: string; title: string | null; ident: string | null; repo: string | null; /** Repos this artifact spans — a feature's full subtree union; one entry for a PR. */ repos: string[]; /** Most recent linked session start; for a feature, the max across its subtree. Null if none. */ lastSessionAt: string | null; status: string | null; source: string | null; externalId: string | null; /** PR creation time (from `gh`); null when not captured (offline / pre-backfill). */ createdAt: string | null; completedAt: string | null; parentId: string | null; complexity: number | null; complexityBasis: string | null; sessions: number; costUsd: number; /** Max content-match AI-attribution fraction across the artifact's session links (0–1); * null when no content-match link exists (e.g. an explicit-only PR). PRs only. */ aiPct: number | null; } /** One window's worth of headline KPIs (see Store.kpis). */ interface KpiSnapshot { sessions: number; totalSpend: number; /** Fraction of sessions judged success; null when the window has no sessions. */ successRate: number | null; costPerFeature: { count: number; costPerUnit: number | null; }; costPerPr: { count: number; costPerUnit: number | null; }; } /** One bucket of a success-rate series: the cohort's numerator/denominator/rate. */ interface RatePoint { bucket: string; num: number; denom: number; /** Total session cost (USD) in the bucket — feeds the per-value cost table * (total spend, and $/session = spend/denom). Summed over the SAME population * as `denom` (all sessions, not just successful ones), so spend/denom is an * honest avg cost per session. */ spend: number; /** num/denom, or null when the bucket has no sessions (drawn as a gap). */ rate: number | null; } /** A success-rate line: per-bucket points plus the windowed totals/rate. */ interface RateSeries { key: string; points: RatePoint[]; num: number; denom: number; /** Window-total session cost (USD); spend/denom = avg cost per session. */ spend: number; rate: number | null; } interface SuccessRateQuery { /** Outcome types counting as success (numerator). Empty → ['session_success']. */ outcomes: string[]; bucket: Bucket; /** Facet key to split into one series per value (top-K by volume). */ by?: string; from?: string; to?: string; /** Session-level facet filters (multi-value OR within a facet), applied to * numerator and denominator alike. */ filters?: Record; topK?: number; } interface SuccessRateResult { outcomes: string[]; bucket: Bucket; /** The x-axis: every bucket label the overall line spans. */ buckets: string[]; overall: RateSeries; /** Present when `by` is set. */ series?: RateSeries[]; /** Set when more facet values existed than were drawn. */ truncated?: { shown: number; total: number; }; } /** One bucket of a session-count series. */ interface CountPoint { bucket: string; count: number; } interface CountSeries { key: string; points: CountPoint[]; total: number; } interface SessionsOverTimeQuery { bucket: Bucket; by?: string; from?: string; to?: string; filters?: Record; topK?: number; } interface SessionsOverTimeResult { bucket: Bucket; buckets: string[]; overall: { points: CountPoint[]; total: number; }; series?: CountSeries[]; truncated?: { shown: number; total: number; }; } /** One bucket of a spend series. */ interface SpendPoint { bucket: string; spend: number; } /** A spend line: per-bucket points plus the total over the range. */ interface SpendSeries { key: string; points: SpendPoint[]; total: number; } interface SpendOverTimeQuery { bucket: Bucket; /** Facet key to split into one series per value (top-K by total spend). */ by?: string; from?: string; to?: string; filters?: Record; topK?: number; } interface SpendOverTimeResult { bucket: Bucket; buckets: string[]; overall: { points: SpendPoint[]; total: number; }; series?: SpendSeries[]; truncated?: { shown: number; total: number; }; /** True when the breakdown facet is multi-valued, so series sum past overall. */ presenceInflated?: boolean; } type Bucket = 'day' | 'week' | 'month'; interface TimePoint { bucket: string; sessions: number; spend: number; shipped: number; } interface SessionFilter { /** facetKey -> value; compiled to predicates via the facet registry. */ facets?: Record; /** Restrict to exactly these session ids (single-session tag writes; hand-picked sets later). */ ids?: string[]; q?: string; /** Match sessions linked to an artifact whose path/PR/url/repo/feature-title matches. */ artifact?: string; /** Restrict the artifact match to a kind: file | pr | feature | ticket | commit. */ artifactKind?: string; /** Window on session start (ISO); inclusive lower / exclusive upper bound. */ from?: string; to?: string; /** Match sessions that produced ANY of these outcome types (OR). */ outcomeTypes?: string[]; limit?: number; /** Sort column: started (default) | cost | title | complexity. Unknown values fall back to started. */ sort?: string; dir?: 'asc' | 'desc'; /** Row index of the first returned row (pagination; default 0). */ offset?: number; } interface SessionListItem { id: string; title: string; startedAt: string | null; costUsd: number; models: string[]; complexity: string | null; useCase: string[]; intent: string | null; /** Distinct outcome types this session produced (e.g. pr_merged, session_success). */ outcomes: string[]; } interface TranscriptTool { name: string; action: string; ok: boolean; /** This call's index in session.toolCalls — the transcript anchors a failed call as `txerr-`. */ idx?: number; target?: string; /** Full tool input rendered as displayable text (key field or JSON). */ command?: string; /** Tool output/result text (clipped to OUTPUT_MAX, with an explicit tail notice if cut). */ output?: string; /** For Edit/Write: old→new hunks for inline diff rendering. */ hunks?: { del: string; ins: string; }[]; /** For a multi-file apply_patch: preserve each file's identity and hunks. */ fileDiffs?: Array<{ path: string; hunks: { del: string; ins: string; }[]; }>; error?: string; /** For a subagent-spawning call (`Task`/`Agent`), the agentId it links to. */ agentId?: string; } interface TranscriptTurn { role: 'user' | 'assistant' | 'system'; ts?: string; sidechain: boolean; /** Which subagent emitted this turn; undefined for main-thread turns. */ agentId?: string; /** Main-thread sequence index (undefined for sidechain turns). */ seq?: number; /** Block this turn belongs to (handling_long_sessions); undefined if unmapped. */ blockIdx?: number; text: string; tools: TranscriptTool[]; } /** One subagent's identity, for the transcript's per-subagent tab + spawn link. */ interface SubagentInfo { agentId: string; agentType?: string; description?: string; /** tool_use id of the spawning call in the parent thread (absent for workflow subagents). */ toolUseId?: string; } /** * A session's viewer-ready transcript: one flat, globally-indexed list of turns * (each tagged with its `agentId`, so the client can split the main thread from * each subagent into its own tab) plus the subagent roster. */ /** One block's identity + labels, for the transcript's filter bar. */ interface TranscriptBlock { idx: number; useCase?: string | null; pr?: { ident: string; title?: string; } | null; feature?: { id: string; title?: string; } | null; } interface Transcript { turns: TranscriptTurn[]; subagents: SubagentInfo[]; /** Block partition + per-block labels, for filtering the transcript by PR / feature / use-case. */ blocks: TranscriptBlock[]; } /** A facet's resolved value for one session — the registry-driven detail row. */ interface FacetValue { key: string; label: string; type: FacetType; /** scalar, list (multi / child-grain facets), or null when the session has none. */ value: string | string[] | null; /** Registry producer — 'user' marks a user-defined field the drawer may edit. */ producer?: string; } interface SessionDetail { session: Record; annotations: Record; outcomes: Array<{ type: string; artifactId: string | null; }>; artifacts: Array>; facets: FacetValue[]; transcript: Transcript; } /** * One successful file write in the session — a before/after (Edit), full content * (Write), or hunks (MultiEdit). Returned as a flat, chronological list so the * client can group it either by file or by prompt. */ interface FileEdit { path: string; op: 'edit' | 'multiedit' | 'write'; hunks: Array<{ del: string; ins: string; }>; ts?: string; /** Index into the transcript turns of the assistant turn that made the edit. */ turn: number; /** Index of the preceding (non-synthetic) user turn — the prompting intent, or -1. */ userTurn: number; } /** * The processor contract — the main extension point. * * Everything derived from a session is a registered processor with this uniform * interface: token/cost, files touched, git/PR outcomes, and (later) LLM * enrichment. To add a new fact, implement Processor and register it — no * changes to the runner, the store schema, or the dashboard. */ type ProcessorKind = 'static' | 'enrichment'; interface ShResult { stdout: string; code: number; } /** * An existing feature a processor can link a session to. Carries enough of the * hierarchy for an enrichment processor to attach a session to the most specific * feature, place a new feature under the right parent, and refine the tree. */ interface FeatureRef { id: string; title: string; /** Parent feature id (null = top-level) — the shape of the hierarchy. */ parentId?: string | null; /** Provenance; `user`-authored features are locked from auto-rename/reparent. */ source?: string | null; /** * Repos associated anywhere in this feature's subtree (itself + descendants), * from linked sessions and any explicit repo. Empty = unscoped/global (e.g. a * cross-repo epic or a fresh user feature). Auto-derived linkage is allowed * only to a feature that is global or already includes the session's repo. */ repos?: string[]; } /** A user-linked artifact that needs block-level attribution. */ interface UserLinkedArtifact { artifactId: string; kind: 'pr' | 'feature'; title: string | null; ident: string | null; } /** Block indices already attributed to a PR by deterministic processors. */ interface PrBlockAttribution { blockIdx: number; artifactId: string; title: string | null; } interface ProcessorContext { session: Session; log: Logger; /** Whether an LLM provider + key is configured this run. */ llmEnabled: boolean; /** LLM client for enrichment processors; null when not configured. */ llm: LlmClient | null; /** Existing features in the store, to bias derived feature linkage toward. */ existingFeatures: FeatureRef[]; /** Titles of features the user has rejected for this session (tombstoned). */ rejectedFeatureTitles: string[]; /** User-linked PRs/features for this session that have no block-level attribution yet. */ userLinkedArtifacts: UserLinkedArtifact[]; /** Blocks already attributed to PRs by deterministic processors (outcomes-git). */ prBlockAttributions: PrBlockAttribution[]; /** Run a local binary (git, gh). Resolves null if the binary is missing. */ sh: (cmd: string, args: string[], opts?: { cwd?: string; }) => Promise; } /** Everything a processor can emit. The runner stamps each row with the processor name. */ interface ProcessorResult { annotations?: AnnotationInput[]; artifacts?: ArtifactInput[]; links?: ArtifactLinkInput[]; sessionArtifacts?: SessionArtifactInput[]; /** In-place edits to existing (non-user) features — rename / reparent. */ featureRevisions?: FeatureRevisionInput[]; outcomes?: OutcomeInput[]; files?: FileIndexInput[]; /** Block partition + membership (owned by segment-blocks). */ blocks?: BlockInput[]; blockUsage?: BlockUsageInput[]; blockTool?: BlockToolInput[]; /** Per-block labels / links (use_case from enrich-session, PR/commit from outcomes-git, feature from enrich-session). */ blockAnnotations?: BlockAnnotationInput[]; blockArtifacts?: BlockArtifactInput[]; /** Fix-prompt markers sighted in user turns (owned by fix-marker; interpreted by reconcile in analyze). */ fixMarkerSightings?: FixMarkerSightingInput[]; /** For enrichment processors: the LLM spend this processor incurred. */ selfCost?: { tokens: TokenUsage; usd: number; }; } interface RefreshContext { artifacts: ArtifactInput[]; log: Logger; sh: (cmd: string, args: string[], opts?: { cwd?: string; }) => Promise; } interface RefreshResult { artifacts?: ArtifactInput[]; outcomes?: OutcomeInput[]; } /** * Context for a cross-session `finalize()` pass — run once after every session is * processed, with read/write access to the whole store (unlike per-session `run()`). */ interface FinalizeContext { store: Store; /** Base LLM client (same tier the processor's run() uses); null when not configured. */ llm: LlmClient | null; llmEnabled: boolean; log: Logger; } interface Processor { name: string; /** Bump to invalidate cached results and force reprocessing. */ version: number; kind: ProcessorKind; /** Gates execution: `llm` skips when no provider is configured. */ needs?: { llm?: boolean; network?: boolean; }; /** * Which model tier an LLM processor's calls run on: * - 'default' (implied when omitted) — the cheap per-session model, right for most. * - 'heavy' — the strong model (--llm-model-heavy / TUNELOOP_LLM_MODEL_HEAVY) */ model?: 'default' | 'heavy'; /** Names of processors that must run first (topo-sorted). */ requires?: string[]; /** Facets this processor contributes to the dashboard registry. */ facets?: FacetSpec[]; /** Measures this processor contributes (over numeric facts it emits). */ measures?: MeasureSpec[]; run(ctx: ProcessorContext): Promise | ProcessorResult; /** Re-check artifacts this processor owns that may have gone stale. */ refresh?(ctx: RefreshContext): Promise; /** * Cross-session consolidation, run ONCE after every session has been processed — * like refresh(), but over the whole corpus. enrich-session uses it to reconcile the * feature taxonomy its per-session run() proposed. Optional; most processors omit it. */ finalize?(ctx: FinalizeContext): Promise; } declare function registerAdapter(adapter: SourceAdapter): void; declare function registerProcessor(processor: Processor): void; declare function getAdapters(): SourceAdapter[]; declare function getProcessors(): Processor[]; /** Topologically order processors so a processor runs after everything in `requires`. */ declare function orderProcessors(procs: Processor[]): Processor[]; interface RunOptions { session: Session; processors: Processor[]; store: Store; log: Logger; llmEnabled: boolean; llmModel: string | null; llm: LlmClient | null; /** The strong (detector-tier) client + model, for processors that opt in via * `model: 'heavy'`. Defaults to the light client when no distinct heavy tier is set. */ heavyLlm?: LlmClient | null; heavyLlmModel?: string | null; sh: ProcessorContext['sh']; } interface RunResult { costUsd: number; } /** Run every applicable processor for one session, honoring deps + the cache. */ declare function runProcessors(opts: RunOptions): Promise; /** Resolved runtime configuration for a single invocation. */ interface TuneloopConfig { /** Directory holding the SQLite store and other local state. */ dataDir: string; dbPath: string; /** LLM provider for enrichment (BYO key), or null when not configured. */ llm: { provider: string; model: string; apiKey: string; baseURL?: string; heavyModel?: string; headers?: string; } | null; } /** * Non-secret LLM knobs settable via CLI flags; they override env. The API key * deliberately has no flag — argv leaks into shell history and `ps` — so it * comes from env, or from `apiKey` when the caller collected it interactively * (analyze's run-only enrichment setup). */ interface LlmOverrides { provider?: string; model?: string; /** Optional stronger model for the detector pass; unset = the provider's default * heavy model, or the base `model` when the provider has no strong sibling. */ heavyModel?: string; baseURL?: string; /** In-process override (interactive prompt); never exposed as a CLI flag. */ apiKey?: string; } declare function loadConfig(opts?: { dataDir?: string; db?: string; llm?: LlmOverrides; }): TuneloopConfig; interface AnalyzeOptions { dirs?: string[]; /** `--source` entries: a harness name, optionally `name=dir` to override its roots. */ sources?: string[]; db?: string; verbose?: boolean; /** Cap the number of sessions processed — handy for a cheap enrichment test. */ limit?: number; /** Path to a JSON pipeline config selecting which processors/detectors run; unset = the shipped default (everything). */ configPath?: string; /** Non-secret LLM flag overrides (provider/model/base-url); the key stays env-only. */ llm?: LlmOverrides; } /** * Discover sessions → parse (adapter) → ingest changed ones → run processors * (cache-aware) → print a summary. Writes to the store only; the dashboard, * `search`, and `observe` all read it. */ declare function analyze(opts: AnalyzeOptions): Promise; interface ServeOptions { db?: string; port?: number; open?: boolean; address?: string; } /** Serve the dashboard over an already-analyzed store. Reads only; Ctrl+C stops. */ declare function serve(opts: ServeOptions): Promise; type ShFn = (cmd: string, args: string[]) => Promise; /** * JSON API + dashboard SPA over the analyzed store. Reads are queries at request * time; POST endpoints write user judgment only — curation (features + * session↔artifact links, stamped user-authored so `analyze` never clobbers * them), user tag fields (user-facets / sessions/tag), and insight lifecycle * (dismiss / fix-issued). Deriving facts from transcripts stays in the * `analyze` path. */ declare function createDashboardServer(store: Store, dbPath: string, sh?: ShFn): Server; interface QueryOptions { /** Stop after this many rows (default 1000). */ maxRows?: number; /** Stop once accumulated JSON size exceeds this (default 5MB). */ maxBytes?: number; /** Stop if row production exceeds this wall-clock budget (default 5s). */ timeoutMs?: number; /** Positional (?) or named (:name) bind parameters. */ params?: unknown[] | Record; } interface QueryResult { columns: string[]; rows: Record[]; rowCount: number; /** Which cap ended the read early, or null if the full result fit. */ truncated: 'rows' | 'bytes' | 'time' | null; elapsedMs: number; } /** Rejected before touching the DB: shape violations the SQL engine wouldn't flag. */ declare class QueryError extends Error { constructor(message: string); } /** * Run a single read-only SELECT against the store at `dbPath`. Opens and closes * its own connection every call — cheap, and keeps this fully independent of any * live Store/serve handle. Throws {@link QueryError} for guard violations and * SQLite's own errors (syntax, unknown column) for genuine SQL mistakes. */ declare function runQuery(dbPath: string, sql: string, opts?: QueryOptions): QueryResult; interface SchemaTable { name: string; /** The CREATE statement as SQLite normalized it — guaranteed in sync with the store. */ sql: string; } /** What's actually in the store — the extent, not the shape. Derived from `sessions`. */ interface Coverage { sessions: number; firstAt: string | null; lastAt: string | null; lastAnalyzedAt: string | null; sources: { source: string | null; count: number; }[]; repos: number; cwds: number; /** Source directories scanned, with each one's last-analyzed time (empty on pre-v9 stores). */ roots: { source: string | null; path: string; lastAnalyzedAt: string | null; }[]; } interface SchemaDump { schemaVersion: number | null; /** Store extent; null when reflecting the canonical (empty) schema. */ coverage: Coverage | null; tables: SchemaTable[]; facets: FacetSpec[]; measures: MeasureSpec[]; } /** Open the store read-only and dump its schema (see {@link schemaFromDb}). */ declare function describeSchema(dbPath: string): SchemaDump; /** Build a fresh in-memory store purely to reflect the canonical schema (no data). */ declare function canonicalSchema(): SchemaDump; interface ModelPrice { input: number; output: number; cache_write_5m: number; cache_write_1h: number; cache_read: number; } /** Bump when models.json rates change so stored costs can be recomputed. */ declare const PRICE_TABLE_VERSION = "2026-07-14.1"; /** * Look up a price, tolerant of model-id drift: exact match, then strip a * trailing date snapshot (`-20251001` or `@20251001`), then prefix match. */ declare function priceFor(provider: string, model: string, opts?: { backfill?: boolean; }): ModelPrice | undefined; /** Cost of a single usage record at a given model's rates (0 if unpriced). */ declare function costOfUsage(provider: string, model: string, u: TokenUsage): number; /** * Usage + cost for one assistant message — the atomic grain of token economics. * Persisted to `usage_facts` so model / main-vs-sidechain / time breakdowns are * all read-time GROUP BYs (cost can't be summed by model off the session row). */ interface UsageFact { idx: number; model: string; isSidechain: boolean; ts?: string; tokens: TokenUsage; usd: number; } interface CostResult { usd: number; /** Models we had no price for — their tokens count, but contribute $0. */ unpriced: string[]; /** One entry per assistant message, in order. Sums to `usd` / session tokens. */ facts: UsageFact[]; } /** * Cost of a session, priced per assistant message at that message's model, with * the per-message breakdown retained. Cache-creation tokens are priced per TTL: * Claude Code writes much of its cache at the 1-hour rate (2x input), which is * 1.6x the 5-minute rate — pricing it all at 5m under-counts real spend. */ declare function computeSessionCost(session: Session): CostResult; /** Build an LLM client from config, or null if enrichment isn't configured. */ declare function createLlmClient(llm: TuneloopConfig['llm']): LlmClient | null; export { type AnalyzeOptions, type AnnotationInput, type ArtifactInput, type ArtifactKind, type ArtifactLinkInput, type ArtifactRelation, type AssistantMessage, type BlockAnnotationInput, type BlockArtifactInput, type BlockInput, type BlockToolInput, type BlockUsageInput, type CanonicalAction, type ContentBlock, type Coverage, type DetectorRunRow, type EnvCategory, type EnvCategorySnapshot, type EnvSnapshotAsOf, type EnvSnapshotInput, type EnvSnapshotRow, type Event, type FeatureRef, type FeatureRevisionInput, type FileIndexInput, type FinalizeContext, type FixMarkerSightingInput, type InsightRow, type InsightState, type KitchenSinkVerdictInput, type LinkSource, type LlmClient, type LlmResult, type OutcomeInput, PRICE_TABLE_VERSION, type PrBlockAttribution, type Processor, type ProcessorContext, type ProcessorKind, type ProcessorResult, type ProcessorRunRow, QueryError, type QueryOptions, type QueryResult, type RefreshContext, type RefreshResult, type RunOptions, type SchemaDump, type SchemaTable, type ServeOptions, type Session, type SessionArtifactInput, type SessionArtifactRole, type SessionRow, type ShResult, type SourceAdapter, Store, type StructuredRequest, type SubagentMeta, type Summary, type SystemEvent, type ThemeEventInput, type ThemeInput, type ThemeRef, type ThemeRemedy, type ThemeTrigger, type ThemeType, type TokenUsage, type ToolCall, type TuneloopConfig, type UsageFactInput, type UserLinkedArtifact, type UserMessage, addUsage, analyze, cacheCreateTotal, canonicalSchema, computeSessionCost, costOfUsage, createDashboardServer, createLlmClient, describeSchema, emptyUsage, getAdapters, getProcessors, loadConfig, orderProcessors, priceFor, registerAdapter, registerProcessor, runProcessors, runQuery, serve };