recallnest 3.0.1 → 4.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 +203 -0
  2. package/README.md +42 -17
  3. package/README_CN.md +31 -15
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +10 -2
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +434 -68
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer.ts +11 -2
  28. package/src/distill-lock.ts +246 -74
  29. package/src/dream-pipeline.ts +3 -1
  30. package/src/env-config.ts +118 -0
  31. package/src/flock.ts +97 -0
  32. package/src/forget-engine.ts +342 -58
  33. package/src/gateway-server.ts +4 -0
  34. package/src/ingest-plan.ts +56 -0
  35. package/src/ingest.ts +69 -104
  36. package/src/kg-contradiction.ts +753 -0
  37. package/src/kg-store.ts +1 -1
  38. package/src/known-item-eval.ts +455 -0
  39. package/src/llm-client.ts +13 -26
  40. package/src/llm-consolidation.ts +2 -2
  41. package/src/mcp-tool-deps.ts +6 -0
  42. package/src/mcp-tools-advanced.ts +80 -45
  43. package/src/mcp-tools-core.ts +23 -17
  44. package/src/mcp-tools-governance.ts +13 -7
  45. package/src/memory-assets.ts +144 -8
  46. package/src/memory-boundaries.ts +19 -0
  47. package/src/memory-evolution.ts +9 -0
  48. package/src/memory-lint.ts +1 -1
  49. package/src/memory-output.ts +276 -14
  50. package/src/memory-reconcile.ts +1437 -0
  51. package/src/memory-schema.ts +11 -0
  52. package/src/memory-store-port.ts +1 -0
  53. package/src/minis-archive.ts +151 -0
  54. package/src/noise-filter.ts +15 -2
  55. package/src/pii-detector.ts +31 -1
  56. package/src/pivot-apply.ts +3 -4
  57. package/src/preference-matcher.ts +40 -28
  58. package/src/prospective-memory.ts +7 -4
  59. package/src/retriever.ts +297 -57
  60. package/src/retroactive-boost.ts +3 -2
  61. package/src/runtime-config.ts +6 -1
  62. package/src/scope-policy.ts +1 -0
  63. package/src/session-distiller.ts +6 -2
  64. package/src/session-output.ts +11 -0
  65. package/src/session-schema.ts +5 -2
  66. package/src/session-store.ts +57 -9
  67. package/src/store-receipts.ts +109 -0
  68. package/src/store-write-budget.ts +37 -0
  69. package/src/store.ts +298 -57
  70. package/src/text-fingerprint.ts +25 -0
  71. package/src/trigger-store.ts +443 -0
  72. package/src/ui-server.ts +22 -21
  73. package/src/version-manager.ts +19 -5
  74. package/src/write-verifier.ts +16 -11
@@ -0,0 +1,443 @@
1
+ /**
2
+ * Trigger Store — 写入时预演触发器(借自腾讯 T-Mem,EMNLP 2026,arXiv 2606.15405)。
3
+ *
4
+ * 一条记忆存进来的时候,顺手存几句「她以后会怎么问起这件事」(trigger)。
5
+ * 这些句子单独嵌入、单独建表、只参与召回、永远不进证据:检索时用 query 向量
6
+ * 对 trigger 表做向量搜索,命中的 trigger 把它的宿主记忆拉进候选池,宿主按
7
+ * 「所有 trigger 里最高的那条」计分(T-Mem 的 nanmax 归属),再走既有打分链。
8
+ *
9
+ * 为什么另开一张表而不是塞进正文:正文向量是几百字的均值,尾巴上的一句问法
10
+ * 会被稀释(2026-09-22 实测:64df5691 写了「她以后可能会这样问起…」,换套口语
11
+ * 照样 top-10 不出现)。为什么不在主表加列:主表 12.8 万行,加列要迁移;侧表
12
+ * 可由主表 metadata.triggers 整表重建(`triggers-backfill --rebuild`),删掉即回退。
13
+ *
14
+ * trigger 文本用 embedQuery 嵌入:它本身就是「未来的问法」,与查询同侧任务
15
+ * (jina-v5 的 retrieval.query),query↔query 的余弦比 query↔passage 稳定。
16
+ */
17
+
18
+ import { resolve } from "node:path";
19
+ import type * as LanceDB from "@lancedb/lancedb";
20
+ import { loadLanceDB, escapeSqlLiteral, scopeWhereClause } from "./store.js";
21
+ import { matchesScopeFilter, type ScopeMatchMode } from "./scope-policy.js";
22
+ import { readConsistencyInterval as envReadConsistencyInterval } from "./env-config.js";
23
+ import { logWarn } from "./stderr-log.js";
24
+ import { textOverlapScore, tokenize } from "./multi-vector.js";
25
+ import { redactSecrets } from "./pii-detector.js";
26
+ import { withWriteLock as withCrossProcessLock } from "./distill-lock.js";
27
+
28
+ export const TRIGGER_TABLE_NAME = "memory_triggers";
29
+
30
+ /** 每条记忆最多存几条 trigger(T-Mem 每条 item 2–6 个,这里取上限 6)。 */
31
+ export const MAX_TRIGGERS_PER_MEMORY = 6;
32
+ export const MIN_TRIGGER_LENGTH = 2;
33
+ export const MAX_TRIGGER_LENGTH = 200;
34
+
35
+ export interface TriggerRow {
36
+ id: string;
37
+ memory_id: string;
38
+ scope: string;
39
+ text: string;
40
+ vector: number[];
41
+ created_at: number;
42
+ }
43
+
44
+ export interface TriggerHit {
45
+ memoryId: string;
46
+ /** 命中的那条 trigger 原文(只用于 explain,不进证据) */
47
+ text: string;
48
+ /** 余弦相似度(1 - cosine distance) */
49
+ cosine: number;
50
+ /** 与主表 vectorSearch 同一映射:1 / (1 + distance),可直接与向量分比较 */
51
+ score: number;
52
+ scope: string;
53
+ /** query 与 trigger 文本的词面重合(textOverlapScore),只在传了 queryText 时有 */
54
+ overlap?: number;
55
+ /** 靠哪道闸放行:hard = 余弦过硬闸;soft = 余弦过软闸 + 词面重合过线 */
56
+ admittedBy: "hard" | "soft";
57
+ }
58
+
59
+ export interface TriggerSearchOptions {
60
+ /** 余弦硬闸(≥ 即放行) */
61
+ minCosine?: number;
62
+ /** 余弦软闸:≥ softCosine 且 overlap ≥ minOverlap 也放行;需同时给 queryText */
63
+ softCosine?: number;
64
+ minOverlap?: number;
65
+ /** 软闸只对去停用词后 ≥ 这么多 token 的 query 开(关键词短 query 不走软闸) */
66
+ minQueryTokens?: number;
67
+ queryText?: string;
68
+ scopeMatch?: ScopeMatchMode;
69
+ }
70
+
71
+ export interface TriggerStoreConfig {
72
+ dbPath: string;
73
+ vectorDim: number;
74
+ readConsistencyInterval?: number;
75
+ /** 跨进程写锁的锁文件目录;不传用 distill-lock 的默认目录(<dataDir>/locks)。测试指到临时目录。 */
76
+ lockDir?: string;
77
+ }
78
+
79
+ /**
80
+ * 侧表写锁的名字。按库目录区分:同一个库的所有进程共用一把,不同的库(测试的临时库)互不等待。
81
+ */
82
+ export function triggerWriteLockKey(dbPath: string): string {
83
+ return `trigger-write:${resolve(dbPath)}`;
84
+ }
85
+
86
+ export interface TriggerStoreStats {
87
+ rows: number;
88
+ memories: number;
89
+ }
90
+
91
+ /**
92
+ * 规整 trigger 文本:脱敏、去空白、去重(不分大小写)、丢太短太长的、封顶 6 条。
93
+ * 脱敏放在这里(2026-10-17):trigger 原文会进 metadata.triggers、侧表和外部嵌入接口,
94
+ * 写入、回填、侧表重建都经过这一个函数。
95
+ */
96
+ export function normalizeTriggerTexts(texts: readonly string[] | undefined | null): string[] {
97
+ if (!texts || texts.length === 0) return [];
98
+ const seen = new Set<string>();
99
+ const out: string[] = [];
100
+ for (const raw of texts) {
101
+ if (typeof raw !== "string") continue;
102
+ const text = redactSecrets(raw, { keepIdentifiers: true }).text.replace(/\s+/g, " ").trim().replace(/[。..!!??;;,,、]+$/u, "").trim();
103
+ if (text.length < MIN_TRIGGER_LENGTH || text.length > MAX_TRIGGER_LENGTH) continue;
104
+ const key = text.toLowerCase();
105
+ if (seen.has(key)) continue;
106
+ seen.add(key);
107
+ out.push(text);
108
+ if (out.length >= MAX_TRIGGERS_PER_MEMORY) break;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * 从既有正文里把「她以后会怎么问」那段抠出来拆成 trigger 候选(回填用,零 LLM)。
115
+ *
116
+ * 认的是 §5.3 那条纪律的各种手写变体:「她以后会这么问起:「A」「B」」「她以后可能会这样问起:A、B、C」
117
+ * 「检索锚点:…」「她会说…」。段落里有引号就取引号内的,没有就按 、;/ 或 拆。
118
+ * 只认正文里已经写下的话,不生成、不改写——生成是另一条线(qwen-turbo,按 T-Mem 三条禁令),
119
+ * 且要等批量提炼那批的忠实度有结论再做。
120
+ */
121
+ export function extractTriggerCandidates(source: string): string[] {
122
+ if (!source) return [];
123
+ // 先对整段原文脱敏再拆:按 `/` 拆会把 `https://user:密码@host` 拆成不带 scheme 的
124
+ // `user:密码@host`,规整时的脱敏就认不出来了(回填会把它嵌入、写进侧表与 metadata)。
125
+ const text = redactSecrets(source, { keepIdentifiers: true }).text;
126
+ const lead = /(她以后[^。\n]{0,6}问[起法]?|她(?:大概|可能|应该|以后)?会(?:这么|这样|怎么|这样子)?(?:问|说|提)(?:起)?|检索锚点|以后[^。\n]{0,8}问起|下次[^。\n]{0,6}(?:会|再)[^。\n]{0,4}(?:问|提))/gu;
127
+ // 取**最后一次**出现:这句纪律写在正文末尾(09-22 实测 98% 在最后 30%),
128
+ // 正文中段提到「她以后会怎么问」多半是在讨论这条纪律本身(如本项目自己的记忆),不是问法。
129
+ let m: RegExpExecArray | null = null;
130
+ for (const hit of text.matchAll(lead)) m = hit;
131
+ if (!m || m.index === undefined) return [];
132
+ // 长正文里这句若离末尾太远(后面还拖着六成正文),那是在讨论纪律本身,不是问法
133
+ if (text.length > 200 && (text.length - m.index) / text.length > 0.6) return [];
134
+ // 从触发词起到段落结束(下一个空行)为止
135
+ let segment = text.slice(m.index);
136
+ const paraEnd = segment.search(/\n\s*\n/);
137
+ if (paraEnd > 0) segment = segment.slice(0, paraEnd);
138
+ // 去掉引导语本身(到第一个冒号为止)
139
+ const colon = segment.search(/[::]/);
140
+ const body = colon >= 0 && colon < 40 ? segment.slice(colon + 1) : segment.slice(m[0].length);
141
+
142
+ const quoted = [...body.matchAll(/[「“"『]([^」”"』]{2,200})[」”"』]/gu)].map((q) => q[1]);
143
+ let parts: string[];
144
+ if (quoted.length > 0) {
145
+ parts = quoted;
146
+ } else {
147
+ parts = body
148
+ .split(/[、;;//\n]|,或者|或者|、或|或是/u)
149
+ .map((p) => p.trim())
150
+ .filter(Boolean);
151
+ }
152
+ const leadOnly = new RegExp(lead.source, "u");
153
+ const cleaned = parts
154
+ .map((p) => p.replace(/^(?:比如|例如|像|如)[::]?\s*/u, "").replace(/^[「“"『]|[」”"』]$/gu, "").trim())
155
+ // 拆出来的最后一段常常拖着后文(「……这类问题。」),剪掉句号后的尾巴
156
+ .map((p) => p.split(/[。!?!?]/u)[0].trim())
157
+ .filter((p) => p.length >= 4 && p.length <= MAX_TRIGGER_LENGTH)
158
+ // 引导语自己、日期开头的引用、纯路径 —— 都不是问法
159
+ .filter((p) => !leadOnly.test(p))
160
+ .filter((p) => !/^\d{4}-\d{2}(-\d{2})?/.test(p))
161
+ .filter((p) => !(/[\/\\]/.test(p) && !/\s/.test(p)));
162
+ return normalizeTriggerTexts(cleaned);
163
+ }
164
+
165
+ export class TriggerStore {
166
+ private table: LanceDB.Table | null = null;
167
+ private initPromise: Promise<void> | null = null;
168
+ private writeChain: Promise<unknown> = Promise.resolve();
169
+
170
+ constructor(private readonly config: TriggerStoreConfig) {}
171
+
172
+ get dbPath(): string {
173
+ return this.config.dbPath;
174
+ }
175
+
176
+ private withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
177
+ const run = this.writeChain.then(fn, fn);
178
+ this.writeChain = run.catch(() => {});
179
+ return run;
180
+ }
181
+
182
+ private async ensureInitialized(): Promise<void> {
183
+ if (this.table) return;
184
+ if (this.initPromise) return this.initPromise;
185
+ this.initPromise = this.doInitialize().catch((err) => {
186
+ this.initPromise = null;
187
+ throw err;
188
+ });
189
+ return this.initPromise;
190
+ }
191
+
192
+ private async doInitialize(): Promise<void> {
193
+ const lancedb = await loadLanceDB();
194
+ const db = await lancedb.connect(this.config.dbPath, {
195
+ readConsistencyInterval:
196
+ this.config.readConsistencyInterval ?? envReadConsistencyInterval(),
197
+ });
198
+
199
+ let table: LanceDB.Table;
200
+ try {
201
+ table = await db.openTable(TRIGGER_TABLE_NAME);
202
+ } catch {
203
+ const schemaRow: TriggerRow = {
204
+ id: "__schema__",
205
+ memory_id: "__schema__",
206
+ scope: "__schema__",
207
+ text: "",
208
+ vector: new Array(this.config.vectorDim).fill(0),
209
+ created_at: 0,
210
+ };
211
+ try {
212
+ table = await db.createTable(TRIGGER_TABLE_NAME, [schemaRow]);
213
+ await table.delete(`id = '__schema__'`);
214
+ } catch (createErr) {
215
+ if (String(createErr).includes("already exists")) {
216
+ table = await db.openTable(TRIGGER_TABLE_NAME);
217
+ } else {
218
+ throw createErr;
219
+ }
220
+ }
221
+ }
222
+ this.table = table;
223
+ }
224
+
225
+ async ready(): Promise<void> {
226
+ await this.ensureInitialized();
227
+ }
228
+
229
+ /**
230
+ * 覆盖式写入一条记忆的全部 trigger,同一 memory_id 永远只有一组。
231
+ * `embed` 由调用方传入(应为 embedQuery 批量版),这里不持有 embedder。
232
+ * 返回实际写入的条数;texts 规整后为空则只删不加、返回 0。
233
+ *
234
+ * 先嵌入、后动表(2026-10-17):嵌入是网络调用,原先先删旧行再嵌入,嵌入一失败旧 trigger 就全没了。
235
+ * 现在嵌入失败或条数对不上时直接抛错,表一行不动。
236
+ *
237
+ * 动表在跨进程写锁里(`triggerWriteLockKey`,distill-lock 的 withWriteLock):进程内的写锁管不到
238
+ * 别的 MCP / CLI 进程。两个进程同时给一条还没有 trigger 的记忆首写,按 id 合并时双方都只是插入、
239
+ * 不改已有行,Lance 不判冲突,会写出两组(2026-10-17 复审)。嵌入在跨进程锁外面做,锁只罩住读表与一次提交;
240
+ * 进程内的写锁仍罩住嵌入,同一进程里后发起的重写一定后落表。
241
+ *
242
+ * 换新是一次 mergeInsert 提交,合并键是 (id, created_at):新行的 created_at 在锁内取成比这条记忆
243
+ * 现有各行都大,所以没有一行旧行对得上,新行全部插入,这条记忆的旧行全部经
244
+ * whenNotMatchedBySourceDelete(限定本 memory_id)在同一个提交里删掉。只按 id 合并时,旧竞态留下的
245
+ * 两行同 id 会同时对上一条新行、两行都被更新而不是删掉,重复行永远修不掉(2026-10-17 复审)。
246
+ */
247
+ async upsertForMemory(
248
+ memoryId: string,
249
+ scope: string,
250
+ texts: readonly string[],
251
+ embed: (texts: string[]) => Promise<number[][]>,
252
+ ): Promise<number> {
253
+ const cleaned = normalizeTriggerTexts(texts);
254
+ return this.withWriteLock(async () => {
255
+ await this.ensureInitialized();
256
+ const where = `memory_id = '${escapeSqlLiteral(memoryId)}'`;
257
+ if (cleaned.length === 0) {
258
+ await this.withSharedLock(async (table) => { await table.delete(where); });
259
+ return 0;
260
+ }
261
+ const vectors = await embed(cleaned);
262
+ if (vectors.length !== cleaned.length) {
263
+ throw new Error(`TriggerStore: embed returned ${vectors.length} vectors for ${cleaned.length} texts`);
264
+ }
265
+ return this.withSharedLock(async (table) => {
266
+ const existing = await table.query().where(where).select(["created_at"]).toArray();
267
+ let createdAt = Date.now();
268
+ for (const row of existing) {
269
+ const prev = Number((row as Record<string, unknown>).created_at);
270
+ if (Number.isFinite(prev) && prev >= createdAt) createdAt = prev + 1;
271
+ }
272
+ const rows: TriggerRow[] = cleaned.map((text, i) => ({
273
+ id: `${memoryId}#${i}`,
274
+ memory_id: memoryId,
275
+ scope,
276
+ text,
277
+ vector: vectors[i],
278
+ created_at: createdAt,
279
+ }));
280
+ await table.mergeInsert(["id", "created_at"])
281
+ .whenNotMatchedInsertAll()
282
+ .whenNotMatchedBySourceDelete({ where })
283
+ .execute(rows);
284
+ return rows.length;
285
+ });
286
+ });
287
+ }
288
+
289
+ /**
290
+ * 删掉一条记忆的全部 trigger 行,返回删了几行。
291
+ * forget 引擎在删主行之前调它(2026-10-05 之前这个方法没有任何调用方,
292
+ * 记忆删掉后它的问法原文和向量留在侧表里)。
293
+ * 「数」和「删」都在跨进程写锁里,别的进程经 upsertForMemory 写这条记忆时插不进中间;
294
+ * 返回值就是删掉的行数。forget 引擎删完主行后会再数一次(countForMemory)。
295
+ * 侧表还不存在时,这里和 countForMemory 都会像写入路径一样把它建出来(空表)。
296
+ */
297
+ async deleteForMemory(memoryId: string): Promise<number> {
298
+ return this.withWriteLock(async () => {
299
+ await this.ensureInitialized();
300
+ const where = `memory_id = '${escapeSqlLiteral(memoryId)}'`;
301
+ return this.withSharedLock(async (table) => {
302
+ const rows = await table.countRows(where);
303
+ if (rows > 0) await table.delete(where);
304
+ return rows;
305
+ });
306
+ });
307
+ }
308
+
309
+ /**
310
+ * 持跨进程写锁跑一段写表操作。拿到锁之后先把表句柄切到最新版本:
311
+ * 句柄可能停在等锁之前看到的版本,锁内的读(现有行的 created_at、删前计数)要看到别的进程刚提交的行。
312
+ */
313
+ private withSharedLock<T>(fn: (table: LanceDB.Table) => Promise<T>): Promise<T> {
314
+ // lock-wait: no store-write inside — fn only touches the memory_triggers side table.
315
+ return withCrossProcessLock(triggerWriteLockKey(this.config.dbPath), async () => {
316
+ const table = this.table!;
317
+ await table.checkoutLatest();
318
+ return fn(table);
319
+ }, {
320
+ expireMs: 30_000,
321
+ waitTimeoutMs: 30_000,
322
+ ...(this.config.lockDir ? { lockDir: this.config.lockDir } : {}),
323
+ });
324
+ }
325
+
326
+ /** 这条记忆现在还有几行 trigger(forget 删后回读用)。 */
327
+ async countForMemory(memoryId: string): Promise<number> {
328
+ await this.ensureInitialized();
329
+ return this.table!.countRows(`memory_id = '${escapeSqlLiteral(memoryId)}'`);
330
+ }
331
+
332
+ /**
333
+ * 用 query 向量搜 trigger,按宿主聚合取最高分(nanmax 归属),再过闸。
334
+ * 两道闸(2026-09-22 校准,见 env-config triggerGate / triggerSoftGate):
335
+ * hard:cosine ≥ minCosine;
336
+ * soft:query 去停用词 ≥ minQueryTokens 个 token,且 cosine ≥ softCosine,且 textOverlapScore(queryText, trigger) ≥ minOverlap。
337
+ * 为什么要软闸:jina-v5-small 上「腾讯那篇论文的榜我们要不要也去排个名」对
338
+ * 「别的记忆系统的榜我们要不要也去排个名」余弦只有 0.44,与无关噪声(最高 0.53)分不开,
339
+ * 但两句共用「榜 / 要不要 / 排个名」——词面是第二根轴,T-Mem 每层也是词法 + 稠密一起用。
340
+ * scope 过滤下推到 SQL(与主表 vectorSearch 同款,2026-08-16 互审 C1 的教训)。
341
+ */
342
+ async search(
343
+ queryVector: number[],
344
+ topK: number,
345
+ scopeFilter?: string[],
346
+ options: TriggerSearchOptions = {},
347
+ ): Promise<TriggerHit[]> {
348
+ await this.ensureInitialized();
349
+ const minCosine = options.minCosine ?? 0;
350
+ const minOverlap = options.minOverlap ?? 1;
351
+ const minQueryTokens = options.minQueryTokens ?? 0;
352
+ // 软闸的前提:query 像一句话(不是两个关键词)——否则一个共用词就能把词面重合撑高
353
+ const softEligible = options.softCosine !== undefined
354
+ && !!options.queryText
355
+ && tokenize(options.queryText).length >= minQueryTokens;
356
+ const softCosine = softEligible ? options.softCosine : undefined;
357
+ const scopeMatch = options.scopeMatch ?? "family";
358
+ const safeTopK = Math.max(1, Math.min(50, Math.floor(topK)));
359
+ // 一个宿主可能有多条 trigger 都命中,多取一些再聚合
360
+ const fetchLimit = Math.min(safeTopK * MAX_TRIGGERS_PER_MEMORY, 200);
361
+
362
+ let query = this.table!.vectorSearch(queryVector).distanceType("cosine").limit(fetchLimit);
363
+ if (scopeFilter && scopeFilter.length > 0) {
364
+ query = query.where(`(${scopeWhereClause(scopeFilter, scopeMatch)})`);
365
+ }
366
+
367
+ let rows: Array<Record<string, unknown>>;
368
+ try {
369
+ rows = await query.toArray();
370
+ } catch (err) {
371
+ logWarn("TriggerStore.search failed, continuing without trigger recall:", err);
372
+ return [];
373
+ }
374
+
375
+ const best = new Map<string, TriggerHit>();
376
+ for (const row of rows) {
377
+ const memoryId = String(row.memory_id ?? "");
378
+ if (!memoryId || memoryId === "__schema__") continue;
379
+ const rowScope = String(row.scope ?? "");
380
+ if (!matchesScopeFilter(rowScope, scopeFilter, scopeMatch)) continue;
381
+ const distance = Number(row._distance ?? 1);
382
+ const cosine = 1 - distance;
383
+ const text = String(row.text ?? "");
384
+ let admittedBy: "hard" | "soft" | null = null;
385
+ let overlap: number | undefined;
386
+ if (cosine >= minCosine) {
387
+ admittedBy = "hard";
388
+ } else if (softCosine !== undefined && options.queryText && cosine >= softCosine) {
389
+ overlap = textOverlapScore(options.queryText, text);
390
+ if (overlap >= minOverlap) admittedBy = "soft";
391
+ }
392
+ if (!admittedBy) continue;
393
+ const hit: TriggerHit = {
394
+ memoryId,
395
+ text,
396
+ cosine,
397
+ score: 1 / (1 + distance),
398
+ scope: rowScope,
399
+ overlap,
400
+ admittedBy,
401
+ };
402
+ const prev = best.get(memoryId);
403
+ if (!prev || hit.cosine > prev.cosine) best.set(memoryId, hit);
404
+ }
405
+ return [...best.values()]
406
+ .sort((a, b) => b.cosine - a.cosine)
407
+ .slice(0, safeTopK);
408
+ }
409
+
410
+ /** 列出某 scope 下已有 trigger 的宿主 id(回填时跳过已做的)。 */
411
+ async listMemoryIds(scopeFilter?: string[]): Promise<Set<string>> {
412
+ await this.ensureInitialized();
413
+ let query = this.table!.query().select(["memory_id", "scope"]);
414
+ if (scopeFilter && scopeFilter.length > 0) {
415
+ query = query.where(`(${scopeWhereClause(scopeFilter)})`);
416
+ }
417
+ const rows = await query.toArray();
418
+ const ids = new Set<string>();
419
+ for (const row of rows) {
420
+ const id = String((row as Record<string, unknown>).memory_id ?? "");
421
+ if (id && id !== "__schema__") ids.add(id);
422
+ }
423
+ return ids;
424
+ }
425
+
426
+ async stats(scopeFilter?: string[]): Promise<TriggerStoreStats> {
427
+ await this.ensureInitialized();
428
+ let query = this.table!.query().select(["memory_id", "scope"]);
429
+ if (scopeFilter && scopeFilter.length > 0) {
430
+ query = query.where(`(${scopeWhereClause(scopeFilter)})`);
431
+ }
432
+ const rows = await query.toArray();
433
+ const memories = new Set<string>();
434
+ let count = 0;
435
+ for (const row of rows) {
436
+ const id = String((row as Record<string, unknown>).memory_id ?? "");
437
+ if (!id || id === "__schema__") continue;
438
+ count += 1;
439
+ memories.add(id);
440
+ }
441
+ return { rows: count, memories: memories.size };
442
+ }
443
+ }
package/src/ui-server.ts CHANGED
@@ -5,15 +5,15 @@ import { join, resolve, sep } from "node:path";
5
5
 
6
6
  import { metaDir } from "./compat.js";
7
7
  import type { RetrievalResult } from "./retriever.js";
8
- import type { MemoryStore } from "./store.js";
9
8
  import { distillResults, formatExplainResults, formatSearchResults, selectBriefSeedResults, summarizeResults } from "./memory-output.js";
10
9
  import { extractMemoryProvenance } from "./memory-boundaries.js";
11
- import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, buildPinAsset, listDirtyBriefAssets, listExportArtifacts, listMemoryAssets, saveBriefAsset, savePinAsset, writeExportArtifact } from "./memory-assets.js";
12
- import { indexAsset, indexPinnedAsset } from "./asset-sync.js";
10
+ import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, formatDirtyBriefEmptyMessage, formatDirtyBriefReadErrors, listExportArtifacts, scanDirtyBriefAssets, listMemoryAssets, saveBriefAsset, writeExportArtifact } from "./memory-assets.js";
11
+ import { indexAsset, pinMemory } from "./asset-sync.js";
13
12
  import { createComponentResolver, loadConfig, loadDotEnv } from "./runtime-config.js";
14
13
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
15
14
  import { enforceLocalHttpRequestPolicy, LOCAL_HTTP_HOSTNAME } from "./http-request-policy.js";
16
15
  import * as envConfig from "./env-config.js";
16
+ import { HTTP_WRITE_IDLE_TIMEOUT_S } from "./store-write-budget.js";
17
17
  const config = (loadDotEnv(), loadConfig());
18
18
  const getComponents = createComponentResolver(config);
19
19
 
@@ -26,14 +26,6 @@ class UiHttpError extends Error {
26
26
  }
27
27
  }
28
28
 
29
- function entryToRetrievalResult(entry: Awaited<ReturnType<MemoryStore["get"]>>): RetrievalResult {
30
- if (!entry) throw new Error("Memory entry not found.");
31
- return {
32
- entry,
33
- score: entry.importance || 0.7,
34
- sources: { fused: { score: entry.importance || 0.7 } },
35
- };
36
- }
37
29
 
38
30
  async function readJson(request: Request) {
39
31
  try {
@@ -112,6 +104,7 @@ function getRetrievalPath(result: RetrievalResult): string {
112
104
  if (result.sources.vector) parts.push("vector");
113
105
  if (result.sources.bm25) parts.push("bm25");
114
106
  if (result.sources.reranked) parts.push("reranked");
107
+ if (result.sources.trigger) parts.push("trigger");
115
108
  return parts.join("+") || "direct";
116
109
  }
117
110
 
@@ -189,6 +182,9 @@ async function handleSearch(mode: "search" | "explain" | "distill", body: Record
189
182
 
190
183
  const server = Bun.serve({
191
184
  port: readLimit(envConfig.uiPortRaw(), 4317, 1, 65535),
185
+ // Writes can wait up to 30 s for store-write before they start; Bun's 10 s default would drop
186
+ // the connection while the handler still commits.
187
+ idleTimeout: HTTP_WRITE_IDLE_TIMEOUT_S,
192
188
  hostname: LOCAL_HTTP_HOSTNAME,
193
189
  async fetch(request) {
194
190
  try {
@@ -305,13 +301,16 @@ const server = Bun.serve({
305
301
  }
306
302
 
307
303
  if (request.method === "GET" && url.pathname === "/api/dirty-briefs") {
308
- const rows = listDirtyBriefAssets();
304
+ const scan = scanDirtyBriefAssets();
305
+ const rows = scan.items;
306
+ const readErrors = formatDirtyBriefReadErrors(scan.readErrors);
309
307
  const output = rows.length === 0
310
- ? "No dirty briefs found."
311
- : rows.map((row) => `${row.id.slice(0, 8)} ${row.title} [${row.scope}] ${row.reasons.join("; ")}`).join("\n");
308
+ ? formatDirtyBriefEmptyMessage(scan)
309
+ : rows.map((row) => `${row.id.slice(0, 8)} ${row.title} [${row.scope}] ${row.reasons.join("; ")}`).join("\n") + (readErrors ? `\n\n${readErrors}` : "");
312
310
  return Response.json({
313
311
  output,
314
312
  count: rows.length,
313
+ readErrors: scan.readErrors,
315
314
  items: rows.map((row) => ({
316
315
  id: row.id,
317
316
  shortId: row.id.slice(0, 8),
@@ -335,15 +334,14 @@ const server = Bun.serve({
335
334
  });
336
335
  const entry = await store.get(memoryId, scopeSelection.scopeFilter);
337
336
  if (!entry) return textResponse(`Memory not found: ${memoryId}`, { status: 404 });
338
- await store.update(entry.id, { importance: Math.max(entry.importance || 0.7, 0.95) }, scopeSelection.scopeFilter);
339
- const asset = buildPinAsset(entryToRetrievalResult(entry), {
337
+ const pinned = await pinMemory(store, embedder, entry, scopeSelection.scopeFilter, {
340
338
  title: readOptionalString(body.title),
341
339
  summary: readOptionalString(body.summary),
342
340
  query: readOptionalString(body.query),
343
341
  profile: readOptionalString(body.profile) as any || "default",
344
342
  });
345
- const path = savePinAsset(asset);
346
- await indexPinnedAsset(store, embedder, asset);
343
+ if (!pinned) return textResponse(`Memory not found: ${memoryId}`, { status: 404 });
344
+ const { asset, path } = pinned;
347
345
  return Response.json({
348
346
  output: `Pinned ${asset.id.slice(0, 8)}\nMemory: ${entry.id.slice(0, 8)} (${entry.scope})\nPath: ${path}`,
349
347
  assetId: asset.id,
@@ -381,9 +379,11 @@ const server = Bun.serve({
381
379
  }
382
380
 
383
381
  if (request.method === "POST" && url.pathname === "/api/clean-dirty-briefs") {
384
- const rows = listDirtyBriefAssets();
382
+ const scan = scanDirtyBriefAssets();
383
+ const rows = scan.items;
384
+ const readErrors = formatDirtyBriefReadErrors(scan.readErrors);
385
385
  if (rows.length === 0) {
386
- return Response.json({ output: "No dirty briefs found.", count: 0, archived: 0, deleted: 0 });
386
+ return Response.json({ output: formatDirtyBriefEmptyMessage(scan), count: 0, archived: 0, deleted: 0, readErrors: scan.readErrors });
387
387
  }
388
388
 
389
389
  let archived = 0;
@@ -395,10 +395,11 @@ const server = Bun.serve({
395
395
  }
396
396
 
397
397
  return Response.json({
398
- output: `Dirty briefs: ${rows.length}\nArchived: ${archived}\nIndex rows deleted: ${deleted}`,
398
+ output: `Dirty briefs: ${rows.length}\nArchived: ${archived}\nIndex rows deleted: ${deleted}` + (readErrors ? `\n\n${readErrors}` : ""),
399
399
  count: rows.length,
400
400
  archived,
401
401
  deleted,
402
+ readErrors: scan.readErrors,
402
403
  });
403
404
  }
404
405
 
@@ -117,14 +117,14 @@ export function computeVersionGroupPatch(
117
117
  * @param canonical The stronger entry (higher canonicalScore)
118
118
  * @param member The weaker entry
119
119
  * @param scope Scope filter for updates
120
- * @returns The version_group ID
120
+ * @returns The version_group ID, or null when either row was deleted before it could be tagged
121
121
  */
122
122
  export async function createVersionGroup(
123
- store: Pick<MemoryStorePort, "update">,
123
+ store: Pick<MemoryStorePort, "update" | "patchMetadata">,
124
124
  canonical: MemoryEntry,
125
125
  member: MemoryEntry,
126
126
  scope: string,
127
- ): Promise<string> {
127
+ ): Promise<string | null> {
128
128
  // Check if canonical already has a version_group
129
129
  const canonMeta = parseMetadata(canonical.metadata);
130
130
  const existingGroup = typeof canonMeta.version_group === "string" ? canonMeta.version_group : null;
@@ -136,7 +136,10 @@ export async function createVersionGroup(
136
136
  canonMeta.version_group = groupId;
137
137
  canonMeta.version_rank = canonRank;
138
138
  if (!canonMeta.version_created) canonMeta.version_created = now;
139
- await store.update(canonical.id, { metadata: JSON.stringify(canonMeta) }, [scope]);
139
+ // update() returns null when the row was deleted after it was read: a group needs both
140
+ // rows, so a vanished canonical stops here and the member is left untouched.
141
+ const canonUpdated = await store.update(canonical.id, { metadata: JSON.stringify(canonMeta) }, [scope]);
142
+ if (!canonUpdated) return null;
140
143
 
141
144
  // Update member
142
145
  const memberMeta = parseMetadata(member.metadata);
@@ -144,7 +147,18 @@ export async function createVersionGroup(
144
147
  memberMeta.version_group = groupId;
145
148
  memberMeta.version_rank = memberRank;
146
149
  memberMeta.version_created = now;
147
- await store.update(member.id, { metadata: JSON.stringify(memberMeta) }, [scope]);
150
+ const memberUpdated = await store.update(member.id, { metadata: JSON.stringify(memberMeta) }, [scope]);
151
+ if (!memberUpdated) {
152
+ // Member vanished: a group created just now would be left with the canonical alone —
153
+ // take the three group keys back off it. A pre-existing group keeps its other members.
154
+ // Rolled back through the locked read-modify-write: it re-reads the canonical's current
155
+ // metadata and clears the three keys only while the group is still the one set here, so
156
+ // another writer's metadata committed since is kept (the stale canonMeta snapshot is not).
157
+ if (!existingGroup) {
158
+ await store.patchMetadata(canonical.id, (meta) => clearVersionGroupMetadata(meta, groupId), [scope]);
159
+ }
160
+ return null;
161
+ }
148
162
 
149
163
  logInfo(
150
164
  `[INFO] Version group ${groupId.slice(0, 8)}: ` +
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * HP-2: Formation Review Pass — post-write verification.
3
- * After storing a memory, verify: embedding exists, metadata complete, scope set.
3
+ * After storing a memory, verify: embedding exists, text non-empty, scope and importance set, metadata parses.
4
4
  * Non-blocking: failures are logged but never block the write path.
5
5
  */
6
6
 
@@ -31,7 +31,8 @@ export type VerificationIssue =
31
31
  | "missing_vector"
32
32
  | "missing_scope"
33
33
  | "missing_importance"
34
- | "empty_text";
34
+ | "empty_text"
35
+ | "corrupt_metadata";
35
36
 
36
37
  export interface VerificationResult {
37
38
  ok: boolean;
@@ -107,19 +108,23 @@ function checkEntry(entry: MemoryEntry | null, id: string): VerificationIssue[]
107
108
  issues.push("empty_text");
108
109
  }
109
110
 
110
- // Metadata should contain scope
111
+ // scope / importance 是行上的列,不在 metadata 里——buildStructuredMetadata 从不写这两个字段。
112
+ // 原先查 metadata,对每一次真实写入都报 missing_scope + missing_importance(agy-sync 日志
113
+ // 5,052 条告警里 5,051 条是它,2026-09-24 统计),真正有用的 missing_entry 被埋在里面。
114
+ if (typeof entry.scope !== "string" || entry.scope.trim().length === 0) {
115
+ issues.push("missing_scope");
116
+ }
117
+ if (typeof entry.importance !== "number" || !Number.isFinite(entry.importance)) {
118
+ issues.push("missing_importance");
119
+ }
120
+
121
+ // Metadata has to parse: every downstream reader JSON.parse()s it.
111
122
  if (entry.metadata) {
112
123
  try {
113
- const meta = JSON.parse(entry.metadata);
114
- if (!meta.scope) issues.push("missing_scope");
115
- if (typeof meta.importance !== "number") issues.push("missing_importance");
124
+ JSON.parse(entry.metadata);
116
125
  } catch {
117
- issues.push("missing_scope");
118
- issues.push("missing_importance");
126
+ issues.push("corrupt_metadata");
119
127
  }
120
- } else {
121
- issues.push("missing_scope");
122
- issues.push("missing_importance");
123
128
  }
124
129
 
125
130
  return issues;