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.
- package/CHANGELOG.md +37 -0
- package/README.md +34 -9
- package/README_CN.md +37 -12
- package/package.json +6 -5
- package/src/__tests__/access-tracker-novelty.test.ts +66 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/capture-engine.test.ts +93 -3
- package/src/__tests__/category-min-scores.test.ts +83 -0
- package/src/__tests__/cluster-consolidation.test.ts +167 -4
- package/src/__tests__/conflict-store.test.ts +46 -2
- package/src/__tests__/consolidation-engine.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +46 -0
- package/src/__tests__/decay-exemption.test.ts +45 -0
- package/src/__tests__/dream-pipeline.test.ts +464 -5
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/hotness-blend.test.ts +207 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/maintenance-locks.test.ts +15 -0
- package/src/__tests__/memory-boundaries.test.ts +32 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/pii-redact.test.ts +19 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/preference-matcher.test.ts +54 -2
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/skill-promotion.test.ts +53 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-upsert.test.ts +17 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/access-tracker.ts +53 -3
- package/src/adaptive-retrieval.ts +11 -0
- package/src/api-server.ts +9 -2
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +88 -11
- package/src/cli.ts +109 -14
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +146 -12
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/data-checkup.ts +1 -1
- package/src/decay-engine.ts +59 -7
- package/src/doctor.ts +4 -2
- package/src/dream-pipeline.ts +359 -11
- package/src/env-config.ts +41 -0
- package/src/eval.ts +154 -11
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/ingest.ts +277 -2
- package/src/kg-extractor.ts +7 -1
- package/src/llm-client.ts +106 -11
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +13 -0
- package/src/mcp-tools-core.ts +5 -1
- package/src/memory-boundaries.ts +23 -2
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-output.ts +16 -2
- package/src/memory-store-port.ts +3 -0
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +52 -2
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/recall-gate.ts +305 -0
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +95 -18
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +70 -5
- package/src/skill-promotion.ts +58 -2
- package/src/store.ts +54 -19
- package/src/term-registry.ts +35 -0
- package/src/ui-server.ts +7 -0
- package/src/version.ts +19 -0
package/src/memory-store-port.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/pii-detector.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|