@remnic/core 9.3.641 → 9.3.643

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.
@@ -0,0 +1,401 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { expandTildePath } from "../utils/path.js";
6
+ import { getLocalSessionSourceAdapter, listLocalSessionSourceAdapters } from "./adapters.js";
7
+ import { compileRedactionRules, redactSessionSummaryText } from "./redaction.js";
8
+ import type {
9
+ CollectLocalSessionSummariesOptions,
10
+ CompiledRedactionRule,
11
+ LocalSessionRole,
12
+ LocalSessionSummaryCliOptions,
13
+ LocalSessionSummaryReport,
14
+ LocalSessionTurn,
15
+ RedactionConfig,
16
+ SessionSummaryDraft,
17
+ SessionSummaryExcerpt,
18
+ } from "./types.js";
19
+
20
+ export * from "./adapters.js";
21
+ export * from "./redaction.js";
22
+ export * from "./types.js";
23
+
24
+ const DEFAULT_MAX_FILES = 5000;
25
+ const DEFAULT_MAX_SESSIONS = 500;
26
+ const SUPPORTED_EXTENSIONS = new Set([".json", ".jsonl"]);
27
+
28
+ function shortHash(value: string, length = 16): string {
29
+ return createHash("sha256").update(value).digest("hex").slice(0, length);
30
+ }
31
+
32
+ async function listTranscriptFiles(root: string, maxFiles: number): Promise<{ files: string[]; truncated: boolean }> {
33
+ const out: string[] = [];
34
+ let truncated = false;
35
+ const entrySortKey = (entryName: string, isDirectory: boolean): string => (isDirectory ? `${entryName}${path.sep}` : entryName);
36
+ async function visit(dir: string): Promise<void> {
37
+ if (truncated) return;
38
+ const entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) =>
39
+ entrySortKey(a.name, a.isDirectory()).localeCompare(entrySortKey(b.name, b.isDirectory()))
40
+ );
41
+ for (const entry of entries) {
42
+ if (truncated) return;
43
+ if (entry.name.startsWith(".")) continue;
44
+ if (entry.isSymbolicLink()) continue;
45
+ const fullPath = path.join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ await visit(fullPath);
48
+ continue;
49
+ }
50
+ if (!entry.isFile()) continue;
51
+ const ext = path.extname(entry.name).toLowerCase();
52
+ if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
53
+ out.push(fullPath);
54
+ if (out.length > maxFiles) {
55
+ truncated = true;
56
+ return;
57
+ }
58
+ }
59
+ }
60
+ await visit(root);
61
+ const sorted = out.sort();
62
+ return {
63
+ files: sorted.slice(0, maxFiles),
64
+ truncated,
65
+ };
66
+ }
67
+
68
+ function validatePositiveInt(value: number | undefined, name: string, defaultValue: number): number {
69
+ if (value === undefined) return defaultValue;
70
+ if (!Number.isInteger(value) || value < 1) {
71
+ throw new Error(`${name} must be a positive integer`);
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function normalizeInputDir(inputDir: string): string {
77
+ if (typeof inputDir !== "string" || inputDir.trim().length === 0) {
78
+ throw new Error("inputDir must be a non-empty string");
79
+ }
80
+ return path.resolve(expandTildePath(inputDir.trim()));
81
+ }
82
+
83
+ function initRoleCounts(): Record<LocalSessionRole, number> {
84
+ return {
85
+ user: 0,
86
+ assistant: 0,
87
+ tool: 0,
88
+ system: 0,
89
+ other: 0,
90
+ };
91
+ }
92
+
93
+ function safeTimestampSortValue(turn: LocalSessionTurn): number {
94
+ if (!turn.timestamp) return Number.MAX_SAFE_INTEGER;
95
+ const millis = Date.parse(turn.timestamp);
96
+ return Number.isFinite(millis) ? millis : Number.MAX_SAFE_INTEGER;
97
+ }
98
+
99
+ function earliestTurnSortValue(turns: readonly LocalSessionTurn[]): number {
100
+ let earliest = Number.MAX_SAFE_INTEGER;
101
+ for (const turn of turns) {
102
+ const value = safeTimestampSortValue(turn);
103
+ if (value < earliest) earliest = value;
104
+ }
105
+ return earliest;
106
+ }
107
+
108
+ function truncateExcerpt(text: string): string {
109
+ const compact = text.replace(/\s+/g, " ").trim();
110
+ return compact.length > 240 ? `${compact.slice(0, 237)}...` : compact;
111
+ }
112
+
113
+ function mergeRuleNames(target: Set<string>, names: readonly string[]): void {
114
+ for (const name of names) target.add(name);
115
+ }
116
+
117
+ function buildDraft(options: {
118
+ source: string;
119
+ sessionKey: string;
120
+ turns: Array<LocalSessionTurn & { fileHash: string; fileExtension: string }>;
121
+ generatedAt: string;
122
+ redactionRules: readonly CompiledRedactionRule[];
123
+ includeRedactedExcerpts: boolean;
124
+ }): SessionSummaryDraft {
125
+ const sortedTurns = [...options.turns].sort((a, b) => safeTimestampSortValue(a) - safeTimestampSortValue(b));
126
+ const roles = initRoleCounts();
127
+ const fileRefs = new Map<string, string>();
128
+ let redactionApplied = 0;
129
+ const redactionRuleNames = new Set<string>();
130
+ const excerpts: SessionSummaryExcerpt[] = [];
131
+
132
+ for (const turn of sortedTurns) {
133
+ roles[turn.role] += 1;
134
+ fileRefs.set(turn.fileHash, turn.fileExtension);
135
+ const redacted = redactSessionSummaryText(turn.content, options.redactionRules);
136
+ redactionApplied += redacted.report.applied;
137
+ mergeRuleNames(redactionRuleNames, redacted.report.ruleNames);
138
+ if (options.includeRedactedExcerpts && excerpts.length < 5) {
139
+ excerpts.push({
140
+ role: turn.role,
141
+ text: truncateExcerpt(redacted.text),
142
+ ...(turn.timestamp ? { timestamp: turn.timestamp } : {}),
143
+ });
144
+ }
145
+ }
146
+
147
+ const firstTimestamp = sortedTurns.find((turn) => turn.timestamp)?.timestamp;
148
+ const lastTimestamp = [...sortedTurns].reverse().find((turn) => turn.timestamp)?.timestamp;
149
+ const sourceSessionRef = shortHash(options.sessionKey);
150
+ const sourceFileRefs = [...fileRefs.entries()]
151
+ .sort(([a], [b]) => a.localeCompare(b))
152
+ .map(([hash, extension]) => ({ hash, extension }));
153
+ const dateText = firstTimestamp && lastTimestamp ? ` from ${firstTimestamp} to ${lastTimestamp}` : "";
154
+ const summary =
155
+ `Local AI session summary: ${sortedTurns.length} turn(s)` +
156
+ ` (${roles.user} user, ${roles.assistant} assistant, ${roles.tool} tool, ${roles.system} system, ${roles.other} other)` +
157
+ `${dateText}. Raw transcript text, source session keys, and local file paths are not stored.`;
158
+
159
+ return {
160
+ schemaVersion: 1,
161
+ draftId: shortHash(
162
+ JSON.stringify({
163
+ source: options.source,
164
+ sourceSessionRef,
165
+ firstTimestamp,
166
+ lastTimestamp,
167
+ turnCount: sortedTurns.length,
168
+ }),
169
+ 24
170
+ ),
171
+ generatedAt: options.generatedAt,
172
+ sourceAdapter: options.source,
173
+ sourceSessionRef,
174
+ sourceFileCount: sourceFileRefs.length,
175
+ sourceFileRefs,
176
+ ...(firstTimestamp ? { firstTimestamp } : {}),
177
+ ...(lastTimestamp ? { lastTimestamp } : {}),
178
+ turnCount: sortedTurns.length,
179
+ roles,
180
+ summary,
181
+ redaction: {
182
+ enabled: options.redactionRules.length > 0,
183
+ applied: redactionApplied,
184
+ ruleNames: [...redactionRuleNames].sort(),
185
+ },
186
+ ...(options.includeRedactedExcerpts && excerpts.length > 0 ? { excerpts } : {}),
187
+ metadata: {
188
+ storesRawTranscript: false,
189
+ storesLocalPaths: false,
190
+ storesSourceSessionKey: false,
191
+ },
192
+ };
193
+ }
194
+
195
+ export async function collectLocalSessionSummaries(
196
+ options: CollectLocalSessionSummariesOptions
197
+ ): Promise<LocalSessionSummaryReport> {
198
+ const inputDir = normalizeInputDir(options.inputDir);
199
+ const inputLstat = await lstat(inputDir);
200
+ if (inputLstat.isSymbolicLink()) {
201
+ throw new Error(`inputDir must not be a symbolic link: ${options.inputDir}`);
202
+ }
203
+ const inputStat = await stat(inputDir);
204
+ if (!inputStat.isDirectory()) {
205
+ throw new Error(`inputDir must be a directory: ${options.inputDir}`);
206
+ }
207
+
208
+ const source = options.source?.trim() || "generic-jsonl";
209
+ const adapter = getLocalSessionSourceAdapter(source);
210
+ if (!adapter) {
211
+ throw new Error(
212
+ `Unknown local session source '${source}'. Valid sources: ${listLocalSessionSourceAdapters().join(", ")}`
213
+ );
214
+ }
215
+
216
+ const maxFiles = validatePositiveInt(options.maxFiles, "maxFiles", DEFAULT_MAX_FILES);
217
+ const maxSessions = validatePositiveInt(options.maxSessions, "maxSessions", DEFAULT_MAX_SESSIONS);
218
+ const redactionConfig = options.redactionConfig ?? {};
219
+ const excerptsRequested = options.includeRedactedExcerpts === true;
220
+ const redactionRules = compileRedactionRules({
221
+ ...redactionConfig,
222
+ ...(excerptsRequested ? { disableDefaults: false } : {}),
223
+ });
224
+ const includeRedactedExcerpts = options.includeRedactedExcerpts === true && redactionRules.length > 0;
225
+ const listed = await listTranscriptFiles(inputDir, maxFiles);
226
+ const files = listed.files;
227
+ const generatedAt = (options.now ?? new Date()).toISOString();
228
+ const warnings: LocalSessionSummaryReport["warnings"] = [];
229
+ const sessions = new Map<string, Array<LocalSessionTurn & { fileHash: string; fileExtension: string }>>();
230
+ const seenFileHashes = new Set<string>();
231
+ let turnsParsed = 0;
232
+ let filesParsed = 0;
233
+
234
+ if (listed.truncated) {
235
+ warnings.push({
236
+ code: "session-summaries.max_files_truncated",
237
+ message: `Scanned the first ${maxFiles} transcript file(s); increase maxFiles to include all matching files.`,
238
+ });
239
+ }
240
+ if (excerptsRequested && redactionConfig.disableDefaults === true) {
241
+ warnings.push({
242
+ code: "session-summaries.default_redaction_forced_for_excerpts",
243
+ message: "Default redaction rules were enabled because redacted excerpts were requested.",
244
+ });
245
+ }
246
+ if (excerptsRequested && redactionRules.length === 0) {
247
+ warnings.push({
248
+ code: "session-summaries.excerpts_suppressed_no_redaction",
249
+ message: "Redacted excerpts were requested, but no redaction rules are enabled; excerpts were omitted.",
250
+ });
251
+ }
252
+
253
+ for (const filePath of files) {
254
+ const content = await readFile(filePath, "utf-8");
255
+ const fileHash = shortHash(content, 24);
256
+ if (seenFileHashes.has(fileHash)) {
257
+ warnings.push({
258
+ code: "session-summaries.duplicate_file_skipped",
259
+ message: "Skipped a duplicate transcript file with identical content.",
260
+ fileRef: fileHash,
261
+ });
262
+ continue;
263
+ }
264
+ seenFileHashes.add(fileHash);
265
+ const fileExtension = path.extname(filePath).toLowerCase();
266
+ const parsed = await adapter.parseFile(
267
+ {
268
+ content,
269
+ fileName: path.basename(filePath),
270
+ fileExtension,
271
+ fileRef: fileHash,
272
+ },
273
+ { strict: options.strict }
274
+ );
275
+ warnings.push(...parsed.warnings);
276
+ if (parsed.turns.length === 0) continue;
277
+ filesParsed += 1;
278
+ for (const turn of parsed.turns) {
279
+ turnsParsed += 1;
280
+ const sessionKey = turn.sessionKey?.trim() || `${adapter.id}:${fileHash}`;
281
+ const bucket = sessions.get(sessionKey) ?? [];
282
+ bucket.push({ ...turn, fileHash, fileExtension });
283
+ sessions.set(sessionKey, bucket);
284
+ }
285
+ }
286
+
287
+ const drafts: SessionSummaryDraft[] = [];
288
+ const sessionEntries = [...sessions.entries()].sort(([aKey, a], [bKey, b]) => {
289
+ const aFirst = earliestTurnSortValue(a);
290
+ const bFirst = earliestTurnSortValue(b);
291
+ return aFirst - bFirst || a[0]?.fileHash.localeCompare(b[0]?.fileHash ?? "") || aKey.localeCompare(bKey);
292
+ });
293
+ if (sessionEntries.length > maxSessions) {
294
+ warnings.push({
295
+ code: "session-summaries.max_sessions_truncated",
296
+ message: `Summarized ${maxSessions} of ${sessionEntries.length} session(s); increase maxSessions to include all parsed sessions.`,
297
+ });
298
+ }
299
+ for (const [sessionKey, turns] of sessionEntries.slice(0, maxSessions)) {
300
+ drafts.push(
301
+ buildDraft({
302
+ source: adapter.id,
303
+ sessionKey,
304
+ turns,
305
+ generatedAt,
306
+ redactionRules,
307
+ includeRedactedExcerpts,
308
+ })
309
+ );
310
+ }
311
+ drafts.sort((a, b) => (a.firstTimestamp ?? "").localeCompare(b.firstTimestamp ?? ""));
312
+
313
+ return {
314
+ generatedAt,
315
+ source: adapter.id,
316
+ filesScanned: files.length,
317
+ filesParsed,
318
+ turnsParsed,
319
+ sessionsSummarized: drafts.length,
320
+ warnings,
321
+ drafts,
322
+ wroteFiles: [],
323
+ };
324
+ }
325
+
326
+ async function readRedactionConfig(pathLike: string | undefined): Promise<RedactionConfig | undefined> {
327
+ if (!pathLike) return undefined;
328
+ const raw = await readFile(path.resolve(expandTildePath(pathLike)), "utf-8");
329
+ const parsed = JSON.parse(raw) as RedactionConfig;
330
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
331
+ throw new Error("redaction config must be a JSON object");
332
+ }
333
+ return parsed;
334
+ }
335
+
336
+ function toJsonl(drafts: readonly SessionSummaryDraft[]): string {
337
+ return `${drafts.map((draft) => JSON.stringify(draft)).join("\n")}\n`;
338
+ }
339
+
340
+ function defaultDraftOutputPath(memoryDir: string, generatedAt: string): string {
341
+ const stamp = generatedAt.replace(/[:.]/g, "-");
342
+ return path.join(
343
+ path.resolve(expandTildePath(memoryDir)),
344
+ "state",
345
+ "session-summary-drafts",
346
+ `session-summaries-${stamp}.jsonl`
347
+ );
348
+ }
349
+
350
+ async function writeDrafts(filePath: string, drafts: readonly SessionSummaryDraft[]): Promise<void> {
351
+ await mkdir(path.dirname(filePath), { recursive: true });
352
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
353
+ try {
354
+ await writeFile(tempPath, toJsonl(drafts), "utf-8");
355
+ await rename(tempPath, filePath);
356
+ } catch (error) {
357
+ await rm(tempPath, { force: true });
358
+ throw error;
359
+ }
360
+ }
361
+
362
+ export async function runLocalSessionSummaryCliCommand(
363
+ options: LocalSessionSummaryCliOptions
364
+ ): Promise<LocalSessionSummaryReport> {
365
+ const redactionConfig = options.redactionConfig ?? (await readRedactionConfig(options.redactionConfigPath));
366
+ const report = await collectLocalSessionSummaries({
367
+ ...options,
368
+ ...(redactionConfig ? { redactionConfig } : {}),
369
+ });
370
+ const wroteFiles: string[] = [];
371
+
372
+ if (options.output) {
373
+ const outputPath = path.resolve(expandTildePath(options.output));
374
+ await writeDrafts(outputPath, report.drafts);
375
+ wroteFiles.push(outputPath);
376
+ }
377
+
378
+ if (options.write === true) {
379
+ if (!options.memoryDir || options.memoryDir.trim().length === 0) {
380
+ throw new Error("memoryDir is required when write is true");
381
+ }
382
+ const outputPath = defaultDraftOutputPath(options.memoryDir, report.generatedAt);
383
+ await writeDrafts(outputPath, report.drafts);
384
+ wroteFiles.push(outputPath);
385
+ }
386
+
387
+ const stdout = options.stdout;
388
+ if (stdout) {
389
+ stdout.write(`Local session summaries complete (source: ${report.source})\n`);
390
+ stdout.write(` Files scanned: ${report.filesScanned}\n`);
391
+ stdout.write(` Files parsed: ${report.filesParsed}\n`);
392
+ stdout.write(` Turns parsed: ${report.turnsParsed}\n`);
393
+ stdout.write(` Sessions summarized: ${report.sessionsSummarized}\n`);
394
+ stdout.write(` Draft files written: ${wroteFiles.length}\n`);
395
+ if (wroteFiles.length === 0) {
396
+ stdout.write(" (dry run - no Remnic draft files were written)\n");
397
+ }
398
+ }
399
+
400
+ return { ...report, wroteFiles };
401
+ }
@@ -0,0 +1,160 @@
1
+ import type { CompiledRedactionRule, RedactionConfig, RedactionReport } from "./types.js";
2
+
3
+ export const DEFAULT_REDACTION_RULES: readonly CompiledRedactionRule[] = [
4
+ {
5
+ name: "secret-token",
6
+ pattern:
7
+ /(?<![A-Za-z0-9_])(?:sk-[A-Za-z0-9_-]{12,}|sk-proj-[A-Za-z0-9_-]{12,}|(?:"authorization"|'authorization'|authorization)\s*[:=]\s*(?:"[A-Za-z][A-Za-z0-9._-]*\s+[^"\r\n]*"|'[A-Za-z][A-Za-z0-9._-]*\s+[^'\r\n]*'|[A-Za-z][A-Za-z0-9._-]*\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s"',;]+))|(?:"(?:[A-Za-z0-9]+[_-]+)*(?:api[_-]?key|access[_-]?key|private[_-]?key|token|secret|password)"|'(?:[A-Za-z0-9]+[_-]+)*(?:api[_-]?key|access[_-]?key|private[_-]?key|token|secret|password)'|(?:[A-Za-z0-9]+[_-]+)*(?:api[_-]?key|access[_-]?key|private[_-]?key|token|secret|password))\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s"',;]+))/gi,
8
+ replacement: "[REDACTED_SECRET]",
9
+ },
10
+ {
11
+ name: "email",
12
+ pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
13
+ replacement: "[REDACTED_EMAIL]",
14
+ },
15
+ {
16
+ name: "private-ip",
17
+ pattern:
18
+ /\b(?:(?:10|127)\.(?:\d{1,3}\.){2}\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3})\b/g,
19
+ replacement: "[REDACTED_PRIVATE_IP]",
20
+ },
21
+ {
22
+ name: "url",
23
+ pattern: /\bhttps?:\/\/[^\s"'<>)]*/gi,
24
+ replacement: "[REDACTED_URL]",
25
+ },
26
+ {
27
+ name: "home-relative-posix-spaced-path",
28
+ pattern:
29
+ /~\/(?:[^/\s"',;<>]+(?: [^/\s"',;<>]+)*\/)*[^/\s"',;<>]+(?: (?!(?:before|after|then|and|or)\b)[^/\s"',;<>]+)+\.[^/\s"',;<>]+?(?=$|[\s"',;<>)]|[.,](?:\s|$))/g,
30
+ replacement: "[REDACTED_PATH]",
31
+ },
32
+ {
33
+ name: "home-relative-posix-spaced-extensionless-path",
34
+ pattern:
35
+ /~\/(?:[^/\s"',;<>]+(?: [^/\s"',;<>]+)*\/)*[^/\s"',;<>]+(?: (?!(?:before|after|then|and(?=\s+[a-z])|or(?=\s+[a-z]))\b)[^/\s"',;<>]+)+?(?=$|["'<>)](?!\/)|[,;.]|\s+(?:before|after|then|and(?=\s+(?:[a-z]|[~/"']|$))|or(?=\s+(?:[a-z]|[~/"']|$)))\b)/g,
36
+ replacement: "[REDACTED_PATH]",
37
+ },
38
+ {
39
+ name: "home-relative-posix-path",
40
+ pattern: /~\/(?:[^/\s"',;<>]+(?: [^/\s"',;<>]+)*\/)*[^/\s"',;<>]+?(?=$|[\s"',;<>)]|[.,](?:\s|$))/g,
41
+ replacement: "[REDACTED_PATH]",
42
+ },
43
+ {
44
+ name: "absolute-posix-spaced-path",
45
+ pattern:
46
+ /\/(?:[^/\s"',;<>]+(?: [^/\s"',;<>]+)*\/)*[^/\s"',;<>]+(?: (?!(?:before|after|then|and|or)\b)[^/\s"',;<>]+)+\.[^/\s"',;<>]+?(?=$|[\s"',;<>)]|[.,](?:\s|$))/g,
47
+ replacement: "[REDACTED_PATH]",
48
+ },
49
+ {
50
+ name: "absolute-posix-spaced-extensionless-path",
51
+ pattern:
52
+ /\/(?:[^/\s"',;<>]+(?: [^/\s"',;<>]+)*\/)*[^/\s"',;<>]+(?: (?!(?:before|after|then|and(?=\s+[a-z])|or(?=\s+[a-z]))\b)[^/\s"',;<>]+)+?(?=$|["'<>)](?!\/)|[,;.]|\s+(?:before|after|then|and(?=\s+(?:[a-z]|[~/"']|$))|or(?=\s+(?:[a-z]|[~/"']|$)))\b)/g,
53
+ replacement: "[REDACTED_PATH]",
54
+ },
55
+ {
56
+ name: "absolute-posix-path",
57
+ pattern: /\/(?:[^/\s"',;<>]+(?: [^/\s"',;<>]+)*\/)*[^/\s"',;<>]+?(?=$|[\s"',;<>)]|[.,](?:\s|$))/g,
58
+ replacement: "[REDACTED_PATH]",
59
+ },
60
+ {
61
+ name: "absolute-windows-spaced-path",
62
+ pattern:
63
+ /\b[A-Za-z]:\\(?:[^\\/:*?"<>|,;\r\n]+\\)*[^\\/:*?"<>|,;\s\r\n]+(?: [^\\/:*?"<>|,;\s\r\n]+)+\.[A-Za-z0-9_-]+/g,
64
+ replacement: "[REDACTED_PATH]",
65
+ },
66
+ {
67
+ name: "absolute-windows-spaced-extensionless-path",
68
+ pattern:
69
+ /\b[A-Za-z]:\\(?:[^\\/:*?"<>|,;\r\n]+\\)*[^\\/:*?"<>|,;\s\r\n]+(?: [^\\/:*?"<>|,;\s\r\n]+)+(?=$|["'<>)](?!\\)|[,;.]|\s+(?:before|after|then|and|or)\b)/gi,
70
+ replacement: "[REDACTED_PATH]",
71
+ },
72
+ {
73
+ name: "absolute-windows-path",
74
+ pattern: /\b[A-Za-z]:\\(?:[^\\/:*?"<>|,;\r\n]+\\)*[^\\/:*?"<>|,;\s\r\n]+/g,
75
+ replacement: "[REDACTED_PATH]",
76
+ },
77
+ {
78
+ name: "unc-windows-spaced-path",
79
+ pattern:
80
+ /\\\\[^\\/:*?"<>|,;\s\r\n]+\\[^\\/:*?"<>|,;\r\n]+\\(?:[^\\/:*?"<>|,;\r\n]+\\)*[^\\/:*?"<>|,;\s\r\n]+(?: [^\\/:*?"<>|,;\s\r\n]+)+\.[A-Za-z0-9_-]+/g,
81
+ replacement: "[REDACTED_PATH]",
82
+ },
83
+ {
84
+ name: "unc-windows-spaced-extensionless-path",
85
+ pattern:
86
+ /\\\\[^\\/:*?"<>|,;\s\r\n]+\\[^\\/:*?"<>|,;\r\n]+\\(?:[^\\/:*?"<>|,;\r\n]+\\)*[^\\/:*?"<>|,;\s\r\n]+(?: [^\\/:*?"<>|,;\s\r\n]+)+(?=$|["'<>)](?!\\)|[,;.]|\s+(?:before|after|then|and|or)\b)/gi,
87
+ replacement: "[REDACTED_PATH]",
88
+ },
89
+ {
90
+ name: "unc-windows-share-root",
91
+ pattern: /\\\\[^\\/:*?"<>|,;\s\r\n]+\\[^\\/:*?"<>|,;\s\r\n]+(?=$|[\s"',;<>)]|[.,](?:\s|$))/g,
92
+ replacement: "[REDACTED_PATH]",
93
+ },
94
+ {
95
+ name: "unc-windows-path",
96
+ pattern: /\\\\[^\\/:*?"<>|,;\s\r\n]+\\[^\\/:*?"<>|,;\r\n]+\\(?:[^\\/:*?"<>|,;\r\n]+\\)*[^\\/:*?"<>|,;\s\r\n]+/g,
97
+ replacement: "[REDACTED_PATH]",
98
+ },
99
+ ];
100
+
101
+ function cloneRedactionRule(rule: CompiledRedactionRule): CompiledRedactionRule {
102
+ return {
103
+ name: rule.name,
104
+ pattern: new RegExp(rule.pattern.source, rule.pattern.flags),
105
+ replacement: rule.replacement,
106
+ };
107
+ }
108
+
109
+ export function compileRedactionRules(config: RedactionConfig = {}): CompiledRedactionRule[] {
110
+ const rules: CompiledRedactionRule[] =
111
+ config.disableDefaults === true ? [] : DEFAULT_REDACTION_RULES.map(cloneRedactionRule);
112
+ const customRules = config.rules ?? [];
113
+ if (!Array.isArray(customRules)) {
114
+ throw new Error("redaction rules must be an array");
115
+ }
116
+ for (const rule of customRules) {
117
+ if (!rule || typeof rule !== "object") {
118
+ throw new Error("redaction rule must be an object");
119
+ }
120
+ if (typeof rule.name !== "string" || rule.name.trim().length === 0) {
121
+ throw new Error("redaction rule name must be a non-empty string");
122
+ }
123
+ if (typeof rule.pattern !== "string" || rule.pattern.length === 0) {
124
+ throw new Error(`redaction rule '${rule.name}' pattern must be non-empty`);
125
+ }
126
+ const flags = rule.flags ?? "g";
127
+ rules.push({
128
+ name: rule.name.trim(),
129
+ pattern: new RegExp(rule.pattern, flags.includes("g") ? flags : `${flags}g`),
130
+ replacement: rule.replacement ?? `[REDACTED_${rule.name.trim().toUpperCase()}]`,
131
+ });
132
+ }
133
+ return rules;
134
+ }
135
+
136
+ export function redactSessionSummaryText(
137
+ text: string,
138
+ rules: readonly CompiledRedactionRule[]
139
+ ): { text: string; report: RedactionReport } {
140
+ let redacted = text;
141
+ let applied = 0;
142
+ const ruleNames = new Set<string>();
143
+
144
+ for (const rule of rules) {
145
+ rule.pattern.lastIndex = 0;
146
+ const matches = redacted.match(rule.pattern);
147
+ if (!matches || matches.length === 0) continue;
148
+ applied += matches.length;
149
+ ruleNames.add(rule.name);
150
+ redacted = redacted.replace(rule.pattern, rule.replacement);
151
+ }
152
+
153
+ return {
154
+ text: redacted,
155
+ report: {
156
+ applied,
157
+ ruleNames: [...ruleNames].sort(),
158
+ },
159
+ };
160
+ }