claude-mem-lite 6.7.2 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.7.2",
12
+ "version": "6.8.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.7.2",
3
+ "version": "6.8.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -236,6 +236,31 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
236
236
  ```
237
237
 
238
238
  <!-- normalize-per-project-note:start -->
239
+ ## Upgrading to 6.8.0
240
+
241
+ **Two things change on upgrade. Neither needs an action from you, and neither is a schema
242
+ change — an older build can still open the database.**
243
+
244
+ *The first open backfills the file-lookup table, once.* Observations imported from a
245
+ transcript before 6.7.2 carry their modified-files list but no row in the junction table the
246
+ file-recall paths join, so asking about a file never found them. The repair was gated on that
247
+ table being completely empty, which one ordinary `mem_save` falsifies forever. It now runs
248
+ once per database, keyed on its own marker, and retries on a later open if it fails. A store
249
+ that never ran `import-jsonl` matches no rows and pays nothing.
250
+
251
+ Those rows become reachable through `recall` / `mem_recall` and the prompt-submit path. They
252
+ do **not** become reachable through the pre-tool recall hook, which admits only
253
+ `importance >= 2` and every imported row carries `1`.
254
+
255
+ *`doctor --json` changes shape.* Checks that print a repair command now carry it in a
256
+ `details` array — previously the human screen got the command and the JSON got only the
257
+ diagnosis. And four checks (dev drift, managed files, and both hook-script branches) now
258
+ report `"level": "fail"` where they used to report `"warn"`, with a new `"glyph": "warn"`
259
+ recording that they still render as ⚠ rather than ✗. They always counted toward the issue
260
+ total and the exit code; the level now says so. If you filter on `level === "fail"` you will
261
+ see four findings you were missing. The `issues` count, the summary line and the exit code
262
+ are unchanged.
263
+
239
264
  ## Upgrading to 6.1.0
240
265
 
241
266
  **One default changes, and only for the daily background pass.** Until 6.1.0 the unattended
package/README.zh-CN.md CHANGED
@@ -199,6 +199,24 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  ```
200
200
 
201
201
  <!-- normalize-per-project-note:start -->
202
+ ## 升级到 6.8.0
203
+
204
+ **升级后有两处变化,都不需要你做什么,也都不是 schema 变更——旧版本仍能打开这个数据库。**
205
+
206
+ *第一次开库会做一次性回填。* 6.7.2 之前从 transcript 导入的观察,带着「修改过的文件」列表,
207
+ 却没有在文件召回所 JOIN 的联结表里留下行,所以按文件去问永远找不到它们。原来的修复门是
208
+ 「这张表完全为空」——任何一次正常的 `mem_save` 都会让它永久为假。现在它按自己的标记每个库跑
209
+ 一次,失败则下次开库重试。从没跑过 `import-jsonl` 的库匹配不到任何行,代价为零。
210
+
211
+ 这些行会经 `recall` / `mem_recall` 和 UserPromptSubmit 这条路变得可达;**不会**经 pre-tool
212
+ 召回钩子变得可达——那条腿只收 `importance >= 2`,而导入的行一律是 `1`。
213
+
214
+ *`doctor --json` 的输出形状变了。* 带修复命令的检查项现在把命令放在 `details` 数组里——此前
215
+ 人类界面拿到命令、JSON 界面只拿到诊断。另外四个检查项(dev drift、managed files、hook 脚本的
216
+ 两条)现在报 `"level": "fail"`,此前报 `"warn"`,并新增 `"glyph": "warn"` 记录它们在屏幕上
217
+ 仍渲染为 ⚠ 而非 ✗。它们一直都计入 issue 总数和退出码,现在 level 与之一致。如果你按
218
+ `level === "fail"` 过滤,会多看到四条此前漏掉的发现。`issues` 计数、摘要行和退出码不变。
219
+
202
220
  ## 升级到 6.1.0
203
221
 
204
222
  **只有一个默认行为改变,且只影响每日后台任务。** 6.1.0 之前,无人值守的 `normalize` 会一次性
package/install.mjs CHANGED
@@ -1715,8 +1715,26 @@ async function doctor() {
1715
1715
  checks.push({ level: 'fail', message: msg });
1716
1716
  if (!json) console.log(` ✗ ${msg}`);
1717
1717
  };
1718
+ // Detail / remedy lines. These were `--json`'s blind spot: `log()` was a no-op under
1719
+ // --json and EVERY repair instruction doctor gives goes through it, so the machine face
1720
+ // received the diagnosis and none of the treatment (R12 audit P2-3). 7 of its 16 call
1721
+ // sites carry a runnable command; the other 9 are paths, notes and prose. They attach to
1722
+ // the check they follow, not to a report-level bucket, because the stated purpose of
1723
+ // --json is acting on an individual check.
1724
+ //
1725
+ // The count was first written here as "12 of 14" and both halves were wrong — pre-ship
1726
+ // review recounted. 16 is also the count at the previous release, so no edit of this
1727
+ // round moved it.
1718
1728
  const log = (msg) => {
1719
- if (!json) console.log(` ${msg}`);
1729
+ if (!json) {
1730
+ console.log(` ${msg}`);
1731
+ return;
1732
+ }
1733
+ const last = checks[checks.length - 1];
1734
+ // A detail before any check has nothing to attach to — same as today's drop, but the
1735
+ // human face would show it, so this is the one line the two faces cannot share.
1736
+ if (!last) return;
1737
+ (last.details ??= []).push(msg.trim());
1720
1738
  };
1721
1739
 
1722
1740
  let issues = 0;
@@ -1730,6 +1748,28 @@ async function doctor() {
1730
1748
  warnings++;
1731
1749
  warn(msg);
1732
1750
  };
1751
+ // The fourth cell of the matrix, and the one that had no home: findings rendered at ⚠
1752
+ // severity that nevertheless REQUIRE action. Four checks used to spell it `warn(...)`
1753
+ // followed by `issues++`, which made `issues` count rows reporting `level:'warn'` — so
1754
+ // `checks.filter(c => c.level === 'fail')`, the exact use --json documents, under-reported
1755
+ // by four (R12 audit P2-4). Severity and glyph are separate facts: `level` is what the
1756
+ // counter and the exit code are derived from, `glyph` is how loud the screen is.
1757
+ //
1758
+ // The first draft of this comment justified the split by claiming that promoting these
1759
+ // four to `fail()` would break `doctor-missing-files-severity` and
1760
+ // `doctor-hook-script-manifest`. Pre-ship review measured it: rendering them as ✗ with
1761
+ // `level:'fail'` and no glyph leaves both files 13/13 GREEN, because each asserts
1762
+ // `level === 'warn' || level === 'fail'` — a disjunction over the only two values those
1763
+ // checks can carry, so it cannot say NO about a severity change in either direction.
1764
+ // The real reason is their stated INTENT (an unlinked-but-reachable module must not read
1765
+ // as an error), which nothing enforced until this round's `doctor-failure-branches` pin.
1766
+ // Self-counting like `dwarn`, so neither source scan in doctor-summary.test.mjs needs to
1767
+ // reach inside it; the end-to-end counter check in doctor-json-face-parity.test.mjs does.
1768
+ const issueWarn = (msg) => {
1769
+ issues++;
1770
+ checks.push({ level: 'fail', glyph: 'warn', message: msg });
1771
+ if (!json) console.log(` ⚠ ${msg}`);
1772
+ };
1733
1773
 
1734
1774
  // Node version. The floor is read from package.json#engines rather than restated here —
1735
1775
  // see requiredNodeMajor. It is PRINTED on the ok line too, so the guard test has something
@@ -1992,8 +2032,13 @@ async function doctor() {
1992
2032
  `Disk footprint: DB ${mb(dbBytes)}MB, ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB (budget ${mb(backupBudgetBytes())}MB)`,
1993
2033
  );
1994
2034
  }
1995
- } catch {
1996
- /* footprint check is informational never block doctor */
2035
+ } catch (e) {
2036
+ // "Informational" was the reason this was silent, and silence is the one thing it must
2037
+ // not be: the import above is of a sibling module, so the run where it fails is a broken
2038
+ // install — doctor's entire audience. The line vanished with no trace, which reads
2039
+ // identically to a check that was never written (R12 audit P3-7). Every other "I could
2040
+ // not look" in this file has its own sentence; this is the same shape as the four.
2041
+ dwarn('Disk footprint: check failed — ' + e.message);
1997
2042
  }
1998
2043
 
1999
2044
  // Plugin/hook lifecycle state
@@ -2354,8 +2399,7 @@ async function doctor() {
2354
2399
  `${r.missingModuleCount} missing module: ${nameList(r.missingModuleFiles, r.missingModuleCount)}`,
2355
2400
  );
2356
2401
  }
2357
- warn(`Dev drift: ${parts.join('; ')} (${devRemedy})`);
2358
- issues++;
2402
+ issueWarn(`Dev drift: ${parts.join('; ')} (${devRemedy})`);
2359
2403
  } else if (r.missingModuleCount > 0) {
2360
2404
  // Informational, NOT an issue: in a pure-symlink install every entry point resolves
2361
2405
  // to the repo, and Node resolves each module's imports against that REALPATH — so an
@@ -2391,12 +2435,11 @@ async function doctor() {
2391
2435
  // `claude-mem-lite update` is the observation editor (`update <id>`); the
2392
2436
  // self-updater is `self-update`. Naming the wrong one sent the user to a
2393
2437
  // usage error at the exact moment their install was incomplete.
2394
- warn(
2438
+ issueWarn(
2395
2439
  `Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves ` +
2396
2440
  `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update ` +
2397
2441
  `(or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`,
2398
2442
  );
2399
- issues++;
2400
2443
  }
2401
2444
  // Complete copy install: no message — drift is a dev-install concern.
2402
2445
  } catch (e) {
@@ -2423,12 +2466,11 @@ async function doctor() {
2423
2466
  if (skipScripts) {
2424
2467
  ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
2425
2468
  } else if (!h.present) {
2426
- warn(
2469
+ issueWarn(
2427
2470
  `Hook scripts: ${join(INSTALL_DIR, 'scripts')} ` +
2428
2471
  `${h.dirSymlink ? 'is a dangling symlink' : 'is absent'} — all ${HOOK_SCRIPT_ENTRY_POINTS.size} hook ` +
2429
2472
  `commands name absolute paths under it, so no hook can fire. Fix: ${scriptRemedy}`,
2430
2473
  );
2431
- issues++;
2432
2474
  } else if (h.missingCount > 0) {
2433
2475
  const parts = [];
2434
2476
  if (h.missingEntryFiles.length > 0) {
@@ -2441,8 +2483,7 @@ async function doctor() {
2441
2483
  `${h.missingModuleFiles.length} imported helper (${h.missingModuleFiles.join(', ')}) — ERR_MODULE_NOT_FOUND at hook time`,
2442
2484
  );
2443
2485
  }
2444
- warn(`Hook scripts: ${h.missingCount} missing — ${parts.join('; ')}. Fix: ${scriptRemedy}`);
2445
- issues++;
2486
+ issueWarn(`Hook scripts: ${h.missingCount} missing — ${parts.join('; ')}. Fix: ${scriptRemedy}`);
2446
2487
  } else {
2447
2488
  ok(
2448
2489
  `Hook scripts: ${HOOK_SCRIPT_FILES.length} present ` +
@@ -2608,7 +2649,12 @@ async function doctor() {
2608
2649
  } else {
2609
2650
  ok(`Plugin cache: ${versions.length} version(s) (${sizeStr})`);
2610
2651
  }
2611
- } catch {}
2652
+ } catch (e) {
2653
+ // Was empty to the point of carrying no comment. `existsSync` already said the path is
2654
+ // there, so reaching here means it is unreadable or not a directory — a fact about the
2655
+ // plugin install worth one line (R12 audit P3-7).
2656
+ dwarn(`Plugin cache: could not read ${pluginCacheBase} — ${e.message}`);
2657
+ }
2612
2658
  }
2613
2659
 
2614
2660
  if (json) {
@@ -36,6 +36,27 @@ const TOOL_TO_TYPE = {
36
36
  WebSearch: 'discovery',
37
37
  };
38
38
 
39
+ /**
40
+ * The title an imported tool-use carries — and, byte for byte, the cross-run dedup key.
41
+ *
42
+ * Two call sites need this string: `importToolPair` stores it, and `tryImportToolPair`
43
+ * synthesizes it to ask whether a previous run already stored this row. They were separate
44
+ * expressions, and only one of them was SCRUBBED — so any transcript whose title holds
45
+ * something the scrubber rewrites (a bearer token in a `Bash` command, a path segment shaped
46
+ * like a key) never matched itself and re-imported on every run (R12 pre-ship review P3-4).
47
+ *
48
+ * This returns the RAW title and both sites scrub it through `scrubRecord` exactly once.
49
+ * The first cut scrubbed HERE, which left the storage side scrubbed twice (`scrubRecord`
50
+ * scrubs `title` again) against the preview's once — equal only while `scrubSecrets` is
51
+ * idempotent, which is an assumption about the pattern table, not a property anything
52
+ * enforces. Pre-ship review named the asymmetry; one scrub per side removes the assumption
53
+ * rather than betting on it.
54
+ */
55
+ function importedObsTitle(toolUse) {
56
+ const detail = toolUse?.input?.command || toolEditPath(toolUse?.input) || '';
57
+ return `${toolUse?.name || 'unknown'}: ${String(detail).slice(0, 80)}`;
58
+ }
59
+
39
60
  function dedupKey(parts) {
40
61
  return createHash('sha256').update(parts.join('\x1f')).digest('hex');
41
62
  }
@@ -141,7 +162,11 @@ function importToolPair(db, toolUse, toolResult, project) {
141
162
  (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') && editedPath
142
163
  ? [editedPath]
143
164
  : [];
144
- const filesRead = toolName === 'Read' && editedPath ? [editedPath] : [];
165
+ // `file_path`, not the shared `editedPath`: `toolEditPath` answers "which path did this
166
+ // tool WRITE", and falls back to `notebook_path` for NotebookEdit's sake. Routing the read
167
+ // column through it too gave a `Read` carrying only `notebook_path` a files_read entry —
168
+ // a shape no tool emits, and a behaviour change D#35 made without declaring it (P3-5).
169
+ const filesRead = toolName === 'Read' && toolUse.input?.file_path ? [toolUse.input.file_path] : [];
145
170
 
146
171
  // `narrative` carries the body and `text` is the derived search blob
147
172
  // (lib/observation-write.mjs rebuildObservationDerived). Writing the payload to `text`
@@ -151,18 +176,17 @@ function importToolPair(db, toolUse, toolResult, project) {
151
176
  // write directly.
152
177
  const body = `${inputJson}\n---\n${resultText}`;
153
178
  const safe = scrubRecord('observations', {
154
- // This string IS the cross-run dedup key: tryImportToolPair synthesizes it
155
- // byte-for-byte from the same expression to decide whether a row was already
156
- // imported, so the two sites must be widened TOGETHER or every import
157
- // duplicates. An earlier cut of this round kept the title on `file_path`
158
- // alone to avoid the migration and shipped `NotebookEdit: ` with an empty
159
- // label for exactly the rows D#35 had just made reachable. Pre-ship review:
160
- // "the surface the round unblocked renders a row that identifies nothing."
179
+ // This string IS the cross-run dedup key `importedObsTitle` is the single home for
180
+ // it, because the two sites that must agree byte-for-byte did not. An earlier cut of
181
+ // this round kept the title on `file_path` alone to avoid the migration — and shipped
182
+ // `NotebookEdit: ` with an empty label for exactly the rows D#35 had just made
183
+ // reachable. Pre-ship review: "the surface the round unblocked renders a row that
184
+ // identifies nothing."
161
185
  //
162
186
  // The cost is real and bounded: NotebookEdit rows imported before v6.7.2
163
187
  // carry the old key, so the next import re-adds each of them ONCE and then
164
188
  // matches forever. Stated in the CHANGELOG rather than absorbed silently.
165
- title: `${toolName}: ${(toolUse.input?.command || toolEditPath(toolUse.input) || '').slice(0, 80)}`,
189
+ title: importedObsTitle(toolUse),
166
190
  subtitle: '',
167
191
  text: body,
168
192
  narrative: body,
@@ -292,10 +316,10 @@ export async function importJsonl(db, path, { project }) {
292
316
  if (seenToolUseIds.has(callKey)) return false;
293
317
  seenToolUseIds.add(callKey);
294
318
 
295
- // Cross-call dedup: synthesize the title the previous run would have
296
- // written and check the seenObs set seeded from the DB.
297
- const toolName = useEv.name || 'unknown';
298
- const titlePreview = `${toolName}: ${(useEv.input?.command || toolEditPath(useEv.input) || '').slice(0, 80)}`;
319
+ // Cross-call dedup: synthesize the title the previous run would have written and check
320
+ // the seenObs set seeded from the DB. Same raw expression AND the same single scrub as
321
+ // the storage site, so the scrubber cannot rewrite one and not the other.
322
+ const titlePreview = scrubRecord('observations', { title: importedObsTitle(useEv) }).title;
299
323
  const ts = useEv.timestamp || new Date().toISOString();
300
324
  // Match the storage convention from importToolPair (memId-prefixed) so
301
325
  // the seenObs entries seeded from the DB can be matched on a re-run.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.7.2",
3
+ "version": "6.8.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.7.2",
9
+ "version": "6.8.0",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.7.2",
3
+ "version": "6.8.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -831,42 +831,9 @@ export function initSchema(db) {
831
831
  }
832
832
  }
833
833
 
834
- // Data migration: populate observation_files from existing observations.files_modified JSON
835
- // Only runs once: when observation_files is empty but observations has rows with files_modified
836
- try {
837
- const obsFilesCount = db.prepare('SELECT COUNT(*) as c FROM observation_files').get().c;
838
- if (obsFilesCount === 0) {
839
- const obsWithFiles = db
840
- .prepare(
841
- `SELECT id, files_modified FROM observations WHERE files_modified IS NOT NULL AND files_modified != '[]'`,
842
- )
843
- .all();
844
- if (obsWithFiles.length > 0) {
845
- const migrateFiles = db.transaction(() => {
846
- const insertFile = db.prepare(
847
- 'INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)',
848
- );
849
- for (const row of obsWithFiles) {
850
- try {
851
- const files = JSON.parse(row.files_modified);
852
- if (Array.isArray(files)) {
853
- for (const f of files) {
854
- if (typeof f === 'string' && f.length > 0) {
855
- insertFile.run(row.id, f);
856
- }
857
- }
858
- }
859
- } catch {
860
- /* skip malformed JSON */
861
- }
862
- }
863
- });
864
- migrateFiles();
865
- }
866
- }
867
- } catch {
868
- /* non-critical — migration can retry on next open */
869
- }
834
+ // The files_modified -> observation_files backfill moved to runDeferredCleanups()
835
+ // (R12 pre-ship review P3-3). It used to live here gated on `COUNT(*) FROM
836
+ // observation_files === 0`, which is not the question: see DEFERRED_CLEANUPS.
870
837
 
871
838
  // observation_files orphan cleanup moved to runDeferredCleanups() (audit P1-5):
872
839
  // it now runs retryably outside the version fast-path. See DEFERRED_CLEANUPS.
@@ -1076,6 +1043,59 @@ const DEFERRED_CLEANUPS = [
1076
1043
  run: (db) =>
1077
1044
  db.prepare(`DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`).run(),
1078
1045
  },
1046
+ {
1047
+ // Backfill the junction from the files_modified JSON column. This ran inside initSchema
1048
+ // for a long time, gated on `COUNT(*) FROM observation_files === 0` — which answers "has
1049
+ // this store ever written an edge", not "has this backfill run". One real `mem_save`
1050
+ // falsifies it forever, so every observation imported before v6.7.2 (the release that
1051
+ // taught import-jsonl to write edges at all) stayed permanently unreachable by file, and
1052
+ // re-importing could not repair them: cross-run dedup skips the row before the edge
1053
+ // write. The marker here answers the question actually being asked, and an exception
1054
+ // leaves it unset so the next open retries.
1055
+ //
1056
+ // Deliberately NOT a schema-version bump. A bump locks every older code home out of the
1057
+ // database permanently (see lib/schema-skew.mjs), and on a plugin install that is
1058
+ // reached routinely — far too much to charge for a derived table.
1059
+ //
1060
+ // One-shot by design: every live path that stores an observation routes its edges
1061
+ // through the single junction writer (`insertObservationFiles`, lib/observation-write),
1062
+ // so rows arriving after this pass need nothing from it. An earlier draft of this line
1063
+ // said "the import and save paths both" — there are more than two entry points reaching
1064
+ // that one writer (save, episode flush, insight promotion, restore, import), and the
1065
+ // claim that matters is the single writer, not the count of callers.
1066
+ // `NOT EXISTS` keeps the scan to the rows that are actually missing an edge, which is
1067
+ // zero on a store that never imported.
1068
+ name: 'backfill-observation-files',
1069
+ run: (db) => {
1070
+ const rows = db
1071
+ .prepare(
1072
+ `SELECT o.id, o.files_modified FROM observations o
1073
+ WHERE o.files_modified IS NOT NULL AND o.files_modified != '[]'
1074
+ AND NOT EXISTS (SELECT 1 FROM observation_files f WHERE f.obs_id = o.id)`,
1075
+ )
1076
+ .all();
1077
+ if (rows.length === 0) return;
1078
+ const insertFile = db.prepare(
1079
+ 'INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)',
1080
+ );
1081
+ db.transaction(() => {
1082
+ for (const row of rows) {
1083
+ let files;
1084
+ try {
1085
+ files = JSON.parse(row.files_modified);
1086
+ } catch {
1087
+ // One unparseable row must not cost every row after it its edges — the whole
1088
+ // pass is marked done afterwards, so "skipped" here means "never backfilled".
1089
+ continue;
1090
+ }
1091
+ if (!Array.isArray(files)) continue;
1092
+ for (const f of files) {
1093
+ if (typeof f === 'string' && f.length > 0) insertFile.run(row.id, f);
1094
+ }
1095
+ }
1096
+ })();
1097
+ },
1098
+ },
1079
1099
  {
1080
1100
  // Project-name normalization: migrate short names ("mem") to canonical
1081
1101
  // ("projects--mem") by EXACT canonical-suffix match. Idempotent: only acts on