@gobing-ai/ts-llm-jsonl-importer 0.4.22 → 0.4.23

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/dist/importer.js CHANGED
@@ -180,7 +180,10 @@ function normalizeRecord(definition, raw, context) {
180
180
  }
181
181
  async function discoverFiles(definition, roots, files, fileSystem, paths) {
182
182
  if (files !== undefined && files.length > 0) {
183
- return files.map((file) => resolvePath(file)).sort();
183
+ return files
184
+ .map((file) => resolvePath(file))
185
+ .map((file) => normalizeSourceFilePath(file, fileSystem))
186
+ .sort();
184
187
  }
185
188
  // Root resolution splits by provenance (ADR-023 A1 / task 0042):
186
189
  // - explicit caller-supplied `roots` → cwd semantics: resolve against `paths.cwd` when
@@ -201,16 +204,37 @@ async function discoverFiles(definition, roots, files, fileSystem, paths) {
201
204
  continue;
202
205
  if (stat.isFile()) {
203
206
  if (matchesPattern(root, definition.filePatterns))
204
- found.add(root);
207
+ found.add(normalizeSourceFilePath(root, fileSystem));
205
208
  continue;
206
209
  }
207
210
  for (const file of await walkDir(root, fileSystem)) {
208
211
  if (matchesPattern(file, definition.filePatterns))
209
- found.add(file);
212
+ found.add(normalizeSourceFilePath(file, fileSystem));
210
213
  }
211
214
  }
212
215
  return [...found].sort();
213
216
  }
217
+ /**
218
+ * Normalize a discovered source file path to its canonical real-path identity.
219
+ *
220
+ * WHY: the same physical session file is reachable via a symlinked path and via its
221
+ * real path (every agent history dir under `$HOME` is a symlink into the dotfiles
222
+ * tree here). Without normalization, the checkpoint, ledger, and record_hash keys
223
+ * diverge, yielding duplicate checkpoint rows and silent full-corpus re-imports.
224
+ * `realPath` is optional on `FileSystem`: injected and in-memory test doubles may
225
+ * omit it, and a path that does not exist on disk has no realpath. Fall back to the
226
+ * original path in either case — never throw, never bypass the injected FileSystem.
227
+ */
228
+ function normalizeSourceFilePath(path, fileSystem) {
229
+ if (!fileSystem.realPath)
230
+ return path;
231
+ try {
232
+ return fileSystem.realPath(path);
233
+ }
234
+ catch {
235
+ return path;
236
+ }
237
+ }
214
238
  function matchesPattern(path, patterns) {
215
239
  return patterns.some((pattern) => {
216
240
  if (pattern === '*.jsonl')
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { HistoryImportError } from './errors';
2
2
  export { sha256, stableJson } from './hash';
3
3
  export { runJsonlImport } from './importer';
4
- export { applyHistoryImportSchema } from './jsonl-importer-dao';
4
+ export { applyHistoryImportSchema, normalizeSourceFilePaths } from './jsonl-importer-dao';
5
5
  export { DEFAULT_REDACTION_RULES, redactRecord, redactValue } from './redaction';
6
6
  export { HISTORY_IMPORT_SCHEMA_SQL } from './schema-sql';
7
7
  export { getSourceDefinition, resolveSourceDefinition, SOURCE_DEFINITIONS, VALID_TABLE_NAME, validateSourceDefinition, } from './sources';
@@ -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,MAAM,sBAAsB,CAAC;AAChE,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,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"}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { HistoryImportError } from './errors.js';
2
2
  export { sha256, stableJson } from './hash.js';
3
3
  export { runJsonlImport } from './importer.js';
4
- export { applyHistoryImportSchema } from './jsonl-importer-dao.js';
4
+ export { applyHistoryImportSchema, normalizeSourceFilePaths } from './jsonl-importer-dao.js';
5
5
  export { DEFAULT_REDACTION_RULES, redactRecord, redactValue } from './redaction.js';
6
6
  export { HISTORY_IMPORT_SCHEMA_SQL } from './schema-sql.js';
7
7
  export { getSourceDefinition, resolveSourceDefinition, SOURCE_DEFINITIONS, VALID_TABLE_NAME, validateSourceDefinition, } from './sources.js';
@@ -25,6 +25,27 @@ declare function writeCheckpoint(db: ImportOptions['db'], source: string, source
25
25
  declare function ledgerExists(db: ImportOptions['db'], recordHash: string): Promise<boolean>;
26
26
  declare function insertRecord(db: ImportOptions['db'], targetTable: string, recordHash: string, sourceFile: string, sourceLine: number, splitIndex: number, payload: JsonObject, now: ImportOptions['now']): Promise<void>;
27
27
  declare function insertLedger(db: ImportOptions['db'], recordHash: string, source: string, sourceFile: string, sourceLine: number, splitIndex: number, targetTable: string, now: ImportOptions['now']): Promise<void>;
28
+ /**
29
+ * Idempotent migration that rewrites `source_file` to its realpath across the checkpoint,
30
+ * ledger, and any contract table present (R4).
31
+ *
32
+ * WHY: `record_hash` is `sha256({source, sourceFile, sourceLine, splitIndex, record})`, so
33
+ * `sourceFile` is inside the hash. Normalizing its representation at discovery (R1) changes
34
+ * every future hash; pre-normalization rows carry unnormalized paths and their old hashes
35
+ * remain valid as dedupe keys. This migration rewrites the `source_file` *column* so old and
36
+ * new rows agree on path identity, and collapses duplicate checkpoint rows produced when one
37
+ * physical file was imported via both a symlinked and a real path — keeping the highest
38
+ * `last_imported_line` so an incremental resume does not re-import already-seen content.
39
+ *
40
+ * `record_hash` is intentionally NOT touched: it is path-representation dependent by
41
+ * construction, pre-migration rows are grandfathered, and recomputing it would require the
42
+ * original record payload that the ledger does not store.
43
+ *
44
+ * @param resolveRealPath - resolves a `source_file` to its canonical real path. Falls back to
45
+ * the original value when it returns null/undefined or throws. Decouples the DAO from the
46
+ * runtime `FileSystem` seam.
47
+ */
48
+ export declare function normalizeSourceFilePaths(db: ImportOptions['db'], resolveRealPath: (sourceFile: string) => string | null | undefined): Promise<void>;
28
49
  export type { CheckpointRow };
29
50
  export { ETL_TABLE_DDL, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, resetCheckpoints, targetTableFor, timestamp, writeCheckpoint, };
30
51
  //# 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":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAE3E,UAAU,aAAa;IACnB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACvC;AAqDD,iBAAS,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAK7C;AAED,iBAAS,SAAS,CAAC,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAAG,MAAM,CAEpD;AAED,iBAAS,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS5C;AAED,0DAA0D;AAC1D,wBAAsB,wBAAwB,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAOrF;AAED;;;;;;;;;;GAUG;AACH,iBAAe,kBAAkB,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtG;AAED,iBAAe,cAAc,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO1G;AAED,iBAAe,gBAAgB,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhH;AAED,iBAAe,eAAe,CAC1B,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EACvB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CAYf;AAED,iBAAe,YAAY,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMzF;AAID,iBAAe,YAAY,CACvB,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EACvB,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CAiDf;AAED,iBAAe,YAAY,CACvB,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CAYf;AAED,YAAY,EAAE,aAAa,EAAE,CAAC;AAC9B,OAAO,EACH,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,cAAc,EACd,SAAS,EACT,eAAe,GAClB,CAAC"}
1
+ {"version":3,"file":"jsonl-importer-dao.d.ts","sourceRoot":"","sources":["../src/jsonl-importer-dao.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAE3E,UAAU,aAAa;IACnB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACvC;AAqDD,iBAAS,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAK7C;AAED,iBAAS,SAAS,CAAC,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAAG,MAAM,CAEpD;AAED,iBAAS,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS5C;AAED,0DAA0D;AAC1D,wBAAsB,wBAAwB,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAOrF;AAED;;;;;;;;;;GAUG;AACH,iBAAe,kBAAkB,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtG;AAED,iBAAe,cAAc,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO1G;AAED,iBAAe,gBAAgB,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhH;AAED,iBAAe,eAAe,CAC1B,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EACvB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CAYf;AAED,iBAAe,YAAY,CAAC,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMzF;AAID,iBAAe,YAAY,CACvB,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EACvB,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CAiDf;AAED,iBAAe,YAAY,CACvB,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,aAAa,CAAC,KAAK,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CAYf;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,wBAAwB,CAC1C,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,EACvB,eAAe,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,GACnE,OAAO,CAAC,IAAI,CAAC,CAWf;AA6FD,YAAY,EAAE,aAAa,EAAE,CAAC;AAC9B,OAAO,EACH,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,cAAc,EACd,SAAS,EACT,eAAe,GAClB,CAAC"}
@@ -165,4 +165,91 @@ async function insertLedger(db, recordHash, source, sourceFile, sourceLine, spli
165
165
  await db.run(`INSERT INTO history_import_ledger (record_hash, source, source_file, source_line, split_index, target_table, imported_at)
166
166
  VALUES (?, ?, ?, ?, ?, ?, ?)`, recordHash, source, sourceFile, sourceLine, splitIndex, targetTable, timestamp(now));
167
167
  }
168
+ /**
169
+ * Idempotent migration that rewrites `source_file` to its realpath across the checkpoint,
170
+ * ledger, and any contract table present (R4).
171
+ *
172
+ * WHY: `record_hash` is `sha256({source, sourceFile, sourceLine, splitIndex, record})`, so
173
+ * `sourceFile` is inside the hash. Normalizing its representation at discovery (R1) changes
174
+ * every future hash; pre-normalization rows carry unnormalized paths and their old hashes
175
+ * remain valid as dedupe keys. This migration rewrites the `source_file` *column* so old and
176
+ * new rows agree on path identity, and collapses duplicate checkpoint rows produced when one
177
+ * physical file was imported via both a symlinked and a real path — keeping the highest
178
+ * `last_imported_line` so an incremental resume does not re-import already-seen content.
179
+ *
180
+ * `record_hash` is intentionally NOT touched: it is path-representation dependent by
181
+ * construction, pre-migration rows are grandfathered, and recomputing it would require the
182
+ * original record payload that the ledger does not store.
183
+ *
184
+ * @param resolveRealPath - resolves a `source_file` to its canonical real path. Falls back to
185
+ * the original value when it returns null/undefined or throws. Decouples the DAO from the
186
+ * runtime `FileSystem` seam.
187
+ */
188
+ export async function normalizeSourceFilePaths(db, resolveRealPath) {
189
+ await normalizeCheckpointPaths(db, resolveRealPath);
190
+ await normalizeColumnPaths(db, resolveRealPath, 'history_import_ledger');
191
+ for (const table of TYPED_TABLE_COLUMNS_SOURCE_FILE) {
192
+ if (await tableExists(db, table)) {
193
+ await normalizeColumnPaths(db, resolveRealPath, table);
194
+ }
195
+ }
196
+ for (const table of await listEtlTables(db)) {
197
+ await normalizeColumnPaths(db, resolveRealPath, table);
198
+ }
199
+ }
200
+ /** Typed contract tables that carry a `source_file` column (0466 forensic contract). */
201
+ const TYPED_TABLE_COLUMNS_SOURCE_FILE = ['history_message', 'history_tool_call'];
202
+ async function tableExists(db, table) {
203
+ const row = await db.queryFirst("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table);
204
+ return row !== undefined && row !== null;
205
+ }
206
+ async function listEtlTables(db) {
207
+ const rows = await db.queryAll("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'history_etl_%'");
208
+ return rows.map((row) => row.name);
209
+ }
210
+ /**
211
+ * Normalize `source_file` in the checkpoint table, collapsing duplicate rows per
212
+ * `(source, realpath)` by keeping the highest `last_imported_line`.
213
+ */
214
+ async function normalizeCheckpointPaths(db, resolveRealPath) {
215
+ const rows = await db.queryAll('SELECT source, source_file, last_imported_line, updated_at FROM history_import_checkpoint');
216
+ // Collapse per (source, realPath) keeping the highest last_imported_line.
217
+ const collapsed = new Map();
218
+ for (const row of rows) {
219
+ const canonical = resolveWithFallback(resolveRealPath, row.source_file);
220
+ const key = `${row.source}\u0000${canonical}`;
221
+ const existing = collapsed.get(key);
222
+ if (existing === undefined || row.last_imported_line > existing.last_imported_line) {
223
+ collapsed.set(key, {
224
+ source: row.source,
225
+ source_file: canonical,
226
+ last_imported_line: row.last_imported_line,
227
+ updated_at: row.updated_at,
228
+ });
229
+ }
230
+ }
231
+ await db.exec('DELETE FROM history_import_checkpoint');
232
+ for (const row of collapsed.values()) {
233
+ await db.run(`INSERT INTO history_import_checkpoint (source, source_file, last_imported_line, updated_at)
234
+ VALUES (?, ?, ?, ?)`, row.source, row.source_file, row.last_imported_line, row.updated_at);
235
+ }
236
+ }
237
+ /** Normalize `source_file` in a table that stores it as a plain column. */
238
+ async function normalizeColumnPaths(db, resolveRealPath, table) {
239
+ const rows = await db.queryAll(`SELECT rowid AS rowid, source_file FROM ${table} WHERE source_file IS NOT NULL`);
240
+ for (const row of rows) {
241
+ const canonical = resolveWithFallback(resolveRealPath, row.source_file);
242
+ if (canonical !== row.source_file) {
243
+ await db.run(`UPDATE ${table} SET source_file = ? WHERE rowid = ?`, canonical, row.rowid);
244
+ }
245
+ }
246
+ }
247
+ function resolveWithFallback(resolveRealPath, sourceFile) {
248
+ try {
249
+ return resolveRealPath(sourceFile) ?? sourceFile;
250
+ }
251
+ catch {
252
+ return sourceFile;
253
+ }
254
+ }
168
255
  export { ETL_TABLE_DDL, ensureTargetTables, insertLedger, insertRecord, ledgerExists, readCheckpoint, resetCheckpoints, targetTableFor, timestamp, writeCheckpoint, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-llm-jsonl-importer",
3
- "version": "0.4.22",
3
+ "version": "0.4.23",
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.22",
51
- "@gobing-ai/ts-runtime": "^0.4.22",
52
- "@gobing-ai/ts-utils": "^0.4.22",
50
+ "@gobing-ai/ts-db": "^0.4.23",
51
+ "@gobing-ai/ts-runtime": "^0.4.23",
52
+ "@gobing-ai/ts-utils": "^0.4.23",
53
53
  "zod": "^4.1.0"
54
54
  },
55
55
  "devDependencies": {
package/src/importer.ts CHANGED
@@ -251,7 +251,10 @@ async function discoverFiles(
251
251
  paths: RuntimePaths | undefined,
252
252
  ): Promise<readonly string[]> {
253
253
  if (files !== undefined && files.length > 0) {
254
- return files.map((file) => resolvePath(file)).sort();
254
+ return files
255
+ .map((file) => resolvePath(file))
256
+ .map((file) => normalizeSourceFilePath(file, fileSystem))
257
+ .sort();
255
258
  }
256
259
 
257
260
  // Root resolution splits by provenance (ADR-023 A1 / task 0042):
@@ -271,16 +274,36 @@ async function discoverFiles(
271
274
  const stat = await fileSystem.stat(root);
272
275
  if (stat === null) continue;
273
276
  if (stat.isFile()) {
274
- if (matchesPattern(root, definition.filePatterns)) found.add(root);
277
+ if (matchesPattern(root, definition.filePatterns)) found.add(normalizeSourceFilePath(root, fileSystem));
275
278
  continue;
276
279
  }
277
280
  for (const file of await walkDir(root, fileSystem)) {
278
- if (matchesPattern(file, definition.filePatterns)) found.add(file);
281
+ if (matchesPattern(file, definition.filePatterns)) found.add(normalizeSourceFilePath(file, fileSystem));
279
282
  }
280
283
  }
281
284
  return [...found].sort();
282
285
  }
283
286
 
287
+ /**
288
+ * Normalize a discovered source file path to its canonical real-path identity.
289
+ *
290
+ * WHY: the same physical session file is reachable via a symlinked path and via its
291
+ * real path (every agent history dir under `$HOME` is a symlink into the dotfiles
292
+ * tree here). Without normalization, the checkpoint, ledger, and record_hash keys
293
+ * diverge, yielding duplicate checkpoint rows and silent full-corpus re-imports.
294
+ * `realPath` is optional on `FileSystem`: injected and in-memory test doubles may
295
+ * omit it, and a path that does not exist on disk has no realpath. Fall back to the
296
+ * original path in either case — never throw, never bypass the injected FileSystem.
297
+ */
298
+ function normalizeSourceFilePath(path: string, fileSystem: FileSystem): string {
299
+ if (!fileSystem.realPath) return path;
300
+ try {
301
+ return fileSystem.realPath(path);
302
+ } catch {
303
+ return path;
304
+ }
305
+ }
306
+
284
307
  function matchesPattern(path: string, patterns: readonly string[]): boolean {
285
308
  return patterns.some((pattern) => {
286
309
  if (pattern === '*.jsonl') return path.endsWith('.jsonl');
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { HistoryImportError } from './errors';
2
2
  export { sha256, stableJson } from './hash';
3
3
  export { runJsonlImport } from './importer';
4
- export { applyHistoryImportSchema } from './jsonl-importer-dao';
4
+ export { applyHistoryImportSchema, normalizeSourceFilePaths } from './jsonl-importer-dao';
5
5
  export { DEFAULT_REDACTION_RULES, redactRecord, redactValue } from './redaction';
6
6
  export { HISTORY_IMPORT_SCHEMA_SQL } from './schema-sql';
7
7
  export {
@@ -241,6 +241,133 @@ async function insertLedger(
241
241
  );
242
242
  }
243
243
 
244
+ /**
245
+ * Idempotent migration that rewrites `source_file` to its realpath across the checkpoint,
246
+ * ledger, and any contract table present (R4).
247
+ *
248
+ * WHY: `record_hash` is `sha256({source, sourceFile, sourceLine, splitIndex, record})`, so
249
+ * `sourceFile` is inside the hash. Normalizing its representation at discovery (R1) changes
250
+ * every future hash; pre-normalization rows carry unnormalized paths and their old hashes
251
+ * remain valid as dedupe keys. This migration rewrites the `source_file` *column* so old and
252
+ * new rows agree on path identity, and collapses duplicate checkpoint rows produced when one
253
+ * physical file was imported via both a symlinked and a real path — keeping the highest
254
+ * `last_imported_line` so an incremental resume does not re-import already-seen content.
255
+ *
256
+ * `record_hash` is intentionally NOT touched: it is path-representation dependent by
257
+ * construction, pre-migration rows are grandfathered, and recomputing it would require the
258
+ * original record payload that the ledger does not store.
259
+ *
260
+ * @param resolveRealPath - resolves a `source_file` to its canonical real path. Falls back to
261
+ * the original value when it returns null/undefined or throws. Decouples the DAO from the
262
+ * runtime `FileSystem` seam.
263
+ */
264
+ export async function normalizeSourceFilePaths(
265
+ db: ImportOptions['db'],
266
+ resolveRealPath: (sourceFile: string) => string | null | undefined,
267
+ ): Promise<void> {
268
+ await normalizeCheckpointPaths(db, resolveRealPath);
269
+ await normalizeColumnPaths(db, resolveRealPath, 'history_import_ledger');
270
+ for (const table of TYPED_TABLE_COLUMNS_SOURCE_FILE) {
271
+ if (await tableExists(db, table)) {
272
+ await normalizeColumnPaths(db, resolveRealPath, table);
273
+ }
274
+ }
275
+ for (const table of await listEtlTables(db)) {
276
+ await normalizeColumnPaths(db, resolveRealPath, table);
277
+ }
278
+ }
279
+
280
+ /** Typed contract tables that carry a `source_file` column (0466 forensic contract). */
281
+ const TYPED_TABLE_COLUMNS_SOURCE_FILE = ['history_message', 'history_tool_call'] as const;
282
+
283
+ async function tableExists(db: ImportOptions['db'], table: string): Promise<boolean> {
284
+ const row = await db.queryFirst<{ name: string }>(
285
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
286
+ table,
287
+ );
288
+ return row !== undefined && row !== null;
289
+ }
290
+
291
+ async function listEtlTables(db: ImportOptions['db']): Promise<string[]> {
292
+ const rows = await db.queryAll<{ name: string }>(
293
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'history_etl_%'",
294
+ );
295
+ return rows.map((row) => row.name);
296
+ }
297
+
298
+ /**
299
+ * Normalize `source_file` in the checkpoint table, collapsing duplicate rows per
300
+ * `(source, realpath)` by keeping the highest `last_imported_line`.
301
+ */
302
+ async function normalizeCheckpointPaths(
303
+ db: ImportOptions['db'],
304
+ resolveRealPath: (sourceFile: string) => string | null | undefined,
305
+ ): Promise<void> {
306
+ const rows = await db.queryAll<{
307
+ source: string;
308
+ source_file: string;
309
+ last_imported_line: number;
310
+ updated_at: string;
311
+ }>('SELECT source, source_file, last_imported_line, updated_at FROM history_import_checkpoint');
312
+ // Collapse per (source, realPath) keeping the highest last_imported_line.
313
+ const collapsed = new Map<
314
+ string,
315
+ { source: string; source_file: string; last_imported_line: number; updated_at: string }
316
+ >();
317
+ for (const row of rows) {
318
+ const canonical = resolveWithFallback(resolveRealPath, row.source_file);
319
+ const key = `${row.source}\u0000${canonical}`;
320
+ const existing = collapsed.get(key);
321
+ if (existing === undefined || row.last_imported_line > existing.last_imported_line) {
322
+ collapsed.set(key, {
323
+ source: row.source,
324
+ source_file: canonical,
325
+ last_imported_line: row.last_imported_line,
326
+ updated_at: row.updated_at,
327
+ });
328
+ }
329
+ }
330
+ await db.exec('DELETE FROM history_import_checkpoint');
331
+ for (const row of collapsed.values()) {
332
+ await db.run(
333
+ `INSERT INTO history_import_checkpoint (source, source_file, last_imported_line, updated_at)
334
+ VALUES (?, ?, ?, ?)`,
335
+ row.source,
336
+ row.source_file,
337
+ row.last_imported_line,
338
+ row.updated_at,
339
+ );
340
+ }
341
+ }
342
+
343
+ /** Normalize `source_file` in a table that stores it as a plain column. */
344
+ async function normalizeColumnPaths(
345
+ db: ImportOptions['db'],
346
+ resolveRealPath: (sourceFile: string) => string | null | undefined,
347
+ table: string,
348
+ ): Promise<void> {
349
+ const rows = await db.queryAll<{ rowid: number; source_file: string }>(
350
+ `SELECT rowid AS rowid, source_file FROM ${table} WHERE source_file IS NOT NULL`,
351
+ );
352
+ for (const row of rows) {
353
+ const canonical = resolveWithFallback(resolveRealPath, row.source_file);
354
+ if (canonical !== row.source_file) {
355
+ await db.run(`UPDATE ${table} SET source_file = ? WHERE rowid = ?`, canonical, row.rowid);
356
+ }
357
+ }
358
+ }
359
+
360
+ function resolveWithFallback(
361
+ resolveRealPath: (sourceFile: string) => string | null | undefined,
362
+ sourceFile: string,
363
+ ): string {
364
+ try {
365
+ return resolveRealPath(sourceFile) ?? sourceFile;
366
+ } catch {
367
+ return sourceFile;
368
+ }
369
+ }
370
+
244
371
  export type { CheckpointRow };
245
372
  export {
246
373
  ETL_TABLE_DDL,