imprint-mcp 0.6.1 → 0.6.2
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/README.md +2 -0
- package/package.json +3 -2
- package/prompts/audit-agent.md +4 -2
- package/prompts/compile-agent.md +46 -23
- package/prompts/live-verifier-agent.md +101 -0
- package/prompts/playbook-compilation.md +8 -1
- package/src/cli.ts +83 -1
- package/src/imprint/audit.ts +219 -20
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +48 -34
- package/src/imprint/cdp-browser-fetch.ts +135 -42
- package/src/imprint/claude-cli-compile.ts +38 -16
- package/src/imprint/codex-cli-compile.ts +120 -21
- package/src/imprint/compile-agent.ts +71 -26
- package/src/imprint/compile-tools.ts +169 -38
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1665 -0
- package/src/imprint/llm.ts +16 -0
- package/src/imprint/mcp-compile-server.ts +380 -81
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +12 -2
- package/src/imprint/probe-backends.ts +88 -7
- package/src/imprint/runtime.ts +61 -11
- package/src/imprint/teach-state.ts +18 -1
- package/src/imprint/teach.ts +77 -19
- package/src/imprint/types.ts +39 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, join as pathJoin } from 'node:path';
|
|
13
|
+
import { loadBackendsCacheStatus } from './backend-cache.ts';
|
|
14
|
+
import { runWorkflowWithLadder } from './backend-ladder.ts';
|
|
15
|
+
import type { CredentialStore } from './runtime.ts';
|
|
16
|
+
import { WorkflowSchema } from './types.ts';
|
|
17
|
+
|
|
18
|
+
export const LIVE_EVIDENCE_PATH_ENV = 'IMPRINT_LIVE_EVIDENCE_PATH';
|
|
19
|
+
export const LIVE_PREFERRED_BACKEND_ONLY_ENV = 'IMPRINT_LIVE_PREFERRED_BACKEND_ONLY';
|
|
20
|
+
|
|
21
|
+
type WorkflowParams = Record<string, string | number | boolean>;
|
|
22
|
+
type LadderRun = Awaited<ReturnType<typeof runWorkflowWithLadder>>;
|
|
23
|
+
|
|
24
|
+
export interface LiveIntegrationEvidence {
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
kind: 'call';
|
|
27
|
+
label: string;
|
|
28
|
+
caseName: string;
|
|
29
|
+
toolName: string;
|
|
30
|
+
requestedParams: WorkflowParams;
|
|
31
|
+
effectiveParams: WorkflowParams;
|
|
32
|
+
result: LadderRun['result'];
|
|
33
|
+
usedBackend: LadderRun['usedBackend'];
|
|
34
|
+
attempts: LadderRun['attempts'];
|
|
35
|
+
durationMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scalarDefaults(parameters: Array<{ name: string; default?: unknown }>): WorkflowParams {
|
|
39
|
+
const defaults: WorkflowParams = {};
|
|
40
|
+
for (const parameter of parameters) {
|
|
41
|
+
const value = parameter.default;
|
|
42
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
43
|
+
defaults[parameter.name] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return defaults;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function processIsAlive(pid: number): boolean {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function acquireSiteLiveLock(workflowPath: string): Promise<() => void> {
|
|
59
|
+
const siteDir = dirname(dirname(workflowPath));
|
|
60
|
+
const lockPath = pathJoin(siteDir, '.imprint-live-verification.lock');
|
|
61
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
62
|
+
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
try {
|
|
65
|
+
const fd = openSync(lockPath, 'wx');
|
|
66
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
return () => {
|
|
69
|
+
try {
|
|
70
|
+
unlinkSync(lockPath);
|
|
71
|
+
} catch {
|
|
72
|
+
// Best effort: a later acquisition can clean up a stale lock.
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
77
|
+
if (code !== 'EEXIST') throw error;
|
|
78
|
+
try {
|
|
79
|
+
const current = JSON.parse(readFileSync(lockPath, 'utf8')) as {
|
|
80
|
+
pid?: number;
|
|
81
|
+
createdAt?: number;
|
|
82
|
+
};
|
|
83
|
+
const stale =
|
|
84
|
+
typeof current.pid === 'number'
|
|
85
|
+
? !processIsAlive(current.pid)
|
|
86
|
+
: Date.now() - (current.createdAt ?? 0) > 10 * 60_000;
|
|
87
|
+
if (stale) {
|
|
88
|
+
unlinkSync(lockPath);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
try {
|
|
93
|
+
// Another process can observe the lock between exclusive creation
|
|
94
|
+
// and the tiny metadata write. Never delete a fresh, partially
|
|
95
|
+
// written lock; only reap malformed metadata after a long stale age.
|
|
96
|
+
if (Date.now() - statSync(lockPath).mtimeMs > 10 * 60_000) {
|
|
97
|
+
unlinkSync(lockPath);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Another process may have released/replaced the lock. Retry below.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`Timed out waiting for same-site live verification lock: ${lockPath}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compile-only wrapper around the backend ladder. Production generated tools do
|
|
112
|
+
* not use this function. During `imprint teach`, it captures the actual tool
|
|
113
|
+
* input and parsed result for an independent semantic verifier.
|
|
114
|
+
*/
|
|
115
|
+
export async function runCapturedIntegrationCase(opts: {
|
|
116
|
+
caseName: string;
|
|
117
|
+
workflowPath: string;
|
|
118
|
+
params: WorkflowParams;
|
|
119
|
+
credentials?: CredentialStore;
|
|
120
|
+
/** Final semantic verification pins the cache winner so a failed preferred
|
|
121
|
+
* backend becomes explicit reprobe feedback instead of a hidden ladder walk. */
|
|
122
|
+
preferredOnlyBackend?: boolean;
|
|
123
|
+
}): Promise<LadderRun> {
|
|
124
|
+
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
|
|
125
|
+
const release = await acquireSiteLiveLock(opts.workflowPath);
|
|
126
|
+
const startedAt = Date.now();
|
|
127
|
+
let run: LadderRun;
|
|
128
|
+
try {
|
|
129
|
+
let forceBackend: LadderRun['usedBackend'] | undefined;
|
|
130
|
+
if (opts.preferredOnlyBackend || process.env[LIVE_PREFERRED_BACKEND_ONLY_ENV] === '1') {
|
|
131
|
+
const toolDir = dirname(opts.workflowPath);
|
|
132
|
+
const status = loadBackendsCacheStatus(
|
|
133
|
+
workflow.site ?? '',
|
|
134
|
+
dirname(dirname(toolDir)),
|
|
135
|
+
toolDir,
|
|
136
|
+
{ warn: false, toolName: workflow.toolName },
|
|
137
|
+
);
|
|
138
|
+
forceBackend = status.status === 'ok' ? status.cache.preferredOrder[0] : undefined;
|
|
139
|
+
if (!forceBackend) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Live verifier backend is not prepared for ${workflow.toolName}; call prepare_live_backend before running the suite.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
run = await runWorkflowWithLadder({
|
|
146
|
+
workflowPath: opts.workflowPath,
|
|
147
|
+
params: opts.params,
|
|
148
|
+
credentials: opts.credentials,
|
|
149
|
+
forceBackend,
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
release();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const evidencePath = process.env[LIVE_EVIDENCE_PATH_ENV];
|
|
156
|
+
if (evidencePath) {
|
|
157
|
+
const evidence: LiveIntegrationEvidence = {
|
|
158
|
+
schemaVersion: 1,
|
|
159
|
+
kind: 'call',
|
|
160
|
+
label: `${opts.caseName}:${randomUUID().slice(0, 8)}`,
|
|
161
|
+
caseName: opts.caseName,
|
|
162
|
+
toolName: workflow.toolName,
|
|
163
|
+
requestedParams: opts.params,
|
|
164
|
+
effectiveParams: { ...scalarDefaults(workflow.parameters), ...opts.params },
|
|
165
|
+
result: run.result,
|
|
166
|
+
usedBackend: run.usedBackend,
|
|
167
|
+
attempts: run.attempts,
|
|
168
|
+
durationMs: Date.now() - startedAt,
|
|
169
|
+
};
|
|
170
|
+
appendFileSync(evidencePath, `${JSON.stringify(evidence)}\n`, { mode: 0o600 });
|
|
171
|
+
}
|
|
172
|
+
return run;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function readLiveIntegrationEvidence(path: string): LiveIntegrationEvidence[] {
|
|
176
|
+
if (!existsSync(path)) return [];
|
|
177
|
+
const evidence: LiveIntegrationEvidence[] = [];
|
|
178
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
179
|
+
if (!line.trim()) continue;
|
|
180
|
+
const parsed = JSON.parse(line) as LiveIntegrationEvidence;
|
|
181
|
+
if (parsed.schemaVersion !== 1 || parsed.kind !== 'call' || !parsed.label || !parsed.toolName) {
|
|
182
|
+
throw new Error('invalid live integration evidence record');
|
|
183
|
+
}
|
|
184
|
+
evidence.push(parsed);
|
|
185
|
+
}
|
|
186
|
+
return evidence;
|
|
187
|
+
}
|
package/src/imprint/compile.ts
CHANGED
|
@@ -66,6 +66,10 @@ interface CompileOptions {
|
|
|
66
66
|
* skips its own triageRequests() LLM call and merges the shared selectedSeqs
|
|
67
67
|
* with any per-tool preserveSeqs locally. */
|
|
68
68
|
preTriagedSession?: TriageResult;
|
|
69
|
+
/** Authoritative, post-verification workflow contract. Playbook compilation
|
|
70
|
+
* uses this instead of re-inferring the public parameter surface from the
|
|
71
|
+
* recording candidate. */
|
|
72
|
+
workflow?: Workflow;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
// ─── generate (workflow.json) ────────────────────────────────────────────────
|
|
@@ -93,6 +97,8 @@ interface GenerateOptions extends CompileOptions {
|
|
|
93
97
|
* response parsing, shared-module imports). Injected into the agent's initial
|
|
94
98
|
* message so the compile follows it. */
|
|
95
99
|
toolPlan?: string;
|
|
100
|
+
/** Bounded teach resume: revise the current artifact from durable feedback. */
|
|
101
|
+
revisionMode?: boolean;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
interface GenerateResult {
|
|
@@ -136,6 +142,7 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
|
|
|
136
142
|
buildPlanPath: opts.buildPlanPath,
|
|
137
143
|
sharedModules: opts.sharedModules,
|
|
138
144
|
toolPlan: opts.toolPlan,
|
|
145
|
+
revisionMode: opts.revisionMode,
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
setSpanAttributes(span, {
|
|
@@ -216,6 +223,10 @@ function relocateGeneratedWorkflow(workflowPath: string, workflow: Workflow): st
|
|
|
216
223
|
'.compile-log.json',
|
|
217
224
|
'.compile-done.json',
|
|
218
225
|
'.compile-give-up.json',
|
|
226
|
+
'.live-verifier-log.jsonl',
|
|
227
|
+
'.live-verification-evidence.json',
|
|
228
|
+
'.live-verification.json',
|
|
229
|
+
'backends.json',
|
|
219
230
|
]) {
|
|
220
231
|
const source = pathJoin(sourceDir, artifact);
|
|
221
232
|
if (!existsSync(source)) continue;
|
|
@@ -791,10 +802,18 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
|
|
|
791
802
|
`compiling playbook from ${session.events.length} events / ${xhrs.length} XHRs / ${session.narration.length} narration lines…`,
|
|
792
803
|
);
|
|
793
804
|
|
|
805
|
+
const workflowContract = opts.workflow
|
|
806
|
+
? {
|
|
807
|
+
toolName: opts.workflow.toolName,
|
|
808
|
+
parameters: opts.workflow.parameters,
|
|
809
|
+
limitations: opts.workflow.limitations ?? [],
|
|
810
|
+
}
|
|
811
|
+
: undefined;
|
|
794
812
|
const slimmed = {
|
|
795
813
|
site: session.site,
|
|
796
814
|
url: compactUrlForLlm(session.url),
|
|
797
815
|
candidate: opts.candidate,
|
|
816
|
+
workflowContract,
|
|
798
817
|
sharedContext: opts.sharedContext,
|
|
799
818
|
narration: session.narration,
|
|
800
819
|
events: session.events,
|
|
@@ -812,6 +831,10 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
|
|
|
812
831
|
opts.candidate
|
|
813
832
|
? `\n\nCandidate scope:\nCompile only this candidate: ${JSON.stringify(opts.candidate, null, 2)}\nShared context: ${JSON.stringify(opts.sharedContext ?? {}, null, 2)}\nThe playbook toolName and parameters must match the selected candidate/workflow, not any other action in the recording.\n`
|
|
814
833
|
: ''
|
|
834
|
+
}${
|
|
835
|
+
workflowContract
|
|
836
|
+
? `\n\nAuthoritative verified workflow contract:\n${JSON.stringify(workflowContract, null, 2)}\nThis contract supersedes candidate likelyParams and raw recording branches. Use exactly these public parameters. Never resurrect an omitted parameter or reference it in a step. When a limitation omits a recorded branch, leave that branch out of the playbook and retain the limitation in notes if useful.\n`
|
|
837
|
+
: ''
|
|
815
838
|
}`;
|
|
816
839
|
|
|
817
840
|
const llm = resolveProvider(opts.llmConfig ?? {});
|
|
@@ -825,13 +848,21 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
|
|
|
825
848
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
826
849
|
try {
|
|
827
850
|
playbook = parsePlaybook(stripCodeFences(lastResult.text).trim());
|
|
851
|
+
if (opts.workflow) {
|
|
852
|
+
const contractFailures = playbookWorkflowContractFailures(playbook, opts.workflow);
|
|
853
|
+
if (contractFailures.length > 0) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
`playbook conflicts with the authoritative verified workflow contract:\n- ${contractFailures.join('\n- ')}`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
828
859
|
lastErr = undefined;
|
|
829
860
|
break;
|
|
830
861
|
} catch (err) {
|
|
831
862
|
lastErr = err;
|
|
832
863
|
if (attempt === 0) {
|
|
833
|
-
log('playbook
|
|
834
|
-
const fixPrompt = `Your previous
|
|
864
|
+
log('playbook failed validation, retrying with error feedback…');
|
|
865
|
+
const fixPrompt = `Your previous playbook was invalid. The validation error was:\n\n${err instanceof Error ? err.message : String(err)}\n\nReason about the verified workflow contract, fix the YAML, and return the corrected playbook. Output ONLY valid YAML, no prose.`;
|
|
835
866
|
lastResult = await llm.analyze(systemPrompt, `${JSON.stringify(slimmed)}\n\n${fixPrompt}`);
|
|
836
867
|
llmInputTokens = addNullable(llmInputTokens, lastResult.inputTokens);
|
|
837
868
|
llmOutputTokens = addNullable(llmOutputTokens, lastResult.outputTokens);
|
|
@@ -868,6 +899,44 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
|
|
|
868
899
|
};
|
|
869
900
|
}
|
|
870
901
|
|
|
902
|
+
/**
|
|
903
|
+
* Check only the compatibility boundary required for API→playbook fallback.
|
|
904
|
+
* The playbook agent remains free to choose DOM actions and locators, but it
|
|
905
|
+
* cannot change the verified public tool surface or depend on hidden inputs.
|
|
906
|
+
*/
|
|
907
|
+
export function playbookWorkflowContractFailures(playbook: Playbook, workflow: Workflow): string[] {
|
|
908
|
+
const failures: string[] = [];
|
|
909
|
+
if (playbook.toolName !== workflow.toolName) {
|
|
910
|
+
failures.push(
|
|
911
|
+
`toolName ${JSON.stringify(playbook.toolName)} must equal ${JSON.stringify(workflow.toolName)}`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const workflowNames = new Set(workflow.parameters.map((parameter) => parameter.name));
|
|
916
|
+
const playbookNames = new Set(playbook.parameters.map((parameter) => parameter.name));
|
|
917
|
+
const missing = [...workflowNames].filter((name) => !playbookNames.has(name)).sort();
|
|
918
|
+
const unexpected = [...playbookNames].filter((name) => !workflowNames.has(name)).sort();
|
|
919
|
+
if (missing.length > 0) {
|
|
920
|
+
failures.push(`missing public parameter(s): ${missing.join(', ')}`);
|
|
921
|
+
}
|
|
922
|
+
if (unexpected.length > 0) {
|
|
923
|
+
failures.push(`unexpected parameter(s): ${unexpected.join(', ')}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const hiddenReferences = new Set<string>();
|
|
927
|
+
const executableText = JSON.stringify({ steps: playbook.steps, result: playbook.result });
|
|
928
|
+
for (const match of executableText.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
929
|
+
const name = match[1];
|
|
930
|
+
if (name && !workflowNames.has(name)) hiddenReferences.add(name);
|
|
931
|
+
}
|
|
932
|
+
if (hiddenReferences.size > 0) {
|
|
933
|
+
failures.push(
|
|
934
|
+
`step/result references hidden parameter(s): ${[...hiddenReferences].sort().join(', ')}`,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
return failures;
|
|
938
|
+
}
|
|
939
|
+
|
|
871
940
|
function addNullable(a: number | null, b: number | null): number | null {
|
|
872
941
|
if (a === null && b === null) return null;
|
|
873
942
|
return (a ?? 0) + (b ?? 0);
|
|
@@ -123,6 +123,14 @@ const PROTECTED_PATTERNS = [
|
|
|
123
123
|
/\b[0-9a-f]{40}\b/gi,
|
|
124
124
|
];
|
|
125
125
|
|
|
126
|
+
// UUIDs and 40-hex identifiers are normally protected from generic secret
|
|
127
|
+
// heuristics because they are common, benign IDs in recorded payloads. When
|
|
128
|
+
// the same shapes occur immediately in an authentication context, however,
|
|
129
|
+
// that context is the useful signal: redact only the value while continuing to
|
|
130
|
+
// preserve identical bare IDs elsewhere.
|
|
131
|
+
const CONTEXTUAL_PROTECTED_SECRET_RE =
|
|
132
|
+
/(\b(?:authorization|proxy-authorization)\b["']?\s*[:=]\s*["']?(?:bearer\s+)?|\bbearer\s+|\b(?:oauth(?:2)?(?:[_ -]?access)?[_ -]?token|access[_ -]?token)\b["']?\s*[:=]\s*["']?)([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9a-f]{40})(?=["'\s,;&)}\]]|$)/gi;
|
|
133
|
+
|
|
126
134
|
const REDACTOR = createRedactum({
|
|
127
135
|
policies: FREEFORM_POLICIES,
|
|
128
136
|
replacement: () => '[REDACTED]',
|
|
@@ -145,24 +153,37 @@ export function redactFreeformText(text: string): FreeformRedaction {
|
|
|
145
153
|
return { redacted: text, redactionsCount: 0 };
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
|
|
156
|
+
let contextualRedactions = 0;
|
|
157
|
+
const contextuallyRedacted = text.replace(
|
|
158
|
+
CONTEXTUAL_PROTECTED_SECRET_RE,
|
|
159
|
+
(_match, prefix: string) => {
|
|
160
|
+
contextualRedactions++;
|
|
161
|
+
return `${prefix}[REDACTED]`;
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const protectedRanges = collectProtectedRanges(contextuallyRedacted);
|
|
149
166
|
if (protectedRanges.length === 0) {
|
|
150
|
-
|
|
167
|
+
const result = redactUnprotectedText(contextuallyRedacted);
|
|
168
|
+
return {
|
|
169
|
+
redacted: result.redacted,
|
|
170
|
+
redactionsCount: contextualRedactions + result.redactionsCount,
|
|
171
|
+
};
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
let redacted = '';
|
|
154
175
|
let cursor = 0;
|
|
155
|
-
let redactionsCount =
|
|
176
|
+
let redactionsCount = contextualRedactions;
|
|
156
177
|
for (const range of protectedRanges) {
|
|
157
|
-
const segment =
|
|
178
|
+
const segment = contextuallyRedacted.slice(cursor, range.start);
|
|
158
179
|
const segmentResult = redactUnprotectedText(segment);
|
|
159
180
|
redacted += segmentResult.redacted;
|
|
160
181
|
redactionsCount += segmentResult.redactionsCount;
|
|
161
|
-
redacted +=
|
|
182
|
+
redacted += contextuallyRedacted.slice(range.start, range.end);
|
|
162
183
|
cursor = range.end;
|
|
163
184
|
}
|
|
164
185
|
|
|
165
|
-
const tail = redactUnprotectedText(
|
|
186
|
+
const tail = redactUnprotectedText(contextuallyRedacted.slice(cursor));
|
|
166
187
|
redacted += tail.redacted;
|
|
167
188
|
redactionsCount += tail.redactionsCount;
|
|
168
189
|
|