tuneloop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1623 @@
1
+ import Database from 'better-sqlite3';
2
+ import { Server } from 'node:http';
3
+
4
+ /**
5
+ * The normalized session model — the contract every processor reads and every
6
+ * adapter produces. Adapters translate a vendor's transcript into this shape;
7
+ * processors and the store never need to know which harness a session came from.
8
+ *
9
+ * `raw` is always preserved as an escape hatch for processors that need
10
+ * vendor-specific detail the canonical view doesn't capture.
11
+ */
12
+ /** Vendor-neutral classification of a tool call. The per-vendor mapping lives in the adapter. */
13
+ type CanonicalAction = 'file_write' | 'file_read' | 'shell' | 'search' | 'task_spawn' | 'mcp_call' | 'web' | 'todo' | 'skill' | 'other';
14
+ interface TokenUsage {
15
+ input: number;
16
+ output: number;
17
+ cacheCreate: number;
18
+ cacheRead: number;
19
+ }
20
+ declare function emptyUsage(): TokenUsage;
21
+ declare function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage;
22
+ type ContentBlock = {
23
+ type: 'text';
24
+ text: string;
25
+ } | {
26
+ type: 'thinking';
27
+ text: string;
28
+ } | {
29
+ type: 'tool_use';
30
+ id: string;
31
+ name: string;
32
+ input: unknown;
33
+ } | {
34
+ type: 'tool_result';
35
+ toolUseId: string;
36
+ isError: boolean;
37
+ content: unknown;
38
+ };
39
+ interface BaseEvent {
40
+ uuid?: string;
41
+ parentUuid?: string | null;
42
+ ts?: string;
43
+ isSidechain: boolean;
44
+ /**
45
+ * Dense ordinal over MAIN-THREAD events (sidechain events have none), assigned
46
+ * post-merge by assignSeq() (core/blocks.ts). The coordinate the block partition
47
+ * is defined in; persisted in the session blob.
48
+ */
49
+ seq?: number;
50
+ /**
51
+ * For sidechain (subagent) events, the stable id of the subagent that emitted
52
+ * them — Claude Code's per-subagent transcript id. Lets the viewer group a
53
+ * subagent's turns into their own thread instead of interleaving them with the
54
+ * main conversation. Undefined for main-thread events.
55
+ */
56
+ agentId?: string;
57
+ }
58
+ interface UserMessage extends BaseEvent {
59
+ kind: 'user';
60
+ text: string;
61
+ blocks: ContentBlock[];
62
+ }
63
+ interface AssistantMessage extends BaseEvent {
64
+ kind: 'assistant';
65
+ model?: string;
66
+ blocks: ContentBlock[];
67
+ usage: TokenUsage;
68
+ /**
69
+ * Native cost (USD) for this message as reported by the source, when the source
70
+ * computes its own cost (e.g. OpenCode, which routes to many providers tuneloop's
71
+ * rate table doesn't cover). Used by computeSessionCost as a fallback when the
72
+ * model has no entry in models.json. Absent for sources priced from tokens.
73
+ */
74
+ costUsd?: number;
75
+ }
76
+ interface SystemEvent extends BaseEvent {
77
+ kind: 'system';
78
+ subtype?: string;
79
+ text?: string;
80
+ }
81
+ type Event = UserMessage | AssistantMessage | SystemEvent;
82
+ /** A tool_use joined to its tool_result, classified into a canonical action. */
83
+ interface ToolCall {
84
+ id: string;
85
+ name: string;
86
+ action: CanonicalAction;
87
+ input: unknown;
88
+ /** Normalized fields per action (paths for file ops, command for shell, etc.). */
89
+ target: {
90
+ paths?: string[];
91
+ command?: string;
92
+ };
93
+ result: {
94
+ ok: boolean;
95
+ isError: boolean;
96
+ raw?: unknown;
97
+ };
98
+ isSidechain: boolean;
99
+ ts?: string;
100
+ durationMs?: number;
101
+ }
102
+ /**
103
+ * A subagent (sidechain) spawned within a session. Claude Code writes each
104
+ * subagent's transcript to its own file with a sibling `.meta.json`; this is the
105
+ * normalized view of that metadata. `toolUseId` is the id of the spawning tool
106
+ * call (the `Task`/`Agent` tool_use) in the parent thread, which lets the viewer
107
+ * link that call to the subagent's transcript. Workflow subagents have no
108
+ * spawning tool call, so `toolUseId` is absent for them.
109
+ */
110
+ interface SubagentMeta {
111
+ agentId: string;
112
+ agentType?: string;
113
+ description?: string;
114
+ toolUseId?: string;
115
+ }
116
+ interface Session {
117
+ /** Namespaced id, e.g. `claude-code:<uuid>` — unique across vendors. */
118
+ id: string;
119
+ /** Raw vendor session id. */
120
+ sessionId: string;
121
+ /** Adapter / harness id, e.g. `claude-code`. */
122
+ source: string;
123
+ /** LLM vendor family for slicing, e.g. `anthropic`. */
124
+ provider: string;
125
+ title?: string;
126
+ /**
127
+ * For a child transcript that lives in its own file (Codex sub-agent or `/fork`),
128
+ * the parent session's raw id. Used to (a) fold sub-agents into the parent as
129
+ * sidechains and (b) trim the replayed parent prefix both kinds inherit
130
+ * (see analyze.ts / merge.ts, ADR-0005). Undefined for top-level sessions.
131
+ */
132
+ forkedFromId?: string;
133
+ /**
134
+ * True only for a sub-agent (sidechain) child. Distinguishes it from a `/fork`,
135
+ * which also carries `forkedFromId` but is its own top-level session: only
136
+ * sub-agents fold into the parent group (ADR-0005).
137
+ */
138
+ isSubagent?: boolean;
139
+ project: {
140
+ cwd?: string;
141
+ repo?: string;
142
+ branch?: string;
143
+ };
144
+ startedAt?: string;
145
+ endedAt?: string;
146
+ /** Distinct models seen across assistant messages (model is per-message). */
147
+ models: string[];
148
+ /** Rolled-up token usage across all assistant messages (incl. sidechains). */
149
+ tokens: TokenUsage;
150
+ events: Event[];
151
+ /** Flattened convenience view of every tool call, incl. sidechains. */
152
+ toolCalls: ToolCall[];
153
+ /** Subagents spawned in this session (one per sidechain transcript). */
154
+ subagents?: SubagentMeta[];
155
+ raw: {
156
+ path: string;
157
+ contentHash: string;
158
+ };
159
+ }
160
+
161
+ /** Translates a vendor's transcripts into the normalized session model. */
162
+ interface SourceAdapter {
163
+ /** Stable adapter id, e.g. `claude-code`. */
164
+ id: string;
165
+ /** LLM vendor family, e.g. `anthropic`. */
166
+ provider: string;
167
+ /**
168
+ * Version of THIS adapter's parse output. Bumped when the adapter extracts more
169
+ * (or different) data from the same transcript bytes. Combined with the shared
170
+ * `NORMALIZE_VERSION` into the stored `parse_version` (see analyze.ts), so a
171
+ * per-vendor bump re-ingests only that vendor's sessions.
172
+ */
173
+ parseVersion: number;
174
+ /** Locations to scan when the user passes no directories. */
175
+ defaultRoots(): string[];
176
+ /** Find candidate session files under the given roots. */
177
+ discover(roots: string[]): Promise<string[]>;
178
+ /** Parse one file into a Session; null if it isn't a session this adapter owns. */
179
+ parse(path: string): Promise<Session | null>;
180
+ /**
181
+ * Store-backed alternative to discover/parse. Adapters whose sessions live in a
182
+ * single database (not one file per session) implement this to yield sessions
183
+ * directly; analyze.ts prefers it over the discover→parse file loop when present.
184
+ */
185
+ discoverSessions?(roots: string[]): Promise<Session[]>;
186
+ }
187
+
188
+ /**
189
+ * The facet registry: the single source of truth for the categorical dimensions
190
+ * the dashboard charts, filters, and (later) compares by.
191
+ *
192
+ * A facet's `source` names WHERE its value lives — which also implies its grain.
193
+ * `multi` is cardinality (array vs scalar). `type` is the element type. The query
194
+ * builder (Store.facetDistribution / facetPredicate) derives the exact read shape
195
+ * — raw column / json_extract / json_each / EXISTS — from `(source, multi)`, so
196
+ * nothing above the store hardcodes which dimensions exist.
197
+ *
198
+ * Two sources of facets, both persisted to the `facets` table at analyze time so
199
+ * the separate serve process can read them without importing processors:
200
+ * - intrinsic facets (below): structural, present without any processor
201
+ * - processor-declared facets: a processor's `facets` field (e.g. enrichment)
202
+ */
203
+ /** Where a facet's value lives — implies its grain. */
204
+ type FacetSource = 'session' | 'annotation' | 'tool-call' | 'usage' | 'block';
205
+ type FacetType = 'string' | 'number' | 'boolean' | 'enum';
206
+ /** Where a facet may surface in the UI. */
207
+ type FacetRole = 'chart' | 'filter' | 'detail';
208
+ interface FacetSpec {
209
+ key: string;
210
+ label?: string;
211
+ /** Element type (drives rendering); never 'array' — array-ness is `multi`. */
212
+ type: FacetType;
213
+ source: FacetSource;
214
+ /**
215
+ * Physical column for session / tool-call / usage facets; defaults to `key`.
216
+ * Unused for `annotation` (there `key` IS the annotation key).
217
+ */
218
+ column?: string;
219
+ /** Base predicate scoping rows for tool-call / usage facets, e.g. action='skill'. */
220
+ base?: string;
221
+ /**
222
+ * Array-valued (json_each) vs scalar. Only meaningful for session/annotation
223
+ * storage; for tool-call/usage the to-many-ness is intrinsic to the grain.
224
+ */
225
+ multi?: boolean;
226
+ roles?: FacetRole[];
227
+ }
228
+
229
+ /**
230
+ * The measure registry: the "how much" axis, parallel to the facet registry.
231
+ * A measure is an aggregation (`agg`) of an expression (`expr`) over the
232
+ * population at its grain. Crossed with a facet (the "which" axis) by
233
+ * Store.breakdown, it produces every "<measure> by <facet>" view.
234
+ *
235
+ * Like facets: intrinsic measures live here; processors add more via
236
+ * Processor.measures; both persist to the `measures` table at analyze time so
237
+ * the serve process discovers them without importing processors.
238
+ *
239
+ * `source` (reused from facets) says WHERE the value lives and implies the grain
240
+ * (grainOf). `expr` is SQL over that source's anchor alias — s (sessions),
241
+ * u (usage_facts), t (tool_calls). For `rate`, expr is a 0/1 (boolean) predicate.
242
+ */
243
+
244
+ type MeasureAgg = 'sum' | 'count' | 'count_distinct' | 'avg' | 'rate';
245
+ interface MeasureSpec {
246
+ key: string;
247
+ label?: string;
248
+ source: FacetSource;
249
+ /** SQL over the anchor alias (s/u/t). For `rate`, a 0/1 boolean expression. */
250
+ expr: string;
251
+ agg: MeasureAgg;
252
+ /** Optional base predicate restricting the population. */
253
+ base?: string;
254
+ format?: 'usd' | 'int' | 'pct';
255
+ }
256
+
257
+ /**
258
+ * Persistence-facing record shapes. Kept separate from both the normalized
259
+ * model (core/model.ts) and the Store implementation so processors can import
260
+ * these without pulling in better-sqlite3.
261
+ */
262
+
263
+ type ArtifactKind = 'file' | 'commit' | 'pr' | 'ticket' | 'feature';
264
+ type LinkSource = 'explicit' | 'transitive' | 'derived' | 'user';
265
+ type ArtifactRelation = 'part_of' | 'resolves' | 'child_of' | 'caused_by';
266
+ type SessionArtifactRole = 'created' | 'edited' | 'contributed' | 'reviewed';
267
+ interface ArtifactInput {
268
+ id: string;
269
+ kind: ArtifactKind;
270
+ repo?: string;
271
+ ident?: string;
272
+ externalId?: string;
273
+ /** github | jira | linear | asana | user | codebase-inferred | transcript | ... */
274
+ source?: string;
275
+ title?: string;
276
+ /** PR author / ticket assignee / feature owner — the "team" artifact filter. */
277
+ owner?: string;
278
+ complexity?: number;
279
+ /** story_points | diff_size | equal_split */
280
+ complexityBasis?: string;
281
+ status?: string;
282
+ createdAt?: string;
283
+ /** Merge / resolve / ship date. NULL until the artifact completes. */
284
+ completedAt?: string;
285
+ parentArtifactId?: string;
286
+ json?: unknown;
287
+ }
288
+ interface ArtifactLinkInput {
289
+ fromId: string;
290
+ toId: string;
291
+ relation: ArtifactRelation;
292
+ source: LinkSource;
293
+ confidence?: number;
294
+ }
295
+ interface SessionArtifactInput {
296
+ artifactId: string;
297
+ role: SessionArtifactRole;
298
+ source: LinkSource;
299
+ confidence?: number;
300
+ }
301
+ /**
302
+ * A reparent of an existing feature, for an enrichment processor that maintains
303
+ * the feature hierarchy as it sees more sessions. Applied only to machine-derived
304
+ * features — `user`-authored features are never touched. Auto-rename is
305
+ * deliberately NOT supported: a bad rename retroactively mislabels every session
306
+ * under the feature, so titles are fixed at creation (the dashboard can rename).
307
+ */
308
+ interface FeatureRevisionInput {
309
+ id: string;
310
+ /** New parent id; `null` = make top-level; omit (`undefined`) = keep. */
311
+ parentId?: string | null;
312
+ }
313
+ interface OutcomeInput {
314
+ type: string;
315
+ /** NULL for session-level outcomes (session_success, plan_drafted, ...). */
316
+ artifactId?: string | null;
317
+ ts?: string;
318
+ }
319
+ interface FileIndexInput {
320
+ repo?: string;
321
+ path: string;
322
+ }
323
+ interface AnnotationInput {
324
+ key: string;
325
+ value: unknown;
326
+ }
327
+ /** A contiguous deterministic slice of a session's main thread. */
328
+ interface BlockInput {
329
+ idx: number;
330
+ startSeq: number;
331
+ endSeq: number;
332
+ boundaryKind: string;
333
+ tsStart?: string;
334
+ tsEnd?: string;
335
+ }
336
+ /** usage_facts.idx -> block idx (a total partition; non-overlap is PK-enforced). */
337
+ interface BlockUsageInput {
338
+ usageIdx: number;
339
+ blockIdx: number;
340
+ }
341
+ /** tool_calls.idx -> block idx. */
342
+ interface BlockToolInput {
343
+ toolIdx: number;
344
+ blockIdx: number;
345
+ }
346
+ /** A label on one block (e.g. use_case), parallel to AnnotationInput. */
347
+ interface BlockAnnotationInput {
348
+ blockIdx: number;
349
+ key: string;
350
+ value: unknown;
351
+ }
352
+ /** A block -> artifact link (block→PR/commit deterministic; block→feature derived). */
353
+ interface BlockArtifactInput {
354
+ blockIdx: number;
355
+ artifactId: string;
356
+ role: SessionArtifactRole;
357
+ source?: LinkSource;
358
+ confidence?: number;
359
+ }
360
+ /** One assistant message's usage + cost — a row in the `usage_facts` table. */
361
+ interface UsageFactInput {
362
+ idx: number;
363
+ model: string;
364
+ isSidechain: boolean;
365
+ ts?: string;
366
+ tokens: TokenUsage;
367
+ usd: number;
368
+ }
369
+ interface SessionRow {
370
+ id: string;
371
+ sessionId: string;
372
+ source: string;
373
+ provider: string;
374
+ title?: string;
375
+ repo?: string;
376
+ branch?: string;
377
+ cwd?: string;
378
+ startedAt?: string;
379
+ endedAt?: string;
380
+ nTurns: number;
381
+ nToolCalls: number;
382
+ models: string[];
383
+ tokens: TokenUsage;
384
+ costUsd: number;
385
+ priceTableVersion: string;
386
+ contentHash: string;
387
+ parseVersion: number;
388
+ }
389
+ interface ProcessorRunRow {
390
+ version: number;
391
+ inputHash: string;
392
+ model: string | null;
393
+ invalidated: boolean;
394
+ }
395
+
396
+ /** A JSON Schema object describing the structured output a completion must return. */
397
+ type JsonSchema = Record<string, unknown>;
398
+ interface StructuredRequest {
399
+ system: string;
400
+ user: string;
401
+ /** JSON Schema for the result — the forced tool's input schema. */
402
+ schema: JsonSchema;
403
+ /** Name of the single forced tool. */
404
+ toolName: string;
405
+ maxTokens?: number;
406
+ }
407
+ interface LlmResult {
408
+ /** The model's structured output (the forced tool's input), normalized by the caller. */
409
+ data: Record<string, unknown>;
410
+ usage: TokenUsage;
411
+ }
412
+ /**
413
+ * Thin provider-neutral client. Enrichment uses a single structured completion
414
+ * per session: the output schema is exposed as one forced tool call (the tool
415
+ * input IS the result), which works identically across Anthropic and every
416
+ * OpenAI-compatible endpoint — unlike provider-specific structured-output modes.
417
+ * This requires a tool-call-capable model; non-tool models are unsupported.
418
+ */
419
+ interface LlmClient {
420
+ provider: string;
421
+ model: string;
422
+ completeStructured(req: StructuredRequest): Promise<LlmResult>;
423
+ }
424
+
425
+ interface Logger {
426
+ debug(msg: string): void;
427
+ info(msg: string): void;
428
+ warn(msg: string): void;
429
+ error(msg: string): void;
430
+ }
431
+
432
+ /**
433
+ * The processor contract — the main extension point.
434
+ *
435
+ * Everything derived from a session is a registered processor with this uniform
436
+ * interface: token/cost, files touched, git/PR outcomes, and (later) LLM
437
+ * enrichment. To add a new fact, implement Processor and register it — no
438
+ * changes to the runner, the store schema, or the dashboard.
439
+ */
440
+
441
+ type ProcessorKind = 'static' | 'enrichment';
442
+ interface ShResult {
443
+ stdout: string;
444
+ code: number;
445
+ }
446
+ /**
447
+ * An existing feature a processor can link a session to. Carries enough of the
448
+ * hierarchy for an enrichment processor to attach a session to the most specific
449
+ * feature, place a new feature under the right parent, and refine the tree.
450
+ */
451
+ interface FeatureRef {
452
+ id: string;
453
+ title: string;
454
+ /** Parent feature id (null = top-level) — the shape of the hierarchy. */
455
+ parentId?: string | null;
456
+ /** Provenance; `user`-authored features are locked from auto-rename/reparent. */
457
+ source?: string | null;
458
+ /**
459
+ * Repos associated anywhere in this feature's subtree (itself + descendants),
460
+ * from linked sessions and any explicit repo. Empty = unscoped/global (e.g. a
461
+ * cross-repo epic or a fresh user feature). Auto-derived linkage is allowed
462
+ * only to a feature that is global or already includes the session's repo.
463
+ */
464
+ repos?: string[];
465
+ }
466
+ /** A user-linked artifact that needs block-level attribution. */
467
+ interface UserLinkedArtifact {
468
+ artifactId: string;
469
+ kind: 'pr' | 'feature';
470
+ title: string | null;
471
+ ident: string | null;
472
+ }
473
+ /** Block indices already attributed to a PR by deterministic processors. */
474
+ interface PrBlockAttribution {
475
+ blockIdx: number;
476
+ artifactId: string;
477
+ title: string | null;
478
+ }
479
+ interface ProcessorContext {
480
+ session: Session;
481
+ log: Logger;
482
+ /** Whether an LLM provider + key is configured this run. */
483
+ llmEnabled: boolean;
484
+ /** LLM client for enrichment processors; null when not configured. */
485
+ llm: LlmClient | null;
486
+ /** Existing features in the store, to bias derived feature linkage toward. */
487
+ existingFeatures: FeatureRef[];
488
+ /** Titles of features the user has rejected for this session (tombstoned). */
489
+ rejectedFeatureTitles: string[];
490
+ /** User-linked PRs/features for this session that have no block-level attribution yet. */
491
+ userLinkedArtifacts: UserLinkedArtifact[];
492
+ /** Blocks already attributed to PRs by deterministic processors (outcomes-git). */
493
+ prBlockAttributions: PrBlockAttribution[];
494
+ /** Run a local binary (git, gh). Resolves null if the binary is missing. */
495
+ sh: (cmd: string, args: string[], opts?: {
496
+ cwd?: string;
497
+ }) => Promise<ShResult | null>;
498
+ }
499
+ /** Everything a processor can emit. The runner stamps each row with the processor name. */
500
+ interface ProcessorResult {
501
+ annotations?: AnnotationInput[];
502
+ artifacts?: ArtifactInput[];
503
+ links?: ArtifactLinkInput[];
504
+ sessionArtifacts?: SessionArtifactInput[];
505
+ /** In-place edits to existing (non-user) features — rename / reparent. */
506
+ featureRevisions?: FeatureRevisionInput[];
507
+ outcomes?: OutcomeInput[];
508
+ files?: FileIndexInput[];
509
+ /** Block partition + membership (owned by segment-blocks). */
510
+ blocks?: BlockInput[];
511
+ blockUsage?: BlockUsageInput[];
512
+ blockTool?: BlockToolInput[];
513
+ /** Per-block labels / links (use_case from enrich-session, PR/commit from outcomes-git, feature from enrich-session). */
514
+ blockAnnotations?: BlockAnnotationInput[];
515
+ blockArtifacts?: BlockArtifactInput[];
516
+ /** For enrichment processors: the LLM spend this processor incurred. */
517
+ selfCost?: {
518
+ tokens: TokenUsage;
519
+ usd: number;
520
+ };
521
+ }
522
+ interface RefreshContext {
523
+ artifacts: ArtifactInput[];
524
+ log: Logger;
525
+ sh: (cmd: string, args: string[], opts?: {
526
+ cwd?: string;
527
+ }) => Promise<ShResult | null>;
528
+ }
529
+ interface RefreshResult {
530
+ artifacts?: ArtifactInput[];
531
+ outcomes?: OutcomeInput[];
532
+ }
533
+ interface Processor {
534
+ name: string;
535
+ /** Bump to invalidate cached results and force reprocessing. */
536
+ version: number;
537
+ kind: ProcessorKind;
538
+ /** Gates execution: `llm` skips when no provider is configured. */
539
+ needs?: {
540
+ llm?: boolean;
541
+ network?: boolean;
542
+ };
543
+ /** Names of processors that must run first (topo-sorted). */
544
+ requires?: string[];
545
+ /** Facets this processor contributes to the dashboard registry. */
546
+ facets?: FacetSpec[];
547
+ /** Measures this processor contributes (over numeric facts it emits). */
548
+ measures?: MeasureSpec[];
549
+ run(ctx: ProcessorContext): Promise<ProcessorResult> | ProcessorResult;
550
+ /** Re-check artifacts this processor owns that may have gone stale. */
551
+ refresh?(ctx: RefreshContext): Promise<RefreshResult>;
552
+ }
553
+
554
+ declare function registerAdapter(adapter: SourceAdapter): void;
555
+ declare function registerProcessor(processor: Processor): void;
556
+ declare function getAdapters(): SourceAdapter[];
557
+ declare function getProcessors(): Processor[];
558
+
559
+ type DB = Database.Database;
560
+
561
+ interface Dist {
562
+ value: string;
563
+ count: number;
564
+ }
565
+ /** One failed tool call in the "Errors by category" drill-down (see errorOccurrences). */
566
+ interface ErrorOccurrence {
567
+ sessionId: string;
568
+ title: string | null;
569
+ idx: number;
570
+ name: string;
571
+ action: string;
572
+ command: string | null;
573
+ targetPath: string | null;
574
+ message: string | null;
575
+ ts: string | null;
576
+ startedAt: string | null;
577
+ }
578
+ interface Summary {
579
+ sessions: number;
580
+ costUsd: number;
581
+ tokens: number;
582
+ firstAt: string | null;
583
+ lastAt: string | null;
584
+ models: Array<{
585
+ model: string;
586
+ count: number;
587
+ }>;
588
+ outcomes: Array<{
589
+ type: string;
590
+ count: number;
591
+ }>;
592
+ topTools: Array<{
593
+ name: string;
594
+ calls: number;
595
+ errors: number;
596
+ }>;
597
+ costPerMergedPr: {
598
+ count: number;
599
+ costPerUnit: number | null;
600
+ };
601
+ /** Spend on enrichment (the "cost of running the analysis itself"). */
602
+ analysisCostUsd: number;
603
+ /** Whether LLM enrichment has run (any processor recorded an LLM model). */
604
+ enrichmentRan: boolean;
605
+ /** ISO timestamp of the most recent `analyze` run (null if never recorded). */
606
+ lastAnalyzedAt: string | null;
607
+ /** Source directories scanned, each with its own last-analyzed time (empty until an analyze runs on this schema). */
608
+ analyzedRoots: Array<{
609
+ source: string | null;
610
+ path: string;
611
+ lastAnalyzedAt: string | null;
612
+ }>;
613
+ /** Enrichment dimension distributions, empty when enrichment hasn't run. */
614
+ useCases: Dist[];
615
+ complexity: Dist[];
616
+ autonomy: Dist[];
617
+ features: {
618
+ total: number;
619
+ derived: number;
620
+ linked: number;
621
+ };
622
+ }
623
+ /** One computed insight for the Highlights digest. The client maps `kind` to the
624
+ * display sentence + its drill-in; the payload carries the data. */
625
+ interface Highlight {
626
+ kind: string;
627
+ [field: string]: unknown;
628
+ }
629
+ declare class Store {
630
+ private db;
631
+ constructor(db: DB);
632
+ /** Read a value from the key-value `meta` table (undefined when absent). */
633
+ getMeta(key: string): string | undefined;
634
+ /** Upsert a value into the key-value `meta` table. */
635
+ setMeta(key: string, value: string): void;
636
+ /**
637
+ * Stamp each source directory scanned this run with the run timestamp. Upsert,
638
+ * so roots a scoped re-run didn't touch keep their prior stamp — the table then
639
+ * answers "when was THIS directory last analyzed" per directory.
640
+ */
641
+ recordAnalyzedRoots(roots: Array<{
642
+ source: string;
643
+ path: string;
644
+ }>, at: string): void;
645
+ /**
646
+ * Content hash + parse version for a session, if already ingested. Both feed
647
+ * the re-ingest decision: content_hash catches changed transcripts, parse
648
+ * version catches a smarter parser (new fields extracted from the same bytes).
649
+ */
650
+ storedMeta(id: string): {
651
+ hash: string;
652
+ parseVersion: number;
653
+ } | undefined;
654
+ /** Set a session's resolved repo. Used to backfill repo without a full re-ingest. */
655
+ setSessionRepo(id: string, repo: string): void;
656
+ ingestSession(session: Session, costUsd: number, facts: UsageFactInput[], priceTableVersion: string, parseVersion: number): void;
657
+ /**
658
+ * Token/cost rolled up by model from `usage_facts` — the honest cost-by-model
659
+ * the `sessions.models` array can't give (exploding it double-counts cost).
660
+ */
661
+ usageByModel(): Array<{
662
+ model: string;
663
+ sessions: number;
664
+ costUsd: number;
665
+ tokens: number;
666
+ }>;
667
+ /** Prior run record for cache checks. */
668
+ processorRun(sessionId: string, processor: string): ProcessorRunRow | undefined;
669
+ unresolvedArtifacts(producer: string): ArtifactInput[];
670
+ persistRefresh(producer: string, result: RefreshResult): void;
671
+ /**
672
+ * Persist one processor's output. Replaces this processor's prior rows for the
673
+ * session (provenance via `producer`); never touches other processors' or
674
+ * user-authored rows. Records the run for caching + analysis-cost accounting.
675
+ */
676
+ persistResult(sessionId: string, processor: string, version: number, inputHash: string, model: string | null, result: ProcessorResult): void;
677
+ /**
678
+ * Apply an enrichment processor's hierarchy edits: REPARENT existing features.
679
+ * (Auto-rename is intentionally unsupported — see FeatureRevisionInput.) Skips
680
+ * user-authored features (locked) and any reparent that would form a cycle or
681
+ * self-parent. Caller runs this inside a transaction.
682
+ */
683
+ private applyFeatureRevisions;
684
+ /** True if parenting `id` under `newParentId` would create a cycle (walks ancestors). */
685
+ private wouldCreateFeatureCycle;
686
+ /**
687
+ * The WHOLE feature hierarchy — what an enrichment processor needs to attach a
688
+ * session to the most specific feature, slot a new feature under the right
689
+ * parent, and refine the tree. The hierarchy is global and human-managed (a
690
+ * single epic may span repos), so the processor sees everything; repo isolation
691
+ * is enforced only on auto-derived *linkage* (see `repos`). `source` flags
692
+ * user-authored features so the processor leaves them locked.
693
+ *
694
+ * `repos` = repos associated anywhere in a feature's subtree (itself + every
695
+ * descendant), unioned from linked sessions and any explicit `repo` column.
696
+ * Empty = unscoped/global. A feature is a safe auto-link target for a session
697
+ * iff its `repos` is empty or already contains the session's repo.
698
+ */
699
+ listFeatures(): Array<{
700
+ id: string;
701
+ title: string;
702
+ parentId: string | null;
703
+ source: string | null;
704
+ repos: string[];
705
+ }>;
706
+ /**
707
+ * Per-feature subtree repo set: the repos associated anywhere in a feature's
708
+ * subtree (itself + every descendant), unioned from each feature's explicit
709
+ * `repo` column and the repos of sessions linked to it. Shared by feature
710
+ * extraction (linkage isolation) and the dashboard (the Features repo column).
711
+ */
712
+ private featureRepoSets;
713
+ /** Persist facets (intrinsic + processor-declared) so the dashboard discovers them generically. */
714
+ registerFacets(producer: string, specs: FacetSpec[]): void;
715
+ summary(): Summary;
716
+ /** The single most significant week-over-week move to lead the digest with:
717
+ * compares this window's headline KPIs (spend, success rate, session count) to
718
+ * the prior equal-length window and returns the biggest mover — normalized to
719
+ * how many times over its own "notable" bar each one is, so one metric type
720
+ * can't dominate just because its natural swings run larger. Null when nothing
721
+ * clears its bar, or the prior window's base is too thin to trust the delta.
722
+ * Only called for a bounded [from, to). */
723
+ private trendHeadline;
724
+ /** The windowed "reliable facts" behind the Highlights digest — most-spend
725
+ * shipped artifact, its stalled (not-yet-shipped) counterpart, converted spend,
726
+ * and the busiest source file — all scoped to [from, to) (omit for all-time) so
727
+ * the whole digest honors the dashboard window. */
728
+ private windowedFacts;
729
+ /** The Highlights digest: a few reliably-interesting facts plus facet-WALKED
730
+ * comparisons (spend concentration, outcome-rate spread) — for each, we go down
731
+ * an ordered facet list and keep the FIRST facet whose breakdown clears an
732
+ * interestingness threshold, so nothing is hardcoded to `repo` and a dominated
733
+ * split (e.g. one harness at 99%) is skipped. Each insight is a typed payload;
734
+ * the client renders the sentence + drill-in. `from`/`to` window everything;
735
+ * omit for all-time. */
736
+ highlights(from?: string, to?: string): Highlight[];
737
+ /** Distribution of a scalar annotation value across sessions. */
738
+ private scalarDist;
739
+ /**
740
+ * Windowed cost-per-shipped-artifact KPI (no window = all time). The numerator is
741
+ * the cost of the BLOCKS that produced each in-window completed artifact (block→PR
742
+ * is deterministic; block→feature is the LLM feature_runs). Blocks partition the
743
+ * session, so a session that also did unshipped/other work is NOT charged whole —
744
+ * the old unique-session approximation dissolves (handling_long_sessions P1/P2).
745
+ * Falls back to whole-session cost for any artifact with NO block links (a feature
746
+ * the model never block-linked, or pre-block data). Both paths are at usage grain
747
+ * and UNION-deduped, so a usage row shared across in-window artifacts counts once.
748
+ */
749
+ costPerArtifact(kind: string, from?: string, to?: string, complexity?: string): {
750
+ count: number;
751
+ costPerUnit: number | null;
752
+ };
753
+ /**
754
+ * The headline KPI row for one time window. Session-grain metrics (count,
755
+ * spend, outcome rate) window by session start; cost-per-artifact windows by
756
+ * completion (see costPerArtifact). The API calls this twice — current and the
757
+ * same-length prior period — to derive deltas. No window = all time.
758
+ */
759
+ kpis(from?: string, to?: string, outcomes?: string[]): KpiSnapshot;
760
+ /**
761
+ * The two decomposition curves for the cost-per-artifact section
762
+ * (cost_per_shipped_artifact.md). Both are PURE SUMS (0 is a real value, no
763
+ * attribution): burn = AI spend per bucket dated at SESSION time (with a
764
+ * `shippedSpend` sub-band = spend of sessions linked to a completed `kind`
765
+ * artifact — the gap to `spend` is in-flight/never-shipped spend); throughput
766
+ * = count of `kind` artifacts per bucket dated at COMPLETION. Both honor the
767
+ * optional window (burn by session start, throughput by completion); no window
768
+ * = full history. The `bucket` granularity is the caller's (day/week/month).
769
+ */
770
+ costCurves(kind: string, bucket: Bucket, from?: string, to?: string, complexity?: string): {
771
+ burn: Array<{
772
+ bucket: string;
773
+ spend: number;
774
+ shippedSpend: number;
775
+ }>;
776
+ throughput: Array<{
777
+ bucket: string;
778
+ count: number;
779
+ }>;
780
+ /** PRs reviewed per bucket, dated at REVIEW time (the pr_reviewed outcome ts). PRs only. */
781
+ reviewed: Array<{
782
+ bucket: string;
783
+ count: number;
784
+ }>;
785
+ buckets: string[];
786
+ };
787
+ /**
788
+ * The complete ordered list of bucket labels spanning [from, to] at the given
789
+ * granularity. Walks one calendar day at a time in SQL and buckets each with
790
+ * the same expression as the data, so the labels match exactly (no JS attempt
791
+ * to reproduce SQLite's %W week numbering). Used to give the cost curves a
792
+ * continuous x-axis across the window.
793
+ */
794
+ private bucketAxis;
795
+ /**
796
+ * The x-axis for a windowed time series: every bucket from `from` to `to`
797
+ * (so the chart spans the whole window and empty periods show as gaps),
798
+ * unioned with the data's own buckets as a safety net. No window → the data's
799
+ * buckets as-is (all-time). Shared by the dashboard time-series endpoints.
800
+ */
801
+ private fullAxis;
802
+ /**
803
+ * The "burn efficiency" lens for a window: Σ session spend in the window ÷
804
+ * count of `kind` artifacts completed in the window. Deliberately distinct
805
+ * from the unit-cost KPI (whose numerator includes pre-window spend) — the doc
806
+ * insists both be shown so dividing the curves doesn't read as a contradiction.
807
+ * `throughput` here equals the KPI denominator exactly. No window = all time.
808
+ */
809
+ costPeriod(kind: string, from?: string, to?: string, complexity?: string): {
810
+ burn: number;
811
+ throughput: number;
812
+ efficiency: number | null;
813
+ };
814
+ /**
815
+ * Spend over time, optionally split into one series per facet value — the doc's
816
+ * non-headline "total spend breakdown" / burn view (cost_per_shipped_artifact.md
817
+ * §Separate). Anchored on usage_facts so cost-by-model splits HONESTLY (each
818
+ * usage row attributed to its own model), not by charging a multi-model
819
+ * session's whole cost to each model. Spend is dated at session start (matching
820
+ * the burn curve). Only usage/session-grain facets are valid (the cost measure's
821
+ * grain guard); tool-call facets (skill) are rejected. Multi-valued facets
822
+ * (use_case) presence-inflate — flagged via `presenceInflated`.
823
+ */
824
+ spendOverTime(q: SpendOverTimeQuery): SpendOverTimeResult | {
825
+ error: string;
826
+ };
827
+ /**
828
+ * Session COUNT over time, optionally split into one series per COMPOSITE label
829
+ * — the time-series form of the distribution cards. Each session is labeled by
830
+ * the sorted set of its distinct values for the dimension (e.g. <opus, haiku>)
831
+ * and grouped by it, so every session lands in exactly one series and the
832
+ * counts partition the total (honest to STACK) — no presence-inflation. The
833
+ * tail past top-K collapses into "Other".
834
+ */
835
+ sessionsOverTime(q: SessionsOverTimeQuery): SessionsOverTimeResult;
836
+ /**
837
+ * Operational tool-call metrics over time. One anchor (tool_calls t JOIN
838
+ * sessions s, dated at session start); the `view` selects what to plot:
839
+ * tool_calls = COUNT(*), error_rate = SUM(is_error)/COUNT(*), skill_usage =
840
+ * COUNT(*) WHERE action='skill'. `by:'name'` splits by tool_calls.name (tool
841
+ * name in general; skill name when skills-only), ranked top-K by call volume.
842
+ */
843
+ opsOverTime(q: OpsOverTimeQuery): OpsOverTimeResult;
844
+ /** Distinct tool-call names, busiest first — feeds the Ops error-rate tool filter. */
845
+ toolNames(): string[];
846
+ /**
847
+ * Outcome types present in the data, with the count of distinct sessions that
848
+ * produced each — feeds the success-rate "what counts as success" selector.
849
+ * (A first-class outcome-type registry, parallel to facets/measures, is a
850
+ * deferred follow-up; for now the selector reflects what's actually in the DB.)
851
+ */
852
+ outcomeTypes(): Array<{
853
+ type: string;
854
+ sessions: number;
855
+ }>;
856
+ /**
857
+ * Session Outcome Rate over time (headline_metrics.md): the fraction of
858
+ * sessions — cohorted by START date — that produced any outcome in the
859
+ * selected set. Numerator = sessions with an outcome in `outcomes`; denominator
860
+ * = all sessions in the bucket. Session-level filters apply to BOTH (so the
861
+ * rate is honest). With `by`, returns one series per COMPOSITE label (top-K by
862
+ * volume, the tail collapsed into "Other"): each session is labeled by the
863
+ * sorted set of its distinct values for the dimension (e.g. <opus, haiku>), so
864
+ * every session falls in exactly one series and the bars partition the
865
+ * population — multi-valued sessions are counted once, not fanned out.
866
+ */
867
+ successRate(q: SuccessRateQuery): SuccessRateResult;
868
+ /** Spend, session count, and shipped-PR count per time bucket. */
869
+ timeseries(bucket: Bucket, from?: string, to?: string): TimePoint[];
870
+ /** The facet registry — drives dist cards, filters, and (later) breakdowns. */
871
+ facetList(): FacetSpec[];
872
+ facet(key: string): FacetSpec | undefined;
873
+ /**
874
+ * Sessions per value of a facet — the generic dist card. The read shape is
875
+ * derived from (source, multi): raw column, json_each, json_extract, or a child
876
+ * table. This is a COUNT, so exploding a multi-valued facet is safe (a session
877
+ * present under two values is intended); SUM measures are a separate concern.
878
+ */
879
+ facetDistribution(key: string): Dist[];
880
+ /**
881
+ * Compile a facet + value into a session-scoped boolean SQL fragment (alias `s`).
882
+ * One compiler, reused by session filters today and cohort splits later. Column
883
+ * identifiers and `base` are registry-defined (trusted); the value is a bound param.
884
+ */
885
+ private facetPredicate;
886
+ /**
887
+ * Correlated subquery (alias `s`) yielding a session's DISTINCT values for a
888
+ * facet as one alpha-sorted, ", "-joined string — the composite label for the
889
+ * success-rate breakdown; empty set → NULL. Mirrors facetPredicate's source
890
+ * switch (identifiers/base are registry-defined and trusted, values are data).
891
+ */
892
+ private comboExpr;
893
+ /** Persist measures (intrinsic + processor-declared) for the dashboard. */
894
+ registerMeasures(producer: string, specs: MeasureSpec[]): void;
895
+ measureList(): MeasureSpec[];
896
+ measure(key: string): MeasureSpec | undefined;
897
+ /**
898
+ * The breakdown engine: aggregate a measure, optionally grouped by a facet,
899
+ * with session-scoped filters. The grain guard keeps SUM/AVG honest — a facet
900
+ * is valid here only at the measure's grain or session-grain (the common
901
+ * ancestor). Finer / sibling facets need the pre-reduction (cohort) path, not
902
+ * built yet, and return an error rather than a silently double-counted number.
903
+ */
904
+ breakdown(measureKey: string, byFacetKey?: string, filters?: Record<string, string>, window?: {
905
+ from?: string;
906
+ to?: string;
907
+ }, toolNames?: string[]): {
908
+ rows: Array<{
909
+ bucket: string | null;
910
+ value: number;
911
+ }>;
912
+ total: number;
913
+ } | {
914
+ error: string;
915
+ };
916
+ /**
917
+ * Every failed tool call of one error category — the occurrence list behind the
918
+ * "Errors by category" drill-down. Newest session first; `idx` is the tool call's
919
+ * position in its session, which the transcript anchors as `txerr-<idx>` so a row
920
+ * deep-links to that exact error block. Windowed like breakdown. Capped at 50; the
921
+ * widget shows the true total (the bar count) with a "+N more" note past the cap.
922
+ */
923
+ errorOccurrences(category: string, window?: {
924
+ from?: string;
925
+ to?: string;
926
+ }, toolNames?: string[]): ErrorOccurrence[];
927
+ /** Filtered session list. Filter VALUES are bound params; keys are hardcoded. */
928
+ sessionList(f: SessionFilter): SessionListItem[];
929
+ /** Full detail for one session, including a viewer-ready transcript from the blob. */
930
+ /** Per-block labels (use_case / PR / feature) for the transcript filter bar. */
931
+ private blockLabels;
932
+ sessionDetail(id: string): SessionDetail | null;
933
+ /**
934
+ * The session's value for every facet flagged for the `detail` role — the
935
+ * registry-driven metadata list the drawer renders. Keeps the drawer from
936
+ * hardcoding which dimensions exist: a new processor facet with a `detail`
937
+ * role appears here with no store or client edits. Ordered by registration
938
+ * (intrinsic first, then processors) rather than alphabetically.
939
+ */
940
+ facetValues(id: string): FacetValue[];
941
+ /** Resolve one facet's value(s) for a session, branching on (source, multi) like facetPredicate. */
942
+ private facetValueFor;
943
+ /** Gunzip + parse a session's stored blob (the full normalized Session), or null. */
944
+ private loadSession;
945
+ /**
946
+ * The session's successful file edits as a flat, CHRONOLOGICAL list — the
947
+ * Files-changed view. Each carries its raw before/after (Edit), full content
948
+ * (Write), or hunks (MultiEdit), plus the transcript turn it happened in and
949
+ * the preceding (non-synthetic) user turn, so the UI can group by file or by
950
+ * prompt and link each change to its intent. Rejected / not-yet-read edits are
951
+ * excluded (they changed nothing). Reconstructs from the blob at read time.
952
+ */
953
+ fileChanges(id: string): FileEdit[];
954
+ /**
955
+ * Shippable artifacts (PRs + features) with session count and fully-loaded
956
+ * cost. Cost sums the UNIQUE sessions linked to each artifact; a session
957
+ * spanning several artifacts is counted in each, so the column can exceed
958
+ * total spend (per-artifact attribution, by design).
959
+ */
960
+ artifactList(kind?: string, complexity?: string, from?: string, to?: string, shippedOnly?: boolean): ArtifactListItem[];
961
+ /**
962
+ * Per-feature last session time: the most recent start of any session linked to
963
+ * the feature OR any descendant (subtree max), so a parent reflects the latest
964
+ * activity beneath it. Null when nothing under it has a dated session.
965
+ */
966
+ private featureLastSession;
967
+ /**
968
+ * Per-feature cost rolled up over the feature hierarchy, for the hierarchical
969
+ * cost-breakdown charts. `ownCost` is the spend attributed DIRECTLY to a feature
970
+ * (the same block-attributed-with-whole-session-fallback cost as the artifactList
971
+ * column); `subtreeCost` adds every descendant's own cost, so a parent epic
972
+ * reflects the total invested beneath it (subtreeCost − Σ children.subtreeCost =
973
+ * ownCost). All-time, not windowed: total investment per feature, matching the
974
+ * artifactList cost semantics. Cycle-safe + memoized, mirroring featureLastSession.
975
+ * parentId is normalized to null when it doesn't point at another feature, so the
976
+ * client can treat such rows as roots.
977
+ */
978
+ featureCostTree(complexity?: string, from?: string, to?: string): Array<{
979
+ id: string;
980
+ title: string | null;
981
+ parentId: string | null;
982
+ ownCost: number;
983
+ subtreeCost: number;
984
+ }>;
985
+ /**
986
+ * Build a SQL condition + params for artifact text search that handles plain
987
+ * terms, `#N` (PR number with hash prefix), and `repo#N` (repo + number).
988
+ */
989
+ private artifactSearchCond;
990
+ /**
991
+ * Typeahead suggestions for the session-list artifact search. Only artifacts
992
+ * actually linked to a session (so a pick yields results), matched on the same
993
+ * columns the filter uses (ident/title/external_id/repo). `value` is what to
994
+ * put in the filter input (feature→title, pr→external_id|ident, file→path);
995
+ * `label` is for display. Features/PRs rank above the many file rows.
996
+ */
997
+ suggestArtifacts(q: string, kind: string | undefined, limit?: number): Array<{
998
+ kind: string;
999
+ value: string;
1000
+ label: string;
1001
+ }>;
1002
+ /** Create a user-authored feature (source='user' — never clobbered by analyze). */
1003
+ createFeature(title: string, parentId?: string, complexity?: number): {
1004
+ id: string;
1005
+ };
1006
+ /** Mark complete/reopen, rename, reparent, or set complexity of a feature. */
1007
+ updateFeature(id: string, patch: {
1008
+ completed?: boolean;
1009
+ parentId?: string | null;
1010
+ title?: string;
1011
+ complexity?: number | null;
1012
+ }): boolean;
1013
+ /** Delete a feature; promote its children to its parent and remove its links. */
1014
+ deleteFeature(id: string): boolean;
1015
+ /**
1016
+ * Delete machine-derived artifacts no longer referenced by any session or
1017
+ * link (e.g. PRs whose false-positive links were removed on re-derivation).
1018
+ * Never touches user-authored artifacts.
1019
+ */
1020
+ /**
1021
+ * Delete sessions whose parse_version is below the current version for their
1022
+ * source — these are sessions the parser now returns null for (e.g. synthetic-only).
1023
+ */
1024
+ pruneStaleSessionsByVersion(versionBySource: Map<string, number>): number;
1025
+ pruneOrphanArtifacts(): number;
1026
+ /** Link an existing artifact to a session (user-authored, never overwritten by processors). Clears any prior rejection tombstone. */
1027
+ addSessionLink(sessionId: string, artifactId: string, role?: SessionArtifactRole): boolean;
1028
+ /** Reject a session→artifact link: delete existing rows and insert a tombstone so re-enrichment won't recreate it. */
1029
+ rejectSessionLink(sessionId: string, artifactId: string): boolean;
1030
+ /**
1031
+ * Mark every processor run for a session stale so the next analyze re-runs them.
1032
+ *
1033
+ * Called on any user link/unlink. The user-linked set feeds enrichment, and
1034
+ * unlink deletes block_artifacts across producers, so the deterministic
1035
+ * processors (outcomes-git) must re-derive too — enrich-only invalidation
1036
+ * would leave their wiped rows unregenerated. We flag rather than delete so
1037
+ * the row's cost_usd/tokens survive; persistResult resets the flag on the
1038
+ * next successful run. Cost: at most one extra analyze per explicit user
1039
+ * action (not on every analyze).
1040
+ */
1041
+ private invalidateSessionProcessors;
1042
+ /**
1043
+ * The single writer for user-created session links. Every dashboard link path
1044
+ * funnels through here so the write and its cache invalidation can never drift
1045
+ * apart (that drift is how add-pr/create-feature previously skipped it). Call
1046
+ * within the caller's own transaction so the link and invalidation commit atomically.
1047
+ */
1048
+ private linkUserArtifact;
1049
+ /** Titles of features the user rejected for this session (for LLM prompt context). */
1050
+ rejectedFeatureTitles(sessionId: string): string[];
1051
+ /** Blocks already attributed to PRs for a session (deterministic, from outcomes-git). */
1052
+ prBlockAttributions(sessionId: string): Array<{
1053
+ blockIdx: number;
1054
+ artifactId: string;
1055
+ title: string | null;
1056
+ }>;
1057
+ /** All user-linked PRs/features for a session, with a flag indicating deterministic block ownership. */
1058
+ userLinkedArtifactsAll(sessionId: string): Array<{
1059
+ artifactId: string;
1060
+ kind: 'pr' | 'feature';
1061
+ title: string | null;
1062
+ ident: string | null;
1063
+ hasNonEnrichBlocks: boolean;
1064
+ }>;
1065
+ /** Create a new feature and link it to a session in one transaction. */
1066
+ createAndLinkFeature(sessionId: string, title: string, parentId?: string): {
1067
+ id: string;
1068
+ } | null;
1069
+ /** Upsert a PR artifact and link it to a session. */
1070
+ upsertAndLinkPr(sessionId: string, repo: string, prNumber: string, meta?: {
1071
+ title?: string;
1072
+ status?: string;
1073
+ externalId?: string;
1074
+ }): {
1075
+ id: string;
1076
+ } | null;
1077
+ /** Typeahead for linkable artifacts (excludes those already linked to the session). */
1078
+ suggestLinkableArtifacts(sessionId: string, q: string, kind?: string, limit?: number): Array<{
1079
+ id: string;
1080
+ kind: string;
1081
+ label: string;
1082
+ }>;
1083
+ close(): void;
1084
+ }
1085
+ interface ArtifactListItem {
1086
+ id: string;
1087
+ kind: string;
1088
+ title: string | null;
1089
+ ident: string | null;
1090
+ repo: string | null;
1091
+ /** Repos this artifact spans — a feature's full subtree union; one entry for a PR. */
1092
+ repos: string[];
1093
+ /** Most recent linked session start; for a feature, the max across its subtree. Null if none. */
1094
+ lastSessionAt: string | null;
1095
+ status: string | null;
1096
+ source: string | null;
1097
+ externalId: string | null;
1098
+ /** PR creation time (from `gh`); null when not captured (offline / pre-backfill). */
1099
+ createdAt: string | null;
1100
+ completedAt: string | null;
1101
+ parentId: string | null;
1102
+ complexity: number | null;
1103
+ complexityBasis: string | null;
1104
+ sessions: number;
1105
+ costUsd: number;
1106
+ /** Max content-match AI-attribution fraction across the artifact's session links (0–1);
1107
+ * null when no content-match link exists (e.g. an explicit-only PR). PRs only. */
1108
+ aiPct: number | null;
1109
+ }
1110
+ /** One window's worth of headline KPIs (see Store.kpis). */
1111
+ interface KpiSnapshot {
1112
+ sessions: number;
1113
+ totalSpend: number;
1114
+ /** Fraction of sessions judged success; null when the window has no sessions. */
1115
+ successRate: number | null;
1116
+ /** Tool-call error rate (fraction); null when the window has no tool calls. */
1117
+ errorRate: number | null;
1118
+ costPerFeature: {
1119
+ count: number;
1120
+ costPerUnit: number | null;
1121
+ };
1122
+ costPerPr: {
1123
+ count: number;
1124
+ costPerUnit: number | null;
1125
+ };
1126
+ }
1127
+ /** One bucket of a success-rate series: the cohort's numerator/denominator/rate. */
1128
+ interface RatePoint {
1129
+ bucket: string;
1130
+ num: number;
1131
+ denom: number;
1132
+ /** Total session cost (USD) in the bucket — feeds the per-value cost table
1133
+ * (total spend, and $/session = spend/denom). Summed over the SAME population
1134
+ * as `denom` (all sessions, not just successful ones), so spend/denom is an
1135
+ * honest avg cost per session. */
1136
+ spend: number;
1137
+ /** num/denom, or null when the bucket has no sessions (drawn as a gap). */
1138
+ rate: number | null;
1139
+ }
1140
+ /** A success-rate line: per-bucket points plus the windowed totals/rate. */
1141
+ interface RateSeries {
1142
+ key: string;
1143
+ points: RatePoint[];
1144
+ num: number;
1145
+ denom: number;
1146
+ /** Window-total session cost (USD); spend/denom = avg cost per session. */
1147
+ spend: number;
1148
+ rate: number | null;
1149
+ }
1150
+ interface SuccessRateQuery {
1151
+ /** Outcome types counting as success (numerator). Empty → ['session_success']. */
1152
+ outcomes: string[];
1153
+ bucket: Bucket;
1154
+ /** Facet key to split into one series per value (top-K by volume). */
1155
+ by?: string;
1156
+ from?: string;
1157
+ to?: string;
1158
+ /** Session-level facet filters (multi-value OR within a facet), applied to
1159
+ * numerator and denominator alike. */
1160
+ filters?: Record<string, string[]>;
1161
+ topK?: number;
1162
+ }
1163
+ interface SuccessRateResult {
1164
+ outcomes: string[];
1165
+ bucket: Bucket;
1166
+ /** The x-axis: every bucket label the overall line spans. */
1167
+ buckets: string[];
1168
+ overall: RateSeries;
1169
+ /** Present when `by` is set. */
1170
+ series?: RateSeries[];
1171
+ /** Set when more facet values existed than were drawn. */
1172
+ truncated?: {
1173
+ shown: number;
1174
+ total: number;
1175
+ };
1176
+ }
1177
+ /** One bucket of an operational (tool-call) series: count + error split. */
1178
+ interface OpsPoint {
1179
+ bucket: string;
1180
+ /** The plotted metric: call count (count views) or error rate (rate view), null if no calls. */
1181
+ value: number | null;
1182
+ calls: number;
1183
+ errors: number;
1184
+ }
1185
+ interface OpsSeries {
1186
+ key: string;
1187
+ /** Display label when the key isn't already human-readable (error categories). */
1188
+ label?: string;
1189
+ points: OpsPoint[];
1190
+ /** Total count, or overall error rate, depending on the view. */
1191
+ total: number | null;
1192
+ /** Call volume — used to rank series (top-K most-used tools/skills). */
1193
+ calls: number;
1194
+ }
1195
+ interface OpsOverTimeQuery {
1196
+ /** tool_calls = count all; error_rate = AVG(is_error); skill_usage = count where action='skill'. */
1197
+ view: 'tool_calls' | 'error_rate' | 'skill_usage';
1198
+ bucket: Bucket;
1199
+ /** 'name' splits by tool name; 'error_category' decomposes the rate by category. */
1200
+ by?: string;
1201
+ from?: string;
1202
+ to?: string;
1203
+ /** Generic session-level facet filters (harness/repo/model); unused by the Ops UI today. */
1204
+ filters?: Record<string, string[]>;
1205
+ /** Row-level scope: only count calls of these tool names (denominator + numerator). */
1206
+ toolNames?: string[];
1207
+ /** Row-level scope: only these categories count as errors (numerator only). */
1208
+ errorCategories?: string[];
1209
+ topK?: number;
1210
+ }
1211
+ interface OpsOverTimeResult {
1212
+ view: string;
1213
+ bucket: Bucket;
1214
+ /** The active breakdown dimension, echoed back so the client can label series. */
1215
+ by?: string;
1216
+ buckets: string[];
1217
+ overall: {
1218
+ points: OpsPoint[];
1219
+ total: number | null;
1220
+ };
1221
+ series?: OpsSeries[];
1222
+ truncated?: {
1223
+ shown: number;
1224
+ total: number;
1225
+ };
1226
+ format: 'int' | 'pct';
1227
+ }
1228
+ /** One bucket of a session-count series. */
1229
+ interface CountPoint {
1230
+ bucket: string;
1231
+ count: number;
1232
+ }
1233
+ interface CountSeries {
1234
+ key: string;
1235
+ points: CountPoint[];
1236
+ total: number;
1237
+ }
1238
+ interface SessionsOverTimeQuery {
1239
+ bucket: Bucket;
1240
+ by?: string;
1241
+ from?: string;
1242
+ to?: string;
1243
+ filters?: Record<string, string[]>;
1244
+ topK?: number;
1245
+ }
1246
+ interface SessionsOverTimeResult {
1247
+ bucket: Bucket;
1248
+ buckets: string[];
1249
+ overall: {
1250
+ points: CountPoint[];
1251
+ total: number;
1252
+ };
1253
+ series?: CountSeries[];
1254
+ truncated?: {
1255
+ shown: number;
1256
+ total: number;
1257
+ };
1258
+ }
1259
+ /** One bucket of a spend series. */
1260
+ interface SpendPoint {
1261
+ bucket: string;
1262
+ spend: number;
1263
+ }
1264
+ /** A spend line: per-bucket points plus the total over the range. */
1265
+ interface SpendSeries {
1266
+ key: string;
1267
+ points: SpendPoint[];
1268
+ total: number;
1269
+ }
1270
+ interface SpendOverTimeQuery {
1271
+ bucket: Bucket;
1272
+ /** Facet key to split into one series per value (top-K by total spend). */
1273
+ by?: string;
1274
+ from?: string;
1275
+ to?: string;
1276
+ filters?: Record<string, string[]>;
1277
+ topK?: number;
1278
+ }
1279
+ interface SpendOverTimeResult {
1280
+ bucket: Bucket;
1281
+ buckets: string[];
1282
+ overall: {
1283
+ points: SpendPoint[];
1284
+ total: number;
1285
+ };
1286
+ series?: SpendSeries[];
1287
+ truncated?: {
1288
+ shown: number;
1289
+ total: number;
1290
+ };
1291
+ /** True when the breakdown facet is multi-valued, so series sum past overall. */
1292
+ presenceInflated?: boolean;
1293
+ }
1294
+ type Bucket = 'day' | 'week' | 'month';
1295
+ interface TimePoint {
1296
+ bucket: string;
1297
+ sessions: number;
1298
+ spend: number;
1299
+ shipped: number;
1300
+ }
1301
+ interface SessionFilter {
1302
+ /** facetKey -> value; compiled to predicates via the facet registry. */
1303
+ facets?: Record<string, string>;
1304
+ q?: string;
1305
+ /** Match sessions linked to an artifact whose path/PR/url/repo/feature-title matches. */
1306
+ artifact?: string;
1307
+ /** Restrict the artifact match to a kind: file | pr | feature | ticket | commit. */
1308
+ artifactKind?: string;
1309
+ /** Window on session start (ISO); inclusive lower / exclusive upper bound. */
1310
+ from?: string;
1311
+ to?: string;
1312
+ /** Match sessions that produced ANY of these outcome types (OR). */
1313
+ outcomeTypes?: string[];
1314
+ limit?: number;
1315
+ }
1316
+ interface SessionListItem {
1317
+ id: string;
1318
+ title: string;
1319
+ startedAt: string | null;
1320
+ costUsd: number;
1321
+ models: string[];
1322
+ complexity: string | null;
1323
+ useCase: string[];
1324
+ intent: string | null;
1325
+ /** Distinct outcome types this session produced (e.g. pr_merged, session_success). */
1326
+ outcomes: string[];
1327
+ }
1328
+ interface TranscriptTool {
1329
+ name: string;
1330
+ action: string;
1331
+ ok: boolean;
1332
+ /** This call's index in session.toolCalls — the transcript anchors a failed call as `txerr-<idx>`. */
1333
+ idx?: number;
1334
+ target?: string;
1335
+ /** Full tool input rendered as displayable text (key field or JSON). */
1336
+ command?: string;
1337
+ /** Tool output/result text (clipped to OUTPUT_MAX, with an explicit tail notice if cut). */
1338
+ output?: string;
1339
+ /** For Edit/Write: old→new hunks for inline diff rendering. */
1340
+ hunks?: {
1341
+ del: string;
1342
+ ins: string;
1343
+ }[];
1344
+ error?: string;
1345
+ /** For a subagent-spawning call (`Task`/`Agent`), the agentId it links to. */
1346
+ agentId?: string;
1347
+ }
1348
+ interface TranscriptTurn {
1349
+ role: 'user' | 'assistant' | 'system';
1350
+ ts?: string;
1351
+ sidechain: boolean;
1352
+ /** Which subagent emitted this turn; undefined for main-thread turns. */
1353
+ agentId?: string;
1354
+ /** Main-thread sequence index (undefined for sidechain turns). */
1355
+ seq?: number;
1356
+ /** Block this turn belongs to (handling_long_sessions); undefined if unmapped. */
1357
+ blockIdx?: number;
1358
+ text: string;
1359
+ tools: TranscriptTool[];
1360
+ }
1361
+ /** One subagent's identity, for the transcript's per-subagent tab + spawn link. */
1362
+ interface SubagentInfo {
1363
+ agentId: string;
1364
+ agentType?: string;
1365
+ description?: string;
1366
+ /** tool_use id of the spawning call in the parent thread (absent for workflow subagents). */
1367
+ toolUseId?: string;
1368
+ }
1369
+ /**
1370
+ * A session's viewer-ready transcript: one flat, globally-indexed list of turns
1371
+ * (each tagged with its `agentId`, so the client can split the main thread from
1372
+ * each subagent into its own tab) plus the subagent roster.
1373
+ */
1374
+ /** One block's identity + labels, for the transcript's filter bar. */
1375
+ interface TranscriptBlock {
1376
+ idx: number;
1377
+ useCase?: string | null;
1378
+ pr?: {
1379
+ ident: string;
1380
+ title?: string;
1381
+ } | null;
1382
+ feature?: {
1383
+ id: string;
1384
+ title?: string;
1385
+ } | null;
1386
+ }
1387
+ interface Transcript {
1388
+ turns: TranscriptTurn[];
1389
+ subagents: SubagentInfo[];
1390
+ /** Block partition + per-block labels, for filtering the transcript by PR / feature / use-case. */
1391
+ blocks: TranscriptBlock[];
1392
+ }
1393
+ /** A facet's resolved value for one session — the registry-driven detail row. */
1394
+ interface FacetValue {
1395
+ key: string;
1396
+ label: string;
1397
+ type: FacetType;
1398
+ /** scalar, list (multi / child-grain facets), or null when the session has none. */
1399
+ value: string | string[] | null;
1400
+ }
1401
+ interface SessionDetail {
1402
+ session: Record<string, unknown>;
1403
+ annotations: Record<string, unknown>;
1404
+ outcomes: Array<{
1405
+ type: string;
1406
+ artifactId: string | null;
1407
+ }>;
1408
+ artifacts: Array<Record<string, unknown>>;
1409
+ facets: FacetValue[];
1410
+ transcript: Transcript;
1411
+ }
1412
+ /**
1413
+ * One successful file write in the session — a before/after (Edit), full content
1414
+ * (Write), or hunks (MultiEdit). Returned as a flat, chronological list so the
1415
+ * client can group it either by file or by prompt.
1416
+ */
1417
+ interface FileEdit {
1418
+ path: string;
1419
+ op: 'edit' | 'multiedit' | 'write';
1420
+ hunks: Array<{
1421
+ del: string;
1422
+ ins: string;
1423
+ }>;
1424
+ ts?: string;
1425
+ /** Index into the transcript turns of the assistant turn that made the edit. */
1426
+ turn: number;
1427
+ /** Index of the preceding (non-synthetic) user turn — the prompting intent, or -1. */
1428
+ userTurn: number;
1429
+ }
1430
+
1431
+ /** Topologically order processors so a processor runs after everything in `requires`. */
1432
+ declare function orderProcessors(procs: Processor[]): Processor[];
1433
+ interface RunOptions {
1434
+ session: Session;
1435
+ processors: Processor[];
1436
+ store: Store;
1437
+ log: Logger;
1438
+ llmEnabled: boolean;
1439
+ llmModel: string | null;
1440
+ llm: LlmClient | null;
1441
+ sh: ProcessorContext['sh'];
1442
+ }
1443
+ interface RunResult {
1444
+ costUsd: number;
1445
+ }
1446
+ /** Run every applicable processor for one session, honoring deps + the cache. */
1447
+ declare function runProcessors(opts: RunOptions): Promise<RunResult>;
1448
+
1449
+ /** Resolved runtime configuration for a single invocation. */
1450
+ interface TuneloopConfig {
1451
+ /** Directory holding the SQLite store and other local state. */
1452
+ dataDir: string;
1453
+ dbPath: string;
1454
+ /** LLM provider for enrichment (BYO key), or null when not configured. */
1455
+ llm: {
1456
+ provider: string;
1457
+ model: string;
1458
+ apiKey: string;
1459
+ baseURL?: string;
1460
+ } | null;
1461
+ }
1462
+ /** Non-secret LLM knobs settable via CLI flags; they override env. The API key is env-only. */
1463
+ interface LlmOverrides {
1464
+ provider?: string;
1465
+ model?: string;
1466
+ baseURL?: string;
1467
+ }
1468
+ declare function loadConfig(opts?: {
1469
+ dataDir?: string;
1470
+ db?: string;
1471
+ llm?: LlmOverrides;
1472
+ }): TuneloopConfig;
1473
+
1474
+ interface AnalyzeOptions {
1475
+ dirs?: string[];
1476
+ /** `--source` entries: a harness name, optionally `name=dir` to override its roots. */
1477
+ sources?: string[];
1478
+ db?: string;
1479
+ verbose?: boolean;
1480
+ /** Cap the number of sessions processed — handy for a cheap enrichment test. */
1481
+ limit?: number;
1482
+ /** Non-secret LLM flag overrides (provider/model/base-url); the key stays env-only. */
1483
+ llm?: LlmOverrides;
1484
+ }
1485
+ /**
1486
+ * Discover sessions → parse (adapter) → ingest changed ones → run processors
1487
+ * (cache-aware) → print a summary. Writes to the store only; the dashboard,
1488
+ * `search`, and `observe` all read it.
1489
+ */
1490
+ declare function analyze(opts: AnalyzeOptions): Promise<void>;
1491
+
1492
+ interface ServeOptions {
1493
+ db?: string;
1494
+ port?: number;
1495
+ open?: boolean;
1496
+ }
1497
+ /** Serve the dashboard over an already-analyzed store. Reads only; Ctrl+C stops. */
1498
+ declare function serve(opts: ServeOptions): Promise<void>;
1499
+
1500
+ type ShFn = (cmd: string, args: string[]) => Promise<ShResult | null>;
1501
+ /**
1502
+ * JSON API + dashboard SPA over the analyzed store. Reads are queries at request
1503
+ * time; POST endpoints write user curation only (features + session↔artifact
1504
+ * links), stamped user-authored so `analyze` never clobbers them. Deriving facts
1505
+ * from transcripts stays in the `analyze` path.
1506
+ */
1507
+ declare function createDashboardServer(store: Store, dbPath: string, sh?: ShFn): Server;
1508
+
1509
+ interface QueryOptions {
1510
+ /** Stop after this many rows (default 1000). */
1511
+ maxRows?: number;
1512
+ /** Stop once accumulated JSON size exceeds this (default 5MB). */
1513
+ maxBytes?: number;
1514
+ /** Stop if row production exceeds this wall-clock budget (default 5s). */
1515
+ timeoutMs?: number;
1516
+ /** Positional (?) or named (:name) bind parameters. */
1517
+ params?: unknown[] | Record<string, unknown>;
1518
+ }
1519
+ interface QueryResult {
1520
+ columns: string[];
1521
+ rows: Record<string, unknown>[];
1522
+ rowCount: number;
1523
+ /** Which cap ended the read early, or null if the full result fit. */
1524
+ truncated: 'rows' | 'bytes' | 'time' | null;
1525
+ elapsedMs: number;
1526
+ }
1527
+ /** Rejected before touching the DB: shape violations the SQL engine wouldn't flag. */
1528
+ declare class QueryError extends Error {
1529
+ constructor(message: string);
1530
+ }
1531
+ /**
1532
+ * Run a single read-only SELECT against the store at `dbPath`. Opens and closes
1533
+ * its own connection every call — cheap, and keeps this fully independent of any
1534
+ * live Store/serve handle. Throws {@link QueryError} for guard violations and
1535
+ * SQLite's own errors (syntax, unknown column) for genuine SQL mistakes.
1536
+ */
1537
+ declare function runQuery(dbPath: string, sql: string, opts?: QueryOptions): QueryResult;
1538
+ interface SchemaTable {
1539
+ name: string;
1540
+ /** The CREATE statement as SQLite normalized it — guaranteed in sync with the store. */
1541
+ sql: string;
1542
+ }
1543
+ /** What's actually in the store — the extent, not the shape. Derived from `sessions`. */
1544
+ interface Coverage {
1545
+ sessions: number;
1546
+ firstAt: string | null;
1547
+ lastAt: string | null;
1548
+ lastAnalyzedAt: string | null;
1549
+ sources: {
1550
+ source: string | null;
1551
+ count: number;
1552
+ }[];
1553
+ repos: number;
1554
+ cwds: number;
1555
+ /** Source directories scanned, with each one's last-analyzed time (empty on pre-v9 stores). */
1556
+ roots: {
1557
+ source: string | null;
1558
+ path: string;
1559
+ lastAnalyzedAt: string | null;
1560
+ }[];
1561
+ }
1562
+ interface SchemaDump {
1563
+ schemaVersion: number | null;
1564
+ /** Store extent; null when reflecting the canonical (empty) schema. */
1565
+ coverage: Coverage | null;
1566
+ tables: SchemaTable[];
1567
+ facets: FacetSpec[];
1568
+ measures: MeasureSpec[];
1569
+ }
1570
+ /** Open the store read-only and dump its schema (see {@link schemaFromDb}). */
1571
+ declare function describeSchema(dbPath: string): SchemaDump;
1572
+ /** Build a fresh in-memory store purely to reflect the canonical schema (no data). */
1573
+ declare function canonicalSchema(): SchemaDump;
1574
+
1575
+ interface ModelPrice {
1576
+ input: number;
1577
+ output: number;
1578
+ cache_write_5m: number;
1579
+ cache_write_1h: number;
1580
+ cache_read: number;
1581
+ }
1582
+ /** Bump when models.json rates change so stored costs can be recomputed. */
1583
+ declare const PRICE_TABLE_VERSION = "2026-06-30";
1584
+ /**
1585
+ * Look up a price, tolerant of model-id drift: exact match, then strip a
1586
+ * trailing date snapshot (`-20251001` or `@20251001`), then prefix match.
1587
+ */
1588
+ declare function priceFor(provider: string, model: string, opts?: {
1589
+ backfill?: boolean;
1590
+ }): ModelPrice | undefined;
1591
+ /** Cost of a single usage record at a given model's rates (0 if unpriced). */
1592
+ declare function costOfUsage(provider: string, model: string, u: TokenUsage): number;
1593
+ /**
1594
+ * Usage + cost for one assistant message — the atomic grain of token economics.
1595
+ * Persisted to `usage_facts` so model / main-vs-sidechain / time breakdowns are
1596
+ * all read-time GROUP BYs (cost can't be summed by model off the session row).
1597
+ */
1598
+ interface UsageFact {
1599
+ idx: number;
1600
+ model: string;
1601
+ isSidechain: boolean;
1602
+ ts?: string;
1603
+ tokens: TokenUsage;
1604
+ usd: number;
1605
+ }
1606
+ interface CostResult {
1607
+ usd: number;
1608
+ /** Models we had no price for — their tokens count, but contribute $0. */
1609
+ unpriced: string[];
1610
+ /** One entry per assistant message, in order. Sums to `usd` / session tokens. */
1611
+ facts: UsageFact[];
1612
+ }
1613
+ /**
1614
+ * Cost of a session, priced per assistant message at that message's model, with
1615
+ * the per-message breakdown retained. Cache-creation tokens are priced at the
1616
+ * 5-minute rate (Claude Code's default).
1617
+ */
1618
+ declare function computeSessionCost(session: Session): CostResult;
1619
+
1620
+ /** Build an LLM client from config, or null if enrichment isn't configured. */
1621
+ declare function createLlmClient(llm: TuneloopConfig['llm']): LlmClient | null;
1622
+
1623
+ 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 Event, type FeatureRef, type FeatureRevisionInput, type FileIndexInput, 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 TokenUsage, type ToolCall, type TuneloopConfig, type UsageFactInput, type UserLinkedArtifact, type UserMessage, addUsage, analyze, canonicalSchema, computeSessionCost, costOfUsage, createDashboardServer, createLlmClient, describeSchema, emptyUsage, getAdapters, getProcessors, loadConfig, orderProcessors, priceFor, registerAdapter, registerProcessor, runProcessors, runQuery, serve };