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
package/src/retriever.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Hybrid Retrieval System
3
- * Combines vector search + BM25 full-text search with RRF fusion
3
+ * Combines vector search + BM25 full-text search with weighted score fusion
4
4
  */
5
5
 
6
- import type { MemoryStore, MemorySearchResult } from "./store.js";
6
+ import type { MemoryStore, MemorySearchResult, MemoryEntry } from "./store.js";
7
7
  import type { Embedder } from "./embedder.js";
8
8
  import { filterNoise } from "./noise-filter.js";
9
9
  import * as envConfig from "./env-config.js";
10
10
  import { extractErrorSignatures } from "./error-signature.js";
11
- import { shouldSkipRetrieval } from "./adaptive-retrieval.js";
11
+ import { shouldSkipRetrievalFor } from "./adaptive-retrieval.js";
12
12
  import { expandQueryWithAliases } from "./aliases.js";
13
13
  import { expandQuery } from "./query-expander.js";
14
14
  import { detectLang, tokenizeFts } from "./language-hook.js";
@@ -16,7 +16,9 @@ import { type AccessTracker, computeHotnessScore, parseAccessMetadata } from "./
16
16
  import { readUtility } from "./memory-utility.js";
17
17
  import { weibullDecay, resolveTier, isDecayExempt, adjustHalfLifeForEmotion, computeArousalBoost } from "./decay-engine.js";
18
18
  import { logInfo, logWarn } from "./stderr-log.js";
19
- import { extractBoundaryMetadata, isDurableMemoryScope, isTranscriptScope } from "./memory-boundaries.js";
19
+ import { matchesScopeFilter } from "./scope-policy.js";
20
+ import type { TriggerStore, TriggerHit } from "./trigger-store.js";
21
+ import { extractBoundaryMetadata, isDurableMemoryScope, isTranscriptScope, resolveExcludedScopes } from "./memory-boundaries.js";
20
22
  import type { TraceCollector } from "./retrieval-trace.js";
21
23
  import { extractTopicTag } from "./topic-tag.js";
22
24
  import { filterInterference } from "./rif.js";
@@ -24,7 +26,7 @@ import { FrequencyTracker } from "./frequency-tracker.js";
24
26
  import { applyConfidenceWeight } from "./confidence-tracker.js";
25
27
  import { deduplicateByVersionGroup } from "./version-manager.js";
26
28
  import { deduplicateByClusterInsight } from "./consolidation-engine.js";
27
- import { isActiveMemory, recordAccess as recordEvolutionAccess, parseEvolution, computeDecayScore } from "./memory-evolution.js";
29
+ import { isActiveMemory, recordAccessOnMeta as recordEvolutionAccessOnMeta, parseEvolution, computeDecayScore } from "./memory-evolution.js";
28
30
  import {
29
31
  isMultiVectorEnabled,
30
32
  extractMultiVectorText,
@@ -125,6 +127,11 @@ export interface RetrievalConfig {
125
127
  * 注释不会有人定期回来读,那个条目每窗口都会被推到眼前。
126
128
  */
127
129
  utilityWeight: number;
130
+ /**
131
+ * γ:`RECALLNEST_POPULARITY_RANKING=bounded` 下链尾那一步流行度加成的上限,
132
+ * score × (1 + γ · h),h ∈ [0,1]。legacy 下不读。(default: 0.2,第二步 plan §一.4)
133
+ */
134
+ popularityBonusMax: number;
128
135
  /**
129
136
  * Per-category score thresholds. When a result's category matches a key,
130
137
  * that threshold is used instead of hardMinScore. Categories not listed
@@ -173,6 +180,12 @@ export interface RetrievalContext {
173
180
  * calls to prevent auto-recall from strengthening noise memories.
174
181
  * Defaults to "manual" when unset (backward-compatible). */
175
182
  source?: "manual" | "auto-recall" | "cli";
183
+ /**
184
+ * Whether the trivial-query gate (shouldSkipRetrieval: greetings, command-like prefixes, short
185
+ * non-CJK phrases …) applies. Default: only when source is "auto-recall". Explicit searches that
186
+ * use source "auto-recall" just to avoid reinforcement writes pass false.
187
+ */
188
+ trivialQueryGate?: boolean;
176
189
  /** When false (default), archived records are excluded from results. */
177
190
  includeArchived?: boolean;
178
191
  /** Optional trace collector for per-stage pipeline observability. */
@@ -189,6 +202,11 @@ export interface RetrievalContext {
189
202
  validAt?: number;
190
203
  /** F3: When true, include expired memories (demoted 80%). Default: false. */
191
204
  includeExpired?: boolean;
205
+ /**
206
+ * Scopes kept out of the candidate pool (pushed down into the vector / BM25 queries). A category
207
+ * filter other than `events` also excludes the checkpoint-mirror scope — see resolveExcludedScopes.
208
+ */
209
+ excludeScopes?: string[];
192
210
  }
193
211
 
194
212
  export interface RetrievalResult extends MemorySearchResult {
@@ -200,6 +218,11 @@ export interface RetrievalResult extends MemorySearchResult {
200
218
  reranked?: { score: number };
201
219
  /** HP-narrative: true when this result was pulled as a narrative sibling */
202
220
  narrativeSibling?: boolean;
221
+ /**
222
+ * T-Mem 写入时预演触发器(2026-09-22):经 trigger 到达或被 trigger 加强。
223
+ * text 只用于 explain(「为什么捞到」),不进 context 渲染——trigger 是入口不是证据。
224
+ */
225
+ trigger?: { score: number; cosine: number; text: string; admittedBy?: "hard" | "soft"; overlap?: number };
203
226
  };
204
227
  }
205
228
 
@@ -238,7 +261,10 @@ export const DEFAULT_CATEGORY_MIN_SCORES: Record<string, number> = {
238
261
  // P1.2(2026-06-25):默认 vector-only。canary eval 实测个人记忆库 vector 召回质量全面优于
239
262
  // hybrid(robustness 带 scope WITH 6/12 + 不带 scope WITHOUT 10/12,完胜 hybrid 的 3/9;BM25 在
240
263
  // 高度同质的个人记忆里是净噪声,会把 vector 本应 top 的目标如 dbcb317a 挤掉)。改频率层救低频
241
- // 目标实测是死结(动 hotness 必伤靠 hotness 上位的记忆)。hybrid 路径代码全保留,mode 改回
264
+ // 目标实测是死结(动 hotness 必伤靠 hotness 上位的记忆)。〔2026-09-25:这个死结现在有一条开关后的出路——
265
+ // RECALLNEST_POPULARITY_RANKING=bounded 把三个流行度加成合成一步、只加不减、上限 +20%,快照上靠热度上位的
266
+ // canary rebuild-pool 仍过(γ=0.1 时掉到第 5,所以取 0.2);正式 shadow 之后 2026-09-25 切成默认,
267
+ // 设 RECALLNEST_POPULARITY_RANKING=legacy 可回旧行为。见 sync-bridge AI产出/2026-09-24-recallnest-新条目排序/。〕hybrid 路径代码全保留,mode 改回
242
268
  // "hybrid" 即可切换(注:multiHopExpand 仍硬编码走 hybrid,默认 off,config.retrieval.multiHop
243
269
  // 开了会破纯 vector)。数据盲区:canary 全是语义 query,未覆盖 BM25 强项的精确关键词/token 召回
244
270
  // (变量名 / commit hash / alias-map 昵称)——待补 exact-token canary 验证。
@@ -278,6 +304,7 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
278
304
  timeDecayHalfLifeDays: 60,
279
305
  hotnessWeight: 0,
280
306
  utilityWeight: 0,
307
+ popularityBonusMax: 0.2,
281
308
  };
282
309
 
283
310
  /**
@@ -399,6 +426,21 @@ function applyAnchorBoost(results: RetrievalResult[], query: string): RetrievalR
399
426
  });
400
427
  }
401
428
 
429
+ /**
430
+ * T-Mem triggers:hybrid 路径 rerank 之后的地板。cross-encoder 按「query 与正文像不像」打分,
431
+ * 而经 trigger 到达的宿主与 query 恰恰是零词面重合的——不设地板就等于把 T-Mem 自己要对抗的
432
+ * 相似度体制重新加回来(论文原话)。地板取 trigger 分的 0.9,只托底不抬头。
433
+ */
434
+ const TRIGGER_FLOOR_FACTOR = 0.9;
435
+ function applyTriggerFloor(results: RetrievalResult[]): RetrievalResult[] {
436
+ return results.map(r => {
437
+ const t = r.sources.trigger;
438
+ if (!t) return r;
439
+ const floor = t.score * TRIGGER_FLOOR_FACTOR;
440
+ return r.score >= floor ? r : { ...r, score: floor };
441
+ });
442
+ }
443
+
402
444
  /** Multiplicative boost applied to a case on exact error-signature match. */
403
445
  const ERROR_SIGNATURE_BOOST_FACTOR = 1.5;
404
446
 
@@ -409,9 +451,18 @@ const ERROR_SIGNATURE_BOOST_FACTOR = 1.5;
409
451
  * (default) returns the input unchanged — bit-identical, so the pure-move eval baseline holds.
410
452
  * Only affects category="cases"; read and query side share extractErrorSignatures so the
411
453
  * signatures compare apples-to-apples.
454
+ *
455
+ * `clamp: false`(bounded 流行度下用):不在这里截到 1——链尾的分数此时可以超过 1,
456
+ * 在这里截会把精确命中的 1.14 压成 1.00、排到未命中的 1.08 之后(第二步 R1 Codex C2)。
457
+ * 截平统一在 retrieve() 出口做。legacy 调用处不传参,行为不变。
412
458
  */
413
- export function applyErrorSignatureBoost(results: RetrievalResult[], query: string): RetrievalResult[] {
459
+ export function applyErrorSignatureBoost(
460
+ results: RetrievalResult[],
461
+ query: string,
462
+ opts: { clamp?: boolean } = {},
463
+ ): RetrievalResult[] {
414
464
  if (!envConfig.errorSignatureBoost()) return results;
465
+ const clamp = opts.clamp ?? true;
415
466
  const querySignatures = extractErrorSignatures({ problem: query });
416
467
  if (querySignatures.length === 0) return results;
417
468
  const querySet = new Set(querySignatures.map(s => s.toLowerCase()));
@@ -421,7 +472,8 @@ export function applyErrorSignatureBoost(results: RetrievalResult[], query: stri
421
472
  if (!Array.isArray(sigs) || sigs.length === 0) return r;
422
473
  const hit = sigs.some(s => typeof s === "string" && querySet.has(s.toLowerCase()));
423
474
  if (!hit) return r;
424
- return { ...r, score: Math.min(1, Math.max(0, r.score * ERROR_SIGNATURE_BOOST_FACTOR)) };
475
+ const boosted = r.score * ERROR_SIGNATURE_BOOST_FACTOR;
476
+ return { ...r, score: clamp ? Math.min(1, Math.max(0, boosted)) : Math.max(0, boosted) };
425
477
  });
426
478
  }
427
479
 
@@ -741,6 +793,7 @@ export class MemoryRetriever {
741
793
  private frequencyTracker?: FrequencyTracker;
742
794
  private auditLogger?: AuditLogger;
743
795
  private llmClient?: ReconstructionLLMClient;
796
+ private triggerStore?: TriggerStore;
744
797
  /**
745
798
  * Session-scoped suppression list (dopamine-inspired "do not disturb").
746
799
  * IDs in this set get a score penalty during retrieval but are NOT deleted.
@@ -764,6 +817,11 @@ export class MemoryRetriever {
764
817
  this.kgStore = kgStore;
765
818
  }
766
819
 
820
+ /** T-Mem triggers(2026-09-22):挂上侧表后,检索多一条「经 trigger 到达」的入口。 */
821
+ setTriggerStore(triggerStore: TriggerStore): void {
822
+ this.triggerStore = triggerStore;
823
+ }
824
+
767
825
  /** P0.2: Attach a FrequencyTracker for hit-count based boosting. */
768
826
  setFrequencyTracker(tracker: FrequencyTracker): void {
769
827
  this.frequencyTracker = tracker;
@@ -810,9 +868,12 @@ export class MemoryRetriever {
810
868
  async retrieve(context: RetrievalContext): Promise<RetrievalResultSet> {
811
869
  const { query: rawQuery, limit, scopeFilter, category, includeArchived, trace, graph } = context;
812
870
  const safeLimit = clampInt(limit, 1, 20);
871
+ const excluded = resolveExcludedScopes(context);
872
+ const excludeScopes = excluded.length > 0 ? excluded : undefined;
813
873
 
814
- // Adaptive retrieval: skip trivial queries to save embedding API calls
815
- if (shouldSkipRetrieval(rawQuery)) {
874
+ // Adaptive retrieval: skip trivial queries to save embedding API calls — auto-recall only;
875
+ // explicit queries skip just empty / whitespace (see shouldSkipRetrievalFor).
876
+ if (shouldSkipRetrievalFor(context)) {
816
877
  return [];
817
878
  }
818
879
 
@@ -834,19 +895,19 @@ export class MemoryRetriever {
834
895
 
835
896
  // For vector-only mode, use legacy behavior
836
897
  if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
837
- results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace);
898
+ results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, undefined, excludeScopes);
838
899
  } else {
839
- // Hybrid retrieval with vector + BM25 + RRF fusion (+ optional PPR graph)
840
- results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph);
900
+ // Hybrid retrieval with vector + BM25 weighted score fusion (+ optional PPR graph)
901
+ results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph, undefined, excludeScopes);
841
902
  }
842
903
 
843
904
  // 0-hit fallback: minScore filter 把所有候选过滤光时,放宽阈值(=0)重试一次。
844
905
  // 只在原 path 0 hit 触发;正常召回不受影响。
845
906
  if (results.length === 0) {
846
907
  if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
847
- results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, 0);
908
+ results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, 0, excludeScopes);
848
909
  } else {
849
- results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph, 0);
910
+ results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph, 0, excludeScopes);
850
911
  }
851
912
  }
852
913
 
@@ -859,7 +920,7 @@ export class MemoryRetriever {
859
920
  // 字符串匹配,是 embedding 本质,跟语言无关)。普通词(mem0/recallnest/GitHub)不触发,日常
860
921
  // 语义召回不受影响。
861
922
  if (hasExactToken(rawQuery)) {
862
- results = await this.supplementExactTokenWithBm25(query, results, safeLimit, scopeFilter, category, includeArchived);
923
+ results = await this.supplementExactTokenWithBm25(query, results, safeLimit, scopeFilter, category, includeArchived, excludeScopes);
863
924
  }
864
925
 
865
926
  // LME-2: Multi-hop retrieval — extract entities from first-pass results,
@@ -886,6 +947,14 @@ export class MemoryRetriever {
886
947
  });
887
948
  }
888
949
 
950
+ // 第二步(bounded 流行度):检索器内部一律按原值排序,出口才截到 1——下游阈值与显示仍在 [0,1]。
951
+ // 必须在 BM25 补偿 / multiHop / topicTag / 有效期过滤之后:multiHopExpand 会按分数重排再截取,
952
+ // 截早了首轮 1.08 与跟进 1.14 都成 1.0、留下的是首轮那条(第二步 R3 Codex N2)。
953
+ // 也必须在下面挂 omitted / reconstruction 之前:那两个属性是之后才挂上的,这里 map 不会丢(R1 Agy A3)。
954
+ if (envConfig.popularityRanking() === "bounded" && results.length > 0) {
955
+ results = results.map(r => (r.score > 1 ? { ...r, score: 1 } : r));
956
+ }
957
+
889
958
  // P0.2: Record frequency hits for returned results (manual queries only)
890
959
  // Moved after topicTag filter so filtered-out entries are not reinforced.
891
960
  if (this.frequencyTracker && results.length > 0 && context.source !== "auto-recall") {
@@ -894,14 +963,15 @@ export class MemoryRetriever {
894
963
 
895
964
  // A-3: Record evolution access counts (async, non-blocking)
896
965
  // Moved after topicTag filter so filtered-out entries are not reinforced.
897
- if (results.length > 0 && context.source !== "auto-recall" && this.store.update) {
966
+ // 2026-09-24:一批行在写锁内读最新元数据再加计数(patchMetadataBatch,一次提交)。原先拿检索时读到的元数据
967
+ // 整行 update 回去,检索与写回之间别的进程写下的状态(对账下架、dream 合并、GC 归档)会被旧值盖掉。
968
+ if (results.length > 0 && context.source !== "auto-recall" && typeof this.store.patchMetadataBatch === "function") {
969
+ const ids = results.map((r) => r.entry.id);
898
970
  Promise.resolve().then(async () => {
899
- for (const r of results) {
900
- try {
901
- const updated = recordEvolutionAccess(r.entry.metadata);
902
- await this.store.update!(r.entry.id, { metadata: updated });
903
- } catch (err) { console.error("[recallnest] Evolution access tracking failed:", err instanceof Error ? err.message : String(err)); }
904
- }
971
+ try {
972
+ const now = Date.now();
973
+ await this.store.patchMetadataBatch(ids.map((id) => ({ id, patchFn: (meta) => recordEvolutionAccessOnMeta(meta, now) })));
974
+ } catch (err) { console.error("[recallnest] Evolution access tracking failed:", err instanceof Error ? err.message : String(err)); }
905
975
  });
906
976
  }
907
977
 
@@ -989,10 +1059,11 @@ export class MemoryRetriever {
989
1059
  scopeFilter?: string[],
990
1060
  category?: string,
991
1061
  includeArchived?: boolean,
1062
+ excludeScopes?: string[],
992
1063
  ): Promise<RetrievalResult[]> {
993
1064
  if (!this.store.hasFtsSupport) return vectorResults;
994
1065
  const ftsQuery = tokenizeFts(expandQuery(query), detectLang(query));
995
- const bm25 = await this.runBM25Search(ftsQuery, Math.max(limit, 5), scopeFilter, category, includeArchived);
1066
+ const bm25 = await this.runBM25Search(ftsQuery, Math.max(limit, 5), scopeFilter, category, includeArchived, excludeScopes);
996
1067
  const vecIds = new Set(vectorResults.map(r => r.entry.id));
997
1068
  const supplements = bm25
998
1069
  .filter(b => !vecIds.has(b.entry.id))
@@ -1038,6 +1109,8 @@ export class MemoryRetriever {
1038
1109
  context.includeArchived,
1039
1110
  undefined, // no trace for follow-up
1040
1111
  context.graph,
1112
+ undefined,
1113
+ resolveExcludedScopes(context).length > 0 ? resolveExcludedScopes(context) : undefined,
1041
1114
  ).catch(() => [] as RetrievalResult[]),
1042
1115
  );
1043
1116
  const followUpResults = await Promise.all(followUpPromises);
@@ -1062,6 +1135,74 @@ export class MemoryRetriever {
1062
1135
  .slice(0, context.limit);
1063
1136
  }
1064
1137
 
1138
+ /**
1139
+ * T-Mem 写入时预演触发器召回(2026-09-22,借自腾讯 T-Mem / EMNLP 2026)。
1140
+ *
1141
+ * 用 query 向量搜 memory_triggers 侧表,命中的 trigger 把宿主拉进候选池:
1142
+ * - 宿主已在候选里 → 分数取 max(原分, trigger 分),并记 sources.trigger;
1143
+ * - 宿主不在候选里 → 按 id 取回主表行,以 trigger 分入池(T-Mem 的「union 进候选、绕过相似度闸门」)。
1144
+ * 之后照常走 applySharedScoring / finalizeResults——trigger 只决定「能不能到达」,不另起一套分。
1145
+ * 硬闸 RECALLNEST_TRIGGER_GATE(余弦)防噪;侧表缺失或查询失败一律 fail-open 回原候选。
1146
+ * category / archived / scope 三道过滤与主路径同款,trigger 不能越权。
1147
+ */
1148
+ private async applyTriggerRecall(
1149
+ candidates: RetrievalResult[],
1150
+ queryVector: number[],
1151
+ queryText: string,
1152
+ scopeFilter?: string[],
1153
+ category?: string,
1154
+ includeArchived?: boolean,
1155
+ trace?: TraceCollector,
1156
+ ): Promise<RetrievalResult[]> {
1157
+ if (!this.triggerStore || !envConfig.triggerRecall()) return candidates;
1158
+ trace?.startStage("trigger_recall", candidates.length);
1159
+ let hits: TriggerHit[] = [];
1160
+ try {
1161
+ hits = await this.triggerStore.search(queryVector, envConfig.triggerTopK(), scopeFilter, {
1162
+ minCosine: envConfig.triggerGate(),
1163
+ softCosine: envConfig.triggerSoftGate(),
1164
+ minOverlap: envConfig.triggerMinOverlap(),
1165
+ minQueryTokens: envConfig.triggerSoftMinQueryTokens(),
1166
+ queryText,
1167
+ });
1168
+ } catch (err) {
1169
+ logWarn("trigger recall failed, continuing without it:", err);
1170
+ }
1171
+ if (hits.length === 0) {
1172
+ trace?.endStage(candidates.length, candidates.map(r => r.score));
1173
+ return candidates;
1174
+ }
1175
+ const byId = new Map(candidates.map((r, i) => [r.entry.id, i]));
1176
+ const out = [...candidates];
1177
+ for (const hit of hits) {
1178
+ const triggerSource = { score: hit.score, cosine: hit.cosine, text: hit.text, admittedBy: hit.admittedBy, overlap: hit.overlap };
1179
+ const idx = byId.get(hit.memoryId);
1180
+ if (idx !== undefined) {
1181
+ const existing = out[idx];
1182
+ out[idx] = {
1183
+ ...existing,
1184
+ score: Math.max(existing.score, hit.score),
1185
+ sources: { ...existing.sources, trigger: triggerSource },
1186
+ };
1187
+ continue;
1188
+ }
1189
+ let entry: MemoryEntry | null = null;
1190
+ try {
1191
+ entry = await this.store.getById(hit.memoryId);
1192
+ } catch (err) {
1193
+ logWarn(`trigger host lookup failed for ${hit.memoryId}:`, err);
1194
+ }
1195
+ if (!entry) continue;
1196
+ if (!matchesScopeFilter(entry.scope, scopeFilter)) continue;
1197
+ if (category && entry.category !== category) continue;
1198
+ if (!includeArchived && parseMetadata(entry.metadata).archived === true) continue;
1199
+ out.push({ entry, score: hit.score, sources: { trigger: triggerSource } });
1200
+ byId.set(entry.id, out.length - 1);
1201
+ }
1202
+ trace?.endStage(out.length, out.map(r => r.score));
1203
+ return out;
1204
+ }
1205
+
1065
1206
  private async vectorOnlyRetrieval(
1066
1207
  query: string,
1067
1208
  limit: number,
@@ -1070,6 +1211,7 @@ export class MemoryRetriever {
1070
1211
  includeArchived?: boolean,
1071
1212
  trace?: TraceCollector,
1072
1213
  minScoreOverride?: number,
1214
+ excludeScopes?: string[],
1073
1215
  ): Promise<RetrievalResult[]> {
1074
1216
  // Temporal reasoning for vector-only mode
1075
1217
  const temporal = parseTemporalQuery(query);
@@ -1090,7 +1232,7 @@ export class MemoryRetriever {
1090
1232
  // plus a few more local scoring passes, no extra API; P1.2's vector-only savings come from
1091
1233
  // skipping hybrid BM25/fusion/rerank, not from fetch size.
1092
1234
  const fetchLimit = Math.max(limit, this.config.candidatePoolSize ?? limit);
1093
- const results = await this.store.vectorSearch(queryVector, fetchLimit, vectorMinScore, scopeFilter);
1235
+ const results = await this.store.vectorSearch(queryVector, fetchLimit, vectorMinScore, scopeFilter, undefined, excludeScopes);
1094
1236
  trace?.endStage(results.length, results.map(r => r.score));
1095
1237
 
1096
1238
  // Filter by category if specified
@@ -1118,7 +1260,10 @@ export class MemoryRetriever {
1118
1260
  // P0.1: Anchor boost for vector-only path
1119
1261
  const anchorBoosted = applyAnchorBoost(mapped, searchQuery);
1120
1262
 
1121
- const scored = this.applySharedScoring(anchorBoosted, query, trace);
1263
+ // T-Mem triggers:联想入口在这里并入候选(相似度那一步之后、打分链之前)
1264
+ const withTriggers = await this.applyTriggerRecall(anchorBoosted, queryVector, searchQuery, scopeFilter, category, includeArchived, trace);
1265
+
1266
+ const scored = this.applySharedScoring(withTriggers, query, trace);
1122
1267
 
1123
1268
  // Session suppression: temporarily penalize "do not disturb" memories (vector-only path)
1124
1269
  const afterSuppression = this.applySessionSuppression(scored);
@@ -1141,6 +1286,7 @@ export class MemoryRetriever {
1141
1286
  trace?: TraceCollector,
1142
1287
  graph?: boolean,
1143
1288
  minScoreOverride?: number,
1289
+ excludeScopes?: string[],
1144
1290
  ): Promise<RetrievalResult[]> {
1145
1291
  // Adaptive pool: widen candidate pool for aggregation queries ("how many", "all the")
1146
1292
  const multiplier = (this.config.adaptivePoolMultiplier ?? 1) > 1 && isAggregationQuery(query)
@@ -1170,8 +1316,8 @@ export class MemoryRetriever {
1170
1316
 
1171
1317
  trace?.startStage("vector_search", 0);
1172
1318
  const [vectorResults, bm25Results, pprResults] = await Promise.all([
1173
- this.runVectorSearch(queryVector, candidatePoolSize, scopeFilter, category, includeArchived),
1174
- this.runBM25Search(ftsQuery, candidatePoolSize, scopeFilter, category, includeArchived),
1319
+ this.runVectorSearch(queryVector, candidatePoolSize, scopeFilter, category, includeArchived, excludeScopes),
1320
+ this.runBM25Search(ftsQuery, candidatePoolSize, scopeFilter, category, includeArchived, excludeScopes),
1175
1321
  pprPromise,
1176
1322
  ]);
1177
1323
  trace?.endStage(vectorResults.length, vectorResults.map(r => r.score));
@@ -1181,6 +1327,7 @@ export class MemoryRetriever {
1181
1327
 
1182
1328
  // Fuse results using weighted score fusion (async: validates BM25-only entries exist in store)
1183
1329
  const shortQ = isShortQuery(searchQuery);
1330
+ // Stage name "rrf_fusion" is historical and kept for log compatibility; fuseResults() does weighted score fusion, not RRF.
1184
1331
  trace?.startStage("rrf_fusion", vectorResults.length + bm25Results.length + pprResults.length);
1185
1332
  const fusedResults = await this.fuseResults(vectorResults, bm25Results, pprResults, shortQ);
1186
1333
  trace?.endStage(fusedResults.length, fusedResults.map(r => r.score));
@@ -1202,8 +1349,11 @@ export class MemoryRetriever {
1202
1349
  const anchorBoosted = applyAnchorBoost(multiVecBlended, searchQuery);
1203
1350
  trace?.endStage(anchorBoosted.length, anchorBoosted.map(r => r.score));
1204
1351
 
1352
+ // T-Mem triggers:联想入口并入候选,在 min_score / rerank 之前
1353
+ const withTriggers = await this.applyTriggerRecall(anchorBoosted, queryVector, searchQuery, scopeFilter, category, includeArchived, trace);
1354
+
1205
1355
  // Apply minimum score threshold
1206
- trace?.startStage("min_score_filter", anchorBoosted.length);
1356
+ trace?.startStage("min_score_filter", withTriggers.length);
1207
1357
  // P0.1: Lower minScore for short queries to widen the candidate pool;
1208
1358
  // minScoreOverride 用于 retrieve() 顶层的 0-hit fallback retry。
1209
1359
  const effectiveMinScore = minScoreOverride !== undefined
@@ -1211,7 +1361,7 @@ export class MemoryRetriever {
1211
1361
  : (isShortQuery(searchQuery)
1212
1362
  ? this.config.minScore * SHORT_QUERY_MIN_SCORE_FACTOR
1213
1363
  : this.config.minScore);
1214
- const filtered = anchorBoosted.filter(r => r.score >= effectiveMinScore);
1364
+ const filtered = withTriggers.filter(r => r.score >= effectiveMinScore);
1215
1365
  trace?.endStage(filtered.length, filtered.map(r => r.score));
1216
1366
 
1217
1367
  // Rerank if enabled
@@ -1221,7 +1371,10 @@ export class MemoryRetriever {
1221
1371
  : filtered;
1222
1372
  trace?.endStage(reranked.length, reranked.map(r => r.score));
1223
1373
 
1224
- const scored = this.applySharedScoring(reranked, query, trace);
1374
+ // T-Mem triggers:rerank 是相似度打分,经 trigger 到达的宿主要托底
1375
+ const triggerFloored = applyTriggerFloor(reranked);
1376
+
1377
+ const scored = this.applySharedScoring(triggerFloored, query, trace, TRIGGER_FLOOR_FACTOR);
1225
1378
 
1226
1379
  return this.finalizeResults(scored, {
1227
1380
  shortQ,
@@ -1238,11 +1391,69 @@ export class MemoryRetriever {
1238
1391
  * `query` feeds the emotion stage; `trace` carries per-stage trace spans.
1239
1392
  * Path-specific stages stay in the caller: anchor / min_score / rerank / multi-vector
1240
1393
  * blend run BEFORE this; vector-only session suppression runs AFTER this.
1394
+ *
1395
+ * `triggerPathBase`:开关 2 下「trigger 那一路」以 trigger 分的多少倍为底——vector 路径 1;
1396
+ * hybrid 路径传 TRIGGER_FLOOR_FACTOR,与 applyTriggerFloor 给 rerank 之后托的底同口径。
1241
1397
  */
1242
1398
  private applySharedScoring(
1243
1399
  results: RetrievalResult[],
1244
1400
  query: string,
1245
1401
  trace?: TraceCollector,
1402
+ triggerPathBase = 1,
1403
+ ): RetrievalResult[] {
1404
+ const assetWeighted = this.applyPreLengthStages(results, query, trace);
1405
+
1406
+ trace?.startStage("length_norm", assetWeighted.length);
1407
+ const normalized = this.applyLengthNormalization(assetWeighted);
1408
+ // 开关 2(2026-09-25 起默认开;设 false 时原样):trigger 那一路不做长度归一,与正文那一路取大
1409
+ const lengthNormalized = envConfig.triggerLengthExempt()
1410
+ ? this.applyTriggerPathFloor(normalized, results, query, triggerPathBase)
1411
+ : normalized;
1412
+ trace?.endStage(lengthNormalized.length, lengthNormalized.map(r => r.score));
1413
+
1414
+ trace?.startStage("time_decay", lengthNormalized.length);
1415
+ const timeDecayed = this.applyTimeDecay(lengthNormalized);
1416
+ trace?.endStage(timeDecayed.length, timeDecayed.map(r => r.score));
1417
+
1418
+ // B-1/E-1: Evolution decay blend — boost memories with high composite decay score
1419
+ const evolutionBlended = this.applyEvolutionDecayBlend(timeDecayed);
1420
+
1421
+ if (envConfig.popularityRanking() === "bounded") {
1422
+ // 流行度只进排序、只加不减、有上限、只读一个计数器、只施加一次(第二步 plan §零.1):
1423
+ // 下面 legacy 的访问计数加成、热度混合、频次加成三环都不跑。这一环与错误指纹加成都不截平,
1424
+ // 分数可以超过 1——检索器内部按原值排序,retrieve() 出口才截到 1。
1425
+ trace?.startStage("popularity_bonus", evolutionBlended.length);
1426
+ const bonused = this.applyPopularityBonus(evolutionBlended);
1427
+ trace?.endStage(bonused.length, bonused.map(r => r.score));
1428
+ return applyErrorSignatureBoost(bonused, query, { clamp: false });
1429
+ }
1430
+
1431
+ // E-1: Access count boost — memories retrieved more often get a score nudge
1432
+ const accessBoosted = this.applyAccessCountBoost(evolutionBlended);
1433
+
1434
+ // Hotness blend: boost frequently + recently accessed memories
1435
+ trace?.startStage("hotness_blend", accessBoosted.length);
1436
+ const hotnessBlended = this.applyHotnessBlend(accessBoosted);
1437
+ trace?.endStage(hotnessBlended.length, hotnessBlended.map(r => r.score));
1438
+
1439
+ // P0.2: Frequency boost — repeatedly retrieved memories score higher
1440
+ trace?.startStage("frequency_boost", hotnessBlended.length);
1441
+ const frequencyBoosted = this.applyFrequencyBoost(hotnessBlended);
1442
+ trace?.endStage(frequencyBoosted.length, frequencyBoosted.map(r => r.score));
1443
+
1444
+ // A1 error-signature 精确匹配 boost(flag 门控,默认 off → no-op、bit-identical)
1445
+ return applyErrorSignatureBoost(frequencyBoosted, query);
1446
+ }
1447
+
1448
+ /**
1449
+ * 长度归一之前的 7 个环节(新近度 → importance → utility → confidence → emotion → boundary →
1450
+ * 资产类型),从 applySharedScoring 原样挪出来,顺序与 trace 调用逐字不变。单独成方法是为了
1451
+ * 开关 2 能让「trigger 那一路」走完全相同的前置环节(applyTriggerPathFloor,不带 trace)。
1452
+ */
1453
+ private applyPreLengthStages(
1454
+ results: RetrievalResult[],
1455
+ query: string,
1456
+ trace?: TraceCollector,
1246
1457
  ): RetrievalResult[] {
1247
1458
  trace?.startStage("recency_boost", results.length);
1248
1459
  const boosted = this.applyRecencyBoost(results);
@@ -1278,32 +1489,59 @@ export class MemoryRetriever {
1278
1489
  const assetWeighted = this.applyAssetTypeWeight(boundaryWeighted);
1279
1490
  trace?.endStage(assetWeighted.length, assetWeighted.map(r => r.score));
1280
1491
 
1281
- trace?.startStage("length_norm", assetWeighted.length);
1282
- const lengthNormalized = this.applyLengthNormalization(assetWeighted);
1283
- trace?.endStage(lengthNormalized.length, lengthNormalized.map(r => r.score));
1284
-
1285
- trace?.startStage("time_decay", lengthNormalized.length);
1286
- const timeDecayed = this.applyTimeDecay(lengthNormalized);
1287
- trace?.endStage(timeDecayed.length, timeDecayed.map(r => r.score));
1288
-
1289
- // B-1/E-1: Evolution decay blend — boost memories with high composite decay score
1290
- const evolutionBlended = this.applyEvolutionDecayBlend(timeDecayed);
1291
-
1292
- // E-1: Access count boost — memories retrieved more often get a score nudge
1293
- const accessBoosted = this.applyAccessCountBoost(evolutionBlended);
1294
-
1295
- // Hotness blend: boost frequently + recently accessed memories
1296
- trace?.startStage("hotness_blend", accessBoosted.length);
1297
- const hotnessBlended = this.applyHotnessBlend(accessBoosted);
1298
- trace?.endStage(hotnessBlended.length, hotnessBlended.map(r => r.score));
1492
+ return assetWeighted;
1493
+ }
1299
1494
 
1300
- // P0.2: Frequency boost — repeatedly retrieved memories score higher
1301
- trace?.startStage("frequency_boost", hotnessBlended.length);
1302
- const frequencyBoosted = this.applyFrequencyBoost(hotnessBlended);
1303
- trace?.endStage(frequencyBoosted.length, frequencyBoosted.map(r => r.score));
1495
+ /**
1496
+ * 开关 2(RECALLNEST_TRIGGER_LENGTH_EXEMPT):trigger 那一路不做长度归一。
1497
+ *
1498
+ * 带 sources.trigger 的条目另算「只靠 trigger 这一路会是多少分」:以 trigger 分 × base 为底,
1499
+ * 不带 trace 走一遍与正文完全相同的前置环节,不做长度归一;长度归一后的分数取两路较大。
1500
+ * 它的相关度来自那句短 trigger、不来自正文,所以不该按正文长度打折。
1501
+ *
1502
+ * 不用「长度系数取 max(原系数, trigger 分 / 并入后分数)」(第二步 v2 的写法):长度归一之前有
1503
+ * 新近度加法,终分 ≈ (B + r)·M·T/B,向量分 B 变大时反而下降(R3 Codex 实测 0.79→0.81 终分降),
1504
+ * 而且得往 sources 记占比、从 API 原样漏出去。两路各自对向量分、trigger 分单调,取大仍单调。
1505
+ */
1506
+ private applyTriggerPathFloor(
1507
+ normalized: RetrievalResult[],
1508
+ original: RetrievalResult[],
1509
+ query: string,
1510
+ base: number,
1511
+ ): RetrievalResult[] {
1512
+ const twins: RetrievalResult[] = [];
1513
+ for (const r of original) {
1514
+ const t = r.sources.trigger;
1515
+ if (t) twins.push({ ...r, score: t.score * base });
1516
+ }
1517
+ if (twins.length === 0) return normalized;
1518
+ const triggerPath = new Map(this.applyPreLengthStages(twins, query).map(r => [r.entry.id, r.score]));
1519
+ return normalized
1520
+ .map(r => {
1521
+ const floor = triggerPath.get(r.entry.id);
1522
+ return floor !== undefined && floor > r.score ? { ...r, score: floor } : r;
1523
+ })
1524
+ .sort((a, b) => b.score - a.score);
1525
+ }
1304
1526
 
1305
- // A1 error-signature 精确匹配 boost(flag 门控,默认 off → no-op、bit-identical)
1306
- return applyErrorSignatureBoost(frequencyBoosted, query);
1527
+ /**
1528
+ * bounded 流行度(第二步 plan §零.1):score × (1 + γ·h),h = min(1, log2(1 + evolution.accessCount) / 5)。
1529
+ * 只读 evolution.accessCount——每次 manual 检索的每条返回都 +1、写锁内读改写、库内持久;
1530
+ * 频次台账(跨进程丢更新)与顶层 accessCount 都不读。零访问 h = 0 原样放行,只加不减,不截平。
1531
+ * # simplified: 31 次封顶的对数饱和、不看时间;要按「最近常用」加权再换成带衰减的计数(§一.4)
1532
+ */
1533
+ private applyPopularityBonus(results: RetrievalResult[]): RetrievalResult[] {
1534
+ const raw = this.config.popularityBonusMax;
1535
+ const gamma = Math.max(0, Number.isFinite(raw) ? raw : 0);
1536
+ if (gamma <= 0) return results;
1537
+ return results.map(r => {
1538
+ const n: unknown = parseEvolution(r.entry.metadata, r.entry.timestamp).accessCount;
1539
+ // parseEvolution 不校验类型:元数据里的计数若是字符串 / 负数 / 非有限数,一律按零访问。
1540
+ // 这一环不截平,NaN 会一路漏到出口(legacy 那几环有 clamp01 的非有限兜底,这里没有;第二步实现互审 Codex 实测 "broken" → NaN%)
1541
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) return r;
1542
+ const h = Math.min(1, Math.log2(1 + n) / 5);
1543
+ return { ...r, score: r.score * (1 + gamma * h) };
1544
+ });
1307
1545
  }
1308
1546
 
1309
1547
  /**
@@ -1390,10 +1628,11 @@ export class MemoryRetriever {
1390
1628
  scopeFilter?: string[],
1391
1629
  category?: string,
1392
1630
  includeArchived?: boolean,
1631
+ excludeScopes?: string[],
1393
1632
  ): Promise<Array<MemorySearchResult & { rank: number }>> {
1394
1633
  let results: MemorySearchResult[];
1395
1634
  try {
1396
- results = await this.store.vectorSearch(queryVector, limit, 0.1, scopeFilter);
1635
+ results = await this.store.vectorSearch(queryVector, limit, 0.1, scopeFilter, undefined, excludeScopes);
1397
1636
  } catch (err) {
1398
1637
  // Fail-open: log warning and continue with empty results (backport from v1.0.30)
1399
1638
  logWarn("vectorSearch failed, continuing with empty results:", err);
@@ -1422,8 +1661,9 @@ export class MemoryRetriever {
1422
1661
  scopeFilter?: string[],
1423
1662
  category?: string,
1424
1663
  includeArchived?: boolean,
1664
+ excludeScopes?: string[],
1425
1665
  ): Promise<Array<MemorySearchResult & { rank: number }>> {
1426
- const results = await this.store.bm25Search(query, limit, scopeFilter);
1666
+ const results = await this.store.bm25Search(query, limit, scopeFilter, excludeScopes);
1427
1667
 
1428
1668
  // Filter by category if specified
1429
1669
  const afterCategory = category
@@ -142,12 +142,13 @@ export async function retroactiveBoost(
142
142
  meta.tier = "working";
143
143
  }
144
144
 
145
- await store.update(entry.id, {
145
+ const boosted = await store.update(entry.id, {
146
146
  importance: newImportance,
147
147
  metadata: JSON.stringify(meta),
148
148
  }, [newEntry.scope]);
149
149
 
150
- boostedIds.push(entry.id);
150
+ // update() 返回 null = 这一行在检索之后被删掉了,没有提权成,不计入
151
+ if (boosted) boostedIds.push(entry.id);
151
152
  }
152
153
 
153
154
  return {
@@ -6,6 +6,7 @@ import { metaDir } from "./compat.js";
6
6
  import { MemoryStore, validateStoragePath } from "./store.js";
7
7
  import { createEmbedder, getVectorDimensions, type EmbeddingConfig } from "./embedder.js";
8
8
  import { createRetriever, type RetrievalConfig, DEFAULT_RETRIEVAL_CONFIG } from "./retriever.js";
9
+ import { TriggerStore } from "./trigger-store.js";
9
10
  import { applyRetrievalProfile } from "./retrieval-profiles.js";
10
11
  import { AccessTracker } from "./access-tracker.js";
11
12
  import { createAuditLogger } from "./audit-log.js";
@@ -182,6 +183,10 @@ export function createComponents(config: LocalMemoryConfig, profileName?: string
182
183
  const { profile, config: retrieverConfig } = applyRetrievalProfile(baseRetrievalConfig, profileName);
183
184
  const retriever = createRetriever(store, embedder, retrieverConfig);
184
185
 
186
+ // T-Mem triggers(2026-09-22):侧表与主表同库同 dim;挂到 retriever 后检索多一条联想入口
187
+ const triggerStore = new TriggerStore({ dbPath, vectorDim: embedder.dimensions });
188
+ retriever.setTriggerStore(triggerStore);
189
+
185
190
  // Attach access tracker for reinforcement-based decay
186
191
  const accessTracker = new AccessTracker(store);
187
192
  accessTracker.registerExitFlush();
@@ -220,7 +225,7 @@ export function createComponents(config: LocalMemoryConfig, profileName?: string
220
225
  }
221
226
  }
222
227
 
223
- return { store, embedder, retriever, profile, accessTracker, frequencyTracker, llm };
228
+ return { store, embedder, retriever, profile, accessTracker, frequencyTracker, llm, triggerStore };
224
229
  }
225
230
 
226
231
  export function createStoreOnly(config: LocalMemoryConfig): MemoryStore {