recallnest 2.2.1 → 2.5.4

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 (227) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +46 -35
  3. package/README_CN.md +41 -26
  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/connectors/examples/email-adapter.ts +73 -0
  9. package/connectors/examples/logs-adapter.ts +103 -0
  10. package/connectors/examples/rss-adapter.ts +95 -0
  11. package/docs/api-reference.md +1291 -0
  12. package/docs/architecture.md +168 -0
  13. package/docs/connector-spec.md +126 -0
  14. package/eval/cases-canary.json +58 -0
  15. package/eval/cases-scope-robustness.json +278 -0
  16. package/eval/cases.json +117 -20
  17. package/package.json +22 -1
  18. package/src/__tests__/access-tracker-novelty.test.ts +238 -0
  19. package/src/__tests__/activity-counter.test.ts +125 -0
  20. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  21. package/src/__tests__/admission-control.test.ts +206 -0
  22. package/src/__tests__/anchor-generator.test.ts +67 -0
  23. package/src/__tests__/auto-consolidation.test.ts +168 -0
  24. package/src/__tests__/auto-recall.test.ts +131 -0
  25. package/src/__tests__/babel-integration.test.ts +144 -0
  26. package/src/__tests__/batch-dedup.test.ts +65 -0
  27. package/src/__tests__/canary-eval.test.ts +114 -0
  28. package/src/__tests__/capture-engine.test.ts +1238 -0
  29. package/src/__tests__/capture-heuristic.test.ts +171 -0
  30. package/src/__tests__/cascade-forget.test.ts +210 -0
  31. package/src/__tests__/category-min-scores.test.ts +33 -0
  32. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  33. package/src/__tests__/circuit-breaker.test.ts +99 -0
  34. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  35. package/src/__tests__/confidence-metadata.test.ts +125 -0
  36. package/src/__tests__/confidence-tracker.test.ts +261 -0
  37. package/src/__tests__/conflict-advisor.test.ts +206 -0
  38. package/src/__tests__/conflict-escalation.test.ts +149 -0
  39. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  40. package/src/__tests__/conflict-output.test.ts +85 -0
  41. package/src/__tests__/conflict-store.test.ts +374 -0
  42. package/src/__tests__/connector-v1.test.ts +181 -0
  43. package/src/__tests__/consolidation-engine.test.ts +333 -0
  44. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  45. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  46. package/src/__tests__/context-composer-pins.test.ts +64 -0
  47. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  48. package/src/__tests__/context-reconstructor.test.ts +65 -0
  49. package/src/__tests__/context-renderer.test.ts +91 -0
  50. package/src/__tests__/continuity-eval.test.ts +275 -0
  51. package/src/__tests__/continuity-schema.test.ts +97 -0
  52. package/src/__tests__/contracts.test.ts +216 -0
  53. package/src/__tests__/conversation-importer.test.ts +692 -0
  54. package/src/__tests__/core-recall-eval.test.ts +235 -0
  55. package/src/__tests__/core-summary.test.ts +109 -0
  56. package/src/__tests__/data-checkup.test.ts +276 -0
  57. package/src/__tests__/decay-exemption.test.ts +84 -0
  58. package/src/__tests__/dedup-actions.test.ts +58 -0
  59. package/src/__tests__/dedup-guard.test.ts +39 -0
  60. package/src/__tests__/deterministic-id.test.ts +39 -0
  61. package/src/__tests__/distill-lock.test.ts +305 -0
  62. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  63. package/src/__tests__/doctor.test.ts +319 -0
  64. package/src/__tests__/dream-pipeline.test.ts +321 -0
  65. package/src/__tests__/drill-down.test.ts +108 -0
  66. package/src/__tests__/embedder.test.ts +122 -0
  67. package/src/__tests__/emotion-decay.test.ts +46 -0
  68. package/src/__tests__/emotion-detector.test.ts +56 -0
  69. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  70. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  71. package/src/__tests__/entity-resolver.test.ts +58 -0
  72. package/src/__tests__/env-config.test.ts +151 -0
  73. package/src/__tests__/error-signature-boost.test.ts +87 -0
  74. package/src/__tests__/error-signature.test.ts +95 -0
  75. package/src/__tests__/error-taxonomy.test.ts +129 -0
  76. package/src/__tests__/ethics-layer.test.ts +476 -0
  77. package/src/__tests__/event-segmenter.test.ts +153 -0
  78. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  79. package/src/__tests__/frequency-tracker.test.ts +113 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +76 -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-consolidation.test.ts +226 -0
  88. package/src/__tests__/maintenance-locks.test.ts +174 -0
  89. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  90. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  91. package/src/__tests__/memory-boundaries.test.ts +250 -0
  92. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  93. package/src/__tests__/memory-evolution.test.ts +271 -0
  94. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  95. package/src/__tests__/memory-lint.test.ts +421 -0
  96. package/src/__tests__/memory-output.test.ts +215 -0
  97. package/src/__tests__/memory-promotion.test.ts +315 -0
  98. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  99. package/src/__tests__/multi-vector.test.ts +242 -0
  100. package/src/__tests__/narrative-architecture.test.ts +456 -0
  101. package/src/__tests__/noise-filter.test.ts +205 -0
  102. package/src/__tests__/obsidian-connector.test.ts +363 -0
  103. package/src/__tests__/omitted-reporting.test.ts +98 -0
  104. package/src/__tests__/pending-review.test.ts +77 -0
  105. package/src/__tests__/pii-redact.test.ts +314 -0
  106. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  107. package/src/__tests__/ppr-traversal.test.ts +256 -0
  108. package/src/__tests__/prediction-engine.test.ts +349 -0
  109. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  110. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  111. package/src/__tests__/preference-matcher.test.ts +230 -0
  112. package/src/__tests__/preference-slots.test.ts +205 -0
  113. package/src/__tests__/prospective-memory.test.ts +283 -0
  114. package/src/__tests__/query-entity-detector.test.ts +171 -0
  115. package/src/__tests__/query-expander-alias.test.ts +146 -0
  116. package/src/__tests__/query-expander.test.ts +34 -0
  117. package/src/__tests__/recall-governor.test.ts +227 -0
  118. package/src/__tests__/recall-mode.test.ts +64 -0
  119. package/src/__tests__/result-synthesizer.test.ts +198 -0
  120. package/src/__tests__/resume-context.test.ts +4898 -0
  121. package/src/__tests__/retrieval-trace.test.ts +213 -0
  122. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  123. package/src/__tests__/retroactive-boost.test.ts +223 -0
  124. package/src/__tests__/salience-scoring.test.ts +106 -0
  125. package/src/__tests__/scope-inventory.test.ts +200 -0
  126. package/src/__tests__/scope-policy.test.ts +69 -0
  127. package/src/__tests__/scope-suggester.test.ts +80 -0
  128. package/src/__tests__/session-checkpoint.test.ts +291 -0
  129. package/src/__tests__/session-distiller.test.ts +640 -0
  130. package/src/__tests__/session-gc.test.ts +215 -0
  131. package/src/__tests__/skill-engine.test.ts +552 -0
  132. package/src/__tests__/skill-promotion.test.ts +601 -0
  133. package/src/__tests__/skill-verifier.test.ts +122 -0
  134. package/src/__tests__/source-heartbeat.test.ts +250 -0
  135. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  136. package/src/__tests__/store-scope-audit.test.ts +81 -0
  137. package/src/__tests__/store-upsert.test.ts +184 -0
  138. package/src/__tests__/supersede-chain.test.ts +154 -0
  139. package/src/__tests__/temporal-parser.test.ts +241 -0
  140. package/src/__tests__/temporal-validity.test.ts +160 -0
  141. package/src/__tests__/term-registry.test.ts +121 -0
  142. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  143. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  144. package/src/__tests__/topic-tag.test.ts +113 -0
  145. package/src/__tests__/trusted-memory.test.ts +839 -0
  146. package/src/__tests__/usage-tracker.test.ts +287 -0
  147. package/src/__tests__/version-manager.test.ts +213 -0
  148. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  149. package/src/__tests__/workflow-observation.test.ts +433 -0
  150. package/src/__tests__/write-verifier.test.ts +122 -0
  151. package/src/access-tracker.ts +38 -32
  152. package/src/activity-counter.ts +46 -18
  153. package/src/aliases.ts +133 -0
  154. package/src/api-server.ts +27 -7
  155. package/src/audit-log.ts +4 -2
  156. package/src/auto-consolidation.ts +8 -3
  157. package/src/auto-gc.ts +85 -21
  158. package/src/capture-engine.ts +54 -21
  159. package/src/cli.ts +199 -37
  160. package/src/confidence-tracker.ts +36 -18
  161. package/src/connector-types.ts +57 -0
  162. package/src/consolidation-engine.ts +109 -11
  163. package/src/context-composer.ts +8 -1
  164. package/src/conversation-importer.ts +25 -1
  165. package/src/data-checkup.ts +126 -7
  166. package/src/distill-lock.ts +194 -15
  167. package/src/doctor.ts +38 -2
  168. package/src/dream-pipeline.ts +118 -34
  169. package/src/env-config.ts +77 -0
  170. package/src/error-signature.ts +153 -0
  171. package/src/error-taxonomy.ts +115 -0
  172. package/src/eval.ts +251 -6
  173. package/src/idempotency.ts +8 -0
  174. package/src/ingest.ts +234 -10
  175. package/src/kg-extractor.ts +6 -2
  176. package/src/kg-store.ts +236 -44
  177. package/src/llm-consolidation.ts +2 -1
  178. package/src/mcp-server.ts +85 -1799
  179. package/src/mcp-tool-deps.ts +48 -0
  180. package/src/mcp-tools-advanced.ts +941 -0
  181. package/src/mcp-tools-core.ts +633 -0
  182. package/src/mcp-tools-governance.ts +475 -0
  183. package/src/memory-boundaries.ts +3 -1
  184. package/src/memory-evolution.ts +73 -1
  185. package/src/memory-lint.ts +183 -10
  186. package/src/memory-output.ts +114 -3
  187. package/src/memory-promotion.ts +246 -0
  188. package/src/memory-schema.ts +25 -2
  189. package/src/memory-store-port.ts +43 -0
  190. package/src/multi-vector.ts +2 -1
  191. package/src/narrative-schema.ts +2 -1
  192. package/src/noise-filter.ts +57 -6
  193. package/src/obsidian-connector.ts +282 -0
  194. package/src/pii-detector.ts +157 -5
  195. package/src/query-expander.ts +90 -9
  196. package/src/result-synthesizer.ts +2 -1
  197. package/src/retention-policy.ts +2 -1
  198. package/src/retriever.ts +259 -139
  199. package/src/runtime-config.ts +8 -1
  200. package/src/scope-suggester.ts +113 -0
  201. package/src/session-distiller.ts +23 -2
  202. package/src/session-engine.ts +2 -1
  203. package/src/session-schema.ts +3 -2
  204. package/src/session-store.ts +5 -0
  205. package/src/skill-engine.ts +78 -0
  206. package/src/skill-promotion.ts +247 -47
  207. package/src/skill-schema.ts +16 -3
  208. package/src/skill-verifier.ts +152 -0
  209. package/src/source-heartbeat.ts +108 -0
  210. package/src/store.ts +267 -38
  211. package/src/tool-output-compressor.ts +3 -1
  212. package/src/ui-server.ts +2 -1
  213. package/src/usage-tracker.ts +242 -0
  214. package/src/version-manager.ts +3 -2
  215. package/src/workflow-observation-engine.ts +2 -1
  216. package/src/workflow-observation-schema.ts +2 -1
  217. package/src/workflow-observation-store.ts +6 -2
  218. package/.env +0 -4
  219. package/CLAUDE.md +0 -46
  220. package/config.json +0 -55
  221. package/daily-recall.sh +0 -110
  222. package/logs/daily-recall.error.log +0 -0
  223. package/logs/daily-recall.log +0 -30
  224. package/logs/ingest-2026-04-11.log +0 -110
  225. package/logs/ingest-2026-04-12.log +0 -90
  226. package/logs/launchagent-stderr.log +0 -0
  227. package/logs/launchagent-stdout.log +0 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * A1 · Error-signature extractor — zero-LLM 结构化错误指纹(debug case 精确召回用)。
3
+ *
4
+ * 借鉴 MemOS local-plugin `core/capture/error-signature.ts`(repo-insight 审计 2026-06-13),
5
+ * 针对 RecallNest 本地化适配(Codex read-only 验证指出的三处不适配):
6
+ * - case 没有 toolCalls,错误指纹藏在 problem/context 自由文本里(MemOS 面向 tool output)
7
+ * - 补裸 `X not found` / `No such file or directory` / 中文错误模式(MemOS 原 6 正则抽不到
8
+ * 裸的 `xmlsec1 not found`,也会被英文 alpha 检查滤掉中文)
9
+ * - normaliseFragment 放宽长度/字母检查以保留中文短片段
10
+ *
11
+ * **读写对称**:纯函数。写入端(persistCaseMemory)抽指纹存 metadata.error_signature;
12
+ * 未来 P3-A 检索精确召回通道复用同一个 extractErrorSignatures,保证读写抽取规则一致。
13
+ * 零 LLM、确定性——每次 case 写入都跑,必须便宜。
14
+ */
15
+
16
+ /** 每条 case 最多保留的指纹数(控制 hot-path 边界)。 */
17
+ export const MAX_SIGNATURES = 4;
18
+ const MIN_FRAGMENT_LEN = 6;
19
+ const MAX_FRAGMENT_LEN = 160;
20
+
21
+ /**
22
+ * verbatim 抽取模式——顺序即优先级,整段 match(m[0])作为 fragment。
23
+ */
24
+ const ERROR_PATTERNS: RegExp[] = [
25
+ // <Name>Error: / <Name>Exception: body
26
+ /\b([A-Z][A-Za-z0-9]*(?:Error|Exception)):\s*([^\n]{4,160})/g,
27
+ // error: / fatal: / ERROR: body
28
+ /\b(?:error|Error|fatal|FATAL|ERROR)\s*:\s*([^\n]{4,160})/g,
29
+ // <cmd>: ... not found / no such file / permission denied(带冒号)
30
+ /\b([A-Za-z0-9_\-./]+):\s*[^\n]{0,40}\b(not found|no such (?:file|directory)|permission denied|undefined reference|command not found)\b[^\n]*/g,
31
+ // 裸 <thing> [command] not found(无冒号——MemOS 原版抽不到 "xmlsec1 not found")
32
+ /\b([A-Za-z0-9_.\-/]{2,60})\s+(?:command )?not found\b/gi,
33
+ // No such file or directory(独立出现)
34
+ /\bno such file or directory\b/gi,
35
+ // <thing> is required / must be / cannot / could not / failed to
36
+ /\b([A-Za-z0-9_]{3,40})\s+(is required|must be|cannot|could not|failed to)\s+[^\n]{3,120}/g,
37
+ // exit code / status N
38
+ /\bexit (?:code|status)\s*[:=]?\s*(\d{1,4})\b[^\n]{0,80}/g,
39
+ // HTTP 4xx/5xx + 标准状态短语(收紧:避免 "500 ms" / "404 rows" 这类裸数字误抽)
40
+ /\b(4\d\d|5\d\d)\s+(Bad Request|Unauthorized|Payment Required|Forbidden|Not Found|Method Not Allowed|Not Acceptable|Request Timeout|Conflict|Gone|Payload Too Large|Unprocessable Entity|Too Many Requests|Internal Server Error|Internal|Not Implemented|Bad Gateway|Service Unavailable|Gateway Timeout)\b/g,
41
+ // 中文错误现象 + 后续短语
42
+ /(找不到|没有找到|不存在|无法|没有权限|权限被拒|拒绝访问|命令不存在|没有那个文件|连接失败|连接超时|超时|崩溃|段错误|空指针|未定义|报错|异常|失败)[^\n]{0,24}/g,
43
+ // 中文 退出码/状态码/错误码/返回码 N
44
+ /(退出码|状态码|错误码|返回码)\s*[:=:]?\s*\d{1,4}/g,
45
+ ];
46
+
47
+ const STOP_WORDS = new Set([
48
+ "the", "for", "this", "that", "your", "from", "with", "have", "has",
49
+ "not", "a", "an", "of", "to", "is", "in", "on", "by", "was", "were",
50
+ ]);
51
+
52
+ /**
53
+ * 过泛中文错误词——单独出现(裸词 + 语气助词、无具体锚点)会在检索端过度匹配。
54
+ * 具体短语(找不到 / 权限被拒 / 连接失败 / 段错误 / 空指针)不在此列、照常保留。
55
+ * 已有 cjk<3 长度门挡掉 2 字裸词;本表治的是 3+ 字「裸词+助词」(失败了 / 超时了)。
56
+ */
57
+ const GENERIC_CN_ERROR = /^(?:报错|出错|错误|异常|失败|超时|崩溃|无法|未定义|不能)[了的过着吧呢啊啦哦呀嘛。,!?!?,\s]*$/;
58
+
59
+ /** 失败信号词——outcome/solutionSteps 仅在命中时才纳入语料(避免成功态反向噪声)。 */
60
+ const FAILURE_HINT = /(error|fail|failed|failure|exception|not found|cannot|denied|timeout|拒绝|失败|报错|错误|异常|不能|无法|超时|崩溃)/i;
61
+
62
+ export interface ExtractInput {
63
+ /** 主信号:case 的 problem 字段("What problem happened")。 */
64
+ problem: string;
65
+ /** case 的 context(原始 stderr / 触发条件常在此)。 */
66
+ context?: string;
67
+ /** case 的 outcome(解决后状态——偏成功态,仅含失败词时参与)。 */
68
+ outcome?: string;
69
+ /** solution 步骤(修复命令噪声多,仅含失败词时参与)。 */
70
+ solutionSteps?: string[];
71
+ }
72
+
73
+ /**
74
+ * 抽取至多 MAX_SIGNATURES 条规范化错误指纹,按 specificity 降序。
75
+ * problem + context 是主语料;outcome / solutionSteps 仅当包含失败词时纳入
76
+ * (outcome 多为成功态,否则会抽到 "exit code 0 / 不再报错" 这类反向信号)。
77
+ */
78
+ export function extractErrorSignatures(input: ExtractInput): string[] {
79
+ const corpus: string[] = [input.problem];
80
+ if (input.context) corpus.push(input.context);
81
+ const tail = [input.outcome ?? "", ...(input.solutionSteps ?? [])].join("\n");
82
+ if (tail.trim() && FAILURE_HINT.test(tail)) corpus.push(tail);
83
+
84
+ // dedup by lowercased/collapsed key(重叠正则不产近似重复);first-seen casing 胜出。
85
+ const candidates = new Map<string, { frag: string; freq: number }>();
86
+ for (const text of corpus) {
87
+ for (const frag of extractFragments(text)) {
88
+ const normalised = normaliseFragment(frag);
89
+ if (!normalised) continue;
90
+ const key = normalised.toLowerCase().replace(/\s+/g, " ");
91
+ const existing = candidates.get(key);
92
+ if (existing) existing.freq++;
93
+ else candidates.set(key, { frag: normalised, freq: 1 });
94
+ }
95
+ }
96
+
97
+ const scored = Array.from(candidates.values()).map(({ frag, freq }) => ({
98
+ frag,
99
+ score: specificityScore(frag, freq),
100
+ }));
101
+ scored.sort((a, b) => b.score - a.score);
102
+ return scored.slice(0, MAX_SIGNATURES).map((s) => s.frag);
103
+ }
104
+
105
+ function extractFragments(text: string): string[] {
106
+ if (!text) return [];
107
+ const out: string[] = [];
108
+ for (const pattern of ERROR_PATTERNS) {
109
+ pattern.lastIndex = 0;
110
+ let m: RegExpExecArray | null;
111
+ while ((m = pattern.exec(text))) {
112
+ out.push(m[0]);
113
+ if (m.index === pattern.lastIndex) pattern.lastIndex++; // 防零宽匹配死循环
114
+ if (out.length >= 32) break; // 每个 pattern 硬上限
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function normaliseFragment(frag: string): string | null {
121
+ const collapsed = frag.replace(/\s+/g, " ").trim();
122
+ const cjk = collapsed.replace(/[^一-鿿]/g, "");
123
+ // 长度门:英文片段 ≥MIN_FRAGMENT_LEN,或中文 ≥3 字(中文短片段如"找不到文件"也保留)
124
+ if (collapsed.length < MIN_FRAGMENT_LEN && cjk.length < 3) return null;
125
+ const truncated = collapsed.length > MAX_FRAGMENT_LEN ? collapsed.slice(0, MAX_FRAGMENT_LEN) : collapsed;
126
+ // 信息量门:英文字母 ≥4 或 中文 ≥2 字(MemOS 原版只看 alpha≥4,会滤掉纯中文)
127
+ const alpha = truncated.replace(/[^A-Za-z]/g, "");
128
+ if (alpha.length < 4 && cjk.length < 2) return null;
129
+ // 纯英文停用词片段才拒;纯中文(words 为空)不走这条
130
+ const words = truncated.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
131
+ if (words.length > 0 && words.every((w) => STOP_WORDS.has(w))) return null;
132
+ // P3-A 收紧:裸过泛中文词(报错/失败/超时…+语气助词)且无具体锚点(latin≥3 / 多位数字 / 路径)→ 拒,
133
+ // 避免检索端 error-signature 精确通道被这类词过度匹配成噪声。
134
+ const hasSpecificAnchor = /[A-Za-z]{3,}|\d{2,}|\//.test(truncated);
135
+ if (!hasSpecificAnchor && GENERIC_CN_ERROR.test(truncated)) return null;
136
+ return truncated;
137
+ }
138
+
139
+ /** specificity:不寻常 token(PascalCase Error / 路径 / 错误码 / 中文错误)得分更高。 */
140
+ function specificityScore(frag: string, freq: number): number {
141
+ let score = 0;
142
+ if (/\b[A-Z][a-zA-Z]*Error\b/.test(frag)) score += 3;
143
+ if (/\b[A-Z][a-zA-Z]*Exception\b/.test(frag)) score += 3;
144
+ if (/(\b|_)E[A-Z]{3,}\b/.test(frag)) score += 2; // ENOENT, EACCES
145
+ if (/\/[a-zA-Z0-9._\-/]+/.test(frag)) score += 2; // path
146
+ if (/\bcode\s*=\s*\d+/.test(frag)) score += 1;
147
+ if (/\b\d{3}\b/.test(frag)) score += 1; // status
148
+ if (/_/.test(frag)) score += 1; // snake_case id
149
+ if (/[一-鿿]/.test(frag)) score += 1; // 中文错误片段值得保留
150
+ score += Math.min(2, freq - 1); // 重复片段小幅加权
151
+ if (frag.length > 120) score -= 1; // 过长片段降权
152
+ return score;
153
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * MCP 错误分类学 — 借鉴 RepoPrompt CE 的 error taxonomy。
3
+ *
4
+ * 问题:RecallNest 43 个 MCP 工具的错误现在三种风格混着(fail-open 空结果 /
5
+ * 错误当正常 content / 局部 catch 返回文本),agent 拿到错误无法判断该重试、
6
+ * 改参数还是上报用户。
7
+ *
8
+ * 方案:给每种失败标三元组 {reasonCode, retryable, responsibility},在 registerTool
9
+ * 外层统一兜底分类,返回带机器可读 block 的 isError result。
10
+ *
11
+ * 范围(第一阶段):只兜底「抛到 handler 顶层、未被内部 catch」的错误。各 handler
12
+ * 内部的 fail-open / 文本错误风格统一是后续第二阶段,不在此处改(避免破坏客户端兼容)。
13
+ */
14
+
15
+ /** 责任方:host=运行环境/配置,app=RecallNest 自身逻辑,peer=调用方输入或上游 API,transport=网络层。 */
16
+ export type ErrorResponsibility = "host" | "app" | "peer" | "transport";
17
+
18
+ export interface ErrorClassification {
19
+ /** 稳定错误码(机器可读,跨版本稳定,供 agent 分支判断)。 */
20
+ reasonCode: string;
21
+ /** 是否值得重试(transient vs permanent)。 */
22
+ retryable: boolean;
23
+ /** 责任方,决定 agent 是重试 / 改参数 / 上报用户。 */
24
+ responsibility: ErrorResponsibility;
25
+ }
26
+
27
+ /**
28
+ * 失败模式表:按 error message 模式分类。
29
+ * 未匹配 → internal_error / app / non-retryable(保守:不误导 agent 去重试一个未知错误)。
30
+ */
31
+ export function classifyError(err: unknown): ErrorClassification {
32
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
33
+
34
+ // transport:网络/连接层,可重试
35
+ if (/econnreset|etimedout|enotfound|econnrefused|socket|network|fetch failed|timed out|timeout/.test(msg)) {
36
+ return { reasonCode: "transport_error", retryable: true, responsibility: "transport" };
37
+ }
38
+ // 上游 API 过载/限流,可重试(退避后)
39
+ if (/rate.?limit|\b429\b|\b529\b|overloaded|too many requests|quota exceeded/.test(msg)) {
40
+ return { reasonCode: "upstream_overloaded", retryable: true, responsibility: "peer" };
41
+ }
42
+ // 上游 API 鉴权/密钥,不可重试(需修 host 配置)
43
+ if (/\b401\b|\b403\b|unauthorized|invalid api key|authentication failed|missing api key/.test(msg)) {
44
+ return { reasonCode: "upstream_auth", retryable: false, responsibility: "host" };
45
+ }
46
+ // 运行环境/配置缺失(host):缺 env var、未配置(Codex 复审 P2-3,关联 lazy-init 失败 P2-1)
47
+ if (/environment variable .* not set|not configured|missing required (config|environment)/.test(msg)) {
48
+ return { reasonCode: "config_missing", retryable: false, responsibility: "host" };
49
+ }
50
+ // 调用方未给 scope(resolveScopeSelection 抛出)
51
+ if (/requires a scope|scope is required|pass scope/.test(msg)) {
52
+ return { reasonCode: "scope_required", retryable: false, responsibility: "peer" };
53
+ }
54
+ // 调用方给的 memory 引用无效:ID 格式错 / 前缀歧义 / 不在可访问 scope(Codex 复审 P2-3)。
55
+ // 指引 agent 给有效 ID / 更长前缀 / 正确 scope,而非误以为 RecallNest 内部故障。
56
+ if (/invalid memory id|ambiguous prefix|invalid .* id format|outside accessible scope|not accessible/.test(msg)) {
57
+ return { reasonCode: "invalid_memory_ref", retryable: false, responsibility: "peer" };
58
+ }
59
+ // 调用方输入非法(zod/validation)
60
+ if (/validation|invalid input|expected .* received|must be a|is required|not a valid|failed to parse/.test(msg)) {
61
+ return { reasonCode: "invalid_input", retryable: false, responsibility: "peer" };
62
+ }
63
+ // 存储层(LanceDB / 向量 / 索引)
64
+ if (/lancedb|database|\btable\b|vector ?search|embedding failed|index/.test(msg)) {
65
+ return { reasonCode: "store_error", retryable: false, responsibility: "app" };
66
+ }
67
+ // 默认:保守归为 app 内部错误,不可重试
68
+ return { reasonCode: "internal_error", retryable: false, responsibility: "app" };
69
+ }
70
+
71
+ /** MCP tool error content item 类型(与 handler 成功路径的 content 同构)。 */
72
+ interface ErrorResult {
73
+ content: { type: "text"; text: string }[];
74
+ isError: true;
75
+ }
76
+
77
+ /**
78
+ * 把任意错误转成带机器可读分类 block 的 MCP isError result。
79
+ * agent 可解析 reason_code / retryable / responsibility 决定下一步。
80
+ */
81
+ export function toErrorResult(toolName: string, err: unknown): ErrorResult {
82
+ const cls = classifyError(err);
83
+ const detail = err instanceof Error ? err.message : String(err);
84
+ return {
85
+ content: [{
86
+ type: "text",
87
+ text: [
88
+ `❌ Tool "${toolName}" failed`,
89
+ `reason_code: ${cls.reasonCode}`,
90
+ `retryable: ${cls.retryable}`,
91
+ `responsibility: ${cls.responsibility}`,
92
+ `detail: ${detail}`,
93
+ ].join("\n"),
94
+ }],
95
+ isError: true,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * 安全执行 tool 逻辑:捕获任意抛错(含 lazy-init / handler 执行),分类 + 结构化
101
+ * stderr 日志 + 返回 isError result。提取成独立函数以便单测锁定回归(Codex 复审建议)。
102
+ * registerTool 的 lazyHandler 把「ensureComponents + handler 调用」整体交给它。
103
+ */
104
+ export async function runToolSafely<T>(toolName: string, fn: () => Promise<T>): Promise<T | ErrorResult> {
105
+ try {
106
+ return await fn();
107
+ } catch (err) {
108
+ const cls = classifyError(err);
109
+ console.error(
110
+ `[recallnest] tool ${toolName} failed [${cls.reasonCode}/${cls.responsibility}/retryable=${cls.retryable}]:`,
111
+ err instanceof Error ? err.stack || err.message : String(err),
112
+ );
113
+ return toErrorResult(toolName, err);
114
+ }
115
+ }
package/src/eval.ts CHANGED
@@ -15,7 +15,7 @@ import type { WorkflowObservationInput } from "./workflow-observation-schema.js"
15
15
  import { WorkflowObservationStore } from "./workflow-observation-store.js";
16
16
 
17
17
  export type ProfileName = "default" | "writing" | "debug" | "fact-check";
18
- export type EvalMode = "retrieval" | "continuity";
18
+ export type EvalMode = "retrieval" | "continuity" | "canary";
19
19
 
20
20
  export interface RetrievalEvalCase {
21
21
  name: string;
@@ -59,6 +59,47 @@ export interface ContinuityEvalCase {
59
59
  notes?: string;
60
60
  }
61
61
 
62
+ export interface CanaryEvalCase {
63
+ name: string;
64
+ query: string;
65
+ profile?: ProfileName;
66
+ scope?: string;
67
+ limit?: number;
68
+ /** A 类:该召回的 memory id(按 target id 算 rank) */
69
+ targets?: string[];
70
+ /** C 类:不该进 TopK 的干扰项 memory id */
71
+ hardNegatives?: string[];
72
+ /** 目标应进前几(默认 3) */
73
+ expectTopK?: number;
74
+ /** B 类新旧序:按此顺序排列,newer 在前,前者 rank 应小于后者 */
75
+ expectOrder?: string[];
76
+ /** D 类稳定偏好:召回文本命中任一要点即算对(不钉 id) */
77
+ expectContentAny?: string[];
78
+ /** 文本层禁词(沿用 retrieval 语义) */
79
+ forbid?: string[];
80
+ notes?: string;
81
+ }
82
+
83
+ export interface CanaryCaseReport {
84
+ mode: "canary";
85
+ name: string;
86
+ query: string;
87
+ profile: ProfileName;
88
+ score: number;
89
+ passed: boolean;
90
+ hitCount: number;
91
+ targetRanks: Array<{ id: string; rank: number | null }>;
92
+ top1Hit: boolean;
93
+ top3Hit: boolean;
94
+ forbiddenIdMatches: string[];
95
+ orderOk: boolean | null;
96
+ matchedContentAny: string[];
97
+ forbiddenMatches: string[];
98
+ topIds: string[];
99
+ topSnippet: string;
100
+ notes?: string;
101
+ }
102
+
62
103
  export interface RetrievalCaseReport {
63
104
  mode: "retrieval";
64
105
  name: string;
@@ -100,7 +141,7 @@ export interface ContinuityCaseReport {
100
141
  notes?: string;
101
142
  }
102
143
 
103
- type EvalReport = RetrievalCaseReport | ContinuityCaseReport;
144
+ type EvalReport = RetrievalCaseReport | ContinuityCaseReport | CanaryCaseReport;
104
145
 
105
146
  interface EvalArgs {
106
147
  mode: EvalMode;
@@ -144,7 +185,7 @@ function parseArgs(args: string[]): EvalArgs {
144
185
  const observationScopeIdx = args.indexOf("--observation-scope");
145
186
  const observationSourceIdx = args.indexOf("--observation-source");
146
187
  const modeRaw = modeIdx >= 0 ? args[modeIdx + 1] : "retrieval";
147
- const mode = modeRaw === "continuity" ? "continuity" : "retrieval";
188
+ const mode: EvalMode = modeRaw === "continuity" ? "continuity" : modeRaw === "canary" ? "canary" : "retrieval";
148
189
 
149
190
  return {
150
191
  mode,
@@ -158,9 +199,9 @@ function parseArgs(args: string[]): EvalArgs {
158
199
  }
159
200
 
160
201
  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");
202
+ if (mode === "continuity") return resolve(metaDir(import.meta), "../eval/continuity/cases.json");
203
+ if (mode === "canary") return resolve(metaDir(import.meta), "../eval/cases-canary.json");
204
+ return resolve(metaDir(import.meta), "../eval/cases.json");
164
205
  }
165
206
 
166
207
  function loadCases<T>(mode: EvalMode, pathArg?: string): T[] {
@@ -221,6 +262,109 @@ export function scoreRetrievalCase(
221
262
  };
222
263
  }
223
264
 
265
+ export function scoreCanaryCase(
266
+ evalCase: CanaryEvalCase,
267
+ results: Array<{ entry: { id: string; text: string; scope: string; metadata?: string } }>,
268
+ ): CanaryCaseReport {
269
+ const profile = evalCase.profile || "default";
270
+ const ids = results.map((r) => r.entry.id);
271
+ const topK = evalCase.expectTopK ?? 3;
272
+ const limit = evalCase.limit ?? results.length;
273
+ const joined = results
274
+ .map((r) => `${r.entry.scope}\n${r.entry.text}\n${r.entry.metadata || ""}`)
275
+ .join("\n")
276
+ .toLowerCase();
277
+
278
+ // memory id 用前缀匹配:targets/expectOrder/hardNegatives 常写 8 位短前缀,
279
+ // 而召回结果的 entry.id 是完整 UUID(recallnest 惯例:≥8 hex 前缀即可定位)。
280
+ const idMatches = (fullId: string, target: string): boolean =>
281
+ fullId === target || fullId.startsWith(target);
282
+ const rankOf = (target: string): number | null => {
283
+ const idx = ids.findIndex((fullId) => idMatches(fullId, target));
284
+ return idx === -1 ? null : idx + 1;
285
+ };
286
+
287
+ // A 类:单目标命中
288
+ const targets = evalCase.targets || [];
289
+ const targetRanks = targets.map((id) => ({ id, rank: rankOf(id) }));
290
+ const top1Hit = targetRanks.some((t) => t.rank === 1);
291
+ const top3Hit = targetRanks.some((t) => t.rank !== null && t.rank <= Math.min(3, topK));
292
+
293
+ // C 类:干扰项进 TopK(limit 内)即违规(同样前缀匹配)
294
+ const forbiddenIdMatches = (evalCase.hardNegatives || []).filter((neg) => {
295
+ const rank = rankOf(neg);
296
+ return rank !== null && rank <= limit;
297
+ });
298
+
299
+ // B 类:新旧序,前者须召回且 rank 小于后者
300
+ let orderOk: boolean | null = null;
301
+ const order = evalCase.expectOrder || [];
302
+ if (order.length >= 2) {
303
+ orderOk = true;
304
+ for (let i = 0; i < order.length - 1; i += 1) {
305
+ const a = rankOf(order[i]);
306
+ const b = rankOf(order[i + 1]);
307
+ if (a === null) {
308
+ orderOk = false;
309
+ break;
310
+ }
311
+ if (b !== null && a >= b) {
312
+ orderOk = false;
313
+ break;
314
+ }
315
+ }
316
+ }
317
+
318
+ // D 类:内容要点命中
319
+ const matchedContentAny = matchedTerms(evalCase.expectContentAny, joined);
320
+
321
+ // 文本禁词
322
+ const forbiddenMatches = matchedTerms(evalCase.forbid, joined);
323
+
324
+ // 动态加权:只计 case 实际声明的维度
325
+ const parts: Array<{ weight: number; value: number }> = [];
326
+ if (targets.length > 0) {
327
+ const ranks = targetRanks.map((t) => t.rank).filter((r): r is number => r !== null);
328
+ const bestRank = ranks.length > 0 ? Math.min(...ranks) : null;
329
+ const targetScore = bestRank === null ? 0 : bestRank === 1 ? 1 : bestRank <= 3 ? 0.7 : 0.4;
330
+ parts.push({ weight: 0.5, value: targetScore });
331
+ }
332
+ if (orderOk !== null) {
333
+ parts.push({ weight: 0.2, value: orderOk ? 1 : 0 });
334
+ }
335
+ const contentAny = evalCase.expectContentAny || [];
336
+ if (contentAny.length > 0) {
337
+ parts.push({ weight: 0.3, value: matchedContentAny.length / contentAny.length });
338
+ }
339
+ const totalWeight = parts.reduce((sum, p) => sum + p.weight, 0);
340
+ let score = totalWeight > 0
341
+ ? parts.reduce((sum, p) => sum + p.weight * p.value, 0) / totalWeight
342
+ : (results.length > 0 ? 0.5 : 0);
343
+ if (forbiddenIdMatches.length > 0) score -= 0.3;
344
+ if (forbiddenMatches.length > 0) score -= 0.3;
345
+ score = Math.max(0, Math.min(1, score));
346
+
347
+ return {
348
+ mode: "canary",
349
+ name: evalCase.name,
350
+ query: evalCase.query,
351
+ profile,
352
+ score,
353
+ passed: score >= 0.7 && forbiddenIdMatches.length === 0 && forbiddenMatches.length === 0,
354
+ hitCount: results.length,
355
+ targetRanks,
356
+ top1Hit,
357
+ top3Hit,
358
+ forbiddenIdMatches,
359
+ orderOk,
360
+ matchedContentAny,
361
+ forbiddenMatches,
362
+ topIds: ids.slice(0, 5),
363
+ topSnippet: results[0] ? clip(results[0].entry.text) : "-",
364
+ notes: evalCase.notes,
365
+ };
366
+ }
367
+
224
368
  function joinResumeSections(response: ResumeContextResponse): string {
225
369
  return [
226
370
  response.summary,
@@ -438,6 +582,50 @@ function markdownRetrievalReport(reports: RetrievalCaseReport[]): string {
438
582
  return lines.join("\n");
439
583
  }
440
584
 
585
+ function markdownCanaryReport(reports: CanaryCaseReport[]): string {
586
+ const { passed, average } = summarizeReports(reports);
587
+ const top1 = reports.filter((r) => r.top1Hit).length;
588
+ const top3 = reports.filter((r) => r.top3Hit).length;
589
+
590
+ const lines = [
591
+ "# RecallNest Canary Eval",
592
+ "",
593
+ `- Generated: ${new Date().toISOString()}`,
594
+ `- Cases: ${reports.length}`,
595
+ `- Passed: ${passed}/${reports.length}`,
596
+ `- Average score: ${formatPercent(average)}`,
597
+ `- Top1 hit: ${top1}/${reports.length}`,
598
+ `- Top3 hit: ${top3}/${reports.length}`,
599
+ "",
600
+ "| Case | Profile | Score | Pass | Top1 | Top3 | Order | Forbid |",
601
+ "|------|---------|-------|------|------|------|-------|--------|",
602
+ ...reports.map((item) =>
603
+ `| ${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 || "-"} |`,
604
+ ),
605
+ "",
606
+ "## Case Notes",
607
+ "",
608
+ ];
609
+
610
+ for (const item of reports) {
611
+ lines.push(`### ${item.name}`);
612
+ lines.push(`- Query: ${item.query}`);
613
+ lines.push(`- Score: ${(item.score * 100).toFixed(0)}% (${item.passed ? "pass" : "fail"})`);
614
+ lines.push(`- Target ranks: ${item.targetRanks.map((t) => `${t.id}=${t.rank ?? "miss"}`).join(", ") || "-"}`);
615
+ lines.push(`- Top1 / Top3: ${item.top1Hit ? "yes" : "no"} / ${item.top3Hit ? "yes" : "no"}`);
616
+ lines.push(`- Order ok: ${item.orderOk === null ? "n/a" : item.orderOk}`);
617
+ lines.push(`- Matched content: ${item.matchedContentAny.join(", ") || "-"}`);
618
+ lines.push(`- Forbidden id matches: ${item.forbiddenIdMatches.join(", ") || "-"}`);
619
+ lines.push(`- Forbidden term matches: ${item.forbiddenMatches.join(", ") || "-"}`);
620
+ lines.push(`- Top ids: ${item.topIds.join(", ") || "-"}`);
621
+ lines.push(`- Top snippet: ${item.topSnippet}`);
622
+ if (item.notes) lines.push(`- Notes: ${item.notes}`);
623
+ lines.push("");
624
+ }
625
+
626
+ return lines.join("\n");
627
+ }
628
+
441
629
  function markdownContinuityReport(reports: ContinuityCaseReport[]): string {
442
630
  const { passed, average } = summarizeReports(reports);
443
631
 
@@ -523,6 +711,44 @@ export async function runRetrievalEval(
523
711
  return reports;
524
712
  }
525
713
 
714
+ export async function runCanaryEval(
715
+ cases: CanaryEvalCase[],
716
+ deps: {
717
+ createEvalComponents?: EvalComponentFactory;
718
+ } = {},
719
+ ): Promise<CanaryCaseReport[]> {
720
+ const config = deps.createEvalComponents ? null : loadConfig();
721
+ const createEvalComponentsForCase = deps.createEvalComponents || createFreshEvalComponentsFactory(config!);
722
+ const reports: CanaryCaseReport[] = [];
723
+
724
+ for (const [index, evalCase] of cases.entries()) {
725
+ if (cases.length > 1) {
726
+ logInfo(`[INFO] canary-eval ${index + 1}/${cases.length}: ${evalCase.name}`);
727
+ }
728
+ const profileName = evalCase.profile || "default";
729
+ const { retriever, accessTracker } = createEvalComponentsForCase(profileName);
730
+ try {
731
+ const results = await retriever.retrieve({
732
+ query: evalCase.query,
733
+ limit: evalCase.limit || 8,
734
+ scopeFilter: evalCase.scope ? [evalCase.scope] : undefined,
735
+ source: "auto-recall",
736
+ });
737
+ const report = scoreCanaryCase(evalCase, results);
738
+ reports.push(report);
739
+ if (cases.length > 1) {
740
+ logInfo(
741
+ `[INFO] canary-eval ${index + 1}/${cases.length} done: ${evalCase.name} ${formatPercent(report.score)} ${report.passed ? "pass" : "fail"}`,
742
+ );
743
+ }
744
+ } finally {
745
+ accessTracker.destroy();
746
+ }
747
+ }
748
+
749
+ return reports;
750
+ }
751
+
526
752
  export async function runContinuityEval(
527
753
  cases: ContinuityEvalCase[],
528
754
  options: { recordObservations?: boolean; observationScope?: string; observationSource?: string } = {},
@@ -610,6 +836,25 @@ async function main() {
610
836
  return;
611
837
  }
612
838
 
839
+ if (args.mode === "canary") {
840
+ const cases = loadCases<CanaryEvalCase>("canary", args.casesPath);
841
+ const reports = await runCanaryEval(cases);
842
+ if (args.jsonMode) {
843
+ const payload = JSON.stringify({
844
+ mode: "canary",
845
+ generatedAt: new Date().toISOString(),
846
+ reports,
847
+ }, null, 2);
848
+ writeOutput(args.outputPath, payload);
849
+ console.log(payload);
850
+ return;
851
+ }
852
+ const output = markdownCanaryReport(reports);
853
+ writeOutput(args.outputPath, output);
854
+ console.log(output);
855
+ return;
856
+ }
857
+
613
858
  const cases = loadCases<RetrievalEvalCase>("retrieval", args.casesPath);
614
859
  const reports = await runRetrievalEval(cases);
615
860
  if (args.jsonMode) {
@@ -0,0 +1,8 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function buildIdempotentRecordId(prefix: string, key?: string): string | undefined {
4
+ const normalized = key?.replace(/\s+/g, " ").trim();
5
+ if (!normalized) return undefined;
6
+ const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 32);
7
+ return `${prefix}-${digest}`;
8
+ }