recallnest 2.5.4 → 2.6.1

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 (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +34 -9
  3. package/README_CN.md +37 -12
  4. package/package.json +6 -5
  5. package/src/__tests__/access-tracker-novelty.test.ts +66 -0
  6. package/src/__tests__/api-server-security.test.ts +102 -0
  7. package/src/__tests__/belief-history.test.ts +220 -0
  8. package/src/__tests__/capture-engine.test.ts +93 -3
  9. package/src/__tests__/category-min-scores.test.ts +83 -0
  10. package/src/__tests__/cluster-consolidation.test.ts +167 -4
  11. package/src/__tests__/conflict-store.test.ts +46 -2
  12. package/src/__tests__/consolidation-engine.test.ts +97 -0
  13. package/src/__tests__/contracts.test.ts +46 -0
  14. package/src/__tests__/decay-exemption.test.ts +45 -0
  15. package/src/__tests__/dream-pipeline.test.ts +464 -5
  16. package/src/__tests__/freshness.test.ts +186 -0
  17. package/src/__tests__/hotness-blend.test.ts +207 -0
  18. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  19. package/src/__tests__/maintenance-locks.test.ts +15 -0
  20. package/src/__tests__/memory-boundaries.test.ts +32 -0
  21. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  22. package/src/__tests__/pii-redact.test.ts +19 -0
  23. package/src/__tests__/pivot-apply.test.ts +373 -0
  24. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  25. package/src/__tests__/pivot-distill.test.ts +1178 -0
  26. package/src/__tests__/preference-matcher.test.ts +54 -2
  27. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  28. package/src/__tests__/recall-gate.test.ts +266 -0
  29. package/src/__tests__/resolve-db-path.test.ts +130 -0
  30. package/src/__tests__/resolve-tier.test.ts +64 -0
  31. package/src/__tests__/retrieval-eval.test.ts +163 -0
  32. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  33. package/src/__tests__/skill-promotion.test.ts +53 -0
  34. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  35. package/src/__tests__/store-read-consistency.test.ts +122 -0
  36. package/src/__tests__/store-schema-migration.test.ts +87 -0
  37. package/src/__tests__/store-upsert.test.ts +17 -0
  38. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  39. package/src/__tests__/ui-server-security.test.ts +93 -0
  40. package/src/access-tracker.ts +53 -3
  41. package/src/adaptive-retrieval.ts +11 -0
  42. package/src/api-server.ts +9 -2
  43. package/src/auto-recall.ts +61 -1
  44. package/src/belief-history.ts +164 -0
  45. package/src/capture-engine.ts +88 -11
  46. package/src/cli.ts +109 -14
  47. package/src/conflict-engine.ts +24 -3
  48. package/src/consolidation-engine.ts +146 -12
  49. package/src/context-composer-stable-selection.ts +7 -1
  50. package/src/data-checkup.ts +1 -1
  51. package/src/decay-engine.ts +59 -7
  52. package/src/doctor.ts +4 -2
  53. package/src/dream-pipeline.ts +359 -11
  54. package/src/env-config.ts +41 -0
  55. package/src/eval.ts +154 -11
  56. package/src/freshness.ts +192 -0
  57. package/src/http-request-policy.ts +148 -0
  58. package/src/ingest.ts +277 -2
  59. package/src/kg-extractor.ts +7 -1
  60. package/src/llm-client.ts +106 -11
  61. package/src/mcp-server.ts +2 -1
  62. package/src/mcp-tools-advanced.ts +13 -0
  63. package/src/mcp-tools-core.ts +5 -1
  64. package/src/memory-boundaries.ts +23 -2
  65. package/src/memory-health-rebalance.ts +4 -0
  66. package/src/memory-output.ts +16 -2
  67. package/src/memory-store-port.ts +3 -0
  68. package/src/noise-prototype-bank.ts +408 -0
  69. package/src/pii-detector.ts +52 -2
  70. package/src/pivot-apply.ts +399 -0
  71. package/src/preference-matcher.ts +37 -3
  72. package/src/recall-gate.ts +305 -0
  73. package/src/retrieval-profiles.ts +46 -4
  74. package/src/retriever.ts +95 -18
  75. package/src/retroactive-boost.ts +6 -2
  76. package/src/runtime-config.ts +70 -5
  77. package/src/skill-promotion.ts +58 -2
  78. package/src/store.ts +54 -19
  79. package/src/term-registry.ts +35 -0
  80. package/src/ui-server.ts +7 -0
  81. package/src/version.ts +19 -0
@@ -20,6 +20,9 @@ export type MemoryStoreUpdate = {
20
20
  export interface MemoryStorePort {
21
21
  stats(scopeFilter?: string[]): Promise<MemoryStoreStats>;
22
22
  list(scopeFilter?: string[], category?: string, limit?: number, offset?: number): Promise<MemoryEntry[]>;
23
+ /** 按 id 批量取真实向量。list()/listPage() 为性能恒返回 vector:[](假空数组),
24
+ * 任何要做相似度/聚类的消费者必须用这个回填——promote_scan 与 dream 3b 都栽过同一坑。 */
25
+ getVectors(ids: string[]): Promise<Map<string, number[]>>;
23
26
  /** DB 层真分页(where/limit/offset 下推),全库维护扫描用;list() 是全量拉取再切片。 */
24
27
  listPage(opts?: {
25
28
  scopeFilter?: string[];
@@ -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
+ }
@@ -41,6 +41,9 @@ interface PIIRule {
41
41
  redact: boolean;
42
42
  /** For key=value style rules: keep the key name, scrub only the value */
43
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;
44
47
  /** Checksum gate for broad numeric patterns: redact ONLY when this returns
45
48
  * true (scan still warns regardless). Keeps snowflake IDs / order numbers
46
49
  * from being destructively rewritten. */
@@ -74,11 +77,49 @@ function isValidLuhn(candidate: string): boolean {
74
77
  }
75
78
 
76
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
+ },
77
115
  // --- Vendor-prefixed token literals (high confidence, full scrub) ---
78
116
  {
79
117
  type: "anthropic_key",
80
118
  severity: "high",
81
- pattern: /sk-ant-[A-Za-z0-9_\-]{10,}/g,
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,
82
123
  redact: true,
83
124
  },
84
125
  {
@@ -111,6 +152,12 @@ const PII_RULES: PIIRule[] = [
111
152
  pattern: /\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b/g,
112
153
  redact: true,
113
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
+ },
114
161
  {
115
162
  type: "bearer_token",
116
163
  severity: "high",
@@ -129,7 +176,9 @@ const PII_RULES: PIIRule[] = [
129
176
  {
130
177
  type: "api_key",
131
178
  severity: "high",
132
- pattern: /(?:sk-[A-Za-z0-9_\-]{20,}|["']?(?:api[_-]?key|token|secret)["']?\s*[=:]\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,
133
182
  redact: true,
134
183
  keepPrefix: /^["']?(?:api[_-]?key|token|secret)["']?\s*[=:]\s*["']?/i,
135
184
  },
@@ -228,6 +277,7 @@ export function redactSecrets(text: string): RedactResult {
228
277
  return match; // checksum failed → leave intact (detection-only)
229
278
  }
230
279
  redacted++;
280
+ if (rule.replacement) return rule.replacement(match);
231
281
  if (rule.keepPrefix) {
232
282
  const prefixMatch = match.match(rule.keepPrefix);
233
283
  if (prefixMatch) {