@titan-design/session-graph 0.12.1 → 0.13.1

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.
package/README.md CHANGED
@@ -9,10 +9,12 @@ Tier 2 of the titan-platform DAG. Depends on `session-read`, `store-sqlite`,
9
9
  `cluster`, `locator`, and `agent-protocol`. Extracted from active-work's session index (AW-23, TP-6).
10
10
 
11
11
  ```ts
12
+ import os from "node:os";
13
+ import path from "node:path";
12
14
  import { discoverTranscripts } from "@titan-design/session-read";
13
15
  import { openSessionGraph, refreshCorpus } from "@titan-design/session-graph";
14
16
 
15
- const graph = openSessionGraph("~/.local/state/miner/index.sqlite3");
17
+ const graph = openSessionGraph(path.join(os.homedir(), ".local/state/miner/index.sqlite3"));
16
18
  const summary = await refreshCorpus(graph, await discoverTranscripts());
17
19
  // summary.indexed, summary.unchanged, summary.rewound, summary.quarantined, summary.missing,
18
20
  // summary.facetsBackfilled, summary.facetBacklog
@@ -174,8 +176,14 @@ with its transcript. `pr_ref` stays null until a later pass resolves it.
174
176
  `resetIndex` drops `pr` rows and forge review rows with everything else, so `commit_times`,
175
177
  `review_rounds_gh` and the forge reviews return only when a resolver answers again.
176
178
 
177
- Everything except `transcript` is derivable, which is what makes the schema safe to evolve
178
- by drop-and-rederive.
179
+ Not everything can be rebuilt from transcripts. Original sources can be pruned, so schema
180
+ migrations must preserve `fact` and `session` rows rather than assuming a replay is possible.
181
+ `resetIndex` clears the tables in `DERIVED_TABLES`, including `fact` and `session`. That is a
182
+ deliberate reset, not a migration step. It keeps the `transcript` watermark table (rewound, not
183
+ deleted), `price` and `session_state`, which nothing derives from transcripts, and `conversation`
184
+ and `conversation_alias`, which are derived from `session` rows or sources but are not in
185
+ `DERIVED_TABLES`. `session_origin` and `session_external_event` are not derived from transcripts
186
+ either: `resetIndex` clears them and the next pass refills them from their own sources.
179
187
 
180
188
 
181
189
  ## Injected context
@@ -277,6 +285,8 @@ empty tables, so existing rows are untouched.
277
285
  - `request_dedup` collapses fan-out copies of a request to the earliest one. Every cost
278
286
  query reads it, never `request`. `request_cost` prices each row by longest model prefix
279
287
  and latest `effective_from`; an unmatched model reads `priced = 0` and costs 0.
288
+ A price row matches only when the model id equals its prefix or continues with `[..]` or
289
+ `-YYYYMMDD` (optionally followed by `[..]`), so `claude-opus-5` never prices `claude-opus-5-9`.
280
290
  `context_contribution` attributes each request's context growth to the blocks before it.
281
291
 
282
292
  Migration 6, `episode transcript ids`, adds nullable `start_transcript_id` and
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ interface OpenSessionGraphOptions {
20
20
  * the graph must already carry every migration this package declares.
21
21
  */
22
22
  readonly?: boolean;
23
+ /** Keep the `normalized_*` tables the Codex path reads and writes. Off by default: a graph holds none unless asked. Ignored when read-only. */
24
+ normalized?: boolean;
23
25
  }
24
26
  declare class SessionGraphNotMigratedError extends Error {
25
27
  readonly missing: readonly string[];
@@ -46,9 +48,12 @@ declare const KIT: {
46
48
  * Locators are `(transcript_id, byte_offset, byte_length)`; `fact_id` rows
47
49
  * resolve through the facts table's unique `(transcript_id, byte_offset)`.
48
50
  */
49
- declare const DOMAIN_DDL = "\n CREATE TABLE IF NOT EXISTS fact (\n fact_id INTEGER PRIMARY KEY,\n transcript_id INTEGER NOT NULL,\n byte_offset INTEGER NOT NULL,\n byte_length INTEGER NOT NULL,\n event_type TEXT NOT NULL,\n ts TEXT NOT NULL,\n seq INTEGER NOT NULL,\n session_id TEXT NOT NULL,\n prompt_id TEXT,\n tool_use_id TEXT,\n t_indexed TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n UNIQUE (transcript_id, byte_offset)\n );\n CREATE INDEX IF NOT EXISTS idx_fact_session_ts ON fact(session_id, ts);\n CREATE INDEX IF NOT EXISTS idx_fact_prompt ON fact(prompt_id);\n CREATE INDEX IF NOT EXISTS idx_fact_tool_use ON fact(tool_use_id);\n\n CREATE TABLE IF NOT EXISTS session (\n session_id TEXT PRIMARY KEY,\n transcript_id INTEGER,\n started_at TEXT,\n ended_at TEXT,\n start_type TEXT,\n cwd TEXT,\n git_branch TEXT,\n ai_title TEXT,\n seed_prompt TEXT,\n cli_version TEXT,\n turn_count INTEGER NOT NULL DEFAULT 0,\n commit_count INTEGER NOT NULL DEFAULT 0,\n push_count INTEGER NOT NULL DEFAULT 0\n );\n CREATE INDEX IF NOT EXISTS idx_session_started ON session(started_at);\n\n CREATE TABLE IF NOT EXISTS session_model_usage (\n session_id TEXT NOT NULL,\n model TEXT NOT NULL,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n thinking_tokens INTEGER NOT NULL DEFAULT 0,\n request_count INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (session_id, model)\n );\n\n CREATE TABLE IF NOT EXISTS turn (\n prompt_id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL,\n turn_index INTEGER NOT NULL,\n started_at TEXT NOT NULL,\n ended_at TEXT,\n duration_ms INTEGER,\n tool_call_count INTEGER NOT NULL DEFAULT 0,\n thinking_ms INTEGER NOT NULL DEFAULT 0,\n fact_id_start INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_turn_session ON turn(session_id, turn_index);\n\n CREATE TABLE IF NOT EXISTS permission_phase (\n phase_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n from_mode TEXT,\n to_mode TEXT NOT NULL,\n trigger TEXT NOT NULL,\n t_valid TEXT NOT NULL,\n t_invalid TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_phase_session ON permission_phase(session_id, t_valid);\n\n CREATE TABLE IF NOT EXISTS human_edit (\n edit_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n ts TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, ts)\n );\n\n CREATE TABLE IF NOT EXISTS file_checkpoint (\n checkpoint_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n backup_file_name TEXT NOT NULL,\n version INTEGER NOT NULL,\n backup_time TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, backup_file_name)\n );\n\n CREATE TABLE IF NOT EXISTS pr (\n pr_ref TEXT PRIMARY KEY, number INTEGER, repo TEXT, title TEXT, state TEXT, url TEXT, merged_at TEXT\n );\n CREATE TABLE IF NOT EXISTS branch (\n branch_ref TEXT PRIMARY KEY, repo TEXT, name TEXT NOT NULL, base TEXT, created_at TEXT, deleted_at TEXT\n );\n CREATE TABLE IF NOT EXISTS file (\n file_ref TEXT PRIMARY KEY, repo TEXT, path TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS task (\n task_ref TEXT PRIMARY KEY, task_id TEXT NOT NULL, initiative TEXT, title TEXT, status TEXT\n );\n CREATE TABLE IF NOT EXISTS subagent (\n agent_ref TEXT PRIMARY KEY,\n session_id TEXT,\n child_session_id TEXT,\n parent_agent_ref TEXT,\n agent_type TEXT,\n label TEXT,\n started_at TEXT,\n ended_at TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_subagent_child ON subagent(child_session_id);\n CREATE TABLE IF NOT EXISTS artifact (\n artifact_ref TEXT PRIMARY KEY, kind TEXT, title TEXT, url TEXT, path TEXT, created_at TEXT\n );\n CREATE TABLE IF NOT EXISTS pr_merge_observation (\n number INTEGER NOT NULL, repo_hint TEXT, merged_at TEXT NOT NULL,\n PRIMARY KEY (number, repo_hint, merged_at)\n );\n CREATE TABLE IF NOT EXISTS pr_create_observation (\n tool_use_id TEXT PRIMARY KEY, title TEXT, number INTEGER, repo TEXT, url TEXT\n );\n";
50
- /** Every derived table, in an order safe to clear. The watermark table is not derived. */
51
- declare const DERIVED_TABLES: readonly ["normalized_span", "normalized_event", "normalized_source", "search_span", "edge", "turn", "permission_phase", "human_edit", "file_checkpoint", "subagent", "request", "tool_call", "inbound", "context_block", "compaction", "queue_op", "session_signal", "cost_state_observation", "transcript_facet", "session_origin", "session_external_event", "episode", "session_model_usage", "session", "fact", "pr", "pr_merge_observation", "pr_create_observation", "pr_review", "branch", "file", "task", "artifact"];
51
+ declare const DOMAIN_DDL = "\n CREATE TABLE IF NOT EXISTS fact (\n fact_id INTEGER PRIMARY KEY,\n transcript_id INTEGER NOT NULL,\n byte_offset INTEGER NOT NULL,\n byte_length INTEGER NOT NULL,\n event_type TEXT NOT NULL,\n ts TEXT NOT NULL,\n seq INTEGER NOT NULL,\n session_id TEXT NOT NULL,\n prompt_id TEXT,\n tool_use_id TEXT,\n t_indexed TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n UNIQUE (transcript_id, byte_offset)\n );\n CREATE INDEX IF NOT EXISTS idx_fact_session_ts ON fact(session_id, ts);\n CREATE INDEX IF NOT EXISTS idx_fact_prompt ON fact(prompt_id);\n CREATE INDEX IF NOT EXISTS idx_fact_tool_use ON fact(tool_use_id);\n\n CREATE TABLE IF NOT EXISTS session (\n session_id TEXT PRIMARY KEY,\n transcript_id INTEGER,\n started_at TEXT,\n ended_at TEXT,\n start_type TEXT,\n cwd TEXT,\n git_branch TEXT,\n ai_title TEXT,\n seed_prompt TEXT,\n cli_version TEXT,\n turn_count INTEGER NOT NULL DEFAULT 0,\n commit_count INTEGER NOT NULL DEFAULT 0,\n push_count INTEGER NOT NULL DEFAULT 0\n );\n CREATE INDEX IF NOT EXISTS idx_session_started ON session(started_at);\n\n CREATE TABLE IF NOT EXISTS session_model_usage (\n session_id TEXT NOT NULL,\n model TEXT NOT NULL,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n thinking_tokens INTEGER NOT NULL DEFAULT 0,\n request_count INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (session_id, model)\n );\n\n CREATE TABLE IF NOT EXISTS turn (\n prompt_id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL,\n turn_index INTEGER NOT NULL,\n started_at TEXT NOT NULL,\n ended_at TEXT,\n duration_ms INTEGER,\n tool_call_count INTEGER NOT NULL DEFAULT 0,\n thinking_ms INTEGER NOT NULL DEFAULT 0,\n fact_id_start INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_turn_session ON turn(session_id, turn_index);\n\n CREATE TABLE IF NOT EXISTS permission_phase (\n phase_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n from_mode TEXT,\n to_mode TEXT NOT NULL,\n trigger TEXT NOT NULL,\n t_valid TEXT NOT NULL,\n t_invalid TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_phase_session ON permission_phase(session_id, t_valid);\n\n CREATE TABLE IF NOT EXISTS human_edit (\n edit_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n ts TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, ts)\n );\n\n CREATE TABLE IF NOT EXISTS file_checkpoint (\n checkpoint_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n backup_file_name TEXT NOT NULL,\n version INTEGER NOT NULL,\n backup_time TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, backup_file_name)\n );\n\n CREATE TABLE IF NOT EXISTS pr (\n pr_ref TEXT PRIMARY KEY, number INTEGER, repo TEXT, title TEXT, state TEXT, url TEXT, merged_at TEXT\n );\n CREATE TABLE IF NOT EXISTS branch (\n branch_ref TEXT PRIMARY KEY, repo TEXT, name TEXT NOT NULL, base TEXT, created_at TEXT, deleted_at TEXT\n );\n CREATE TABLE IF NOT EXISTS file (\n file_ref TEXT PRIMARY KEY, repo TEXT, path TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS task (\n task_ref TEXT PRIMARY KEY, task_id TEXT NOT NULL, initiative TEXT, title TEXT, status TEXT\n );\n CREATE TABLE IF NOT EXISTS subagent (\n agent_ref TEXT PRIMARY KEY,\n session_id TEXT,\n child_session_id TEXT,\n parent_agent_ref TEXT,\n agent_type TEXT,\n label TEXT,\n started_at TEXT,\n ended_at TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_subagent_child ON subagent(child_session_id);\n CREATE TABLE IF NOT EXISTS pr_merge_observation (\n number INTEGER NOT NULL, repo_hint TEXT, merged_at TEXT NOT NULL,\n PRIMARY KEY (number, repo_hint, merged_at)\n );\n CREATE TABLE IF NOT EXISTS pr_create_observation (\n tool_use_id TEXT PRIMARY KEY, title TEXT, number INTEGER, repo TEXT, url TEXT\n );\n";
52
+ /** Opt-in tables: absent from a graph opened without `normalized: true`. */
53
+ declare const NORMALIZED_TABLES: readonly ["normalized_span", "normalized_event", "normalized_source"];
54
+ /** Derived tables present in this database; the opt-in normalized ones come first so they clear first. */
55
+ declare function derivedTables(db: Db): string[];
56
+ declare const DERIVED_TABLES: readonly ["normalized_span", "normalized_event", "normalized_source", "search_span", "edge", "turn", "permission_phase", "human_edit", "file_checkpoint", "subagent", "request", "tool_call", "inbound", "context_block", "compaction", "queue_op", "session_signal", "cost_state_observation", "transcript_facet", "session_origin", "session_external_event", "episode", "session_model_usage", "session", "fact", "pr", "pr_merge_observation", "pr_create_observation", "pr_review", "branch", "file", "task"];
52
57
  declare const MIGRATIONS: Migration[];
53
58
 
54
59
  /** Recorded in `_migration`; store-sqlite refuses a database whose applied name differs, so never rename it. */
@@ -386,6 +391,10 @@ interface RefreshOptions extends IndexOptions {
386
391
  full?: boolean;
387
392
  /** Stale audit facets re-extracted per pass (default 40). `Infinity` clears the backlog. */
388
393
  facetLimit?: number;
394
+ /** What a leading `~` in a stored source key means when checking for vanished files. Defaults to the OS home directory. */
395
+ homeDir?: string;
396
+ /** Source keys known to exist this pass even when not visited, such as a sealed transcript the caller skipped. */
397
+ present?: Iterable<string>;
389
398
  }
390
399
  type TranscriptOutcome = {
391
400
  status: "unchanged" | "indexed" | "rewound";
@@ -471,7 +480,7 @@ declare function replaceEpisodes(graph: SessionGraph, sessionId: string, heurist
471
480
 
472
481
  /** USD per million tokens for one model prefix from one date. Shaped so session-analytics' `PRICE_TABLE` passes straight through. */
473
482
  interface PriceInput {
474
- /** Matched against `request.model` by longest prefix in `request_cost`. */
483
+ /** Matched against `request.model` in `request_cost` by longest prefix at an id boundary: the id itself, or followed by -YYYYMMDD or [..]. */
475
484
  modelPrefix: string;
476
485
  /** ISO date or timestamp; compared as text against `request.ts`. */
477
486
  effectiveFrom: string;
@@ -523,7 +532,9 @@ interface IndexedSpan {
523
532
  spanId?: number;
524
533
  }
525
534
  /** One resolver for search and error clustering; never display a raw JSON line. */
526
- declare function readIndexedText(graph: SessionGraph, span: IndexedSpan): Promise<string | null>;
535
+ declare function readIndexedText(graph: SessionGraph, span: IndexedSpan, options?: {
536
+ homeDir?: string;
537
+ }): Promise<string | null>;
527
538
  interface ConversationSummary {
528
539
  sessionId: string;
529
540
  harness: string;
@@ -547,7 +558,9 @@ type NormalizedUsageSummary = SessionUsageSummary;
547
558
  /** Shared storage-free usage policy keeps graph and direct readers consistent. */
548
559
  declare function normalizedUsage(graph: SessionGraph, ref: string): NormalizedUsageSummary[];
549
560
 
561
+ /** Re-creates the opt-in tables migration 9 drops from a graph that held none of their rows. */
562
+ declare function ensureNormalizedSchema(db: Db): void;
550
563
  /** Ambiguity is explicit; workspace session bodies remain in their original namespace. */
551
564
  declare function resolveConversationAlias(db: Db, legacyRef: string): string | null;
552
565
 
553
- export { AUDIT_DDL, AUDIT_FACET, AUDIT_MIGRATION_NAME, AUDIT_TABLES, type BackfillOptions, type BackfillSummary, type ConversationSummary, DEFAULT_FACET_LIMIT, DERIVED_TABLES, DOMAIN_DDL, type DeltaSource, EPISODE_TABLE, type EpisodeRow, type ExternalEvent, FACET_TABLE, type IndexOptions, type IndexedSpan, KIT, MIGRATIONS, NO_ENRICHMENT, NO_ORIGINS, NO_PR_OUTCOMES, NO_TASK_LINK, type NormalizedIndexResult, type NormalizedUsageSummary, ORIGIN_DDL, ORIGIN_MIGRATION_NAME, ORIGIN_TABLES, ORIGIN_TASK_LINK_MIGRATION_NAME, ORIGIN_VIEWS, type OpenSessionGraphOptions, type OriginEnrichment, type OriginResolution, type OriginResolver, type PrEnrichment, type PrKey, type PrResolution, type PrResolver, type PriceInput, REVIEW_DDL, REVIEW_TABLE, REVIEW_VERDICT_MIGRATION_NAME, type ReconcileCounts, type ReconcileResult, type RefreshOptions, type RefreshSummary, type ResolvedOrigin, type ResolvedPr, type ResolvedReview, type ResolvedTask, type ReviewProjection, type ReviewRoundOptions, type ReviewerProfilePredicate, type SessionGraph, SessionGraphNotMigratedError, type SyncPricesOptions, type TaskEnrichment, type TaskLinkSource, type TaskResolution, type TaskResolver, type TranscriptOutcome, allSessionIds, allTaskIds, applyAudit, applyDelta, backfillFacets, countRounds, enrichPrs, enrichTasks, indexCodexSource, indexTranscript, isInjectedCause, isReviewerProfile, isUntypedPrompt, normalizedSessions, normalizedUsage, openSessionGraph, projectReviewRounds, prsNeedingOutcome, purgeTranscript, readIndexedText, reconcile, reconcilePrices, refreshCorpus, replaceEpisodes, resetIndex, resolveConversationAlias, resolveOrigins, rollupSessions, sessionsNeedingOrigin, stripInjected, syncPrices };
566
+ export { AUDIT_DDL, AUDIT_FACET, AUDIT_MIGRATION_NAME, AUDIT_TABLES, type BackfillOptions, type BackfillSummary, type ConversationSummary, DEFAULT_FACET_LIMIT, DERIVED_TABLES, DOMAIN_DDL, type DeltaSource, EPISODE_TABLE, type EpisodeRow, type ExternalEvent, FACET_TABLE, type IndexOptions, type IndexedSpan, KIT, MIGRATIONS, NORMALIZED_TABLES, NO_ENRICHMENT, NO_ORIGINS, NO_PR_OUTCOMES, NO_TASK_LINK, type NormalizedIndexResult, type NormalizedUsageSummary, ORIGIN_DDL, ORIGIN_MIGRATION_NAME, ORIGIN_TABLES, ORIGIN_TASK_LINK_MIGRATION_NAME, ORIGIN_VIEWS, type OpenSessionGraphOptions, type OriginEnrichment, type OriginResolution, type OriginResolver, type PrEnrichment, type PrKey, type PrResolution, type PrResolver, type PriceInput, REVIEW_DDL, REVIEW_TABLE, REVIEW_VERDICT_MIGRATION_NAME, type ReconcileCounts, type ReconcileResult, type RefreshOptions, type RefreshSummary, type ResolvedOrigin, type ResolvedPr, type ResolvedReview, type ResolvedTask, type ReviewProjection, type ReviewRoundOptions, type ReviewerProfilePredicate, type SessionGraph, SessionGraphNotMigratedError, type SyncPricesOptions, type TaskEnrichment, type TaskLinkSource, type TaskResolution, type TaskResolver, type TranscriptOutcome, allSessionIds, allTaskIds, applyAudit, applyDelta, backfillFacets, countRounds, derivedTables, enrichPrs, enrichTasks, ensureNormalizedSchema, indexCodexSource, indexTranscript, isInjectedCause, isReviewerProfile, isUntypedPrompt, normalizedSessions, normalizedUsage, openSessionGraph, projectReviewRounds, prsNeedingOutcome, purgeTranscript, readIndexedText, reconcile, reconcilePrices, refreshCorpus, replaceEpisodes, resetIndex, resolveConversationAlias, resolveOrigins, rollupSessions, sessionsNeedingOrigin, stripInjected, syncPrices };
package/dist/index.js CHANGED
@@ -1,5 +1,53 @@
1
1
  // src/graph.ts
2
- import { EdgeTable, MIGRATION_TABLE_NAME, SpanFtsTables, WatermarkTable, hasTable, openDatabase, runMigrations } from "@titan-design/store-sqlite";
2
+ import { EdgeTable, MIGRATION_TABLE_NAME, SpanFtsTables, WatermarkTable, hasTable as hasTable2, openDatabase, runMigrations } from "@titan-design/store-sqlite";
3
+
4
+ // src/normalized-schema.ts
5
+ import { conversationRef } from "@titan-design/agent-protocol";
6
+ var NORMALIZED_DDL = `
7
+ CREATE TABLE IF NOT EXISTS conversation (
8
+ ref TEXT PRIMARY KEY, harness TEXT NOT NULL, namespace TEXT NOT NULL,
9
+ native_id TEXT NOT NULL, legacy_session_id TEXT,
10
+ UNIQUE(harness, namespace, native_id)
11
+ );
12
+ CREATE TABLE IF NOT EXISTS conversation_alias (
13
+ legacy_ref TEXT NOT NULL, conversation_ref TEXT NOT NULL,
14
+ PRIMARY KEY(legacy_ref, conversation_ref)
15
+ );
16
+ CREATE TABLE IF NOT EXISTS normalized_source (
17
+ transcript_id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE,
18
+ conversation_ref TEXT NOT NULL, descriptor TEXT NOT NULL
19
+ );
20
+ CREATE TABLE IF NOT EXISTS normalized_event (
21
+ transcript_id INTEGER NOT NULL, byte_offset INTEGER NOT NULL,
22
+ subrecord_index INTEGER NOT NULL, byte_length INTEGER NOT NULL,
23
+ conversation_ref TEXT NOT NULL, kind TEXT NOT NULL, ts TEXT,
24
+ turn_ref TEXT, call_ref TEXT, item_ref TEXT, phase TEXT, is_error INTEGER,
25
+ usage TEXT, metadata TEXT, history_origin TEXT, related_ref TEXT, relationship TEXT,
26
+ PRIMARY KEY(transcript_id, byte_offset, subrecord_index)
27
+ );
28
+ CREATE INDEX IF NOT EXISTS idx_normalized_event_conversation ON normalized_event(conversation_ref, ts);
29
+ CREATE TABLE IF NOT EXISTS normalized_span (
30
+ span_id INTEGER PRIMARY KEY, locators TEXT NOT NULL
31
+ );
32
+ `;
33
+ function ensureNormalizedSchema(db) {
34
+ db.exec(NORMALIZED_DDL);
35
+ }
36
+ function backfillClaudeAliases(db, sessionIds) {
37
+ const insert = db.prepare("INSERT OR IGNORE INTO conversation(ref,harness,namespace,native_id,legacy_session_id) VALUES (?,?,?,?,?)");
38
+ const alias = db.prepare("INSERT OR IGNORE INTO conversation_alias VALUES (?,?)");
39
+ const rows = sessionIds?.map((session_id) => ({ session_id })) ?? db.prepare("SELECT session_id FROM session").all();
40
+ for (const row of rows) {
41
+ const ref = conversationRef({ harness: "claude-code", namespace: "legacy", nativeId: row.session_id });
42
+ insert.run(ref, "claude-code", "legacy", row.session_id, row.session_id);
43
+ alias.run(`session:${row.session_id}`, ref);
44
+ }
45
+ }
46
+ function resolveConversationAlias(db, legacyRef) {
47
+ const rows = db.prepare("SELECT conversation_ref FROM conversation_alias WHERE legacy_ref = ?").all(legacyRef);
48
+ if (rows.length > 1) throw new Error(`ambiguous conversation alias: ${legacyRef}`);
49
+ return rows[0]?.conversation_ref ?? null;
50
+ }
3
51
 
4
52
  // src/audit-schema.ts
5
53
  var AUDIT_MIGRATION_NAME = "audit tables";
@@ -293,53 +341,48 @@ function applyReviewVerdictSchema(db) {
293
341
  db.exec("UPDATE pr SET outcome_checked_at = NULL");
294
342
  }
295
343
 
296
- // src/normalized-schema.ts
297
- import { conversationRef } from "@titan-design/agent-protocol";
298
- var NORMALIZED_DDL = `
299
- CREATE TABLE IF NOT EXISTS conversation (
300
- ref TEXT PRIMARY KEY, harness TEXT NOT NULL, namespace TEXT NOT NULL,
301
- native_id TEXT NOT NULL, legacy_session_id TEXT,
302
- UNIQUE(harness, namespace, native_id)
303
- );
304
- CREATE TABLE IF NOT EXISTS conversation_alias (
305
- legacy_ref TEXT NOT NULL, conversation_ref TEXT NOT NULL,
306
- PRIMARY KEY(legacy_ref, conversation_ref)
307
- );
308
- CREATE TABLE IF NOT EXISTS normalized_source (
309
- transcript_id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE,
310
- conversation_ref TEXT NOT NULL, descriptor TEXT NOT NULL
311
- );
312
- CREATE TABLE IF NOT EXISTS normalized_event (
313
- transcript_id INTEGER NOT NULL, byte_offset INTEGER NOT NULL,
314
- subrecord_index INTEGER NOT NULL, byte_length INTEGER NOT NULL,
315
- conversation_ref TEXT NOT NULL, kind TEXT NOT NULL, ts TEXT,
316
- turn_ref TEXT, call_ref TEXT, item_ref TEXT, phase TEXT, is_error INTEGER,
317
- usage TEXT, metadata TEXT, history_origin TEXT, related_ref TEXT, relationship TEXT,
318
- PRIMARY KEY(transcript_id, byte_offset, subrecord_index)
319
- );
320
- CREATE INDEX IF NOT EXISTS idx_normalized_event_conversation ON normalized_event(conversation_ref, ts);
321
- CREATE TABLE IF NOT EXISTS normalized_span (
322
- span_id INTEGER PRIMARY KEY, locators TEXT NOT NULL
323
- );
344
+ // src/audit-schema-v10.ts
345
+ var REQUEST_COST_BOUNDARY_MIGRATION_NAME = "request_cost model-id boundary";
346
+ var DIGITS_8 = "[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]";
347
+ var REST = "substr(d.model, length(p.model) + 1)";
348
+ var AT_BOUNDARY = `substr(d.model, 1, length(p.model)) = p.model AND (
349
+ ${REST} = '' OR ${REST} GLOB '[[]*'
350
+ OR ${REST} GLOB '-${DIGITS_8}' OR ${REST} GLOB '-${DIGITS_8}[[]*]')`;
351
+ var REQUEST_COST2 = `
352
+ CREATE VIEW request_cost AS
353
+ WITH candidate AS (
354
+ SELECT d.request_id, p.model AS price_model, p.effective_from AS price_effective_from,
355
+ p.input_usd_mtok, p.cache_read_usd_mtok, p.cache_write_5m_usd_mtok, p.cache_write_1h_usd_mtok, p.output_usd_mtok,
356
+ ROW_NUMBER() OVER (PARTITION BY d.request_id ORDER BY length(p.model) DESC, p.effective_from DESC) AS match_rank
357
+ FROM request_dedup d
358
+ JOIN price p ON ${AT_BOUNDARY} AND p.effective_from <= d.ts
359
+ ), component AS (
360
+ SELECT d.*, c.price_model, c.price_effective_from, c.price_model IS NOT NULL AS priced,
361
+ COALESCE(d.input_tokens * c.input_usd_mtok, 0) / 1e6 AS input_cost_usd,
362
+ COALESCE(d.cache_read_tokens * c.cache_read_usd_mtok, 0) / 1e6 AS cache_read_cost_usd,
363
+ COALESCE(CASE WHEN d.cache_creation_5m + d.cache_creation_1h = 0 THEN d.cache_creation_tokens ELSE d.cache_creation_5m END
364
+ * c.cache_write_5m_usd_mtok, 0) / 1e6 AS cache_write_5m_cost_usd,
365
+ COALESCE(d.cache_creation_1h * c.cache_write_1h_usd_mtok, 0) / 1e6 AS cache_write_1h_cost_usd,
366
+ COALESCE(d.output_tokens * c.output_usd_mtok, 0) / 1e6 AS output_cost_usd
367
+ FROM request_dedup d
368
+ LEFT JOIN candidate c ON c.request_id = d.request_id AND c.match_rank = 1
369
+ )
370
+ SELECT *,
371
+ input_cost_usd + cache_read_cost_usd + cache_write_5m_cost_usd + cache_write_1h_cost_usd + output_cost_usd AS cost_usd,
372
+ (cache_read_tokens < 0.2 * context_tokens AND cache_creation_tokens >= 20000) AS is_cold,
373
+ CASE WHEN context_tokens < 50000 THEN '<50k' WHEN context_tokens < 100000 THEN '50-100k'
374
+ WHEN context_tokens < 200000 THEN '100-200k' ELSE '200k+' END AS context_band,
375
+ CASE WHEN gap_ms IS NULL THEN NULL WHEN gap_ms < 300000 THEN '<5m'
376
+ WHEN gap_ms < 3600000 THEN '5-60m' ELSE '>60m' END AS gap_band
377
+ FROM component;
324
378
  `;
325
- function backfillClaudeAliases(db, sessionIds) {
326
- const insert = db.prepare("INSERT OR IGNORE INTO conversation(ref,harness,namespace,native_id,legacy_session_id) VALUES (?,?,?,?,?)");
327
- const alias = db.prepare("INSERT OR IGNORE INTO conversation_alias VALUES (?,?)");
328
- const rows = sessionIds?.map((session_id) => ({ session_id })) ?? db.prepare("SELECT session_id FROM session").all();
329
- for (const row of rows) {
330
- const ref = conversationRef({ harness: "claude-code", namespace: "legacy", nativeId: row.session_id });
331
- insert.run(ref, "claude-code", "legacy", row.session_id, row.session_id);
332
- alias.run(`session:${row.session_id}`, ref);
333
- }
334
- }
335
- function resolveConversationAlias(db, legacyRef) {
336
- const rows = db.prepare("SELECT conversation_ref FROM conversation_alias WHERE legacy_ref = ?").all(legacyRef);
337
- if (rows.length > 1) throw new Error(`ambiguous conversation alias: ${legacyRef}`);
338
- return rows[0]?.conversation_ref ?? null;
379
+ function applyRequestCostBoundary(db) {
380
+ db.exec("DROP VIEW IF EXISTS request_cost");
381
+ db.exec(REQUEST_COST2);
339
382
  }
340
383
 
341
384
  // src/schema.ts
342
- import { SQL_NOW, kitMigration } from "@titan-design/store-sqlite";
385
+ import { SQL_NOW, hasTable, kitMigration } from "@titan-design/store-sqlite";
343
386
  var KIT = { watermark: "transcript", edge: "edge", spanFts: "search" };
344
387
  var DOMAIN_DDL = `
345
388
  CREATE TABLE IF NOT EXISTS fact (
@@ -458,9 +501,6 @@ var DOMAIN_DDL = `
458
501
  fact_id INTEGER
459
502
  );
460
503
  CREATE INDEX IF NOT EXISTS idx_subagent_child ON subagent(child_session_id);
461
- CREATE TABLE IF NOT EXISTS artifact (
462
- artifact_ref TEXT PRIMARY KEY, kind TEXT, title TEXT, url TEXT, path TEXT, created_at TEXT
463
- );
464
504
  CREATE TABLE IF NOT EXISTS pr_merge_observation (
465
505
  number INTEGER NOT NULL, repo_hint TEXT, merged_at TEXT NOT NULL,
466
506
  PRIMARY KEY (number, repo_hint, merged_at)
@@ -469,10 +509,9 @@ var DOMAIN_DDL = `
469
509
  tool_use_id TEXT PRIMARY KEY, title TEXT, number INTEGER, repo TEXT, url TEXT
470
510
  );
471
511
  `;
472
- var DERIVED_TABLES = [
473
- "normalized_span",
474
- "normalized_event",
475
- "normalized_source",
512
+ var NORMALIZED_TABLES = ["normalized_span", "normalized_event", "normalized_source"];
513
+ var ALWAYS_DERIVED = [
514
+ // Dropped too, not only FTS rows: spans of a deleted or rewritten transcript are never re-streamed and would survive.
476
515
  `${KIT.spanFts}_span`,
477
516
  KIT.edge,
478
517
  "turn",
@@ -493,9 +532,19 @@ var DERIVED_TABLES = [
493
532
  REVIEW_TABLE,
494
533
  "branch",
495
534
  "file",
496
- "task",
497
- "artifact"
535
+ "task"
498
536
  ];
537
+ function derivedTables(db) {
538
+ return [...NORMALIZED_TABLES.filter((t) => hasTable(db, t)), ...ALWAYS_DERIVED];
539
+ }
540
+ var DERIVED_TABLES = [...NORMALIZED_TABLES, ...ALWAYS_DERIVED];
541
+ function stopStoringBulkClasses(db) {
542
+ db.exec("DROP TABLE IF EXISTS artifact");
543
+ const present = NORMALIZED_TABLES.filter((t) => hasTable(db, t));
544
+ const empty = present.every((t) => db.prepare(`SELECT 1 FROM ${t} LIMIT 1`).get() === void 0);
545
+ if (empty) for (const t of present) db.exec(`DROP TABLE ${t}`);
546
+ db.exec("CREATE TABLE IF NOT EXISTS session_state (session_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT, PRIMARY KEY (session_id, key))");
547
+ }
499
548
  var MIGRATIONS = [
500
549
  kitMigration(1, { watermark: KIT.watermark, edge: KIT.edge, spanFts: KIT.spanFts }, "kit tables"),
501
550
  { version: 2, name: "session graph tables", up: (db) => db.exec(DOMAIN_DDL) },
@@ -507,7 +556,9 @@ var MIGRATIONS = [
507
556
  { version: 5, name: ORIGIN_MIGRATION_NAME, up: applyOriginSchema },
508
557
  { version: 6, name: EPISODE_TRANSCRIPT_MIGRATION_NAME, up: applyEpisodeTranscriptSchema },
509
558
  { version: 7, name: ORIGIN_TASK_LINK_MIGRATION_NAME, up: applyOriginTaskLinkSchema },
510
- { version: 8, name: REVIEW_VERDICT_MIGRATION_NAME, up: applyReviewVerdictSchema }
559
+ { version: 8, name: REVIEW_VERDICT_MIGRATION_NAME, up: applyReviewVerdictSchema },
560
+ { version: 9, name: "stop storing bulk classes", up: stopStoringBulkClasses },
561
+ { version: 10, name: REQUEST_COST_BOUNDARY_MIGRATION_NAME, up: applyRequestCostBoundary }
511
562
  ];
512
563
 
513
564
  // src/graph.ts
@@ -523,6 +574,7 @@ function openSessionGraph(dbPath, options = {}) {
523
574
  const db = openDatabase(dbPath, { schemaVersion: options.schemaVersion, readonly: options.readonly });
524
575
  if (options.readonly) assertMigratedOrClose(db);
525
576
  else runMigrations(db, MIGRATIONS);
577
+ if (options.normalized && !options.readonly) ensureNormalizedSchema(db);
526
578
  return {
527
579
  db,
528
580
  transcripts: new WatermarkTable(db, { name: KIT.watermark }),
@@ -538,13 +590,13 @@ function assertMigratedOrClose(db) {
538
590
  throw new SessionGraphNotMigratedError(missing);
539
591
  }
540
592
  function appliedNames(db) {
541
- if (!hasTable(db, MIGRATION_TABLE_NAME)) return /* @__PURE__ */ new Map();
593
+ if (!hasTable2(db, MIGRATION_TABLE_NAME)) return /* @__PURE__ */ new Map();
542
594
  const rows = db.prepare(`SELECT version, name FROM ${MIGRATION_TABLE_NAME}`).all();
543
595
  return new Map(rows.map((r) => [r.version, r.name]));
544
596
  }
545
597
  function resetIndex(graph) {
546
598
  graph.db.transaction(() => {
547
- for (const table of DERIVED_TABLES) graph.db.exec(`DELETE FROM "${table}"`);
599
+ for (const table of derivedTables(graph.db)) graph.db.exec(`DELETE FROM "${table}"`);
548
600
  graph.spans.clearIndex();
549
601
  for (const row of graph.transcripts.list()) graph.transcripts.rewind(row.sourceKey);
550
602
  })();
@@ -832,8 +884,6 @@ var ASSET_UPSERTS = {
832
884
  files: `INSERT INTO file (file_ref, repo, path) VALUES (@fileRef, @repo, @path) ON CONFLICT (file_ref) DO NOTHING`,
833
885
  tasks: `INSERT INTO task (task_ref, task_id, status) VALUES (@taskRef, @taskId, @status)
834
886
  ON CONFLICT (task_ref) DO UPDATE SET status = COALESCE(excluded.status, status)`,
835
- artifacts: `INSERT INTO artifact (artifact_ref, kind, title, url, path, created_at) VALUES (@artifactRef, @artifactKind, @title, @url, @path, @ts)
836
- ON CONFLICT (artifact_ref) DO NOTHING`,
837
887
  prMerges: `INSERT INTO pr_merge_observation (number, repo_hint, merged_at) VALUES (@number, @repoHint, @ts)
838
888
  ON CONFLICT (number, repo_hint, merged_at) DO NOTHING`,
839
889
  prCreates: `INSERT INTO pr_create_observation (tool_use_id, title, number, repo, url) VALUES (@toolUseId, @title, @number, @repo, @url)
@@ -1114,9 +1164,18 @@ function reconcile(graph) {
1114
1164
 
1115
1165
  // src/refresh.ts
1116
1166
  import { promises as fs } from "fs";
1167
+ import { hasTable as hasTable3 } from "@titan-design/store-sqlite";
1168
+ import os from "os";
1117
1169
  import { contentHash, resumePoint } from "@titan-design/locator";
1118
1170
  import { TranscriptParseError as TranscriptParseError2, extractTranscript as extractTranscript2 } from "@titan-design/session-read";
1119
1171
 
1172
+ // src/expand-home.ts
1173
+ import path from "path";
1174
+ function expandHome(file, homeDir) {
1175
+ if (file === "~") return homeDir;
1176
+ return file.startsWith("~/") ? path.join(homeDir, file.slice(2)) : file;
1177
+ }
1178
+
1120
1179
  // src/review-rounds.ts
1121
1180
  import { RELATIONS as RELATIONS2 } from "@titan-design/session-read";
1122
1181
  var isReviewerProfile = (profile) => profile === "reviewer" || profile.endsWith("-reviewer");
@@ -1535,7 +1594,7 @@ async function indexTranscript(graph, transcript, options = {}) {
1535
1594
  }
1536
1595
  }
1537
1596
  async function refreshCorpus(graph, transcripts, options = {}) {
1538
- const { resolveTasks, resolveOrigins: originResolver, resolvePrs, isReviewerProfile: isReviewerProfile2, full, facetLimit, ...perTranscript } = options;
1597
+ const { resolveTasks, resolveOrigins: originResolver, resolvePrs, isReviewerProfile: isReviewerProfile2, full, facetLimit, homeDir, present, ...perTranscript } = options;
1539
1598
  const counts = { indexed: 0, unchanged: 0, rewound: 0, missing: 0, quarantined: 0 };
1540
1599
  const touched = [];
1541
1600
  let facts = 0;
@@ -1553,19 +1612,20 @@ async function refreshCorpus(graph, transcripts, options = {}) {
1553
1612
  const prs = await enrichPrs(graph, resolvePrs);
1554
1613
  const reviews = projectReviewRounds(graph, { isReviewerProfile: isReviewerProfile2 });
1555
1614
  const tasks = await enrichTasks(graph, resolveTasks, allTaskIds(graph));
1556
- const markedMissing = await markMissing(graph, transcripts);
1615
+ const markedMissing = await markMissing(graph, transcripts, homeDir ?? os.homedir(), present);
1557
1616
  const facetSummary = { facetsBackfilled: facets.backfilled, facetBacklog: facets.backlog };
1558
1617
  return { transcripts: transcripts.length, ...counts, facts, turnsRolledUp, reconciled, tasks, origins, prs, reviews, markedMissing, ...facetSummary };
1559
1618
  }
1560
- async function markMissing(graph, discovered) {
1561
- const present = new Set(discovered.map((t) => t.displayPath));
1619
+ async function markMissing(graph, discovered, homeDir, alsoPresent = []) {
1620
+ const present = /* @__PURE__ */ new Set([...discovered.map((t) => t.displayPath), ...alsoPresent]);
1621
+ const hasNormalized = hasTable3(graph.db, "normalized_source");
1562
1622
  const byPath = new Map(discovered.map((t) => [t.displayPath, t.absolutePath]));
1563
1623
  let marked = 0;
1564
1624
  for (const row of graph.transcripts.list()) {
1565
1625
  if (row.status === "missing" || present.has(row.sourceKey)) continue;
1566
- const normalized = graph.db.prepare("SELECT descriptor FROM normalized_source WHERE transcript_id = ?").get(row.sourceId);
1626
+ const normalized = hasNormalized ? graph.db.prepare("SELECT descriptor FROM normalized_source WHERE transcript_id = ?").get(row.sourceId) : void 0;
1567
1627
  const absolute = normalized ? JSON.parse(normalized.descriptor).path : byPath.get(row.sourceKey) ?? row.sourceKey;
1568
- if (await exists(absolute)) continue;
1628
+ if (await exists(expandHome(absolute, homeDir))) continue;
1569
1629
  graph.transcripts.markStatus(row.sourceKey, "missing", "source file no longer exists");
1570
1630
  marked += 1;
1571
1631
  }
@@ -1718,6 +1778,7 @@ function observationText(o) {
1718
1778
 
1719
1779
  // src/normalized-index.ts
1720
1780
  async function indexCodexSource(graph, source) {
1781
+ ensureNormalizedSchema(graph.db);
1721
1782
  const ref = conversationRef3(source.conversation);
1722
1783
  const base = { conversationRef: ref, observations: 0 };
1723
1784
  const row = graph.transcripts.ensure(source.sourceId);
@@ -1812,12 +1873,14 @@ function* stagedRows(graph, table) {
1812
1873
  // src/normalized-query.ts
1813
1874
  import { contentHash as contentHash3, prefixHash } from "@titan-design/locator";
1814
1875
  import { readSessionSourceText, readSessionText, SessionUsageAccumulator } from "@titan-design/session-read";
1815
- async function readIndexedText(graph, span) {
1816
- const text = await readSourceText(graph, span);
1876
+ import { hasTable as hasTable4 } from "@titan-design/store-sqlite";
1877
+ import os2 from "os";
1878
+ async function readIndexedText(graph, span, options = {}) {
1879
+ const text = await readSourceText(graph, span, options.homeDir ?? os2.homedir());
1817
1880
  return text !== null && span.field === "prompt" ? stripInjected(text) : text;
1818
1881
  }
1819
- async function readSourceText(graph, span) {
1820
- const row = graph.db.prepare(`SELECT n.locators FROM normalized_span n JOIN search_span s USING(span_id)
1882
+ async function readSourceText(graph, span, homeDir) {
1883
+ const row = !hasTable4(graph.db, "normalized_span") ? void 0 : graph.db.prepare(`SELECT n.locators FROM normalized_span n JOIN search_span s USING(span_id)
1821
1884
  WHERE s.source_id = ? AND s.byte_offset = ? AND s.field = ?`).get(span.sourceId, span.byteOffset, span.field);
1822
1885
  if (row) {
1823
1886
  const source = graph.db.prepare("SELECT descriptor FROM normalized_source WHERE transcript_id = ?").get(span.sourceId);
@@ -1839,12 +1902,13 @@ async function readSourceText(graph, span) {
1839
1902
  const texts = await Promise.all(JSON.parse(row.locators).map((locator) => readSessionSourceText(locator, { sources: [JSON.parse(source.descriptor)] })));
1840
1903
  return texts.some((text) => text === null) ? null : texts.join("\n");
1841
1904
  }
1842
- const normalized = graph.db.prepare("SELECT 1 FROM normalized_source WHERE transcript_id = ?").get(span.sourceId);
1905
+ const normalized = hasTable4(graph.db, "normalized_source") && graph.db.prepare("SELECT 1 FROM normalized_source WHERE transcript_id = ?").get(span.sourceId);
1843
1906
  if (normalized) return null;
1844
1907
  const legacy = graph.transcripts.list().find((t) => t.sourceId === span.sourceId);
1845
- return legacy ? readSessionText({ path: legacy.sourceKey, byteOffset: span.byteOffset, byteLength: span.byteLength, field: span.field }) : null;
1908
+ return legacy ? readSessionText({ path: expandHome(legacy.sourceKey, homeDir), byteOffset: span.byteOffset, byteLength: span.byteLength, field: span.field }) : null;
1846
1909
  }
1847
1910
  function normalizedSessions(graph, options = {}) {
1911
+ if (!hasTable4(graph.db, "normalized_event")) return [];
1848
1912
  const rows = graph.db.prepare(`SELECT c.ref,c.harness,c.native_id,c.namespace,MIN(e.ts) AS started,
1849
1913
  COUNT(DISTINCT CASE WHEN e.kind = 'native_turn' THEN e.turn_ref END) AS turns
1850
1914
  FROM conversation c JOIN (SELECT DISTINCT conversation_ref FROM normalized_source) s ON s.conversation_ref = c.ref
@@ -1877,6 +1941,7 @@ function normalizedSessions(graph, options = {}) {
1877
1941
  });
1878
1942
  }
1879
1943
  function normalizedUsage(graph, ref) {
1944
+ if (!hasTable4(graph.db, "normalized_event")) return [];
1880
1945
  const preferred = graph.db.prepare(`SELECT transcript_id FROM normalized_event
1881
1946
  WHERE conversation_ref = ? AND usage IS NOT NULL AND history_origin IS NULL
1882
1947
  GROUP BY transcript_id ORDER BY MAX(ts) DESC, COUNT(*) DESC, transcript_id ASC LIMIT 1`).get(ref);
@@ -1902,6 +1967,7 @@ export {
1902
1967
  FACET_TABLE,
1903
1968
  KIT,
1904
1969
  MIGRATIONS,
1970
+ NORMALIZED_TABLES,
1905
1971
  NO_ENRICHMENT,
1906
1972
  NO_ORIGINS,
1907
1973
  NO_PR_OUTCOMES,
@@ -1921,8 +1987,10 @@ export {
1921
1987
  applyDelta,
1922
1988
  backfillFacets,
1923
1989
  countRounds,
1990
+ derivedTables,
1924
1991
  enrichPrs,
1925
1992
  enrichTasks,
1993
+ ensureNormalizedSchema,
1926
1994
  indexCodexSource,
1927
1995
  indexTranscript,
1928
1996
  isInjectedCause,