recallnest 2.3.0 → 2.6.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 (258) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +49 -38
  3. package/README_CN.md +46 -31
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +25 -4
  15. package/src/__tests__/access-tracker-novelty.test.ts +304 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/api-server-security.test.ts +102 -0
  21. package/src/__tests__/auto-consolidation.test.ts +168 -0
  22. package/src/__tests__/auto-recall.test.ts +131 -0
  23. package/src/__tests__/babel-integration.test.ts +144 -0
  24. package/src/__tests__/batch-dedup.test.ts +65 -0
  25. package/src/__tests__/belief-history.test.ts +220 -0
  26. package/src/__tests__/canary-eval.test.ts +114 -0
  27. package/src/__tests__/capture-engine.test.ts +1328 -0
  28. package/src/__tests__/capture-heuristic.test.ts +171 -0
  29. package/src/__tests__/cascade-forget.test.ts +210 -0
  30. package/src/__tests__/category-min-scores.test.ts +116 -0
  31. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  32. package/src/__tests__/circuit-breaker.test.ts +99 -0
  33. package/src/__tests__/cluster-consolidation.test.ts +945 -0
  34. package/src/__tests__/confidence-metadata.test.ts +125 -0
  35. package/src/__tests__/confidence-tracker.test.ts +261 -0
  36. package/src/__tests__/conflict-advisor.test.ts +206 -0
  37. package/src/__tests__/conflict-escalation.test.ts +149 -0
  38. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  39. package/src/__tests__/conflict-output.test.ts +85 -0
  40. package/src/__tests__/conflict-store.test.ts +418 -0
  41. package/src/__tests__/connector-v1.test.ts +181 -0
  42. package/src/__tests__/consolidation-engine.test.ts +430 -0
  43. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  44. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  45. package/src/__tests__/context-composer-pins.test.ts +64 -0
  46. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  47. package/src/__tests__/context-reconstructor.test.ts +65 -0
  48. package/src/__tests__/context-renderer.test.ts +91 -0
  49. package/src/__tests__/continuity-eval.test.ts +275 -0
  50. package/src/__tests__/continuity-schema.test.ts +97 -0
  51. package/src/__tests__/contracts.test.ts +262 -0
  52. package/src/__tests__/conversation-importer.test.ts +692 -0
  53. package/src/__tests__/core-recall-eval.test.ts +235 -0
  54. package/src/__tests__/core-summary.test.ts +109 -0
  55. package/src/__tests__/data-checkup.test.ts +276 -0
  56. package/src/__tests__/decay-exemption.test.ts +129 -0
  57. package/src/__tests__/dedup-actions.test.ts +58 -0
  58. package/src/__tests__/dedup-guard.test.ts +39 -0
  59. package/src/__tests__/deterministic-id.test.ts +39 -0
  60. package/src/__tests__/distill-lock.test.ts +305 -0
  61. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  62. package/src/__tests__/doctor.test.ts +319 -0
  63. package/src/__tests__/dream-pipeline.test.ts +780 -0
  64. package/src/__tests__/drill-down.test.ts +108 -0
  65. package/src/__tests__/embedder.test.ts +122 -0
  66. package/src/__tests__/emotion-decay.test.ts +46 -0
  67. package/src/__tests__/emotion-detector.test.ts +56 -0
  68. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  69. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  70. package/src/__tests__/entity-resolver.test.ts +58 -0
  71. package/src/__tests__/env-config.test.ts +151 -0
  72. package/src/__tests__/error-signature-boost.test.ts +87 -0
  73. package/src/__tests__/error-signature.test.ts +95 -0
  74. package/src/__tests__/error-taxonomy.test.ts +129 -0
  75. package/src/__tests__/ethics-layer.test.ts +476 -0
  76. package/src/__tests__/event-segmenter.test.ts +153 -0
  77. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  78. package/src/__tests__/frequency-tracker.test.ts +113 -0
  79. package/src/__tests__/freshness.test.ts +186 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +283 -0
  82. package/src/__tests__/ingest-chunking.test.ts +44 -0
  83. package/src/__tests__/ingest.test.ts +360 -0
  84. package/src/__tests__/interference-detector.test.ts +203 -0
  85. package/src/__tests__/kg-extractor.test.ts +192 -0
  86. package/src/__tests__/kg-store.test.ts +300 -0
  87. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  88. package/src/__tests__/llm-consolidation.test.ts +226 -0
  89. package/src/__tests__/maintenance-locks.test.ts +189 -0
  90. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  91. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  92. package/src/__tests__/memory-boundaries.test.ts +282 -0
  93. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  94. package/src/__tests__/memory-evolution.test.ts +271 -0
  95. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  96. package/src/__tests__/memory-lint.test.ts +421 -0
  97. package/src/__tests__/memory-output.test.ts +215 -0
  98. package/src/__tests__/memory-promotion.test.ts +315 -0
  99. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  100. package/src/__tests__/multi-vector.test.ts +242 -0
  101. package/src/__tests__/narrative-architecture.test.ts +456 -0
  102. package/src/__tests__/noise-filter.test.ts +205 -0
  103. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  104. package/src/__tests__/obsidian-connector.test.ts +363 -0
  105. package/src/__tests__/omitted-reporting.test.ts +98 -0
  106. package/src/__tests__/pending-review.test.ts +77 -0
  107. package/src/__tests__/pii-redact.test.ts +333 -0
  108. package/src/__tests__/pivot-apply.test.ts +373 -0
  109. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  110. package/src/__tests__/pivot-distill.test.ts +1178 -0
  111. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  112. package/src/__tests__/ppr-traversal.test.ts +256 -0
  113. package/src/__tests__/prediction-engine.test.ts +349 -0
  114. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  115. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  116. package/src/__tests__/preference-matcher.test.ts +282 -0
  117. package/src/__tests__/preference-slots.test.ts +205 -0
  118. package/src/__tests__/prospective-memory.test.ts +283 -0
  119. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  120. package/src/__tests__/query-entity-detector.test.ts +171 -0
  121. package/src/__tests__/query-expander-alias.test.ts +146 -0
  122. package/src/__tests__/query-expander.test.ts +34 -0
  123. package/src/__tests__/recall-gate.test.ts +266 -0
  124. package/src/__tests__/recall-governor.test.ts +227 -0
  125. package/src/__tests__/recall-mode.test.ts +64 -0
  126. package/src/__tests__/resolve-db-path.test.ts +130 -0
  127. package/src/__tests__/resolve-tier.test.ts +64 -0
  128. package/src/__tests__/result-synthesizer.test.ts +198 -0
  129. package/src/__tests__/resume-context.test.ts +4898 -0
  130. package/src/__tests__/retrieval-eval.test.ts +163 -0
  131. package/src/__tests__/retrieval-trace.test.ts +213 -0
  132. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  133. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  134. package/src/__tests__/retroactive-boost.test.ts +223 -0
  135. package/src/__tests__/salience-scoring.test.ts +106 -0
  136. package/src/__tests__/scope-inventory.test.ts +200 -0
  137. package/src/__tests__/scope-policy.test.ts +69 -0
  138. package/src/__tests__/scope-suggester.test.ts +80 -0
  139. package/src/__tests__/session-checkpoint.test.ts +291 -0
  140. package/src/__tests__/session-distiller.test.ts +640 -0
  141. package/src/__tests__/session-gc.test.ts +215 -0
  142. package/src/__tests__/skill-engine.test.ts +552 -0
  143. package/src/__tests__/skill-promotion.test.ts +654 -0
  144. package/src/__tests__/skill-verifier.test.ts +122 -0
  145. package/src/__tests__/source-heartbeat.test.ts +250 -0
  146. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  147. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  148. package/src/__tests__/store-read-consistency.test.ts +122 -0
  149. package/src/__tests__/store-schema-migration.test.ts +87 -0
  150. package/src/__tests__/store-scope-audit.test.ts +81 -0
  151. package/src/__tests__/store-upsert.test.ts +201 -0
  152. package/src/__tests__/supersede-chain.test.ts +154 -0
  153. package/src/__tests__/temporal-parser.test.ts +241 -0
  154. package/src/__tests__/temporal-validity.test.ts +160 -0
  155. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  156. package/src/__tests__/term-registry.test.ts +121 -0
  157. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  158. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  159. package/src/__tests__/topic-tag.test.ts +113 -0
  160. package/src/__tests__/trusted-memory.test.ts +839 -0
  161. package/src/__tests__/ui-server-security.test.ts +93 -0
  162. package/src/__tests__/usage-tracker.test.ts +287 -0
  163. package/src/__tests__/version-manager.test.ts +213 -0
  164. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  165. package/src/__tests__/workflow-observation.test.ts +433 -0
  166. package/src/__tests__/write-verifier.test.ts +122 -0
  167. package/src/access-tracker.ts +90 -34
  168. package/src/activity-counter.ts +46 -18
  169. package/src/adaptive-retrieval.ts +11 -0
  170. package/src/aliases.ts +133 -0
  171. package/src/api-server.ts +36 -9
  172. package/src/audit-log.ts +4 -2
  173. package/src/auto-consolidation.ts +8 -3
  174. package/src/auto-gc.ts +85 -21
  175. package/src/auto-recall.ts +61 -1
  176. package/src/belief-history.ts +164 -0
  177. package/src/capture-engine.ts +142 -32
  178. package/src/cli.ts +268 -41
  179. package/src/confidence-tracker.ts +36 -18
  180. package/src/conflict-engine.ts +24 -3
  181. package/src/consolidation-engine.ts +255 -23
  182. package/src/context-composer-stable-selection.ts +7 -1
  183. package/src/context-composer.ts +8 -1
  184. package/src/data-checkup.ts +96 -7
  185. package/src/decay-engine.ts +59 -7
  186. package/src/distill-lock.ts +194 -15
  187. package/src/doctor.ts +13 -4
  188. package/src/dream-pipeline.ts +473 -41
  189. package/src/env-config.ts +118 -0
  190. package/src/error-signature.ts +153 -0
  191. package/src/error-taxonomy.ts +115 -0
  192. package/src/eval.ts +402 -14
  193. package/src/freshness.ts +192 -0
  194. package/src/http-request-policy.ts +148 -0
  195. package/src/idempotency.ts +8 -0
  196. package/src/ingest.ts +308 -12
  197. package/src/kg-extractor.ts +13 -3
  198. package/src/kg-store.ts +236 -44
  199. package/src/llm-client.ts +106 -11
  200. package/src/llm-consolidation.ts +2 -1
  201. package/src/mcp-server.ts +87 -1800
  202. package/src/mcp-tool-deps.ts +48 -0
  203. package/src/mcp-tools-advanced.ts +954 -0
  204. package/src/mcp-tools-core.ts +637 -0
  205. package/src/mcp-tools-governance.ts +475 -0
  206. package/src/memory-boundaries.ts +26 -3
  207. package/src/memory-evolution.ts +73 -1
  208. package/src/memory-health-rebalance.ts +4 -0
  209. package/src/memory-lint.ts +183 -10
  210. package/src/memory-output.ts +129 -4
  211. package/src/memory-promotion.ts +246 -0
  212. package/src/memory-schema.ts +25 -2
  213. package/src/memory-store-port.ts +46 -0
  214. package/src/multi-vector.ts +2 -1
  215. package/src/narrative-schema.ts +2 -1
  216. package/src/noise-filter.ts +57 -6
  217. package/src/noise-prototype-bank.ts +408 -0
  218. package/src/pii-detector.ts +207 -5
  219. package/src/pivot-apply.ts +399 -0
  220. package/src/preference-matcher.ts +37 -3
  221. package/src/query-expander.ts +90 -9
  222. package/src/recall-gate.ts +305 -0
  223. package/src/result-synthesizer.ts +2 -1
  224. package/src/retention-policy.ts +2 -1
  225. package/src/retrieval-profiles.ts +46 -4
  226. package/src/retriever.ts +354 -157
  227. package/src/retroactive-boost.ts +6 -2
  228. package/src/runtime-config.ts +78 -6
  229. package/src/scope-suggester.ts +113 -0
  230. package/src/session-distiller.ts +23 -2
  231. package/src/session-engine.ts +2 -1
  232. package/src/session-schema.ts +1 -0
  233. package/src/session-store.ts +5 -0
  234. package/src/skill-engine.ts +78 -0
  235. package/src/skill-promotion.ts +304 -48
  236. package/src/skill-schema.ts +16 -3
  237. package/src/skill-verifier.ts +152 -0
  238. package/src/store.ts +321 -57
  239. package/src/term-registry.ts +35 -0
  240. package/src/tool-output-compressor.ts +3 -1
  241. package/src/ui-server.ts +9 -1
  242. package/src/usage-tracker.ts +242 -0
  243. package/src/version-manager.ts +3 -2
  244. package/src/version.ts +19 -0
  245. package/src/workflow-observation-engine.ts +2 -1
  246. package/src/workflow-observation-schema.ts +2 -1
  247. package/src/workflow-observation-store.ts +6 -2
  248. package/.env +0 -4
  249. package/CLAUDE.md +0 -46
  250. package/config.json +0 -55
  251. package/daily-recall.sh +0 -110
  252. package/logs/daily-recall.error.log +0 -0
  253. package/logs/daily-recall.log +0 -60
  254. package/logs/ingest-2026-04-11.log +0 -110
  255. package/logs/ingest-2026-04-12.log +0 -90
  256. package/logs/ingest-2026-04-13.log +0 -87
  257. package/logs/launchagent-stderr.log +0 -0
  258. package/logs/launchagent-stdout.log +0 -0
package/src/eval.ts CHANGED
@@ -8,6 +8,8 @@ import { buildSessionCheckpointRecord, normalizeCheckpointScope } from "./sessio
8
8
  import type { ResumeContextResponse, SessionCheckpointRecord } from "./session-schema.js";
9
9
  import { composeResumeContext } from "./context-composer.js";
10
10
  import { cleanText } from "./context-composer-text.js";
11
+ import { estimateTokens } from "./context-collapse-renderer.js";
12
+ import { TraceCollector } from "./retrieval-trace.js";
11
13
  import { createComponents, loadConfig, loadDotEnv, type LocalMemoryConfig } from "./runtime-config.js";
12
14
  import { logInfo } from "./stderr-log.js";
13
15
  import { buildWorkflowObservationRecord } from "./workflow-observation-engine.js";
@@ -15,7 +17,7 @@ import type { WorkflowObservationInput } from "./workflow-observation-schema.js"
15
17
  import { WorkflowObservationStore } from "./workflow-observation-store.js";
16
18
 
17
19
  export type ProfileName = "default" | "writing" | "debug" | "fact-check";
18
- export type EvalMode = "retrieval" | "continuity";
20
+ export type EvalMode = "retrieval" | "continuity" | "canary";
19
21
 
20
22
  export interface RetrievalEvalCase {
21
23
  name: string;
@@ -59,6 +61,73 @@ export interface ContinuityEvalCase {
59
61
  notes?: string;
60
62
  }
61
63
 
64
+ export interface CanaryEvalCase {
65
+ name: string;
66
+ query: string;
67
+ profile?: ProfileName;
68
+ scope?: string;
69
+ limit?: number;
70
+ /** A 类:该召回的 memory id(按 target id 算 rank) */
71
+ targets?: string[];
72
+ /** C 类:不该进 TopK 的干扰项 memory id */
73
+ hardNegatives?: string[];
74
+ /** 目标应进前几(默认 3) */
75
+ expectTopK?: number;
76
+ /** B 类新旧序:按此顺序排列,newer 在前,前者 rank 应小于后者 */
77
+ expectOrder?: string[];
78
+ /** D 类稳定偏好:召回文本命中任一要点即算对(不钉 id) */
79
+ expectContentAny?: string[];
80
+ /** 文本层禁词(沿用 retrieval 语义) */
81
+ forbid?: string[];
82
+ notes?: string;
83
+ }
84
+
85
+ /**
86
+ * Per-case cost accounting for the retrieval pipeline — the X axis of the
87
+ * Pareto curve (see hippo-wiki [[帕累托效率曲线]]).
88
+ *
89
+ * Scope: self-comparison across runs (did a retrieval change make it slower or
90
+ * fatter?). NOT for cross-system comparison — `contextTokens` comes from an
91
+ * estimator, not a real tokenizer, and this suite is not LoCoMo, so the numbers
92
+ * are not commensurable with competitors' published token counts.
93
+ */
94
+ export interface EvalCostMetrics {
95
+ /** End-to-end retrieve() wall time. */
96
+ totalMs: number;
97
+ /** Number of memories returned. */
98
+ resultCount: number;
99
+ /** Estimated tokens of returned memory text — the context a caller would spend. */
100
+ contextTokens: number;
101
+ /** Duration of the rerank stage, or null when the pipeline ran no rerank.
102
+ * Left as a raw fact rather than an inferred "api calls" count: a local
103
+ * lightweight rerank and a remote cross-encoder both produce a stage here. */
104
+ rerankMs: number | null;
105
+ /** Three slowest stages, for locating the bottleneck. */
106
+ slowestStages: Array<{ name: string; durationMs: number }>;
107
+ }
108
+
109
+ export interface CanaryCaseReport {
110
+ mode: "canary";
111
+ name: string;
112
+ query: string;
113
+ profile: ProfileName;
114
+ score: number;
115
+ passed: boolean;
116
+ hitCount: number;
117
+ targetRanks: Array<{ id: string; rank: number | null }>;
118
+ top1Hit: boolean;
119
+ top3Hit: boolean;
120
+ forbiddenIdMatches: string[];
121
+ orderOk: boolean | null;
122
+ matchedContentAny: string[];
123
+ forbiddenMatches: string[];
124
+ topIds: string[];
125
+ topSnippet: string;
126
+ notes?: string;
127
+ /** Attached by the runner after scoring; absent when scored standalone. */
128
+ cost?: EvalCostMetrics;
129
+ }
130
+
62
131
  export interface RetrievalCaseReport {
63
132
  mode: "retrieval";
64
133
  name: string;
@@ -74,6 +143,8 @@ export interface RetrievalCaseReport {
74
143
  topScopes: string[];
75
144
  topSnippet: string;
76
145
  notes?: string;
146
+ /** Attached by the runner after scoring; absent when scored standalone. */
147
+ cost?: EvalCostMetrics;
77
148
  }
78
149
 
79
150
  export interface ContinuityCaseReport {
@@ -100,7 +171,7 @@ export interface ContinuityCaseReport {
100
171
  notes?: string;
101
172
  }
102
173
 
103
- type EvalReport = RetrievalCaseReport | ContinuityCaseReport;
174
+ type EvalReport = RetrievalCaseReport | ContinuityCaseReport | CanaryCaseReport;
104
175
 
105
176
  interface EvalArgs {
106
177
  mode: EvalMode;
@@ -144,7 +215,7 @@ function parseArgs(args: string[]): EvalArgs {
144
215
  const observationScopeIdx = args.indexOf("--observation-scope");
145
216
  const observationSourceIdx = args.indexOf("--observation-source");
146
217
  const modeRaw = modeIdx >= 0 ? args[modeIdx + 1] : "retrieval";
147
- const mode = modeRaw === "continuity" ? "continuity" : "retrieval";
218
+ const mode: EvalMode = modeRaw === "continuity" ? "continuity" : modeRaw === "canary" ? "canary" : "retrieval";
148
219
 
149
220
  return {
150
221
  mode,
@@ -158,9 +229,9 @@ function parseArgs(args: string[]): EvalArgs {
158
229
  }
159
230
 
160
231
  function defaultCasesPath(mode: EvalMode): string {
161
- return mode === "continuity"
162
- ? resolve(metaDir(import.meta), "../eval/continuity/cases.json")
163
- : resolve(metaDir(import.meta), "../eval/cases.json");
232
+ if (mode === "continuity") return resolve(metaDir(import.meta), "../eval/continuity/cases.json");
233
+ if (mode === "canary") return resolve(metaDir(import.meta), "../eval/cases-canary.json");
234
+ return resolve(metaDir(import.meta), "../eval/cases.json");
164
235
  }
165
236
 
166
237
  function loadCases<T>(mode: EvalMode, pathArg?: string): T[] {
@@ -176,8 +247,16 @@ function matchedTerms(terms: string[] | undefined, haystack: string): string[] {
176
247
  return (terms || []).filter((term) => haystack.includes(term.toLowerCase()));
177
248
  }
178
249
 
250
+ /**
251
+ * 命中比例 × 权重。
252
+ *
253
+ * 调用方必须先滤掉 case 没声明的期望、再对剩下的权重重新归一化——下面三个
254
+ * 评分器都是这么做的。空期望这里返回 0 而不是 weight:万一将来有第四个调用
255
+ * 点忘了滤,代价是少给分而不是白送分。三个现有调用点都已滤过,所以这个分支
256
+ * 当前走不到,纯属兜底。
257
+ */
179
258
  function scoreExpectation(expected: string[] | undefined, matched: string[], weight: number): number {
180
- if (!expected || expected.length === 0) return weight;
259
+ if (!expected || expected.length === 0) return 0;
181
260
  return (matched.length / expected.length) * weight;
182
261
  }
183
262
 
@@ -195,10 +274,23 @@ export function scoreRetrievalCase(
195
274
  const matchedScopes = (evalCase.expectScopePrefixes || []).filter((scope) => scopes.some((item) => item.startsWith(scope)));
196
275
  const forbiddenMatches = matchedTerms(evalCase.forbid, joined);
197
276
 
198
- let score = 0;
199
- score += scoreExpectation(evalCase.expectAny, matchedAny, 0.4);
200
- score += scoreExpectation(evalCase.expectAll, matchedAll, 0.3);
201
- score += scoreExpectation(evalCase.expectScopePrefixes, matchedScopes, 0.2);
277
+ // 动态加权:只计 case 实际声明的维度,权重在这些维度间重新归一化——
278
+ // 和下面 canary、continuity 两个评分器已有的写法一致。
279
+ // 改之前未声明的维度按满权重计入,而 eval/cases.json 里 20 个 case 一个都没写
280
+ // expectAll,于是每个 case 白拿 0.3:expectAny 四个词命中一个再加 scope 前缀命中,
281
+ // 正好落在 0.7 及格线上。
282
+ const expectationScores = [
283
+ { expected: evalCase.expectAny, matched: matchedAny, weight: 0.4 },
284
+ { expected: evalCase.expectAll, matched: matchedAll, weight: 0.3 },
285
+ { expected: evalCase.expectScopePrefixes, matched: matchedScopes, weight: 0.2 },
286
+ ].filter((item) => (item.expected || []).length > 0);
287
+
288
+ const totalExpectedWeight = expectationScores.reduce((sum, item) => sum + item.weight, 0);
289
+ const normalizedExpectationScore = totalExpectedWeight > 0
290
+ ? expectationScores.reduce((sum, item) => sum + scoreExpectation(item.expected, item.matched, item.weight), 0) / totalExpectedWeight
291
+ : 0;
292
+
293
+ let score = normalizedExpectationScore * 0.9;
202
294
  if (results.length > 0) score += 0.1;
203
295
  if (forbiddenMatches.length > 0) score -= 0.3;
204
296
  score = Math.max(0, Math.min(1, score));
@@ -221,6 +313,109 @@ export function scoreRetrievalCase(
221
313
  };
222
314
  }
223
315
 
316
+ export function scoreCanaryCase(
317
+ evalCase: CanaryEvalCase,
318
+ results: Array<{ entry: { id: string; text: string; scope: string; metadata?: string } }>,
319
+ ): CanaryCaseReport {
320
+ const profile = evalCase.profile || "default";
321
+ const ids = results.map((r) => r.entry.id);
322
+ const topK = evalCase.expectTopK ?? 3;
323
+ const limit = evalCase.limit ?? results.length;
324
+ const joined = results
325
+ .map((r) => `${r.entry.scope}\n${r.entry.text}\n${r.entry.metadata || ""}`)
326
+ .join("\n")
327
+ .toLowerCase();
328
+
329
+ // memory id 用前缀匹配:targets/expectOrder/hardNegatives 常写 8 位短前缀,
330
+ // 而召回结果的 entry.id 是完整 UUID(recallnest 惯例:≥8 hex 前缀即可定位)。
331
+ const idMatches = (fullId: string, target: string): boolean =>
332
+ fullId === target || fullId.startsWith(target);
333
+ const rankOf = (target: string): number | null => {
334
+ const idx = ids.findIndex((fullId) => idMatches(fullId, target));
335
+ return idx === -1 ? null : idx + 1;
336
+ };
337
+
338
+ // A 类:单目标命中
339
+ const targets = evalCase.targets || [];
340
+ const targetRanks = targets.map((id) => ({ id, rank: rankOf(id) }));
341
+ const top1Hit = targetRanks.some((t) => t.rank === 1);
342
+ const top3Hit = targetRanks.some((t) => t.rank !== null && t.rank <= Math.min(3, topK));
343
+
344
+ // C 类:干扰项进 TopK(limit 内)即违规(同样前缀匹配)
345
+ const forbiddenIdMatches = (evalCase.hardNegatives || []).filter((neg) => {
346
+ const rank = rankOf(neg);
347
+ return rank !== null && rank <= limit;
348
+ });
349
+
350
+ // B 类:新旧序,前者须召回且 rank 小于后者
351
+ let orderOk: boolean | null = null;
352
+ const order = evalCase.expectOrder || [];
353
+ if (order.length >= 2) {
354
+ orderOk = true;
355
+ for (let i = 0; i < order.length - 1; i += 1) {
356
+ const a = rankOf(order[i]);
357
+ const b = rankOf(order[i + 1]);
358
+ if (a === null) {
359
+ orderOk = false;
360
+ break;
361
+ }
362
+ if (b !== null && a >= b) {
363
+ orderOk = false;
364
+ break;
365
+ }
366
+ }
367
+ }
368
+
369
+ // D 类:内容要点命中
370
+ const matchedContentAny = matchedTerms(evalCase.expectContentAny, joined);
371
+
372
+ // 文本禁词
373
+ const forbiddenMatches = matchedTerms(evalCase.forbid, joined);
374
+
375
+ // 动态加权:只计 case 实际声明的维度
376
+ const parts: Array<{ weight: number; value: number }> = [];
377
+ if (targets.length > 0) {
378
+ const ranks = targetRanks.map((t) => t.rank).filter((r): r is number => r !== null);
379
+ const bestRank = ranks.length > 0 ? Math.min(...ranks) : null;
380
+ const targetScore = bestRank === null ? 0 : bestRank === 1 ? 1 : bestRank <= 3 ? 0.7 : 0.4;
381
+ parts.push({ weight: 0.5, value: targetScore });
382
+ }
383
+ if (orderOk !== null) {
384
+ parts.push({ weight: 0.2, value: orderOk ? 1 : 0 });
385
+ }
386
+ const contentAny = evalCase.expectContentAny || [];
387
+ if (contentAny.length > 0) {
388
+ parts.push({ weight: 0.3, value: matchedContentAny.length / contentAny.length });
389
+ }
390
+ const totalWeight = parts.reduce((sum, p) => sum + p.weight, 0);
391
+ let score = totalWeight > 0
392
+ ? parts.reduce((sum, p) => sum + p.weight * p.value, 0) / totalWeight
393
+ : (results.length > 0 ? 0.5 : 0);
394
+ if (forbiddenIdMatches.length > 0) score -= 0.3;
395
+ if (forbiddenMatches.length > 0) score -= 0.3;
396
+ score = Math.max(0, Math.min(1, score));
397
+
398
+ return {
399
+ mode: "canary",
400
+ name: evalCase.name,
401
+ query: evalCase.query,
402
+ profile,
403
+ score,
404
+ passed: score >= 0.7 && forbiddenIdMatches.length === 0 && forbiddenMatches.length === 0,
405
+ hitCount: results.length,
406
+ targetRanks,
407
+ top1Hit,
408
+ top3Hit,
409
+ forbiddenIdMatches,
410
+ orderOk,
411
+ matchedContentAny,
412
+ forbiddenMatches,
413
+ topIds: ids.slice(0, 5),
414
+ topSnippet: results[0] ? clip(results[0].entry.text) : "-",
415
+ notes: evalCase.notes,
416
+ };
417
+ }
418
+
224
419
  function joinResumeSections(response: ResumeContextResponse): string {
225
420
  return [
226
421
  response.summary,
@@ -408,11 +603,12 @@ function markdownRetrievalReport(reports: RetrievalCaseReport[]): string {
408
603
  `- Cases: ${reports.length}`,
409
604
  `- Passed: ${passed}/${reports.length}`,
410
605
  `- Average score: ${formatPercent(average)}`,
606
+ ...costSummaryLines(summarizeCost(reports), reports.length),
411
607
  "",
412
- "| Case | Profile | Score | Pass | Hits | Top scopes |",
413
- "|------|---------|-------|------|------|------------|",
608
+ "| Case | Profile | Score | Pass | Hits | ms | ~tok | Top scopes |",
609
+ "|------|---------|-------|------|------|----|------|------------|",
414
610
  ...reports.map((item) =>
415
- `| ${item.name} | ${item.profile} | ${(item.score * 100).toFixed(0)}% | ${item.passed ? "yes" : "no"} | ${item.hitCount} | ${item.topScopes.join(", ") || "-"} |`,
611
+ `| ${item.name} | ${item.profile} | ${(item.score * 100).toFixed(0)}% | ${item.passed ? "yes" : "no"} | ${item.hitCount} | ${item.cost?.totalMs ?? "-"} | ${item.cost?.contextTokens ?? "-"} | ${item.topScopes.join(", ") || "-"} |`,
416
612
  ),
417
613
  "",
418
614
  "## Case Notes",
@@ -431,6 +627,53 @@ function markdownRetrievalReport(reports: RetrievalCaseReport[]): string {
431
627
  lines.push(`- Matched all: ${item.matchedAll.join(", ") || "-"}`);
432
628
  lines.push(`- Matched scopes: ${item.matchedScopes.join(", ") || "-"}`);
433
629
  lines.push(`- Forbidden matches: ${item.forbiddenMatches.join(", ") || "-"}`);
630
+ if (item.cost) lines.push(`- Cost: ${formatCost(item.cost)}`);
631
+ if (item.notes) lines.push(`- Notes: ${item.notes}`);
632
+ lines.push("");
633
+ }
634
+
635
+ return lines.join("\n");
636
+ }
637
+
638
+ function markdownCanaryReport(reports: CanaryCaseReport[]): string {
639
+ const { passed, average } = summarizeReports(reports);
640
+ const top1 = reports.filter((r) => r.top1Hit).length;
641
+ const top3 = reports.filter((r) => r.top3Hit).length;
642
+
643
+ const lines = [
644
+ "# RecallNest Canary Eval",
645
+ "",
646
+ `- Generated: ${new Date().toISOString()}`,
647
+ `- Cases: ${reports.length}`,
648
+ `- Passed: ${passed}/${reports.length}`,
649
+ `- Average score: ${formatPercent(average)}`,
650
+ `- Top1 hit: ${top1}/${reports.length}`,
651
+ `- Top3 hit: ${top3}/${reports.length}`,
652
+ ...costSummaryLines(summarizeCost(reports), reports.length),
653
+ "",
654
+ "| Case | Profile | Score | Pass | Top1 | Top3 | Order | Forbid | ms | ~tok |",
655
+ "|------|---------|-------|------|------|------|-------|--------|----|------|",
656
+ ...reports.map((item) =>
657
+ `| ${item.name} | ${item.profile} | ${(item.score * 100).toFixed(0)}% | ${item.passed ? "yes" : "no"} | ${item.top1Hit ? "yes" : "-"} | ${item.top3Hit ? "yes" : "-"} | ${item.orderOk === null ? "-" : item.orderOk ? "ok" : "bad"} | ${item.forbiddenIdMatches.length + item.forbiddenMatches.length || "-"} | ${item.cost?.totalMs ?? "-"} | ${item.cost?.contextTokens ?? "-"} |`,
658
+ ),
659
+ "",
660
+ "## Case Notes",
661
+ "",
662
+ ];
663
+
664
+ for (const item of reports) {
665
+ lines.push(`### ${item.name}`);
666
+ lines.push(`- Query: ${item.query}`);
667
+ lines.push(`- Score: ${(item.score * 100).toFixed(0)}% (${item.passed ? "pass" : "fail"})`);
668
+ lines.push(`- Target ranks: ${item.targetRanks.map((t) => `${t.id}=${t.rank ?? "miss"}`).join(", ") || "-"}`);
669
+ lines.push(`- Top1 / Top3: ${item.top1Hit ? "yes" : "no"} / ${item.top3Hit ? "yes" : "no"}`);
670
+ lines.push(`- Order ok: ${item.orderOk === null ? "n/a" : item.orderOk}`);
671
+ lines.push(`- Matched content: ${item.matchedContentAny.join(", ") || "-"}`);
672
+ lines.push(`- Forbidden id matches: ${item.forbiddenIdMatches.join(", ") || "-"}`);
673
+ lines.push(`- Forbidden term matches: ${item.forbiddenMatches.join(", ") || "-"}`);
674
+ lines.push(`- Top ids: ${item.topIds.join(", ") || "-"}`);
675
+ lines.push(`- Top snippet: ${item.topSnippet}`);
676
+ if (item.cost) lines.push(`- Cost: ${formatCost(item.cost)}`);
434
677
  if (item.notes) lines.push(`- Notes: ${item.notes}`);
435
678
  lines.push("");
436
679
  }
@@ -485,6 +728,88 @@ function markdownContinuityReport(reports: ContinuityCaseReport[]): string {
485
728
  return lines.join("\n");
486
729
  }
487
730
 
731
+ /**
732
+ * Turn a finished TraceCollector plus the returned memories into cost metrics.
733
+ *
734
+ * The `mode` argument to finalize() only populates a display field on the trace
735
+ * object; this function consumes stages / totalMs only, so it is not inferred.
736
+ */
737
+ function collectCostMetrics(
738
+ trace: TraceCollector,
739
+ query: string,
740
+ results: Array<{ entry: { text: string } }>,
741
+ ): EvalCostMetrics {
742
+ const finalized = trace.finalize(query, "hybrid");
743
+ const rerankStage = finalized.stages.find((stage) => stage.name === "rerank");
744
+ const slowestStages = [...finalized.stages]
745
+ .sort((a, b) => b.durationMs - a.durationMs)
746
+ .slice(0, 3)
747
+ .map((stage) => ({ name: stage.name, durationMs: stage.durationMs }));
748
+
749
+ return {
750
+ totalMs: finalized.totalMs,
751
+ resultCount: results.length,
752
+ contextTokens: results.reduce((sum, item) => sum + estimateTokens(item.entry.text), 0),
753
+ rerankMs: rerankStage ? rerankStage.durationMs : null,
754
+ slowestStages,
755
+ };
756
+ }
757
+
758
+ /** Aggregate cost across cases. Cases without cost data are skipped, and the
759
+ * count of those that did carry it is returned so the report can be honest
760
+ * about coverage rather than silently averaging over fewer cases. */
761
+ function summarizeCost(reports: Array<{ cost?: EvalCostMetrics }>): {
762
+ measured: number;
763
+ totalMs: number;
764
+ avgMs: number;
765
+ totalTokens: number;
766
+ avgTokens: number;
767
+ } | null {
768
+ const withCost = reports.map((item) => item.cost).filter((cost): cost is EvalCostMetrics => Boolean(cost));
769
+ if (withCost.length === 0) return null;
770
+
771
+ const totalMs = withCost.reduce((sum, cost) => sum + cost.totalMs, 0);
772
+ const totalTokens = withCost.reduce((sum, cost) => sum + cost.contextTokens, 0);
773
+ return {
774
+ measured: withCost.length,
775
+ totalMs,
776
+ avgMs: Math.round(totalMs / withCost.length),
777
+ totalTokens,
778
+ avgTokens: Math.round(totalTokens / withCost.length),
779
+ };
780
+ }
781
+
782
+ /** Aggregate cost lines for a report header. Returns [] when no case carried
783
+ * cost data, so reports produced before this instrumentation still render. */
784
+ function costSummaryLines(
785
+ summary: ReturnType<typeof summarizeCost>,
786
+ caseCount: number,
787
+ ): string[] {
788
+ if (!summary) return [];
789
+ const coverage = summary.measured === caseCount
790
+ ? ""
791
+ : ` (measured on ${summary.measured}/${caseCount} cases)`;
792
+ return [
793
+ `- Retrieval latency: ${summary.avgMs} ms avg, ${summary.totalMs} ms total${coverage}`,
794
+ `- Context tokens: ~${summary.avgTokens} avg, ~${summary.totalTokens} total`
795
+ + ` (estimated, not tokenizer-measured — compare across runs, not against other systems)`,
796
+ ];
797
+ }
798
+
799
+ /** One-line per-case cost breakdown. */
800
+ function formatCost(cost: EvalCostMetrics): string {
801
+ const parts = [
802
+ `${cost.totalMs}ms`,
803
+ `~${cost.contextTokens} tok`,
804
+ `${cost.resultCount} results`,
805
+ cost.rerankMs === null ? "no rerank stage" : `rerank ${cost.rerankMs}ms`,
806
+ ];
807
+ const slowest = cost.slowestStages
808
+ .map((stage) => `${stage.name} ${stage.durationMs}ms`)
809
+ .join(", ");
810
+ return `${parts.join(" / ")}${slowest ? ` | slowest: ${slowest}` : ""}`;
811
+ }
812
+
488
813
  export async function runRetrievalEval(
489
814
  cases: RetrievalEvalCase[],
490
815
  deps: {
@@ -502,13 +827,16 @@ export async function runRetrievalEval(
502
827
  const profileName = evalCase.profile || "default";
503
828
  const { retriever, accessTracker } = createEvalComponentsForCase(profileName);
504
829
  try {
830
+ const trace = new TraceCollector();
505
831
  const results = await retriever.retrieve({
506
832
  query: evalCase.query,
507
833
  limit: evalCase.limit || 5,
508
834
  scopeFilter: evalCase.scope ? [evalCase.scope] : undefined,
509
835
  source: "auto-recall",
836
+ trace,
510
837
  });
511
838
  const report = scoreRetrievalCase(evalCase, results);
839
+ report.cost = collectCostMetrics(trace, evalCase.query, results);
512
840
  reports.push(report);
513
841
  if (cases.length > 1) {
514
842
  logInfo(
@@ -523,6 +851,47 @@ export async function runRetrievalEval(
523
851
  return reports;
524
852
  }
525
853
 
854
+ export async function runCanaryEval(
855
+ cases: CanaryEvalCase[],
856
+ deps: {
857
+ createEvalComponents?: EvalComponentFactory;
858
+ } = {},
859
+ ): Promise<CanaryCaseReport[]> {
860
+ const config = deps.createEvalComponents ? null : loadConfig();
861
+ const createEvalComponentsForCase = deps.createEvalComponents || createFreshEvalComponentsFactory(config!);
862
+ const reports: CanaryCaseReport[] = [];
863
+
864
+ for (const [index, evalCase] of cases.entries()) {
865
+ if (cases.length > 1) {
866
+ logInfo(`[INFO] canary-eval ${index + 1}/${cases.length}: ${evalCase.name}`);
867
+ }
868
+ const profileName = evalCase.profile || "default";
869
+ const { retriever, accessTracker } = createEvalComponentsForCase(profileName);
870
+ try {
871
+ const trace = new TraceCollector();
872
+ const results = await retriever.retrieve({
873
+ query: evalCase.query,
874
+ limit: evalCase.limit || 8,
875
+ scopeFilter: evalCase.scope ? [evalCase.scope] : undefined,
876
+ source: "auto-recall",
877
+ trace,
878
+ });
879
+ const report = scoreCanaryCase(evalCase, results);
880
+ report.cost = collectCostMetrics(trace, evalCase.query, results);
881
+ reports.push(report);
882
+ if (cases.length > 1) {
883
+ logInfo(
884
+ `[INFO] canary-eval ${index + 1}/${cases.length} done: ${evalCase.name} ${formatPercent(report.score)} ${report.passed ? "pass" : "fail"}`,
885
+ );
886
+ }
887
+ } finally {
888
+ accessTracker.destroy();
889
+ }
890
+ }
891
+
892
+ return reports;
893
+ }
894
+
526
895
  export async function runContinuityEval(
527
896
  cases: ContinuityEvalCase[],
528
897
  options: { recordObservations?: boolean; observationScope?: string; observationSource?: string } = {},
@@ -610,6 +979,25 @@ async function main() {
610
979
  return;
611
980
  }
612
981
 
982
+ if (args.mode === "canary") {
983
+ const cases = loadCases<CanaryEvalCase>("canary", args.casesPath);
984
+ const reports = await runCanaryEval(cases);
985
+ if (args.jsonMode) {
986
+ const payload = JSON.stringify({
987
+ mode: "canary",
988
+ generatedAt: new Date().toISOString(),
989
+ reports,
990
+ }, null, 2);
991
+ writeOutput(args.outputPath, payload);
992
+ console.log(payload);
993
+ return;
994
+ }
995
+ const output = markdownCanaryReport(reports);
996
+ writeOutput(args.outputPath, output);
997
+ console.log(output);
998
+ return;
999
+ }
1000
+
613
1001
  const cases = loadCases<RetrievalEvalCase>("retrieval", args.casesPath);
614
1002
  const reports = await runRetrievalEval(cases);
615
1003
  if (args.jsonMode) {