@remnic/core 9.3.725 → 9.3.726
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/dist/access-boundary.js +26 -25
- package/dist/access-cli.js +64 -62
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.js +33 -32
- package/dist/access-mcp.js +30 -29
- package/dist/access-operations-batch.js +27 -26
- package/dist/access-operations.js +29 -28
- package/dist/access-schema.js +3 -3
- package/dist/access-service.js +25 -24
- package/dist/active-recall.js +1 -1
- package/dist/adapters/index.js +4 -4
- package/dist/adapters/registry.js +2 -2
- package/dist/briefing.js +6 -5
- package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-GWVG7LGC.js} +2 -2
- package/dist/causal-consolidation.js +7 -6
- package/dist/causal-consolidation.js.map +1 -1
- package/dist/{chunk-C6BWLYW5.js → chunk-2NBP25BG.js} +7 -7
- package/dist/{chunk-YUYPWJD3.js → chunk-3MS3QVX6.js} +2 -2
- package/dist/{chunk-FVZ2ISIX.js → chunk-3TUT4PWB.js} +2 -2
- package/dist/{chunk-HCVWF657.js → chunk-53EEQYE5.js} +7 -7
- package/dist/{chunk-TAPSNIMA.js → chunk-5CEJH5ZN.js} +4 -4
- package/dist/{chunk-D7EBTQ24.js → chunk-5NWYQUQC.js} +2 -2
- package/dist/{chunk-NT36GU6X.js → chunk-5OOA7SGX.js} +2 -2
- package/dist/{chunk-KQAFEZQX.js → chunk-5W7CTZ2E.js} +4 -4
- package/dist/{chunk-QR67FU5G.js → chunk-6AFVIOVZ.js} +2 -2
- package/dist/{chunk-UBHVAGO7.js → chunk-6WAJQKBS.js} +2 -2
- package/dist/{chunk-6CWGS2LY.js → chunk-7NS7AWMZ.js} +3 -3
- package/dist/{chunk-T55EGZSJ.js → chunk-BB57OZWC.js} +2 -2
- package/dist/{chunk-YQMPUYHE.js → chunk-BBIE7W52.js} +2 -2
- package/dist/{chunk-WAFDHKES.js → chunk-BEUCACTV.js} +2 -2
- package/dist/{chunk-4SF232E6.js → chunk-BIA7ELIB.js} +17 -5
- package/dist/chunk-BIA7ELIB.js.map +1 -0
- package/dist/{chunk-GZQXOQQ2.js → chunk-DCKNMLEQ.js} +4 -4
- package/dist/{chunk-ZUDM75KG.js → chunk-DCWIQFNA.js} +4 -4
- package/dist/{chunk-VI5SGGXB.js → chunk-DQXPJ6HD.js} +4 -4
- package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
- package/dist/{chunk-Z3FGC3QE.js → chunk-EZNDNMRH.js} +139 -30
- package/dist/chunk-EZNDNMRH.js.map +1 -0
- package/dist/{chunk-FACTVMXQ.js → chunk-FO3HO52S.js} +2 -2
- package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
- package/dist/{chunk-JJJCVCCQ.js → chunk-JPUT5NGH.js} +2 -2
- package/dist/{chunk-5OPYYPJN.js → chunk-K4ZNARDO.js} +2 -2
- package/dist/{chunk-4WZ3J7M6.js → chunk-KJR66XOM.js} +2 -2
- package/dist/{chunk-LD53WPMU.js → chunk-LQ6JI4VH.js} +4 -4
- package/dist/chunk-LTHVM4XN.js +109 -0
- package/dist/chunk-LTHVM4XN.js.map +1 -0
- package/dist/{chunk-4R3CGCI5.js → chunk-MQVKHFIU.js} +16 -13
- package/dist/chunk-MQVKHFIU.js.map +1 -0
- package/dist/{chunk-FXCIOLRB.js → chunk-NPL7VDM6.js} +2 -2
- package/dist/{chunk-KLFYKC3K.js → chunk-OAHOL3ZC.js} +112 -72
- package/dist/chunk-OAHOL3ZC.js.map +1 -0
- package/dist/{chunk-2NLLXCJG.js → chunk-OWHERGF2.js} +2 -2
- package/dist/{chunk-I2HL5M6T.js → chunk-PLG6JHQS.js} +42 -42
- package/dist/{chunk-KGZF2VO3.js → chunk-Q6D75LVH.js} +15 -15
- package/dist/{chunk-SC2YAWRF.js → chunk-RRHHQYJS.js} +2 -2
- package/dist/{chunk-J334N6QV.js → chunk-TA3Y6GEX.js} +2 -2
- package/dist/{chunk-FN6QP7JQ.js → chunk-TQQH2FTX.js} +4 -4
- package/dist/{chunk-DF3I6JGP.js → chunk-UKTDXTWX.js} +15 -15
- package/dist/{chunk-ARV3AUOM.js → chunk-UNNDFYUX.js} +5 -5
- package/dist/{chunk-ZK2L72VN.js → chunk-UQSVUEN2.js} +2 -2
- package/dist/{chunk-X7Y7WX73.js → chunk-UVYI6VIX.js} +1 -1
- package/dist/{chunk-H7HUQIY7.js → chunk-V7CMKMHM.js} +3 -3
- package/dist/chunk-W5H6ZRPT.js +14 -0
- package/dist/chunk-W5H6ZRPT.js.map +1 -0
- package/dist/{chunk-TT5U3UDO.js → chunk-WGTWGDR5.js} +5 -5
- package/dist/{chunk-YQGHW5ML.js → chunk-WRFKZEO6.js} +4 -4
- package/dist/{chunk-WM7DNKBD.js → chunk-YTZ3VGC7.js} +2 -2
- package/dist/cli.js +54 -53
- package/dist/compounding/engine.js +6 -5
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.js +6 -5
- package/dist/connectors/index.js +6 -5
- package/dist/content-hash.d.ts +14 -0
- package/dist/content-hash.js +10 -0
- package/dist/content-hash.js.map +1 -0
- package/dist/conversation-index/backend.js +2 -2
- package/dist/dashboard-runtime.js +2 -2
- package/dist/entity-retrieval.js +6 -5
- package/dist/extraction-redaction-rules.d.ts +33 -0
- package/dist/extraction-redaction-rules.js +14 -0
- package/dist/extraction-redaction-rules.js.map +1 -0
- package/dist/extraction.js +2 -2
- package/dist/{forget-OEE5OELK.js → forget-WYLR4ZFV.js} +2 -2
- package/dist/index.js +102 -100
- package/dist/index.js.map +1 -1
- package/dist/lcm/index.js +3 -3
- package/dist/maintenance/memory-governance.js +6 -5
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +6 -5
- package/dist/maintenance/rebuild-memory-projection.js +7 -6
- package/dist/namespaces/migrate.js +16 -15
- package/dist/namespaces/search.js +9 -9
- package/dist/namespaces/storage.js +6 -5
- package/dist/operator-toolkit.js +24 -23
- package/dist/orchestration/maintenance.js +8 -7
- package/dist/orchestrator.js +48 -46
- package/dist/resume-bundles.js +2 -2
- package/dist/search/factory.js +8 -8
- package/dist/search/index.js +12 -12
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/semantic-consolidation.js +7 -6
- package/dist/semantic-rule-promotion.js +6 -5
- package/dist/semantic-rule-verifier.js +6 -5
- package/dist/storage.d.ts +2 -2
- package/dist/storage.js +6 -5
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/verified-recall.js +6 -5
- package/package.json +2 -2
- package/src/config.test.ts +50 -0
- package/src/config.ts +21 -4
- package/src/content-hash.ts +24 -0
- package/src/correction/correction-access-wiring.ts +104 -19
- package/src/correction/correction-contract.ts +68 -10
- package/src/correction/correction-executor.test.ts +112 -2
- package/src/correction/correction-executor.ts +65 -5
- package/src/correction/correction-planner.test.ts +47 -0
- package/src/correction/correction-planner.ts +40 -1
- package/src/correction/correction-storage-integration.test.ts +183 -0
- package/src/extraction-redaction-rules.test.ts +213 -0
- package/src/extraction-redaction-rules.ts +208 -0
- package/src/orchestrator.ts +52 -1
- package/src/storage.ts +6 -10
- package/dist/chunk-4R3CGCI5.js.map +0 -1
- package/dist/chunk-4SF232E6.js.map +0 -1
- package/dist/chunk-KLFYKC3K.js.map +0 -1
- package/dist/chunk-Z3FGC3QE.js.map +0 -1
- /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
- /package/dist/{chunk-C6BWLYW5.js.map → chunk-2NBP25BG.js.map} +0 -0
- /package/dist/{chunk-YUYPWJD3.js.map → chunk-3MS3QVX6.js.map} +0 -0
- /package/dist/{chunk-FVZ2ISIX.js.map → chunk-3TUT4PWB.js.map} +0 -0
- /package/dist/{chunk-HCVWF657.js.map → chunk-53EEQYE5.js.map} +0 -0
- /package/dist/{chunk-TAPSNIMA.js.map → chunk-5CEJH5ZN.js.map} +0 -0
- /package/dist/{chunk-D7EBTQ24.js.map → chunk-5NWYQUQC.js.map} +0 -0
- /package/dist/{chunk-NT36GU6X.js.map → chunk-5OOA7SGX.js.map} +0 -0
- /package/dist/{chunk-KQAFEZQX.js.map → chunk-5W7CTZ2E.js.map} +0 -0
- /package/dist/{chunk-QR67FU5G.js.map → chunk-6AFVIOVZ.js.map} +0 -0
- /package/dist/{chunk-UBHVAGO7.js.map → chunk-6WAJQKBS.js.map} +0 -0
- /package/dist/{chunk-6CWGS2LY.js.map → chunk-7NS7AWMZ.js.map} +0 -0
- /package/dist/{chunk-T55EGZSJ.js.map → chunk-BB57OZWC.js.map} +0 -0
- /package/dist/{chunk-YQMPUYHE.js.map → chunk-BBIE7W52.js.map} +0 -0
- /package/dist/{chunk-WAFDHKES.js.map → chunk-BEUCACTV.js.map} +0 -0
- /package/dist/{chunk-GZQXOQQ2.js.map → chunk-DCKNMLEQ.js.map} +0 -0
- /package/dist/{chunk-ZUDM75KG.js.map → chunk-DCWIQFNA.js.map} +0 -0
- /package/dist/{chunk-VI5SGGXB.js.map → chunk-DQXPJ6HD.js.map} +0 -0
- /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
- /package/dist/{chunk-FACTVMXQ.js.map → chunk-FO3HO52S.js.map} +0 -0
- /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
- /package/dist/{chunk-JJJCVCCQ.js.map → chunk-JPUT5NGH.js.map} +0 -0
- /package/dist/{chunk-5OPYYPJN.js.map → chunk-K4ZNARDO.js.map} +0 -0
- /package/dist/{chunk-4WZ3J7M6.js.map → chunk-KJR66XOM.js.map} +0 -0
- /package/dist/{chunk-LD53WPMU.js.map → chunk-LQ6JI4VH.js.map} +0 -0
- /package/dist/{chunk-FXCIOLRB.js.map → chunk-NPL7VDM6.js.map} +0 -0
- /package/dist/{chunk-2NLLXCJG.js.map → chunk-OWHERGF2.js.map} +0 -0
- /package/dist/{chunk-I2HL5M6T.js.map → chunk-PLG6JHQS.js.map} +0 -0
- /package/dist/{chunk-KGZF2VO3.js.map → chunk-Q6D75LVH.js.map} +0 -0
- /package/dist/{chunk-SC2YAWRF.js.map → chunk-RRHHQYJS.js.map} +0 -0
- /package/dist/{chunk-J334N6QV.js.map → chunk-TA3Y6GEX.js.map} +0 -0
- /package/dist/{chunk-FN6QP7JQ.js.map → chunk-TQQH2FTX.js.map} +0 -0
- /package/dist/{chunk-DF3I6JGP.js.map → chunk-UKTDXTWX.js.map} +0 -0
- /package/dist/{chunk-ARV3AUOM.js.map → chunk-UNNDFYUX.js.map} +0 -0
- /package/dist/{chunk-ZK2L72VN.js.map → chunk-UQSVUEN2.js.map} +0 -0
- /package/dist/{chunk-X7Y7WX73.js.map → chunk-UVYI6VIX.js.map} +0 -0
- /package/dist/{chunk-H7HUQIY7.js.map → chunk-V7CMKMHM.js.map} +0 -0
- /package/dist/{chunk-TT5U3UDO.js.map → chunk-WGTWGDR5.js.map} +0 -0
- /package/dist/{chunk-YQGHW5ML.js.map → chunk-WRFKZEO6.js.map} +0 -0
- /package/dist/{chunk-WM7DNKBD.js.map → chunk-YTZ3VGC7.js.map} +0 -0
- /package/dist/{forget-OEE5OELK.js.map → forget-WYLR4ZFV.js.map} +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extraction-redaction-rules.test.ts — issue #1669 (#1580 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* Proves the extraction-layer redaction-rule helper reads persisted
|
|
5
|
+
* `redaction_rule` patterns and withholds matching content. The orchestrator
|
|
6
|
+
* consults these rules before the storage write chokepoint so a never_store
|
|
7
|
+
* correction actually blocks future extraction of matching content.
|
|
8
|
+
*/
|
|
9
|
+
import { strict as assert } from "node:assert";
|
|
10
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import {
|
|
15
|
+
REDACTION_RULES_SUBDIR,
|
|
16
|
+
compileRedactionPattern,
|
|
17
|
+
contentMatchesRedactionRules,
|
|
18
|
+
loadRedactionRules,
|
|
19
|
+
} from "./extraction-redaction-rules.js";
|
|
20
|
+
import { validateRedactionPattern } from "./correction/correction-contract.js";
|
|
21
|
+
|
|
22
|
+
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
23
|
+
const dir = await mkdtemp(path.join(tmpdir(), "remnic-redact-"));
|
|
24
|
+
try {
|
|
25
|
+
return await fn(dir);
|
|
26
|
+
} finally {
|
|
27
|
+
await rm(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function writeRule(stateDir: string, pattern: string): Promise<void> {
|
|
32
|
+
const dir = path.join(stateDir, REDACTION_RULES_SUBDIR);
|
|
33
|
+
await mkdir(dir, { recursive: true });
|
|
34
|
+
const slug = pattern.replace(/[^a-zA-Z0-9]+/g, "-").slice(0, 64) || "rule";
|
|
35
|
+
await writeFile(
|
|
36
|
+
path.join(dir, `${slug}.json`),
|
|
37
|
+
`${JSON.stringify({ pattern, namespace: "default", createdAt: "2026-07-06T00:00:00Z" })}\n`,
|
|
38
|
+
"utf-8",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("compileRedactionPattern: literal pattern matches by substring", () => {
|
|
43
|
+
const rule = compileRedactionPattern("secret-token-123");
|
|
44
|
+
assert.ok(rule.matcher("the secret-token-123 leaked here"));
|
|
45
|
+
assert.ok(!rule.matcher("nothing here"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("compileRedactionPattern: regex-like pattern compiles to a RegExp", () => {
|
|
49
|
+
// Only /.../-wrapped patterns compile as RegExp (review thread P1).
|
|
50
|
+
const rule = compileRedactionPattern("/secret-token-\\d+/");
|
|
51
|
+
assert.ok(rule.matcher("found secret-token-999 in logs"));
|
|
52
|
+
assert.ok(!rule.matcher("found secret-token-abc in logs"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("compileRedactionPattern (P1): unwrapped pattern with metacharacters is literal", () => {
|
|
56
|
+
// abc+def must match the LITERAL string, not the regex abccccdef.
|
|
57
|
+
const rule = compileRedactionPattern("abc+def");
|
|
58
|
+
assert.ok(rule.matcher("the value is abc+def here"),
|
|
59
|
+
"unwrapped metachar pattern must match the literal substring");
|
|
60
|
+
assert.ok(!rule.matcher("abccccdef"),
|
|
61
|
+
"unwrapped metachar pattern must NOT be compiled as a regex");
|
|
62
|
+
// An API-token-shaped literal like foo(bar) also works.
|
|
63
|
+
const rule2 = compileRedactionPattern("foo(bar)");
|
|
64
|
+
assert.ok(rule2.matcher("contains foo(bar) text"));
|
|
65
|
+
assert.ok(!rule2.matcher("contains foobar text"));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("compileRedactionPattern: /wrapped/ regex strips delimiters", () => {
|
|
69
|
+
const rule = compileRedactionPattern("/internal-only-[a-z]+/");
|
|
70
|
+
assert.ok(rule.matcher("this is internal-only-foo data"));
|
|
71
|
+
assert.ok(!rule.matcher("this is public data"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("compileRedactionPattern: malformed /wrapped/ regex falls back to literal", () => {
|
|
75
|
+
// /.../-wrapped with an unbalanced bracket — RegExp() throws, falls back
|
|
76
|
+
// to literal substring on the BODY (without delimiters).
|
|
77
|
+
const rule = compileRedactionPattern("/[unclosed/");
|
|
78
|
+
assert.ok(rule.matcher("contains [unclosed literally"));
|
|
79
|
+
assert.ok(!rule.matcher("no match"));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("loadRedactionRules: empty when the dir does not exist (cold install)", async () => {
|
|
83
|
+
await withTempDir(async (dir) => {
|
|
84
|
+
const rules = await loadRedactionRules(dir);
|
|
85
|
+
assert.equal(rules.length, 0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("loadRedactionRules: reads persisted literal + regex patterns", async () => {
|
|
90
|
+
await withTempDir(async (dir) => {
|
|
91
|
+
await writeRule(dir, "secret-token-123");
|
|
92
|
+
await writeRule(dir, "/api-key-[a-f0-9]{8}/");
|
|
93
|
+
const rules = await loadRedactionRules(dir);
|
|
94
|
+
assert.equal(rules.length, 2);
|
|
95
|
+
assert.ok(contentMatchesRedactionRules("leaked secret-token-123 here", rules));
|
|
96
|
+
assert.ok(contentMatchesRedactionRules("api-key-deadbeef was used", rules));
|
|
97
|
+
assert.ok(!contentMatchesRedactionRules("clean content", rules));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("loadRedactionRules: skips a corrupt rule file without failing the pass", async () => {
|
|
102
|
+
await withTempDir(async (dir) => {
|
|
103
|
+
await writeRule(dir, "good-pattern");
|
|
104
|
+
const dir2 = path.join(dir, REDACTION_RULES_SUBDIR);
|
|
105
|
+
// A non-JSON file masquerading as a rule.
|
|
106
|
+
await writeFile(path.join(dir2, "broken.json"), "{not valid json", "utf-8");
|
|
107
|
+
// A rule file missing the `pattern` field.
|
|
108
|
+
await writeFile(path.join(dir2, "nopattern.json"), JSON.stringify({ foo: "bar" }), "utf-8");
|
|
109
|
+
const rules = await loadRedactionRules(dir);
|
|
110
|
+
assert.equal(rules.length, 1, "only the well-formed rule compiles");
|
|
111
|
+
assert.ok(contentMatchesRedactionRules("good-pattern here", rules));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("contentMatchesRedactionRules: empty rule set never matches", () => {
|
|
116
|
+
assert.equal(contentMatchesRedactionRules("anything", []), false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("regression (#1669): a persisted redaction rule blocks matching content", async () => {
|
|
120
|
+
await withTempDir(async (dir) => {
|
|
121
|
+
// Simulate a `redaction_rule` correction that persisted a never-store pattern.
|
|
122
|
+
await writeRule(dir, "do-not-store-this-exact-phrase");
|
|
123
|
+
const rules = await loadRedactionRules(dir);
|
|
124
|
+
// Content matching the persisted pattern is withheld.
|
|
125
|
+
assert.ok(
|
|
126
|
+
contentMatchesRedactionRules("a fact containing do-not-store-this-exact-phrase inside", rules),
|
|
127
|
+
"matching content must be withheld",
|
|
128
|
+
);
|
|
129
|
+
// Non-matching content passes through.
|
|
130
|
+
assert.ok(
|
|
131
|
+
!contentMatchesRedactionRules("a perfectly fine durable fact", rules),
|
|
132
|
+
"non-matching content must pass",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("#1669 thread #3: catastrophic-backtracking regex falls back to literal (ReDoS guard)", () => {
|
|
138
|
+
// A pattern like (a+)+ is a classic ReDoS shape. It must NOT compile as a
|
|
139
|
+
// RegExp — it would hang persistExtraction on a near-miss fact. Instead it
|
|
140
|
+
// falls back to literal substring match.
|
|
141
|
+
const rule = compileRedactionPattern("(a+)+");
|
|
142
|
+
// The literal fallback matches when the exact string "(a+)+" appears:
|
|
143
|
+
assert.equal(rule.matcher("a fact with (a+)+ inside"), true,
|
|
144
|
+
"literal fallback must still match the pattern as a substring");
|
|
145
|
+
// A long near-miss string that would cause exponential backtracking on the
|
|
146
|
+
// regex must return quickly (literal match, no regex evaluation):
|
|
147
|
+
const nearMiss = "a".repeat(100) + "!";
|
|
148
|
+
assert.equal(rule.matcher(nearMiss), false,
|
|
149
|
+
"near-miss must not hang — literal fallback, not regex");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#1669 thread #3: safe regex pattern still compiles as RegExp", () => {
|
|
153
|
+
// A benign anchored pattern compiles normally.
|
|
154
|
+
const rule = compileRedactionPattern("/^secret-key-[a-f0-9]+$/");
|
|
155
|
+
assert.equal(rule.matcher("secret-key-deadbeef"), true);
|
|
156
|
+
assert.equal(rule.matcher("not-a-secret"), false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("#1669 cursor thread: overly broad /wrapped/ regex falls back to literal (not compiled)", () => {
|
|
160
|
+
// A hand-edited rule file with /.*/ must NOT compile — it would match every
|
|
161
|
+
// fact and withhold all extraction. isSafeRegex now rejects overly-broad
|
|
162
|
+
// bodies, so the rule falls back to literal substring on the body.
|
|
163
|
+
const rule = compileRedactionPattern("/.*/");
|
|
164
|
+
assert.ok(!rule.matcher("any fact content"),
|
|
165
|
+
"overly broad regex must not match arbitrary content");
|
|
166
|
+
assert.ok(rule.matcher("contains .* literally"),
|
|
167
|
+
"literal fallback on the body must still match the substring .*");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("#1669 thread #3 (first-line guard): validateRedactionPattern rejects catastrophic-backtracking shapes", () => {
|
|
171
|
+
// Classic ReDoS shapes must be rejected at apply time — not just fall back
|
|
172
|
+
// to literal at extraction time. Each is a nested quantifier or overlapping
|
|
173
|
+
// alternation that would hang on a near-miss fact.
|
|
174
|
+
const pathological = ["/(a+)+/", "/(a*)*/", "/(a?)+/", "/(a|a)+/"];
|
|
175
|
+
for (const p of pathological) {
|
|
176
|
+
assert.throws(
|
|
177
|
+
() => validateRedactionPattern(p),
|
|
178
|
+
{ message: /unsafe/ },
|
|
179
|
+
`validateRedactionPattern must reject catastrophic pattern: ${p}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
// The overly-broad check still fires for /.../-wrapped .* / .+ patterns.
|
|
183
|
+
assert.throws(() => validateRedactionPattern("/.*/"));
|
|
184
|
+
assert.throws(() => validateRedactionPattern("/.+/"));
|
|
185
|
+
// A near-miss string that would cause exponential backtracking on the
|
|
186
|
+
// regex never reaches extraction — the plan is rejected at apply time.
|
|
187
|
+
const nearMiss = "a".repeat(100) + "!";
|
|
188
|
+
assert.ok(nearMiss.length === 101);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("#1669 thread #3 (first-line guard): valid patterns still pass", () => {
|
|
192
|
+
// Bounded literal + benign anchored regex must NOT be rejected.
|
|
193
|
+
assert.equal(validateRedactionPattern("secret-token-\\d+"), "secret-token-\\d+");
|
|
194
|
+
assert.equal(validateRedactionPattern("(a|b)+"), "(a|b)+");
|
|
195
|
+
assert.equal(validateRedactionPattern("/^secret-key-[a-f0-9]+$/"), "/^secret-key-[a-f0-9]+$/");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("#1669 P2: validateRedactionPattern rejects zero-width regex", () => {
|
|
199
|
+
// Empty body or regex that matches the empty string would withhold every fact.
|
|
200
|
+
assert.throws(() => validateRedactionPattern("//"));
|
|
201
|
+
assert.throws(() => validateRedactionPattern("/a?/"));
|
|
202
|
+
assert.throws(() => validateRedactionPattern("/a*/"));
|
|
203
|
+
assert.throws(() => validateRedactionPattern("/(a|)/"));
|
|
204
|
+
// A bounded non-zero-width regex still passes.
|
|
205
|
+
assert.equal(validateRedactionPattern("/^secret-[a-f]+$/"), "/^secret-[a-f]+$/");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("#1669 P2: compileRedactionPattern empty body never matches", () => {
|
|
209
|
+
// A hand-edited // rule file must not withhold all extraction.
|
|
210
|
+
const rule = compileRedactionPattern("//");
|
|
211
|
+
assert.equal(rule.matcher("anything"), false);
|
|
212
|
+
assert.equal(rule.matcher(""), false);
|
|
213
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extraction-redaction-rules.ts — consults persisted correction redaction
|
|
3
|
+
* rules during the extraction→persist path so a `never_store` / redaction_rule
|
|
4
|
+
* correction actually blocks future extraction of matching content (issue
|
|
5
|
+
* #1669, #1580 follow-up).
|
|
6
|
+
*
|
|
7
|
+
* The Correction Contract persists `redaction_rule` patterns to
|
|
8
|
+
* `<storage>/state/corrections/redaction-rules/<slug>.json` (one file per
|
|
9
|
+
* rule, idempotent on pattern). This module reads those patterns at the start
|
|
10
|
+
* of each extraction persist pass and exposes a pure matcher the orchestrator
|
|
11
|
+
* consults BEFORE a fact reaches the storage write chokepoint — mirroring how
|
|
12
|
+
* tombstones are consulted at the write chokepoint (#1579), but one stage
|
|
13
|
+
* earlier so the content never even lands as `pending_review`.
|
|
14
|
+
*
|
|
15
|
+
* Scope (issue #1669): the extraction layer consults the rules. Persistence
|
|
16
|
+
* stays owned by the correction module. This helper is the read surface.
|
|
17
|
+
*
|
|
18
|
+
* Safety: patterns are validated at correction-apply time
|
|
19
|
+
* (`validateRedactionPattern` — bounded literal/safe-regex, no catastrophic
|
|
20
|
+
* shapes). We compile defensively here too: a pattern that fails to compile
|
|
21
|
+
* as a regex is treated as a literal substring, and any matcher error is
|
|
22
|
+
* swallowed (fail-open to "no match") so a malformed rule can never block the
|
|
23
|
+
* entire extraction pipeline.
|
|
24
|
+
*/
|
|
25
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Directory (relative to a namespace storage dir) where redaction rules live.
|
|
30
|
+
* Kept in sync with `registerRedactionRuleFn` in correction-access-wiring.ts.
|
|
31
|
+
*/
|
|
32
|
+
export const REDACTION_RULES_SUBDIR = path.join("state", "corrections", "redaction-rules");
|
|
33
|
+
|
|
34
|
+
/** Maximum number of rule files read per pass (defense in depth). */
|
|
35
|
+
const MAX_RULE_FILES = 256;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A compiled redaction rule. `pattern` is the original (validated) pattern;
|
|
39
|
+
* `matcher` returns true when content should be withheld.
|
|
40
|
+
*/
|
|
41
|
+
export interface CompiledRedactionRule {
|
|
42
|
+
pattern: string;
|
|
43
|
+
matcher: (content: string) => boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Shape persisted by `registerRedactionRuleFn`. */
|
|
47
|
+
interface RedactionRuleFile {
|
|
48
|
+
pattern: string;
|
|
49
|
+
namespace?: string;
|
|
50
|
+
createdAt?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Only `/…/`-wrapped patterns compile as RegExp; everything else is a literal
|
|
55
|
+
* substring (review thread P1). An unwrapped pattern like `abc+def` must match
|
|
56
|
+
* the literal string `abc+def`, NOT the regex `abccccdef`.
|
|
57
|
+
*/
|
|
58
|
+
function isRegexLike(pattern: string): boolean {
|
|
59
|
+
return pattern.startsWith("/") && pattern.endsWith("/") && pattern.length >= 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reject patterns prone to catastrophic backtracking (ReDoS, review thread #3).
|
|
64
|
+
* Mirrors the safe-regex heuristic: a quantifier inside a group that is itself
|
|
65
|
+
* quantified creates exponential blowup on near-miss inputs. Also rejects
|
|
66
|
+
* patterns with overlapping alternation under repetition
|
|
67
|
+
* (e.g. (a|a)*). Returns true when the pattern is safe to compile.
|
|
68
|
+
*
|
|
69
|
+
* This is a second line of defense — validateRedactionPattern runs first at
|
|
70
|
+
* apply time, but a hand-edited rule file or an edge case can still slip past.
|
|
71
|
+
*/
|
|
72
|
+
function isSafeRegex(source: string): boolean {
|
|
73
|
+
if (source.length === 0 || source.length > 512) return false;
|
|
74
|
+
// Reject overly-broad patterns (.*) that would match every fact and
|
|
75
|
+
// withhold all extraction (cursor Bugbot thread — mirrors
|
|
76
|
+
// isUnsafeRedactionRegex in correction-contract.ts).
|
|
77
|
+
if (/(?:^|[^\\])\(\.\*\)|(?:^|[^\\])\.\*|(?:^|[^\\])\.\+|^\.([^*+]?)$/.test(source)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// group body itself ends with a quantifier. This catches (a+)+, (a*)*, etc.
|
|
81
|
+
// Also catches overlapping alternation like (a|a)+.
|
|
82
|
+
for (let i = 0; i < source.length; i++) {
|
|
83
|
+
if (source[i] !== "(") continue;
|
|
84
|
+
// Find the matching close paren
|
|
85
|
+
let depth = 1;
|
|
86
|
+
let j = i + 1;
|
|
87
|
+
while (j < source.length && depth > 0) {
|
|
88
|
+
if (source[j] === "\\") { j += 2; continue; }
|
|
89
|
+
if (source[j] === "(") depth++;
|
|
90
|
+
else if (source[j] === ")") depth--;
|
|
91
|
+
j++;
|
|
92
|
+
}
|
|
93
|
+
if (depth !== 0) continue; // unbalanced — will fail RegExp compile anyway
|
|
94
|
+
// Check if the char after the closing paren is a quantifier
|
|
95
|
+
const afterGroup = source[j];
|
|
96
|
+
if (afterGroup !== "+" && afterGroup !== "*" && afterGroup !== "{") continue;
|
|
97
|
+
const groupBody = source.slice(i + 1, j - 1);
|
|
98
|
+
// The group body ends with a quantifier on a non-escape char
|
|
99
|
+
if (/[+*?]$/.test(groupBody) || /\{\d+,?\d*\}[+*?]?$/.test(groupBody)) {
|
|
100
|
+
// Nested quantifier → potential ReDoS
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
// Overlapping alternation: (a|a)+ where branches share a common prefix
|
|
104
|
+
if (groupBody.includes("|")) {
|
|
105
|
+
const branches = groupBody.split("|");
|
|
106
|
+
if (branches.length >= 2) {
|
|
107
|
+
const firstChars = new Set(branches.map((b) => b[0]).filter(Boolean));
|
|
108
|
+
if (firstChars.size < branches.filter((b) => b.length > 0).length) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
i = j;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compile a single pattern into a matcher. A literal pattern matches by
|
|
120
|
+
* case-sensitive substring; a `/…/`-wrapped pattern compiles into a RegExp
|
|
121
|
+
* anchored to search (global flag off — we only need a boolean). Compilation
|
|
122
|
+
* failures fall back to literal substring so a bad pattern never throws here.
|
|
123
|
+
*/
|
|
124
|
+
export function compileRedactionPattern(pattern: string): CompiledRedactionRule {
|
|
125
|
+
const trimmed = pattern.trim();
|
|
126
|
+
if (!isRegexLike(trimmed)) {
|
|
127
|
+
return { pattern: trimmed, matcher: (content) => content.includes(trimmed) };
|
|
128
|
+
}
|
|
129
|
+
const body = trimmed.slice(1, -1);
|
|
130
|
+
try {
|
|
131
|
+
if (!isSafeRegex(body)) {
|
|
132
|
+
if (body.length === 0) return { pattern: trimmed, matcher: () => false };
|
|
133
|
+
return { pattern: trimmed, matcher: (content) => content.includes(body) };
|
|
134
|
+
}
|
|
135
|
+
const re = new RegExp(body);
|
|
136
|
+
if (re.test("")) {
|
|
137
|
+
// Zero-width regex matches every string — would withhold all extraction.
|
|
138
|
+
return { pattern: trimmed, matcher: (content) => content.includes(body) };
|
|
139
|
+
}
|
|
140
|
+
return { pattern: trimmed, matcher: (content) => re.test(content) };
|
|
141
|
+
} catch {
|
|
142
|
+
// Malformed regex despite validation (e.g. rule file hand-edited) —
|
|
143
|
+
// treat as a literal on the BODY so the rule still does something useful.
|
|
144
|
+
return { pattern: trimmed, matcher: (content) => content.includes(body) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Load + compile every redaction rule persisted under a namespace's storage
|
|
150
|
+
* dir. Returns an empty array when the directory is absent or unreadable
|
|
151
|
+
* (fail-open — a missing or corrupt rules dir must never block extraction).
|
|
152
|
+
*/
|
|
153
|
+
export async function loadRedactionRules(stateDir: string): Promise<CompiledRedactionRule[]> {
|
|
154
|
+
const dir = path.join(stateDir, REDACTION_RULES_SUBDIR);
|
|
155
|
+
let names: string[];
|
|
156
|
+
try {
|
|
157
|
+
names = await readdir(dir);
|
|
158
|
+
} catch {
|
|
159
|
+
// ENOENT (cold install, no rules yet) or permission error — no rules.
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
// Sort deterministically (alphabetical by filename) so the set of rules
|
|
163
|
+
// that survives the MAX_RULE_FILES cap is NOT filesystem-dependent (review
|
|
164
|
+
// thread P2). Without this, readdir order varies by OS/FS and the silently
|
|
165
|
+
// truncated rules are arbitrary — a never-store pattern could be dropped.
|
|
166
|
+
const jsonFiles = names.filter((n) => n.endsWith(".json")).sort();
|
|
167
|
+
if (jsonFiles.length > MAX_RULE_FILES) {
|
|
168
|
+
// Visible failure mode: log a warning so the operator knows rules were
|
|
169
|
+
// truncated, rather than silently dropping enforcement.
|
|
170
|
+
console.warn(
|
|
171
|
+
`extraction-redaction: ${jsonFiles.length} redaction rules in ${dir} exceed the ${MAX_RULE_FILES} cap; ` +
|
|
172
|
+
`${jsonFiles.length - MAX_RULE_FILES} rules will NOT be enforced. Remove unused rules or raise the cap.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const rules: CompiledRedactionRule[] = [];
|
|
176
|
+
for (const name of jsonFiles.slice(0, MAX_RULE_FILES)) {
|
|
177
|
+
try {
|
|
178
|
+
const raw = await readFile(path.join(dir, name), "utf-8");
|
|
179
|
+
const parsed = JSON.parse(raw) as RedactionRuleFile;
|
|
180
|
+
if (typeof parsed?.pattern !== "string" || parsed.pattern.trim().length === 0) continue;
|
|
181
|
+
rules.push(compileRedactionPattern(parsed.pattern));
|
|
182
|
+
} catch {
|
|
183
|
+
// Skip a corrupt rule file rather than failing the pass (rule 34 —
|
|
184
|
+
// visible skip, never a silent pipeline block).
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return rules;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Test content against a set of compiled rules. Returns true if ANY rule
|
|
192
|
+
* matches (the content should be withheld). An empty rule set never matches.
|
|
193
|
+
*/
|
|
194
|
+
export function contentMatchesRedactionRules(
|
|
195
|
+
content: string,
|
|
196
|
+
rules: readonly CompiledRedactionRule[],
|
|
197
|
+
): boolean {
|
|
198
|
+
for (const rule of rules) {
|
|
199
|
+
try {
|
|
200
|
+
if (rule.matcher(content)) return true;
|
|
201
|
+
} catch {
|
|
202
|
+
// A matcher should not throw (compilation already fell back), but if a
|
|
203
|
+
// RegExp blows up on pathological input we skip that rule rather than
|
|
204
|
+
// failing the whole gate.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -44,6 +44,11 @@ import {
|
|
|
44
44
|
createFaithfulnessCounters,
|
|
45
45
|
runFaithfulnessGateBatch,
|
|
46
46
|
} from "./extraction-faithfulness.js";
|
|
47
|
+
import {
|
|
48
|
+
contentMatchesRedactionRules,
|
|
49
|
+
loadRedactionRules,
|
|
50
|
+
type CompiledRedactionRule,
|
|
51
|
+
} from "./extraction-redaction-rules.js";
|
|
47
52
|
import type { FaithfulnessGateCounters } from "./extraction-faithfulness.js";
|
|
48
53
|
import {
|
|
49
54
|
EXTRACTION_JUDGE_VERDICT_CATEGORY,
|
|
@@ -14119,6 +14124,23 @@ export class Orchestrator {
|
|
|
14119
14124
|
// (declared here, inside persistExtraction) so a transient hiccup in one
|
|
14120
14125
|
// batch does not permanently disable dedup in future batches.
|
|
14121
14126
|
let batchBackendUnavailable = false;
|
|
14127
|
+
// #1669: per-namespace redaction-rule cache for this persist pass. A
|
|
14128
|
+
// `never_store` / redaction_rule correction persists patterns under each
|
|
14129
|
+
// namespace's state/corrections/redaction-rules/ dir; we consult them
|
|
14130
|
+
// before a fact reaches the storage write chokepoint so matching content
|
|
14131
|
+
// is withheld entirely rather than landing as pending_review. Cached per
|
|
14132
|
+
// dir so a multi-fact batch over one namespace reads the dir once.
|
|
14133
|
+
let redactionGatedCount = 0;
|
|
14134
|
+
const redactionRulesByDir = new Map<string, CompiledRedactionRule[]>();
|
|
14135
|
+
const redactionRulesFor = async (...dirs: string[]): Promise<CompiledRedactionRule[]> => {
|
|
14136
|
+
const out: CompiledRedactionRule[] = [];
|
|
14137
|
+
for (const d of dirs) {
|
|
14138
|
+
let r = redactionRulesByDir.get(d);
|
|
14139
|
+
if (!r) { r = await loadRedactionRules(d); redactionRulesByDir.set(d, r); }
|
|
14140
|
+
out.push(...r);
|
|
14141
|
+
}
|
|
14142
|
+
return out;
|
|
14143
|
+
};
|
|
14122
14144
|
const behaviorSignalsByStorage = new Map<
|
|
14123
14145
|
string,
|
|
14124
14146
|
{ storage: StorageManager; events: BehaviorSignalEvent[] }
|
|
@@ -14846,6 +14868,10 @@ export class Orchestrator {
|
|
|
14846
14868
|
// into the caller's buffer-retention decision.
|
|
14847
14869
|
this.lastPersistExtractionDeferredCount = 0;
|
|
14848
14870
|
if (this.config.extractionJudgeEnabled) {
|
|
14871
|
+
// #1669 P1: pre-filter redacted facts from judge candidates so never-store
|
|
14872
|
+
// content is not persisted as judge training data.
|
|
14873
|
+
let preJudgeRedactionRules: CompiledRedactionRule[] = [];
|
|
14874
|
+
try { preJudgeRedactionRules = await redactionRulesFor(storage.dir); } catch { /* fail open */ }
|
|
14849
14875
|
try {
|
|
14850
14876
|
const judgeCandidates: JudgeCandidate[] = [];
|
|
14851
14877
|
const candidateToFactIndex: number[] = [];
|
|
@@ -14886,6 +14912,11 @@ export class Orchestrator {
|
|
|
14886
14912
|
) {
|
|
14887
14913
|
continue;
|
|
14888
14914
|
}
|
|
14915
|
+
if (preJudgeRedactionRules.length > 0) {
|
|
14916
|
+
const rc = f.content + (f.structuredAttributes ? " " + JSON.stringify(f.structuredAttributes) : "")
|
|
14917
|
+
+ (f.procedureSteps ? " " + f.procedureSteps.map((s) => `${s.intent} ${s.expectedOutcome ?? ""} ${s.toolCall ? `${s.toolCall.kind} ${s.toolCall.signature}` : ""}`.trim()).join(" ") : "");
|
|
14918
|
+
if (contentMatchesRedactionRules(rc, preJudgeRedactionRules)) continue;
|
|
14919
|
+
}
|
|
14889
14920
|
judgeCandidates.push({
|
|
14890
14921
|
text: f.content,
|
|
14891
14922
|
category: judgeCategory,
|
|
@@ -15064,6 +15095,7 @@ export class Orchestrator {
|
|
|
15064
15095
|
// affect both the dedup fingerprint and importance (issue #519 procedure routing).
|
|
15065
15096
|
let writeCategory = fact.category;
|
|
15066
15097
|
let targetStorage = storage;
|
|
15098
|
+
const sourceStorageDir = storage.dir; // #1669 thread #2: pre-routing source ns for redaction gate
|
|
15067
15099
|
// Track the KNOWN target namespace NAME alongside targetStorage (round 6,
|
|
15068
15100
|
// codex P2 — NCQI0). Re-deriving it from `targetStorage.dir` mangles a raw
|
|
15069
15101
|
// namespace literally named like a canonical token (e.g. `ns-616c706861`
|
|
@@ -15138,6 +15170,23 @@ export class Orchestrator {
|
|
|
15138
15170
|
);
|
|
15139
15171
|
}
|
|
15140
15172
|
}
|
|
15173
|
+
// #1669 redaction-rule gate: consult BOTH source and target namespace
|
|
15174
|
+
// rules before any write. A never-store pattern registered under the
|
|
15175
|
+
// source namespace must survive scope-routing to a different target
|
|
15176
|
+
// (review thread #2). Fails open on read error.
|
|
15177
|
+
try {
|
|
15178
|
+
const redactionRules = await redactionRulesFor(sourceStorageDir, targetStorage.dir);
|
|
15179
|
+
const redactionCandidate = fact.content
|
|
15180
|
+
+ (fact.structuredAttributes ? " " + JSON.stringify(fact.structuredAttributes) : "")
|
|
15181
|
+
+ (fact.procedureSteps ? " " + fact.procedureSteps.map((s) => `${s.intent} ${s.expectedOutcome ?? ""} ${s.toolCall ? `${s.toolCall.kind} ${s.toolCall.signature}` : ""}`.trim()).join(" ") : "");
|
|
15182
|
+
if (redactionRules.length > 0 && contentMatchesRedactionRules(redactionCandidate, redactionRules)) {
|
|
15183
|
+
redactionGatedCount++;
|
|
15184
|
+
log.debug(`extraction: redaction-rule withheld fact #${redactionGatedCount} in ${targetStorage.dir}`);
|
|
15185
|
+
continue;
|
|
15186
|
+
}
|
|
15187
|
+
} catch (redactionErr) {
|
|
15188
|
+
log.warn(`extraction: redaction-rule gate failed open: ${redactionErr}`);
|
|
15189
|
+
}
|
|
15141
15190
|
|
|
15142
15191
|
// Procedures: fingerprint the full serialized body (title + steps), not
|
|
15143
15192
|
// the title alone, so distinct step lists are not collapsed (issue #519).
|
|
@@ -16263,8 +16312,10 @@ export class Orchestrator {
|
|
|
16263
16312
|
importanceGatedCount > 0 ? ` (${importanceGatedCount} gated)` : "";
|
|
16264
16313
|
const judgeSuffix =
|
|
16265
16314
|
judgeGatedCount > 0 ? ` (${judgeGatedCount} judge-rejected)` : "";
|
|
16315
|
+
const redactionSuffix =
|
|
16316
|
+
redactionGatedCount > 0 ? ` (${redactionGatedCount} redacted)` : "";
|
|
16266
16317
|
log.info(
|
|
16267
|
-
`persisted: ${facts.length - dedupedCount - importanceGatedCount - judgeGatedCount} facts${dedupSuffix}${gatedSuffix}${judgeSuffix}, ${entities.length} entities, ${questions.length} questions, ${profileUpdates.length} profile updates`,
|
|
16318
|
+
`persisted: ${facts.length - dedupedCount - importanceGatedCount - judgeGatedCount - redactionGatedCount} facts${dedupSuffix}${gatedSuffix}${judgeSuffix}${redactionSuffix}, ${entities.length} entities, ${questions.length} questions, ${profileUpdates.length} profile updates`,
|
|
16268
16319
|
);
|
|
16269
16320
|
|
|
16270
16321
|
// Update temporal + tag indexes (v8.1) — fire-and-forget, fail-open
|
package/src/storage.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access, lstat, readdir, readFile, realpath, stat, writeFile, mkdir, unlink, rename, appendFile, open } from "node:fs/promises";
|
|
2
2
|
import { appendFileSync, createReadStream, mkdirSync, readFileSync, statSync, type Dirent } from "node:fs";
|
|
3
|
-
import { createHash } from "node:crypto"
|
|
3
|
+
import { createHash } from "node:crypto"
|
|
4
|
+
import { normalizeContent, computeContentHash } from "./content-hash.js";;
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { log } from "./logger.js";
|
|
6
7
|
import { isErrnoCode } from "./utils/errno.js";
|
|
@@ -1189,19 +1190,14 @@ export class ContentHashIndex {
|
|
|
1189
1190
|
}
|
|
1190
1191
|
}
|
|
1191
1192
|
|
|
1192
|
-
/** Normalize content
|
|
1193
|
+
/** Normalize content (delegates to content-hash.ts for a single source of truth). */
|
|
1193
1194
|
static normalizeContent(content: string): string {
|
|
1194
|
-
return content
|
|
1195
|
-
.toLowerCase()
|
|
1196
|
-
.replace(/[^a-z0-9\s]/g, " ")
|
|
1197
|
-
.replace(/\s+/g, " ")
|
|
1198
|
-
.trim();
|
|
1195
|
+
return normalizeContent(content);
|
|
1199
1196
|
}
|
|
1200
1197
|
|
|
1201
|
-
/**
|
|
1198
|
+
/** Compute SHA-256 hash (delegates to content-hash.ts for a single source of truth). */
|
|
1202
1199
|
static computeHash(content: string): string {
|
|
1203
|
-
|
|
1204
|
-
return createHash("sha256").update(normalized).digest("hex");
|
|
1200
|
+
return computeContentHash(content);
|
|
1205
1201
|
}
|
|
1206
1202
|
}
|
|
1207
1203
|
|