recallnest 2.6.1 → 3.0.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +131 -14
  3. package/README_CN.md +121 -17
  4. package/config.json.example +8 -1
  5. package/docs/api-reference.md +1 -1
  6. package/integrations/README.md +1 -1
  7. package/integrations/agy/README.md +73 -0
  8. package/integrations/agy/setup.sh +75 -0
  9. package/integrations/setup-all.sh +1 -1
  10. package/package.json +5 -4
  11. package/src/__tests__/access-tracker-novelty.test.ts +22 -2
  12. package/src/__tests__/activity-counter.test.ts +38 -38
  13. package/src/__tests__/age-label.test.ts +33 -0
  14. package/src/__tests__/cluster-consolidation.test.ts +313 -22
  15. package/src/__tests__/consolidation-engine.test.ts +90 -0
  16. package/src/__tests__/contracts.test.ts +2 -2
  17. package/src/__tests__/data-checkup.test.ts +31 -3
  18. package/src/__tests__/dream-pipeline.test.ts +41 -1
  19. package/src/__tests__/dream-scope-isolation.test.ts +386 -0
  20. package/src/__tests__/dream-write-batching.test.ts +211 -0
  21. package/src/__tests__/env-config-invariant.test.ts +80 -0
  22. package/src/__tests__/env-config.test.ts +60 -0
  23. package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
  24. package/src/__tests__/maintenance-locks.test.ts +8 -0
  25. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
  26. package/src/__tests__/memory-output.test.ts +9 -0
  27. package/src/__tests__/memory-utility.test.ts +256 -0
  28. package/src/__tests__/openai-contract.test.ts +513 -0
  29. package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
  30. package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
  31. package/src/__tests__/source-heartbeat.test.ts +6 -2
  32. package/src/__tests__/store-patch-metadata.test.ts +30 -0
  33. package/src/__tests__/synthesis-contract.test.ts +252 -0
  34. package/src/__tests__/synthesis-promotion.test.ts +369 -0
  35. package/src/__tests__/trusted-memory.test.ts +28 -0
  36. package/src/__tests__/utility-weight.test.ts +57 -0
  37. package/src/__tests__/verbatim-self-recall.test.ts +279 -0
  38. package/src/__tests__/version-manager.test.ts +35 -0
  39. package/src/access-tracker.ts +10 -1
  40. package/src/activity-counter.ts +69 -11
  41. package/src/age-label.ts +29 -0
  42. package/src/audit-log.ts +35 -0
  43. package/src/auto-gc.ts +97 -30
  44. package/src/cli.ts +224 -10
  45. package/src/consolidation-engine.ts +317 -123
  46. package/src/data-checkup.ts +17 -3
  47. package/src/dream-pipeline.ts +90 -12
  48. package/src/embedder.ts +20 -5
  49. package/src/env-config.ts +53 -0
  50. package/src/forget-engine.ts +8 -2
  51. package/src/gateway-server.ts +272 -0
  52. package/src/ingest.ts +23 -5
  53. package/src/llm-client.ts +86 -25
  54. package/src/mcp-server.ts +2 -1
  55. package/src/mcp-tools-advanced.ts +7 -2
  56. package/src/mcp-tools-core.ts +11 -1
  57. package/src/mcp-tools-governance.ts +45 -3
  58. package/src/memory-evolution.ts +16 -0
  59. package/src/memory-output.ts +12 -8
  60. package/src/memory-promotion.ts +280 -0
  61. package/src/memory-store-port.ts +32 -3
  62. package/src/memory-utility.ts +314 -0
  63. package/src/recall-ledger.ts +76 -0
  64. package/src/retriever.ts +82 -9
  65. package/src/scope-policy.ts +22 -1
  66. package/src/session-output.ts +4 -3
  67. package/src/skill-promotion.ts +152 -1
  68. package/src/store.ts +231 -71
  69. package/src/synthesis-contract.ts +286 -0
  70. package/src/version-manager.ts +83 -0
  71. package/src/workflow-observation-schema.ts +26 -0
  72. package/integrations/gemini-cli/README.md +0 -57
  73. package/integrations/gemini-cli/setup.sh +0 -64
  74. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
package/src/store.ts CHANGED
@@ -9,11 +9,12 @@ import { dirname, join } from "node:path";
9
9
  import { logWarn } from "./stderr-log.js";
10
10
  import { readConsistencyInterval as envReadConsistencyInterval } from "./env-config.js";
11
11
  import type { DurableMemoryCategory } from "./memory-schema.js";
12
- import { matchesScopeFilter } from "./scope-policy.js";
12
+ import { matchesScopeFilter, type ScopeMatchMode } from "./scope-policy.js";
13
13
  import { detectEmotionIfEnabled } from "./emotion-detector.js";
14
14
  import { withWriteLock } from "./distill-lock.js";
15
15
  import { incrementWriteCount } from "./activity-counter.js";
16
16
  import type { MemoryStorePort, MemoryStoreStats, MemoryStoreUpdate } from "./memory-store-port.js";
17
+ import { clearVersionGroupMetadata, planSingletonVersionGroupRepairs } from "./version-manager.js";
17
18
 
18
19
  // ============================================================================
19
20
  // Types
@@ -222,6 +223,27 @@ export function validateStoragePath(dbPath: string): string {
222
223
 
223
224
  const TABLE_NAME = "memories";
224
225
 
226
+ /**
227
+ * 把 scopeFilter 编译成 SQL 条件。**store 里所有 scope 过滤的唯一出处**
228
+ * (2026-08-16 收敛,此前同一条规则在 6 个方法里各抄了一遍)。
229
+ *
230
+ * `mode` 见 scope-policy.ts 的 ScopeMatchMode:默认 `family` = 历史行为(有冒号精确 /
231
+ * 无冒号前缀),`exact` = 调用方明说给的是具体 scope 名、一律精确。
232
+ * 应用层双检 `matchesScopeFilter(row, filter, mode)` 必须传同一个 mode,否则 SQL 收紧了
233
+ * 应用层又放行(或反之),两层判据打架。
234
+ */
235
+ function scopeWhereClause(scopeFilter: string[], mode: ScopeMatchMode = "family"): string {
236
+ return scopeFilter
237
+ .map(scope => {
238
+ const safe = escapeSqlLiteral(scope);
239
+ if (mode === "exact") return `scope = '${safe}'`;
240
+ return scope.includes(":")
241
+ ? `scope = '${safe}'`
242
+ : `scope LIKE '${safe}%'`;
243
+ })
244
+ .join(" OR ");
245
+ }
246
+
225
247
  export class MemoryStore implements MemoryStorePort {
226
248
  private db: LanceDB.Connection | null = null;
227
249
  private table: LanceDB.Table | null = null;
@@ -467,7 +489,7 @@ export class MemoryStore implements MemoryStorePort {
467
489
  const statsPath = this.activityStatsPath();
468
490
  const countByScope = new Map<string, number>();
469
491
  for (const e of toWrite) countByScope.set(e.scope, (countByScope.get(e.scope) ?? 0) + 1);
470
- for (const [scope, n] of countByScope) incrementWriteCount(scope, n, { statsPath });
492
+ for (const [scope, n] of countByScope) await incrementWriteCount(scope, n, { statsPath });
471
493
  } catch { /* activity tracking is best-effort */ }
472
494
  return toWrite.length;
473
495
  }
@@ -497,7 +519,7 @@ export class MemoryStore implements MemoryStorePort {
497
519
  }, { expireMs: 30_000 });
498
520
  // Per-scope write count drives dream activation. Best-effort — never block a write.
499
521
  try {
500
- incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
522
+ await incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
501
523
  } catch { /* activity tracking is best-effort */ }
502
524
  return entry;
503
525
  }
@@ -554,7 +576,13 @@ export class MemoryStore implements MemoryStorePort {
554
576
  return res.length > 0;
555
577
  }
556
578
 
557
- async vectorSearch(vector: number[], limit = 5, minScore = 0.3, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
579
+ async vectorSearch(
580
+ vector: number[],
581
+ limit = 5,
582
+ minScore = 0.3,
583
+ scopeFilter?: string[],
584
+ scopeMatch: ScopeMatchMode = "family",
585
+ ): Promise<MemorySearchResult[]> {
558
586
  await this.ensureInitialized();
559
587
 
560
588
  const safeLimit = clampInt(limit, 1, 20);
@@ -562,19 +590,11 @@ export class MemoryStore implements MemoryStorePort {
562
590
 
563
591
  let query = this.table!.vectorSearch(vector).distanceType('cosine').limit(fetchLimit);
564
592
 
565
- // Apply scope filter if provided
566
- // Support both exact match ("cc:abc123") and prefix match ("cc")
593
+ // Apply scope filter if provided. Default mode "family" = 历史行为(含冒号精确 /
594
+ // 无冒号前缀);exact 由调用方显式声明。**必须下推到 SQL,不能事后在应用层滤**——
595
+ // top-k 在收窄前就被兄弟 scope 占满,滤完只剩残缺候选(2026-08-16 互审 C1)。
567
596
  if (scopeFilter && scopeFilter.length > 0) {
568
- const scopeConditions = scopeFilter
569
- .map(scope => {
570
- const safe = escapeSqlLiteral(scope);
571
- // If scope contains ":", treat as exact match; otherwise prefix match
572
- return scope.includes(":")
573
- ? `scope = '${safe}'`
574
- : `scope LIKE '${safe}%'`;
575
- })
576
- .join(" OR ");
577
- query = query.where(`(${scopeConditions})`);
597
+ query = query.where(`(${scopeWhereClause(scopeFilter, scopeMatch)})`);
578
598
  }
579
599
 
580
600
  const results = await query.toArray();
@@ -588,8 +608,8 @@ export class MemoryStore implements MemoryStorePort {
588
608
 
589
609
  const rowScope = (row.scope as string | undefined) ?? "";
590
610
 
591
- // Double-check scope filter in application layer (prefix-aware)
592
- if (!matchesScopeFilter(rowScope, scopeFilter)) {
611
+ // Double-check scope filter in application layer (same mode as the SQL above)
612
+ if (!matchesScopeFilter(rowScope, scopeFilter, scopeMatch)) {
593
613
  continue;
594
614
  }
595
615
 
@@ -626,17 +646,9 @@ export class MemoryStore implements MemoryStorePort {
626
646
  // Use FTS query type explicitly
627
647
  let searchQuery = this.table!.search(query, "fts").limit(safeLimit);
628
648
 
629
- // Apply scope filter if provided (prefix-aware, same as vectorSearch)
649
+ // Apply scope filter if provided (family mode, same as vectorSearch's default)
630
650
  if (scopeFilter && scopeFilter.length > 0) {
631
- const scopeConditions = scopeFilter
632
- .map(scope => {
633
- const safe = escapeSqlLiteral(scope);
634
- return scope.includes(":")
635
- ? `scope = '${safe}'`
636
- : `scope LIKE '${safe}%'`;
637
- })
638
- .join(" OR ");
639
- searchQuery = searchQuery.where(`(${scopeConditions})`);
651
+ searchQuery = searchQuery.where(`(${scopeWhereClause(scopeFilter)})`);
640
652
  }
641
653
 
642
654
  const results = await searchQuery.toArray();
@@ -725,7 +737,13 @@ export class MemoryStore implements MemoryStorePort {
725
737
  return true;
726
738
  }
727
739
 
728
- async list(scopeFilter?: string[], category?: string, limit = 20, offset = 0): Promise<MemoryEntry[]> {
740
+ async list(
741
+ scopeFilter?: string[],
742
+ category?: string,
743
+ limit = 20,
744
+ offset = 0,
745
+ scopeMatch: ScopeMatchMode = "family",
746
+ ): Promise<MemoryEntry[]> {
729
747
  await this.ensureInitialized();
730
748
 
731
749
  let query = this.table!.query();
@@ -734,15 +752,9 @@ export class MemoryStore implements MemoryStorePort {
734
752
  const conditions: string[] = [];
735
753
 
736
754
  if (scopeFilter && scopeFilter.length > 0) {
737
- const scopeConditions = scopeFilter
738
- .map(scope => {
739
- const safe = escapeSqlLiteral(scope);
740
- return scope.includes(":")
741
- ? `scope = '${safe}'`
742
- : `scope LIKE '${safe}%'`;
743
- })
744
- .join(" OR ");
745
- conditions.push(`((${scopeConditions}))`);
755
+ // 收窄必须在 SQL 里:limit 是在这之后下推的,事后过滤等于先截断再筛,
756
+ // 目标 scope 实际拿到多少行不可控(2026-08-16 互审 C1/K3)。
757
+ conditions.push(`((${scopeWhereClause(scopeFilter, scopeMatch)}))`);
746
758
  }
747
759
 
748
760
  if (category) {
@@ -801,15 +813,7 @@ export class MemoryStore implements MemoryStorePort {
801
813
 
802
814
  const conditions: string[] = [];
803
815
  if (scopeFilter && scopeFilter.length > 0) {
804
- const scopeConditions = scopeFilter
805
- .map(scope => {
806
- const safe = escapeSqlLiteral(scope);
807
- return scope.includes(":")
808
- ? `scope = '${safe}'`
809
- : `scope LIKE '${safe}%'`;
810
- })
811
- .join(" OR ");
812
- conditions.push(`((${scopeConditions}))`);
816
+ conditions.push(`((${scopeWhereClause(scopeFilter)}))`);
813
817
  }
814
818
  if (category) {
815
819
  conditions.push(`category = '${escapeSqlLiteral(category)}'`);
@@ -962,21 +966,13 @@ export class MemoryStore implements MemoryStorePort {
962
966
  return result;
963
967
  }
964
968
 
965
- async stats(scopeFilter?: string[]): Promise<MemoryStoreStats> {
969
+ async stats(scopeFilter?: string[], scopeMatch: ScopeMatchMode = "family"): Promise<MemoryStoreStats> {
966
970
  await this.ensureInitialized();
967
971
 
968
972
  let query = this.table!.query();
969
973
 
970
974
  if (scopeFilter && scopeFilter.length > 0) {
971
- const scopeConditions = scopeFilter
972
- .map(scope => {
973
- const safe = escapeSqlLiteral(scope);
974
- return scope.includes(":")
975
- ? `scope = '${safe}'`
976
- : `scope LIKE '${safe}%'`;
977
- })
978
- .join(" OR ");
979
- query = query.where(`((${scopeConditions}))`);
975
+ query = query.where(`((${scopeWhereClause(scopeFilter, scopeMatch)}))`);
980
976
  }
981
977
 
982
978
  const results = await query.select(["scope", "category"]).toArray();
@@ -1110,11 +1106,17 @@ export class MemoryStore implements MemoryStorePort {
1110
1106
  // Atomic upsert via mergeInsert — replaces the previous delete+add two-step,
1111
1107
  // which could lose the row entirely on a crash between steps and exposed a
1112
1108
  // window where concurrent reads saw the id momentarily vanish.
1113
- await this.table!
1114
- .mergeInsert("id")
1115
- .whenMatchedUpdateAll()
1116
- .whenNotMatchedInsertAll()
1117
- .execute([updated]);
1109
+ //
1110
+ // 2026-08-14: 包进 store-write 锁与 upsert/storeBatch 对齐 —— 此前 update 是唯一
1111
+ // 裸奔的写路径(dream 的 3a/3b/auto-gc 全走它),与其他进程的 mergeInsert 并发时
1112
+ // 靠 LanceDB 乐观重试硬扛,是 commit conflict 的隐性源之一。
1113
+ await withWriteLock("store-write", async () => {
1114
+ await this.table!
1115
+ .mergeInsert("id")
1116
+ .whenMatchedUpdateAll()
1117
+ .whenNotMatchedInsertAll()
1118
+ .execute([updated]);
1119
+ }, { expireMs: 30_000 });
1118
1120
 
1119
1121
  return updated;
1120
1122
  }
@@ -1195,21 +1197,179 @@ export class MemoryStore implements MemoryStorePort {
1195
1197
  return this.metadataPatchQueues.size;
1196
1198
  }
1197
1199
 
1200
+ /**
1201
+ * Batched metadata read-modify-write: one store-write lock acquisition, one
1202
+ * mergeInsert commit per batch of up to 200 rows — versus one commit per row
1203
+ * through update()/patchMetadata().
1204
+ *
1205
+ * 为什么存在(2026-08-14 dream 写爆库根治):LanceDB 每次写 commit 生成一个新
1206
+ * manifest 版本文件,manifest 大小 ∝ 全表 fragment 数(实测 348-436KB/个)。dream
1207
+ * 的 3a/3b/auto-gc 全部逐条 update,一轮 --auto 写出 ~6600 个 commit ≈ 2GB+ 的
1208
+ * _versions 增量。本方法把"一批相关行的 metadata 变更"折成单次 commit。
1209
+ *
1210
+ * 语义要点:
1211
+ * - patchFn 在 **锁内、以库里最新行为底座** 应用 —— 不接受调用方预先物化的完整
1212
+ * metadata 串。这是互审 R1 的 P0 修正:3b 持有的是 3a 之前 gather 的旧条目,
1213
+ * 预物化串批量写会把 3a 刚提交的 version_group/sourceMemories 再抹一遍
1214
+ * (与它要修的 Bug-1 同病,只是从循环内搬到阶段间)。
1215
+ * - patches 按数组序在同一临界区内依次应用:同批的 patchFn 之间可通过调用方
1216
+ * 闭包传递决策(3a 靠它让 canonical 与 members 共享同一个 groupId)。
1217
+ * - 单条 patchFn 抛错或 id 不存在/越权:跳过该条继续(stderr 记一行),不炸整批 ——
1218
+ * 与 dream"单 scope 失败不阻断其他"的分级一致。返回成功写入数。
1219
+ * - 与 patchMetadata 的 per-id 队列并存:本方法自身持 store-write 锁做批内原子,
1220
+ * 跨进程窗口由锁消除;进程内与单条 patchMetadata 的交错等同于现状并发水平。
1221
+ */
1222
+ async patchMetadataBatch(
1223
+ patches: Array<{
1224
+ id: string;
1225
+ patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>;
1226
+ }>,
1227
+ scopeFilter?: string[],
1228
+ ): Promise<number> {
1229
+ await this.ensureInitialized();
1230
+ if (patches.length === 0) return 0;
1231
+
1232
+ let written = 0;
1233
+ // 分批:避免 OR 链过长(抄 getVectors 的范式,批更保守——每批一个 commit 本身就是收益)
1234
+ const BATCH = 200;
1235
+ for (let i = 0; i < patches.length; i += BATCH) {
1236
+ const batch = patches.slice(i, i + BATCH);
1237
+ written += await withWriteLock("store-write", async () => {
1238
+ // 锁内读最新行
1239
+ const conditions = batch.map(p => `id = '${escapeSqlLiteral(p.id)}'`).join(" OR ");
1240
+ const rows = await this.table!.query().where(conditions).limit(batch.length).toArray();
1241
+ const rowById = new Map<string, Record<string, unknown>>();
1242
+ for (const row of rows) rowById.set(row.id as string, row as Record<string, unknown>);
1243
+
1244
+ const toWrite: MemoryEntry[] = [];
1245
+ for (const { id, patchFn } of batch) {
1246
+ const row = rowById.get(id);
1247
+ if (!row) continue; // 行不存在:跳过(dream 处理途中被并发删除属正常)
1248
+ const rowScope = (row.scope as string | undefined) ?? "";
1249
+ if (!matchesScopeFilter(rowScope, scopeFilter)) continue;
1250
+ let meta: Record<string, unknown>;
1251
+ try {
1252
+ const parsed: unknown = JSON.parse((row.metadata as string) || "{}");
1253
+ meta = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
1254
+ ? (parsed as Record<string, unknown>)
1255
+ : {};
1256
+ } catch {
1257
+ meta = {};
1258
+ }
1259
+ const entry: MemoryEntry = {
1260
+ id: row.id as string,
1261
+ text: row.text as string,
1262
+ // null 向量防护同 update():历史病行 → [],metadata-only 批量写不被绊倒
1263
+ vector: row.vector ? Array.from(row.vector as Iterable<number>) : [],
1264
+ category: row.category as MemoryEntry["category"],
1265
+ scope: rowScope,
1266
+ importance: Number(row.importance),
1267
+ timestamp: Number(row.timestamp),
1268
+ metadata: (row.metadata as string) || "{}",
1269
+ language: (row.language as string) || "en",
1270
+ fts_text: (row.fts_text as string) || (row.text as string),
1271
+ };
1272
+ try {
1273
+ const patched = patchFn(meta, entry);
1274
+ toWrite.push({ ...entry, metadata: JSON.stringify(patched) });
1275
+ } catch (err) {
1276
+ console.error(`[patchMetadataBatch] patchFn failed for ${id}, skipping: ${err instanceof Error ? err.message : String(err)}`);
1277
+ }
1278
+ }
1279
+ if (toWrite.length === 0) return 0;
1280
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toWrite);
1281
+ return toWrite.length;
1282
+ }, { expireMs: 30_000 });
1283
+ }
1284
+ return written;
1285
+ }
1286
+
1287
+ /**
1288
+ * Dissolve version groups that have only one surviving member.
1289
+ *
1290
+ * The membership scan and metadata rewrite share the global store-write lock.
1291
+ * Keeping both inside one critical section matters: planning during auto-GC and
1292
+ * patching later could otherwise dismantle a group that gained a new member in
1293
+ * between. Hard deletes that bypass the store may still create a new singleton
1294
+ * after this pass; the next scheduled pass repairs it.
1295
+ */
1296
+ async repairSingletonVersionGroups(): Promise<number> {
1297
+ await this.ensureInitialized();
1298
+
1299
+ return withWriteLock("store-write", async () => {
1300
+ const membershipRows = await this.table!.query()
1301
+ .select(["id", "metadata"])
1302
+ .toArray();
1303
+ const repairs = planSingletonVersionGroupRepairs(
1304
+ membershipRows.map(row => ({
1305
+ id: row.id as string,
1306
+ metadata: (row.metadata as string) || "{}",
1307
+ })),
1308
+ );
1309
+ if (repairs.length === 0) return 0;
1310
+
1311
+ let repaired = 0;
1312
+ const BATCH = 200;
1313
+ for (let i = 0; i < repairs.length; i += BATCH) {
1314
+ const batch = repairs.slice(i, i + BATCH);
1315
+ const expectedById = new Map(batch.map(item => [item.id, item.groupId]));
1316
+ const conditions = batch
1317
+ .map(item => `id = '${escapeSqlLiteral(item.id)}'`)
1318
+ .join(" OR ");
1319
+ const rows = await this.table!.query()
1320
+ .where(conditions)
1321
+ .limit(batch.length)
1322
+ .toArray();
1323
+
1324
+ const toWrite: MemoryEntry[] = [];
1325
+ for (const row of rows) {
1326
+ const id = row.id as string;
1327
+ const expectedGroupId = expectedById.get(id);
1328
+ if (!expectedGroupId) continue;
1329
+ let meta: Record<string, unknown>;
1330
+ try {
1331
+ const parsed: unknown = JSON.parse((row.metadata as string) || "{}");
1332
+ meta = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
1333
+ ? (parsed as Record<string, unknown>)
1334
+ : {};
1335
+ } catch {
1336
+ continue;
1337
+ }
1338
+ // The group id is rechecked against the locked, current row.
1339
+ if (meta.version_group !== expectedGroupId) continue;
1340
+ clearVersionGroupMetadata(meta, expectedGroupId);
1341
+ toWrite.push({
1342
+ id,
1343
+ text: row.text as string,
1344
+ vector: row.vector ? Array.from(row.vector as Iterable<number>) : [],
1345
+ category: row.category as MemoryEntry["category"],
1346
+ scope: (row.scope as string | undefined) ?? "",
1347
+ importance: Number(row.importance),
1348
+ timestamp: Number(row.timestamp),
1349
+ metadata: JSON.stringify(meta),
1350
+ language: (row.language as string) || "en",
1351
+ fts_text: (row.fts_text as string) || (row.text as string),
1352
+ });
1353
+ }
1354
+ if (toWrite.length === 0) continue;
1355
+ // Matched rows only: a concurrent hard delete must never be resurrected.
1356
+ const result = await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute(toWrite);
1357
+ repaired += result.numUpdatedRows;
1358
+ }
1359
+ return repaired;
1360
+ }, { expireMs: 30 * 60_000 });
1361
+ }
1362
+
1198
1363
  async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
1199
1364
  await this.ensureInitialized();
1200
1365
 
1201
1366
  const conditions: string[] = [];
1202
1367
 
1203
1368
  if (scopeFilter.length > 0) {
1204
- const scopeConditions = scopeFilter
1205
- .map(scope => {
1206
- const safe = escapeSqlLiteral(scope);
1207
- return scope.includes(":")
1208
- ? `scope = '${safe}'`
1209
- : `scope LIKE '${safe}%'`;
1210
- })
1211
- .join(" OR ");
1212
- conditions.push(`(${scopeConditions})`);
1369
+ // family 模式(历史行为)。⚠️ 这是**破坏性**操作走前缀:`bulkDelete(["memory"])`
1370
+ // 会连 `memory:pivot` 一起删。当前无生产 caller;要加 caller 请先显式传 exact
1371
+ // (同 forgetByScope 的处理,2026-08-16)。
1372
+ conditions.push(`(${scopeWhereClause(scopeFilter)})`);
1213
1373
  }
1214
1374
 
1215
1375
  if (beforeTimestamp) {