@gobing-ai/ts-llm-jsonl-importer 0.4.24 → 0.4.26

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.
@@ -1 +1 @@
1
- {"version":3,"file":"importer.d.ts","sourceRoot":"","sources":["../src/importer.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAe,aAAa,EAAE,YAAY,EAAc,gBAAgB,EAAoB,MAAM,SAAS,CAAC;AAOxH;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAgJrH"}
1
+ {"version":3,"file":"importer.d.ts","sourceRoot":"","sources":["../src/importer.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAER,aAAa,EACb,YAAY,EAGZ,gBAAgB,EAEnB,MAAM,SAAS,CAAC;AAOjB;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CA4KrH"}
package/dist/importer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ambientRuntimePaths, createNodeFileSystem, resolvePath, walkDir, } from '@gobing-ai/ts-runtime';
2
2
  import { sha256 } from './hash.js';
3
- import { applyHistoryImportSchema, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, resetCheckpoints, targetTableFor, writeCheckpoint, } from './jsonl-importer-dao.js';
3
+ import { applyHistoryImportSchema, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, reconcileFullImport, resetCheckpoints, targetTableFor, writeCheckpoint, } from './jsonl-importer-dao.js';
4
4
  import { redactRecord } from './redaction.js';
5
5
  import { resolveSourceDefinition } from './sources.js';
6
6
  /** Run the JSONL import pipeline for one source.
@@ -29,6 +29,9 @@ export async function runJsonlImport(source, options) {
29
29
  let skippedDuplicates = 0;
30
30
  let unknownRecords = 0;
31
31
  let checkpointUpdates = 0;
32
+ // Full-mode desired set (R1, task 0504): every record hash the current source produces.
33
+ // ReconcileFullImport diffs this against the persisted ledger to retire stale rows.
34
+ const desiredHashes = new Set();
32
35
  for (const file of files) {
33
36
  const checkpoint = mode === 'incremental' ? await readCheckpoint(options.db, resolvedSource, file) : 0;
34
37
  let lineNumber = 0;
@@ -48,6 +51,10 @@ export async function runJsonlImport(source, options) {
48
51
  splitIndex: 0,
49
52
  });
50
53
  let lineSucceeded = false;
54
+ // Atomic per-record acceptance (R2, task 0504): a schema-invalid split rejects the
55
+ // WHOLE line, so no partially accepted rows (or orphaned tool-call rows with a
56
+ // dangling message_hash) can be left behind by one bad split.
57
+ let lineRejected = false;
51
58
  const prepared = [];
52
59
  for (let splitIndex = 0; splitIndex < splitRecords.length; splitIndex += 1) {
53
60
  const split = splitRecords[splitIndex];
@@ -66,6 +73,7 @@ export async function runJsonlImport(source, options) {
66
73
  sourceLine: lineNumber,
67
74
  reason: parsed.error.issues.map((issue) => issue.message).join('; '),
68
75
  });
76
+ lineRejected = true;
69
77
  continue;
70
78
  }
71
79
  const redacted = redactRecord(parsed.data, options.redactionRules);
@@ -77,12 +85,17 @@ export async function runJsonlImport(source, options) {
77
85
  splitIndex,
78
86
  record: redacted,
79
87
  });
88
+ desiredHashes.add(recordHash);
80
89
  if (await ledgerExists(options.db, recordHash)) {
81
90
  skippedDuplicates += 1;
82
91
  continue;
83
92
  }
84
93
  prepared.push({ split, splitIndex, normalized: redacted, recordHash });
85
94
  }
95
+ // A rejected line is reported (validationErrors above) but never persisted — no
96
+ // second pass, no checkpoint advance, no partial rows from this record.
97
+ if (lineRejected)
98
+ continue;
86
99
  // Second pass: resolve _messageSplitIndex → message_hash, then insert.
87
100
  const recordHashBySplitIndex = new Map(prepared.map((e) => [e.splitIndex, e.recordHash]));
88
101
  for (const entry of prepared) {
@@ -111,6 +124,13 @@ export async function runJsonlImport(source, options) {
111
124
  }
112
125
  }
113
126
  }
127
+ // Full-mode reconciliation (R1, task 0504): diff the desired hash set against the
128
+ // persisted ledger and retire stale derived rows (target, tool, ledger, checkpoint) in
129
+ // one source-scoped batch. Dry-run computes the identical counts without mutation.
130
+ let reconciliation;
131
+ if (mode === 'full') {
132
+ reconciliation = await reconcileFullImport(options.db, resolvedSource, desiredHashes, files, options.dryRun ?? false);
133
+ }
114
134
  return {
115
135
  source: resolvedSource,
116
136
  mode,
@@ -122,6 +142,7 @@ export async function runJsonlImport(source, options) {
122
142
  parseErrors,
123
143
  validationErrors,
124
144
  checkpointUpdates,
145
+ reconciliation,
125
146
  };
126
147
  }
127
148
  function parseJsonLine(line, sourceFile, sourceLine, parseErrors) {
package/dist/index.d.ts CHANGED
@@ -6,5 +6,5 @@ export { type OpenCodeImportOptions, runOpenCodeImport } from './opencode-import
6
6
  export { DEFAULT_REDACTION_RULES, redactRecord, redactValue } from './redaction';
7
7
  export { HISTORY_IMPORT_SCHEMA_SQL } from './schema-sql';
8
8
  export { getSourceDefinition, resolveSourceDefinition, SOURCE_DEFINITIONS, VALID_TABLE_NAME, validateSourceDefinition, } from './sources';
9
- export type { FieldTransform, ImportIssue, ImportMode, ImportOptions, ImportResult, JsonObject, LlmJsonlSource, RedactionRule, SourceDefinition, SplitConfig, SplitEntry, TransformContext, } from './types';
9
+ export type { FieldTransform, ImportIssue, ImportMode, ImportOptions, ImportResult, JsonObject, LlmJsonlSource, ReconcileSummary, RedactionRule, SourceDefinition, SplitConfig, SplitEntry, TransformContext, } from './types';
10
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAC9C,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,wBAAwB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAC1F,OAAO,EAAE,KAAK,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACpF,OAAO,EAAE,uBAAuB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EACH,mBAAmB,EACnB,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,WAAW,CAAC;AACnB,YAAY,EACR,cAAc,EACd,WAAW,EACX,UAAU,EACV,aAAa,EACb,YAAY,EACZ,UAAU,EACV,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,gBAAgB,GACnB,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAC9C,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,wBAAwB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAC1F,OAAO,EAAE,KAAK,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACpF,OAAO,EAAE,uBAAuB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EACH,mBAAmB,EACnB,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,WAAW,CAAC;AACnB,YAAY,EACR,cAAc,EACd,WAAW,EACX,UAAU,EACV,aAAa,EACb,YAAY,EACZ,UAAU,EACV,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,gBAAgB,GACnB,MAAM,SAAS,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import type { DbAdapter, DbBatchOp } from '@gobing-ai/ts-db';
2
- import type { ImportOptions, JsonObject, SourceDefinition } from './types';
2
+ import type { ImportOptions, JsonObject, ReconcileSummary, SourceDefinition } from './types';
3
3
  interface CheckpointRow {
4
4
  readonly last_imported_line: number;
5
5
  }
@@ -47,6 +47,19 @@ declare function insertLedger(db: ImportOptions['db'], recordHash: string, sourc
47
47
  * runtime `FileSystem` seam.
48
48
  */
49
49
  export declare function normalizeSourceFilePaths(db: ImportOptions['db'], resolveRealPath: (sourceFile: string) => string | null | undefined): Promise<void>;
50
+ /**
51
+ * Reconcile a source's persisted derived rows against the desired set produced by a full
52
+ * import (task 0504 R1). Rows whose `record_hash` is no longer reproduced by current source
53
+ * data or mapper output are stale: their target rows (typed contract tables — including tool
54
+ * calls — and ETL tables), ledger rows, and checkpoints for vanished source files are removed
55
+ * in ONE source-scoped batch (atomic). Dry-run returns the exact same counts without mutating
56
+ * the database, so `--dry-run` is a safe preview and a second full write reports zero changes.
57
+ *
58
+ * Deletes are keyed by `record_hash` (PK) and checkpoint `(source, source_file)` (PK) — never
59
+ * by an unindexed ledger predicate — so reconciliation cost scales with the stale count, not
60
+ * with ledger size.
61
+ */
62
+ declare function reconcileFullImport(db: ImportOptions['db'], source: string, desiredHashes: ReadonlySet<string>, discoveredFiles: readonly string[], dryRun: boolean): Promise<ReconcileSummary>;
50
63
  /** OpenCode message row projected from its SQLite store. */
51
64
  export interface OpenCodeMessageRow {
52
65
  id: string;
@@ -86,5 +99,5 @@ export declare function openCodeDeleteOperations(entries: readonly OpenCodeExist
86
99
  /** Build batched history, ledger, and checkpoint writes for OpenCode entries. */
87
100
  export declare function openCodeBulkWriteOperations(entries: readonly OpenCodeQueuedEntry[], checkpointFiles: readonly string[], now: ImportOptions['now']): DbBatchOp[];
88
101
  export type { CheckpointRow };
89
- export { ETL_TABLE_DDL, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, resetCheckpoints, targetTableFor, timestamp, writeCheckpoint, };
102
+ export { ETL_TABLE_DDL, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, reconcileFullImport, resetCheckpoints, targetTableFor, timestamp, writeCheckpoint, };
90
103
  //# sourceMappingURL=jsonl-importer-dao.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"jsonl-importer-dao.d.ts","sourceRoot":"","sources":["../src/jsonl-importer-dao.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"jsonl-importer-dao.d.ts","sourceRoot":"","sources":["../src/jsonl-importer-dao.ts"],"names":[],"mappings":"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"}
@@ -252,6 +252,57 @@ function resolveWithFallback(resolveRealPath, sourceFile) {
252
252
  return sourceFile;
253
253
  }
254
254
  }
255
+ /**
256
+ * Reconcile a source's persisted derived rows against the desired set produced by a full
257
+ * import (task 0504 R1). Rows whose `record_hash` is no longer reproduced by current source
258
+ * data or mapper output are stale: their target rows (typed contract tables — including tool
259
+ * calls — and ETL tables), ledger rows, and checkpoints for vanished source files are removed
260
+ * in ONE source-scoped batch (atomic). Dry-run returns the exact same counts without mutating
261
+ * the database, so `--dry-run` is a safe preview and a second full write reports zero changes.
262
+ *
263
+ * Deletes are keyed by `record_hash` (PK) and checkpoint `(source, source_file)` (PK) — never
264
+ * by an unindexed ledger predicate — so reconciliation cost scales with the stale count, not
265
+ * with ledger size.
266
+ */
267
+ async function reconcileFullImport(db, source, desiredHashes, discoveredFiles, dryRun) {
268
+ const ledgerRows = await db.queryAll('SELECT record_hash, target_table FROM history_import_ledger WHERE source = ?', source);
269
+ const stale = [];
270
+ for (const row of ledgerRows) {
271
+ if (!desiredHashes.has(row.record_hash))
272
+ stale.push(row);
273
+ }
274
+ const checkpointRows = await db.queryAll('SELECT source_file FROM history_import_checkpoint WHERE source = ?', source);
275
+ const discovered = new Set(discoveredFiles);
276
+ const staleCheckpoints = checkpointRows
277
+ .map((row) => row.source_file)
278
+ .filter((sourceFile) => !discovered.has(sourceFile));
279
+ const summary = {
280
+ staleTargetRows: stale.length,
281
+ staleLedgerRows: stale.length,
282
+ staleCheckpointRows: staleCheckpoints.length,
283
+ };
284
+ if (dryRun || (stale.length === 0 && staleCheckpoints.length === 0))
285
+ return summary;
286
+ const operations = [];
287
+ for (const row of stale) {
288
+ operations.push({
289
+ sql: `DELETE FROM ${targetTableFor(row.target_table)} WHERE record_hash = ?`,
290
+ params: [row.record_hash],
291
+ });
292
+ operations.push({
293
+ sql: 'DELETE FROM history_import_ledger WHERE record_hash = ?',
294
+ params: [row.record_hash],
295
+ });
296
+ }
297
+ for (const sourceFile of staleCheckpoints) {
298
+ operations.push({
299
+ sql: 'DELETE FROM history_import_checkpoint WHERE source = ? AND source_file = ?',
300
+ params: [source, sourceFile],
301
+ });
302
+ }
303
+ await db.batch(operations);
304
+ return summary;
305
+ }
255
306
  const OPENCODE_SOURCE = 'opencode';
256
307
  /** Read one ordered page of OpenCode messages after the supplied cursor. */
257
308
  export async function readOpenCodeMessages(db, lastTime, lastId, limit) {
@@ -353,4 +404,4 @@ export function openCodeBulkWriteOperations(entries, checkpointFiles, now) {
353
404
  }
354
405
  return operations;
355
406
  }
356
- export { ETL_TABLE_DDL, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, resetCheckpoints, targetTableFor, timestamp, writeCheckpoint, };
407
+ export { ETL_TABLE_DDL, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, reconcileFullImport, resetCheckpoints, targetTableFor, timestamp, writeCheckpoint, };
package/dist/mappers.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import type { JsonObject, SplitEntry, TransformContext } from './types';
3
+ /** Compute args_digest: sha256 of stable-jsonified, key-sorted, redacted args. */
4
+ export declare function argsDigest(args: unknown): string;
3
5
  /** Map a Claude Code JSONL record into one or more ETL split entries. */
4
6
  export declare function claudeSplit(raw: Record<string, unknown>): readonly SplitEntry[];
5
7
  /** Map a Pi JSONL record into one or more ETL split entries. */
6
8
  export declare function piSplit(raw: unknown): readonly SplitEntry[];
7
9
  /** Map an OMP JSONL record into one or more ETL split entries. */
8
- export declare function ompSplit(raw: Record<string, unknown>): readonly SplitEntry[];
10
+ export declare function ompSplit(raw: Record<string, unknown>, context?: TransformContext): readonly SplitEntry[];
9
11
  /** Map a Codex JSONL record into one or more ETL split entries. */
10
12
  export declare function codexSplit(raw: Record<string, unknown>): readonly SplitEntry[];
11
13
  /** Map an Antigravity (AGY) JSONL record into one or more ETL split entries. */
@@ -1 +1 @@
1
- {"version":3,"file":"mappers.d.ts","sourceRoot":"","sources":["../src/mappers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AA2FxE,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CA4G/E;AAMD,gEAAgE;AAChE,wBAAgB,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,UAAU,EAAE,CAsE3D;AAMD,kEAAkE;AAClE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CA4F5E;AAMD,mEAAmE;AACnE,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CAiG9E;AAMD,gFAAgF;AAChF,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CAgH5E;AAMD,gGAAgG;AAChG,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,UAAU,EAAE,CAgF3G;AAyMD,kEAAkE;AAClE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CAyS7E;AAwDD,wGAAwG;AACxG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAKxD;AAMD,gDAAgD;AAChD,eAAO,MAAM,gBAAgB,wBAAsE,CAAC;AACpG,4CAA4C;AAC5C,eAAO,MAAM,YAAY,wBAAsE,CAAC;AAChG,6CAA6C;AAC7C,eAAO,MAAM,aAAa,wBAAsE,CAAC;AACjG,+CAA+C;AAC/C,eAAO,MAAM,eAAe,wBAAsE,CAAC;AACnG,6CAA6C;AAC7C,eAAO,MAAM,aAAa,wBAAsE,CAAC;AACjG,8CAA8C;AAC9C,eAAO,MAAM,cAAc,wBAAsE,CAAC;AAClG,gDAAgD;AAChD,eAAO,MAAM,gBAAgB,wBAAsE,CAAC;AAQpG,oDAAoD;AACpD,eAAO,MAAM,aAAa,gCAAoB,CAAC;AAC/C,gDAAgD;AAChD,eAAO,MAAM,SAAS,gCAAoB,CAAC;AAC3C,iDAAiD;AACjD,eAAO,MAAM,UAAU,gCAAoB,CAAC;AAC5C,mDAAmD;AACnD,eAAO,MAAM,YAAY,gCAAoB,CAAC;AAC9C,iDAAiD;AACjD,eAAO,MAAM,UAAU,gCAAoB,CAAC;AAC5C,kDAAkD;AAClD,eAAO,MAAM,WAAW,gCAAoB,CAAC;AAC7C,oDAAoD;AACpD,eAAO,MAAM,aAAa,gCAAoB,CAAC"}
1
+ {"version":3,"file":"mappers.d.ts","sourceRoot":"","sources":["../src/mappers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AA+DxE,kFAAkF;AAClF,wBAAgB,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAGhD;AAwBD,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CA4G/E;AAMD,gEAAgE;AAChE,wBAAgB,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,UAAU,EAAE,CAsE3D;AAMD,kEAAkE;AAClE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,UAAU,EAAE,CA0GxG;AAoBD,mEAAmE;AACnE,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CAiG9E;AAMD,gFAAgF;AAChF,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CAgH5E;AAMD,gGAAgG;AAChG,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,UAAU,EAAE,CAgF3G;AAyMD,kEAAkE;AAClE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,EAAE,CAyS7E;AAwDD,wGAAwG;AACxG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAKxD;AAMD,gDAAgD;AAChD,eAAO,MAAM,gBAAgB,wBAAsE,CAAC;AACpG,4CAA4C;AAC5C,eAAO,MAAM,YAAY,wBAAsE,CAAC;AAChG,6CAA6C;AAC7C,eAAO,MAAM,aAAa,wBAAsE,CAAC;AACjG,+CAA+C;AAC/C,eAAO,MAAM,eAAe,wBAAsE,CAAC;AACnG,6CAA6C;AAC7C,eAAO,MAAM,aAAa,wBAAsE,CAAC;AACjG,8CAA8C;AAC9C,eAAO,MAAM,cAAc,wBAAsE,CAAC;AAClG,gDAAgD;AAChD,eAAO,MAAM,gBAAgB,wBAAsE,CAAC;AAQpG,oDAAoD;AACpD,eAAO,MAAM,aAAa,gCAAoB,CAAC;AAC/C,gDAAgD;AAChD,eAAO,MAAM,SAAS,gCAAoB,CAAC;AAC3C,iDAAiD;AACjD,eAAO,MAAM,UAAU,gCAAoB,CAAC;AAC5C,mDAAmD;AACnD,eAAO,MAAM,YAAY,gCAAoB,CAAC;AAC9C,iDAAiD;AACjD,eAAO,MAAM,UAAU,gCAAoB,CAAC;AAC5C,kDAAkD;AAClD,eAAO,MAAM,WAAW,gCAAoB,CAAC;AAC7C,oDAAoD;AACpD,eAAO,MAAM,aAAa,gCAAoB,CAAC"}
package/dist/mappers.js CHANGED
@@ -57,7 +57,7 @@ function detectProvenance(cwd) {
57
57
  return cwd.includes('/spur') || cwd.includes('/spur-new') ? 'spur-run' : 'ambient';
58
58
  }
59
59
  /** Compute args_digest: sha256 of stable-jsonified, key-sorted, redacted args. */
60
- function argsDigest(args) {
60
+ export function argsDigest(args) {
61
61
  const redacted = redactArgs(args);
62
62
  return sha256(redacted);
63
63
  }
@@ -262,44 +262,59 @@ export function piSplit(raw) {
262
262
  // OMP mapper (near-identical to pi)
263
263
  // ---------------------------------------------------------------------------
264
264
  /** Map an OMP JSONL record into one or more ETL split entries. */
265
- export function ompSplit(raw) {
265
+ export function ompSplit(raw, context) {
266
+ // Current OMP files are top-level event envelopes: `{type: "message", id, timestamp, parentId,
267
+ // message: {role, content, model, usage, cost, duration, ...}}`. The unique top-level `id` is
268
+ // an event id, never a session id; the session key and sequence come from the source file
269
+ // (context) when available. The legacy direct shape keeps role/type/content at the top level.
270
+ const msg = raw.message;
266
271
  const recordType = String(raw.type ?? '');
272
+ const sessionId = sessionIdFromContext(context, raw);
273
+ const seq = context?.sourceLine ?? (typeof raw.seq === 'number' ? raw.seq : 0);
274
+ const ts = s(raw.ts, raw.timestamp, raw.createdAt) ?? new Date(0).toISOString();
275
+ // Meta lifecycle/custom records — including the current `custom.*` events and non-message
276
+ // lifecycle types — collapse to one meta row keyed by the source session, never the unique
277
+ // event id, and never a guessed role.
267
278
  if (recordType === 'title' ||
268
279
  recordType === 'title_change' ||
269
280
  recordType === 'service_tier_change' ||
270
281
  recordType === 'ttsr_injection' ||
271
282
  recordType === 'session_init' ||
272
- recordType === 'compaction') {
283
+ recordType === 'session' ||
284
+ recordType === 'model_change' ||
285
+ recordType === 'thinking_level_change' ||
286
+ recordType === 'compaction' ||
287
+ recordType === 'custom' ||
288
+ recordType === 'custom_message' ||
289
+ recordType.startsWith('custom.')) {
273
290
  return [
274
291
  {
275
292
  targetTable: 'history_message',
276
293
  record: {
277
- session_id: s(raw.id, o(raw.session).id) ?? 'unknown',
278
- seq: typeof raw.seq === 'number' ? raw.seq : 0,
294
+ session_id: sessionId,
295
+ seq,
279
296
  role: 'meta',
280
297
  record_type: recordType,
281
298
  disposition: 'meta',
282
- ts: s(raw.ts, raw.timestamp) ?? new Date(0).toISOString(),
299
+ ts,
283
300
  provenance: 'ambient',
284
301
  },
285
302
  },
286
303
  ];
287
304
  }
288
305
  const entries = [];
289
- const sessionId = s(raw.id, o(raw.session).id) ?? 'unknown';
290
- const seq = typeof raw.seq === 'number' ? raw.seq : 0;
291
- const ts = s(raw.ts, raw.timestamp, raw.createdAt) ?? new Date(0).toISOString();
292
- const role = mapRole(raw.type ?? raw.role);
293
- const model = s(raw.model, o(raw.message).model);
306
+ const role = mapRole(msg?.role ?? raw.type ?? raw.role);
307
+ const model = s(raw.model, o(msg).model);
294
308
  const cwd = s(raw.cwd, raw.dir);
295
- const msg = raw.message;
296
- const usage = msg?.usage;
309
+ const usage = (msg?.usage ?? raw.usage);
297
310
  const inputTokens = (usage?.input ?? usage?.input_tokens ?? undefined);
298
311
  const outputTokens = (usage?.output ?? usage?.output_tokens ?? undefined);
299
312
  const cacheRead = (usage?.cacheRead ?? usage?.cache_read_tokens ?? undefined);
300
313
  const cacheWrite = (usage?.cacheWrite ?? usage?.cache_write_tokens ?? undefined);
301
314
  const costObj = (msg?.cost ?? raw.cost);
302
315
  const costUsd = typeof costObj?.total === 'number' ? costObj.total : computeCost(inputTokens, outputTokens, model);
316
+ const contentBlocks = msg?.content ?? raw.content;
317
+ const durationMs = typeof msg?.duration === 'number' && Number.isFinite(msg.duration) ? msg.duration : undefined;
303
318
  const messageSplitIndex = entries.length;
304
319
  entries.push({
305
320
  targetTable: 'history_message',
@@ -310,43 +325,59 @@ export function ompSplit(raw) {
310
325
  record_type: recordType,
311
326
  disposition: 'keep',
312
327
  ts,
313
- duration_ms: undefined,
328
+ duration_ms: durationMs,
314
329
  model: model ?? null,
315
330
  input_tokens: inputTokens ?? null,
316
331
  output_tokens: outputTokens ?? null,
317
332
  cache_read_tokens: cacheRead ?? null,
318
333
  cache_write_tokens: cacheWrite ?? null,
319
334
  cost_usd: costUsd ?? null,
320
- content_text: s(raw.content, raw.text, msg?.content) ?? null,
335
+ content_text: extractContentText(contentBlocks) ?? s(raw.content, raw.text, msg?.content) ?? null,
321
336
  cwd: cwd ?? null,
322
337
  provenance: detectProvenance(cwd),
323
338
  },
324
339
  });
325
- if (Array.isArray(raw.content) && role === 'assistant') {
326
- for (const block of raw.content) {
327
- if (block?.toolCall) {
328
- const tc = block.toolCall;
329
- entries.push({
330
- targetTable: 'history_tool_call',
331
- record: {
332
- _messageSplitIndex: messageSplitIndex,
333
- session_id: sessionId,
334
- seq,
335
- tool_name: String(tc.name ?? ''),
336
- args_digest: argsDigest(tc.input ?? tc.arguments),
337
- status: 'ok',
338
- started_at: undefined,
339
- completed_at: undefined,
340
- duration_ms: undefined,
341
- result_bytes: undefined,
342
- error_text: undefined,
343
- },
344
- });
345
- }
340
+ if (Array.isArray(contentBlocks) && role === 'assistant') {
341
+ for (const block of contentBlocks) {
342
+ const call = normalizeOmpToolCall(block);
343
+ if (call === null)
344
+ continue;
345
+ entries.push({
346
+ targetTable: 'history_tool_call',
347
+ record: {
348
+ _messageSplitIndex: messageSplitIndex,
349
+ session_id: sessionId,
350
+ seq,
351
+ tool_name: String(call.name ?? ''),
352
+ args_digest: argsDigest(call.input ?? call.arguments),
353
+ status: 'ok',
354
+ started_at: undefined,
355
+ completed_at: undefined,
356
+ duration_ms: undefined,
357
+ result_bytes: undefined,
358
+ error_text: undefined,
359
+ },
360
+ });
346
361
  }
347
362
  }
348
363
  return entries;
349
364
  }
365
+ /**
366
+ * Normalize an OMP assistant content block to a tool-call object, or null when the block is not a
367
+ * call. Supports both the legacy nested `{toolCall: {...}}` block and the current flat
368
+ * `{type: "toolCall", id, name, arguments}` block. Exactly one history_tool_call row is emitted
369
+ * per call block.
370
+ */
371
+ function normalizeOmpToolCall(block) {
372
+ if (typeof block !== 'object' || block === null)
373
+ return null;
374
+ const nested = o(block.toolCall);
375
+ if (Object.keys(nested).length > 0)
376
+ return nested;
377
+ if (block.type === 'toolCall')
378
+ return block;
379
+ return null;
380
+ }
350
381
  // ---------------------------------------------------------------------------
351
382
  // Codex mapper
352
383
  // ---------------------------------------------------------------------------
@@ -1 +1 @@
1
- {"version":3,"file":"opencode-importer.d.ts","sourceRoot":"","sources":["../src/opencode-importer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,SAAS,EAAkB,MAAM,kBAAkB,CAAC;AACnF,OAAO,EAGH,KAAK,UAAU,EAEf,KAAK,YAAY,EACpB,MAAM,uBAAuB,CAAC;AAc/B,OAAO,KAAK,EAAe,UAAU,EAAE,YAAY,EAAc,aAAa,EAAE,MAAM,SAAS,CAAC;AAYhG,8EAA8E;AAC9E,MAAM,WAAW,qBAAqB;IAClC,EAAE,EAAE,SAAS,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CACpB;AAED,wFAAwF;AACxF,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,YAAY,CAAC,CA+F7F"}
1
+ {"version":3,"file":"opencode-importer.d.ts","sourceRoot":"","sources":["../src/opencode-importer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,SAAS,EAAkB,MAAM,kBAAkB,CAAC;AACnF,OAAO,EAGH,KAAK,UAAU,EAEf,KAAK,YAAY,EACpB,MAAM,uBAAuB,CAAC;AAc/B,OAAO,KAAK,EAAe,UAAU,EAAE,YAAY,EAAgC,aAAa,EAAE,MAAM,SAAS,CAAC;AAYlH,8EAA8E;AAC9E,MAAM,WAAW,qBAAqB;IAClC,EAAE,EAAE,SAAS,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CACpB;AAED,wFAAwF;AACxF,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,YAAY,CAAC,CAuI7F"}
@@ -24,7 +24,20 @@ export async function runOpenCodeImport(options) {
24
24
  let lastTime = -1;
25
25
  let lastId = '';
26
26
  const existingBySourceFile = await readOpenCodeExistingEntries(options.db);
27
+ // Source files still present in the OpenCode store this pass (R1, task 0504). Files with
28
+ // no valid role produce no entries and are deliberately NOT marked seen, so their old
29
+ // derived rows are swept as no-longer-reproduced in full mode.
30
+ const seenSourceFiles = new Set();
27
31
  const operations = [];
32
+ // Full-mode reconciliation counters (R1, task 0504): stale rows from BOTH sources —
33
+ // per-file hash-diff deletes (changed messages) and the vanished-file sweep below. Dry-run
34
+ // counts the identical totals the write would delete, so `--dry-run` is an exact preview.
35
+ // Declared here (outside `try`) because the result is returned after `finally` closes the
36
+ // source database.
37
+ let staleTargetRows = 0;
38
+ let staleLedgerRows = 0;
39
+ let staleCheckpointRows = 0;
40
+ let reconciliation;
28
41
  try {
29
42
  while (true) {
30
43
  const messages = await readOpenCodeMessages(sourceDb, lastTime, lastId, PAGE_SIZE);
@@ -48,11 +61,18 @@ export async function runOpenCodeImport(options) {
48
61
  validationErrors.push({ sourceFile, sourceLine: 1, reason: 'OpenCode message has no valid role' });
49
62
  continue;
50
63
  }
64
+ seenSourceFiles.add(sourceFile);
51
65
  const existing = existingBySourceFile.get(sourceFile) ?? [];
52
66
  if (sameHashes(existing, entries)) {
53
67
  skippedDuplicates += entries.length;
54
68
  continue;
55
69
  }
70
+ if (mode === 'full') {
71
+ // Per-file mapper drift: the old derived rows for this source file are
72
+ // stale and are deleted before the new entries are written.
73
+ staleTargetRows += existing.length;
74
+ staleLedgerRows += existing.length;
75
+ }
56
76
  if (!options.dryRun) {
57
77
  operations.push(...openCodeDeleteOperations(existing));
58
78
  for (const entry of entries) {
@@ -73,6 +93,26 @@ export async function runOpenCodeImport(options) {
73
93
  lastTime = tail.time_created;
74
94
  lastId = tail.id;
75
95
  }
96
+ // Full-mode reconciliation (R1, task 0504): messages deleted from the OpenCode store
97
+ // leave stale derived rows behind. Sweep ledger/target rows and checkpoints for source
98
+ // files never seen this pass, merged into the SAME source-scoped batch as the writes.
99
+ if (mode === 'full') {
100
+ for (const [sourceFile, entries] of existingBySourceFile) {
101
+ if (seenSourceFiles.has(sourceFile))
102
+ continue;
103
+ staleLedgerRows += entries.length;
104
+ staleTargetRows += entries.length;
105
+ staleCheckpointRows += 1;
106
+ if (!options.dryRun) {
107
+ operations.push(...openCodeDeleteOperations(entries));
108
+ operations.push({
109
+ sql: 'DELETE FROM history_import_checkpoint WHERE source = ? AND source_file = ?',
110
+ params: [SOURCE, sourceFile],
111
+ });
112
+ }
113
+ }
114
+ reconciliation = { staleTargetRows, staleLedgerRows, staleCheckpointRows };
115
+ }
76
116
  if (operations.length > 0)
77
117
  await options.db.batch(operations);
78
118
  }
@@ -90,6 +130,7 @@ export async function runOpenCodeImport(options) {
90
130
  parseErrors,
91
131
  validationErrors,
92
132
  checkpointUpdates,
133
+ reconciliation,
93
134
  };
94
135
  }
95
136
  function groupParts(parts) {
package/dist/types.d.ts CHANGED
@@ -76,6 +76,20 @@ export interface ImportIssue {
76
76
  readonly sourceLine: number;
77
77
  readonly reason: string;
78
78
  }
79
+ /**
80
+ * Source-scoped full-mode reconciliation outcome (task 0504 R1). Computed by diffing the
81
+ * current source's desired record hashes against the persisted ledger; rows no longer
82
+ * reproduced by current source data or mapper output are stale and are removed in one
83
+ * source-scoped batch (dry-run reports the same counts without mutating the database).
84
+ */
85
+ export interface ReconcileSummary {
86
+ /** Rows deleted from typed/ETL target tables (history_message / history_tool_call / history_etl_*). */
87
+ readonly staleTargetRows: number;
88
+ /** Rows deleted from `history_import_ledger`. */
89
+ readonly staleLedgerRows: number;
90
+ /** Checkpoint rows deleted for source files no longer discovered. */
91
+ readonly staleCheckpointRows: number;
92
+ }
79
93
  /** Summary produced by the importer control function. */
80
94
  export interface ImportResult {
81
95
  /** Source identifier — the built-in name or custom definition name that produced this result. */
@@ -89,5 +103,10 @@ export interface ImportResult {
89
103
  readonly parseErrors: readonly ImportIssue[];
90
104
  readonly validationErrors: readonly ImportIssue[];
91
105
  readonly checkpointUpdates: number;
106
+ /**
107
+ * Full-mode reconciliation counts (only set when `mode === 'full'`). A second full run
108
+ * reports zero stale rows once the database matches the current source (task 0504 R1).
109
+ */
110
+ readonly reconciliation?: ReconcileSummary;
92
111
  }
93
112
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-llm-jsonl-importer",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "@gobing-ai/ts-llm-jsonl-importer — Generic JSONL importer for LLM agent history files.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -47,9 +47,9 @@
47
47
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-llm-jsonl-importer-v<version> && git push --tags' && exit 1"
48
48
  },
49
49
  "dependencies": {
50
- "@gobing-ai/ts-db": "^0.4.24",
51
- "@gobing-ai/ts-runtime": "^0.4.24",
52
- "@gobing-ai/ts-utils": "^0.4.24",
50
+ "@gobing-ai/ts-db": "^0.4.26",
51
+ "@gobing-ai/ts-runtime": "^0.4.26",
52
+ "@gobing-ai/ts-utils": "^0.4.26",
53
53
  "zod": "^4.1.0"
54
54
  },
55
55
  "devDependencies": {
package/src/importer.ts CHANGED
@@ -14,13 +14,22 @@ import {
14
14
  insertRecord,
15
15
  ledgerExists,
16
16
  readCheckpoint,
17
+ reconcileFullImport,
17
18
  resetCheckpoints,
18
19
  targetTableFor,
19
20
  writeCheckpoint,
20
21
  } from './jsonl-importer-dao';
21
22
  import { redactRecord } from './redaction';
22
23
  import { resolveSourceDefinition } from './sources';
23
- import type { ImportIssue, ImportOptions, ImportResult, JsonObject, SourceDefinition, TransformContext } from './types';
24
+ import type {
25
+ ImportIssue,
26
+ ImportOptions,
27
+ ImportResult,
28
+ JsonObject,
29
+ ReconcileSummary,
30
+ SourceDefinition,
31
+ TransformContext,
32
+ } from './types';
24
33
 
25
34
  interface SplitRecord {
26
35
  readonly targetTable: string;
@@ -55,6 +64,9 @@ export async function runJsonlImport(source: string | SourceDefinition, options:
55
64
  let skippedDuplicates = 0;
56
65
  let unknownRecords = 0;
57
66
  let checkpointUpdates = 0;
67
+ // Full-mode desired set (R1, task 0504): every record hash the current source produces.
68
+ // ReconcileFullImport diffs this against the persisted ledger to retire stale rows.
69
+ const desiredHashes = new Set<string>();
58
70
 
59
71
  for (const file of files) {
60
72
  const checkpoint = mode === 'incremental' ? await readCheckpoint(options.db, resolvedSource, file) : 0;
@@ -75,6 +87,10 @@ export async function runJsonlImport(source: string | SourceDefinition, options:
75
87
  splitIndex: 0,
76
88
  });
77
89
  let lineSucceeded = false;
90
+ // Atomic per-record acceptance (R2, task 0504): a schema-invalid split rejects the
91
+ // WHOLE line, so no partially accepted rows (or orphaned tool-call rows with a
92
+ // dangling message_hash) can be left behind by one bad split.
93
+ let lineRejected = false;
78
94
 
79
95
  // First pass: normalize, validate, redact, compute recordHash for every entry.
80
96
  interface PreparedEntry {
@@ -100,6 +116,7 @@ export async function runJsonlImport(source: string | SourceDefinition, options:
100
116
  sourceLine: lineNumber,
101
117
  reason: parsed.error.issues.map((issue) => issue.message).join('; '),
102
118
  });
119
+ lineRejected = true;
103
120
  continue;
104
121
  }
105
122
 
@@ -112,6 +129,7 @@ export async function runJsonlImport(source: string | SourceDefinition, options:
112
129
  splitIndex,
113
130
  record: redacted,
114
131
  });
132
+ desiredHashes.add(recordHash);
115
133
  if (await ledgerExists(options.db, recordHash)) {
116
134
  skippedDuplicates += 1;
117
135
  continue;
@@ -119,6 +137,10 @@ export async function runJsonlImport(source: string | SourceDefinition, options:
119
137
  prepared.push({ split, splitIndex, normalized: redacted, recordHash });
120
138
  }
121
139
 
140
+ // A rejected line is reported (validationErrors above) but never persisted — no
141
+ // second pass, no checkpoint advance, no partial rows from this record.
142
+ if (lineRejected) continue;
143
+
122
144
  // Second pass: resolve _messageSplitIndex → message_hash, then insert.
123
145
  const recordHashBySplitIndex = new Map(prepared.map((e) => [e.splitIndex, e.recordHash] as const));
124
146
  for (const entry of prepared) {
@@ -167,6 +189,20 @@ export async function runJsonlImport(source: string | SourceDefinition, options:
167
189
  }
168
190
  }
169
191
 
192
+ // Full-mode reconciliation (R1, task 0504): diff the desired hash set against the
193
+ // persisted ledger and retire stale derived rows (target, tool, ledger, checkpoint) in
194
+ // one source-scoped batch. Dry-run computes the identical counts without mutation.
195
+ let reconciliation: ReconcileSummary | undefined;
196
+ if (mode === 'full') {
197
+ reconciliation = await reconcileFullImport(
198
+ options.db,
199
+ resolvedSource,
200
+ desiredHashes,
201
+ files,
202
+ options.dryRun ?? false,
203
+ );
204
+ }
205
+
170
206
  return {
171
207
  source: resolvedSource,
172
208
  mode,
@@ -178,6 +214,7 @@ export async function runJsonlImport(source: string | SourceDefinition, options:
178
214
  parseErrors,
179
215
  validationErrors,
180
216
  checkpointUpdates,
217
+ reconciliation,
181
218
  };
182
219
  }
183
220
 
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  ImportResult,
21
21
  JsonObject,
22
22
  LlmJsonlSource,
23
+ ReconcileSummary,
23
24
  RedactionRule,
24
25
  SourceDefinition,
25
26
  SplitConfig,
@@ -2,7 +2,7 @@ import type { DbAdapter, DbBatchOp } from '@gobing-ai/ts-db';
2
2
  import { HistoryImportError } from './errors';
3
3
  import { HISTORY_IMPORT_SCHEMA_SQL } from './schema-sql';
4
4
  import { VALID_TABLE_NAME } from './sources';
5
- import type { ImportOptions, JsonObject, SourceDefinition } from './types';
5
+ import type { ImportOptions, JsonObject, ReconcileSummary, SourceDefinition } from './types';
6
6
 
7
7
  interface CheckpointRow {
8
8
  readonly last_imported_line: number;
@@ -369,6 +369,71 @@ function resolveWithFallback(
369
369
  }
370
370
  }
371
371
 
372
+ /**
373
+ * Reconcile a source's persisted derived rows against the desired set produced by a full
374
+ * import (task 0504 R1). Rows whose `record_hash` is no longer reproduced by current source
375
+ * data or mapper output are stale: their target rows (typed contract tables — including tool
376
+ * calls — and ETL tables), ledger rows, and checkpoints for vanished source files are removed
377
+ * in ONE source-scoped batch (atomic). Dry-run returns the exact same counts without mutating
378
+ * the database, so `--dry-run` is a safe preview and a second full write reports zero changes.
379
+ *
380
+ * Deletes are keyed by `record_hash` (PK) and checkpoint `(source, source_file)` (PK) — never
381
+ * by an unindexed ledger predicate — so reconciliation cost scales with the stale count, not
382
+ * with ledger size.
383
+ */
384
+ async function reconcileFullImport(
385
+ db: ImportOptions['db'],
386
+ source: string,
387
+ desiredHashes: ReadonlySet<string>,
388
+ discoveredFiles: readonly string[],
389
+ dryRun: boolean,
390
+ ): Promise<ReconcileSummary> {
391
+ const ledgerRows = await db.queryAll<{ record_hash: string; target_table: string }>(
392
+ 'SELECT record_hash, target_table FROM history_import_ledger WHERE source = ?',
393
+ source,
394
+ );
395
+ const stale: { record_hash: string; target_table: string }[] = [];
396
+ for (const row of ledgerRows) {
397
+ if (!desiredHashes.has(row.record_hash)) stale.push(row);
398
+ }
399
+
400
+ const checkpointRows = await db.queryAll<{ source_file: string }>(
401
+ 'SELECT source_file FROM history_import_checkpoint WHERE source = ?',
402
+ source,
403
+ );
404
+ const discovered = new Set(discoveredFiles);
405
+ const staleCheckpoints = checkpointRows
406
+ .map((row) => row.source_file)
407
+ .filter((sourceFile) => !discovered.has(sourceFile));
408
+
409
+ const summary: ReconcileSummary = {
410
+ staleTargetRows: stale.length,
411
+ staleLedgerRows: stale.length,
412
+ staleCheckpointRows: staleCheckpoints.length,
413
+ };
414
+ if (dryRun || (stale.length === 0 && staleCheckpoints.length === 0)) return summary;
415
+
416
+ const operations: DbBatchOp[] = [];
417
+ for (const row of stale) {
418
+ operations.push({
419
+ sql: `DELETE FROM ${targetTableFor(row.target_table)} WHERE record_hash = ?`,
420
+ params: [row.record_hash],
421
+ });
422
+ operations.push({
423
+ sql: 'DELETE FROM history_import_ledger WHERE record_hash = ?',
424
+ params: [row.record_hash],
425
+ });
426
+ }
427
+ for (const sourceFile of staleCheckpoints) {
428
+ operations.push({
429
+ sql: 'DELETE FROM history_import_checkpoint WHERE source = ? AND source_file = ?',
430
+ params: [source, sourceFile],
431
+ });
432
+ }
433
+ await db.batch(operations);
434
+ return summary;
435
+ }
436
+
372
437
  const OPENCODE_SOURCE = 'opencode';
373
438
 
374
439
  /** OpenCode message row projected from its SQLite store. */
@@ -538,6 +603,7 @@ export {
538
603
  insertRecord,
539
604
  ledgerExists,
540
605
  readCheckpoint,
606
+ reconcileFullImport,
541
607
  resetCheckpoints,
542
608
  targetTableFor,
543
609
  timestamp,
package/src/mappers.ts CHANGED
@@ -64,7 +64,7 @@ function detectProvenance(cwd?: string): 'ambient' | 'spur-run' {
64
64
  }
65
65
 
66
66
  /** Compute args_digest: sha256 of stable-jsonified, key-sorted, redacted args. */
67
- function argsDigest(args: unknown): string {
67
+ export function argsDigest(args: unknown): string {
68
68
  const redacted = redactArgs(args);
69
69
  return sha256(redacted);
70
70
  }
@@ -284,26 +284,44 @@ export function piSplit(raw: unknown): readonly SplitEntry[] {
284
284
  // ---------------------------------------------------------------------------
285
285
 
286
286
  /** Map an OMP JSONL record into one or more ETL split entries. */
287
- export function ompSplit(raw: Record<string, unknown>): readonly SplitEntry[] {
287
+ export function ompSplit(raw: Record<string, unknown>, context?: TransformContext): readonly SplitEntry[] {
288
+ // Current OMP files are top-level event envelopes: `{type: "message", id, timestamp, parentId,
289
+ // message: {role, content, model, usage, cost, duration, ...}}`. The unique top-level `id` is
290
+ // an event id, never a session id; the session key and sequence come from the source file
291
+ // (context) when available. The legacy direct shape keeps role/type/content at the top level.
292
+ const msg = raw.message as Record<string, unknown> | undefined;
288
293
  const recordType = String(raw.type ?? '');
294
+ const sessionId = sessionIdFromContext(context, raw);
295
+ const seq = context?.sourceLine ?? (typeof raw.seq === 'number' ? raw.seq : 0);
296
+ const ts = s(raw.ts, raw.timestamp, raw.createdAt) ?? new Date(0).toISOString();
297
+
298
+ // Meta lifecycle/custom records — including the current `custom.*` events and non-message
299
+ // lifecycle types — collapse to one meta row keyed by the source session, never the unique
300
+ // event id, and never a guessed role.
289
301
  if (
290
302
  recordType === 'title' ||
291
303
  recordType === 'title_change' ||
292
304
  recordType === 'service_tier_change' ||
293
305
  recordType === 'ttsr_injection' ||
294
306
  recordType === 'session_init' ||
295
- recordType === 'compaction'
307
+ recordType === 'session' ||
308
+ recordType === 'model_change' ||
309
+ recordType === 'thinking_level_change' ||
310
+ recordType === 'compaction' ||
311
+ recordType === 'custom' ||
312
+ recordType === 'custom_message' ||
313
+ recordType.startsWith('custom.')
296
314
  ) {
297
315
  return [
298
316
  {
299
317
  targetTable: 'history_message',
300
318
  record: {
301
- session_id: s(raw.id, o(raw.session).id) ?? 'unknown',
302
- seq: typeof raw.seq === 'number' ? raw.seq : 0,
319
+ session_id: sessionId,
320
+ seq,
303
321
  role: 'meta',
304
322
  record_type: recordType,
305
323
  disposition: 'meta',
306
- ts: s(raw.ts, raw.timestamp) ?? new Date(0).toISOString(),
324
+ ts,
307
325
  provenance: 'ambient',
308
326
  },
309
327
  },
@@ -311,21 +329,18 @@ export function ompSplit(raw: Record<string, unknown>): readonly SplitEntry[] {
311
329
  }
312
330
 
313
331
  const entries: SplitEntry[] = [];
314
- const sessionId = s(raw.id, o(raw.session).id) ?? 'unknown';
315
- const seq = typeof raw.seq === 'number' ? raw.seq : 0;
316
- const ts = s(raw.ts, raw.timestamp, raw.createdAt) ?? new Date(0).toISOString();
317
- const role = mapRole(raw.type ?? raw.role);
318
- const model = s(raw.model, o(raw.message).model);
332
+ const role = mapRole(msg?.role ?? raw.type ?? raw.role);
333
+ const model = s(raw.model, o(msg).model);
319
334
  const cwd = s(raw.cwd, raw.dir);
320
-
321
- const msg = raw.message as Record<string, unknown> | undefined;
322
- const usage = msg?.usage as Record<string, unknown> | undefined;
335
+ const usage = (msg?.usage ?? raw.usage) as Record<string, unknown> | undefined;
323
336
  const inputTokens = (usage?.input ?? usage?.input_tokens ?? undefined) as number | undefined;
324
337
  const outputTokens = (usage?.output ?? usage?.output_tokens ?? undefined) as number | undefined;
325
338
  const cacheRead = (usage?.cacheRead ?? usage?.cache_read_tokens ?? undefined) as number | undefined;
326
339
  const cacheWrite = (usage?.cacheWrite ?? usage?.cache_write_tokens ?? undefined) as number | undefined;
327
340
  const costObj = (msg?.cost ?? raw.cost) as Record<string, unknown> | undefined;
328
341
  const costUsd = typeof costObj?.total === 'number' ? costObj.total : computeCost(inputTokens, outputTokens, model);
342
+ const contentBlocks = msg?.content ?? raw.content;
343
+ const durationMs = typeof msg?.duration === 'number' && Number.isFinite(msg.duration) ? msg.duration : undefined;
329
344
 
330
345
  const messageSplitIndex = entries.length;
331
346
 
@@ -338,46 +353,59 @@ export function ompSplit(raw: Record<string, unknown>): readonly SplitEntry[] {
338
353
  record_type: recordType,
339
354
  disposition: 'keep',
340
355
  ts,
341
- duration_ms: undefined,
356
+ duration_ms: durationMs,
342
357
  model: model ?? null,
343
358
  input_tokens: inputTokens ?? null,
344
359
  output_tokens: outputTokens ?? null,
345
360
  cache_read_tokens: cacheRead ?? null,
346
361
  cache_write_tokens: cacheWrite ?? null,
347
362
  cost_usd: costUsd ?? null,
348
- content_text: s(raw.content, raw.text, msg?.content) ?? null,
363
+ content_text: extractContentText(contentBlocks) ?? s(raw.content, raw.text, msg?.content) ?? null,
349
364
  cwd: cwd ?? null,
350
365
  provenance: detectProvenance(cwd),
351
366
  },
352
367
  });
353
368
 
354
- if (Array.isArray(raw.content) && role === 'assistant') {
355
- for (const block of raw.content as Record<string, unknown>[]) {
356
- if (block?.toolCall) {
357
- const tc = block.toolCall as Record<string, unknown>;
358
- entries.push({
359
- targetTable: 'history_tool_call',
360
- record: {
361
- _messageSplitIndex: messageSplitIndex,
362
- session_id: sessionId,
363
- seq,
364
- tool_name: String(tc.name ?? ''),
365
- args_digest: argsDigest(tc.input ?? tc.arguments),
366
- status: 'ok',
367
- started_at: undefined,
368
- completed_at: undefined,
369
- duration_ms: undefined,
370
- result_bytes: undefined,
371
- error_text: undefined,
372
- },
373
- });
374
- }
369
+ if (Array.isArray(contentBlocks) && role === 'assistant') {
370
+ for (const block of contentBlocks as Record<string, unknown>[]) {
371
+ const call = normalizeOmpToolCall(block);
372
+ if (call === null) continue;
373
+ entries.push({
374
+ targetTable: 'history_tool_call',
375
+ record: {
376
+ _messageSplitIndex: messageSplitIndex,
377
+ session_id: sessionId,
378
+ seq,
379
+ tool_name: String(call.name ?? ''),
380
+ args_digest: argsDigest(call.input ?? call.arguments),
381
+ status: 'ok',
382
+ started_at: undefined,
383
+ completed_at: undefined,
384
+ duration_ms: undefined,
385
+ result_bytes: undefined,
386
+ error_text: undefined,
387
+ },
388
+ });
375
389
  }
376
390
  }
377
391
 
378
392
  return entries;
379
393
  }
380
394
 
395
+ /**
396
+ * Normalize an OMP assistant content block to a tool-call object, or null when the block is not a
397
+ * call. Supports both the legacy nested `{toolCall: {...}}` block and the current flat
398
+ * `{type: "toolCall", id, name, arguments}` block. Exactly one history_tool_call row is emitted
399
+ * per call block.
400
+ */
401
+ function normalizeOmpToolCall(block: Record<string, unknown>): Record<string, unknown> | null {
402
+ if (typeof block !== 'object' || block === null) return null;
403
+ const nested = o(block.toolCall);
404
+ if (Object.keys(nested).length > 0) return nested;
405
+ if (block.type === 'toolCall') return block;
406
+ return null;
407
+ }
408
+
381
409
  // ---------------------------------------------------------------------------
382
410
  // Codex mapper
383
411
  // ---------------------------------------------------------------------------
@@ -19,7 +19,7 @@ import {
19
19
  readOpenCodeParts,
20
20
  } from './jsonl-importer-dao';
21
21
  import { redactRecord } from './redaction';
22
- import type { ImportIssue, ImportMode, ImportResult, JsonObject, RedactionRule } from './types';
22
+ import type { ImportIssue, ImportMode, ImportResult, JsonObject, ReconcileSummary, RedactionRule } from './types';
23
23
 
24
24
  const SOURCE = 'opencode';
25
25
  const PAGE_SIZE = 250;
@@ -63,7 +63,20 @@ export async function runOpenCodeImport(options: OpenCodeImportOptions): Promise
63
63
  let lastTime = -1;
64
64
  let lastId = '';
65
65
  const existingBySourceFile = await readOpenCodeExistingEntries(options.db);
66
+ // Source files still present in the OpenCode store this pass (R1, task 0504). Files with
67
+ // no valid role produce no entries and are deliberately NOT marked seen, so their old
68
+ // derived rows are swept as no-longer-reproduced in full mode.
69
+ const seenSourceFiles = new Set<string>();
66
70
  const operations: DbBatchOp[] = [];
71
+ // Full-mode reconciliation counters (R1, task 0504): stale rows from BOTH sources —
72
+ // per-file hash-diff deletes (changed messages) and the vanished-file sweep below. Dry-run
73
+ // counts the identical totals the write would delete, so `--dry-run` is an exact preview.
74
+ // Declared here (outside `try`) because the result is returned after `finally` closes the
75
+ // source database.
76
+ let staleTargetRows = 0;
77
+ let staleLedgerRows = 0;
78
+ let staleCheckpointRows = 0;
79
+ let reconciliation: ReconcileSummary | undefined;
67
80
 
68
81
  try {
69
82
  while (true) {
@@ -91,6 +104,7 @@ export async function runOpenCodeImport(options: OpenCodeImportOptions): Promise
91
104
  validationErrors.push({ sourceFile, sourceLine: 1, reason: 'OpenCode message has no valid role' });
92
105
  continue;
93
106
  }
107
+ seenSourceFiles.add(sourceFile);
94
108
 
95
109
  const existing = existingBySourceFile.get(sourceFile) ?? [];
96
110
  if (sameHashes(existing, entries)) {
@@ -98,6 +112,12 @@ export async function runOpenCodeImport(options: OpenCodeImportOptions): Promise
98
112
  continue;
99
113
  }
100
114
 
115
+ if (mode === 'full') {
116
+ // Per-file mapper drift: the old derived rows for this source file are
117
+ // stale and are deleted before the new entries are written.
118
+ staleTargetRows += existing.length;
119
+ staleLedgerRows += existing.length;
120
+ }
101
121
  if (!options.dryRun) {
102
122
  operations.push(...openCodeDeleteOperations(existing));
103
123
  for (const entry of entries) {
@@ -122,6 +142,25 @@ export async function runOpenCodeImport(options: OpenCodeImportOptions): Promise
122
142
  lastTime = tail.time_created;
123
143
  lastId = tail.id;
124
144
  }
145
+ // Full-mode reconciliation (R1, task 0504): messages deleted from the OpenCode store
146
+ // leave stale derived rows behind. Sweep ledger/target rows and checkpoints for source
147
+ // files never seen this pass, merged into the SAME source-scoped batch as the writes.
148
+ if (mode === 'full') {
149
+ for (const [sourceFile, entries] of existingBySourceFile) {
150
+ if (seenSourceFiles.has(sourceFile)) continue;
151
+ staleLedgerRows += entries.length;
152
+ staleTargetRows += entries.length;
153
+ staleCheckpointRows += 1;
154
+ if (!options.dryRun) {
155
+ operations.push(...openCodeDeleteOperations(entries));
156
+ operations.push({
157
+ sql: 'DELETE FROM history_import_checkpoint WHERE source = ? AND source_file = ?',
158
+ params: [SOURCE, sourceFile],
159
+ });
160
+ }
161
+ }
162
+ reconciliation = { staleTargetRows, staleLedgerRows, staleCheckpointRows };
163
+ }
125
164
  if (operations.length > 0) await options.db.batch(operations);
126
165
  } finally {
127
166
  sourceDb.close();
@@ -138,6 +177,7 @@ export async function runOpenCodeImport(options: OpenCodeImportOptions): Promise
138
177
  parseErrors,
139
178
  validationErrors,
140
179
  checkpointUpdates,
180
+ reconciliation,
141
181
  };
142
182
  }
143
183
 
package/src/types.ts CHANGED
@@ -101,6 +101,21 @@ export interface ImportIssue {
101
101
  readonly reason: string;
102
102
  }
103
103
 
104
+ /**
105
+ * Source-scoped full-mode reconciliation outcome (task 0504 R1). Computed by diffing the
106
+ * current source's desired record hashes against the persisted ledger; rows no longer
107
+ * reproduced by current source data or mapper output are stale and are removed in one
108
+ * source-scoped batch (dry-run reports the same counts without mutating the database).
109
+ */
110
+ export interface ReconcileSummary {
111
+ /** Rows deleted from typed/ETL target tables (history_message / history_tool_call / history_etl_*). */
112
+ readonly staleTargetRows: number;
113
+ /** Rows deleted from `history_import_ledger`. */
114
+ readonly staleLedgerRows: number;
115
+ /** Checkpoint rows deleted for source files no longer discovered. */
116
+ readonly staleCheckpointRows: number;
117
+ }
118
+
104
119
  /** Summary produced by the importer control function. */
105
120
  export interface ImportResult {
106
121
  /** Source identifier — the built-in name or custom definition name that produced this result. */
@@ -114,4 +129,9 @@ export interface ImportResult {
114
129
  readonly parseErrors: readonly ImportIssue[];
115
130
  readonly validationErrors: readonly ImportIssue[];
116
131
  readonly checkpointUpdates: number;
132
+ /**
133
+ * Full-mode reconciliation counts (only set when `mode === 'full'`). A second full run
134
+ * reports zero stale rows once the database matches the current source (task 0504 R1).
135
+ */
136
+ readonly reconciliation?: ReconcileSummary;
117
137
  }