recallnest 2.3.0 → 2.5.4

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 (221) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +17 -31
  3. package/README_CN.md +12 -22
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +22 -1
  15. package/src/__tests__/access-tracker-novelty.test.ts +238 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/auto-consolidation.test.ts +168 -0
  21. package/src/__tests__/auto-recall.test.ts +131 -0
  22. package/src/__tests__/babel-integration.test.ts +144 -0
  23. package/src/__tests__/batch-dedup.test.ts +65 -0
  24. package/src/__tests__/canary-eval.test.ts +114 -0
  25. package/src/__tests__/capture-engine.test.ts +1238 -0
  26. package/src/__tests__/capture-heuristic.test.ts +171 -0
  27. package/src/__tests__/cascade-forget.test.ts +210 -0
  28. package/src/__tests__/category-min-scores.test.ts +33 -0
  29. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  30. package/src/__tests__/circuit-breaker.test.ts +99 -0
  31. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  32. package/src/__tests__/confidence-metadata.test.ts +125 -0
  33. package/src/__tests__/confidence-tracker.test.ts +261 -0
  34. package/src/__tests__/conflict-advisor.test.ts +206 -0
  35. package/src/__tests__/conflict-escalation.test.ts +149 -0
  36. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  37. package/src/__tests__/conflict-output.test.ts +85 -0
  38. package/src/__tests__/conflict-store.test.ts +374 -0
  39. package/src/__tests__/connector-v1.test.ts +181 -0
  40. package/src/__tests__/consolidation-engine.test.ts +333 -0
  41. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  42. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  43. package/src/__tests__/context-composer-pins.test.ts +64 -0
  44. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  45. package/src/__tests__/context-reconstructor.test.ts +65 -0
  46. package/src/__tests__/context-renderer.test.ts +91 -0
  47. package/src/__tests__/continuity-eval.test.ts +275 -0
  48. package/src/__tests__/continuity-schema.test.ts +97 -0
  49. package/src/__tests__/contracts.test.ts +216 -0
  50. package/src/__tests__/conversation-importer.test.ts +692 -0
  51. package/src/__tests__/core-recall-eval.test.ts +235 -0
  52. package/src/__tests__/core-summary.test.ts +109 -0
  53. package/src/__tests__/data-checkup.test.ts +276 -0
  54. package/src/__tests__/decay-exemption.test.ts +84 -0
  55. package/src/__tests__/dedup-actions.test.ts +58 -0
  56. package/src/__tests__/dedup-guard.test.ts +39 -0
  57. package/src/__tests__/deterministic-id.test.ts +39 -0
  58. package/src/__tests__/distill-lock.test.ts +305 -0
  59. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  60. package/src/__tests__/doctor.test.ts +319 -0
  61. package/src/__tests__/dream-pipeline.test.ts +321 -0
  62. package/src/__tests__/drill-down.test.ts +108 -0
  63. package/src/__tests__/embedder.test.ts +122 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/entity-resolver.test.ts +58 -0
  69. package/src/__tests__/env-config.test.ts +151 -0
  70. package/src/__tests__/error-signature-boost.test.ts +87 -0
  71. package/src/__tests__/error-signature.test.ts +95 -0
  72. package/src/__tests__/error-taxonomy.test.ts +129 -0
  73. package/src/__tests__/ethics-layer.test.ts +476 -0
  74. package/src/__tests__/event-segmenter.test.ts +153 -0
  75. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  76. package/src/__tests__/frequency-tracker.test.ts +113 -0
  77. package/src/__tests__/graph-export.test.ts +489 -0
  78. package/src/__tests__/hotness-blend.test.ts +76 -0
  79. package/src/__tests__/ingest-chunking.test.ts +44 -0
  80. package/src/__tests__/ingest.test.ts +360 -0
  81. package/src/__tests__/interference-detector.test.ts +203 -0
  82. package/src/__tests__/kg-extractor.test.ts +192 -0
  83. package/src/__tests__/kg-store.test.ts +300 -0
  84. package/src/__tests__/llm-consolidation.test.ts +226 -0
  85. package/src/__tests__/maintenance-locks.test.ts +174 -0
  86. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  87. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  88. package/src/__tests__/memory-boundaries.test.ts +250 -0
  89. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  90. package/src/__tests__/memory-evolution.test.ts +271 -0
  91. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  92. package/src/__tests__/memory-lint.test.ts +421 -0
  93. package/src/__tests__/memory-output.test.ts +215 -0
  94. package/src/__tests__/memory-promotion.test.ts +315 -0
  95. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  96. package/src/__tests__/multi-vector.test.ts +242 -0
  97. package/src/__tests__/narrative-architecture.test.ts +456 -0
  98. package/src/__tests__/noise-filter.test.ts +205 -0
  99. package/src/__tests__/obsidian-connector.test.ts +363 -0
  100. package/src/__tests__/omitted-reporting.test.ts +98 -0
  101. package/src/__tests__/pending-review.test.ts +77 -0
  102. package/src/__tests__/pii-redact.test.ts +314 -0
  103. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  104. package/src/__tests__/ppr-traversal.test.ts +256 -0
  105. package/src/__tests__/prediction-engine.test.ts +349 -0
  106. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  107. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  108. package/src/__tests__/preference-matcher.test.ts +230 -0
  109. package/src/__tests__/preference-slots.test.ts +205 -0
  110. package/src/__tests__/prospective-memory.test.ts +283 -0
  111. package/src/__tests__/query-entity-detector.test.ts +171 -0
  112. package/src/__tests__/query-expander-alias.test.ts +146 -0
  113. package/src/__tests__/query-expander.test.ts +34 -0
  114. package/src/__tests__/recall-governor.test.ts +227 -0
  115. package/src/__tests__/recall-mode.test.ts +64 -0
  116. package/src/__tests__/result-synthesizer.test.ts +198 -0
  117. package/src/__tests__/resume-context.test.ts +4898 -0
  118. package/src/__tests__/retrieval-trace.test.ts +213 -0
  119. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  120. package/src/__tests__/retroactive-boost.test.ts +223 -0
  121. package/src/__tests__/salience-scoring.test.ts +106 -0
  122. package/src/__tests__/scope-inventory.test.ts +200 -0
  123. package/src/__tests__/scope-policy.test.ts +69 -0
  124. package/src/__tests__/scope-suggester.test.ts +80 -0
  125. package/src/__tests__/session-checkpoint.test.ts +291 -0
  126. package/src/__tests__/session-distiller.test.ts +640 -0
  127. package/src/__tests__/session-gc.test.ts +215 -0
  128. package/src/__tests__/skill-engine.test.ts +552 -0
  129. package/src/__tests__/skill-promotion.test.ts +601 -0
  130. package/src/__tests__/skill-verifier.test.ts +122 -0
  131. package/src/__tests__/source-heartbeat.test.ts +250 -0
  132. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  133. package/src/__tests__/store-scope-audit.test.ts +81 -0
  134. package/src/__tests__/store-upsert.test.ts +184 -0
  135. package/src/__tests__/supersede-chain.test.ts +154 -0
  136. package/src/__tests__/temporal-parser.test.ts +241 -0
  137. package/src/__tests__/temporal-validity.test.ts +160 -0
  138. package/src/__tests__/term-registry.test.ts +121 -0
  139. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  140. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  141. package/src/__tests__/topic-tag.test.ts +113 -0
  142. package/src/__tests__/trusted-memory.test.ts +839 -0
  143. package/src/__tests__/usage-tracker.test.ts +287 -0
  144. package/src/__tests__/version-manager.test.ts +213 -0
  145. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  146. package/src/__tests__/workflow-observation.test.ts +433 -0
  147. package/src/__tests__/write-verifier.test.ts +122 -0
  148. package/src/access-tracker.ts +38 -32
  149. package/src/activity-counter.ts +46 -18
  150. package/src/aliases.ts +133 -0
  151. package/src/api-server.ts +27 -7
  152. package/src/audit-log.ts +4 -2
  153. package/src/auto-consolidation.ts +8 -3
  154. package/src/auto-gc.ts +85 -21
  155. package/src/capture-engine.ts +54 -21
  156. package/src/cli.ts +169 -37
  157. package/src/confidence-tracker.ts +36 -18
  158. package/src/consolidation-engine.ts +109 -11
  159. package/src/context-composer.ts +8 -1
  160. package/src/data-checkup.ts +95 -6
  161. package/src/distill-lock.ts +194 -15
  162. package/src/doctor.ts +9 -2
  163. package/src/dream-pipeline.ts +118 -34
  164. package/src/env-config.ts +77 -0
  165. package/src/error-signature.ts +153 -0
  166. package/src/error-taxonomy.ts +115 -0
  167. package/src/eval.ts +251 -6
  168. package/src/idempotency.ts +8 -0
  169. package/src/ingest.ts +31 -10
  170. package/src/kg-extractor.ts +6 -2
  171. package/src/kg-store.ts +236 -44
  172. package/src/llm-consolidation.ts +2 -1
  173. package/src/mcp-server.ts +85 -1799
  174. package/src/mcp-tool-deps.ts +48 -0
  175. package/src/mcp-tools-advanced.ts +941 -0
  176. package/src/mcp-tools-core.ts +633 -0
  177. package/src/mcp-tools-governance.ts +475 -0
  178. package/src/memory-boundaries.ts +3 -1
  179. package/src/memory-evolution.ts +73 -1
  180. package/src/memory-lint.ts +183 -10
  181. package/src/memory-output.ts +114 -3
  182. package/src/memory-promotion.ts +246 -0
  183. package/src/memory-schema.ts +25 -2
  184. package/src/memory-store-port.ts +43 -0
  185. package/src/multi-vector.ts +2 -1
  186. package/src/narrative-schema.ts +2 -1
  187. package/src/noise-filter.ts +57 -6
  188. package/src/pii-detector.ts +157 -5
  189. package/src/query-expander.ts +90 -9
  190. package/src/result-synthesizer.ts +2 -1
  191. package/src/retention-policy.ts +2 -1
  192. package/src/retriever.ts +259 -139
  193. package/src/runtime-config.ts +8 -1
  194. package/src/scope-suggester.ts +113 -0
  195. package/src/session-distiller.ts +23 -2
  196. package/src/session-engine.ts +2 -1
  197. package/src/session-schema.ts +1 -0
  198. package/src/session-store.ts +5 -0
  199. package/src/skill-engine.ts +78 -0
  200. package/src/skill-promotion.ts +247 -47
  201. package/src/skill-schema.ts +16 -3
  202. package/src/skill-verifier.ts +152 -0
  203. package/src/store.ts +267 -38
  204. package/src/tool-output-compressor.ts +3 -1
  205. package/src/ui-server.ts +2 -1
  206. package/src/usage-tracker.ts +242 -0
  207. package/src/version-manager.ts +3 -2
  208. package/src/workflow-observation-engine.ts +2 -1
  209. package/src/workflow-observation-schema.ts +2 -1
  210. package/src/workflow-observation-store.ts +6 -2
  211. package/.env +0 -4
  212. package/CLAUDE.md +0 -46
  213. package/config.json +0 -55
  214. package/daily-recall.sh +0 -110
  215. package/logs/daily-recall.error.log +0 -0
  216. package/logs/daily-recall.log +0 -60
  217. package/logs/ingest-2026-04-11.log +0 -110
  218. package/logs/ingest-2026-04-12.log +0 -90
  219. package/logs/ingest-2026-04-13.log +0 -87
  220. package/logs/launchagent-stderr.log +0 -0
  221. package/logs/launchagent-stdout.log +0 -0
package/src/store.ts CHANGED
@@ -5,11 +5,14 @@
5
5
  import type * as LanceDB from "@lancedb/lancedb";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { existsSync, accessSync, constants, mkdirSync, realpathSync, lstatSync } from "node:fs";
8
- import { dirname } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
  import { logWarn } from "./stderr-log.js";
10
10
  import type { DurableMemoryCategory } from "./memory-schema.js";
11
11
  import { matchesScopeFilter } from "./scope-policy.js";
12
12
  import { detectEmotionIfEnabled } from "./emotion-detector.js";
13
+ import { withWriteLock } from "./distill-lock.js";
14
+ import { incrementWriteCount } from "./activity-counter.js";
15
+ import type { MemoryStorePort, MemoryStoreStats, MemoryStoreUpdate } from "./memory-store-port.js";
13
16
 
14
17
  // ============================================================================
15
18
  // Types
@@ -211,11 +214,13 @@ export function validateStoragePath(dbPath: string): string {
211
214
 
212
215
  const TABLE_NAME = "memories";
213
216
 
214
- export class MemoryStore {
217
+ export class MemoryStore implements MemoryStorePort {
215
218
  private db: LanceDB.Connection | null = null;
216
219
  private table: LanceDB.Table | null = null;
217
220
  private initPromise: Promise<void> | null = null;
218
221
  private ftsIndexCreated = false;
222
+ /** Per-id serial queues for patchMetadata; entries self-remove on settle. */
223
+ private readonly metadataPatchQueues = new Map<string, Promise<void>>();
219
224
 
220
225
  constructor(private readonly config: StoreConfig) {}
221
226
 
@@ -375,7 +380,10 @@ export class MemoryStore {
375
380
  }
376
381
 
377
382
  try {
378
- await this.table!.add([fullEntry]);
383
+ // P0-2: go through upsert (delete+add under the global store-write lock) rather
384
+ // than a bare table.add — with the deterministic id above, storing the same
385
+ // (scope,text) twice now yields one row, not a dup-id pair (fixes the append bug).
386
+ return await this.upsert(fullEntry);
379
387
  } catch (err: any) {
380
388
  const code = err.code || "";
381
389
  const message = err.message || String(err);
@@ -383,20 +391,21 @@ export class MemoryStore {
383
391
  `Failed to store memory in "${this.config.dbPath}": ${code} ${message}`
384
392
  );
385
393
  }
386
- return fullEntry;
387
394
  }
388
395
 
389
396
  /**
390
397
  * Batch store multiple entries at once — much faster than individual store() calls.
391
398
  * LanceDB handles bulk inserts efficiently with a single index update.
392
399
  */
393
- async storeBatch(entries: Omit<MemoryEntry, "id" | "timestamp">[]): Promise<number> {
400
+ async storeBatch(entries: (Omit<MemoryEntry, "id" | "timestamp"> & { id?: string })[]): Promise<number> {
394
401
  if (entries.length === 0) return 0;
395
402
  await this.ensureInitialized();
396
403
 
397
404
  const fullEntries: MemoryEntry[] = entries.map(entry => ({
398
405
  ...entry,
399
- id: deterministicId(entry.scope, entry.text),
406
+ // P0-2: honor a caller-supplied id (consolidation insight/pattern idempotency),
407
+ // else the deterministic content id — unified with store() (was: always recompute).
408
+ id: entry.id ?? deterministicId(entry.scope, entry.text),
400
409
  timestamp: Date.now(),
401
410
  metadata: entry.metadata || "{}",
402
411
  language: entry.language || "en",
@@ -413,8 +422,20 @@ export class MemoryStore {
413
422
  return e;
414
423
  });
415
424
 
425
+ // P2: collapse in-batch duplicate ids (latest-wins) BEFORE writing. Two entries
426
+ // with the same (scope,text) → same deterministic id; adding both would create a
427
+ // fresh dup-id pair in one shot (delete-set was deduped but the add was not).
428
+ const dedupedById = new Map<string, MemoryEntry>();
429
+ for (const e of enrichedEntries) dedupedById.set(e.id, e); // later occurrence wins
430
+ const toWrite = [...dedupedById.values()];
431
+
416
432
  try {
417
- await this.table!.add(enrichedEntries);
433
+ await withWriteLock("store-write", async () => {
434
+ // P0-1/P0-2/P1: atomic idempotent batch write via mergeInsert (no delete+add
435
+ // window a crash could tear). toWrite is already in-batch deduped by id above,
436
+ // so the merge key matches ≤1 existing row (given no pre-existing dup-id rows).
437
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toWrite);
438
+ }, { expireMs: 30_000 });
418
439
  } catch (err: any) {
419
440
  const code = err.code || "";
420
441
  const message = err.message || String(err);
@@ -422,7 +443,44 @@ export class MemoryStore {
422
443
  `Failed to batch store ${entries.length} memories in "${this.config.dbPath}": ${code} ${message}`
423
444
  );
424
445
  }
425
- return fullEntries.length;
446
+ // Per-scope write counts drive dream activation. Best-effort — never block writes.
447
+ try {
448
+ const statsPath = this.activityStatsPath();
449
+ const countByScope = new Map<string, number>();
450
+ for (const e of toWrite) countByScope.set(e.scope, (countByScope.get(e.scope) ?? 0) + 1);
451
+ for (const [scope, n] of countByScope) incrementWriteCount(scope, n, { statsPath });
452
+ } catch { /* activity tracking is best-effort */ }
453
+ return toWrite.length;
454
+ }
455
+
456
+ /**
457
+ * P0-1/P0-2/P1: Idempotent, crash-atomic write of a fully-formed entry, serialized
458
+ * across processes.
459
+ *
460
+ * Uses mergeInsert("id") (same primitive as update()) so the write is atomic — no
461
+ * delete+add window that a crash between the two steps could tear, losing the row
462
+ * (P1). The global `store-write` lock still serializes concurrent merges across the
463
+ * 11 mcp-server processes so two whenNotMatched inserts can't both fire for the same
464
+ * id (the P0-1 race). "Same id overwrites" gives content-addressed idempotency to
465
+ * every store()/storeBatch() caller whose id is deterministicId(scope,text).
466
+ * Precondition: no pre-existing dup-id rows in the table (run the dedup migration
467
+ * scripts/cleanup-duplicate-ids.ts first) — else the merge key match is ambiguous.
468
+ */
469
+ /** Path to this store's activity-counter stats file, beside the LanceDB dir. */
470
+ private activityStatsPath(): string {
471
+ return join(dirname(this.config.dbPath), "activity-stats.json");
472
+ }
473
+
474
+ async upsert(entry: MemoryEntry): Promise<MemoryEntry> {
475
+ await this.ensureInitialized();
476
+ await withWriteLock("store-write", async () => {
477
+ await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([entry]);
478
+ }, { expireMs: 30_000 });
479
+ // Per-scope write count drives dream activation. Best-effort — never block a write.
480
+ try {
481
+ incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
482
+ } catch { /* activity tracking is best-effort */ }
483
+ return entry;
426
484
  }
427
485
 
428
486
  /**
@@ -617,11 +675,19 @@ export class MemoryStore {
617
675
  if (isFullId) {
618
676
  candidates = await this.table!.query().where(`id = '${id}'`).limit(1).toArray();
619
677
  } else {
620
- // Prefix match: fetch candidates and filter in app layer
621
- const all = await this.table!.query().select(["id", "scope"]).limit(1000).toArray();
622
- candidates = all.filter((r: any) => (r.id as string).startsWith(id));
678
+ // v2.5.2 (2026-05-27): Use SQL LIKE for prefix lookup instead of app-layer scan
679
+ // with limit(1000) — the old `.select().limit(1000).filter()` pattern silently
680
+ // missed entries beyond row 1000 in 90K+ libraries (Codex trio review finding,
681
+ // ref: ~/Desktop/codex-v2.5.1-fix-review-20260527.md, store.delete limit 漏洞).
682
+ // Now mirrors getById prefix behavior: LIKE with limit(2) detects ambiguity.
683
+ const safeId = escapeSqlLiteral(id);
684
+ candidates = await this.table!.query()
685
+ .select(["id", "scope"])
686
+ .where(`id LIKE '${safeId}%'`)
687
+ .limit(2)
688
+ .toArray();
623
689
  if (candidates.length > 1) {
624
- throw new Error(`Ambiguous prefix "${id}" matches ${candidates.length} memories. Use a longer prefix or full ID.`);
690
+ throw new Error(`Ambiguous prefix "${id}" matches ${candidates.length}+ memories. Use a longer prefix or full ID.`);
625
691
  }
626
692
  }
627
693
  if (candidates.length === 0) {
@@ -688,6 +754,72 @@ export class MemoryStore {
688
754
  .slice(offset, offset + limit);
689
755
  }
690
756
 
757
+ /**
758
+ * True DB-level pagination — where/limit/offset are pushed down to LanceDB,
759
+ * so unlike list() this never materializes the full match set in JS. Use for
760
+ * full-corpus maintenance sweeps (auto-gc, promotion scan, lint/checkup).
761
+ *
762
+ * Caveats:
763
+ * - No orderBy support in the LanceDB query API: rows come back in fragment
764
+ * scan order, NOT timestamp order. Stable across pages for a read-only
765
+ * sweep, but concurrent writes rewrite fragments and may shift rows
766
+ * between pages (a missed row is picked up by the next sweep). Do not use
767
+ * for user-facing ordered listings — that's what list() is for.
768
+ * - includeVector loads the embedding column; only request it when the
769
+ * consumer actually needs vectors (they dominate row size).
770
+ */
771
+ async listPage(opts: {
772
+ scopeFilter?: string[];
773
+ category?: string;
774
+ limit?: number;
775
+ offset?: number;
776
+ includeVector?: boolean;
777
+ } = {}): Promise<MemoryEntry[]> {
778
+ await this.ensureInitialized();
779
+ const { scopeFilter, category, limit = 1000, offset = 0, includeVector = false } = opts;
780
+
781
+ let query = this.table!.query();
782
+
783
+ const conditions: string[] = [];
784
+ if (scopeFilter && scopeFilter.length > 0) {
785
+ const scopeConditions = scopeFilter
786
+ .map(scope => {
787
+ const safe = escapeSqlLiteral(scope);
788
+ return scope.includes(":")
789
+ ? `scope = '${safe}'`
790
+ : `scope LIKE '${safe}%'`;
791
+ })
792
+ .join(" OR ");
793
+ conditions.push(`((${scopeConditions}))`);
794
+ }
795
+ if (category) {
796
+ conditions.push(`category = '${escapeSqlLiteral(category)}'`);
797
+ }
798
+ if (conditions.length > 0) {
799
+ query = query.where(conditions.join(" AND "));
800
+ }
801
+
802
+ const columns = ["id", "text", "category", "scope", "importance", "timestamp", "metadata"];
803
+ if (includeVector) columns.push("vector");
804
+
805
+ const results = await query
806
+ .select(columns)
807
+ .limit(limit)
808
+ .offset(offset)
809
+ .toArray();
810
+
811
+ return results.map((row): MemoryEntry => ({
812
+ id: row.id as string,
813
+ text: row.text as string,
814
+ vector: includeVector ? Array.from(row.vector as Iterable<number>) : [],
815
+ category: row.category as MemoryEntry["category"],
816
+ scope: (row.scope as string | undefined) ?? "",
817
+ importance: Number(row.importance),
818
+ timestamp: Number(row.timestamp),
819
+ metadata: (row.metadata as string) || "{}",
820
+ }));
821
+ }
822
+
691
823
  async get(id: string, scopeFilter?: string[]): Promise<MemoryEntry | null> {
692
824
  await this.ensureInitialized();
693
825
 
@@ -741,18 +873,35 @@ export class MemoryStore {
741
873
  }
742
874
 
743
875
  /**
744
- * Get a single entry by exact ID without scope filtering.
745
- * Lightweight read used by AccessTracker for metadata updates.
876
+ * Get a single entry by ID (full UUID) or 8+ hex prefix.
877
+ * Lightweight read used by AccessTracker for metadata updates and by skill-engine
878
+ * for outcome recording.
879
+ *
880
+ * v2.5.1 (2026-05-27) — 加 prefix lookup support:和 update() / delete() 行为对齐,
881
+ * 让 agent 拿到 short id(如 store_skill 返回的 8 位前缀、人类粘贴的截断 id)也能
882
+ * resolve 到完整 entry。歧义 prefix(匹配 >1 条)返回 null 让 caller 处理。
883
+ * 现有 caller 全部传完整 UUID,向后兼容。
746
884
  */
747
885
  async getById(id: string): Promise<MemoryEntry | null> {
748
886
  await this.ensureInitialized();
887
+
888
+ // Support both full UUID and 8+ hex prefix (same pattern as update() / delete()).
889
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
890
+ const prefixRegex = /^[0-9a-f]{8,}$/i;
891
+ const isFullId = uuidRegex.test(id);
892
+ const isPrefix = !isFullId && prefixRegex.test(id);
893
+ if (!isFullId && !isPrefix) return null;
894
+
749
895
  const safeId = escapeSqlLiteral(id);
896
+ const whereClause = isFullId ? `id = '${safeId}'` : `id LIKE '${safeId}%'`;
897
+
750
898
  const rows = await this.table!.query()
751
899
  .select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
752
- .where(`id = '${safeId}'`)
753
- .limit(1)
900
+ .where(whereClause)
901
+ .limit(2) // detect ambiguous prefix (matches >1)
754
902
  .toArray();
755
903
  if (rows.length === 0) return null;
904
+ if (isPrefix && rows.length > 1) return null; // ambiguous prefix
756
905
  const row = rows[0];
757
906
  return {
758
907
  id: row.id as string,
@@ -773,28 +922,28 @@ export class MemoryStore {
773
922
  */
774
923
  async getVectors(ids: string[]): Promise<Map<string, number[]>> {
775
924
  await this.ensureInitialized();
776
- if (ids.length === 0) return new Map();
777
-
778
- const conditions = ids.map(id => `id = '${escapeSqlLiteral(id)}'`).join(" OR ");
779
- const rows = await this.table!.query()
780
- .select(["id", "vector"])
781
- .where(conditions)
782
- .limit(ids.length)
783
- .toArray();
784
-
785
925
  const result = new Map<string, number[]>();
786
- for (const row of rows) {
787
- const vec = Array.from(row.vector as Iterable<number>);
788
- if (vec.length > 0) result.set(row.id as string, vec);
926
+ if (ids.length === 0) return result;
927
+
928
+ // 分批:避免 id 很多时 OR 链过长(如 data-checkup/memory-lint 一次补几千条向量)。
929
+ const BATCH = 500;
930
+ for (let i = 0; i < ids.length; i += BATCH) {
931
+ const batch = ids.slice(i, i + BATCH);
932
+ const conditions = batch.map(id => `id = '${escapeSqlLiteral(id)}'`).join(" OR ");
933
+ const rows = await this.table!.query()
934
+ .select(["id", "vector"])
935
+ .where(conditions)
936
+ .limit(batch.length)
937
+ .toArray();
938
+ for (const row of rows) {
939
+ const vec = Array.from(row.vector as Iterable<number>);
940
+ if (vec.length > 0) result.set(row.id as string, vec);
941
+ }
789
942
  }
790
943
  return result;
791
944
  }
792
945
 
793
- async stats(scopeFilter?: string[]): Promise<{
794
- totalCount: number;
795
- scopeCounts: Record<string, number>;
796
- categoryCounts: Record<string, number>
797
- }> {
946
+ async stats(scopeFilter?: string[]): Promise<MemoryStoreStats> {
798
947
  await this.ensureInitialized();
799
948
 
800
949
  let query = this.table!.query();
@@ -874,7 +1023,7 @@ export class MemoryStore {
874
1023
 
875
1024
  async update(
876
1025
  id: string,
877
- updates: { text?: string; vector?: number[]; importance?: number; category?: MemoryEntry["category"]; metadata?: string; timestamp?: number; language?: string; fts_text?: string },
1026
+ updates: MemoryStoreUpdate,
878
1027
  scopeFilter?: string[]
879
1028
  ): Promise<MemoryEntry | null> {
880
1029
  await this.ensureInitialized();
@@ -936,14 +1085,94 @@ export class MemoryStore {
936
1085
  }
937
1086
  }
938
1087
 
939
- // LanceDB doesn't support in-place update; delete + re-add
940
- const resolvedId = escapeSqlLiteral(row.id as string);
941
- await this.table!.delete(`id = '${resolvedId}'`);
942
- await this.table!.add([updated]);
1088
+ // Atomic upsert via mergeInsert — replaces the previous delete+add two-step,
1089
+ // which could lose the row entirely on a crash between steps and exposed a
1090
+ // window where concurrent reads saw the id momentarily vanish.
1091
+ await this.table!
1092
+ .mergeInsert("id")
1093
+ .whenMatchedUpdateAll()
1094
+ .whenNotMatchedInsertAll()
1095
+ .execute([updated]);
943
1096
 
944
1097
  return updated;
945
1098
  }
946
1099
 
1100
+ /**
1101
+ * Serialized read-modify-write for the metadata column.
1102
+ *
1103
+ * Every direct `getById → mutate metadata → update` round-trip can lose
1104
+ * concurrent increments (last write wins). This method funnels metadata
1105
+ * patches for the same id through a per-id promise queue so each patch
1106
+ * reads the result of the previous one.
1107
+ *
1108
+ * patchFn receives the parsed metadata object plus the freshly-read entry
1109
+ * (for callers that need importance/timestamp context) and returns the new
1110
+ * metadata object. A throwing patchFn abandons that patch — the error
1111
+ * propagates to the caller, later queued patches still run.
1112
+ *
1113
+ * Scope of guarantee: in-process only. Cross-process writers (multiple
1114
+ * mcp-server instances) and the remaining direct update() callers
1115
+ * (consolidation/capture/forget/prospective/conflict engines, auto-gc)
1116
+ * are NOT serialized by this queue. Long-term direction: make this the
1117
+ * only legal metadata write path.
1118
+ */
1119
+ async patchMetadata(
1120
+ id: string,
1121
+ patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>,
1122
+ scopeFilter?: string[],
1123
+ ): Promise<MemoryEntry | null> {
1124
+ await this.ensureInitialized();
1125
+
1126
+ // Queue key must be the resolved full UUID — otherwise a prefix caller and
1127
+ // a full-id caller targeting the same row would race on separate queues.
1128
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1129
+ let fullId: string;
1130
+ if (uuidRegex.test(id)) {
1131
+ fullId = id;
1132
+ } else {
1133
+ const resolved = await this.getById(id);
1134
+ if (!resolved) return null;
1135
+ fullId = resolved.id;
1136
+ }
1137
+
1138
+ const run = async (): Promise<MemoryEntry | null> => {
1139
+ const entry = await this.getById(fullId);
1140
+ if (!entry) return null;
1141
+ let meta: Record<string, unknown>;
1142
+ try {
1143
+ const parsed: unknown = JSON.parse(entry.metadata || "{}");
1144
+ meta = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
1145
+ ? (parsed as Record<string, unknown>)
1146
+ : {};
1147
+ } catch {
1148
+ meta = {};
1149
+ }
1150
+ const patched = patchFn(meta, entry);
1151
+ return this.update(fullId, { metadata: JSON.stringify(patched) }, scopeFilter);
1152
+ };
1153
+
1154
+ const prev = this.metadataPatchQueues.get(fullId) ?? Promise.resolve();
1155
+ // Isolate prior failures so one rejected patch can't wedge the queue.
1156
+ const result = prev.then(run, run);
1157
+ // The stored tail settles regardless of outcome; the caller keeps the real
1158
+ // rejection via `result`. Only the current tail removes itself.
1159
+ const tail = result.then(
1160
+ () => undefined,
1161
+ () => undefined,
1162
+ ).then(() => {
1163
+ if (this.metadataPatchQueues.get(fullId) === tail) {
1164
+ this.metadataPatchQueues.delete(fullId);
1165
+ }
1166
+ });
1167
+ this.metadataPatchQueues.set(fullId, tail);
1168
+ return result;
1169
+ }
1170
+
1171
+ /** Number of ids with in-flight metadata patches (test/diagnostic hook). */
1172
+ get pendingMetadataPatchCount(): number {
1173
+ return this.metadataPatchQueues.size;
1174
+ }
1175
+
947
1176
  async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
948
1177
  await this.ensureInitialized();
949
1178
 
@@ -59,7 +59,9 @@ const RULES: CompressionRule[] = [
59
59
 
60
60
  // Patterns that identify tool output blocks in conversation text.
61
61
  // Claude Code / Codex format: command on one line, output follows.
62
- const TOOL_OUTPUT_BLOCK = /^(?:\$\s+|❯\s+|>\s+)(.+)\n([\s\S]*?)(?=\n(?:\$\s+|❯\s+|>\s+)|\n\n[A-Z]|\n##|\z)/gm;
62
+ // 末项用 (?![\s\S]) 表示「字符串绝对结尾」。不能用 \z(JS 无此锚点,会被当字面字符 z →
63
+ // 在输出里第一个 z 处误截断),也不能用 $(gm 模式下匹配每行尾,惰性 [\s\S]*? 会在第一行就停)。
64
+ const TOOL_OUTPUT_BLOCK = /^(?:\$\s+|❯\s+|>\s+)(.+)\n([\s\S]*?)(?=\n(?:\$\s+|❯\s+|>\s+)|\n\n[A-Z]|\n##|(?![\s\S]))/gm;
63
65
 
64
66
  // Base64 image data (screenshots, etc.)
65
67
  const BASE64_BLOCK = /(?:data:image\/[^;]+;base64,)?[A-Za-z0-9+/]{500,}={0,2}/g;
package/src/ui-server.ts CHANGED
@@ -12,6 +12,7 @@ import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, buildPinAsse
12
12
  import { indexAsset, indexPinnedAsset } from "./asset-sync.js";
13
13
  import { createComponentResolver, loadConfig, loadDotEnv } from "./runtime-config.js";
14
14
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
15
+ import * as envConfig from "./env-config.js";
15
16
  const config = (loadDotEnv(), loadConfig());
16
17
  const getComponents = createComponentResolver(config);
17
18
 
@@ -186,7 +187,7 @@ async function handleSearch(mode: "search" | "explain" | "distill", body: Record
186
187
  }
187
188
 
188
189
  const server = Bun.serve({
189
- port: readLimit(process.env.RECALLNEST_UI_PORT, 4317, 1, 65535),
190
+ port: readLimit(envConfig.uiPortRaw(), 4317, 1, 65535),
190
191
  async fetch(request) {
191
192
  try {
192
193
  const url = new URL(request.url);