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
@@ -0,0 +1,408 @@
1
+ /**
2
+ * NoisePrototypeBank — shadow experiment (mlp #907/#914 borrow, cut down per
3
+ * Codex review 2026-07-17).
4
+ *
5
+ * Hypothesis under test: can embedding similarity catch noise *variants* that
6
+ * the regex noise-filter misses? This bank NEVER rejects anything. It only:
7
+ *
8
+ * - learns: when admission rejects with reason "noise_detected" (and only
9
+ * that reason — "importance_too_low" is a heuristic self-judgment, not a
10
+ * noise label, and learning from it would snowball one misjudgment into a
11
+ * whole semantic neighborhood), the text becomes a *pending candidate*;
12
+ * after MIN_SEEN independent rejections it is embedded and stored as a
13
+ * prototype (cap 32, TTL 90d).
14
+ * - matches: for texts admission ACCEPTED, the write-path embedding that was
15
+ * computed anyway is compared against prototypes; a cosine hit ≥0.93 emits
16
+ * a "suggest_reject" shadow-log line. No extra embedding on this side.
17
+ *
18
+ * Exit condition (per review): after real samples accumulate, offline-evaluate
19
+ * incremental precision/recall from the shadow log; no measurable lift → shut
20
+ * the experiment down (RECALLNEST_NOISE_PROTOTYPE=off) and delete the bank.
21
+ *
22
+ * Permanent protections: content carrying explicit memory verbs, corrections,
23
+ * decisions, identity, or preference cues is never learned from and never
24
+ * flagged, regardless of similarity.
25
+ */
26
+
27
+ import { createHash } from "node:crypto";
28
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
+ import { dirname, join } from "node:path";
30
+ import { dataDir } from "./env-config.js";
31
+
32
+ export type NoisePrototypeMode = "shadow" | "off";
33
+
34
+ /**
35
+ * RECALLNEST_NOISE_PROTOTYPE: unset/invalid → "shadow" (log-only), "off"
36
+ * disables. Under NODE_ENV=test an unset flag resolves to "off" so the
37
+ * hundreds of persistMemory tests don't seed the developer's real bank with
38
+ * mock vectors — tests opting in set the env or inject an instance.
39
+ */
40
+ export function resolveNoisePrototypeMode(env: NodeJS.ProcessEnv = process.env): NoisePrototypeMode {
41
+ const raw = (env.RECALLNEST_NOISE_PROTOTYPE || "").trim().toLowerCase();
42
+ if (raw === "off") return "off";
43
+ if (raw === "shadow") return "shadow";
44
+ return env.NODE_ENV === "test" ? "off" : "shadow";
45
+ }
46
+
47
+ export interface NoisePrototype {
48
+ id: string;
49
+ textHash: string;
50
+ /** First 60 chars, kept for human evaluation of the experiment. */
51
+ textPreview: string;
52
+ vector: number[];
53
+ norm: number;
54
+ sourceReason: "noise_detected";
55
+ /** Independent rejections observed before this became a prototype. */
56
+ seenCount: number;
57
+ /** Shadow suggest_reject hits attributed to this prototype. */
58
+ hitCount: number;
59
+ learnedAt: string;
60
+ lastHitAt?: string;
61
+ }
62
+
63
+ interface PendingCandidate {
64
+ count: number;
65
+ firstSeenAt: string;
66
+ preview: string;
67
+ }
68
+
69
+ interface BankFile {
70
+ version: 1;
71
+ prototypes: NoisePrototype[];
72
+ pending: Record<string, PendingCandidate>;
73
+ }
74
+
75
+ export interface NoisePrototypeBankConfig {
76
+ /** Cosine similarity threshold for a shadow hit (Codex: start 0.92–0.95, not mlp's 0.82). */
77
+ threshold: number;
78
+ /** Hard cap on learned prototypes. */
79
+ maxPrototypes: number;
80
+ /** Independent rejections required before a candidate is embedded. */
81
+ minSeenCount: number;
82
+ /** Prototype expiry in days. */
83
+ ttlDays: number;
84
+ /** Pending-candidate expiry in days. */
85
+ pendingTtlDays: number;
86
+ /** Bank persistence path. */
87
+ filePath: string;
88
+ /** Shadow log path (JSONL). */
89
+ shadowLogPath: string;
90
+ }
91
+
92
+ const DEFAULTS: Omit<NoisePrototypeBankConfig, "filePath" | "shadowLogPath"> = {
93
+ threshold: 0.93,
94
+ maxPrototypes: 32,
95
+ minSeenCount: 2,
96
+ ttlDays: 90,
97
+ pendingTtlDays: 7,
98
+ };
99
+
100
+ export type NoisePrototypeEvent =
101
+ | "learned"
102
+ | "candidate_seen"
103
+ | "suggest_reject"
104
+ | "skipped_protected"
105
+ | "skipped_full"
106
+ | "skipped_duplicate"
107
+ | "no_match";
108
+
109
+ // --- permanent protection rules ---
110
+
111
+ const PROTECTED_CUES_ZH = [
112
+ "记住",
113
+ "记一下",
114
+ "记下来",
115
+ "别忘了",
116
+ "帮我记",
117
+ "存一下",
118
+ "我决定",
119
+ "定了",
120
+ "拍板",
121
+ "纠正",
122
+ "不对",
123
+ "错了",
124
+ "我是",
125
+ "我叫",
126
+ "我的名字",
127
+ "我喜欢",
128
+ "我不喜欢",
129
+ "我偏好",
130
+ "偏好",
131
+ "我的生日",
132
+ "我的电话",
133
+ "我的地址",
134
+ ];
135
+
136
+ const PROTECTED_CUES_EN = [
137
+ /(?<![\w-])remember this(?![\w-])/,
138
+ /(?<![\w-])note this(?![\w-])/,
139
+ /(?<![\w-])my name(?![\w-])/,
140
+ /(?<![\w-])i am(?![\w-])/,
141
+ /(?<![\w-])i'm(?![\w-])/,
142
+ /(?<![\w-])i prefer(?![\w-])/,
143
+ /(?<![\w-])my preferences?(?![\w-])/,
144
+ /(?<![\w-])i decided(?![\w-])/,
145
+ /(?<![\w-])correction(?![\w-])/,
146
+ ];
147
+
148
+ /** Identity/preference/decision/correction content is permanently protected. */
149
+ export function isProtectedContent(text: string): boolean {
150
+ for (const cue of PROTECTED_CUES_ZH) {
151
+ if (text.includes(cue)) return true;
152
+ }
153
+ const lower = text.toLowerCase();
154
+ for (const cue of PROTECTED_CUES_EN) {
155
+ if (cue.test(lower)) return true;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ function normalizeForHash(text: string): string {
161
+ return text
162
+ .toLowerCase()
163
+ .replace(/[\s。,,.!!??~〜…、;;::'"'"()()\-_]/gu, "")
164
+ .trim();
165
+ }
166
+
167
+ function hashText(text: string): string {
168
+ return createHash("sha256").update(normalizeForHash(text)).digest("hex").slice(0, 16);
169
+ }
170
+
171
+ function l2norm(vector: number[]): number {
172
+ let sum = 0;
173
+ for (const v of vector) sum += v * v;
174
+ return Math.sqrt(sum);
175
+ }
176
+
177
+ function cosine(a: number[], normA: number, b: number[], normB: number): number {
178
+ if (normA === 0 || normB === 0 || a.length !== b.length) return 0;
179
+ let dot = 0;
180
+ for (let i = 0; i < a.length; i++) dot += a[i]! * b[i]!;
181
+ return dot / (normA * normB);
182
+ }
183
+
184
+ export interface ShadowMatch {
185
+ matched: boolean;
186
+ prototypeId?: string;
187
+ similarity?: number;
188
+ protected?: boolean;
189
+ }
190
+
191
+ export class NoisePrototypeBank {
192
+ private readonly config: NoisePrototypeBankConfig;
193
+ private state: BankFile | null = null;
194
+
195
+ constructor(config: Partial<NoisePrototypeBankConfig> & Pick<NoisePrototypeBankConfig, "filePath" | "shadowLogPath">) {
196
+ this.config = { ...DEFAULTS, ...config };
197
+ }
198
+
199
+ /**
200
+ * Learning side. Call when admission rejected with reason "noise_detected".
201
+ * Embeds at most once per unique normalized text, and only after the
202
+ * candidate has been independently rejected minSeenCount times.
203
+ * Best-effort: any failure is swallowed — the experiment must never break
204
+ * the write path it observes.
205
+ */
206
+ async learnFromRejection(
207
+ text: string,
208
+ embedPassage: (text: string) => Promise<number[]>,
209
+ ): Promise<NoisePrototypeEvent> {
210
+ try {
211
+ if (isProtectedContent(text)) {
212
+ this.logShadow({ event: "skipped_protected", side: "learn", msgLen: text.length });
213
+ return "skipped_protected";
214
+ }
215
+ const state = this.load();
216
+ const hash = hashText(text);
217
+ if (state.prototypes.some((p) => p.textHash === hash)) {
218
+ return "skipped_duplicate";
219
+ }
220
+ const pending = state.pending[hash] ?? {
221
+ count: 0,
222
+ firstSeenAt: new Date().toISOString(),
223
+ preview: text.slice(0, 60),
224
+ };
225
+ pending.count += 1;
226
+ state.pending[hash] = pending;
227
+
228
+ if (pending.count < this.config.minSeenCount) {
229
+ this.save();
230
+ this.logShadow({ event: "candidate_seen", side: "learn", msgLen: text.length, seenCount: pending.count });
231
+ return "candidate_seen";
232
+ }
233
+ if (state.prototypes.length >= this.config.maxPrototypes) {
234
+ this.save();
235
+ this.logShadow({ event: "skipped_full", side: "learn", msgLen: text.length });
236
+ return "skipped_full";
237
+ }
238
+
239
+ const vector = await embedPassage(text);
240
+ const prototype: NoisePrototype = {
241
+ id: `proto-${hash.slice(0, 8)}`,
242
+ textHash: hash,
243
+ textPreview: pending.preview,
244
+ vector,
245
+ norm: l2norm(vector),
246
+ sourceReason: "noise_detected",
247
+ seenCount: pending.count,
248
+ hitCount: 0,
249
+ learnedAt: new Date().toISOString(),
250
+ };
251
+ state.prototypes.push(prototype);
252
+ delete state.pending[hash];
253
+ this.save();
254
+ this.logShadow({ event: "learned", side: "learn", msgLen: text.length, prototypeId: prototype.id, seenCount: prototype.seenCount });
255
+ return "learned";
256
+ } catch {
257
+ return "no_match";
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Matching side. Call with the write-path embedding that was computed
263
+ * anyway for an ACCEPTED text — this side never spends an extra embedding.
264
+ * Only ever logs; the caller must not reject based on the result while the
265
+ * experiment is in shadow.
266
+ */
267
+ matchShadow(text: string, vector: number[], memoryId?: string): ShadowMatch {
268
+ try {
269
+ const state = this.load();
270
+ if (state.prototypes.length === 0) return { matched: false };
271
+ if (isProtectedContent(text)) {
272
+ return { matched: false, protected: true };
273
+ }
274
+ const norm = l2norm(vector);
275
+ let best: { prototype: NoisePrototype; similarity: number } | undefined;
276
+ for (const prototype of state.prototypes) {
277
+ const similarity = cosine(vector, norm, prototype.vector, prototype.norm);
278
+ if (!best || similarity > best.similarity) best = { prototype, similarity };
279
+ }
280
+ if (!best || best.similarity < this.config.threshold) {
281
+ return { matched: false };
282
+ }
283
+ best.prototype.hitCount += 1;
284
+ best.prototype.lastHitAt = new Date().toISOString();
285
+ this.save();
286
+ this.logShadow({
287
+ event: "suggest_reject",
288
+ side: "match",
289
+ msgLen: text.length,
290
+ prototypeId: best.prototype.id,
291
+ similarity: Number(best.similarity.toFixed(4)),
292
+ memoryId,
293
+ });
294
+ return { matched: true, prototypeId: best.prototype.id, similarity: best.similarity };
295
+ } catch {
296
+ return { matched: false };
297
+ }
298
+ }
299
+
300
+ /** Correction API — single-prototype removal (a false positive must be surgically removable). */
301
+ removePrototype(id: string): boolean {
302
+ const state = this.load();
303
+ const before = state.prototypes.length;
304
+ state.prototypes = state.prototypes.filter((p) => p.id !== id);
305
+ const removed = state.prototypes.length < before;
306
+ if (removed) this.save();
307
+ return removed;
308
+ }
309
+
310
+ /** Correction API — full reset. */
311
+ clear(): void {
312
+ this.state = { version: 1, prototypes: [], pending: {} };
313
+ this.save();
314
+ }
315
+
316
+ list(): NoisePrototype[] {
317
+ return [...this.load().prototypes];
318
+ }
319
+
320
+ pendingCount(): number {
321
+ return Object.keys(this.load().pending).length;
322
+ }
323
+
324
+ // --- persistence ---
325
+
326
+ private load(): BankFile {
327
+ if (this.state) return this.state;
328
+ let parsed: BankFile | null = null;
329
+ try {
330
+ if (existsSync(this.config.filePath)) {
331
+ parsed = JSON.parse(readFileSync(this.config.filePath, "utf8")) as BankFile;
332
+ }
333
+ } catch {
334
+ parsed = null;
335
+ }
336
+ const state: BankFile = parsed && parsed.version === 1
337
+ ? parsed
338
+ : { version: 1, prototypes: [], pending: {} };
339
+
340
+ // TTL sweep on load.
341
+ const now = Date.now();
342
+ const protoCutoff = now - this.config.ttlDays * 86_400_000;
343
+ state.prototypes = state.prototypes.filter((p) => Date.parse(p.learnedAt) >= protoCutoff);
344
+ for (const [hash, candidate] of Object.entries(state.pending)) {
345
+ if (Date.parse(candidate.firstSeenAt) < now - this.config.pendingTtlDays * 86_400_000) {
346
+ delete state.pending[hash];
347
+ }
348
+ }
349
+ this.state = state;
350
+ return state;
351
+ }
352
+
353
+ private save(): void {
354
+ try {
355
+ const dir = dirname(this.config.filePath);
356
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
357
+ writeFileSync(this.config.filePath, JSON.stringify(this.load(), null, 2), "utf8");
358
+ } catch {
359
+ // Persistence is best-effort; in-memory state still serves this process.
360
+ }
361
+ }
362
+
363
+ private logShadow(entry: Record<string, unknown>): void {
364
+ try {
365
+ const dir = dirname(this.config.shadowLogPath);
366
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
367
+ appendFileSync(
368
+ this.config.shadowLogPath,
369
+ `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`,
370
+ "utf8",
371
+ );
372
+ } catch {
373
+ // Shadow logging is best-effort by design.
374
+ }
375
+ }
376
+ }
377
+
378
+ // --- shared instance (env-governed, injectable via PersistMemoryDeps) ---
379
+
380
+ export const NOISE_PROTOTYPE_BANK_FILE = "noise-prototype-bank.json";
381
+ export const NOISE_PROTOTYPE_SHADOW_FILE = "noise-prototype-shadow.jsonl";
382
+
383
+ let sharedBank: NoisePrototypeBank | null | undefined;
384
+
385
+ /**
386
+ * Resolve the process-wide bank: explicit deps win (null disables, instance
387
+ * overrides); otherwise lazily construct from env (shadow default, off → null).
388
+ */
389
+ export function resolveNoisePrototypeBank(
390
+ explicit: NoisePrototypeBank | null | undefined,
391
+ env: NodeJS.ProcessEnv = process.env,
392
+ ): NoisePrototypeBank | null {
393
+ if (explicit !== undefined) return explicit;
394
+ if (sharedBank === undefined) {
395
+ sharedBank = resolveNoisePrototypeMode(env) === "off"
396
+ ? null
397
+ : new NoisePrototypeBank({
398
+ filePath: join(dataDir(), NOISE_PROTOTYPE_BANK_FILE),
399
+ shadowLogPath: join(dataDir(), NOISE_PROTOTYPE_SHADOW_FILE),
400
+ });
401
+ }
402
+ return sharedBank;
403
+ }
404
+
405
+ /** Test hook: drop the memoized shared bank so env changes take effect. */
406
+ export function resetSharedNoisePrototypeBank(): void {
407
+ sharedBank = undefined;
408
+ }
@@ -2,12 +2,21 @@
2
2
  * F-3: Lightweight PII detection.
3
3
  * Scans text for common sensitive patterns (passwords, tokens, IDs).
4
4
  * Returns warnings, does not block writes.
5
+ *
6
+ * F-3b (2026-07-10): Deterministic secret redaction on the same rule set.
7
+ * `redactSecrets` scrubs redactable matches BEFORE text reaches any external
8
+ * surface (LLM prompts, embedding API, stderr logs, audit trail). Detection
9
+ * and redaction share one rule table so the two can never drift apart.
10
+ * Deliberately NOT redacted: phone / email (legitimate memory content, warn
11
+ * only) and bare long-hex (would false-positive on git commit hashes, which
12
+ * are high-value memory content; prefixed forms like `secret=<hex>` are
13
+ * caught by the assignment rules).
5
14
  */
6
15
 
7
16
  export type PIISeverity = "high" | "medium" | "low";
8
17
 
9
18
  export interface PIIDetection {
10
- type: string; // "api_key" | "password" | "id_number" | "email" | "phone" | "credit_card"
19
+ type: string; // "api_key" | "password" | "id_number" | "email" | "phone" | "credit_card" | ...
11
20
  severity: PIISeverity;
12
21
  match: string; // the matched text (partially masked)
13
22
  position: number; // char offset
@@ -19,42 +28,196 @@ export interface PIIScanResult {
19
28
  summary: string; // human-readable summary
20
29
  }
21
30
 
31
+ export interface RedactResult {
32
+ text: string; // scrubbed text
33
+ redacted: number; // number of replacements made
34
+ }
35
+
22
36
  interface PIIRule {
23
37
  type: string;
24
38
  severity: PIISeverity;
25
39
  pattern: RegExp;
40
+ /** Scrub matches via redactSecrets (detection-only rules leave text intact) */
41
+ redact: boolean;
42
+ /** For key=value style rules: keep the key name, scrub only the value */
43
+ keepPrefix?: RegExp;
44
+ /** Custom scrubber for structured credentials where preserving a safe
45
+ * prefix/suffix is clearer than replacing the whole match. */
46
+ replacement?: (match: string) => string;
47
+ /** Checksum gate for broad numeric patterns: redact ONLY when this returns
48
+ * true (scan still warns regardless). Keeps snowflake IDs / order numbers
49
+ * from being destructively rewritten. */
50
+ validate?: (match: string) => boolean;
51
+ }
52
+
53
+ /** GB 11643 checksum (ISO 7064 MOD 11-2) for 18-digit Chinese national IDs. */
54
+ function isValidChineseId(id: string): boolean {
55
+ if (!/^\d{17}[\dXx]$/.test(id)) return false;
56
+ const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
57
+ const checkChars = "10X98765432";
58
+ let sum = 0;
59
+ for (let i = 0; i < 17; i++) sum += Number(id[i]) * weights[i];
60
+ return checkChars[sum % 11] === id[17].toUpperCase();
61
+ }
62
+
63
+ /** Luhn checksum for card-number candidates (separators stripped first). */
64
+ function isValidLuhn(candidate: string): boolean {
65
+ const digits = candidate.replace(/[\s-]/g, "");
66
+ if (!/^\d{16}$/.test(digits)) return false;
67
+ let sum = 0;
68
+ for (let i = 0; i < 16; i++) {
69
+ let d = Number(digits[15 - i]);
70
+ if (i % 2 === 1) {
71
+ d *= 2;
72
+ if (d > 9) d -= 9;
73
+ }
74
+ sum += d;
75
+ }
76
+ return sum % 10 === 0;
26
77
  }
27
78
 
28
79
  const PII_RULES: PIIRule[] = [
80
+ // --- Structured credentials commonly found in shell/config transcripts ---
81
+ {
82
+ type: "uri_credentials",
83
+ severity: "high",
84
+ pattern: /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s\/:@]+:[^\s\/@]+@/gi,
85
+ redact: true,
86
+ replacement: (match) => {
87
+ const schemeEnd = match.indexOf("://") + 3;
88
+ return `${match.slice(0, schemeEnd)}[REDACTED:uri_credentials]@`;
89
+ },
90
+ },
91
+ {
92
+ type: "basic_auth",
93
+ severity: "high",
94
+ pattern: /\b(?:Authorization\s*:\s*)?Basic\s+[A-Za-z0-9+/]{8,}={0,2}/gi,
95
+ redact: true,
96
+ replacement: (match) => {
97
+ const prefix = match.match(/^(?:Authorization\s*:\s*)?Basic\s+/i)?.[0] ?? "Basic ";
98
+ return `${prefix}[REDACTED:basic_auth]`;
99
+ },
100
+ },
101
+ {
102
+ type: "cookie_header",
103
+ severity: "high",
104
+ pattern: /\b(?:Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi,
105
+ redact: true,
106
+ replacement: (match) => `${match.slice(0, match.indexOf(":") + 1)} [REDACTED:cookie_header]`,
107
+ },
108
+ {
109
+ type: "sensitive_assignment",
110
+ severity: "high",
111
+ pattern: /["']?(?:database[_-]?url|aws[_-]?secret[_-]?access[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?(?:id|token)|cookie|private[_-]?key)["']?\s*[=:]\s*["']?[^\s"',;]{8,}/gi,
112
+ redact: true,
113
+ keepPrefix: /^["']?(?:database[_-]?url|aws[_-]?secret[_-]?access[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?(?:id|token)|cookie|private[_-]?key)["']?\s*[=:]\s*["']?/i,
114
+ },
115
+ // --- Vendor-prefixed token literals (high confidence, full scrub) ---
116
+ {
117
+ type: "anthropic_key",
118
+ severity: "high",
119
+ // Negative lookbehind: real keys start their own token; without it the
120
+ // "sk" tail of ordinary words (task-, risk-) plus a hyphenated slug
121
+ // matches (e.g. "task-ant..." would scrub from "sk-ant...").
122
+ pattern: /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_\-]{10,}/g,
123
+ redact: true,
124
+ },
125
+ {
126
+ type: "github_token",
127
+ severity: "high",
128
+ pattern: /gh[pousr]_[A-Za-z0-9]{20,}/g,
129
+ redact: true,
130
+ },
131
+ {
132
+ type: "slack_token",
133
+ severity: "high",
134
+ pattern: /xox[baprs]-[A-Za-z0-9\-]{10,}/g,
135
+ redact: true,
136
+ },
137
+ {
138
+ type: "aws_access_key",
139
+ severity: "high",
140
+ pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
141
+ redact: true,
142
+ },
143
+ {
144
+ type: "google_api_key",
145
+ severity: "high",
146
+ pattern: /\bAIza[A-Za-z0-9_\-]{35}\b/g,
147
+ redact: true,
148
+ },
149
+ {
150
+ type: "jwt",
151
+ severity: "high",
152
+ pattern: /\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b/g,
153
+ redact: true,
154
+ },
155
+ {
156
+ type: "segmented_token",
157
+ severity: "high",
158
+ pattern: /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
159
+ redact: true,
160
+ },
161
+ {
162
+ type: "bearer_token",
163
+ severity: "high",
164
+ pattern: /\bBearer\s+[A-Za-z0-9._~+\/\-]{16,}=*/g,
165
+ redact: true,
166
+ },
167
+ {
168
+ type: "pem_private_key",
169
+ severity: "high",
170
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
171
+ redact: true,
172
+ },
173
+ // --- Generic assignment / key styles (keep key name, scrub value) ---
174
+ // Quoted keys and whitespace around the separator are allowed so JSON/YAML/
175
+ // config forms ({"api_key": "..."}, password = ...) are caught too.
29
176
  {
30
177
  type: "api_key",
31
178
  severity: "high",
32
- pattern: /(?:sk-[A-Za-z0-9_\-]{20,}|(?:api[_-]?key|token|secret)[=:\s]["']?[A-Za-z0-9_\-]{20,})/gi,
179
+ // (?<![A-Za-z0-9]) keeps the "sk" tail of hyphenated slugs (task-assignment,
180
+ // risk-gated) from matching as an sk- key; real keys start their own token.
181
+ pattern: /(?:(?<![A-Za-z0-9])sk-[A-Za-z0-9_\-]{20,}|["']?(?:api[_-]?key|token|secret)["']?\s*[=:]\s*["']?[A-Za-z0-9_\-]{20,})/gi,
182
+ redact: true,
183
+ keepPrefix: /^["']?(?:api[_-]?key|token|secret)["']?\s*[=:]\s*["']?/i,
33
184
  },
34
185
  {
35
186
  type: "password",
36
187
  severity: "high",
37
- pattern: /(?:password|passwd|pwd)[=:\s]["']?[^\s"']{8,}/gi,
188
+ pattern: /["']?(?:password|passwd|pwd)["']?\s*[=:]\s*["']?[^\s"',;]{8,}/gi,
189
+ redact: true,
190
+ keepPrefix: /^["']?(?:password|passwd|pwd)["']?\s*[=:]\s*["']?/i,
38
191
  },
192
+ // --- High-risk personal identifiers ---
193
+ // Broad numeric patterns scan-warn on shape but only REDACT when the
194
+ // checksum verifies — otherwise snowflake IDs / order numbers get rewritten.
39
195
  {
40
196
  type: "id_number",
41
197
  severity: "high",
42
- pattern: /\d{17}[\dXx]/g,
198
+ pattern: /(?<!\d)\d{17}[\dXx](?!\d)/g,
199
+ redact: true,
200
+ validate: isValidChineseId,
43
201
  },
44
202
  {
45
203
  type: "credit_card",
46
204
  severity: "high",
47
- pattern: /\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}/g,
205
+ pattern: /(?<!\d)\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}(?![\d])/g,
206
+ redact: true,
207
+ validate: isValidLuhn,
48
208
  },
209
+ // --- Detection-only: legitimate memory content, warn but keep ---
49
210
  {
50
211
  type: "phone",
51
212
  severity: "medium",
52
213
  pattern: /1[3-9]\d{9}/g,
214
+ redact: false,
53
215
  },
54
216
  {
55
217
  type: "email",
56
218
  severity: "low",
57
219
  pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
220
+ redact: false,
58
221
  },
59
222
  ];
60
223
 
@@ -96,3 +259,42 @@ export function scanForPII(text: string): PIIScanResult {
96
259
  summary,
97
260
  };
98
261
  }
262
+
263
+ /**
264
+ * Deterministically scrub redactable secrets. Pure regex, no LLM call.
265
+ * Replacement is `[REDACTED:<type>]`; assignment-style rules keep the key
266
+ * name (`password=[REDACTED:password]`) so the memory stays readable.
267
+ */
268
+ export function redactSecrets(text: string): RedactResult {
269
+ let out = text;
270
+ let redacted = 0;
271
+
272
+ for (const rule of PII_RULES) {
273
+ if (!rule.redact) continue;
274
+ rule.pattern.lastIndex = 0;
275
+ out = out.replace(rule.pattern, (match) => {
276
+ if (rule.validate && !rule.validate(match)) {
277
+ return match; // checksum failed → leave intact (detection-only)
278
+ }
279
+ redacted++;
280
+ if (rule.replacement) return rule.replacement(match);
281
+ if (rule.keepPrefix) {
282
+ const prefixMatch = match.match(rule.keepPrefix);
283
+ if (prefixMatch) {
284
+ return `${prefixMatch[0]}[REDACTED:${rule.type}]`;
285
+ }
286
+ }
287
+ return `[REDACTED:${rule.type}]`;
288
+ });
289
+ }
290
+
291
+ return { text: out, redacted };
292
+ }
293
+
294
+ /**
295
+ * Redact + truncate for log lines. Truncation happens AFTER redaction so a
296
+ * secret can never straddle the cut and leak its prefix.
297
+ */
298
+ export function redactForLog(text: string, max = 60): string {
299
+ return redactSecrets(text).text.slice(0, max);
300
+ }