recallnest 3.0.0 → 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 (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  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 +12 -3
  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 +459 -71
  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-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
@@ -185,7 +185,7 @@ function groupEntries(entries: MemoryEntry[]): Map<string, MemoryEntry[]> {
185
185
  // Check 1: Contradictions
186
186
  // ---------------------------------------------------------------------------
187
187
 
188
- function findContradictions(entries: MemoryEntry[]): LintFinding[] {
188
+ export function findContradictions(entries: MemoryEntry[]): LintFinding[] {
189
189
  const findings: LintFinding[] = [];
190
190
 
191
191
  // Only check merge-type categories where contradictions are meaningful
@@ -8,6 +8,7 @@ import { parseNarrative } from "./narrative-schema.js";
8
8
  import { getConfidence, getConfidenceMetadata } from "./confidence-tracker.js";
9
9
  import { estimateTokens } from "./context-collapse-renderer.js";
10
10
  import { evaluateEntryFreshness, createFreshnessCache } from "./freshness.js";
11
+ import { fullTextScoreThreshold, searchFirstScreen } from "./env-config.js";
11
12
 
12
13
  interface MemoryMetadata {
13
14
  source?: string;
@@ -84,6 +85,80 @@ function getTierLabel(result: RetrievalResult): string {
84
85
  return String(meta.tier || "peripheral");
85
86
  }
86
87
 
88
+ /**
89
+ * 所在 session 里有用户贴图时,多给一行提示。
90
+ *
91
+ * 图本体不在库里,正文里通常也没有对图的描述——这一行是唯一能让读到这条
92
+ * 记忆的人意识到「这附近还有图没看」的信号。判断值不值得看,回 Deja 按
93
+ * session 读原文。
94
+ *
95
+ * ⚠️ 这是 **session 级**标记,不是「本条记忆有图」:同一个 session 切出来
96
+ * 的每条记忆都带同一个数,图未必跟眼前这条有关。粒度换覆盖,明知而为——
97
+ * 图所在的那一轮常因正文太短根本没进库,只有盖到同 session 的其他轮次上
98
+ * 才可能被搜到。
99
+ *
100
+ * 无图时返回 null,调用方据此不输出这一行——不给无图记忆添噪音。
101
+ */
102
+ function formatSessionImages(result: RetrievalResult): string | null {
103
+ const meta = parseMetadata(result.entry);
104
+ const own = typeof meta.sessionImages === "number" ? meta.sessionImages : 0;
105
+ const tool = typeof meta.sessionToolImages === "number" ? meta.sessionToolImages : 0;
106
+ if (own <= 0 && tool <= 0) return null;
107
+
108
+ // 两类分开报,因为它们答的是不同的问题:找「我发过的那张截图」看前者,
109
+ // 回溯「我当时生成的图 / 那个页面当时的样子」看后者。
110
+ // 措辞用英文,与同一块里的 prov / fresh 行保持一致(这个输出面是英文的)。
111
+ const parts: string[] = [];
112
+ if (own > 0) parts.push(`${own} user-pasted`);
113
+ if (tool > 0) parts.push(`${tool} agent-made`);
114
+ const sess = typeof meta.sessionId === "string" ? meta.sessionId.slice(0, 8) : "?";
115
+
116
+ return `${parts.join(", ")} in this session · read sess=${sess}`;
117
+ }
118
+
119
+ interface DistilledSource {
120
+ /** 原会话日期(`date:` 标签),不是写入日——批量回填的行两者能差出半年。 */
121
+ date: string | null;
122
+ /** 写入时存在行上的原文 anchor;可能是当时助手说的,不一定是用户的话。 */
123
+ text: string;
124
+ }
125
+
126
+ /**
127
+ * 批量提炼行(pivot-apply 从历史会话回填,2026-08-04/05 共 1,516 条):表里那句是转述,不是原话。
128
+ *
129
+ * 这批提炼句没逐条核对过——2026-09-11 抽 20 条对照各自存的原文,8 条有实质走样(丢限定词、
130
+ * 一次性要求记成长期规则、把当时助手的话记成用户偏好……)。原文 anchor 本来就存在行上,
131
+ * 读取时亮出来,比事后清洗存量便宜,也能罩住以后同一条路写进来的行。
132
+ *
133
+ * 只认 pivot-apply 标签:当场记录(manual / agent)的 anchor 语义不同,不在这里出。
134
+ * 不是批量行时返回 null,调用方据此什么都不输出——不给别的记忆添噪音。
135
+ */
136
+ function getDistilledSource(result: RetrievalResult): DistilledSource | null {
137
+ const meta = parseMetadata(result.entry);
138
+ const tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
139
+ if (!tags.includes("pivot-apply")) return null;
140
+ const dateTag = tags.find((tag) => tag.startsWith("date:"));
141
+ return {
142
+ date: dateTag ? dateTag.slice("date:".length) : null,
143
+ text: typeof meta.anchor === "string" ? meta.anchor : "",
144
+ };
145
+ }
146
+
147
+ const DISTILLED_ORIGIN_MAX_LEN = 160;
148
+
149
+ // 措辞用英文,与 prov / imgs / fresh 行保持一致(这个输出面是英文的)。
150
+ const DISTILLED_NOTE =
151
+ "rows with an orig line were batch-distilled from old sessions: the snippet is a paraphrase, " +
152
+ "orig is the source text (it may be the assistant's words, not the user's) — trust orig";
153
+
154
+ function formatDistilledOrigin(source: DistilledSource): string {
155
+ const when = source.date ? `(${source.date} session) ` : "";
156
+ const text = source.text
157
+ ? cleanSnippet(source.text, DISTILLED_ORIGIN_MAX_LEN)
158
+ : "(source text missing)";
159
+ return `${when}${text}`;
160
+ }
161
+
87
162
  function getProvenanceSummary(result: RetrievalResult): string {
88
163
  const provenance = extractMemoryProvenance({
89
164
  scope: result.entry.scope,
@@ -174,6 +249,7 @@ function getRetrievalPath(result: RetrievalResult): string {
174
249
  if (result.sources.vector) parts.push("vector");
175
250
  if (result.sources.bm25) parts.push("bm25");
176
251
  if (result.sources.reranked) parts.push("reranked");
252
+ if (result.sources.trigger) parts.push("trigger");
177
253
  if (result.sources.narrativeSibling) parts.push("narrative");
178
254
  return parts.join("+") || "direct";
179
255
  }
@@ -225,8 +301,12 @@ function pickBestSnippet(query: string, text: string): string {
225
301
  * adaptive 专用 query-aware snippet:取匹配词周围窗口(而非整句/开头),保证匹配证据可见。
226
302
  * 解决 pickBestSnippet 的两个退化(Codex 审点4 C 版 P2):① 短缩写 query(如 "CI")
227
303
  * extractTerms 提取不到词 → 退回开头;② 匹配在超长句/log 行后段 → cleanSnippet 从头截断丢匹配。
228
- * 只给 adaptive 用,不改 pickBestSnippet(避免影响 normal/explain 的既有行为)。
304
+ * adaptive 档与默认第一屏(`formatFullTextResults` 里超预算降级的那条路)共用;旧表格(legacy)与 explain
305
+ * 走的是 pickBestSnippet,不经这里。
229
306
  */
307
+ const ADAPTIVE_SNIPPET_BEFORE = 60;
308
+ const ADAPTIVE_SNIPPET_AFTER = 180;
309
+
230
310
  function adaptiveSnippet(query: string, text: string): string {
231
311
  const clean = text.replace(/\s+/g, " ").trim();
232
312
  const lower = clean.toLowerCase();
@@ -254,10 +334,8 @@ function adaptiveSnippet(query: string, text: string): string {
254
334
  }
255
335
  if (matchIdx < 0) return pickBestSnippet(query, text); // 完全无匹配 → 退回整句最佳
256
336
 
257
- const WINDOW_BEFORE = 60;
258
- const WINDOW_AFTER = 180;
259
- const start = Math.max(0, matchIdx - WINDOW_BEFORE);
260
- const end = Math.min(clean.length, matchIdx + WINDOW_AFTER);
337
+ const start = Math.max(0, matchIdx - ADAPTIVE_SNIPPET_BEFORE);
338
+ const end = Math.min(clean.length, matchIdx + ADAPTIVE_SNIPPET_AFTER);
261
339
  let snip = clean.slice(start, end).trim();
262
340
  if (start > 0) snip = "…" + snip;
263
341
  if (end < clean.length) snip = snip + "…";
@@ -305,6 +383,12 @@ function buildWhyMatched(query: string, result: RetrievalResult): string {
305
383
  reasons.push("reranked");
306
384
  }
307
385
 
386
+ if (result.sources.trigger) {
387
+ // T-Mem:说清是哪句预演问法把它捞出来的(只在 explain 露面,不进 context)
388
+ const t = result.sources.trigger;
389
+ reasons.push(`trigger「${t.text}」cos=${t.cosine.toFixed(2)}${t.admittedBy === "soft" ? ` overlap=${(t.overlap ?? 0).toFixed(2)}` : ""}`);
390
+ }
391
+
308
392
  if (matchedTerms.length > 0) {
309
393
  reasons.push(`terms:${matchedTerms.slice(0, 3).join(",")}`);
310
394
  }
@@ -353,14 +437,25 @@ export function formatBriefResults(
353
437
  ): string {
354
438
  if (results.length === 0) return "No results found.";
355
439
  const freshnessCache = createFreshnessCache();
356
- const lines = [`Query: ${context.query}`, `Hits: ${results.length}`, ""];
440
+ // brief 只标不附原文:它就是为省 token 选的档,原文会让它长一倍。
441
+ const distilled = results.map(getDistilledSource);
442
+ const lines = [
443
+ `Query: ${context.query}`,
444
+ `Hits: ${results.length}`,
445
+ ...(distilled.some(Boolean)
446
+ ? ["Note: [distilled] rows paraphrase old sessions — use detail_level=normal to see the source text"]
447
+ : []),
448
+ "",
449
+ ];
357
450
  for (let i = 0; i < results.length; i++) {
358
451
  const r = results[i];
359
452
  const excerpt = extractBriefExcerpt(r);
453
+ const orig = distilled[i] ?? null;
454
+ const distilledTag = orig ? ` [distilled${orig.date ? `, ${orig.date} session` : ""}]` : "";
360
455
  // Freshness suffix: only for memories that declared dependsOn (opt-in, cheap check).
361
456
  const fresh = evaluateEntryFreshness(r.entry.metadata, freshnessCache);
362
457
  const freshTag = fresh ? ` [fresh:${fresh}]` : "";
363
- lines.push(`#${i + 1} ${r.entry.id.slice(0, 8)} ${(r.score * 100).toFixed(1)}% — ${excerpt}${freshTag}`);
458
+ lines.push(`#${i + 1} ${r.entry.id.slice(0, 8)} ${(r.score * 100).toFixed(1)}% — ${excerpt}${distilledTag}${freshTag}`);
364
459
  }
365
460
  return lines.join("\n");
366
461
  }
@@ -372,10 +467,12 @@ export function formatFullResults(
372
467
  if (results.length === 0) return "No results found.";
373
468
 
374
469
  const freshnessCache = createFreshnessCache();
470
+ const distilled = results.map(getDistilledSource);
375
471
  const lines = [
376
472
  `Query : ${context.query}`,
377
473
  `Profile : ${context.profile}`,
378
474
  `Hits : ${results.length}`,
475
+ ...(distilled.some(Boolean) ? [`Note : ${DISTILLED_NOTE}`] : []),
379
476
  "",
380
477
  "# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
381
478
  "-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
@@ -385,6 +482,10 @@ export function formatFullResults(
385
482
  const row = buildSearchRow(i, context.query, results[i]);
386
483
  lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
387
484
  lines.push(` prov : ${getProvenanceSummary(results[i])}`);
485
+ const imgs = formatSessionImages(results[i]);
486
+ if (imgs) lines.push(` imgs : ${imgs}`);
487
+ const orig = distilled[i] ?? null;
488
+ if (orig) lines.push(` orig : ${formatDistilledOrigin(orig)}`);
388
489
  // Full mode: append metadata details
389
490
  const meta = parseMetadata(results[i].entry);
390
491
  const evolution = typeof meta.evolutionStatus === "string" ? meta.evolutionStatus : "-";
@@ -415,17 +516,23 @@ export function formatFullResults(
415
516
  return lines.join("\n");
416
517
  }
417
518
 
418
- export function formatSearchResults(
519
+ /**
520
+ * 2026-10-05 之前的默认第一屏:表格 + 每条 120 字片段。函数体一个字没动,只是改了名。
521
+ * 现在只在 `RECALLNEST_SEARCH_FIRST_SCREEN=legacy` 时由 `formatSearchResults` 走到(退回开关)。
522
+ */
523
+ export function formatLegacyTableResults(
419
524
  results: RetrievalResult[],
420
525
  context: RenderContext,
421
526
  ): string {
422
527
  if (results.length === 0) return "No results found.";
423
528
 
424
529
  const freshnessCache = createFreshnessCache();
530
+ const distilled = results.map(getDistilledSource);
425
531
  const lines = [
426
532
  `Query : ${context.query}`,
427
533
  `Profile : ${context.profile}`,
428
534
  `Hits : ${results.length}`,
535
+ ...(distilled.some(Boolean) ? [`Note : ${DISTILLED_NOTE}`] : []),
429
536
  "",
430
537
  "# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
431
538
  "-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
@@ -435,6 +542,10 @@ export function formatSearchResults(
435
542
  const row = buildSearchRow(i, context.query, results[i]);
436
543
  lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
437
544
  lines.push(` prov : ${getProvenanceSummary(results[i])}`);
545
+ const imgs = formatSessionImages(results[i]);
546
+ if (imgs) lines.push(` imgs : ${imgs}`);
547
+ const orig = distilled[i] ?? null;
548
+ if (orig) lines.push(` orig : ${formatDistilledOrigin(orig)}`);
438
549
  // Freshness: only shown for memories that declared dependsOn (opt-in, cheap check).
439
550
  const fresh = evaluateEntryFreshness(results[i].entry.metadata, freshnessCache);
440
551
  if (fresh) lines.push(` fresh: ${fresh}`);
@@ -444,7 +555,182 @@ export function formatSearchResults(
444
555
  }
445
556
 
446
557
  const ADAPTIVE_TOKEN_BUDGET = 8000;
447
- const ADAPTIVE_FULL_SCORE = 0.85;
558
+
559
+ /**
560
+ * 默认第一屏单条正文的封顶(按码点数)。
561
+ *
562
+ * 线是照库里的实际长度定的(2026-10-05 只读快照,138,501 条活跃记忆):手写与提炼条目最长 2,278、
563
+ * 会话切片最长 2,077、记忆文件切片最长 1,523,都在线下,永远给全文;会被截的只有项目文档的大切片
564
+ * (`project:` 7,797 条,中位 2,401、最长 3,990)。封顶防的是一条长文档排第一时把后面几条的预算吃光。
565
+ */
566
+ const FIRST_SCREEN_ENTRY_CAP = 2400;
567
+
568
+ const DISTILLED_NOTE_FULLTEXT =
569
+ "entries with an orig line are batch-distilled paraphrases of old sessions; " +
570
+ "orig is the source text (it may be the assistant's words, not the user's) — trust orig";
571
+
572
+ /**
573
+ * 条目行末尾的出处:有会话编号就给 `sess=前 8 位`(imgs 行、Deja 回查用的都是它);文件名只在它不是
574
+ * 会话编号的重复时才给——Claude Code 的会话文件叫 `<会话编号>.jsonl`、Codex 的叫 `rollout-<时间>-<会话编号>.jsonl`,
575
+ * 再写一遍每条白占四五十个字。
576
+ */
577
+ function getOriginLabel(result: RetrievalResult): string[] {
578
+ const meta = parseMetadata(result.entry);
579
+ const sessionId = typeof meta.sessionId === "string" ? meta.sessionId : "";
580
+ const file = getFileLabel(result);
581
+ const parts: string[] = [];
582
+ if (sessionId) parts.push(`sess=${sessionId.slice(0, 8)}`);
583
+ // 要整段会话编号对上才算重复:只对上前 8 位的可能是另有内容的文件名。
584
+ const repeatsSession = sessionId !== "" && file.endsWith(`${sessionId}.jsonl`);
585
+ if (file !== "-" && !repeatsSession) parts.push(file);
586
+ return parts;
587
+ }
588
+
589
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
590
+
591
+ /**
592
+ * adaptiveSnippet / cleanSnippet 按 UTF-16 下标切,窗口边上可能留下半个代理对。
593
+ * 默认第一屏这条路上把落单的那半个去掉;那两个函数本身不动(adaptive、legacy、explain 的输出不变)。
594
+ */
595
+ function dropLoneSurrogates(text: string): string {
596
+ return text.replace(LONE_SURROGATE, "");
597
+ }
598
+
599
+ /** 片段里属于原文的字数:去掉片段函数在两头补的省略号。 */
600
+ function snippetSourceChars(snippet: string): number {
601
+ return Array.from(snippet.replace(/^…/, "").replace(/(…|\.\.\.)$/, "")).length;
602
+ }
603
+
604
+ /** 按码点取开头一段,不把代理对切成两半。`total` 是整条的码点数。 */
605
+ function headByCodePoints(text: string, max: number): { head: string; total: number; truncated: boolean } {
606
+ const points = Array.from(text);
607
+ if (points.length <= max) return { head: text, total: points.length, truncated: false };
608
+ return { head: points.slice(0, max).join(""), total: points.length, truncated: true };
609
+ }
610
+
611
+ /**
612
+ * 默认第一屏(2026-10-05 起):按名次给正文全文。
613
+ *
614
+ * 为什么不沿用 120 字片段:同一批 48 条真实查询、同一份命中,三位读者盲判「只看这一屏就够」,
615
+ * 表格 + 120 字是 1 条、全文是 18 条;旧默认屏里正文只占 23%,5 条全文合计的中位字数反而比它短。
616
+ * 为什么不按分数给(adaptive 的做法):分数是融合排序分,只表示这一批里的相对位置,排第一的中位
617
+ * 只有 0.64,43 条非空查询里 31 条一条全文都拿不到。所以这里只看名次,不看分数。
618
+ *
619
+ * 规则:
620
+ * - 按传入顺序(handler 已排好)逐条给正文;单条超过 FIRST_SCREEN_ENTRY_CAP 只给开头那一段。
621
+ * - 给全文的额度是 ADAPTIVE_TOKEN_BUDGET(与 adaptive 同一个数,正文与 orig 行一起算)。放不下的条目降成
622
+ * 匹配词周围的片段,后面更短的条目放得下仍给全文。
623
+ * - 每条命中都列出来,不因预算整条消失——这一点有意不照 adaptive(它超预算会停并报 omitted)。
624
+ * 所以 8000 是「给全文给到哪为止」的线,不是整屏的硬顶:片段、以及整条不比片段窗口(240 字)长的短条目
625
+ * 不受它拦,整屏最多超出「剩下每条一个片段」(limit ≤ 20;20 条都是 2400 个汉字时约 12,000)。
626
+ * 先给所有条目预留片段再分全文的做法试算过:尾部的片段会把排第一的长条目挤成片段,和「按名次给」相反。
627
+ * - search_memory 的 related-scope 附栏各调一次本函数,各有一份额度。
628
+ * - 缩短过的条目各带一行说明:共多少字、给了多少、用哪个编号展开。顶上 Text 行报整屏的全文 / 截断 / 片段条数。
629
+ * - 元数据每条一行。旧表格里的 Tier 与 Retrieval Path 两列不再出,会话文件名换成 `sess=前 8 位`;
630
+ * 这三样 detail_level=full 里还有。
631
+ * - imgs / orig / fresh 三种附加行照旧。
632
+ */
633
+ export function formatFullTextResults(
634
+ results: RetrievalResult[],
635
+ context: RenderContext,
636
+ ): string {
637
+ if (results.length === 0) return "No results found.";
638
+
639
+ const freshnessCache = createFreshnessCache();
640
+ const blocks: string[] = [];
641
+ let tokensUsed = 0;
642
+ let fullCount = 0;
643
+ let cappedCount = 0;
644
+ let snippetCount = 0;
645
+ let anyOrig = false;
646
+
647
+ for (let i = 0; i < results.length; i++) {
648
+ const r = results[i];
649
+ const id = r.entry.id.slice(0, 8);
650
+ // 展开提示给完整编号:8 位前缀在库里有撞的(2026-10-05 快照 138,502 行里 2 组),撞了 memory_drill_down 取不回来。
651
+ const drillHint = `memory_drill_down ${r.entry.id} for the full text`;
652
+ const capped = headByCodePoints(r.entry.text, FIRST_SCREEN_ENTRY_CAP);
653
+ const orig = getDistilledSource(r);
654
+ const origLine = orig ? ` orig : ${dropLoneSurrogates(formatDistilledOrigin(orig))}` : "";
655
+ const origTokens = origLine ? estimateTokens(origLine) : 0;
656
+
657
+ let text = capped.head;
658
+ let cutNote = capped.truncated
659
+ ? ` … [truncated: showing ${FIRST_SCREEN_ENTRY_CAP} of ${capped.total} chars · ${drillHint}]`
660
+ : "";
661
+ let tokens = estimateTokens(text) + origTokens;
662
+ // 整条不比一个片段窗口长的条目不降级:降了也省不下字,只会多出一行「给了片段」的说明。
663
+ const worthSnipping = capped.total > ADAPTIVE_SNIPPET_BEFORE + ADAPTIVE_SNIPPET_AFTER;
664
+ if (worthSnipping && tokensUsed + tokens > ADAPTIVE_TOKEN_BUDGET) {
665
+ text = dropLoneSurrogates(adaptiveSnippet(context.query, r.entry.text));
666
+ cutNote = ` [snippet: token budget reached · showing ${snippetSourceChars(text)} of ${capped.total} chars · ${drillHint}]`;
667
+ tokens = estimateTokens(text) + origTokens;
668
+ snippetCount++;
669
+ } else if (capped.truncated) {
670
+ cappedCount++;
671
+ } else {
672
+ fullCount++;
673
+ }
674
+ tokensUsed += tokens;
675
+
676
+ const prov = getProvenanceSummary(r);
677
+ const header = [
678
+ id,
679
+ `${(r.score * 100).toFixed(1)}%`,
680
+ `${getDateLabel(r.entry.timestamp)} (${formatAgeLabel(r.entry.timestamp)})`,
681
+ getCategoryLabel(r),
682
+ getSourceLabel(r),
683
+ ...(prov !== "-" ? [prov] : []),
684
+ ...getOriginLabel(r),
685
+ ].join(" · ");
686
+ blocks.push(`[${i + 1}] ${header}`);
687
+ // fresh 管的是「这条还能不能照着用」,放在正文前面;只有声明过 dependsOn 的记忆才有(opt-in)。
688
+ const fresh = evaluateEntryFreshness(r.entry.metadata, freshnessCache);
689
+ if (fresh) blocks.push(` fresh: ${fresh}`);
690
+ blocks.push(text);
691
+ if (cutNote) blocks.push(cutNote);
692
+ if (origLine) {
693
+ blocks.push(origLine);
694
+ anyOrig = true;
695
+ }
696
+ const imgs = formatSessionImages(r);
697
+ if (imgs) blocks.push(` imgs : ${imgs}`);
698
+ blocks.push("");
699
+ }
700
+ // 去掉自己加的最后一个块间空行;不对整份输出 trimEnd,否则最后一条正文自己的结尾空白也会被吃掉。
701
+ blocks.pop();
702
+
703
+ // 全部给全的时候这一行只报条数;有缩短的才把封顶与预算写出来。
704
+ const shortened = cappedCount + snippetCount > 0;
705
+ const breakdown = [
706
+ `${fullCount} in full`,
707
+ ...(cappedCount > 0 ? [`${cappedCount} capped (${FIRST_SCREEN_ENTRY_CAP}-char cap)`] : []),
708
+ ...(snippetCount > 0 ? [`${snippetCount} snippet (${ADAPTIVE_TOKEN_BUDGET}-token budget)`] : []),
709
+ ].join(", ") + (shortened ? " — shortened entries say how much is shown" : "");
710
+ const lines = [
711
+ `Query : ${context.query}`,
712
+ `Profile : ${context.profile}`,
713
+ `Hits : ${results.length}`,
714
+ `Text : ${breakdown}`,
715
+ ...(anyOrig ? [`Note : ${DISTILLED_NOTE_FULLTEXT}`] : []),
716
+ "",
717
+ ...blocks,
718
+ ];
719
+ return lines.join("\n");
720
+ }
721
+
722
+ /**
723
+ * search_memory 默认档(detail_level=normal)、命令行 `search`、本地 UI 共用的入口。
724
+ * 默认出全文那一版;`RECALLNEST_SEARCH_FIRST_SCREEN=legacy` 退回表格 + 120 字片段。
725
+ */
726
+ export function formatSearchResults(
727
+ results: RetrievalResult[],
728
+ context: RenderContext,
729
+ ): string {
730
+ return searchFirstScreen() === "legacy"
731
+ ? formatLegacyTableResults(results, context)
732
+ : formatFullTextResults(results, context);
733
+ }
448
734
 
449
735
  /**
450
736
  * P-fidelity (点4): adaptive 保真度渲染 — 借鉴 RepoPrompt CE「保真度阶梯」的内核
@@ -454,7 +740,7 @@ const ADAPTIVE_FULL_SCORE = 0.85;
454
740
  * collapse 的 l0 floor / 按 score 重排 / query-agnostic fallback 都是为 resume 场景定制,
455
741
  * 搬到 search 连续撞三个 P2(隐藏过滤 / 覆盖 highlight 排序 / 丢失匹配证据)。search 的语义
456
742
  * 不同——结果是"为什么这条命中",必须 query-aware;故第一版自实现两档:
457
- * - 高相关(score ≥ 0.85)→ 全文
743
+ * - 高相关(score ≥ fullTextScoreThreshold():legacy 0.85,bounded 流行度下 0.80)→ 全文
458
744
  * - 其余 → query-aware snippet(pickBestSnippet,显示匹配证据)
459
745
  * 按 handler 传入顺序(normal=score / highlight=contextual)依次填入 token 预算,超预算时
460
746
  * 全文降级 snippet、snippet 仍超则停止(剩余计入 omitted)。完整三档保真度阶梯(每条记忆预存
@@ -469,21 +755,27 @@ export function formatCollapsedResults(
469
755
  const rendered: string[] = [];
470
756
  let tokensUsed = 0;
471
757
  let shown = 0;
758
+ let anyOrigShown = false;
759
+ const fullScore = fullTextScoreThreshold();
472
760
 
473
761
  // 保留传入顺序(不重排):handler 已按 normal=score / highlight=contextual 排好。
474
762
  for (const r of results) {
475
- const isFull = r.score >= ADAPTIVE_FULL_SCORE;
763
+ const isFull = r.score >= fullScore;
476
764
  let level = isFull ? "FULL" : "SNIP";
477
765
  // query-aware:snippet 取匹配词周围窗口、显示匹配证据(search 是"为什么命中")。
478
766
  let text = isFull ? r.entry.text : adaptiveSnippet(context.query, r.entry.text);
479
- let tokens = estimateTokens(text);
767
+ // 批量提炼行附原文。它同样占预算,和正文一起算,别让原文把预算悄悄撑破。
768
+ const orig = getDistilledSource(r);
769
+ const origLine = orig ? `orig: ${formatDistilledOrigin(orig)}` : "";
770
+ const origTokens = origLine ? estimateTokens(origLine) : 0;
771
+ let tokens = estimateTokens(text) + origTokens;
480
772
 
481
773
  if (tokensUsed + tokens > ADAPTIVE_TOKEN_BUDGET) {
482
774
  if (isFull) {
483
775
  // 全文超预算 → 降级为 query-aware snippet
484
776
  text = adaptiveSnippet(context.query, r.entry.text);
485
777
  level = "SNIP";
486
- tokens = estimateTokens(text);
778
+ tokens = estimateTokens(text) + origTokens;
487
779
  }
488
780
  if (tokensUsed + tokens > ADAPTIVE_TOKEN_BUDGET) break; // snippet 仍超 → 停止,剩余计入 omitted
489
781
  }
@@ -495,6 +787,10 @@ export function formatCollapsedResults(
495
787
  const scorePct = `${(r.score * 100).toFixed(0)}%`;
496
788
  rendered.push(`[${level}] ${id} ${scorePct} ${getCategoryLabel(r)} ${getSourceLabel(r)}`);
497
789
  rendered.push(text);
790
+ if (origLine) {
791
+ rendered.push(origLine);
792
+ anyOrigShown = true;
793
+ }
498
794
  rendered.push("");
499
795
  }
500
796
 
@@ -502,8 +798,9 @@ export function formatCollapsedResults(
502
798
  const lines = [
503
799
  `Query : ${context.query}`,
504
800
  `Profile : ${context.profile}`,
505
- `Mode : adaptive — full text for score ≥ ${ADAPTIVE_FULL_SCORE}, query-aware snippet otherwise, ${ADAPTIVE_TOKEN_BUDGET}-token budget`,
801
+ `Mode : adaptive — full text for score ≥ ${fullScore}, query-aware snippet otherwise, ${ADAPTIVE_TOKEN_BUDGET}-token budget`,
506
802
  `Shown : ${shown} of ${results.length}${omitted > 0 ? ` (${omitted} omitted: token budget)` : ""}`,
803
+ ...(anyOrigShown ? [`Note : ${DISTILLED_NOTE}`] : []),
507
804
  "",
508
805
  ...rendered,
509
806
  ];