@pugi/cli 0.1.0-beta.20 → 0.1.0-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/core/bare-mode/index.js +107 -0
  2. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  3. package/dist/core/engine/native-pugi.js +21 -10
  4. package/dist/core/engine/prompts.js +30 -2
  5. package/dist/core/engine/tool-bridge.js +32 -0
  6. package/dist/core/feedback/queue.js +177 -0
  7. package/dist/core/feedback/submitter.js +145 -0
  8. package/dist/core/onboarding/marker.js +111 -0
  9. package/dist/core/onboarding/telemetry-state.js +108 -0
  10. package/dist/core/output-style/presets.js +176 -0
  11. package/dist/core/output-style/state.js +185 -0
  12. package/dist/core/permissions/index.js +1 -1
  13. package/dist/core/permissions/state.js +55 -0
  14. package/dist/core/repl/session.js +375 -12
  15. package/dist/core/repl/slash-commands.js +99 -1
  16. package/dist/core/repl/workspace-context.js +22 -0
  17. package/dist/core/share/formatter.js +271 -0
  18. package/dist/core/share/redactor.js +221 -0
  19. package/dist/core/share/uploader.js +267 -0
  20. package/dist/core/todos/invariant.js +10 -0
  21. package/dist/core/todos/state.js +177 -0
  22. package/dist/runtime/cli.js +386 -1
  23. package/dist/runtime/commands/doctor.js +8 -0
  24. package/dist/runtime/commands/feedback.js +184 -0
  25. package/dist/runtime/commands/onboarding.js +275 -0
  26. package/dist/runtime/commands/plan.js +143 -0
  27. package/dist/runtime/commands/share.js +316 -0
  28. package/dist/runtime/commands/stickers.js +82 -0
  29. package/dist/runtime/commands/style.js +194 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tools/registry.js +8 -0
  32. package/dist/tools/todo-write.js +184 -0
  33. package/dist/tui/compact-banner.js +28 -1
  34. package/dist/tui/conversation-pane.js +13 -0
  35. package/dist/tui/feedback-prompt.js +156 -0
  36. package/dist/tui/onboarding-wizard.js +240 -0
  37. package/dist/tui/repl-render.js +9 -1
  38. package/dist/tui/stickers-art.js +136 -0
  39. package/dist/tui/style-table.js +22 -0
  40. package/package.json +2 -2
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Markdown transcript formatter used by `pugi share` (Leak L20, 2026-05-27).
3
+ *
4
+ * Walks the session's `.pugi/events.jsonl` audit log and reconstructs a
5
+ * Markdown document the operator (and downstream Gist / pugi.io readers)
6
+ * can read top-to-bottom. The format is intentionally human-first — turn
7
+ * headers, code-block-wrapped tool I/O, and a small front-matter block
8
+ * with session metadata. Machine-readability is a non-goal here; the
9
+ * JSONL log remains the source of truth for tooling.
10
+ *
11
+ * Why we reconstruct from JSONL instead of from the live REPL state:
12
+ *
13
+ * - The CLI top-level `pugi share` command runs from a fresh shell and
14
+ * has no in-memory REPL state to read; only the event log is
15
+ * persisted.
16
+ * - The in-REPL `/share` slash uses the same handler so behaviour is
17
+ * identical regardless of entry point. Operators sharing a session
18
+ * from inside the REPL get the exact same output they would get from
19
+ * a follow-up shell command.
20
+ * - The JSONL log is append-only and survives REPL crashes, so a
21
+ * `--share` after a crash is the most useful debug surface.
22
+ *
23
+ * Event vocabulary the formatter knows about (see
24
+ * `packages/pugi-sdk/src/audit-trace.ts` for the schema):
25
+ *
26
+ * session.created Session boundary; emits front matter.
27
+ * session.command_started One-line "running pugi <cmd>" header.
28
+ * session.command_completed Status line ("success" / "error").
29
+ * tool_call Markdown turn header + inputSummary
30
+ * rendered as a fenced block.
31
+ * tool_result "Result (status):" + outputSummary
32
+ * rendered as a fenced block.
33
+ * file_mutation Inline `path` + operation summary.
34
+ * subagent.* Indented "[subagent <role>] ..." line.
35
+ * hook.* Quiet "[hook <event>] ..." line.
36
+ * compaction.* "[compaction <tier>] ..." line.
37
+ *
38
+ * Unknown event types are surfaced as a single italic line ("[event
39
+ * type=...]") so a future event added to the SDK does not silently
40
+ * vanish from the transcript.
41
+ *
42
+ * Performance: the formatter is O(n) over the events file, runs entirely
43
+ * in memory, and is bounded by the session log size (currently capped at
44
+ * a few MB per session). No streaming I/O is needed for typical sessions
45
+ * — the operator does not run /share against multi-GB logs.
46
+ */
47
+ /**
48
+ * Format a session's event log as Markdown. Pure — no I/O. The caller
49
+ * reads `.pugi/events.jsonl` and hands the contents in.
50
+ */
51
+ export function formatTranscript(input) {
52
+ const now = input.now ? input.now() : new Date();
53
+ const events = parseEvents(input.eventsJsonl);
54
+ const filteredSessionId = pickSessionId(input.sessionId, events);
55
+ const sessionEvents = events.filter((e) => typeof e.raw.sessionId !== 'string' || e.raw.sessionId === filteredSessionId);
56
+ const lines = [];
57
+ // Front matter — a fenced block at the top so downstream readers can
58
+ // grok the context before any turn content. Not YAML front matter
59
+ // (`---`) because Gist + pugi.io render Markdown directly; the fenced
60
+ // approach renders predictably without a parser.
61
+ lines.push('# Pugi session transcript');
62
+ lines.push('');
63
+ lines.push('```');
64
+ lines.push(`session_id: ${filteredSessionId}`);
65
+ lines.push(`workspace: ${input.workspaceRoot}`);
66
+ lines.push(`cli_version: ${input.cliVersion}`);
67
+ lines.push(`exported_at: ${now.toISOString()}`);
68
+ lines.push(`event_count: ${sessionEvents.length}`);
69
+ lines.push('```');
70
+ lines.push('');
71
+ if (sessionEvents.length === 0) {
72
+ lines.push('_No events recorded for this session._');
73
+ return {
74
+ markdown: `${lines.join('\n')}\n`,
75
+ turnCount: 0,
76
+ eventCount: 0,
77
+ };
78
+ }
79
+ let turnCount = 0;
80
+ for (const event of sessionEvents) {
81
+ const rendered = renderEvent(event);
82
+ if (rendered === null)
83
+ continue;
84
+ lines.push(...rendered.lines);
85
+ lines.push('');
86
+ if (rendered.isTurn)
87
+ turnCount += 1;
88
+ }
89
+ return {
90
+ markdown: `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`,
91
+ turnCount,
92
+ eventCount: sessionEvents.length,
93
+ };
94
+ }
95
+ /**
96
+ * Parse the JSONL log. Malformed lines are skipped silently — the file
97
+ * is append-only and may have partial-write tail rows. Returning the
98
+ * stable typed shape lets the formatter walk without re-checking every
99
+ * field.
100
+ */
101
+ function parseEvents(raw) {
102
+ const out = [];
103
+ for (const line of raw.split('\n')) {
104
+ const trimmed = line.trim();
105
+ if (trimmed.length === 0)
106
+ continue;
107
+ try {
108
+ const parsed = JSON.parse(trimmed);
109
+ const type = typeof parsed.type === 'string' ? parsed.type : '';
110
+ const timestamp = typeof parsed.timestamp === 'string' ? parsed.timestamp : '';
111
+ if (type.length === 0)
112
+ continue;
113
+ out.push({ raw: parsed, type, timestamp });
114
+ }
115
+ catch {
116
+ // partial-write or corrupt row; skip without affecting the rest
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * Resolve the effective session id. When the operator passes a non-empty
123
+ * value we honour it. When they pass an empty string / placeholder
124
+ * (`'no-session'`), we fall back to the newest `session.created` event
125
+ * id in the file. Last-resort fallback is the literal placeholder so the
126
+ * transcript still renders something meaningful in the front matter.
127
+ */
128
+ function pickSessionId(provided, events) {
129
+ if (provided && provided !== 'no-session')
130
+ return provided;
131
+ for (let i = events.length - 1; i >= 0; i -= 1) {
132
+ const e = events[i];
133
+ if (!e)
134
+ continue;
135
+ if (e.type === 'session' && e.raw.name === 'created' && typeof e.raw.sessionId === 'string') {
136
+ return e.raw.sessionId;
137
+ }
138
+ }
139
+ return provided || 'unknown-session';
140
+ }
141
+ /**
142
+ * Format one event as a Markdown block. Returns `null` to suppress the
143
+ * event entirely (e.g. session.created is captured in front matter so we
144
+ * skip it here).
145
+ */
146
+ function renderEvent(event) {
147
+ const ts = event.timestamp || '';
148
+ switch (event.type) {
149
+ case 'session': {
150
+ const name = String(event.raw.name ?? '');
151
+ if (name === 'created')
152
+ return null; // captured by front matter
153
+ if (name === 'command_started') {
154
+ const command = String(event.raw.command ?? '');
155
+ return {
156
+ lines: [`## ${ts} — command \`${escapeInline(command)}\``],
157
+ isTurn: false,
158
+ };
159
+ }
160
+ if (name === 'command_completed') {
161
+ const command = String(event.raw.command ?? '');
162
+ const status = String(event.raw.status ?? 'unknown');
163
+ return {
164
+ lines: [`_command \`${escapeInline(command)}\` finished: ${status}_`],
165
+ isTurn: false,
166
+ };
167
+ }
168
+ return {
169
+ lines: [`_session ${name}_`],
170
+ isTurn: false,
171
+ };
172
+ }
173
+ case 'tool_call': {
174
+ const tool = String(event.raw.tool ?? 'unknown');
175
+ const summary = String(event.raw.inputSummary ?? '');
176
+ const out = [`### ${ts} — tool \`${escapeInline(tool)}\``];
177
+ if (summary.length > 0) {
178
+ out.push('');
179
+ out.push('Input:');
180
+ out.push(fenced(summary));
181
+ }
182
+ return { lines: out, isTurn: true };
183
+ }
184
+ case 'tool_result': {
185
+ const status = String(event.raw.status ?? 'unknown');
186
+ const summary = String(event.raw.outputSummary ?? '');
187
+ const out = [`Result (${status}):`];
188
+ if (summary.length > 0) {
189
+ out.push(fenced(summary));
190
+ }
191
+ return { lines: out, isTurn: false };
192
+ }
193
+ case 'file_mutation': {
194
+ const path = String(event.raw.path ?? '');
195
+ const op = String(event.raw.operation ?? '');
196
+ return {
197
+ lines: [`- file ${op}: \`${escapeInline(path)}\``],
198
+ isTurn: false,
199
+ };
200
+ }
201
+ case 'subagent.spawned':
202
+ case 'subagent.tool_call':
203
+ case 'subagent.completed':
204
+ case 'subagent.blocked':
205
+ case 'subagent.failed': {
206
+ const role = String(event.raw.role ?? '');
207
+ const persona = String(event.raw.personaSlug ?? '');
208
+ const detail = String(event.raw.detail ?? event.raw.error ?? event.raw.toolName ?? '');
209
+ const tail = detail.length > 0 ? ` ${detail}` : '';
210
+ return {
211
+ lines: [`_[subagent ${role} / ${persona}] ${event.type}${tail}_`],
212
+ isTurn: false,
213
+ };
214
+ }
215
+ case 'hook.invoked':
216
+ case 'hook.result':
217
+ case 'hook.skipped': {
218
+ const ev = String(event.raw.event ?? '');
219
+ const reason = String(event.raw.reason ?? event.raw.runSummary ?? event.raw.matchSummary ?? '');
220
+ const tail = reason.length > 0 ? ` ${reason}` : '';
221
+ return {
222
+ lines: [`_[hook ${ev}] ${event.type.replace('hook.', '')}${tail}_`],
223
+ isTurn: false,
224
+ };
225
+ }
226
+ case 'compaction.started':
227
+ case 'compaction.completed':
228
+ case 'compaction.skipped':
229
+ case 'compaction.invariant_violated': {
230
+ const tier = String(event.raw.tier ?? '');
231
+ return {
232
+ lines: [`_[compaction ${tier}] ${event.type.replace('compaction.', '')}_`],
233
+ isTurn: false,
234
+ };
235
+ }
236
+ default: {
237
+ return {
238
+ lines: [`_[event type=${event.type}]_`],
239
+ isTurn: false,
240
+ };
241
+ }
242
+ }
243
+ }
244
+ /**
245
+ * Wrap a string in a fenced code block. Pick a fence length that does
246
+ * not collide with backtick runs inside the content. Markdown 0.30 allows
247
+ * variable-length fences; we pick the shortest that is longer than the
248
+ * longest run inside the content (min 3, max 7).
249
+ */
250
+ function fenced(content) {
251
+ const longestRun = (content.match(/`+/g) ?? [])
252
+ .map((s) => s.length)
253
+ .reduce((max, n) => (n > max ? n : max), 0);
254
+ const fenceLen = Math.min(7, Math.max(3, longestRun + 1));
255
+ const fence = '`'.repeat(fenceLen);
256
+ // Trim trailing whitespace inside content so the closing fence sits
257
+ // tight against the body; preserve leading whitespace (matters for code).
258
+ return `${fence}\n${content.replace(/\s+$/u, '')}\n${fence}`;
259
+ }
260
+ /**
261
+ * Escape inline-Markdown specials (backtick, pipe) inside a span that we
262
+ * are wrapping in inline code. The closing backtick rule says a `<code>`
263
+ * span can contain backticks as long as the fence length differs — for
264
+ * simplicity we replace bare backticks with a Unicode look-alike when
265
+ * they appear in identifier-like positions (e.g. paths or tool names).
266
+ * Backticks in real content go through `fenced()` instead.
267
+ */
268
+ function escapeInline(text) {
269
+ return text.replace(/`/g, 'ˋ');
270
+ }
271
+ //# sourceMappingURL=formatter.js.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * PII redactor used by `pugi share --redact` (Leak L20, 2026-05-27).
3
+ *
4
+ * Zero-dependency regex-based redaction over a Markdown transcript. We
5
+ * intentionally do NOT pull in `apps/admin-api/src/privacy/regex-scrubber.ts`
6
+ * because the CLI is a stand-alone npm package: customers install
7
+ * `@pugi/cli` globally, no admin-api binary is present. The pattern set
8
+ * here mirrors the high-signal subset of the admin-api `RegexScrubber`
9
+ * catalog (apps/admin-api/src/privacy/regex-scrubber.ts) so audit downstream
10
+ * sees the same `[REDACTED:<CATEGORY>:<HASH8>]` token shape regardless of
11
+ * which side scrubs.
12
+ *
13
+ * Coverage (high-signal, low-false-positive):
14
+ *
15
+ * EMAIL user@example.com (RFC-5322 simplified)
16
+ * PHONE +1-555-123-4567 / (555) 123-4567 / 555 123 4567
17
+ * IPV4 1.2.3.4 with octet bounds check
18
+ * API_KEY_OPENAI sk-..., sk-proj-..., sk-svcacct-...
19
+ * API_KEY_ANTHROPIC sk-ant-...
20
+ * API_KEY_GOOGLE AIza...
21
+ * API_KEY_GITHUB ghp_/gho_/ghu_/ghs_/ghr_..., github_pat_...
22
+ * API_KEY_PUGI pugi_live_..., pugi_sk_..., anvil_*_...
23
+ * API_KEY_AWS AKIA... / ASIA...
24
+ * BEARER_TOKEN "Bearer <token>" auth headers (also used by the
25
+ * credential heuristic to refuse upload)
26
+ * JWT eyJ...header.eyJ...payload.signature
27
+ * STRIPE_ID sk_live_..., pk_live_..., whsec_...
28
+ *
29
+ * Out of scope (matches the admin-api RegexScrubber posture):
30
+ *
31
+ * - PERSON / ORG / GPE named entities (L2 NER, no CLI dep)
32
+ * - Free-form addresses
33
+ * - Date-of-birth in prose
34
+ *
35
+ * Token shape `[REDACTED:<CATEGORY>:<HASH8>]` matches the admin-api L1
36
+ * convention (SHA-256 first 8 chars of the original match). The hash is
37
+ * stable across runs so an operator who re-runs `--redact` on the same
38
+ * transcript sees identical tokens — useful for diffing two exports.
39
+ */
40
+ import { createHash } from 'node:crypto';
41
+ function hash8(text) {
42
+ return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 8);
43
+ }
44
+ function token(category, original) {
45
+ return `[REDACTED:${category}:${hash8(original)}]`;
46
+ }
47
+ /**
48
+ * IPv4 octet bounds. The catch-all `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`
49
+ * matches `999.999.999.999` and version strings like `4.5.6.7`. We reject
50
+ * any match where an octet exceeds 255. Loopback / placeholder addresses
51
+ * (`0.0.0.0`) are also rejected so config-doc snippets do not get redacted
52
+ * into noise.
53
+ */
54
+ function ipv4Valid(match) {
55
+ const parts = match.split('.');
56
+ if (parts.length !== 4)
57
+ return false;
58
+ for (const p of parts) {
59
+ const n = Number.parseInt(p, 10);
60
+ if (Number.isNaN(n) || n < 0 || n > 255)
61
+ return false;
62
+ }
63
+ if (match === '0.0.0.0')
64
+ return false;
65
+ return true;
66
+ }
67
+ /**
68
+ * Catalog. Order matters: prefixed API-key rules first so the broader
69
+ * `sk-` pattern does not shadow `sk-ant-` / `sk-proj-`. JWT before
70
+ * BEARER_TOKEN so a `Bearer eyJ...` header redacts the JWT specifically
71
+ * rather than the generic bearer prefix.
72
+ */
73
+ const RULES = [
74
+ // Stripe IDs (livemode + testmode). Catches the secret-key form too;
75
+ // operators paste these into chats more often than they should.
76
+ {
77
+ category: 'STRIPE_ID',
78
+ pattern: /\b(?:cus|sub|pi|ch|acct|seti|prod|price|in|re|whsec|sk_live|sk_test|pk_live|pk_test)_[A-Za-z0-9]{14,}\b/g,
79
+ },
80
+ // Pugi / Anvil API keys.
81
+ {
82
+ category: 'API_KEY_PUGI',
83
+ pattern: /\b(?:pugi|anvil)_(?:live|test|sk)_[A-Za-z0-9_-]{20,}\b/g,
84
+ },
85
+ // Anthropic API keys.
86
+ {
87
+ category: 'API_KEY_ANTHROPIC',
88
+ pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
89
+ },
90
+ // OpenAI API keys (classic sk-, project-scoped sk-proj-, service-acct
91
+ // sk-svcacct-).
92
+ {
93
+ category: 'API_KEY_OPENAI',
94
+ pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}\b/g,
95
+ },
96
+ // Google API keys (Maps, Gemini, Cloud).
97
+ {
98
+ category: 'API_KEY_GOOGLE',
99
+ pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g,
100
+ },
101
+ // GitHub PATs (classic + fine-grained).
102
+ {
103
+ category: 'API_KEY_GITHUB',
104
+ pattern: /\b(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/g,
105
+ },
106
+ // AWS access keys.
107
+ {
108
+ category: 'API_KEY_AWS',
109
+ pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
110
+ },
111
+ // JWT (3-segment dot-delimited base64url).
112
+ {
113
+ category: 'JWT',
114
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
115
+ },
116
+ // Bearer token. The credential heuristic in `containsActiveCredential`
117
+ // ALSO fires on this prefix to refuse the upload entirely.
118
+ {
119
+ category: 'BEARER_TOKEN',
120
+ pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{16,}/g,
121
+ },
122
+ // Email.
123
+ {
124
+ category: 'EMAIL',
125
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
126
+ },
127
+ // E.164 + permissive US/EU phone. International prefix optional;
128
+ // separators allowed (-, space, parens).
129
+ {
130
+ category: 'PHONE',
131
+ pattern: /(?<![A-Za-z0-9.])(?:\+?\d{1,3}[\s-])?(?:\(\d{1,4}\)\s?)?\d{2,4}[\s-]\d{2,4}(?:[\s-]\d{2,9})?(?![A-Za-z0-9.])/g,
132
+ validate: (m) => {
133
+ const digits = m.replace(/\D+/g, '');
134
+ return digits.length >= 7 && digits.length <= 15;
135
+ },
136
+ },
137
+ // IPv4 with bounds check. Order: AFTER all alphanumeric-prefixed rules
138
+ // so a version string like `4.5.6.7` inside a longer SHA-key match
139
+ // never reaches us here.
140
+ {
141
+ category: 'IPV4',
142
+ pattern: /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
143
+ validate: ipv4Valid,
144
+ },
145
+ ];
146
+ /**
147
+ * Redact PII from a Markdown transcript. The output substitutes high-
148
+ * signal patterns with `[REDACTED:<CATEGORY>:<HASH8>]` tokens. Findings
149
+ * are aggregated by category so the privacy gate can surface a
150
+ * compact "Redacted 3 PII spans (2 EMAIL, 1 API_KEY_OPENAI)" line.
151
+ *
152
+ * Idempotency: re-running over an already-redacted transcript will not
153
+ * double-redact because the token form `[REDACTED:...]` matches none of
154
+ * the patterns. This makes `--redact --preview` followed by `--redact`
155
+ * safe — operator can inspect first, then commit to the upload, and the
156
+ * second redact pass is a no-op.
157
+ */
158
+ export function redactPii(input) {
159
+ if (input.length === 0) {
160
+ return { output: '', findings: [], totalSpans: 0 };
161
+ }
162
+ let output = input;
163
+ const counts = new Map();
164
+ for (const rule of RULES) {
165
+ output = output.replace(rule.pattern, (match) => {
166
+ if (rule.validate && !rule.validate(match))
167
+ return match;
168
+ counts.set(rule.category, (counts.get(rule.category) ?? 0) + 1);
169
+ return token(rule.category, match);
170
+ });
171
+ }
172
+ const findings = [];
173
+ for (const [category, count] of counts.entries()) {
174
+ findings.push({ category, count });
175
+ }
176
+ // Stable order so the gate banner is deterministic across runs.
177
+ findings.sort((a, b) => b.count !== a.count ? b.count - a.count : a.category.localeCompare(b.category));
178
+ const totalSpans = findings.reduce((acc, f) => acc + f.count, 0);
179
+ return { output, findings, totalSpans };
180
+ }
181
+ /**
182
+ * Heuristic: does the transcript carry an active credential token that
183
+ * MUST refuse upload regardless of `--redact`? Surfaces as a hard gate
184
+ * before any upload path even with redaction enabled — the operator's
185
+ * intent to share a credential is itself a footgun (the credential
186
+ * leaves their machine before the redactor runs). The privacy gate calls
187
+ * this BEFORE running `redactPii`.
188
+ *
189
+ * The check is intentionally narrower than the redactor catalog: we only
190
+ * refuse on `Bearer ` prefix (the most common live-auth-header form) so
191
+ * we do not block a legitimate share that contains an old expired API
192
+ * key referenced in a code comment. Operators can disable the heuristic
193
+ * with `--allow-credentials` (NOT in scope for L20 — the refusal is
194
+ * absolute today).
195
+ */
196
+ export function containsActiveCredential(input) {
197
+ if (input.length === 0)
198
+ return false;
199
+ return /Bearer\s+[A-Za-z0-9._~+/=-]{16,}/.test(input);
200
+ }
201
+ /**
202
+ * Format the findings array as a short human-readable summary used in
203
+ * the privacy gate banner. Example output:
204
+ *
205
+ * "Redacted 3 PII spans (2 EMAIL, 1 API_KEY_OPENAI)"
206
+ *
207
+ * Falls back to "Redacted 0 PII spans" when nothing matched — surfaces
208
+ * a clean gate so the operator knows the redact pass did run.
209
+ */
210
+ export function summariseFindings(result) {
211
+ if (result.totalSpans === 0) {
212
+ return 'Redacted 0 PII spans (transcript appears clean).';
213
+ }
214
+ const top = result.findings
215
+ .slice(0, 4)
216
+ .map((f) => `${f.count} ${f.category}`)
217
+ .join(', ');
218
+ const tail = result.findings.length > 4 ? `, ${result.findings.length - 4} more` : '';
219
+ return `Redacted ${result.totalSpans} PII spans (${top}${tail}).`;
220
+ }
221
+ //# sourceMappingURL=redactor.js.map