recallnest 2.6.0 → 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 +7 -5
  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
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { MemoryStorePort } from "./memory-store-port.js";
18
+ import type { ScopeMatchMode } from "./scope-policy.js";
18
19
  import type { LLMClient } from "./llm-client.js";
19
20
  import type { Embedder } from "./embedder.js";
20
21
  import { ConsolidationEngine, clusterAndConsolidate, DEFAULT_CONSOLIDATION_CONFIG, isDerivedInsight, type ConsolidationKGSource } from "./consolidation-engine.js";
@@ -45,12 +46,14 @@ export interface DreamConfig {
45
46
  extractPatterns: boolean;
46
47
  /** Max entries to scan per consolidation run (default: 500) */
47
48
  maxEntriesPerRun: number;
48
- /** Wall-clock budget for one `dream --auto` sweep, ms (default: 6h).
49
+ /** Wall-clock budget for one `dream --auto` sweep, ms (default: 2h).
49
50
  * 防跨天兜底闸,不是调度器。2026-07-24 那轮跑了 4 天 15 小时(见 cli.ts --auto 分支
50
51
  * 与 scripts/dream-consolidation.sh:57 的注释),期间堵死三天调度。
51
- * 已知天花板:超预算后剩余 scope 直接丢弃,而每轮都从列表头重来 → 靠后的 scope 会被
52
- * 系统性饿死。真解是 session scope TTL(只 dream 近 N 天活跃的),不是把预算调大——
53
- * scope 数 07-10 是 133、07-29 是 475,只增不减,加预算追不上。 */
52
+ * 2026-08-14: 6h→2h。transcript scope 已整体出队(原文层不参与巩固,cli.ts --auto
53
+ * 的 pruneWriteCounts(isTranscriptScope)),inline 队列只剩个位数 durable scope,
54
+ * 典型轮次分钟级;2h 是纯兜底。单 scope 路径(weekly memory / MCP dream tool)不走
55
+ * 本预算(budget/deadline 只存在于 cli.ts --auto 分支)。env 覆盖:
56
+ * RECALLNEST_DREAM_BUDGET_MS。 */
54
57
  autoRunBudgetMs: number;
55
58
  /** Scopes the `--auto` sweep hands off to a dedicated schedule instead of running inline.
56
59
  * Exact match only — a prefix rule would silently swallow future `memory-*` scopes.
@@ -65,6 +68,23 @@ export interface DreamConfig {
65
68
  * 这是缓解不是根治:scope 只增不减仍在(07-10 是 133、07-29 是 475、07-30 是 507),
66
69
  * 真解仍是 session scope TTL —— 只 dream 近 N 天活跃的。 */
67
70
  autoExcludeScopes: readonly string[];
71
+ /**
72
+ * 按策略**永不**巩固的 scope(2026-08-16 加)。与 autoExcludeScopes 是两件事,别混:
73
+ * 那个是"交给专用 schedule 跑"(deferred,别处会跑),这个是"根本不该被 dream 碰"
74
+ * (never,没有任何 schedule 会跑)。
75
+ *
76
+ * 判定在 runDream 内部而不是 `--auto` 的分拣里 —— CLI、weekly、MCP `dream` 工具
77
+ * 三条入口都必须受同一条策略约束,放在分拣里等于只拦住了其中一条。
78
+ *
79
+ * 首个成员 `memory:pivot`:手写提炼层(判断转折 / 决策 / 偏好 / 踩坑,每条带
80
+ * canonicalKey + pinned + 来源指针)。2026-08-16 实测它被 dream 碰出两类违例:
81
+ * 3b 往池里铸了 23 条无 canonicalKey / 无 pinned / 无来源的 LLM 派生行;3a 把 3 条
82
+ * 手写原件合并进 LLM 复述、复述当了 canonical。**提炼层再合成一层就是"摘要的摘要"**
83
+ * ——同 gather 不回喂派生 insight 的道理(见 :245 注释)。
84
+ * 要翻回"允许 3a 去重",前置条件:派生行永不当 canonical(已修)+ pinned 不失活
85
+ * (未做,GC/3a 都不认 tag)+ 有 uptake 证据证明去重确有收益。
86
+ */
87
+ neverDreamScopes: readonly string[];
68
88
  /** GC config for prune phase */
69
89
  gc: AutoGcConfig;
70
90
  }
@@ -76,8 +96,9 @@ export const DEFAULT_DREAM_CONFIG: DreamConfig = {
76
96
  minClusterSize: 3,
77
97
  extractPatterns: true,
78
98
  maxEntriesPerRun: 500,
79
- autoRunBudgetMs: 6 * 60 * 60 * 1000,
99
+ autoRunBudgetMs: 2 * 60 * 60 * 1000,
80
100
  autoExcludeScopes: ["memory"],
101
+ neverDreamScopes: ["memory:pivot"],
81
102
  gc: DEFAULT_AUTO_GC_CONFIG,
82
103
  };
83
104
 
@@ -146,6 +167,13 @@ export interface DreamResult {
146
167
  semanticClustersFound: number;
147
168
  insightsGenerated: number;
148
169
  patternsExtracted: number;
170
+ /** 3b 合成里模型判定「这簇没有可提炼结论」的次数(insight + pattern 合计)。
171
+ * 2026-08-23 起才可能非 0——在此之前合成没有弃权路径,每个合格簇必写一条。
172
+ * 它要和 insightsGenerated 一起读:0 insight + 高弃权 = 今天确实没料,
173
+ * 0 insight + 0 弃权 = 合成这一步压根没跑起来,两者完全不同。 */
174
+ synthesisAbstained: number;
175
+ /** 3b 合成产物在写库前被校验拦下的次数(提示词回声 / evidence 不合法 / 长度越界)。 */
176
+ synthesisRejected: number;
149
177
  mergedCount: number;
150
178
  archivedCount: number;
151
179
  /** 3a 去重路径新建的关联数。此前引擎返回了但被 dream 丢弃,导致「只加了关联」
@@ -177,8 +205,14 @@ async function runDreamInner(params: {
177
205
  activityStatsPath?: string;
178
206
  /** Optional KG evidence source for triple-overlap merging (null/absent = vector-only) */
179
207
  kgStore?: ConsolidationKGSource | null;
208
+ /**
209
+ * scope 匹配模式(2026-08-16 加)。**调度入口一律传 "exact"** —— 它们手里的是
210
+ * activity-stats 里的真实 scope 名或运维指定的具体 scope,不是 family selector。
211
+ * 缺省保持 "family" 只为兼容既有手动调用(`dream --scope cc` 想扫 `cc:*`)。
212
+ */
213
+ scopeMatch?: ScopeMatchMode;
180
214
  }): Promise<DreamResult> {
181
- const { store, llm, embedder, scope, force = false } = params;
215
+ const { store, llm, embedder, scope, force = false, scopeMatch = "family" } = params;
182
216
  const config = { ...DEFAULT_DREAM_CONFIG, ...params.config };
183
217
  const statsCfg = params.activityStatsPath ? { statsPath: params.activityStatsPath } : undefined;
184
218
  const phases: DreamPhaseResult[] = [];
@@ -192,6 +226,8 @@ async function runDreamInner(params: {
192
226
  semanticClustersFound: 0,
193
227
  insightsGenerated: 0,
194
228
  patternsExtracted: 0,
229
+ synthesisAbstained: 0,
230
+ synthesisRejected: 0,
195
231
  mergedCount: 0,
196
232
  archivedCount: 0,
197
233
  relationsAdded: 0,
@@ -199,13 +235,31 @@ async function runDreamInner(params: {
199
235
  semanticVectorFed: 0,
200
236
  };
201
237
 
238
+ // =========================================================================
239
+ // Phase 0: Policy gate — scopes that must never be consolidated
240
+ // =========================================================================
241
+ // 在写计数门槛**之前**判:策略排除与"今天写得够不够"无关,force 也不该越过它。
242
+ // 放在 runDream 内部 = CLI / weekly / MCP 三条入口同受约束(互审 C2)。
243
+ if (config.neverDreamScopes.includes(scope)) {
244
+ return {
245
+ ran: false,
246
+ reason: `policy_excluded (${scope} is in neverDreamScopes)`,
247
+ output: { kind: "skipped", reason: "policy_excluded", effectsWritten: 0 },
248
+ phases: [{
249
+ phase: "orient",
250
+ detail: `scope ${scope} 按策略不参与巩固(DreamConfig.neverDreamScopes)`,
251
+ }],
252
+ stats,
253
+ };
254
+ }
255
+
202
256
  // =========================================================================
203
257
  // Phase 1: Orient — assess current memory state
204
258
  // =========================================================================
205
259
  const writeCount = getWriteCount(scope, statsCfg);
206
260
  stats.writesSinceLastDream = writeCount;
207
261
 
208
- const storeStats = await store.stats([scope]);
262
+ const storeStats = await store.stats([scope], scopeMatch);
209
263
  stats.totalMemories = storeStats.totalCount ?? 0;
210
264
 
211
265
  if (!force && writeCount < config.minWritesForDream) {
@@ -229,7 +283,7 @@ async function runDreamInner(params: {
229
283
  // =========================================================================
230
284
  // Phase 2: Gather — collect active entries for consolidation
231
285
  // =========================================================================
232
- const entries = await store.list([scope], undefined, config.maxEntriesPerRun, 0);
286
+ const entries = await store.list([scope], undefined, config.maxEntriesPerRun, 0, scopeMatch);
233
287
  const active = entries.filter(e => isActiveMemory(e.metadata));
234
288
  stats.activeMemories = active.length;
235
289
 
@@ -282,7 +336,7 @@ async function runDreamInner(params: {
282
336
  if (consolidatable.length < config.minClusterSize) {
283
337
  phases.push({ phase: "consolidate", detail: "skipped — too few active entries" });
284
338
  phases.push({ phase: "prune", detail: "skipped — too few entries for GC" });
285
- resetWriteCount(scope, statsCfg);
339
+ await resetWriteCount(scope, statsCfg);
286
340
  return {
287
341
  ran: true,
288
342
  reason: "completed_early",
@@ -307,6 +361,9 @@ async function runDreamInner(params: {
307
361
  ...DEFAULT_CONSOLIDATION_CONFIG,
308
362
  clusterThreshold: config.clusterThreshold,
309
363
  maxEntriesPerRun: config.maxEntriesPerRun,
364
+ // 3a 自己重新取数(list + 两处 vectorSearch),gather 收窄了它不会跟着窄 ——
365
+ // 模式必须显式传进来,漏传就只修了一半(2026-08-16 互审 K3)。
366
+ scopeMatch,
310
367
  }, params.kgStore ?? null);
311
368
  const consolidation = await engine.run(scope);
312
369
  stats.dedupeClustersFound += consolidation.clustersFound;
@@ -339,6 +396,8 @@ async function runDreamInner(params: {
339
396
  stats.clustersFound += clusterResult.clustersFound;
340
397
  stats.insightsGenerated = clusterResult.insightsGenerated;
341
398
  stats.patternsExtracted = clusterResult.patternsExtracted;
399
+ stats.synthesisAbstained = clusterResult.synthesisAbstained;
400
+ stats.synthesisRejected = clusterResult.synthesisRejected;
342
401
  }
343
402
  }, { onBusy: "skip", expireMs: 600_000 });
344
403
 
@@ -351,6 +410,9 @@ async function runDreamInner(params: {
351
410
  ? `${stats.dedupeClustersFound} dedupe-clusters (${stats.mergedCount} merged), `
352
411
  + `${stats.semanticClustersFound} semantic-clusters -> `
353
412
  + `${stats.insightsGenerated} insights, ${stats.patternsExtracted} patterns`
413
+ // 弃权与校验拦截跟着一起报:没有它们,「合成认真看过但确实没料」和
414
+ // 「合成根本没跑」在这一行里长得一模一样——这正是本文件反复吃过的亏。
415
+ + `(弃权 ${stats.synthesisAbstained},校验拦下 ${stats.synthesisRejected})`
354
416
  : `skipped — another process holds the consolidate lock for scope ${scope}`,
355
417
  });
356
418
 
@@ -363,7 +425,9 @@ async function runDreamInner(params: {
363
425
  phases.push({
364
426
  phase: "prune",
365
427
  detail: gcResult.triggered
366
- ? `${gcResult.archivedCount} entries archived`
428
+ ? `${gcResult.archivedCount} entries archived, ${gcResult.dissolvedVersionGroups} singleton version groups dissolved`
429
+ : gcResult.dissolvedVersionGroups > 0
430
+ ? `${gcResult.dissolvedVersionGroups} singleton version groups dissolved; archive skipped — ${gcResult.reason}`
367
431
  : `skipped — ${gcResult.reason}`,
368
432
  });
369
433
 
@@ -384,7 +448,7 @@ async function runDreamInner(params: {
384
448
  // 注意:清对之后,listScopesAboveThreshold 的 FIFO 自组织才真正开始工作 ——
385
449
  // 处理完的 scope 被 delete、下次攒够写入重新插入时排到队尾,被跳过的带原计数留在
386
450
  // 队首下轮优先。所以这里不需要额外的游标 / checkpoint 文件。
387
- if (consolidateOutcome.ran) resetWriteCount(scope, statsCfg);
451
+ if (consolidateOutcome.ran) await resetWriteCount(scope, statsCfg);
388
452
 
389
453
  const health = assertDreamSweepHealth({
390
454
  llmPresent: llm != null,
@@ -419,6 +483,8 @@ export async function runDream(params: {
419
483
  activityStatsPath?: string;
420
484
  /** Optional KG evidence source for triple-overlap merging (null/absent = vector-only) */
421
485
  kgStore?: ConsolidationKGSource | null;
486
+ /** 见 runDreamInner 的同名参数:调度入口一律 "exact",缺省 "family" 只为兼容手动调用。 */
487
+ scopeMatch?: ScopeMatchMode;
422
488
  }): Promise<DreamResult> {
423
489
  const outcome = await withLock(
424
490
  `dream-${params.scope}`,
@@ -440,6 +506,8 @@ export async function runDream(params: {
440
506
  semanticClustersFound: 0,
441
507
  insightsGenerated: 0,
442
508
  patternsExtracted: 0,
509
+ synthesisAbstained: 0,
510
+ synthesisRejected: 0,
443
511
  mergedCount: 0,
444
512
  archivedCount: 0,
445
513
  relationsAdded: 0,
@@ -577,12 +645,21 @@ export function classifyDreamFailure(message: string): DreamFailureKind {
577
645
  /** transient 失败占比超过这个比例仍判整轮失败——否则"475 个全被锁挡住"会假绿。 */
578
646
  export const DREAM_TRANSIENT_BLOCK_RATIO = 0.2;
579
647
 
648
+ /** 比例阈值之外的绝对量豁免:transient 失败不超过这个数就不算整轮失败。
649
+ * 2026-08-14 transcript 出队后 inline 队列缩到个位数,1/1 = 100% > 20% 让单个
650
+ * 瞬态锁竞争把整轮判红(当天验证轮实测:一个死进程的残留 store-write 锁 →
651
+ * 唯一 durable scope 等锁超时 → blocked → launchd 失败通知)。比例阈值的原意是
652
+ * 「环境性失败要成规模」,1-2 个绝对不算规模——失败的 scope 计数未清、明天自动
653
+ * 重试,兜底仍在。fatal 不享受豁免(一票否决不变)。 */
654
+ export const DREAM_TRANSIENT_ABSOLUTE_TOLERANCE = 2;
655
+
580
656
  /**
581
657
  * 一轮 `dream --auto` 是否该判 blocked。
582
658
  *
583
659
  * 旧语义是「任意一个 scope 抛异常即 blocked」,在 475 个 scope 的规模下等于
584
660
  * 「有一个撞上锁就整轮红」,而红了会让 shell 脚本走重试 → 重跑全量。
585
- * 新语义:代码/数据缺陷一票否决;环境性失败要成规模才算整轮失败。
661
+ * 新语义:代码/数据缺陷一票否决;环境性失败要成规模才算整轮失败——
662
+ * 「规模」同时看比例(> 20%)与绝对量(> 2 个),小队列下单个瞬态不再整轮红。
586
663
  */
587
664
  export function shouldBlockDreamRun(params: {
588
665
  totalScopes: number;
@@ -591,6 +668,7 @@ export function shouldBlockDreamRun(params: {
591
668
  }): boolean {
592
669
  if (params.fatalFailures > 0) return true;
593
670
  if (params.totalScopes <= 0) return false;
671
+ if (params.transientFailures <= DREAM_TRANSIENT_ABSOLUTE_TOLERANCE) return false;
594
672
  return params.transientFailures / params.totalScopes > DREAM_TRANSIENT_BLOCK_RATIO;
595
673
  }
596
674
 
package/src/embedder.ts CHANGED
@@ -328,7 +328,15 @@ export class Embedder {
328
328
  }
329
329
  }
330
330
 
331
- private async embedSingle(text: string, task?: string): Promise<number[]> {
331
+ /**
332
+ * @param allowChunkRetry - False for texts that are already chunks. Without it the
333
+ * context-error branch below can re-enter itself forever: `smartChunk` returns the
334
+ * input unchanged when it already fits, so re-embedding that chunk reproduces the
335
+ * same provider error and chunks again. The regex that gates the branch also matches
336
+ * rate-limit wording ("quota exceeded", "Rate limit exceeded"), which turned a 429
337
+ * into an unbounded request storm against a paid endpoint.
338
+ */
339
+ private async embedSingle(text: string, task?: string, allowChunkRetry = true): Promise<number[]> {
332
340
  if (!text || text.trim().length === 0) {
333
341
  throw new Error("Cannot embed empty text");
334
342
  }
@@ -352,21 +360,28 @@ export class Embedder {
352
360
  const errorMsg = error instanceof Error ? error.message : String(error);
353
361
  const isContextError = /context|too long|exceed|length/i.test(errorMsg);
354
362
 
355
- if (isContextError && this._autoChunk) {
363
+ if (isContextError && this._autoChunk && allowChunkRetry) {
356
364
  try {
357
365
  logInfo(`Document exceeded context limit (${errorMsg}), attempting chunking...`);
358
366
  const chunkResult = smartChunk(text, this._model);
359
-
367
+
360
368
  if (chunkResult.chunks.length === 0) {
361
369
  throw new Error(`Failed to chunk document: ${errorMsg}`);
362
370
  }
363
371
 
372
+ // Terminating condition: the chunker hands short text straight back, so a
373
+ // single chunk identical to the input means splitting cannot help. Retrying it
374
+ // would reproduce the same error and recurse.
375
+ if (chunkResult.chunks.length === 1 && chunkResult.chunks[0] === text) {
376
+ throw new Error(`Chunking cannot reduce this input: ${errorMsg}`);
377
+ }
378
+
364
379
  // Embed all chunks in parallel
365
380
  logInfo(`Split document into ${chunkResult.chunkCount} chunks for embedding`);
366
381
  const chunkEmbeddings = await Promise.all(
367
382
  chunkResult.chunks.map(async (chunk, idx) => {
368
383
  try {
369
- const embedding = await this.embedSingle(chunk, task);
384
+ const embedding = await this.embedSingle(chunk, task, false);
370
385
  return { embedding };
371
386
  } catch (chunkError) {
372
387
  logWarn(`Failed to embed chunk ${idx}:`, chunkError);
@@ -468,7 +483,7 @@ export class Embedder {
468
483
 
469
484
  // Embed all chunks in parallel, then average.
470
485
  const embeddings = await Promise.all(
471
- chunkResult.chunks.map((chunk) => this.embedSingle(chunk, task))
486
+ chunkResult.chunks.map((chunk) => this.embedSingle(chunk, task, false))
472
487
  );
473
488
 
474
489
  const avgEmbedding = embeddings.reduce(
package/src/env-config.ts CHANGED
@@ -109,6 +109,43 @@ export const readConsistencyInterval = (): number | undefined => {
109
109
  return Number.isFinite(n) && n >= 0 ? n : 0;
110
110
  };
111
111
 
112
+ /**
113
+ * Wall-clock budget (ms) for one `dream --auto` sweep.
114
+ *
115
+ * NOT a pure movement (unlike every accessor above): the original inline read in
116
+ * cli.ts was `Number(process.env.X ?? DEFAULT)`, which had two silent failure
117
+ * modes — `""` slipped past `??` and became **0** (every scope skipped, run is a
118
+ * no-op that still reports ok), and any non-numeric value became **NaN**, making
119
+ * the `Date.now() > deadline` guard permanently false. The second one silently
120
+ * reverts the very incident this budget exists to prevent: the 2026-07-24 sweep
121
+ * ran 4d15h and blocked three days of scheduled runs. Both are corrected here
122
+ * on purpose; do not "restore" the original parsing.
123
+ *
124
+ * Unset / empty / non-finite / <= 0 → caller's fallback.
125
+ */
126
+ export const dreamBudgetMs = (fallbackMs: number): number => {
127
+ const raw = process.env.RECALLNEST_DREAM_BUDGET_MS;
128
+ if (raw === undefined || raw.trim() === "") return fallbackMs;
129
+ const n = Number(raw);
130
+ return Number.isFinite(n) && n > 0 ? n : fallbackMs;
131
+ };
132
+
133
+ /**
134
+ * dream 合成(cluster insight / cross-memory pattern)专用模型,覆盖 config.llm.model。
135
+ *
136
+ * 独立于全局模型的理由(2026-08-23):合成是全库唯一一处「产物质量直接决定库的
137
+ * 可用性」的 LLM 调用 —— 它写进去的东西会被当成记忆检索出来,而 ingest 侧的
138
+ * smartExtract 只是给原文打标签,错了还能回原文。三臂实验里模型贡献了约 1/3 的
139
+ * 改善(同提示词下 32.3% → 45.2%),把这一档单独抬上去,不必让全库调用一起涨价。
140
+ *
141
+ * 未设 / 空串 → 用 config.llm.model(即全局默认),保持老行为。
142
+ */
143
+ export const synthesisModel = (): string | undefined => {
144
+ const raw = process.env.RECALLNEST_SYNTHESIS_MODEL;
145
+ const trimmed = raw?.trim();
146
+ return trimmed ? trimmed : undefined;
147
+ };
148
+
112
149
  // --- Raw env values (caller validates / clamps / falls back to config) ---
113
150
 
114
151
  export const recallModeRaw = (): string | undefined => process.env.RECALLNEST_RECALL_MODE;
@@ -116,3 +153,19 @@ export const recallModeRaw = (): string | undefined => process.env.RECALLNEST_RE
116
153
  export const uiPortRaw = (): string | undefined => process.env.RECALLNEST_UI_PORT;
117
154
 
118
155
  export const apiPortRaw = (): string | undefined => process.env.RECALLNEST_API_PORT;
156
+
157
+ // --- Gateway (read-only front door; caller validates / clamps) ---
158
+
159
+ export const gatewayPortRaw = (): string | undefined => process.env.RECALLNEST_GATEWAY_PORT;
160
+
161
+ export const gatewayHostRaw = (): string | undefined => process.env.RECALLNEST_GATEWAY_HOST;
162
+
163
+ export const gatewayTokenRaw = (): string | undefined => process.env.RECALLNEST_GATEWAY_TOKEN;
164
+
165
+ export const gatewayRateMaxRaw = (): string | undefined => process.env.RECALLNEST_GATEWAY_RATE_MAX;
166
+
167
+ export const gatewayFileRootsRaw = (): string | undefined => process.env.RECALLNEST_GATEWAY_FILE_ROOTS;
168
+
169
+ export const gatewayRgRaw = (): string | undefined => process.env.RECALLNEST_GATEWAY_RG;
170
+
171
+ export const apiUrlRaw = (): string | undefined => process.env.RECALLNEST_API_URL;
@@ -223,8 +223,14 @@ export async function forgetByScope(
223
223
  let failedCount = 0;
224
224
  let totalCascadeDemoted = 0;
225
225
 
226
- // Fetch all entries in scope
227
- const entries = await store.list([scope], undefined, 5000);
226
+ // Fetch all entries in scope.
227
+ //
228
+ // scopeMatch: "exact" —— **破坏性操作绝不做前缀展开**(2026-08-16 互审 Codex R3 提出)。
229
+ // 默认 family 语义下 `forgetByScope("memory")` 会连 `memory:pivot` 一起遗忘,而下面
230
+ // 的 KG 清理走的是精确 `scope = 'memory'`(kg-store.ts:506)—— 记忆行按家族删、KG 只
231
+ // 清一个 scope,两边还会不一致。当前无生产 caller,正因如此更要在有 caller 之前定死语义:
232
+ // 要清一整个家族,就显式枚举那些 scope 逐个调用。
233
+ const entries = await store.list([scope], undefined, 5000, 0, "exact");
228
234
 
229
235
  for (const entry of entries) {
230
236
  const result = await forgetMemory(deps, {
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * RecallNest Read-Only Gateway
4
+ *
5
+ * A thin, authenticated, read-only front door for the RecallNest HTTP API.
6
+ *
7
+ * Why this exists as a separate process:
8
+ * `api-server.ts` deliberately binds to 127.0.0.1 AND rejects any request whose
9
+ * Host header is not local (`enforceLocalHttpRequestPolicy`). That is a load-bearing
10
+ * safety property — it also exposes write routes (`/v1/store`, `/v1/checkpoint`).
11
+ * Exposing it directly to a phone or a tunnel would hand out write access.
12
+ * This gateway sits in front instead: it authenticates, rate-limits, allows only
13
+ * read routes, and forwards to the local API over the loopback interface.
14
+ *
15
+ * Intended use: put it behind a tunnel (Tailscale Serve/Funnel, Cloudflare Tunnel, …)
16
+ * so an AI app on a phone can read the same memory your desktop agents use.
17
+ *
18
+ * Design constraints (each is deliberate — think before changing):
19
+ * - Binds to 127.0.0.1 by default. Reaching it from outside is the tunnel's job,
20
+ * not this process's. Overriding the host is possible but unwise.
21
+ * - Read-only. The forward allow-list has no write route in it, and requests to
22
+ * anything not on the list are rejected before any forwarding happens.
23
+ * - Bearer token required, compared in constant time, and refused if too short.
24
+ * - Rate limited, with a hard cap on response size.
25
+ * - Optional file search roots are an explicit allow-list; queries are passed to
26
+ * ripgrep as an argv element, never through a shell.
27
+ *
28
+ * Start: bun run gateway
29
+ * Env:
30
+ * RECALLNEST_GATEWAY_PORT default 8791
31
+ * RECALLNEST_GATEWAY_HOST default 127.0.0.1
32
+ * RECALLNEST_GATEWAY_TOKEN bearer token (>= 32 chars); or put it in
33
+ * ~/.config/recallnest/gateway-token
34
+ * RECALLNEST_API_URL default http://127.0.0.1:4318
35
+ * RECALLNEST_GATEWAY_RATE_MAX default 30 (requests per minute)
36
+ * RECALLNEST_GATEWAY_FILE_ROOTS optional, enables GET /files/search
37
+ * format: name=/abs/path,name=/abs/path
38
+ * RECALLNEST_GATEWAY_RG path to ripgrep (default: rg on PATH)
39
+ */
40
+
41
+ import { timingSafeEqual, randomUUID } from "node:crypto";
42
+ import { readFileSync, appendFile, existsSync, statSync } from "node:fs";
43
+ import { homedir } from "node:os";
44
+ import { join, isAbsolute } from "node:path";
45
+ import { execFile } from "node:child_process";
46
+ import * as envConfig from "./env-config.js";
47
+
48
+ const HOME = homedir();
49
+ const PORT = clampInt(envConfig.gatewayPortRaw(), 8791, 1, 65535);
50
+ const HOST = envConfig.gatewayHostRaw()?.trim() || "127.0.0.1";
51
+ const API_URL = (envConfig.apiUrlRaw()?.trim() || "http://127.0.0.1:4318").replace(/\/+$/, "");
52
+ const RATE_MAX = clampInt(envConfig.gatewayRateMaxRaw(), 30, 1, 10_000);
53
+ const RATE_WINDOW_MS = 60_000;
54
+ const MAX_RESPONSE_BYTES = 256 * 1024;
55
+ const MAX_REQUEST_BYTES = 64 * 1024;
56
+ const RG = envConfig.gatewayRgRaw()?.trim() || "rg";
57
+ const LOG_FILE = join(HOME, ".config", "recallnest", "gateway-access.log");
58
+
59
+ function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number {
60
+ const n = Number(raw);
61
+ if (!Number.isFinite(n) || !Number.isInteger(n)) return fallback;
62
+ return Math.min(Math.max(n, min), max);
63
+ }
64
+
65
+ // ── token ────────────────────────────────────────────────────────────────────
66
+ function loadToken(): string {
67
+ const fromEnv = envConfig.gatewayTokenRaw()?.trim();
68
+ if (fromEnv) return fromEnv;
69
+ const file = join(HOME, ".config", "recallnest", "gateway-token");
70
+ if (existsSync(file)) return readFileSync(file, "utf8").trim();
71
+ throw new Error(
72
+ "gateway: no token. Set RECALLNEST_GATEWAY_TOKEN or write ~/.config/recallnest/gateway-token",
73
+ );
74
+ }
75
+
76
+ const TOKEN = loadToken();
77
+ if (TOKEN.length < 32) {
78
+ throw new Error("gateway: token shorter than 32 chars — refusing to start");
79
+ }
80
+
81
+ // ── optional file search roots ───────────────────────────────────────────────
82
+ function parseFileRoots(): Record<string, string> {
83
+ const raw = envConfig.gatewayFileRootsRaw()?.trim();
84
+ if (!raw) return {};
85
+ const roots: Record<string, string> = {};
86
+ for (const pair of raw.split(",")) {
87
+ const [name, ...rest] = pair.split("=");
88
+ const key = name?.trim();
89
+ const dir = rest.join("=").trim();
90
+ if (!key || !dir) continue;
91
+ if (!isAbsolute(dir)) throw new Error(`gateway: file root "${key}" must be an absolute path`);
92
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
93
+ throw new Error(`gateway: file root "${key}" is not an existing directory: ${dir}`);
94
+ }
95
+ roots[key] = dir;
96
+ }
97
+ return roots;
98
+ }
99
+
100
+ const FILE_ROOTS = parseFileRoots();
101
+ const FILE_SEARCH_ENABLED = Object.keys(FILE_ROOTS).length > 0;
102
+
103
+ // ── read-only forward allow-list ─────────────────────────────────────────────
104
+ // Anything not listed here is a 404 before a single byte is forwarded.
105
+ const FORWARD_GET: Record<string, string> = {
106
+ "/stats": "/v1/stats",
107
+ "/health": "/v1/health",
108
+ };
109
+ const FORWARD_POST: Record<string, string> = {
110
+ "/recall": "/v1/recall",
111
+ "/search": "/v1/search",
112
+ };
113
+
114
+ // ── auth / rate limiting ─────────────────────────────────────────────────────
115
+ const wantToken = Buffer.from(TOKEN);
116
+
117
+ function authOk(header: string | null): boolean {
118
+ if (!header || !header.startsWith("Bearer ")) return false;
119
+ const given = Buffer.from(header.slice(7).trim());
120
+ if (given.length !== wantToken.length) return false;
121
+ return timingSafeEqual(given, wantToken);
122
+ }
123
+
124
+ const rateHits: number[] = [];
125
+ function rateOk(): boolean {
126
+ const now = Date.now();
127
+ while (rateHits.length && now - rateHits[0]! >= RATE_WINDOW_MS) rateHits.shift();
128
+ if (rateHits.length >= RATE_MAX) return false;
129
+ rateHits.push(now);
130
+ return true;
131
+ }
132
+
133
+ function log(entry: Record<string, unknown>): void {
134
+ appendFile(LOG_FILE, JSON.stringify(entry) + "\n", () => {});
135
+ }
136
+
137
+ function json(status: number, body: unknown): Response {
138
+ let payload = JSON.stringify(body);
139
+ if (payload.length > MAX_RESPONSE_BYTES) {
140
+ payload = JSON.stringify({ error: "response too large", limit: MAX_RESPONSE_BYTES });
141
+ status = 502;
142
+ }
143
+ return new Response(payload, {
144
+ status,
145
+ headers: { "content-type": "application/json; charset=utf-8" },
146
+ });
147
+ }
148
+
149
+ // ── forwarding ───────────────────────────────────────────────────────────────
150
+ async function forward(target: string, method: "GET" | "POST", body?: string): Promise<Response> {
151
+ const init: RequestInit = { method };
152
+ if (method === "POST") {
153
+ init.headers = { "content-type": "application/json" };
154
+ init.body = body ?? "{}";
155
+ }
156
+ const upstream = await fetch(`${API_URL}${target}`, init);
157
+ const text = await upstream.text();
158
+ if (text.length > MAX_RESPONSE_BYTES) {
159
+ return json(502, { error: "upstream response too large", limit: MAX_RESPONSE_BYTES });
160
+ }
161
+ return new Response(text, {
162
+ status: upstream.status,
163
+ headers: { "content-type": "application/json; charset=utf-8" },
164
+ });
165
+ }
166
+
167
+ // ── optional ripgrep file search ─────────────────────────────────────────────
168
+ interface FileHit { file: string; line: number; text: string }
169
+
170
+ function fileSearch(query: string, scope: string, limit: number): Promise<FileHit[]> {
171
+ const targets = scope === "all" ? Object.values(FILE_ROOTS) : [FILE_ROOTS[scope]!];
172
+ return new Promise((resolve, reject) => {
173
+ // argv form: the query is always one element, never touched by a shell
174
+ const args = ["--json", "--max-count", "3", "--max-filesize", "2M", "-i", "--", query, ...targets];
175
+ execFile(RG, args, { maxBuffer: 8 * 1024 * 1024, timeout: 20_000 }, (err, stdout) => {
176
+ // ripgrep exits 1 when there is no match — not an error
177
+ if (err && (err as NodeJS.ErrnoException).code !== 1 && !stdout) return reject(err);
178
+ const hits: FileHit[] = [];
179
+ for (const line of String(stdout).split("\n")) {
180
+ if (!line.trim() || hits.length >= limit) continue;
181
+ let row: any;
182
+ try { row = JSON.parse(line); } catch { continue; }
183
+ if (row.type !== "match") continue;
184
+ hits.push({
185
+ file: String(row.data.path.text).replace(HOME, "~"),
186
+ line: row.data.line_number,
187
+ text: String(row.data.lines.text).trim().slice(0, 400),
188
+ });
189
+ }
190
+ resolve(hits);
191
+ });
192
+ });
193
+ }
194
+
195
+ // ── server ───────────────────────────────────────────────────────────────────
196
+ const server = Bun.serve({
197
+ port: PORT,
198
+ hostname: HOST,
199
+ async fetch(request) {
200
+ const id = randomUUID().slice(0, 8);
201
+ const url = new URL(request.url);
202
+ const { pathname } = url;
203
+ const method = request.method;
204
+ const base = { id, at: new Date().toISOString(), path: pathname, method };
205
+
206
+ // liveness probe needs no token, and reveals nothing but shape
207
+ if (method === "GET" && pathname === "/health") {
208
+ return json(200, {
209
+ ok: true,
210
+ readOnly: true,
211
+ routes: [...Object.keys(FORWARD_GET), ...Object.keys(FORWARD_POST),
212
+ ...(FILE_SEARCH_ENABLED ? ["/files/search"] : [])],
213
+ fileScopes: Object.keys(FILE_ROOTS),
214
+ });
215
+ }
216
+
217
+ if (!authOk(request.headers.get("authorization"))) {
218
+ log({ ...base, r: "auth" });
219
+ return json(401, { error: "unauthorized" });
220
+ }
221
+ if (!rateOk()) {
222
+ log({ ...base, r: "rate" });
223
+ return json(429, { error: `rate limited, ${RATE_MAX}/min` });
224
+ }
225
+
226
+ try {
227
+ if (method === "GET" && FORWARD_GET[pathname]) {
228
+ log({ ...base, r: "fwd" });
229
+ return await forward(FORWARD_GET[pathname]!, "GET");
230
+ }
231
+
232
+ if (method === "POST" && FORWARD_POST[pathname]) {
233
+ const raw = await request.text();
234
+ if (raw.length > MAX_REQUEST_BYTES) {
235
+ log({ ...base, r: "too-large" });
236
+ return json(413, { error: `request body exceeds ${MAX_REQUEST_BYTES} bytes` });
237
+ }
238
+ try { JSON.parse(raw || "{}"); } catch {
239
+ return json(400, { error: "body must be valid JSON" });
240
+ }
241
+ log({ ...base, r: "fwd" });
242
+ return await forward(FORWARD_POST[pathname]!, "POST", raw || "{}");
243
+ }
244
+
245
+ if (method === "GET" && pathname === "/files/search") {
246
+ if (!FILE_SEARCH_ENABLED) return json(404, { error: "file search not configured" });
247
+ const q = (url.searchParams.get("q") || "").trim();
248
+ const scope = url.searchParams.get("scope") || "all";
249
+ const limit = Math.min(clampInt(url.searchParams.get("limit") ?? undefined, 10, 1, 30), 30);
250
+ if (q.length < 2 || q.length > 200) return json(400, { error: "q must be 2-200 chars" });
251
+ if (scope !== "all" && !FILE_ROOTS[scope]) {
252
+ return json(400, { error: `scope must be all|${Object.keys(FILE_ROOTS).join("|")}` });
253
+ }
254
+ const hits = await fileSearch(q, scope, limit);
255
+ log({ ...base, q, scope, r: "ok", n: hits.length });
256
+ return json(200, { query: q, scope, count: hits.length, hits });
257
+ }
258
+
259
+ log({ ...base, r: "404" });
260
+ return json(404, { error: "not found (this gateway is read-only)" });
261
+ } catch (err) {
262
+ const message = err instanceof Error ? err.message : String(err);
263
+ log({ ...base, r: "err", msg: message });
264
+ return json(502, { error: "gateway error", detail: message });
265
+ }
266
+ },
267
+ });
268
+
269
+ console.log(
270
+ `RecallNest read-only gateway on http://${HOST}:${server.port} → ${API_URL}` +
271
+ (FILE_SEARCH_ENABLED ? ` | file scopes: ${Object.keys(FILE_ROOTS).join(", ")}` : ""),
272
+ );