Replaces personal data in prompts and tool results with numbered placeholders (⟦EMAIL_0⟧, ⟦PERSON_1⟧) found by openai/privacy-filter, and restores them in tool…

Claude Code Personally Identifiable Information.
A Claude Code mod that keeps personal data away from the model. Before the model reads a prompt or a tool result, openai/privacy-filter runs over the text locally and each detected span is replaced with a numbered placeholder of its kind: ⟦EMAIL_0⟧, ⟦PERSON_1⟧, ⟦PHONE_0⟧. The same value always gets the same placeholder for the whole session. Only text in the ⟦⟧ brackets is a placeholder, so a bare EMAIL_0 in code or docs is left as it is and never swapped for a real value.
Placeholder kinds: ACCOUNT, ADDRESS, DATE, EMAIL, PERSON, PHONE, URL, SECRET.
From agent-plugins:
claude plugin marketplace add https://github.com/xchacha20-poly1305/agent-plugins.git
claude plugin install ccpii@anrong-plugins
Needs uv on PATH. Then, in a session:
/ccpii install
It installs the Python dependencies into uv's cache and downloads the model's int8 ONNX export with its config and Viterbi calibration (about 1.6 GB) to ~/.cache/ccpii/privacy-filter. Until it is installed everything is withheld; /ccpii pause lets content through meanwhile. The same install from a shell:
uv run --script server/pii_server.py --download
On an Intel GPU with XMX matrix engines (Arc, including the Arc 130T/140T in Core Ultra 200H), the filter can run its forward pass there, about five times as fast on long texts (a 15 KB file: 0.75 s against 3.8 s). ccpii does not make the GPU model: the model's ONNX exports use onnxruntime-only operators, and the conversion to OpenVINO depends on exact library versions. With an OpenVINO model at ~/.cache/ccpii/privacy-filter/openvino-int8/model.xml, the sidecar uses the GPU, keeps the CPU model loaded beside it, and gives each GPU call 5 s before the CPU answers instead: with system memory short, the i915 driver has been seen to hang the GPU's compute work. A converted model is at xchacha20-poly1305/privacy-filter-openvino; GPU.md says how to put it in place, what the model must be, how the fallback works, and how it was converted. /ccpii status says which device answers and how often the GPU has fallen back.
Set the Detection endpoint option (/config › ccpii) to the URL of any service that answers ccpii's small HTTP protocol, and ccpii uses it in place of the bundled filter. BACKENDS.md describes the protocol and ships two ready-made endpoints: any Hugging Face token-classification model, and Microsoft Presidio.
Some of what the model reports is no private data: the model takes a company or a product named after a person (Morgan Stanley, Julia) for a person, and a person's name is often public, a git author or a colleague in a review. Two options (/config › ccpii) leave such spans unmasked:
| Option | What it takes | What it leaves unmasked |
|---|---|---|
| Kinds left unmasked | Placeholder kinds, comma-separated: PERSON, URL. A detector's label works too (private_person, or another backend's PER). | Every span of those kinds |
| Words left unmasked | Words, comma-separated, in any case: your login name, a product name. | Every span that holds one of them, so a whole ls -l owner column, or Charles Darwin for darwin |
A word counts in a span unless it runs on into ASCII letters or digits there: ann is not in Joanna, while 张伟 is in 张伟明天, since Chinese has no spaces between words. /ccpii status lists what the options leave unmasked.
Three mistakes of the model are left unmasked without any option: a hex digest taken for an account number (a git commit, a sha256), a file path taken for a URL (when a text is a path and nothing else, as pwd prints), and a file mode taken for a person (drwxr-xr-x in ls -l). A path is one that starts with /, ~, . or a drive letter, or whose first segment has no dot, port or user, unlike a host's (hooks/vault.ts against example.com/a); a URL with a scheme is never taken for a path.
/ccpiiIt runs at once, mid-turn too, so it works even while the filter holds everything back.
| Command | What it does |
|---|---|
/ccpii status | What the filter is doing, how many placeholders there are |
/ccpii install | Download the model |
/ccpii restart | Start the filter process again |
/ccpii pause | Let content reach the model unfiltered, until /ccpii resume or a reload |
/ccpii resume | Filter again |
Each session starts one sidecar process (server/pii_server.py) serving POST /detect on a Unix socket in a private directory under /tmp. The forward pass runs on onnxruntime (CPU); tokenizing, windows, Viterbi decoding and character offsets are opf's own code, pinned to one commit. It takes about 2 s to load, about 2.2 GB of memory, and roughly 1 s per 4 KB of text. Claude Code gives each request 30 s, so a long text goes in overlapping pieces, at most 16,384 UTF-16 code units a request, one request at a time. A sidecar that was ready and then dies is started again by the next request; one that dies before it is ready is not retried, and /ccpii restart starts it. Either way the directory it left behind is removed at the next start, after a reload of the mod too.
The placeholder table of each session is saved to ~/.claude/ccpii/v2/<session-id>.json (directory mode 700), so a resumed session can still restore its placeholders.
hooks/register.ts: the hooks and /ccpii.hooks/vault.ts: the placeholder table (masking and restoring).hooks/policy.ts: which detected spans are masked (the options and the model's known mistakes).hooks/filter.ts: runs the sidecar and calls it.server/pii_server.py: the sidecar and the install.server/gpu_worker.py: runs a GPU model (GPU.md), a child of the sidecar.examples/: detection endpoints for other backends (BACKENDS.md).IBAN, companies and products named after people (Morgan Stanley, Julia) and random path names for people, and a user name in ls -l output for a person; it missed some Chinese names, and ran one on into the words after it. What is left unmasked says what is done about it. It is a data-minimisation aid, not an anonymisation guarantee.MIT
hooks/register.ts 377 lines1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Register } from 'claude-code'
3
4import type { Vault } from '../types'
5import {
6 configureFilter,
7 describeFilter,
8 describeGpu,
9 detect,
10 filterStatus,
11 installFilter,
12 startFilter,
13} from './filter'
14import { describePolicy, maskedSpans, policyOf } from './policy'
15import type { Policy } from './policy'
16import { CLOSE, KINDS, OPEN, emptyVault, mask, placeholderOf, unmask, unmaskDeep } from './vault'
17
18type Blocks = Args<'session.append'>['message']['content']
19
20// Change the tag when Vault's fields change: it declines what an earlier
21// version stored, which outlives a reload.
22const vaultAtom = atom({ plugin: 'ccpii', key: 'vault' } as const, emptyVault(''), {
23 shape: 'vault-2',
24})
25
26/**
27 * Attachments that are the engine's own prose (a reminder, a listing, a mode
28 * change), never the person's data: masking them garbles what the engine
29 * tells the model. Every other attachment is masked, one whose name is new
30 * included, since a file the person mentioned is an attachment too.
31 */
32const ENGINE_PROSE = new Set([
33 'total_tokens_reminder',
34 'deferred_tools_delta',
35 'todo_reminder',
36 'plan_mode',
37 'plan_mode_reentry',
38 'plan_mode_exit',
39])
40
41const WITHHELD = '[ccpii] Withheld:'
42
43const NOTICE = {
44 id: 'ccpii:notice',
45 scope: 'session',
46 text: `# Privacy placeholders (ccpii)
47
48A privacy filter runs in this session. Before you read them, the user's messages and every tool result have personal or secret data replaced by placeholders of the form ${OPEN}KIND_N${CLOSE}, KIND being what was detected: usually one of ${[
49 ...new Set(Object.values(KINDS)),
50 ].join(', ')}, or another detector's label (for example ${placeholderOf('EMAIL', 0)}, ${placeholderOf('PERSON', 1)}). Only text in these brackets is a placeholder: a bare word that looks like one is just text. The same placeholder always stands for the same real value for the whole session, and different values get different numbers.
51
52- Treat each placeholder as the value it stands for. Write it verbatim wherever you need that value, including inside tool call arguments (file paths, shell commands, file contents, edit strings): the placeholders are replaced with the real values just before a tool runs, and the tool's output is filtered again before you see it.
53- Do not guess, reconstruct or ask for the real values, and do not invent placeholders that you have not seen.
54- In replies to the user, use the placeholders as they are; the user's screen shows the real values in their place.
55- Content that starts with "${WITHHELD}" was held back because the filter could not check it; tell the user the reason it gives instead of guessing what it held.`,
56} as const
57
58const USAGE = `/ccpii status what the filter is doing
59/ccpii install download the privacy-filter model (about 1.6 GB)
60/ccpii restart start the filter process again
61/ccpii pause let content reach the model unfiltered until /ccpii resume or a reload
62/ccpii resume filter again`
63
64/** This session's table, as loaded or last changed; reset by a reload, restored from `$.state`. */
65let live: Vault | undefined
66/** Kept in the module alone, so a reload always filters again. */
67let isPaused = false
68let saving: Promise<void> = Promise.resolve()
69let isSaveDirMade = false
70/** The conversation whose notice this module has claimed, and whether it was this module's to add. */
71let noticeClaim: { sessionId: string; isNew: Promise<boolean> } | undefined
72
73export const register: Register = (on, options) => {
74 const policy = policyOf(String(options.unmaskedKinds ?? ''), String(options.unmaskedWords ?? ''))
75
76 on('session.start', async ($, e, next) => {
77 const started = await next(e)
78 await $.command.register({
79 name: 'ccpii',
80 description: 'Privacy filter: status, install, restart, pause, resume',
81 argumentHint: '[status|install|restart|pause|resume]',
82 // Runs at once, mid-turn too: the way out when the filter holds everything back.
83 immediate: true,
84 })
85 // Fire and forget: the status line is a courtesy, and the hook that set
86 // this up may be long gone, its session with it, by the next change.
87 configureFilter(() => void showStatus($).catch(() => {}), String(options.endpoint ?? ''))
88 await startFilter($)
89
90 return started
91 })
92
93 on('command.run', { command: 'ccpii' }, async ($, e) => ({ text: await runCommand($, policy, e.args.trim()) }))
94
95 on('prompt.compose', async ($, e, next) => {
96 const composed = await next(e)
97 // Rendered before the main conversation's first answer, this is the
98 // system prompt the conversation keeps; a subagent's comes later.
99 if (!(await $.session.messages()).some(message => message.role === 'assistant')) {
100 await markNotice($, 'system prompt')
101 }
102
103 return { sections: [...composed.sections, NOTICE] }
104 })
105
106 // A slash command's text is not rewritable here; its expansion reaches the
107 // model as a `command` row, which `session.append` masks.
108 on('prompt.submit', async ($, e, next) => {
109 if (isPaused || e.command !== undefined || e.isMeta) return next(e)
110 const texts = [e.text, ...(e.context ?? [])]
111 let masked: string[]
112 try {
113 masked = await maskTexts($, policy, texts)
114 } catch (error) {
115 $.ui.toast(`Prompt not sent: ${reasonOf(error)}`)
116
117 return { drop: `ccpii: ${reasonOf(error)}` }
118 }
119 const [text = e.text, ...context] = masked
120 if (masked.some((one, i) => one !== texts[i]) && (await claimNotice($))) {
121 context.push(NOTICE.text)
122 }
123
124 return next(context.length === 0 ? { ...e, text } : { ...e, text, context })
125 }).catch(($, e, next) =>
126 next.called ? next(e) : { drop: 'ccpii: the privacy filter could not check the prompt' },
127 )
128
129 // Every row the model reads is masked here: tool results, command
130 // expansions, attachments, hook context, deliveries. A prompt already masked
131 // at `prompt.submit` comes through unchanged, as its placeholders are skipped.
132 on('session.append', async ($, e, next) => {
133 if (isPaused || !isReadByModel(e) || isOwnNote($, e)) return next(e)
134 let content: Blocks
135 let isChanged: boolean
136 try {
137 ;({ content, isChanged } = await maskBlocks($, policy, e.message.content))
138 } catch (error) {
139 content = withheld(e.message.content, reasonOf(error))
140 isChanged = true
141 }
142 const stored = await next({ ...e, message: { ...e.message, content } })
143 // The first placeholders (or withheld text) of a conversation whose system
144 // prompt has no notice: the notice follows them in, once. A subagent's
145 // prompt is rendered when it starts, and has it.
146 if (e.agentId === undefined && isChanged && (await claimNotice($))) {
147 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: NOTICE.text }] } })
148 }
149
150 return stored
151 }).catch(($, e, next) => {
152 if (next.called || isPaused || !isReadByModel(e)) return next(e)
153
154 return next({ ...e, message: { ...e.message, content: withheld(e.message.content, 'the filter hook failed') } })
155 })
156
157 on('tool.call', async ($, e, next) => next(unmaskDeep(await currentVault($), e))).catch(
158 ($, e, next) =>
159 next.called ? next(e) : { deny: 'ccpii: the placeholders in this call could not be restored.' },
160 )
161
162 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
163 const vault = await read($, vaultAtom)
164
165 return next({ ...e, props: { ...e.props, text: unmask(vault, e.props.text) } })
166 })
167
168 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
169 const vault = await read($, vaultAtom)
170
171 return next({ ...e, props: { ...e.props, text: unmask(vault, e.props.text) } })
172 })
173
174 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
175 const vault = await read($, vaultAtom)
176 const input = unmaskDeep(vault, e.props.input)
177
178 return input === e.props.input ? next(e) : next({ ...e, props: { ...e.props, input } })
179 })
180}
181
182async function runCommand($: EngineInterface, policy: Policy, args: string): Promise<string> {
183 switch (args) {
184 case '':
185 case 'status': {
186 const vault = await currentVault($)
187 const unmasked = describePolicy(policy)
188 const lines = [
189 `filter: ${describeFilter()}`,
190 ...(filterStatus() === 'external' ? [] : [`device: ${describeGpu()}`]),
191 `filtering: ${isPaused ? 'PAUSED, content reaches the model unfiltered' : 'on'}`,
192 ...(unmasked === undefined ? [] : [`left unmasked: ${unmasked}`]),
193 `placeholders: ${Object.keys(vault.byPlaceholder).length}, saved in ${await savePath($, vault.sessionId)}`,
194 ]
195
196 return [...lines, '', USAGE].join('\n')
197 }
198 case 'install':
199 if (filterStatus() === 'external') return `Nothing to install: ${describeFilter()}.`
200 await installFilter($)
201
202 return `${describeFilter()}. The status line shows when it is done.`
203 case 'restart':
204 await startFilter($)
205
206 return `${describeFilter()}.`
207 case 'pause':
208 isPaused = true
209 await showStatus($)
210
211 return 'Filtering paused. Prompts and tool results now reach the model unfiltered, until /ccpii resume or a reload. Placeholders already issued are still restored in tool calls.'
212 case 'resume':
213 isPaused = false
214 await showStatus($)
215
216 return `Filtering on; ${describeFilter()}.`
217 default:
218 return `Unknown argument "${args}".\n\n${USAGE}`
219 }
220}
221
222/**
223 * Whether this conversation's model has been told about the placeholders,
224 * kept in `$.store` by session id so a resumed conversation knows. The
225 * system prompt is rendered once per conversation (--system-prompt-snapshot),
226 * so a mod loaded after the first request adds its section to nothing; the
227 * notice then goes into the conversation itself.
228 */
229async function markNotice($: EngineInterface, where: 'system prompt' | 'conversation'): Promise<void> {
230 await $.store.set(`notice:${await $.session.id()}`, where)
231}
232
233/** True once per conversation that has no notice yet, and records that it now gets one. */
234async function claimNotice($: EngineInterface): Promise<boolean> {
235 const sessionId = await $.session.id()
236 if (noticeClaim?.sessionId === sessionId) return false
237 // Claimed before the next await, so two rows masked at once add one notice.
238 const isNew = (async () => {
239 if ((await $.store.get(`notice:${sessionId}`)) !== undefined) return false
240 await markNotice($, 'conversation')
241
242 return true
243 })()
244 noticeClaim = { sessionId, isNew }
245
246 return isNew
247}
248
249/** The notice this module appended, which passes as it is. */
250function isOwnNote($: EngineInterface, e: Args<'session.append'>): boolean {
251 return e.door === 'note' && e.origin.kind === 'plugin' && 'name' in e.origin && e.origin.name === $.plugin.name
252}
253
254/** Every row the model reads that may carry data: not its own, not a notice, not the engine's own prose. */
255function isReadByModel(e: Args<'session.append'>): boolean {
256 const { type, name } = e.message
257 if (type === 'attachment') return name === undefined || !ENGINE_PROSE.has(name)
258
259 return type !== 'assistant' && type !== 'system'
260}
261
262/** Masks each text against one table, issuing placeholders as it goes. */
263async function maskTexts($: EngineInterface, policy: Policy, texts: readonly string[]): Promise<string[]> {
264 if (texts.every(text => text.trim() === '')) return [...texts]
265 const spans = await detect($, texts)
266 await currentVault($)
267
268 // From here to the end nothing awaits, so no other hook changes `live`
269 // between reading it and writing it back.
270 const before = live ?? emptyVault('')
271 let vault = before
272 const masked = texts.map((text, i) => {
273 const result = mask(vault, text, maskedSpans(policy, text, spans[i] ?? []))
274 vault = result.vault
275
276 return result.text
277 })
278 if (vault !== before) {
279 live = vault
280 saving = saving.then(() => save($)).catch(error => $.ui.log(`ccpii: saving failed: ${error}`, { to: 'debug' }))
281 }
282
283 return masked
284}
285
286/** `content` with its texts masked, and whether any of them changed. */
287async function maskBlocks($: EngineInterface, policy: Policy, content: Blocks): Promise<{ content: Blocks; isChanged: boolean }> {
288 const texts: string[] = []
289 mapTexts(content, text => (texts.push(text), text))
290 if (texts.length === 0) return { content, isChanged: false }
291 const masked = await maskTexts($, policy, texts)
292 if (masked.every((text, i) => text === texts[i])) return { content, isChanged: false }
293 let i = 0
294
295 return { content: mapTexts(content, text => masked[i++] ?? text), isChanged: true }
296}
297
298function withheld(content: Blocks, reason: string): Blocks {
299 return mapTexts(content, () => `${WITHHELD} ${reason}.`).map(block =>
300 block.type === 'tool_result' ? { ...block, is_error: true } : block,
301 )
302}
303
304/**
305 * Rewrites every text the model reads in `content`, in document order: text
306 * blocks and tool results, a tool result's content being a string or blocks.
307 */
308function mapTexts(content: Blocks, fn: (text: string) => string): Blocks {
309 return content.map(block => {
310 if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: fn(block.text) }
311 if (block.type !== 'tool_result') return block
312 if (typeof block.content === 'string') return { ...block, content: fn(block.content) }
313 if (Array.isArray(block.content)) return { ...block, content: mapTexts(block.content as Blocks, fn) }
314
315 return block
316 })
317}
318
319function reasonOf(error: unknown): string {
320 return error instanceof Error ? error.message : String(error)
321}
322
323/**
324 * The current session's table: `live` when it is this session's, else what
325 * `$.state` holds across a reload, else the file a resumed session left.
326 */
327async function currentVault($: EngineInterface): Promise<Vault> {
328 const sessionId = await $.session.id()
329 if (live?.sessionId === sessionId) return live
330
331 const held = await read($, vaultAtom)
332 const restored = held.sessionId === sessionId ? held : await readSaved($, sessionId)
333 // Another hook may have loaded it, and issued placeholders, meanwhile.
334 if (live?.sessionId !== sessionId) live = restored
335
336 return live
337}
338
339async function readSaved($: EngineInterface, sessionId: string): Promise<Vault> {
340 const path = await savePath($, sessionId)
341 if (!(await $.fs.exists(path))) return emptyVault(sessionId)
342
343 return { ...(JSON.parse(await $.fs.read(path)) as Vault), sessionId }
344}
345
346async function save($: EngineInterface): Promise<void> {
347 const vault = live
348 if (vault === undefined) return
349 await update($, vaultAtom, () => vault)
350 const path = await savePath($, vault.sessionId)
351 if (!isSaveDirMade) {
352 // Only the owner may read the real values the table holds.
353 await $.process.run(['mkdir', '-p', '-m', '700', path.slice(0, path.lastIndexOf('/'))])
354 isSaveDirMade = true
355 }
356 await $.fs.write(path, JSON.stringify(vault))
357 await showStatus($)
358}
359
360async function savePath($: EngineInterface, sessionId: string): Promise<string> {
361 const home = await $.env.get('HOME')
362
363 // v2: the bracketed placeholders; a table of the bare ones is not read.
364 return `${home}/.claude/ccpii/v2/${sessionId}.json`
365}
366
367async function showStatus($: EngineInterface): Promise<void> {
368 if (isPaused) {
369 $.ui.status('PAUSED, content reaches the model unfiltered')
370 } else if (filterStatus() === 'ready' || filterStatus() === 'external') {
371 const vault = live ?? (await read($, vaultAtom))
372 $.ui.status(`${Object.keys(vault.byPlaceholder).length} placeholders`)
373 } else {
374 $.ui.status(describeFilter())
375 }
376}
377hooks/filter.ts 445 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Span } from './vault'
4
5export type FilterStatus =
6 /** Not started by this module yet. */
7 | 'idle'
8 /** Spawned; the socket is not bound yet. */
9 | 'starting'
10 /** The socket is bound and the model loads; requests wait for it. */
11 | 'loading'
12 | 'ready'
13 /** The checkpoint is not installed; nothing is retried until `/ccpii install`. */
14 | 'missing'
15 | 'installing'
16 /** The model failed to load; the process lives on, answering errors. */
17 | 'failed'
18 /** The process is gone. */
19 | 'exited'
20 /** No sidecar: the `endpoint` option names a detection service of the person's own. */
21 | 'external'
22
23const sidecarDirRef = { plugin: 'ccpii', key: 'sidecarDir' } as const
24
25/** How long a request waits for a starting sidecar to bind its socket. */
26const LISTEN_WAIT_MS = 5_000
27
28/**
29 * The privacy-filter sidecar this module runs: a Python process serving
30 * `POST /detect` on a Unix socket in a private directory, or the install
31 * that precedes it. One child at a time; each start or install supersedes
32 * the one before, and a superseded child's output changes nothing.
33 */
34const sidecar = {
35 status: 'idle' as FilterStatus,
36 /** What went wrong, for `missing`, `failed` and `exited`. */
37 error: undefined as string | undefined,
38 onChange: () => {},
39 /** The socket the current child named on its `listening` line. */
40 socketPath: undefined as string | undefined,
41 listening: Promise.resolve(),
42 stopChild: undefined as (() => void) | undefined,
43 generation: 0,
44 /**
45 * Whether the current child got as far as `ready`. One that did and then
46 * died is started again by the next request; one that died before it was
47 * ready would most likely die again, so it waits for /ccpii restart.
48 */
49 wasReady: false,
50 endpoint: undefined as string | undefined,
51 /** Which device answers now, from the sidecar's `device` lines. */
52 device: 'cpu' as 'cpu' | 'gpu',
53 /** Whether the sidecar found a GPU model (its `gpu starting` line). */
54 hasGpuModel: false,
55 /** The GPU failures the CPU took over from, and the last one's reason. */
56 fallbacks: 0,
57 lastFallback: undefined as string | undefined,
58}
59
60/**
61 * `endpoint`, when not empty, is the URL of a detection service that answers
62 * `POST {"texts"}` as the sidecar does (BACKENDS.md); no sidecar is run then.
63 */
64export function configureFilter(onChange: () => void, endpoint: string): void {
65 sidecar.onChange = onChange
66 sidecar.endpoint = endpoint.trim() === '' ? undefined : endpoint.trim()
67}
68
69export function filterStatus(): FilterStatus {
70 return sidecar.status
71}
72
73export async function startFilter($: EngineInterface): Promise<void> {
74 const generation = supersede()
75 if (sidecar.endpoint !== undefined) {
76 report(generation, 'external')
77 return
78 }
79 sidecar.wasReady = false
80 // A child stopped by a signal it cannot catch (a crash, or this module
81 // unloading) leaves its directory behind; `$.state` outlives a reload.
82 sidecar.socketPath = undefined
83 const { value: previousDir } = await $.state.get(sidecarDirRef)
84 if (previousDir !== undefined && isSidecarDir(previousDir)) await $.process.run(['rm', '-rf', previousDir])
85 let markListening = () => {}
86 sidecar.listening = new Promise(resolve => (markListening = resolve))
87 report(generation, 'starting')
88
89 sidecar.device = 'cpu'
90 sidecar.hasGpuModel = false
91 const isCurrent = () => generation === sidecar.generation
92 sidecar.stopChild = spawnScript($, 'pii_server.py', ['--serve'], isCurrent, {
93 onLine: (word, rest) => {
94 // Bound means requests are taken: each waits for the model to load.
95 if (word === 'listening') {
96 sidecar.socketPath = rest
97 // Lost, it costs one empty directory left after the next reload.
98 $.state.set(sidecarDirRef, rest.slice(0, rest.lastIndexOf('/'))).catch(() => {})
99 report(generation, 'loading')
100 markListening()
101 }
102 if (word === 'ready') sidecar.wasReady = true
103 if (word === 'ready' || word === 'missing') report(generation, word)
104 if (word === 'failed') report(generation, 'failed', rest)
105 if (word === 'gpu') sidecar.hasGpuModel = true
106 if (word === 'device') {
107 sidecar.device = rest === 'gpu' ? 'gpu' : 'cpu'
108 sidecar.onChange()
109 }
110 if (word === 'fallback') {
111 sidecar.fallbacks += 1
112 sidecar.lastFallback = rest
113 $.ui.log(`GPU fallback: ${rest}`, { to: 'debug' })
114 }
115 },
116 onExit: reason => {
117 markListening()
118 if (sidecar.status !== 'missing') report(generation, 'exited', reason)
119 },
120 })
121}
122
123export async function installFilter($: EngineInterface): Promise<void> {
124 if (sidecar.status === 'installing' || sidecar.status === 'external') return
125 const generation = supersede()
126 report(generation, 'installing')
127
128 const isCurrent = () => generation === sidecar.generation
129 sidecar.stopChild = spawnScript($, 'pii_server.py', ['--download'], isCurrent, {
130 onLine: (word, rest) => {
131 if (word === 'installed') startFilter($).catch(error => report(generation, 'exited', String(error)))
132 if (word === 'failed') report(generation, 'missing', `the install failed: ${rest}`)
133 },
134 onExit: reason => {
135 if (sidecar.status === 'installing') report(generation, 'missing', `the install stopped: ${reason}`)
136 },
137 })
138}
139
140/** The GPU side in a sentence, for /ccpii status. */
141export function describeGpu(): string {
142 if (!sidecar.hasGpuModel) return 'the CPU answers; GPU.md says how to run the filter on an Intel GPU'
143 const fallbacks =
144 sidecar.fallbacks === 0 ? '' : `; fell back to the CPU ${sidecar.fallbacks === 1 ? 'once' : `${sidecar.fallbacks} times`}, last: ${sidecar.lastFallback}`
145 const now = sidecar.device === 'gpu' ? 'the GPU answers' : 'the CPU answers until the GPU worker is ready'
146
147 return `${now}${fallbacks}`
148}
149
150/**
151 * The spans in each of `texts`, offsets inside its text; throws, saying why
152 * in plain words, when the filter cannot answer.
153 *
154 * The host ends a fetch after 30 s, with no way to ask for longer, so no
155 * request carries more than `REQUEST_UNITS`: a longer text goes in pieces,
156 * the pieces in as many requests as they need, and the requests one at a
157 * time across all callers, since the filter answers one after another and a
158 * request waiting behind another's would spend its own 30 s waiting.
159 */
160export async function detect($: EngineInterface, texts: readonly string[]): Promise<Span[][]> {
161 const spans: Span[][] = texts.map(() => [])
162 for (const request of packRequests(texts.flatMap(cutPieces))) {
163 const answers = await inTurn(() => post($, request.map(piece => piece.body)))
164 request.forEach((piece, i) => {
165 for (const span of answers[i] ?? []) {
166 const start = piece.start + span.start
167 // A span belongs to the piece whose own part it starts in; the
168 // overlap of the next or previous piece sees it too.
169 if (start < piece.ownStart || start >= piece.ownEnd) continue
170 spans[piece.text]?.push({ label: span.label, start, end: piece.start + span.end })
171 }
172 })
173 }
174
175 return spans
176}
177
178/**
179 * The most one request carries, in UTF-16 code units: the bundled filter
180 * answers that in about 4 s on a laptop CPU, well inside the 30 s.
181 */
182const REQUEST_UNITS = 16_384
183
184/**
185 * Text a piece carries past its own part on either side, so a span near a
186 * cut is seen whole and with its context. The model looks 128 tokens either
187 * way; 1,024 units hold that in any script.
188 */
189const OVERLAP_UNITS = 1_024
190
191/**
192 * Part of one text: `body` is `text.slice(start, ...)`, and the spans kept
193 * from it are those starting in `[ownStart, ownEnd)`.
194 */
195type Piece = { text: number; start: number; ownStart: number; ownEnd: number; body: string }
196
197function cutPieces(text: string, index: number): Piece[] {
198 if (text.length <= REQUEST_UNITS) {
199 return text === '' ? [] : [{ text: index, start: 0, ownStart: 0, ownEnd: text.length, body: text }]
200 }
201 const pieces: Piece[] = []
202 for (let ownStart = 0; ownStart < text.length; ) {
203 const ownEnd = cutAfter(text, ownStart, REQUEST_UNITS - 2 * OVERLAP_UNITS)
204 const start = boundary(text, Math.max(0, ownStart - OVERLAP_UNITS))
205 const end = boundary(text, Math.min(text.length, ownEnd + OVERLAP_UNITS))
206 pieces.push({ text: index, start, ownStart, ownEnd, body: text.slice(start, end) })
207 ownStart = ownEnd
208 }
209
210 return pieces
211}
212
213/**
214 * Where a piece's own part starting at `from` ends: after the last line
215 * break within `limit` units when that keeps at least half of them, else at
216 * `limit`, never between the halves of a surrogate pair.
217 */
218function cutAfter(text: string, from: number, limit: number): number {
219 if (text.length - from <= limit) return text.length
220 const lineEnd = text.lastIndexOf('\n', from + limit - 1)
221
222 return lineEnd >= from + limit / 2 ? lineEnd + 1 : boundary(text, from + limit)
223}
224
225/** `at`, or one before it when `at` would split a surrogate pair. */
226function boundary(text: string, at: number): number {
227 const code = text.charCodeAt(at - 1)
228
229 return at > 0 && at < text.length && code >= 0xd800 && code <= 0xdbff ? at - 1 : at
230}
231
232/** Pieces in order, grouped into requests of at most `REQUEST_UNITS`. */
233function packRequests(pieces: Piece[]): Piece[][] {
234 const requests: Piece[][] = []
235 let units = REQUEST_UNITS
236 for (const piece of pieces) {
237 if (units + piece.body.length > REQUEST_UNITS) {
238 requests.push([])
239 units = 0
240 }
241 requests[requests.length - 1]?.push(piece)
242 units += piece.body.length
243 }
244
245 return requests
246}
247
248/** The last request handed to `inTurn`; the next one starts after it settles. */
249let queue: Promise<unknown> = Promise.resolve()
250
251function inTurn<T>(request: () => Promise<T>): Promise<T> {
252 const turn = queue.then(request)
253 queue = turn.catch(() => {})
254
255 return turn
256}
257
258/** One `POST {"texts"}` to the endpoint or the sidecar, its answer checked. */
259async function post($: EngineInterface, texts: readonly string[]): Promise<Span[][]> {
260 if (sidecar.status === 'external' && sidecar.endpoint !== undefined) {
261 const response = await $.http.fetch(sidecar.endpoint, {
262 method: 'POST',
263 headers: { 'Content-Type': 'application/json' },
264 body: JSON.stringify({ texts }),
265 })
266 if (!response.ok) throw new Error(`the detection service answered ${response.status}: ${response.text.slice(0, 200)}`)
267
268 return readSpans(response.text, texts, 'the detection service')
269 }
270 if (sidecar.status === 'exited' && sidecar.wasReady) {
271 await startFilter($)
272 }
273 if (sidecar.status === 'starting') await Promise.race([sidecar.listening, $.clock.sleep(LISTEN_WAIT_MS)])
274 if ((sidecar.status !== 'loading' && sidecar.status !== 'ready') || sidecar.socketPath === undefined) {
275 throw new Error(describeFilter())
276 }
277
278 const response = await $.http.fetch('http://ccpii/detect', {
279 method: 'POST',
280 headers: { 'Content-Type': 'application/json' },
281 body: JSON.stringify({ texts }),
282 socketPath: sidecar.socketPath,
283 })
284 if (!response.ok) throw new Error(`the privacy filter answered ${response.status}: ${response.text}`)
285
286 return readSpans(response.text, texts, 'the privacy filter')
287}
288
289/**
290 * The spans of a `/detect` answer, checked against the texts they are for:
291 * a list per text, each span a label and offsets inside its text. Any other
292 * answer throws, so what it was about is withheld rather than sent unchecked.
293 */
294function readSpans(body: string, texts: readonly string[], who: string): Span[][] {
295 let parsed: unknown
296 try {
297 parsed = JSON.parse(body)
298 } catch {
299 throw new Error(`${who} answered something other than JSON`)
300 }
301 const spans = (parsed as { spans?: unknown } | null)?.spans
302 if (!Array.isArray(spans) || spans.length !== texts.length) {
303 throw new Error(`${who} answered no "spans" list with one entry per text`)
304 }
305
306 return spans.map((list: unknown, i) => {
307 const length = texts[i]?.length ?? 0
308 if (!Array.isArray(list)) throw new Error(`${who} answered spans that are not a list`)
309
310 return list.map((span: unknown) => {
311 const { label, start, end } = (span ?? {}) as Partial<Span>
312 const isValid =
313 typeof label === 'string' &&
314 Number.isInteger(start) &&
315 Number.isInteger(end) &&
316 (start as number) >= 0 &&
317 (start as number) < (end as number) &&
318 (end as number) <= length
319 if (!isValid) throw new Error(`${who} answered a span that is not { label, start, end } inside its text`)
320
321 return { label, start, end } as Span
322 })
323 })
324}
325
326/** The status in a sentence the person can act on. */
327export function describeFilter(): string {
328 switch (sidecar.status) {
329 case 'idle':
330 return 'the privacy filter has not started'
331 case 'starting':
332 return 'the privacy filter is still starting'
333 case 'loading':
334 return 'the privacy-filter model is loading'
335 case 'ready':
336 return `the privacy filter is ready (${sidecar.device === 'gpu' ? 'GPU' : 'CPU'})`
337 case 'missing':
338 return `the privacy-filter model is not installed${sidecar.error ? ` (${sidecar.error})` : ''}; run /ccpii install`
339 case 'installing':
340 return 'the privacy-filter model is being installed; see /ccpii status'
341 case 'failed':
342 return `the privacy-filter model failed to load (${sidecar.error}); run /ccpii restart`
343 case 'external':
344 return `the detection service at ${sidecar.endpoint} is in use`
345 case 'exited':
346 return sidecar.wasReady
347 ? `the privacy filter stopped (${sidecar.error}); the next request starts it again`
348 : `the privacy filter stopped before it was ready (${sidecar.error}); run /ccpii restart`
349 }
350}
351
352/** Ends the current child and returns the generation of the next. */
353function supersede(): number {
354 sidecar.stopChild?.()
355 sidecar.stopChild = undefined
356
357 return ++sidecar.generation
358}
359
360function report(generation: number, status: FilterStatus, error?: string): void {
361 if (generation !== sidecar.generation) return
362 sidecar.status = status
363 sidecar.error = error
364 sidecar.onChange()
365}
366
367/**
368 * Runs `server/<script>` with `args` for as long as the module lives,
369 * handing each stdout line on as its first word and the rest while
370 * `isCurrent()`. Returns what stops it.
371 */
372function spawnScript(
373 $: EngineInterface,
374 script: string,
375 args: readonly string[],
376 isCurrent: () => boolean,
377 handlers: { onLine: (word: string, rest: string) => void; onExit: (reason: string) => void },
378): () => void {
379 const child = $.process.spawn({
380 argv: ['uv', 'run', '--quiet', '--script', `${$.plugin.root}/server/${script}`, ...args],
381 })
382
383 void (async () => {
384 let pending = ''
385 let stderrTail = ''
386 try {
387 for await (const { stream, text } of child) {
388 if (stream === 'stderr') {
389 $.ui.log(text.trimEnd(), { to: 'debug' })
390 stderrTail = (stderrTail + text).slice(-300)
391 continue
392 }
393 pending += text
394 for (let newline = pending.indexOf('\n'); newline >= 0; newline = pending.indexOf('\n')) {
395 const line = pending.slice(0, newline).trim()
396 pending = pending.slice(newline + 1)
397 const space = line.indexOf(' ')
398 if (isCurrent()) handlers.onLine(space < 0 ? line : line.slice(0, space), space < 0 ? '' : line.slice(space + 1))
399 }
400 }
401 const { code, signal } = await child.result
402 const how = exitReason(code, signal)
403 const said = lastLine(stderrTail)
404 if (isCurrent()) handlers.onExit(said === '' ? how : `${how}: ${said}`)
405 } catch (error) {
406 if (isCurrent()) handlers.onExit(error instanceof Error ? error.message : String(error))
407 }
408 })()
409
410 return () => void child.return({ code: null, signal: null })
411}
412
413/**
414 * How a child ended, in words. The child is `uv`, which exits with 128 + N
415 * when the Python process under it dies of signal N.
416 */
417function exitReason(code: number | null, signal: string | null): string {
418 if (signal !== null) return `it was killed by ${signal}`
419 if (code !== null && code > 128) return `it was killed by ${SIGNALS[code - 128] ?? `signal ${code - 128}`}`
420
421 return `it exited with code ${code}`
422}
423
424const SIGNALS: Readonly<Record<number, string>> = {
425 1: 'SIGHUP',
426 2: 'SIGINT',
427 6: 'SIGABRT',
428 9: 'SIGKILL',
429 11: 'SIGSEGV',
430 15: 'SIGTERM',
431}
432
433/** A directory the sidecar makes: `/tmp/ccpii.<random>`, nothing above or beside it. */
434function isSidecarDir(path: string): boolean {
435 const name = path.slice('/tmp/'.length)
436
437 return path.startsWith('/tmp/ccpii.') && name.length > 'ccpii.'.length && !name.includes('/') && !name.includes('..')
438}
439
440function lastLine(text: string): string {
441 const lines = text.trim().split('\n')
442
443 return (lines[lines.length - 1] ?? '').trim()
444}
445hooks/policy.ts 132 lines1import { kindOf } from './vault'
2import type { Span } from './vault'
3
4/**
5 * Which detected spans are masked: all of them but what the person left
6 * unmasked in the options and what the model is known to mistake.
7 */
8export type Policy = {
9 /** Placeholder kinds never masked (`PERSON`, `URL`). */
10 unmaskedKinds: ReadonlySet<string>
11 /** Words never masked, lower-cased. */
12 unmaskedWords: readonly string[]
13}
14
15/**
16 * The policy of the two options, each a comma-separated list. A kind is
17 * written as its placeholder kind (`PERSON`) or as the detector's label
18 * (`private_person`), in any case.
19 */
20export function policyOf(kinds: string, words: string): Policy {
21 return {
22 unmaskedKinds: new Set(listOf(kinds).map(kindOf)),
23 unmaskedWords: listOf(words).map(word => word.toLowerCase()),
24 }
25}
26
27/** What the policy leaves unmasked, for `/ccpii status`; undefined when it is nothing but the model's mistakes. */
28export function describePolicy(policy: Policy): string | undefined {
29 const parts = [
30 ...(policy.unmaskedKinds.size === 0 ? [] : [`kinds ${[...policy.unmaskedKinds].join(', ')}`]),
31 ...(policy.unmaskedWords.length === 0 ? [] : [`words ${policy.unmaskedWords.join(', ')}`]),
32 ]
33
34 return parts.length === 0 ? undefined : parts.join('; ')
35}
36
37/** The spans of `text` the policy masks. */
38export function maskedSpans(policy: Policy, text: string, spans: readonly Span[]): Span[] {
39 return spans.filter(span => {
40 const value = text.slice(span.start, span.end)
41
42 return (
43 !policy.unmaskedKinds.has(kindOf(span.label)) &&
44 !holdsUnmaskedWord(policy, value) &&
45 !isHashTakenForAccount(span.label, value) &&
46 !isPathTakenForUrl(span.label, value) &&
47 !isModeTakenForPerson(span.label, value)
48 )
49 })
50}
51
52function listOf(option: string): string[] {
53 return option
54 .split(',')
55 .map(item => item.trim())
56 .filter(item => item !== '')
57}
58
59/**
60 * Whether `text` holds one of the unmasked words, in any case. Where an end
61 * of the word and the character beside it are both ASCII letters or digits,
62 * the word runs on and does not count (`ann` is not in `Joanna`); Chinese
63 * has no spaces between words, so `张伟` counts in `张伟明天`.
64 */
65function holdsUnmaskedWord(policy: Policy, text: string): boolean {
66 const lower = text.toLowerCase()
67
68 return policy.unmaskedWords.some(word => {
69 for (let at = lower.indexOf(word); at >= 0; at = lower.indexOf(word, at + 1)) {
70 const before = lower[at - 1]
71 const after = lower[at + word.length]
72 const runsOn = (beside: string | undefined, edge: string | undefined) =>
73 beside !== undefined && edge !== undefined && isAsciiWordChar(beside) && isAsciiWordChar(edge)
74 if (!runsOn(before, word[0]) && !runsOn(after, word[word.length - 1])) return true
75 }
76
77 return false
78 })
79}
80
81function isAsciiWordChar(char: string): boolean {
82 const code = char.charCodeAt(0) | 0x20 // lower-case a letter, leave a digit
83 return (code >= 48 && code <= 57) || (code >= 97 && code <= 122)
84}
85
86/**
87 * Whether the model took a hex digest (a git commit, a sha256) for an account
88 * number, as it does: account numbers are digits and letters beyond `f`.
89 * A hex `secret` is still masked; a key is often written in hex.
90 */
91function isHashTakenForAccount(label: string, text: string): boolean {
92 if (label !== 'account_number' || text.length < 7) return false
93 let hasDigit = false
94 let hasHexLetter = false
95 for (let i = 0; i < text.length; i++) {
96 const code = text.charCodeAt(i) | 0x20 // lower-case a letter, leave a digit
97 if (code >= 48 && code <= 57) hasDigit = true
98 else if (code >= 97 && code <= 102) hasHexLetter = true
99 else return false
100 }
101
102 return hasDigit && hasHexLetter
103}
104
105/**
106 * Whether the model took a file path for a URL, as it does when a text is a
107 * path and nothing else (`pwd`, a listing of one file). A path starts at the
108 * root, the home or the current directory, or a drive letter; or else its
109 * first segment, unlike a host, has no dot, port or user (`hooks/vault.ts`
110 * against `example.com/a`). A URL with a scheme is never a path.
111 */
112function isPathTakenForUrl(label: string, text: string): boolean {
113 if (label !== 'private_url' || text.includes('://') || text.startsWith('//')) return false
114 if (text.startsWith('/') || text.startsWith('~') || text.startsWith('.')) return true
115 if (text[1] === ':' && (text[2] === '\\' || text[2] === '/')) return true
116 const slash = text.indexOf('/')
117 if (slash < 0) return false
118 const first = text.slice(0, slash)
119
120 return !first.includes('.') && !first.includes(':') && !first.includes('@')
121}
122
123/** Whether the model took a file mode in `ls -l` output (`drwxr-xr-x`) for a person, as it does. */
124function isModeTakenForPerson(label: string, text: string): boolean {
125 if (label !== 'private_person' || text.length !== 10 || !'-dlcbps'.includes(text[0] ?? '')) return false
126 for (let i = 1; i < text.length; i++) {
127 if (!'rwxsStT-'.includes(text[i] ?? '')) return false
128 }
129
130 return true
131}
132hooks/vault.ts 165 lines1import type { Vault } from '../types'
2
3/** A detected span, offsets in UTF-16 code units as JavaScript strings index. */
4export type Span = { label: string; start: number; end: number }
5
6/** openai/privacy-filter's labels and the placeholder kind each one becomes. */
7export const KINDS: Readonly<Record<string, string>> = {
8 account_number: 'ACCOUNT',
9 private_address: 'ADDRESS',
10 private_date: 'DATE',
11 private_email: 'EMAIL',
12 private_person: 'PERSON',
13 private_phone: 'PHONE',
14 private_url: 'URL',
15 secret: 'SECRET',
16}
17
18const UNKNOWN_KIND = 'PII'
19
20/**
21 * The placeholder kind for a label: privacy-filter's own by KINDS, any other
22 * backend's label as it is, upper-cased, each character outside A-Z, 0-9
23 * and `_` made `_` (Presidio's `EMAIL_ADDRESS` stays `EMAIL_ADDRESS`).
24 */
25export function kindOf(label: string): string {
26 const known = KINDS[label]
27 if (known !== undefined) return known
28 let kind = ''
29 for (const char of label.toUpperCase()) {
30 const code = char.charCodeAt(0)
31 const isKept = (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95
32 kind += isKept && char.length === 1 ? char : '_'
33 }
34
35 return kind === '' ? UNKNOWN_KIND : kind
36}
37
38/**
39 * What a placeholder is wrapped in: `⟦EMAIL_0⟧`. Text seldom holds these
40 * brackets, so a bare `EMAIL_0` in a file stays what it is, and only the
41 * placeholders this table issued are ever restored.
42 */
43export const OPEN = '⟦'
44export const CLOSE = '⟧'
45
46export const placeholderOf = (kind: string, number: number): string => `${OPEN}${kind}_${number}${CLOSE}`
47
48export const emptyVault = (sessionId: string): Vault => ({
49 sessionId,
50 counters: {},
51 byValue: {},
52 byPlaceholder: {},
53})
54
55/**
56 * Replaces each span of `text` with its placeholder, issuing new ones as
57 * needed. Returns the vault unchanged (the same object) when nothing new was
58 * issued, so a caller can tell whether it has anything to save.
59 */
60export function mask(
61 vault: Vault,
62 text: string,
63 spans: readonly Span[],
64): { vault: Vault; text: string } {
65 const kept = withoutOverlaps(spans).filter(span => !holdsPlaceholder(vault, text.slice(span.start, span.end)))
66 if (kept.length === 0) return { vault, text }
67
68 let next = vault
69 const issue = (kind: string, value: string): string => {
70 const known = next.byValue[value]
71 if (known !== undefined) return known
72 if (next === vault) {
73 next = {
74 sessionId: vault.sessionId,
75 counters: { ...vault.counters },
76 byValue: { ...vault.byValue },
77 byPlaceholder: { ...vault.byPlaceholder },
78 }
79 }
80 const number = next.counters[kind] ?? 0
81 const placeholder = placeholderOf(kind, number)
82 next.counters[kind] = number + 1
83 next.byValue[value] = placeholder
84 next.byPlaceholder[placeholder] = value
85 return placeholder
86 }
87
88 let masked = ''
89 let cursor = 0
90 for (const span of kept) {
91 masked += text.slice(cursor, span.start)
92 masked += issue(kindOf(span.label), text.slice(span.start, span.end))
93 cursor = span.end
94 }
95 masked += text.slice(cursor)
96
97 return { vault: next, text: masked }
98}
99
100/** Puts the real value back for every placeholder of the vault in `text`. */
101export function unmask(vault: Vault, text: string): string {
102 let restored = ''
103 let cursor = 0
104 for (const found of placeholders(vault, text)) {
105 restored += text.slice(cursor, found.start) + found.value
106 cursor = found.end
107 }
108
109 return cursor === 0 ? text : restored + text.slice(cursor)
110}
111
112/** `unmask` over every string inside a JSON-like value; unchanged parts are kept by identity. */
113export function unmaskDeep<T>(vault: Vault, value: T): T {
114 if (typeof value === 'string') return unmask(vault, value) as T
115 if (Array.isArray(value)) {
116 const items = value.map(item => unmaskDeep(vault, item))
117
118 return (items.some((item, i) => item !== value[i]) ? items : value) as T
119 }
120 if (value !== null && typeof value === 'object') {
121 let changed = false
122 const entries = Object.entries(value).map(([key, item]) => {
123 const restored = unmaskDeep(vault, item)
124 changed ||= restored !== item
125
126 return [key, restored] as const
127 })
128
129 return (changed ? Object.fromEntries(entries) : value) as T
130 }
131
132 return value
133}
134
135/** Spans in order, each one that overlaps an earlier one dropped. */
136function withoutOverlaps(spans: readonly Span[]): Span[] {
137 const sorted = [...spans]
138 .filter(span => span.end > span.start)
139 .sort((a, b) => a.start - b.start || b.end - a.end)
140 const kept: Span[] = []
141 for (const span of sorted) {
142 const last = kept[kept.length - 1]
143 if (last === undefined || span.start >= last.end) kept.push(span)
144 }
145
146 return kept
147}
148
149/** Whether `text` already holds one of the vault's placeholders: masking it again would hide the placeholder itself. */
150function holdsPlaceholder(vault: Vault, text: string): boolean {
151 return !placeholders(vault, text).next().done
152}
153
154/** Each of the vault's placeholders in `text`, in order, with the value it stands for. */
155function* placeholders(vault: Vault, text: string): Generator<{ start: number; end: number; value: string }> {
156 for (let start = text.indexOf(OPEN); start >= 0; start = text.indexOf(OPEN, start + 1)) {
157 const close = text.indexOf(CLOSE, start + 1)
158 if (close < 0) return
159 const value = vault.byPlaceholder[text.slice(start, close + 1)]
160 if (value === undefined) continue
161 yield { start, end: close + 1, value }
162 start = close
163 }
164}
165types/index.d.ts 25 lines1/**
2 * The placeholder table of one session: which real value each placeholder
3 * stands for, and the next number per kind.
4 */
5export type Vault = {
6 /** The session the table belongs to; '' before any session has loaded it. */
7 sessionId: string
8 /** The next free number per placeholder kind (`EMAIL` → 2 after EMAIL_1). */
9 counters: Record<string, number>
10 /** Real value → placeholder. */
11 byValue: Record<string, string>
12 /** Placeholder → real value. */
13 byPlaceholder: Record<string, string>
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 ccpii: {
19 vault: Shaped<Vault>
20 /** The directory of the sidecar this session runs, so a reloaded module can remove a dead one's. */
21 sidecarDir: string
22 }
23 }
24}
25