Keep unrelated topics from pulling Claude off your code: listed terms become stable placeholder names (or their lines are dropped) in tool output, prompts…

<img src=".claude-plugin/icon.svg" alt="topic-filter logo: a benzene ring swapped for code braces" width="200">
Code with Claude without your past life tagging along. Chosen topics become neutral placeholders before Claude reads them; nothing on disk changes.
Your machine carries everything you have ever worked on: a career in chemistry, a thesis, old side projects, a client's name. None of it has anything to do with the bug you are fixing today, but Claude reads it anyway, in file names, notes, memory and command output, and unrelated material pulls a session sideways. You should be free to work on the code in front of you without explaining your history first.
topic-filter is a Claude Mod (a Claude Code plugin built on function hooks) that keeps chosen topics out of the way. Before the model reads tool output, prompts, CLAUDE.md and skills, each listed term becomes a stable placeholder (Teotihuacan becomes Teacup7), or the lines that mention it disappear. Your repos, notes and memory stay as they are.

Claude answers in codenames; the sidebar, shown to you only, maps each one back and counts where it was hidden. The dim topic-filter: 50 hidden sits in the prompt footer.
1. Install the plugin. It needs Claude Code 2.1.287 or newer, where mods load by default. This repository is its own marketplace:
claude plugin marketplace add lperezmo/topic-filter-mod
claude plugin install topic-filter@topic-filter-mod
Or from inside Claude Code: /plugin marketplace add lperezmo/topic-filter-mod, then /plugin install topic-filter@topic-filter-mod.
2. Choose what to hide. Run /config and type topic-filter to find the plugin's rows. Enter or Space flips a switch or edits a field:

| Setting | Default | What it does |
|---|---|---|
| Hide anthropology, biology, chemistry, genetics | off | One switch per built-in pack |
| Other packs | empty | Names of your own packs, comma-separated |
| Start paused | off | Every session starts paused; /topic-filter on turns it on for that session |
| Sidebar | off | Opens the sidebar at every start |
| Sidebar: counts only | off | The sidebar shows counts by list, never a word or file name |
| Extra words to hide | empty | Comma-separated words or names; kept in secure storage, not in settings.json |
| Topics file (advanced) | empty | Where the topics file lives, if you want one |
Extra words to hide is kept secret, so /config does not list it: set it in /plugin: Installed, then topic-filter, then configure it. That screen shows every setting as a text box; there, a switch takes the word true or false (a y is saved as false).
What you type in either place goes to the plugin, never into the conversation. A change applies right away, no restart needed. To hide a repo, see Hiding repositories.
3. Restart Claude Code once after installing: sessions that were already running do not load the plugin. The prompt footer then shows a dim topic-filter: M hidden beside the other modes, /topic-filter shows what is set to be hidden and where each part comes from, and /topic-filter log shows what was actually hidden and where (both shown to you only).
The topics file is optional. Use it for what the settings cannot express: drop-line or restore modes for your own words, several lists, exclude. Edit it in an editor: it lists the very terms you want kept out of the session. Its lists add to the settings'.
Needs Claude Code 2.1.287 or newer. The stable update channel can lag behind that, so use the latest channel if
claude --versionshows something older. CI runs the tests on 2.1.287 and the latest release, and again every week, since the mods API may change between releases.
Claude Code does not update plugins from third-party marketplaces on its own unless you turn that on. Pick one:
/plugin, then Marketplaces, then topic-filter-mod, then enable auto-update. New versions install when Claude Code starts. claude plugin marketplace update topic-filter-mod
claude plugin update topic-filter@topic-filter-mod
Either way, restart Claude Code afterwards; running sessions keep the old version. claude plugin list shows the version you have. Updates never touch your topics file or your own packs in ~/.claude/topic-filter/.
Upgrading to 0.8.0.
Upgrading to 0.7.0.
"pack": "cybersecurity", tool calls pause until you take it out. Its /config switch is gone too, and a saved value is ignored."restore": true. Such a topics file is now a settings problem: tool calls pause until the restore is removed.To remove it: claude plugin uninstall topic-filter@topic-filter-mod. Your topics file stays until you delete it.
The motivating case: gh repo list shows repositories a session has no reason to see. List their names in a drop-line list, and they drop out of every listing the model reads.
Run /topic-filter in a session to see each list and where its terms come from, /topic-filter packs to see every topic pack and which lists use it, /topic-filter off and on to pause and resume it, and /topic-filter reload after editing packs. The overview and the pack list show counts, never terms. Edits to the topics file and packs are picked up on their own.
/topic-filter log shows what was hidden this session and where it came from: each source (a file read, a command, CLAUDE.md, your prompt) with the real terms, the placeholder each became, and how many lines each drop-line term removed. The dropped lines themselves are not shown.
Hidden in what Claude reads with every request (CLAUDE.md, context blocks):
C:/work/CLAUDE.md
Teotihuacan -> Marzipan (x1)
Hidden as it came in (most recent last):
Bash gh repo list
2 lines dropped by "hidden-repos": secret-repo (x1), old-thesis (x1)
Read C:/notes/trip.md
Teotihuacan -> Marzipan (x3)
The log is kept in memory only and ends with the session. The mod never writes it to a file of its own, since that would be a second copy of what you are hiding. /topic-filter log clear empties the second part sooner; the first stays, as that text is still in every request.
Every /topic-filter output is shown to you only: it names packs, lists and terms, so it is printed for you and not added to the conversation. Two caveats for the log, which names real terms:
--debug or --debug-file the terms end up in that file.ui.log sees the lines too.The footer's M hidden counts each hidden word and each dropped line, not distinct terms. The footer label is drawn on the terminal and in the desktop app.
The label is the only thing topic-filter shows under the prompt; it never pins a warning. While all is well it is dim. When something needs you it turns yellow and says what: topic-filter: paused, topic-filter: off, nothing chosen, topic-filter: blocking tool calls, run /topic-filter, or a count with settings problem. /topic-filter then gives the details.
What a subagent reads is filtered the same way, and its placeholders are refused in its tool calls too. The log lists each subagent under its own heading.
/topic-filter sidebar opens a pane that shows what has been hidden so far and updates as it happens. It is the right-hand pane in the screenshot at the top.
Open a subagent's transcript from the tasks list and the sidebar shows only what that subagent read.
/topic-filter sidebar toggles it./config opens it at every start. Turning the switch off closes it.The sidebar is drawn in your terminal and is not part of the conversation.
/topic-filter off (or pause, stop) pauses filtering for the session: tool output reaches Claude whole and placeholders are no longer refused, so Claude can act on something hidden, such as deleting a hidden repo. /topic-filter on (or resume, start) turns it back on and says how many lists and terms it resumed with.
/clear or a reload of the plugin goes back to what the settings say./config and nothing is hidden until you type /topic-filter on, which lasts for that session.topic-filter: paused in place of the count, and the sidebar says so at the top.What Claude read before the pause keeps its placeholders.
{
"placeholder": "codename",
"informModel": true,
"lists": [
{ "name": "hidden-repos", "mode": "drop-line", "terms": ["my-private-experiment", "old-client-work"] },
{ "name": "anthropology", "terms": ["Teotihuacan", "Chichen Itza", "Maya", "Pyramids of Giza"] },
{ "name": "email-contacts", "restore": true, "terms": ["Ada Lovelace"] }
]
}
| Field | Default | Meaning |
|---|---|---|
placeholder | codename | codename gives each term a capitalized word and a number (Bubblegum7, Kazoo12); the number keeps it from ever matching a word you really write. tag gives [hidden-3fa2c1]. |
informModel | true | Tell the model that placeholders exist and how to treat them. Without it the model tends to treat Bubblegum as a real name and go looking for it. |
lists[].name | list N | A label for you. It never reaches the model. |
lists[].terms | [] | The terms to hide. |
lists[].mode | replace | replace swaps each term for its placeholder. drop-line removes every line that mentions a term; in JSON (gh ... --json, MCP results) the whole array item goes. A typed prompt always gets placeholders, never lost lines. |
lists[].match | word | word matches whole words only. substring matches inside words too (maya in Mayapan). |
lists[].restore | false | When the model uses this list's placeholder in a tool call, write the real term back instead of refusing. For your own names (contacts, client names) that a tool must write out exactly. Not allowed on a list that uses a pack. |
lists[].pack | none | A topic pack whose terms join the list. |
lists[].exclude | [] | Terms to leave out of the list, whatever brought them in (a pack or terms). Matched the same forgiving way as terms. |
Matching ignores case and accents (Teotihuacán = teotihuacan), treats spaces, hyphens and underscores as one separator (secret repo also finds secret-repo and secret_repo), and takes plural and possessive endings (Mayas, Maya's). Terms shorter than two characters are ignored.
A term keeps the same placeholder in every session. The names are derived from the term and a random seed kept in the plugin's store, so memory files and the prompt cache stay consistent, and the word list alone does not reveal the mapping.
A pack is a ready-made term list for one topic, so you do not have to type every name yourself. Point a list at one:
{ "lists": [{ "name": "anthropology", "pack": "anthropology", "exclude": ["Maya"], "terms": ["my extra word"] }] }
The list keeps its own mode and match (restore is not allowed on a pack list), exclude drops pack terms you do not want hidden, and terms adds your own. /topic-filter packs lists every pack with what it covers and its counts (never its terms), and marks which of your lists use it.
A pack name that does not exist is never ignored quietly, since that would hide nothing while looking set up. Tool calls pause, a toast names the missing pack, the footer label turns yellow, and /topic-filter suggests the closest real one ("Did you mean paleontology?") and lists what is available. The session is told only that the settings need attention.
Built-in packs ship in this repository's packs/ folder:
| Pack | Terms | Covers |
|---|---|---|
anthropology | 2,167 | Ancient Mesoamerican, Andean and Egyptian sites, civilizations, rulers and deities; anthropology and archaeology vocabulary |
biology | 270 | Molecular biology techniques, cellular processes, anatomical terms |
chemistry | 940 | Chemical elements, named reactions, functional groups, laboratory equipment |
genetics | 2,591 | Genetic disorders and syndromes; genetics and heredity vocabulary |
They are built by the script in tools/packs/ from Wikipedia and Wiktionary categories, word-frequency data and a dry run against ordinary code, with a review report per pack in tools/packs/reports/.
Your own packs go in ~/.claude/topic-filter/packs/<name>.json. A pack there replaces a built-in one of the same name, so to customize a built-in pack, copy it there and edit the copy. Never edit the plugin's own folder: Claude Code replaces it on every update. The format:
{
"name": "my-topic",
"description": "What it covers.",
"terms": ["Hidden on sight", "Another one"],
"hints": ["loose", "related", "words"]
}
Only terms hide anything today; hints are kept for a later version that takes a closer look at paragraphs mentioning them. Editing a pack file takes effect on the next tool call, no restart needed.
Put the repository names in a list whose mode is drop-line, in the topics file or in Extra words to hide (a replace list, so those get placeholders instead):
{ "name": "hidden-repos", "mode": "drop-line", "terms": ["some-repo", "another-repo"] }
Each one vanishes from gh repo list, gh api JSON, GitHub MCP results, paths and file contents. Take the name out to bring it back. The topics file is not read into the session, since that would bring every listed name back into context.
Text reaches the model through many doors, and there is no single outgoing request to filter (turn.step carries a message count, not the messages). So every door but the system prompt is hooked:
| What the model reads | Event |
|---|---|
| Tool results: Bash, Read, Grep, Glob, WebFetch, MCP tools, subagent answers, errors | tool.call, on the way up |
| Your typed prompt | prompt.submit |
| CLAUDE.md and the other first-message context blocks | prompt.context |
| Mentioned and edited files, nested CLAUDE.md, queued prompts, context added by classic hooks and plugins (Claude Code's own reminders pass unchanged) | prompt.attachment |
| Skill text, MCP tool descriptions (built-in tools keep theirs), slash command output | skill.prompt, tool.describe, command.run |
| Remote Control and peer deliveries | session.receive |
Each of these hooks changes one thing: it replaces listed terms in the text with their placeholders (or drops the line, for a drop-line list) and passes everything else on unchanged. With informModel on (the default), Claude also gets one short note that placeholders exist, quoted below. The system prompt is never hooked or changed. prompt.submit filters every prompt that passes through it, yours or one another plugin submits; the mod never submits a prompt itself.
The note Claude gets once per session, in the first message's context:
Some names in this session may be placeholders written by the user's topic-filter mod: capitalized numbered codenames such as Bubblegum7 or Kazoo12, or tags such as [hidden-3fa2c1]. Each stands for an item the user has set aside as off-topic for this session, and lines about some of those items are removed entirely. Treat a placeholder as an opaque name and do not guess what it stands for. Mentioning placeholders in replies is fine. A tool call (command, search, file edit) that uses one is refused, unless the note on the output it came from says that placeholder may be used.
Tool output that had something replaced carries a one-line note saying how many terms were replaced and whether their placeholders may be used.
Going the other way:
Bubblegum into a file where the real word was. Subagent prompts, todos and questions to you are exempt, since they are the model talking to itself or to you."restore": true, a placeholder from that list in a tool call is written back as the real term before the tool runs. This applies to every tool (Bash, Read, Edit, Write, Grep, Glob, WebFetch, MCP tools and the rest) except Agent, Task, TodoWrite, TaskCreate, TaskUpdate, AskUserQuestion, ExitPlanMode and SendMessage, whose input is passed on unchanged. The tool then runs on the real word, not on what Claude wrote. Without a restore list, tool input is never changed.Write to it is refused: its copy lacks what it never saw. Edit still works, and fails safely if its text spans something hidden.Read these before relying on it.
drop-line for items whose surroundings identify them.Teotihuac?n) does not match. This is a focus aid, not a security or privacy boundary.cat > notes.md built from a filtered read loses the hidden lines. Only Write is refused.MEMORY.md), reaches the model unfiltered. Memory files Claude reads with a tool are filtered like any other file.tool.call beneath this one see raw results./plugin are stored in ~/.claude/settings.json, which Claude can read, so they show which topics you hide (the extra words are in secure storage). Only the topics file is guarded./topic-filter is not a command. Claude Code is older than 2.1.287 or the session predates the install. Update, then restart Claude Code. /plugin has an Errors tab, and claude --debug logs why a plugin did not load.off, nothing chosen to hide. Nothing is switched on. Open /config and search topic-filter.BLOCKING tool calls. A setting or the topics file has a problem, such as a pack name that does not exist, and tool calls pause until it is fixed so no listed term slips through. /topic-filter shows the reason and suggests the closest pack name./plugin did not take. That screen needs the word true; anything else is saved as false. /config has real switches.hooks/register.ts 985 lines1// topic-filter: hides chosen topics from the model.
2//
3// Every place text enters the model's context, except the system prompt, is hooked, and the listed
4// terms in it become placeholder names (or their lines are dropped) before
5// the model reads it: tool results on their way up from core, the person's
6// prompt, the first message's context blocks
7// (CLAUDE.md), injected attachments (mentioned files, reminders, classic
8// hook context), skill text, tool descriptions, slash command output and
9// deliveries from outside the session. `turn.step` carries no messages, so
10// there is no single outgoing request to filter instead. The system prompt
11// is never hooked or changed.
12//
13// On the way out, a tool call that uses a placeholder is refused, so the
14// model cannot act on what it cannot see and never writes a placeholder over
15// the real word in a file. A list marked `restore` puts the real term back
16// instead.
17//
18// Every hook fails closed: when filtering throws or overruns, what it was
19// filtering is withheld, never passed through.
20
21import type { AgentInfo, EngineInterface, FsEntry, On, PluginOptions, ToolCallResult } from 'claude-code'
22
23import { closestName, ConfigError, optionLists, parseConfig, parsePack, SLUG, type Config, type Pack } from './config.ts'
24import { HiddenLog, type LogMode, type SourceKind } from './log.ts'
25import { fnv1a } from './placeholders.ts'
26import { openRows, SIDEBAR_ID, sidebarView, type Depth, type SidebarView } from './sidebar.tsx'
27import { Filter, forEachString, newTally, noteFor, type Tally } from './redact.ts'
28
29const COMMAND = 'topic-filter'
30const DEFAULT_CONFIG = '.claude/topic-filter/topics.json'
31
32/** The person's own packs, under the home directory; the plugin's folder is replaced on update. */
33const USER_PACKS = '.claude/topic-filter/packs'
34
35/** How long a checked config stays trusted before the file is looked at again. */
36const RECHECK_MS = 1000
37
38/**
39 * Tools whose input is the model talking to itself or to the person, never
40 * to the world: a placeholder there is harmless, and a restore there would
41 * hand the real term to a model (a subagent's prompt). Neither guard nor
42 * restore touches them.
43 */
44const MODEL_FACING_TOOLS = new Set([
45 'Agent',
46 'Task',
47 'TodoWrite',
48 'TaskCreate',
49 'TaskUpdate',
50 'AskUserQuestion',
51 'ExitPlanMode',
52 'SendMessage',
53])
54
55/**
56 * The attachments the engine writes from the person's own content: files
57 * mentioned or edited, nested CLAUDE.md files, prompts queued into a turn.
58 * Its other attachments (reminders, mode changes, listings) are Claude Code's
59 * own text and pass unchanged; settings-hook and plugin context is filtered.
60 */
61const CONTENT_ATTACHMENTS = new Set(['file', 'edited_text_file', 'nested_memory', 'queued_command'])
62
63/** Whether a tool comes from an MCP server; by name on a build without `provider`. */
64const isMcpTool = (e: { tool: string; provider?: { plugin?: string } }) =>
65 e.provider?.plugin?.startsWith('mcp:') ?? e.tool.startsWith('mcp__')
66
67/** Whether the mod filters this attachment. */
68const filtersAttachment = (e: { type: string; origin?: { kind: string } }) =>
69 e.origin?.kind !== 'engine' || CONTENT_ATTACHMENTS.has(e.type)
70
71/** The context block that tells the model placeholders exist; stable, so the prompt cache holds. */
72const EXPLAINER =
73 "Some names in this session may be placeholders written by the user's topic-filter mod: capitalized " +
74 'numbered codenames such as Bubblegum7 or Kazoo12, or tags such as [hidden-3fa2c1]. Each stands for an item the ' +
75 'user has set aside as off-topic for this session, and lines about some of those items are removed entirely. Treat a ' +
76 'placeholder as an opaque name and do not guess what it stands for. Mentioning placeholders in replies ' +
77 'is fine. A tool call (command, search, file edit) that uses one is refused, unless the note on the ' +
78 'output it came from says that placeholder may be used.'
79
80/** The topics file as last read, and the filter built from it. */
81type Loaded = {
82 key: string
83 path: string
84 /** The pack files the key covers, found or not. */
85 deps: string[]
86 /** Which pack each list got its terms from, by list index. */
87 packsUsed: Map<number, PackUse>
88 /** The settings the filter was built from: the last good ones. */
89 config: Config | null
90 /** The settings as the file says now, even when they could not be used; what the person is shown. */
91 latest: Config | null
92 filter: Filter | null
93 /** Why the file could not be used; the filter kept is the last good one, if any. */
94 error?: string
95}
96
97let loaded: Loaded | undefined
98let loading: { key: string; promise: Promise<Loaded> } | undefined
99let checkedAt = 0
100
101/** Items hidden since the session started, for the status line. */
102let hiddenCount = 0
103
104/** What was hidden and where, for `/topic-filter log`: memory only, shown to the person only. */
105const hiddenLog = new HiddenLog()
106
107/**
108 * Whether filtering is paused, by `/topic-filter off` or by the "Start
109 * paused" setting. Memory only: a restart, /clear or a reload goes back to
110 * what the setting says, so a forgotten pause does not outlive the session.
111 */
112let paused = false
113
114/** Whether the person set every session to start paused. */
115const startsPaused = () => pluginOptions.startPaused === true
116
117/**
118 * Who may pause: the person at this terminal's prompt. Remote Control is left
119 * out, as the engine cannot attest its sender is the owner. Anyone may resume.
120 */
121const MAY_PAUSE = new Set(['composer'])
122
123/** Whether this module has matched the sidebar to its setting yet: once per load, as a /config change reloads it. */
124let sidebarSynced = false
125
126/**
127 * Opens the sidebar when its setting is on, and closes it when the setting
128 * was just turned off. The setting's last value is kept in the store, so a
129 * pane the person opened with `/topic-filter sidebar` is left alone.
130 */
131async function syncSidebar($: EngineInterface): Promise<void> {
132 if (sidebarSynced) return
133 sidebarSynced = true
134 try {
135 const isOn = pluginOptions.showSidebar === true
136 const was = await $.store.get('sidebar')
137 if (isOn) await $.ui.open({ id: SIDEBAR_ID, title: 'topic-filter' })
138 else if (was === true) await $.ui.close({ id: SIDEBAR_ID })
139 if (was !== isOn) await $.store.set('sidebar', isOn)
140 } catch {
141 // The filter works without its sidebar; the next call tries again.
142 sidebarSynced = false
143 }
144}
145
146/** A subagent as the log and the sidebar name it. */
147const agentLabel = (info: AgentInfo) => info.name ?? (info.description.trim() === '' ? info.type : info.description)
148
149/** Every subagent's label by id; empty when they cannot be listed. */
150async function agentLabels($: EngineInterface): Promise<Map<string, string>> {
151 try {
152 return new Map((await $.agent.list()).map(info => [info.id, agentLabel(info)]))
153 } catch {
154 return new Map()
155 }
156}
157
158/** Subagent labels the sidebar has looked up, so a redraw lists the agents only for one it has not seen. */
159const sidebarLabels = new Map<string, string>()
160
161async function sidebarLabel($: EngineInterface, agentId: string): Promise<string> {
162 if (!sidebarLabels.has(agentId)) for (const [id, name] of await agentLabels($)) sidebarLabels.set(id, name)
163 return sidebarLabels.get(agentId) ?? agentId
164}
165
166/** Which kind of source a tool's output is, for the sidebar's bars. */
167function kindOfTool(tool: string): SourceKind {
168 if (['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'NotebookRead'].includes(tool)) return 'files'
169 if (['Bash', 'PowerShell', 'BashOutput', 'Monitor', 'KillShell'].includes(tool)) return 'commands'
170 if (['WebFetch', 'WebSearch'].includes(tool)) return 'web'
171 if (['Grep', 'Glob', 'LS', 'ToolSearch'].includes(tool)) return 'searches'
172 if (tool === 'Skill') return 'skills'
173 return 'tools'
174}
175
176/** The input fields that say what a tool call was about, in order of preference. */
177const SOURCE_KEYS = ['file_path', 'notebook_path', 'command', 'url', 'pattern', 'query', 'path', 'description']
178
179/** A tool call as the log names it: the tool and what it was about. */
180function toolSource(tool: string, input: Record<string, unknown>): string {
181 for (const key of SOURCE_KEYS) {
182 const value = input[key]
183 if (typeof value === 'string' && value.trim() !== '') return `${tool} ${value}`
184 }
185 return tool
186}
187
188/** The `configPath` option; empty means the default under the home directory. */
189let configured = ''
190
191/** The plugin's settings: what to hide besides the topics file (packs, extra words). */
192let pluginOptions: PluginOptions = {}
193
194/** Files the model has read with something hidden, by pathKey. */
195const filteredFiles = new Set<string>()
196
197/** A path as a set key: one slash direction, one case. */
198const pathKey = (path: string) => path.replace(/\\/g, '/').toLowerCase()
199
200/** Whether what the model read lacks something a whole-file rewrite would lose. */
201const hidesContent = (f: Filter, seen: Tally) => seen.dropped > 0 || [...seen.names].some(name => f.guarded.has(name))
202
203async function homeOf($: EngineInterface): Promise<string> {
204 // USERPROFILE first: on Windows it is where Claude Code keeps ~/.claude,
205 // while HOME may be unset or a POSIX spelling from a Unix-like shell.
206 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
207}
208
209async function pathOf($: EngineInterface): Promise<string> {
210 const home = await homeOf($)
211 if (configured === '') return `${home}/${DEFAULT_CONFIG}`
212 return configured.startsWith('~') ? home + configured.slice(1) : configured
213}
214
215/** A file's identity for the cache key: its path and, when it exists, its size and time. */
216async function statKey($: EngineInterface, path: string): Promise<string> {
217 try {
218 const stat = await $.fs.stat(path)
219 return `${path}|${stat.mtimeMs}|${stat.size}`
220 } catch {
221 return `${path}|missing`
222 }
223}
224
225/** Which pack file a list got its terms from. */
226type PackUse = { name: string; where: PackWhere; terms: number }
227
228type PackWhere = 'yours' | 'built-in'
229
230/** One pack file found on disk. */
231type PackEntry = {
232 name: string
233 where: PackWhere
234 /** Undefined when the file is not a valid pack. */
235 pack?: Pack
236 /** A built-in pack that one of yours with the same name replaces. */
237 isReplaced: boolean
238}
239
240/**
241 * The terms of every pack the topics file names, per list index, and which
242 * file each came from. The person's own pack wins over a built-in one of the
243 * same name, so copying a built-in pack there and editing it is how one is
244 * customized; the plugin's own folder is replaced on every update.
245 */
246async function loadPacks(
247 $: EngineInterface,
248 config: Config,
249 home: string,
250): Promise<{ terms: Map<number, string[]>; used: Map<number, PackUse> }> {
251 const terms = new Map<number, string[]>()
252 const used = new Map<number, PackUse>()
253
254 for (const [i, list] of config.lists.entries()) {
255 if (list.pack === undefined) continue
256 const [mine, builtIn] = packPaths(home, $.plugin.root, list.pack)
257
258 let text: string
259 let where: PackWhere = 'yours'
260 try {
261 text = await $.fs.read(mine)
262 } catch {
263 where = 'built-in'
264 try {
265 text = await $.fs.read(builtIn)
266 } catch {
267 const names = [...new Set((await findPacks($, home)).map(entry => entry.name))]
268 const where = list.setting === undefined ? `lists[${i}]` : `the ${list.setting} setting`
269 throw new ConfigError(missingPack(list.pack, where, names))
270 }
271 }
272 const pack = parsePack(text, list.pack)
273 terms.set(i, pack.terms)
274 used.set(i, { name: list.pack, where, terms: pack.terms.length })
275 }
276
277 return { terms, used }
278}
279
280/** What to tell the person about a pack that does not exist; its first sentence fits a status line. */
281function missingPack(name: string, where: string, available: readonly string[]): string {
282 const guess = closestName(name, available)
283 return [
284 `Pack "${name}" (${where}) was not found.`,
285 guess === undefined ? '' : `Did you mean "${guess}"?`,
286 available.length === 0 ? 'No packs are installed.' : `Available: ${available.join(', ')}.`,
287 `Run /topic-filter packs to see what each covers, or make your own at ~/${USER_PACKS}/${name}.json.`,
288 ]
289 .filter(part => part !== '')
290 .join(' ')
291}
292
293const count = (n: number, noun: string) => `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`
294
295/** Where a pack may be: the person's folder first, then the plugin's own. */
296function packPaths(home: string, root: string, name: string): [string, string] {
297 return [`${home}/${USER_PACKS}/${name}.json`, `${root}/packs/${name}.json`]
298}
299
300/** Every pack file there is, yours first, each folder by name. */
301async function findPacks($: EngineInterface, home: string): Promise<PackEntry[]> {
302 const places: { where: PackWhere; dir: string }[] = [
303 { where: 'yours', dir: `${home}/${USER_PACKS}` },
304 { where: 'built-in', dir: `${$.plugin.root}/packs` },
305 ]
306 const mine = new Set<string>()
307 const found: PackEntry[] = []
308
309 for (const { where, dir } of places) {
310 let entries: FsEntry[]
311 try {
312 entries = await $.fs.list(dir)
313 } catch {
314 continue
315 }
316 for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
317 const name = entry.name.replace(/\.json$/, '')
318 if (entry.kind !== 'file' || name === entry.name || !SLUG.test(name)) continue
319 let pack: Pack | undefined
320 try {
321 pack = parsePack(await $.fs.read(`${dir}/${entry.name}`), name)
322 } catch {
323 pack = undefined
324 }
325 found.push({ name, where, pack, isReplaced: where === 'built-in' && mine.has(name) })
326 if (where === 'yours') mine.add(name)
327 }
328 }
329
330 return found
331}
332
333/**
334 * The pack listing, for the person only: every pack with what it covers,
335 * its counts, and which of your lists use it, plus any list naming a pack
336 * that does not exist.
337 */
338async function packListing($: EngineInterface, l: Loaded): Promise<string[]> {
339 const entries = await findPacks($, await homeOf($))
340 const users = new Map<string, string[]>()
341 for (const list of l.latest?.lists ?? []) {
342 if (list.pack !== undefined) users.set(list.pack, [...(users.get(list.pack) ?? []), `"${list.name}"`])
343 }
344
345 const lines = ['Topic packs (yours first, then built-in):']
346 for (const entry of entries) {
347 const head = ` ${entry.name} (${entry.where})`
348 if (entry.isReplaced) {
349 lines.push(`${head}: replaced by yours`)
350 continue
351 }
352 if (entry.pack === undefined) {
353 lines.push(`${head}: not a valid pack file`)
354 continue
355 }
356 const inUse = users.get(entry.name)
357 lines.push(
358 `${head}: ${count(entry.pack.terms.length, 'term')}, ${count(entry.pack.hints.length, 'hint')}` +
359 (inUse === undefined ? '' : `, used by ${inUse.join(', ')}`),
360 )
361 if (entry.pack.description !== '') lines.push(` ${entry.pack.description}`)
362 }
363 if (entries.length === 0) lines.push(' none installed')
364
365 const names = new Set(entries.map(entry => entry.name))
366 for (const [name, lists] of users) {
367 if (!names.has(name)) lines.push(` ${name}: NOT FOUND, named by ${lists.join(', ')}`)
368 }
369
370 lines.push(`Your own packs go in ~/${USER_PACKS}/<name>.json; a list uses one with "pack": "<name>".`)
371 return lines
372}
373
374async function seedOf($: EngineInterface): Promise<string> {
375 const stored = await $.store.get('seed')
376 if (typeof stored === 'string' && stored.length >= 16) return stored
377 const seed = [...crypto.getRandomValues(new Uint8Array(16))].map(b => b.toString(16).padStart(2, '0')).join('')
378 await $.store.set('seed', seed)
379 return seed
380}
381
382/** The filter for the topics file as it is now, read again only when it changed. */
383async function current($: EngineInterface): Promise<Loaded> {
384 await syncSidebar($)
385 if (loaded !== undefined && Date.now() - checkedAt < RECHECK_MS) return loaded
386
387 // The key covers the topics file and every pack file the last build read, so editing any of them is seen.
388 const path = await pathOf($)
389 const topicsKey = await statKey($, path)
390 const deps = loaded?.path === path ? loaded.deps : []
391 const key = [topicsKey, ...(await Promise.all(deps.map(d => statKey($, d))))].join('\n')
392 checkedAt = Date.now()
393
394 if (loaded?.key === key) return loaded
395 if (loading?.key === key) return loading.promise
396
397 const previous = loaded
398 const promise = build($, path, topicsKey, previous)
399 loading = { key, promise }
400 try {
401 loaded = await promise
402 } finally {
403 if (loading?.key === key) loading = undefined
404 }
405
406 // A new problem is shown once where the person looks; the status line keeps it.
407 if (loaded.error !== undefined && loaded.error !== previous?.error) {
408 $.ui.toast(`topic-filter: ${firstSentence(loaded.error)} Run /topic-filter for details.`, { timeoutMs: 10_000 })
409 }
410
411 // Cached answers were computed from the old file.
412 if (previous !== undefined) {
413 $.ui.invalidate('prompt.context')
414 $.ui.invalidate('prompt.attachment')
415 $.ui.invalidate('tool.describe')
416 }
417 showStatus($)
418 return loaded
419}
420
421/** The filter to apply now: none while the person has paused it. */
422async function active($: EngineInterface): Promise<Filter | null> {
423 const f = (await current($)).filter
424 return paused ? null : f
425}
426
427/** Drops what was filtered from answers the engine may have kept, after a pause or a resume. */
428function refilter($: EngineInterface): void {
429 $.ui.invalidate('prompt.context')
430 $.ui.invalidate('prompt.attachment')
431 $.ui.invalidate('tool.describe')
432 $.ui.invalidate('ui.render')
433 showStatus($)
434}
435
436async function build($: EngineInterface, path: string, topicsKey: string, previous: Loaded | undefined): Promise<Loaded> {
437 const isMissing = topicsKey.endsWith('|missing')
438
439 let deps: string[] = previous?.deps ?? []
440 let latest: Config | null = previous?.latest ?? null
441 try {
442 // The settings' lists come after the file's, so `lists[i]` in a message
443 // still points into the file.
444 const own = isMissing ? null : parseConfig(await $.fs.read(path))
445 const extra = optionLists(pluginOptions)
446 if (own === null && extra.length === 0) {
447 return { key: topicsKey, path, deps: [], packsUsed: new Map(), config: null, latest: null, filter: null }
448 }
449 const config: Config = { placeholder: 'codename', informModel: true, ...own, lists: [...(own?.lists ?? []), ...extra] }
450 latest = config
451 // Both places of every named pack, found or not: creating or editing
452 // either one is then noticed.
453 const home = await homeOf($)
454 deps = config.lists.flatMap(list => (list.pack === undefined ? [] : packPaths(home, $.plugin.root, list.pack)))
455 const packs = await loadPacks($, config, home)
456 const key = [topicsKey, ...(await Promise.all(deps.map(d => statKey($, d))))].join('\n')
457 const filter = new Filter(config, await seedOf($), packs.terms)
458 return { key, path, deps, packsUsed: packs.used, config, latest, filter }
459 } catch (error) {
460 const message = error instanceof ConfigError ? error.message : 'the file could not be read'
461 const key = [topicsKey, ...(await Promise.all(deps.map(d => statKey($, d))))].join('\n')
462 return {
463 key,
464 path,
465 deps,
466 packsUsed: previous?.packsUsed ?? new Map(),
467 config: previous?.config ?? null,
468 latest,
469 filter: previous?.filter ?? null,
470 error: message,
471 }
472 }
473}
474
475/** A message's first sentence: a period followed by a space or the end, so `lists[0].pack` is not one. */
476const firstSentence = (text: string) => /^.*?[.?!](?=\s|$)/.exec(text)?.[0] ?? text
477
478/** The state in a sentence, for `/topic-filter`: UI only, never sent to the model, so it may name packs and problems. */
479function statusText(): string {
480 if (loaded === undefined || (loaded.filter === null && loaded.error === undefined)) {
481 return 'topic-filter: off, nothing chosen to hide. Switch on packs in /config.'
482 }
483 if (paused) return 'topic-filter: PAUSED, nothing is hidden. /topic-filter on resumes it.'
484 if (loaded.filter === null) return `topic-filter: BLOCKING tool calls. ${firstSentence(loaded.error ?? '')} Run /topic-filter.`
485 const base = `topic-filter: on, ${count(loaded.filter.termCount, 'term')}, ${hiddenCount} hidden`
486 if (loaded.error !== undefined) return `${base}. Using the last good settings: ${firstSentence(loaded.error)} Run /topic-filter.`
487 return base
488}
489
490/**
491 * The footer label, always shown. `isImportant` draws it in the theme's
492 * warning color: something needs the person, or nothing is being hidden.
493 * The details are in `/topic-filter`; the label only points there.
494 */
495function footerLabel(): { text: string; isImportant: boolean } {
496 if (loaded === undefined || (loaded.filter === null && loaded.error === undefined)) {
497 return { text: 'topic-filter: off, nothing chosen', isImportant: true }
498 }
499 if (paused) return { text: 'topic-filter: paused', isImportant: true }
500 if (loaded.filter === null) return { text: 'topic-filter: blocking tool calls, run /topic-filter', isImportant: true }
501 if (loaded.error !== undefined) {
502 return { text: `topic-filter: ${hiddenCount} hidden, settings problem, run /topic-filter`, isImportant: true }
503 }
504 return { text: `topic-filter: ${hiddenCount} hidden`, isImportant: false }
505}
506
507/** Redraws the footer label. The warning-styled status line is never used. */
508function showStatus($: EngineInterface): void {
509 $.ui.invalidate('ui.render')
510}
511
512/**
513 * Counts a pass for the status line, logs it under `source` as a `kind` of
514 * source (see HiddenLog.record for `mode`, `key` and `agent`), and redraws
515 * the sidebar.
516 */
517function counted(
518 $: EngineInterface,
519 tally: Tally,
520 source: string,
521 kind: SourceKind,
522 mode: LogMode = 'add',
523 key?: string,
524 agent?: string,
525): void {
526 hiddenLog.record(source, tally, mode, key, agent, kind)
527 // A pass that hid nothing changes the log only by removing an entry.
528 const n = tally.replaced + tally.dropped
529 hiddenCount += n
530 if (n > 0) showStatus($)
531 else if (tally.hits.size > 0 || mode !== 'add') $.ui.invalidate('ui.render')
532}
533
534/** The overview `/topic-filter` shows the person: every list and where its terms come from. */
535function describe(l: Loaded): string[] {
536 const lines = [statusText().replace(/^topic-filter: /, 'topic-filter is '), `Topics file: ${l.path}`]
537 if (l.latest !== null && l.latest.lists.every(list => list.setting !== undefined)) {
538 lines.push('(No topics file: everything below comes from the plugin settings in /config.)')
539 }
540 if (l.error !== undefined) lines.push(`Problem: ${l.error}`)
541 if (l.latest !== null) {
542 lines.push('Lists:')
543 l.latest.lists.forEach((list, i) => {
544 const parts: string[] = [list.setting === undefined ? list.mode : `${list.mode}, from settings`]
545 const pack = l.packsUsed.get(i)
546 if (pack !== undefined) parts.push(`pack ${pack.name} (${pack.where}, ${count(pack.terms, 'term')})`)
547 else if (list.pack !== undefined) parts.push(`pack ${list.pack} (NOT FOUND)`)
548 if (list.terms.length > 0) parts.push(count(list.terms.length, 'own term'))
549 if (list.exclude.length > 0) parts.push(`${list.exclude.length} excluded`)
550 lines.push(` "${list.name}": ${parts.join(', ')}`)
551 })
552 if ((l.filter?.skippedTerms ?? 0) > 0) lines.push(`${l.filter?.skippedTerms} terms were too short to use.`)
553 }
554 lines.push('Run /topic-filter packs to see every pack, /topic-filter log to see what was hidden and where,')
555 lines.push('and /topic-filter sidebar to watch it in a pane as it happens.')
556 return lines
557}
558
559export function register(on: On, options: PluginOptions) {
560 configured = typeof options.configPath === 'string' ? options.configPath.trim() : ''
561 pluginOptions = options
562 paused = startsPaused()
563
564 on('session.start', async ($, e, next) => {
565 try {
566 await $.command.register({
567 name: COMMAND,
568 description:
569 'Show what topic-filter is set to hide; `off` pauses it and `on` resumes it; `log` lists what was hidden and where; `sidebar` shows or hides it in a pane; `packs` lists topic packs; `reload` re-reads everything',
570 argumentHint: '[on|off|reload|packs|log|log clear|sidebar]',
571 })
572 } catch {
573 // The filter works without its command.
574 }
575
576 await current($)
577 showStatus($)
578 return next(e)
579 })
580
581 // A pause lasts one session: /clear starts the next as the setting says.
582 on('session.end', async ($, e, next) => {
583 if (paused !== startsPaused()) {
584 paused = startsPaused()
585 refilter($)
586 }
587 return next(e)
588 })
589
590 on('tool.call', async ($, e, next) => {
591 const l = await current($)
592 const f = l.filter
593 const input = e as unknown as Record<string, unknown>
594
595 // These two hold while paused too: the list itself stays out of reach, and
596 // a file the model holds a filtered copy of cannot be overwritten whole.
597 // A topics file that failed to load still holds the list.
598 if ((f !== null || l.error !== undefined) && mentionsPath(input, l.path)) {
599 return { deny: 'topic-filter: that path holds the list of hidden topics, which this session may not read or change.' }
600 }
601 // Edit changes only the text it names, and fails if that text spans
602 // something hidden, so it stays allowed.
603 if (e.tool === 'Write' && typeof e.file_path === 'string' && filteredFiles.has(pathKey(e.file_path))) {
604 return {
605 deny:
606 'topic-filter: this file holds content hidden from this session, so overwriting it whole would delete ' +
607 'that content. Use Edit to change specific parts instead.',
608 }
609 }
610
611 if (f === null && l.error !== undefined && !paused) {
612 // The model is told there is a problem, never what it is: the
613 // message names packs, which would say what is being hidden.
614 return {
615 deny:
616 "topic-filter: tool calls are paused because the user's topic-filter settings have a problem. " +
617 'Ask the user to run /topic-filter to see it.',
618 }
619 }
620
621 let call = e
622 if (f !== null && !MODEL_FACING_TOOLS.has(e.tool)) {
623 const used = paused ? [] : f.guardedIn(input)
624 if (used.length > 0) {
625 const names = used.join(', ')
626 return {
627 deny:
628 `topic-filter: ${names} ${used.length === 1 ? 'is a placeholder for a hidden item' : 'are placeholders for hidden items'}. ` +
629 'Hidden items cannot be used in commands, searches or file edits; leave them out or work around them.',
630 }
631 }
632 const restored = f.restore(input)
633 if (restored.changed) call = restored.value as typeof e
634 }
635
636 const result = await next(call)
637 if (paused || f === null) {
638 // Read whole while paused, the file can be written back without loss.
639 if (paused && e.tool === 'Read' && typeof e.file_path === 'string' && readWhole(input, result)) {
640 filteredFiles.delete(pathKey(e.file_path))
641 }
642 return result
643 }
644 const seen = newTally()
645 const filtered = filterResult(f, result, seen)
646 counted($, seen, toolSource(e.tool, input), kindOfTool(e.tool), 'add', undefined, e.agentId)
647 if (e.tool === 'Read' && typeof e.file_path === 'string' && hidesContent(f, seen)) {
648 filteredFiles.add(pathKey(e.file_path))
649 }
650 return filtered
651 }).catch(($, e, next) => ({
652 deny: next.called
653 ? 'topic-filter: the tool ran, but its output is withheld because topic-filter failed while checking it.'
654 : 'topic-filter: this call was refused because topic-filter failed while checking it.',
655 }))
656
657 on('prompt.submit', async ($, e, next) => {
658 const f = await active($)
659 if (f === null) return next(e)
660
661 const tally = newTally()
662 const text = f.text(e.text, tally, false)
663 const context = e.context?.map(c => f.text(c, tally).value).filter(c => c.length > 0)
664 if (tally.replaced === 0 && tally.dropped === 0) return next(e)
665
666 counted($, tally, 'Your prompt', 'prompts')
667 const note = f.informModel ? noteFor(tally, f.restorable) : undefined
668 return next({ ...e, text: text.value, context: note === undefined ? context : [...(context ?? []), note] })
669 }).catch(($, e, next) =>
670 next.called ? undefined : { drop: 'topic-filter failed while checking this prompt, so it was not sent.' },
671 )
672
673 on('prompt.context', async ($, e, next) => {
674 const f = await active($)
675 if (f === null) return next(e)
676
677 const r = await next(f.informModel ? { ...e, blocks: [...e.blocks, { name: 'topicFilter', text: EXPLAINER }] } : e)
678 // The claudeMd block is the instruction files' text: with the files at
679 // hand, it is counted and logged by file, not a second time as a block.
680 const byFile = r.instructionFiles !== undefined
681 const blocks = r.blocks.map(b => {
682 const tally = newTally()
683 const text = f.text(b.text, tally).value
684 if (!(byFile && b.name === 'claudeMd')) counted($, tally, `Context block ${b.name}`, 'context', 'standing')
685 return { ...b, text }
686 })
687 const instructionFiles = r.instructionFiles?.map(file => {
688 const tally = newTally()
689 const content = f.text(file.content, tally).value
690 counted($, tally, file.path, 'context', 'standing')
691 return { ...file, content }
692 })
693 return instructionFiles === undefined ? { blocks } : { blocks, instructionFiles }
694 }).catch(() => ({ blocks: [] }))
695
696 on('prompt.attachment', async ($, e, next) => {
697 const r = await next(e)
698 if (!filtersAttachment(e)) return r
699 const f = await active($)
700 if (f === null || r.text === null) return r
701 const tally = newTally()
702 const text = f.text(r.text, tally)
703 // An attachment has no name of its own; its text tells one from another,
704 // so one asked again (after a reload) replaces its entry.
705 counted($, tally, `Attachment (${e.type})`, 'context', 'replace', `attachment:${e.type}:${fnv1a(r.text)}`, e.agentId)
706 if (!text.changed) return r
707 return { text: text.vanished ? null : text.value }
708 }).catch(($, e) => (filtersAttachment(e) ? { text: null } : undefined))
709
710 on('skill.prompt', async ($, e, next) => {
711 const r = await next(e)
712 const f = await active($)
713 if (f === null) return r
714 const tally = newTally()
715 const text = f.text(r.text, tally)
716 counted($, tally, `Skill ${e.skill}`, 'skills')
717 return text.changed ? { text: text.value } : r
718 }).catch(() => ({ text: 'topic-filter failed while checking this skill, so its text is withheld.' }))
719
720 // Only MCP tools' descriptions: a built-in tool's description carries
721 // Claude Code's own instructions for it, which are never changed.
722 on('tool.describe', async ($, e, next) => {
723 const r = await next(e)
724 if (!isMcpTool(e)) return r
725 const f = await active($)
726 if (f === null) return r
727 const text = f.text(r.description, newTally(), false)
728 return text.changed ? { ...r, description: text.value } : r
729 }).catch(() => undefined)
730
731 on('session.receive', async ($, e, next) => {
732 const f = await active($)
733 if (f === null) return next(e)
734 const tally = newTally()
735 const text = f.text(e.text, tally, false)
736 counted($, tally, 'Message delivered to the session', 'prompts', 'add', undefined, e.agentId)
737 return next(text.changed ? { ...e, text: text.value } : e)
738 }).catch(($, e, next) =>
739 next.called ? undefined : { consumed: 'topic-filter failed while checking this delivery.' },
740 )
741
742 on('command.run', async ($, e, next) => {
743 if (e.command === COMMAND) {
744 const arg = e.args.trim()
745 if (PAUSE_ARGS.has(arg) || RESUME_ARGS.has(arg)) {
746 const { lines, context } = await setPaused($, PAUSE_ARGS.has(arg), e.origin?.kind)
747 for (const line of [...lines, '(Shown to you only; Claude does not see this.)']) $.ui.log(line)
748 return context === undefined ? {} : { context: [context] }
749 }
750 if (arg === 'reload') {
751 loaded = undefined
752 await current($)
753 $.ui.invalidate('prompt.context')
754 $.ui.invalidate('prompt.attachment')
755 $.ui.invalidate('tool.describe')
756 }
757 // Shown to the person only: `ui.log` lines never reach the model, and
758 // these name packs and lists, which would say what is being hidden.
759 if (arg === 'log clear') {
760 hiddenLog.clear()
761 $.ui.invalidate('ui.render')
762 }
763 const l = await current($)
764 const labels = arg === 'log' ? await agentLabels($) : new Map<string, string>()
765 const lines =
766 arg === 'packs'
767 ? await packListing($, l)
768 : arg === 'log'
769 ? hiddenLog.lines(agent => labels.get(agent) ?? agent)
770 : arg === 'log clear'
771 ? ['The log is cleared, except what Claude reads with every request. The status line count keeps its total.']
772 : arg === 'sidebar'
773 ? await toggleSidebar($)
774 : describe(l)
775 for (const line of [...lines, '(Shown to you only; Claude does not see this.)']) $.ui.log(line)
776 return {}
777 }
778
779 const r = await next(e)
780 const f = await active($)
781 if (f === null || r.text === undefined) return r
782 const tally = newTally()
783 const text = f.text(r.text, tally)
784 if (!text.changed) return r
785 counted($, tally, `/${e.command} output`, 'commands')
786 const { ref: _ref, ...rest } = r
787 return { ...rest, text: text.value }
788 }).catch(() => ({ text: 'topic-filter failed while checking this output, so it is withheld.' }))
789
790 // The footer's dim mode labels, with topic-filter's last. A label is a plain
791 // string, so an important one draws the row itself to color its own part.
792 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
793 const label = footerLabel()
794 if (!label.isImportant) return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label.text] } })
795 const { Text } = await $.ui.resolve(e)
796 const others = e.props.modes.join(' & ')
797 return Text({
798 children: [
799 ...(others === '' ? [] : [Text({ dimColor: true, children: `${others} & ` })]),
800 Text({ color: 'warning', children: label.text }),
801 ],
802 })
803 })
804
805 // The sidebar: the whole session, or the subagent whose transcript is in view.
806 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
807 if (e.requestId !== SIDEBAR_ID) return next(e)
808 const { Box, Text, Button } = await $.ui.resolve(e)
809 const agentId = e.props.view.agentId
810 const agent = agentId === undefined ? undefined : await sidebarLabel($, agentId)
811 sidebarAgent = agentId
812 return sidebarView(
813 { Box, Text, Button },
814 {
815 summary: hiddenLog.summary(agentId),
816 ...(agent === undefined ? {} : { agent }),
817 countsOnly: pluginOptions.sidebarCountsOnly === true,
818 isPaused: paused,
819 view: sidebarState,
820 columns: e.props.bodyColumns,
821 rows: e.props.scroll.bodyRows,
822 onPress: pressSidebar,
823 },
824 )
825 })
826
827 // A press ran its button's handler, which changed the view: draw it again.
828 on('ui.press', async ($, e, next) => {
829 const r = await next(e)
830 if (e.requestId === SIDEBAR_ID) $.ui.invalidate('ui.render')
831 return r
832 })
833}
834
835/**
836 * What the person picked in the sidebar: memory only, like the log, so a
837 * reload opens it fresh. One view for every scope it is drawn in.
838 */
839let sidebarState: SidebarView = { tab: 'lists', open: new Set() }
840
841/** The subagent the sidebar was last drawn for, which a row press refers to. */
842let sidebarAgent: string | undefined
843
844/** A sidebar button's press: `tab:<tab>`, `depth:<0-2>` or `row:<id>`. */
845function pressSidebar(key: string): void {
846 const [kind, ...rest] = key.split(':')
847 const arg = rest.join(':')
848 if (kind === 'tab' && (arg === 'lists' || arg === 'feed')) {
849 sidebarState = { ...sidebarState, tab: arg }
850 } else if (kind === 'depth' && (arg === '0' || arg === '1' || arg === '2')) {
851 sidebarState = { ...sidebarState, depth: Number(arg) as Depth, open: new Set() }
852 } else if (kind === 'row') {
853 // From a depth to rows picked one by one: start from what shows now.
854 const open = openRows(sidebarState, hiddenLog.summary(sidebarAgent))
855 if (open.has(arg)) open.delete(arg)
856 else open.add(arg)
857 sidebarState = { ...sidebarState, depth: null, open }
858 }
859}
860
861const PAUSE_ARGS = new Set(['off', 'pause', 'stop'])
862const RESUME_ARGS = new Set(['on', 'resume', 'start'])
863
864/**
865 * Pauses or resumes filtering. Answers the lines the person sees, and a note
866 * for the model when the state changed, so it knows whether placeholders
867 * are still refused.
868 */
869async function setPaused($: EngineInterface, pause: boolean, origin: string | undefined): Promise<{ lines: string[]; context?: string }> {
870 if (pause && !MAY_PAUSE.has(origin ?? '')) {
871 return { lines: ['Only you can pause topic-filter, by typing /topic-filter off. It stays on.'] }
872 }
873 const l = await current($)
874 const was = paused
875 paused = pause
876 if (was !== pause) refilter($)
877
878 if (pause) {
879 return {
880 lines: was
881 ? ['topic-filter is already paused. /topic-filter on resumes it.']
882 : [
883 'topic-filter paused: nothing is hidden until /topic-filter on.',
884 'What Claude already read stays as it was. A restart, /clear or a reload turns it back on.',
885 ],
886 ...(was ? {} : { context: 'The user paused topic-filter: tool output is no longer filtered, and placeholders are no longer refused.' }),
887 }
888 }
889
890 const status =
891 l.filter === null
892 ? statusText()
893 : `Filter on: ${count(l.config?.lists.length ?? 0, 'list')}, ${count(l.filter.termCount, 'term')}.`
894 return {
895 lines: [was ? status : `${status} (It was not paused.)`],
896 ...(was ? { context: 'The user turned topic-filter back on: hidden items are filtered again, and placeholders are refused in tool calls.' } : {}),
897 }
898}
899
900/** Opens the sidebar, or closes it when it is open; says which, for the person. */
901async function toggleSidebar($: EngineInterface): Promise<string[]> {
902 const isOpen = (await $.ui.panes()).some(pane => pane.id === SIDEBAR_ID)
903 if (isOpen) {
904 await $.ui.close({ id: SIDEBAR_ID })
905 return ['Sidebar closed. /topic-filter sidebar opens it again.']
906 }
907 const opened = await $.ui.open({ id: SIDEBAR_ID, title: 'topic-filter' })
908 if (!opened.isPlaced) return [`Sidebar opened, but it waits undrawn: ${opened.reason}`]
909 return [
910 'Sidebar open. With other panes open it is one of their tabs: click a tab to switch;',
911 'ctrl+x x or its close mark closes it. The "Sidebar" switch in /config opens it at every start.',
912 ]
913}
914
915/**
916 * Filters a tool's result for the model. `seen` counts what was hidden from
917 * the text the model reads (`text`, which core maps from the record), not
918 * from record fields it never sees (a Write's `originalFile`), so the note
919 * and the status line describe the model's view.
920 */
921function filterResult(f: Filter, r: ToolCallResult, seen: Tally): ToolCallResult {
922 if (r.deny !== undefined) {
923 const text = f.text(r.deny, seen, false)
924 return text.changed ? { deny: text.value } : r
925 }
926
927 // An errored call reaches the model as its error text; a deny carries a
928 // rewritten one the same way.
929 if (r.isError === true) {
930 const text = f.text(r.text ?? '', seen, false)
931 return text.changed ? { deny: text.value } : r
932 }
933
934 const record = f.value(r.result, newTally())
935 if (typeof r.text === 'string') f.text(r.text, seen)
936 const context = r.context?.map(c => f.text(c, seen))
937 const contextChanged = context?.some(c => c.changed) ?? false
938
939 if (!record.changed && !contextChanged) {
940 // A term in `text` that the record does not hold came from the mapper,
941 // so filtering the record cannot remove it: send the filtered text as an
942 // error instead.
943 if (typeof r.text === 'string' && f.hasTerm(r.text)) return { deny: f.text(r.text, newTally(), false).value }
944 return r
945 }
946
947 const kept = (context ?? []).map(c => c.value).filter(c => c.length > 0)
948 const note = f.informModel ? noteFor(seen, f.restorable) : undefined
949 const all = note === undefined ? kept : [...kept, note]
950 // Without `ref`, core maps the filtered record for the model afresh.
951 return all.length > 0 ? { result: record.value, context: all } : { result: record.value }
952}
953
954/**
955 * Whether a Read answered the whole file: no range asked, and every line
956 * returned (a long file is cut at the engine's line cap).
957 */
958function readWhole(input: Record<string, unknown>, r: ToolCallResult): boolean {
959 if (r.deny !== undefined || r.isError === true) return false
960 if (input.offset !== undefined || input.limit !== undefined || input.pages !== undefined) return false
961 const file = (r.result as { file?: { numLines?: unknown; totalLines?: unknown } } | undefined)?.file
962 return typeof file?.numLines === 'number' && file.numLines === file.totalLines
963}
964
965/** Whether a tool call names the topics file (best effort: a name match, not a sandbox). */
966/**
967 * Fields that are text being written into some file, not a file to act on: a
968 * README or script that merely mentions the topics file's path is fine. The
969 * target (`file_path`), commands, patterns and paths are still checked.
970 */
971const WRITTEN_TEXT = new Set(['content', 'old_string', 'new_string', 'new_source'])
972
973function mentionsPath(input: Record<string, unknown>, path: string): boolean {
974 const norm = (s: string) => s.replace(/\\/g, '/').toLowerCase()
975 const full = norm(path)
976 const tail = full.split('/').slice(-2).join('/')
977 let found = false
978 const checked = Object.fromEntries(Object.entries(input).filter(([key]) => !WRITTEN_TEXT.has(key)))
979 forEachString(checked, s => {
980 const n = norm(s)
981 if (n.includes(full) || n.includes(tail)) found = true
982 })
983 return found
984}
985hooks/config.ts 209 lines1// Reads topics.json. Errors name a position (`lists[1].terms[3]`), never a
2// term, because an error can reach the transcript and the terms are exactly
3// what must not.
4
5import type { Mode } from './matcher.ts'
6import type { PlaceholderStyle } from './placeholders.ts'
7
8export type ListConfig = {
9 name: string
10 mode: Mode
11 /** `word`: a term matches only as whole words. `substring`: anywhere. */
12 match: 'word' | 'substring'
13 /** Put the real term back when the model uses this list's placeholder in a tool call. */
14 restore: boolean
15 terms: string[]
16 /** A topic pack whose terms join `terms`: the user's own file first, else the built-in one. */
17 pack?: string
18 /** Terms left out of this list, whatever brought them in (the pack or `terms`). */
19 exclude: string[]
20 /** The plugin option this list came from; absent for the topics file's own lists. */
21 setting?: string
22}
23
24export type Config = {
25 placeholder: PlaceholderStyle
26 /** Tell the model that placeholders exist and how to treat them. */
27 informModel: boolean
28 lists: ListConfig[]
29}
30
31export class ConfigError extends Error {}
32
33const isRecord = (v: unknown): v is Record<string, unknown> =>
34 typeof v === 'object' && v !== null && !Array.isArray(v)
35
36function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T, where: string): T {
37 if (value === undefined) return fallback
38 if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
39 throw new ConfigError(`${where} must be one of ${allowed.join(', ')}`)
40}
41
42function flag(value: unknown, fallback: boolean, where: string): boolean {
43 if (value === undefined) return fallback
44 if (typeof value === 'boolean') return value
45 throw new ConfigError(`${where} must be true or false`)
46}
47
48/** Parses and checks the topics file's text. */
49export function parseConfig(text: string): Config {
50 let raw: unknown
51 try {
52 raw = JSON.parse(text)
53 } catch {
54 throw new ConfigError('topics.json is not valid JSON')
55 }
56 if (!isRecord(raw)) throw new ConfigError('topics.json must hold an object')
57
58 const lists = raw.lists
59 if (!Array.isArray(lists)) throw new ConfigError('lists must be an array')
60
61 return {
62 placeholder: oneOf(raw.placeholder, ['codename', 'tag'] as const, 'codename', 'placeholder'),
63 informModel: flag(raw.informModel, true, 'informModel'),
64 lists: lists.map((list, i) => parseList(list, i)),
65 }
66}
67
68function parseList(list: unknown, i: number): ListConfig {
69 const at = `lists[${i}]`
70 if (!isRecord(list)) throw new ConfigError(`${at} must be an object`)
71
72 const pack = list.pack
73 if (pack !== undefined && (typeof pack !== 'string' || !SLUG.test(pack))) {
74 throw new ConfigError(`${at}.pack must be a pack name (lowercase letters, digits, hyphens)`)
75 }
76
77 const restore = flag(list.restore, false, `${at}.restore`)
78 // Restore writes real terms into tool input; it is for the person's own
79 // names, never for a topic pack.
80 if (restore && pack !== undefined) throw new ConfigError(`${at}.restore is not allowed on a list that uses a pack`)
81
82 return {
83 name: typeof list.name === 'string' ? list.name : `list ${i + 1}`,
84 mode: oneOf(list.mode, ['replace', 'drop-line'] as const, 'replace', `${at}.mode`),
85 match: oneOf(list.match, ['word', 'substring'] as const, 'word', `${at}.match`),
86 restore,
87 terms: strings(list.terms, `${at}.terms`),
88 pack,
89 exclude: strings(list.exclude, `${at}.exclude`),
90 }
91}
92
93/** A pack name: also safe as a file name, never a path. */
94export const SLUG = /^[a-z0-9][a-z0-9-]*$/
95
96/** The built-in packs' switches in the plugin's settings, by option key. */
97export const PACK_OPTIONS: Readonly<Record<string, string>> = {
98 hideAnthropology: 'anthropology',
99 hideBiology: 'biology',
100 hideChemistry: 'chemistry',
101 hideGenetics: 'genetics',
102}
103
104/** A comma-separated option (or a list option) as its items. */
105function items(value: unknown): string[] {
106 const parts = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : []
107 return parts.filter((s): s is string => typeof s === 'string').map(s => s.trim()).filter(s => s !== '')
108}
109
110function slugs(value: unknown, setting: string, what: string): string[] {
111 const names = items(value).map(s => s.toLowerCase())
112 const bad = names.findIndex(name => !SLUG.test(name))
113 if (bad >= 0) throw new ConfigError(`The ${setting} setting's item ${bad + 1} is not a ${what} (lowercase letters, digits, hyphens).`)
114 return [...new Set(names)]
115}
116
117/**
118 * The lists the plugin's own settings (`/plugin configure`, `/config`) add to
119 * the topics file's: switched-on packs and extra words. The topics file is then only needed for more than
120 * this.
121 */
122export function optionLists(options: Readonly<Record<string, unknown>>): ListConfig[] {
123 const list = (over: Partial<ListConfig> & { name: string; setting: string }): ListConfig => ({
124 mode: 'replace',
125 match: 'word',
126 restore: false,
127 terms: [],
128 exclude: [],
129 ...over,
130 })
131 const lists: ListConfig[] = []
132
133 const packs = new Map<string, string>()
134 for (const [key, pack] of Object.entries(PACK_OPTIONS)) {
135 if (options[key] === true) packs.set(pack, `Hide ${pack}`)
136 }
137 for (const pack of slugs(options.otherPacks, 'Other packs', 'pack name')) {
138 if (!packs.has(pack)) packs.set(pack, 'Other packs')
139 }
140 for (const [pack, setting] of packs) lists.push(list({ name: `pack ${pack}`, setting, pack }))
141
142 const words = items(options.extraWords)
143 if (words.length > 0) lists.push(list({ name: 'extra words', setting: 'Extra words to hide', terms: words }))
144
145 return lists
146}
147
148function strings(value: unknown, where: string): string[] {
149 if (value === undefined) return []
150 if (!Array.isArray(value)) throw new ConfigError(`${where} must be an array`)
151 value.forEach((item, j) => {
152 if (typeof item !== 'string') throw new ConfigError(`${where}[${j}] must be a string`)
153 })
154 return value as string[]
155}
156
157/** The candidate a mistyped name most likely meant, or undefined when none is close. */
158export function closestName(name: string, candidates: readonly string[]): string | undefined {
159 let best: string | undefined
160 let bestDistance = Infinity
161 for (const candidate of candidates) {
162 if (candidate.startsWith(name) || name.startsWith(candidate)) return candidate
163 const d = editDistance(name, candidate)
164 if (d < bestDistance) {
165 best = candidate
166 bestDistance = d
167 }
168 }
169 return bestDistance <= Math.max(2, Math.floor(name.length / 3)) ? best : undefined
170}
171
172function editDistance(a: string, b: string): number {
173 let row = Array.from({ length: b.length + 1 }, (_, j) => j)
174 for (let i = 1; i <= a.length; i += 1) {
175 const cur = [i]
176 for (let j = 1; j <= b.length; j += 1) {
177 cur[j] = Math.min(row[j]! + 1, cur[j - 1]! + 1, row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
178 }
179 row = cur
180 }
181 return row[b.length]!
182}
183
184/** A topic pack as its file holds it; only `terms` hides anything today. */
185export type Pack = {
186 name: string
187 description: string
188 terms: string[]
189 hints: string[]
190}
191
192/** Parses a pack file. Errors name the pack and a position, never a term. */
193export function parsePack(text: string, name: string): Pack {
194 let raw: unknown
195 try {
196 raw = JSON.parse(text)
197 } catch {
198 throw new ConfigError(`pack "${name}" is not valid JSON`)
199 }
200 if (!isRecord(raw)) throw new ConfigError(`pack "${name}" must hold an object`)
201
202 return {
203 name,
204 description: typeof raw.description === 'string' ? raw.description : '',
205 terms: strings(raw.terms, `pack "${name}" terms`),
206 hints: strings(raw.hints, `pack "${name}" hints`),
207 }
208}
209hooks/log.ts 282 lines1// What was hidden this session and where it came from, for `/topic-filter
2// log`. It names the real terms, so it lives in memory only and is shown
3// through `ui.log`, which never reaches the model: a file of its own would be
4// a second copy of the hidden list, somewhere the model might read it. The
5// engine's debug log does get every `ui.log` line, as the README says.
6
7import type { Hit, Tally } from './redact.ts'
8
9/** Past this many passing sources the oldest are forgotten. */
10const MAX_SOURCES = 200
11
12/** Past this many hits the feed forgets the oldest. */
13const MAX_FEED = 100
14
15/** Past this many terms a source's entry says how many more there were. */
16const MAX_TERMS_SHOWN = 20
17
18/** Past this many characters a source's label is cut. */
19const MAX_LABEL = 100
20
21/** "1 word", "1,204 words": a count as the log and the sidebar show it. */
22export const plural = (n: number, noun: string) => `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`
23
24/** A source's label on one line, cut to a readable length. */
25export function label(text: string): string {
26 const flat = text.replace(/\s+/g, ' ').trim()
27 return flat.length <= MAX_LABEL ? flat : `${flat.slice(0, MAX_LABEL - 3)}...`
28}
29
30/**
31 * How a pass joins the log:
32 * - `add`: something read once (a tool's output, a prompt); passes add up.
33 * - `replace`: the pass covers the source's whole text and may run again for
34 * the same text (an attachment asked again after a reload); it replaces the
35 * last one, and a pass that hid nothing removes the entry.
36 * - `standing`: like `replace`, for text in every request of the session (a
37 * CLAUDE.md and the other context blocks). Kept apart, never forgotten to the
38 * cap, and kept by `clear`, since the engine may not filter it again.
39 */
40export type LogMode = 'add' | 'replace' | 'standing'
41
42/** What kind of source a pass read, for the sidebar's bars. */
43export type SourceKind = 'files' | 'prompts' | 'web' | 'commands' | 'searches' | 'skills' | 'context' | 'tools'
44
45/** Each kind as the sidebar names it. */
46export const KIND_LABELS: Record<SourceKind, string> = {
47 files: 'Files',
48 prompts: 'Prompts',
49 web: 'Web',
50 commands: 'Commands',
51 searches: 'Searches',
52 skills: 'Skills',
53 context: 'Context',
54 tools: 'Other tools',
55}
56
57/**
58 * One source's hits; `agent` is the subagent whose loop read it, absent for
59 * the main conversation, and `at` when it last hid something new.
60 */
61type Entry = { label: string; agent?: string; kind: SourceKind; at: number; hits: Map<string, Hit> }
62
63/** A source a term was hidden in, and how many times. */
64export type TermSource = { label: string; n: number }
65
66/** A term's share of a summary, with the sources it was hidden in, most first. */
67export type TermSummary = Hit & { sources: TermSource[] }
68
69/** One list's share of a summary: its hits merged across sources, most hidden first. */
70export type ListSummary = { list: string; replaced: number; dropped: number; hits: TermSummary[] }
71
72/** One pass that hid something, as the feed shows it. */
73export type FeedItem = {
74 at: number
75 label: string
76 agent?: string
77 replaced: number
78 dropped: number
79 hits: { list: string; term: string; n: number }[]
80}
81
82/** What the sidebar draws: totals, each list's share, and where the last hit came from. */
83export type Summary = {
84 replaced: number
85 dropped: number
86 /** Distinct terms hidden. */
87 terms: number
88 lists: ListSummary[]
89 /** The label of the most recent source with a hit, if any. */
90 latest?: string
91 /** The subagents with hits, ordered by their least recently hidden source. */
92 agents: string[]
93 /** How much each kind of source hid, most first; kinds that hid nothing are left out. */
94 kinds: { kind: SourceKind; n: number }[]
95 /** How many sources hid something. */
96 sources: number
97 /** When something was last hidden, if ever. */
98 lastAt?: number
99 /** The passes that hid something, newest first. */
100 feed: FeedItem[]
101}
102
103export class HiddenLog {
104 /** Passing sources by slot, least recently hidden first. */
105 private readonly passing = new Map<string, Entry>()
106 /** Standing sources by slot. */
107 private readonly standing = new Map<string, Entry>()
108 /** Passing sources forgotten to stay under the cap. */
109 private forgotten = 0
110 /** Every pass that hid something new, oldest first. */
111 private readonly feed: FeedItem[] = []
112
113 /** `now` tells the time; tests pass their own. */
114 constructor(private readonly now: () => number = () => Date.now()) {}
115
116 /**
117 * Adds one pass's hits under `source`, shown cut to one line; `slot` tells
118 * sources apart when their labels could match (a long path cut short), and
119 * `agent` names the subagent whose loop read it.
120 */
121 record(source: string, tally: Tally, mode: LogMode = 'add', slot: string = source, agent?: string, kind: SourceKind = 'tools'): void {
122 const entries = mode === 'standing' ? this.standing : this.passing
123 slot = agent === undefined ? slot : `${agent}\u0000${slot}`
124 const previous = entries.get(slot)
125 if (tally.hits.size === 0) {
126 if (mode !== 'add' && previous !== undefined) entries.delete(slot)
127 return
128 }
129
130 const hits = new Map<string, Hit>(mode !== 'add' || previous === undefined ? [] : previous.hits)
131 for (const [term, hit] of tally.hits) {
132 const had = hits.get(term)
133 // The newest placeholder and list name stand: settings may have changed since.
134 hits.set(term, had === undefined ? { ...hit } : { ...hit, replaced: had.replaced + hit.replaced, dropped: had.dropped + hit.dropped })
135 }
136 // A pass that only repeats what a standing or replaced source already
137 // hid (the same CLAUDE.md, every request) is not news: it keeps its
138 // time and stays out of the feed.
139 const isNew = mode === 'add' || previous === undefined
140 const at = isNew ? this.now() : previous.at
141 if (isNew) this.remember(label(source), tally, at, agent)
142
143 // Newest last: a source hidden again moves to the end.
144 entries.delete(slot)
145 const entry: Entry = { label: label(source), kind, at, hits }
146 entries.set(slot, agent === undefined ? entry : { ...entry, agent })
147
148 while (this.passing.size > MAX_SOURCES) {
149 this.passing.delete(this.passing.keys().next().value!)
150 this.forgotten += 1
151 }
152 }
153
154 private remember(source: string, tally: Tally, at: number, agent?: string): void {
155 const hits = [...tally.hits.values()].map(hit => ({ list: hit.list, term: hit.term, n: hit.replaced + hit.dropped }))
156 hits.sort((a, b) => b.n - a.n)
157 const item: FeedItem = { at, label: source, replaced: tally.replaced, dropped: tally.dropped, hits }
158 this.feed.push(agent === undefined ? item : { ...item, agent })
159 if (this.feed.length > MAX_FEED) this.feed.shift()
160 }
161
162 /** Empties what passed and the feed; what stands in every request stays, as it is still hidden there. */
163 clear(): void {
164 this.passing.clear()
165 this.forgotten = 0
166 this.feed.length = 0
167 }
168
169 /** The log as the person reads it; `agentName` labels a subagent by its id. */
170 lines(agentName: (agent: string) => string = agent => agent): string[] {
171 if (this.passing.size === 0 && this.standing.size === 0) return ['Nothing has been hidden this session yet.']
172
173 const lines: string[] = []
174 if (this.standing.size > 0) {
175 lines.push('Hidden in what Claude reads with every request (CLAUDE.md, context blocks):')
176 for (const entry of this.standing.values()) lines.push(...entryLines(entry))
177 }
178 if (this.forgotten > 0) lines.push(`(${plural(this.forgotten, 'older source')} not shown)`)
179
180 // The main conversation first, then each subagent, ordered by its least recently hidden source.
181 const byAgent = new Map<string | undefined, Entry[]>()
182 for (const entry of this.passing.values()) byAgent.set(entry.agent, [...(byAgent.get(entry.agent) ?? []), entry])
183 const main = byAgent.get(undefined)
184 if (main !== undefined) {
185 lines.push('Hidden as it came in (most recent last):')
186 for (const entry of main) lines.push(...entryLines(entry))
187 }
188 for (const [agent, entries] of byAgent) {
189 if (agent === undefined) continue
190 lines.push(`Hidden in subagent ${agentName(agent)} (most recent last):`)
191 for (const entry of entries) lines.push(...entryLines(entry))
192 }
193 return lines
194 }
195
196 /**
197 * Totals and each list's share, for the sidebar: the whole session, or one
198 * subagent's own reads when `agent` is given.
199 */
200 summary(agent?: string): Summary {
201 const inScope = (entry: Entry) => agent === undefined || entry.agent === agent
202 const entries = [...(agent === undefined ? this.standing.values() : []), ...this.passing.values()].filter(inScope)
203
204 const weight = (x: { replaced: number; dropped: number }) => x.replaced + x.dropped
205 const byTerm = new Map<string, TermSummary>()
206 const byTermSource = new Map<string, Map<string, number>>()
207 const byKind = new Map<SourceKind, number>()
208 for (const entry of entries) {
209 for (const [term, hit] of entry.hits) {
210 const had = byTerm.get(term)
211 byTerm.set(
212 term,
213 had === undefined
214 ? { ...hit, sources: [] }
215 : { ...hit, sources: [], replaced: had.replaced + hit.replaced, dropped: had.dropped + hit.dropped },
216 )
217 const sources = byTermSource.get(term) ?? new Map<string, number>()
218 sources.set(entry.label, (sources.get(entry.label) ?? 0) + weight(hit))
219 byTermSource.set(term, sources)
220 byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + weight(hit))
221 }
222 }
223 for (const [term, hit] of byTerm) {
224 hit.sources = [...(byTermSource.get(term) ?? [])].map(([label, n]) => ({ label, n })).sort((a, b) => b.n - a.n)
225 }
226
227 const byList = new Map<string, ListSummary>()
228 for (const hit of byTerm.values()) {
229 const list = byList.get(hit.list) ?? { list: hit.list, replaced: 0, dropped: 0, hits: [] }
230 list.replaced += hit.replaced
231 list.dropped += hit.dropped
232 list.hits.push(hit)
233 byList.set(hit.list, list)
234 }
235 const lists = [...byList.values()].sort((a, b) => weight(b) - weight(a))
236 for (const list of lists) list.hits.sort((a, b) => weight(b) - weight(a))
237
238 const passing = [...this.passing.values()].filter(inScope)
239 const agents = [...new Set([...this.passing.values()].flatMap(entry => (entry.agent === undefined ? [] : [entry.agent])))]
240 const lastAt = entries.reduce<number | undefined>((at, entry) => (at === undefined || entry.at > at ? entry.at : at), undefined)
241 const feed = this.feed.filter(item => agent === undefined || item.agent === agent).reverse()
242 return {
243 replaced: lists.reduce((n, list) => n + list.replaced, 0),
244 dropped: lists.reduce((n, list) => n + list.dropped, 0),
245 terms: byTerm.size,
246 lists,
247 ...(passing.length > 0 ? { latest: passing.at(-1)!.label } : {}),
248 agents,
249 kinds: [...byKind].map(([kind, n]) => ({ kind, n })).sort((a, b) => b.n - a.n),
250 sources: entries.length,
251 ...(lastAt === undefined ? {} : { lastAt }),
252 feed,
253 }
254 }
255}
256
257/** One source: its label, each replaced term, then the lines each list dropped (counted, never shown). */
258function entryLines({ label, hits }: Entry): string[] {
259 const lines = [` ${label}`]
260 const all = [...hits.values()]
261
262 const replaced = all.filter(hit => hit.replaced > 0).sort((a, b) => b.replaced - a.replaced)
263 for (const hit of replaced.slice(0, MAX_TERMS_SHOWN)) {
264 lines.push(` ${hit.term} -> ${hit.placeholder} (x${hit.replaced})`)
265 }
266 if (replaced.length > MAX_TERMS_SHOWN) lines.push(` and ${plural(replaced.length - MAX_TERMS_SHOWN, 'more term')}`)
267
268 const byList = new Map<string, Hit[]>()
269 for (const hit of all) if (hit.dropped > 0) byList.set(hit.list, [...(byList.get(hit.list) ?? []), hit])
270 for (const [list, dropped] of byList) {
271 dropped.sort((a, b) => b.dropped - a.dropped)
272 const total = dropped.reduce((n, hit) => n + hit.dropped, 0)
273 const terms = dropped
274 .slice(0, MAX_TERMS_SHOWN)
275 .map(hit => `${hit.term} (x${hit.dropped})`)
276 .join(', ')
277 const more = dropped.length > MAX_TERMS_SHOWN ? `, and ${dropped.length - MAX_TERMS_SHOWN} more` : ''
278 lines.push(` ${plural(total, 'line')} dropped by "${list}": ${terms}${more}`)
279 }
280 return lines
281}
282hooks/placeholders.ts 115 lines1// Gives each hidden term the name the model reads in its place, and finds
2// those names again in what the model writes. A term's name depends only on
3// the term and this machine's seed, never on the session or on which text
4// it turned up in, so memory files, the prompt cache and a long transcript
5// stay consistent. The seed keeps the mapping from being recomputed by
6// anyone who has the word list but not this machine's store.
7
8import { CODENAMES } from './codenames.ts'
9
10export type PlaceholderStyle = 'codename' | 'tag'
11
12/** 32-bit FNV-1a: small, fast, and good enough to spread terms over names. */
13export function fnv1a(text: string): number {
14 let hash = 0x811c9dc5
15 for (let i = 0; i < text.length; i += 1) {
16 hash ^= text.charCodeAt(i)
17 hash = Math.imul(hash, 0x01000193)
18 }
19 return hash >>> 0
20}
21
22const CODENAME_SHAPE = /\b([A-Z][a-z]+)(\d*)(e?s)?\b/g
23const TAG_SHAPE = /\[hidden-[0-9a-f]{6}\](e?s)?/g
24
25/** One placeholder found in a text written by the model. */
26export type PlaceholderHit = {
27 start: number
28 end: number
29 name: string
30 /** A plural ending written after the name. */
31 suffix: string
32}
33
34/**
35 * The names for a set of folded terms. `avoid` lists folded strings no
36 * codename may equal or contain (the terms themselves, so a codename is
37 * never mistaken for a hidden word).
38 */
39export function assignPlaceholders(
40 foldedTerms: readonly string[],
41 style: PlaceholderStyle,
42 seed: string,
43 avoid: { equal: ReadonlySet<string>; contain: readonly string[] },
44): Map<string, string> {
45 const names = new Map<string, string>()
46 const taken = new Set<string>()
47 const unique = [...new Set(foldedTerms)].sort()
48
49 for (const term of unique) {
50 const hash = fnv1a(`${seed}\u0000${term}`)
51 names.set(term, style === 'tag' ? tagFor(hash, taken) : codenameFor(hash, taken, avoid))
52 }
53
54 return names
55}
56
57function codenameFor(
58 hash: number,
59 taken: Set<string>,
60 avoid: { equal: ReadonlySet<string>; contain: readonly string[] },
61): string {
62 const n = CODENAMES.length
63 const first = hash % n
64 // Always numbered, so a name is never a word people really write: a plain
65 // one would make every real mention of that word read as a hidden item.
66 const digit = 2 + (Math.floor(hash / n) % 8)
67
68 for (let c = 0; ; c += 1) {
69 const round = Math.floor((first + c) / n)
70 const name = `${CODENAMES[(first + c) % n]!}${round * 10 + digit}`
71 const lower = name.toLowerCase()
72 if (taken.has(name) || avoid.equal.has(lower) || avoid.contain.some(t => lower.includes(t))) continue
73 taken.add(name)
74 return name
75 }
76}
77
78function tagFor(hash: number, taken: Set<string>): string {
79 for (let h = hash; ; h = (h + 1) >>> 0) {
80 const name = `[hidden-${h.toString(16).padStart(8, '0').slice(0, 6)}]`
81 if (taken.has(name)) continue
82 taken.add(name)
83 return name
84 }
85}
86
87/** Every placeholder of `known` in `text`, left to right. */
88export function findPlaceholders(text: string, known: ReadonlySet<string>): PlaceholderHit[] {
89 const hits: PlaceholderHit[] = []
90
91 for (const m of text.matchAll(CODENAME_SHAPE)) {
92 // The letters are greedy, so `Bubblegums` arrives whole: try it, then
93 // without a plural ending.
94 const word = `${m[1]}${m[2]}`
95 const suffix = m[3] ?? ''
96 const end = m.index + m[0].length
97 for (const cut of suffix === '' ? [0, 1, 2] : [0]) {
98 const name = word.slice(0, word.length - cut)
99 const ending = word.slice(word.length - cut) + suffix
100 if ((cut === 0 || /^e?s$/.test(ending)) && known.has(name)) {
101 hits.push({ start: m.index, end, name, suffix: ending })
102 break
103 }
104 }
105 }
106 for (const m of text.matchAll(TAG_SHAPE)) {
107 const name = m[0].slice(0, m[0].length - (m[1]?.length ?? 0))
108 if (known.has(name)) {
109 hits.push({ start: m.index, end: m.index + m[0].length, name, suffix: m[1] ?? '' })
110 }
111 }
112
113 return hits.sort((a, b) => a.start - b.start)
114}
115hooks/sidebar.tsx 306 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4// The sidebar: a pane that shows, as the session goes, what topic-filter has
5// hidden. Drawn for the person only, like `/topic-filter log`: a pane is part
6// of the screen, never of what the model reads.
7//
8// Two tabs. Lists is a tree (list, then term, then the sources it was hidden
9// in) with a three-stop control that expands it to a depth; Feed is each pass
10// that hid something, newest first. Under both, bars for where hits came from.
11
12import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
13
14import { KIND_LABELS, plural, type FeedItem, type Summary } from './log.ts'
15
16/** The pane's id, one per plugin. */
17export const SIDEBAR_ID = 'topic-filter'
18
19/** From this many distinct terms on, the tree starts collapsed to its lists. */
20export const CONDENSE_AT = 30
21
22/** The elements the sidebar draws with: every surface has these three. */
23export type SidebarUi = {
24 Box: ElementConstructor<BoxProps>
25 Text: ElementConstructor<TextProps>
26 Button: ElementConstructor<ButtonProps>
27}
28
29/** How deep the tree is open: 0 lists only, 1 their terms, 2 each term's sources. */
30export type Depth = 0 | 1 | 2
31
32/**
33 * What the person chose in the pane. `depth` set opens the tree to it;
34 * `null` means rows were opened one by one, as `open` lists; absent, the
35 * default for the summary's size.
36 */
37export type SidebarView = { tab: 'lists' | 'feed'; depth?: Depth | null; open: ReadonlySet<string> }
38
39export type SidebarInput = {
40 summary: Summary
41 /** The subagent in view and its label, or undefined for the whole session. */
42 agent?: string
43 /** Counts by list only, never a word, a placeholder or a source. */
44 countsOnly: boolean
45 /** Filtering is paused: the pane says so above everything else. */
46 isPaused?: boolean
47 view: SidebarView
48 /** The pane's body, in cells. */
49 columns: number
50 rows: number
51 /** Presses on the pane's buttons; `key` is the pressed element's. */
52 onPress: (key: string) => void
53}
54
55/** A tree row's id: a list, or a term within one. Also the row button's key, after `row:`. */
56export const listId = (list: string) => `l/${encodeURIComponent(list)}`
57export const termId = (list: string, term: string) => `t/${encodeURIComponent(list)}/${encodeURIComponent(term)}`
58
59/** The depth the tree opens to before the person picks one. */
60export const defaultDepth = (summary: Summary): Depth => (summary.terms >= CONDENSE_AT ? 0 : 1)
61
62/** Whether a row is open in `view`. */
63export function isOpen(view: SidebarView, summary: Summary, id: string): boolean {
64 const depth = view.depth === undefined ? defaultDepth(summary) : view.depth
65 if (depth === null) return view.open.has(id)
66 return id.startsWith('l/') ? depth >= 1 : depth >= 2
67}
68
69/** Every row open in `view` now, so a press can switch from a depth to rows picked one by one. */
70export function openRows(view: SidebarView, summary: Summary): Set<string> {
71 const ids = summary.lists.flatMap(list => [listId(list.list), ...list.hits.map(hit => termId(list.list, hit.term))])
72 return new Set(ids.filter(id => isOpen(view, summary, id)))
73}
74
75/** Short bars: the widest list's bar is this many cells. */
76const MINI = 8
77/** Room kept for a count at a row's end ("12 words"). */
78const COUNT_WIDTH = 9
79/** A term's column in the tree. */
80const TERM_WIDTH = 14
81
82/** "3 words, 2 lines": what a list or the session hid, in the person's terms. */
83function counts(x: { replaced: number; dropped: number }): string {
84 const parts: string[] = []
85 if (x.replaced > 0) parts.push(plural(x.replaced, 'word'))
86 if (x.dropped > 0) parts.push(plural(x.dropped, 'line'))
87 return parts.join(', ')
88}
89
90/** `text` cut or padded to exactly `width` cells. */
91function fit(text: string, width: number): string {
92 if (width <= 0) return ''
93 if (text.length > width) return width <= 3 ? text.slice(0, width) : `${text.slice(0, width - 3)}...`
94 return text.padEnd(width)
95}
96
97const clock = (at: number) => {
98 const d = new Date(at)
99 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
100}
101
102export function sidebarView({ Box, Text, Button }: SidebarUi, input: SidebarInput): RenderElement {
103 const { summary, agent, countsOnly, isPaused = false, view, onPress } = input
104 const columns = Math.max(24, input.columns - 1)
105 const total = summary.replaced + summary.dropped
106 const isEmpty = total === 0
107 const press = (key: string) => () => onPress(key)
108 const top: RenderElement[] = []
109 const body: RenderElement[] = []
110 const bottom: RenderElement[] = []
111 // Each element below is one row high (every Text truncates), so the rows
112 // are counted as they are added, and the stats can sit on the pane's floor.
113 let used = 0
114 const add = (to: RenderElement[], element: RenderElement, rows = 1) => {
115 to.push(element)
116 used += rows
117 }
118
119 if (isPaused) {
120 add(top, <Text key="paused" bold inverse wrap="truncate-end">{fit(' PAUSED: nothing is hidden. /topic-filter on', columns)}</Text>)
121 }
122 add(
123 top,
124 <Box key="total" flexDirection="row">
125 <Text key="n" bold inverse color="claude">{` ${total.toLocaleString('en-US')} `}</Text>
126 <Text key="what" bold wrap="truncate-end">{agent === undefined ? ' hidden this session' : ' hidden in this subagent'}</Text>
127 </Box>,
128 )
129 add(
130 top,
131 <Text key="detail" dimColor wrap="truncate-end">
132 {isEmpty ? 'Nothing yet.' : [summary.replaced > 0 ? `${plural(summary.replaced, 'word')} replaced` : '', summary.dropped > 0 ? `${plural(summary.dropped, 'line')} dropped` : ''].filter(Boolean).join(', ')}
133 </Text>,
134 )
135
136 const scope = agent !== undefined ? `subagent ${agent}` : 'whole session'
137 add(
138 top,
139 <Box key="tabs" flexDirection="row" marginTop={1} gap={2}>
140 <Button key="tab:lists" plain hotkey="1" dimColor={view.tab !== 'lists'} onPress={press('tab:lists')}>Lists</Button>
141 <Button key="tab:feed" plain hotkey="2" dimColor={view.tab !== 'feed'} onPress={press('tab:feed')}>Feed</Button>
142 <Box key="gap" flexGrow={1} />
143 <Text key="scope" dimColor wrap="truncate-start">{scope}</Text>
144 </Box>,
145 2,
146 )
147
148 if (view.tab === 'lists') treeRows()
149 else feedRows()
150 statsRows()
151
152 // Pin the stats to the floor when everything fits; else the pane scrolls.
153 const spare = input.rows - used
154 return (
155 <Box flexDirection="column" paddingRight={1}>
156 {top}
157 {body}
158 {spare > 0 ? <Box key="spacer" height={spare} /> : null}
159 {bottom}
160 </Box>
161 )
162
163 function treeRows(): void {
164 if (isEmpty) {
165 add(body, <Text key="empty" dimColor>Hidden terms show here as Claude reads them.</Text>)
166 return
167 }
168 if (!countsOnly) depthControl()
169 const widest = Math.max(1, ...summary.lists.map(list => list.replaced + list.dropped))
170 for (const list of summary.lists) {
171 const id = listId(list.list)
172 const open = !countsOnly && isOpen(view, summary, id)
173 const weight = list.replaced + list.dropped
174 const bar = '▮'.repeat(Math.max(1, Math.round((weight / widest) * MINI))).padEnd(MINI)
175 const nameWidth = columns - 2 - MINI - 1 - COUNT_WIDTH
176 const name = `${countsOnly ? ' ' : open ? '▾' : '▸'} ${fit(list.list, nameWidth)}`
177 add(
178 body,
179 <Box key={`row-${id}`} flexDirection="row">
180 {countsOnly ? (
181 <Text key="name" bold>{name}</Text>
182 ) : (
183 <Button key={`row:${id}`} plain onPress={press(`row:${id}`)}>{name}</Button>
184 )}
185 <Text key="bar" color="claude">{bar}</Text>
186 <Text key="n" dimColor>{counts(list).padStart(COUNT_WIDTH)}</Text>
187 </Box>,
188 )
189 if (!open) continue
190 for (const hit of list.hits) {
191 const tid = termId(list.list, hit.term)
192 const termOpen = isOpen(view, summary, tid)
193 const shown = hit.replaced > 0 ? `-> ${hit.placeholder}` : 'line dropped'
194 const n = `x${hit.replaced + hit.dropped}`
195 add(
196 body,
197 <Box key={`row-${tid}`} flexDirection="row" paddingLeft={2}>
198 <Button key={`row:${tid}`} plain onPress={press(`row:${tid}`)}>{`${termOpen ? '▾' : '▸'} ${fit(hit.term, TERM_WIDTH)}`}</Button>
199 <Box key="as" flexGrow={1}>
200 <Text key="as" color={hit.replaced > 0 ? 'warning' : undefined} dimColor={hit.replaced === 0} wrap="truncate-end">{` ${shown}`}</Text>
201 </Box>
202 <Text key="n" dimColor>{` ${n}`}</Text>
203 </Box>,
204 )
205 if (!termOpen) continue
206 for (const [i, source] of hit.sources.entries()) {
207 add(body, <Text key={`src-${tid}-${i}`} dimColor wrap="truncate-end">{` ${source.label} x${source.n}`}</Text>)
208 }
209 }
210 }
211 }
212
213 /** `Expand ● none ──── ○ lists ──── ○ all`: the line fills up to the depth in use. */
214 function depthControl(): void {
215 const depth = view.depth === undefined ? defaultDepth(summary) : view.depth
216 const stops: [Depth, string][] = [
217 [0, 'none'],
218 [1, 'lists'],
219 [2, 'all'],
220 ]
221 const fixed = 'Expand '.length + stops.reduce((n, [, label]) => n + label.length + 3, 0)
222 const line = '─'.repeat(Math.max(2, Math.floor((columns - fixed) / 2)))
223 const parts: RenderElement[] = [<Text key="label" dimColor>Expand </Text>]
224 for (const [n, label] of stops) {
225 if (n > 0) parts.push(<Text key={`line-${n}`} color={depth !== null && depth >= n ? 'claude' : undefined} dimColor={depth === null || depth < n}>{line}</Text>)
226 parts.push(
227 <Button key={`depth:${n}`} plain dimColor={depth !== n} onPress={press(`depth:${n}`)}>{`${depth === n ? '●' : '○'} ${label}`}</Button>,
228 )
229 }
230 add(body, <Box key="depth" flexDirection="row" marginBottom={1}>{parts}</Box>, 2)
231 }
232
233 function feedRows(): void {
234 if (summary.feed.length === 0) {
235 add(body, <Text key="empty" dimColor>Each hit shows here as it happens, newest first.</Text>)
236 return
237 }
238 // The pane scrolls, but a feed longer than a screen or two is noise.
239 for (const [i, item] of summary.feed.slice(0, Math.max(10, input.rows)).entries()) feedItem(item, i)
240 }
241
242 function feedItem(item: FeedItem, i: number): void {
243 const who = agent === undefined && item.agent !== undefined ? 'Subagent: ' : ''
244 const what = countsOnly ? `${who}${item.hits.length === 1 ? '1 term' : `${item.hits.length} terms`}` : `${who}${item.label}`
245 const n = counts(item)
246 add(
247 body,
248 <Box key={`feed-${i}`} flexDirection="row">
249 <Text key="at" dimColor>{`${clock(item.at)} `}</Text>
250 <Box key="what" flexGrow={1}>
251 <Text key="what" wrap="truncate-end">{what}</Text>
252 </Box>
253 <Text key="n" dimColor>{` ${n}`}</Text>
254 </Box>,
255 )
256 const lists = [...new Set(item.hits.map(hit => hit.list))]
257 const detail = countsOnly
258 ? lists.join(', ')
259 : lists.map(list => `${list}: ${item.hits.filter(hit => hit.list === list).map(hit => `${hit.term} x${hit.n}`).join(', ')}`).join('; ')
260 add(body, <Text key={`feed-${i}-hits`} dimColor wrap="truncate-end">{` ${detail}`}</Text>)
261 }
262
263 function statsRows(): void {
264 if (isEmpty) return
265 add(bottom, <Text key="rule" dimColor>{'─'.repeat(columns)}</Text>, 2)
266 add(
267 bottom,
268 <Box key="where" flexDirection="row">
269 <Box key="title" flexGrow={1}>
270 <Text key="title" bold>Where it came from</Text>
271 </Box>
272 {summary.lastAt === undefined ? null : <Text key="last" dimColor>{`last hit ${clock(summary.lastAt)}`}</Text>}
273 </Box>,
274 )
275 const most = Math.max(1, ...summary.kinds.map(k => k.n))
276 const width = Math.max(4, columns - 12 - 5)
277 for (const { kind, n } of summary.kinds) {
278 const fill = Math.max(1, Math.round((n / most) * width))
279 add(
280 bottom,
281 <Box key={`kind-${kind}`} flexDirection="row">
282 <Text key="label">{fit(KIND_LABELS[kind], 12)}</Text>
283 <Text key="fill" color="claude">{'█'.repeat(fill)}</Text>
284 {fill < width ? <Text key="track" dimColor>{'░'.repeat(width - fill)}</Text> : null}
285 <Text key="n" dimColor>{String(n).padStart(5)}</Text>
286 </Box>,
287 )
288 }
289 const facts: [number, string][] = [
290 [summary.terms, 'term'],
291 [summary.lists.length, 'list'],
292 [summary.sources, 'source'],
293 ]
294 if (agent === undefined && summary.agents.length > 0) facts.push([summary.agents.length, 'subagent'])
295 add(
296 bottom,
297 <Box key="facts" flexDirection="row" gap={2} marginTop={1}>
298 {facts.map(([n, noun]) => (
299 <Text key={noun} dimColor>{plural(n, noun)}</Text>
300 ))}
301 </Box>,
302 2,
303 )
304 }
305}
306hooks/redact.ts 352 lines1// The filter: a compiled topics file that rewrites what the model is about
2// to read and checks what it is about to do.
3//
4// Reading: every string is searched for listed terms. A `drop-line` list's
5// term removes each line it appears on; where a whole string goes that way
6// inside an array (one repository in a JSON listing, one path in a Glob
7// result), the array's item goes with it. Everything else becomes the term's
8// placeholder. JSON printed as text (`gh ... --json`) is parsed, filtered as
9// structure, and printed again.
10//
11// Writing: a tool call that uses a placeholder is found here; the hook then
12// refuses it, or for a `restore` list writes the real term back.
13
14import type { Config } from './config.ts'
15import { foldTerm, Matcher, type Match, type TermRef } from './matcher.ts'
16import { assignPlaceholders, findPlaceholders } from './placeholders.ts'
17
18/** What one filtering pass hid, for the note the model reads and the status line. */
19export type Tally = {
20 replaced: number
21 dropped: number
22 /** The placeholders written, so the note can name them. */
23 names: Set<string>
24 /** Each term hidden, by folded term: for the person's log only, never the model. */
25 hits: Map<string, Hit>
26}
27
28/** One term's share of a pass: how often it became its placeholder, and how many lines it dropped. */
29export type Hit = { term: string; placeholder: string; list: string; replaced: number; dropped: number }
30
31export const newTally = (): Tally => ({ replaced: 0, dropped: 0, names: new Set(), hits: new Map() })
32
33/** A filtered value: `vanished` when a dropped line took the whole of it. */
34type Filtered<T> = { value: T; changed: boolean; vanished: boolean }
35
36/** Terms shorter than this, folded, are ignored: they would match everywhere. */
37const MIN_TERM_LENGTH = 2
38
39/** Keys whose strings are enums or engine ids, never free text. */
40const SKIPPED_KEYS = new Set(['type', 'kind', 'mode', 'action', 'media_type', 'mediaType', 'tool_use_id', 'agentId'])
41
42/** Keys of `tool.call`'s input that belong to the engine, not the tool. */
43const RESERVED_INPUT_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
44
45/** Past this many characters a string is not parsed as JSON. */
46const MAX_JSON_PARSE = 2_000_000
47
48const MAX_DEPTH = 64
49
50const isPlainObject = (v: unknown): v is Record<string, unknown> =>
51 typeof v === 'object' && v !== null && !Array.isArray(v)
52
53/** Image and file bytes as text: nothing in them is a word, and a rewrite would corrupt them. */
54function isEncodedBytes(s: string): boolean {
55 if (s.startsWith('data:') && s.slice(0, 100).includes(';base64,')) return true
56 return s.length >= 256 && /^[A-Za-z0-9+/=_-]+$/.test(s)
57}
58
59function looksLikeJson(s: string): boolean {
60 const t = s.trim()
61 if (t.length < 2 || t.length > MAX_JSON_PARSE) return false
62 const first = t[0]
63 const last = t[t.length - 1]
64 return (first === '{' && last === '}') || (first === '[' && last === ']')
65}
66
67export class Filter {
68 private readonly matcher = new Matcher()
69 /** Folded term to its placeholder. */
70 private readonly placeholderOf = new Map<string, string>()
71 /** Placeholder to the term a restore writes back. */
72 private readonly canonicalOf = new Map<string, string>()
73 /** Placeholders whose use in a tool call is refused. */
74 readonly guarded = new Set<string>()
75 /** Placeholders a tool call may use; the real term is put back. */
76 readonly restorable = new Set<string>()
77 readonly informModel: boolean
78 /** Each list's name, by index, for the person's log. */
79 private readonly listNames: readonly string[]
80 /** How many terms were too short to use. */
81 readonly skippedTerms: number
82 /** How many distinct terms are active. */
83 readonly termCount: number
84
85 /**
86 * @param config the parsed topics file
87 * @param seed this machine's seed for placeholder names
88 * @param packTerms a pack's terms per list index, always matched as whole words: thousands of
89 * terms matched inside words would hide text everywhere
90 */
91 constructor(
92 config: Config,
93 seed: string,
94 packTerms: ReadonlyMap<number, readonly string[]> = new Map(),
95 ) {
96 this.informModel = config.informModel
97 this.listNames = config.lists.map(list => list.name)
98
99 const refs: TermRef[] = []
100 let skipped = 0
101 config.lists.forEach((list, i) => {
102 const excluded = new Set(list.exclude.map(foldTerm))
103 const sources: [readonly string[], boolean][] = [
104 [list.terms, list.match === 'word'],
105 [packTerms.get(i) ?? [], true],
106 ]
107 for (const [terms, isWordOnly] of sources) {
108 for (const term of terms) {
109 const folded = foldTerm(term)
110 if (folded.length < MIN_TERM_LENGTH) {
111 skipped += 1
112 continue
113 }
114 if (excluded.has(folded)) continue
115 refs.push({ folded, canonical: term.trim(), list: i, mode: list.mode, isWordOnly })
116 }
117 }
118 })
119 this.skippedTerms = skipped
120
121 for (const ref of refs) this.matcher.add(ref)
122 this.termCount = this.matcher.size()
123
124 const folded = refs.map(r => r.folded)
125 const names = assignPlaceholders(folded, config.placeholder, seed, {
126 equal: new Set(folded),
127 contain: refs.filter(r => !r.isWordOnly).map(r => r.folded),
128 })
129
130 // A term listed twice keeps its first spelling for restores, and is
131 // guarded if any list holding it is not a restore list.
132 const restoreOnly = new Map<string, boolean>()
133 for (const ref of refs) {
134 const name = names.get(ref.folded)!
135 this.placeholderOf.set(ref.folded, name)
136 if (!this.canonicalOf.has(name)) this.canonicalOf.set(name, ref.canonical)
137 const isRestore = config.lists[ref.list]!.restore
138 restoreOnly.set(name, (restoreOnly.get(name) ?? true) && isRestore)
139 }
140 for (const [name, isRestore] of restoreOnly) (isRestore ? this.restorable : this.guarded).add(name)
141 }
142
143 /** Whether `text` holds any listed term. */
144 hasTerm(text: string): boolean {
145 return this.matcher.test(text)
146 }
147
148 /**
149 * Filters free text. `allowDrop` false makes drop-line terms placeholders
150 * too: a person's own prompt keeps all its lines.
151 */
152 text(text: string, tally: Tally, allowDrop = true): Filtered<string> {
153 let matches = this.matcher.find(text)
154 if (matches.length === 0) return { value: text, changed: false, vanished: false }
155
156 let out = text
157 if (allowDrop && matches.some(m => m.ref.mode === 'drop-line')) {
158 // A literal `\n` ends a line too: some output prints its newlines as text.
159 const lines = text.split(/(?<=\n|\\n)/)
160 const kept = lines.filter(line => {
161 const dropper = this.matcher.find(line).find(m => m.ref.mode === 'drop-line')
162 if (dropper !== undefined) this.hit(tally, dropper.ref).dropped += 1
163 return dropper === undefined
164 })
165 tally.dropped += lines.length - kept.length
166 if (kept.length === 0) return { value: '', changed: true, vanished: true }
167 out = kept.join('')
168 matches = this.matcher.find(out)
169 }
170
171 return { value: this.replace(out, matches, tally), changed: true, vanished: false }
172 }
173
174 private replace(text: string, matches: readonly Match[], tally: Tally): string {
175 if (matches.length === 0) return text
176
177 const parts: string[] = []
178 let at = 0
179 for (const m of matches) {
180 const name = this.placeholderOf.get(m.ref.folded)!
181 parts.push(text.slice(at, m.start), name, m.suffix)
182 tally.replaced += 1
183 tally.names.add(name)
184 this.hit(tally, m.ref).replaced += 1
185 at = m.end
186 }
187 parts.push(text.slice(at))
188 return parts.join('')
189 }
190
191 /** The tally's entry for a term, made on first use. A line is dropped by the first drop-line term on it. */
192 private hit(tally: Tally, ref: TermRef): Hit {
193 let hit = tally.hits.get(ref.folded)
194 if (hit === undefined) {
195 hit = { term: ref.canonical, placeholder: this.placeholderOf.get(ref.folded)!, list: this.listNames[ref.list]!, replaced: 0, dropped: 0 }
196 tally.hits.set(ref.folded, hit)
197 }
198 return hit
199 }
200
201 /** Filters any value: strings anywhere inside it, JSON printed as text included. */
202 value(value: unknown, tally: Tally, depth = 0): Filtered<unknown> {
203 if (depth > MAX_DEPTH) return { value, changed: false, vanished: false }
204
205 if (typeof value === 'string') return this.string(value, tally, depth)
206
207 if (Array.isArray(value)) {
208 let changed = false
209 const out: unknown[] = []
210 for (const item of value) {
211 const r = this.value(item, tally, depth + 1)
212 changed ||= r.changed
213 if (!r.vanished) out.push(r.value)
214 }
215 return changed ? { value: out, changed, vanished: false } : { value, changed, vanished: false }
216 }
217
218 if (isPlainObject(value)) {
219 let changed = false
220 let vanished = false
221 const out: Record<string, unknown> = {}
222 for (const [key, child] of Object.entries(value)) {
223 if (SKIPPED_KEYS.has(key)) {
224 out[key] = child
225 continue
226 }
227 const r = this.value(child, tally, depth + 1)
228 changed ||= r.changed
229 vanished ||= r.vanished
230 out[key] = r.value
231 }
232 return changed ? { value: out, changed, vanished } : { value, changed, vanished: false }
233 }
234
235 return { value, changed: false, vanished: false }
236 }
237
238 private string(s: string, tally: Tally, depth: number): Filtered<unknown> {
239 if (isEncodedBytes(s) || !this.matcher.test(s)) return { value: s, changed: false, vanished: false }
240
241 if (looksLikeJson(s)) {
242 let parsed: unknown
243 let isJson = true
244 try {
245 parsed = JSON.parse(s)
246 } catch {
247 isJson = false
248 }
249 if (isJson) {
250 const r = this.value(parsed, tally, depth + 1)
251 if (r.vanished) return { value: '', changed: true, vanished: true }
252 const lead = s.slice(0, s.length - s.trimStart().length)
253 const trail = s.slice(s.trimEnd().length)
254 const indent = /\n([ \t]+)\S/.exec(s)?.[1]
255 return { value: lead + JSON.stringify(r.value, null, indent) + trail, changed: true, vanished: false }
256 }
257 }
258
259 return this.text(s, tally)
260 }
261
262 /** The guarded placeholders `input` uses, the engine's own keys aside. */
263 guardedIn(input: Record<string, unknown>): string[] {
264 const found = new Set<string>()
265 forEachString(input, s => {
266 for (const hit of findPlaceholders(s, this.guarded)) found.add(hit.name)
267 })
268 return [...found]
269 }
270
271 /** `input` with each restorable placeholder replaced by its real term. */
272 restore(input: Record<string, unknown>): { value: Record<string, unknown>; changed: boolean } {
273 if (this.restorable.size === 0) return { value: input, changed: false }
274
275 let changed = false
276 const swap = (s: string): string => {
277 const hits = findPlaceholders(s, this.restorable)
278 if (hits.length === 0) return s
279 changed = true
280 const parts: string[] = []
281 let at = 0
282 for (const hit of hits) {
283 parts.push(s.slice(at, hit.start), this.canonicalOf.get(hit.name)!, hit.suffix)
284 at = hit.end
285 }
286 parts.push(s.slice(at))
287 return parts.join('')
288 }
289
290 const out: Record<string, unknown> = {}
291 for (const [key, child] of Object.entries(input)) {
292 out[key] = RESERVED_INPUT_KEYS.has(key) ? child : mapStrings(child, swap)
293 }
294 return { value: out, changed }
295 }
296}
297
298/** Calls `visit` on every string in a tool call's input, the engine's own keys aside. */
299export function forEachString(input: Record<string, unknown>, visit: (s: string) => void): void {
300 const walk = (v: unknown, depth: number): void => {
301 if (depth > MAX_DEPTH) return
302 if (typeof v === 'string') visit(v)
303 else if (Array.isArray(v)) for (const item of v) walk(item, depth + 1)
304 else if (isPlainObject(v)) for (const child of Object.values(v)) walk(child, depth + 1)
305 }
306 for (const [key, child] of Object.entries(input)) {
307 if (!RESERVED_INPUT_KEYS.has(key)) walk(child, 0)
308 }
309}
310
311function mapStrings(v: unknown, f: (s: string) => string, depth = 0): unknown {
312 if (depth > MAX_DEPTH) return v
313 if (typeof v === 'string') return f(v)
314 if (Array.isArray(v)) return v.map(item => mapStrings(item, f, depth + 1))
315 if (isPlainObject(v)) {
316 const out: Record<string, unknown> = {}
317 for (const [key, child] of Object.entries(v)) out[key] = mapStrings(child, f, depth + 1)
318 return out
319 }
320 return v
321}
322
323/**
324 * The note that tells the model what a filtered text holds, or undefined
325 * when nothing was hidden. It names placeholders, never terms or lists.
326 */
327export function noteFor(tally: Tally, restorable: ReadonlySet<string> = new Set()): string | undefined {
328 if (tally.replaced === 0 && tally.dropped === 0) return undefined
329
330 const parts: string[] = []
331 if (tally.replaced > 0) {
332 const names = [...tally.names].slice(0, 12).join(', ')
333 parts.push(`${tally.replaced} hidden term${tally.replaced === 1 ? ' was' : 's were'} replaced with placeholder names (${names})`)
334 }
335 if (tally.dropped > 0) {
336 parts.push(`${tally.dropped} line${tally.dropped === 1 ? '' : 's'} mentioning hidden items ${tally.dropped === 1 ? 'was' : 'were'} removed`)
337 }
338
339 const usable = [...tally.names].filter(name => restorable.has(name))
340 const rule =
341 usable.length === 0
342 ? 'Mentioning a placeholder in a reply is fine, but a tool call that uses one (a command, search or file edit) is refused.'
343 : usable.length === tally.names.size
344 ? 'These placeholders may be used in tool calls: the real name is put back before the tool runs.'
345 : `Of these, ${usable.join(', ')} may be used in tool calls (the real name is put back); a tool call using any other is refused.`
346
347 return (
348 `topic-filter: ${parts.join(', and ')}. The user has set these items aside as off-topic for this session. ` +
349 `Treat a placeholder as an opaque name and do not guess what it stands for. ${rule}`
350 )
351}
352hooks/matcher.ts 214 lines1// Finds listed terms in text. Text and terms are folded the same way before
2// they are compared: accents stripped, lower-cased, and every run of spaces,
3// hyphens and underscores made one space, so `Teotihuacán`, `teotihuacan`
4// and `TEOTIHUACAN` are one term, and `secret repo` also finds
5// `secret-repo` and `secret_repo`. A match is reported in the ORIGINAL
6// text's positions, so the caller replaces exactly what was written.
7
8/** How a list hides what it finds. */
9export type Mode = 'replace' | 'drop-line'
10
11/** One listed term, as the matcher reports it. */
12export type TermRef = {
13 /** The term folded, the key its placeholder is kept under. */
14 folded: string
15 /** The term as the topics file spells it; what a restore writes back. */
16 canonical: string
17 /** Index of the list the term came from. */
18 list: number
19 mode: Mode
20 /** Whether the term must stand as whole words (true) or may sit inside one. */
21 isWordOnly: boolean
22}
23
24/** A term found in a text: `[start, end)` in the original's UTF-16 indices. */
25export type Match = {
26 start: number
27 end: number
28 ref: TermRef
29 /** A plural ending (`s`, `es`) the match took after the term, as written. */
30 suffix: string
31}
32
33/** Text folded for matching, with each folded unit's span in the original. */
34type Folded = {
35 text: string
36 starts: number[]
37 ends: number[]
38}
39
40const SEPARATOR = /[\s\-_‐-―]/u
41const MARKS = /\p{M}+/gu
42const WORD = /[\p{L}\p{N}]/u
43
44/** Folds one code point the way terms are folded. */
45function foldChar(ch: string): string {
46 if (ch === '’' || ch === '‘') return "'"
47 return ch.normalize('NFD').replace(MARKS, '').toLowerCase()
48}
49
50/** Folds a whole text, keeping where each folded unit came from. */
51function fold(text: string): Folded {
52 const out: string[] = []
53 const starts: number[] = []
54 const ends: number[] = []
55 let pos = 0
56 let lastWasSeparator = false
57
58 for (const ch of text) {
59 const start = pos
60 pos += ch.length
61
62 if (SEPARATOR.test(ch)) {
63 if (lastWasSeparator) {
64 ends[ends.length - 1] = pos
65 } else {
66 out.push(' ')
67 starts.push(start)
68 ends.push(pos)
69 }
70 lastWasSeparator = true
71 continue
72 }
73
74 lastWasSeparator = false
75 for (const unit of foldChar(ch)) {
76 out.push(unit)
77 starts.push(start)
78 ends.push(pos)
79 }
80 }
81
82 return { text: out.join(''), starts, ends }
83}
84
85/** A term folded on its own, trimmed: the key terms are compared by. */
86export function foldTerm(term: string): string {
87 return fold(term).text.trim()
88}
89
90type Node = {
91 next: Map<string, Node>
92 ref?: TermRef
93}
94
95const isWordAt = (text: string, i: number) => i >= 0 && i < text.length && WORD.test(text[i]!)
96
97/**
98 * A literal escape such as `\n` or `\t` (backslash, letter) ends the word
99 * before it: `gh --template '...\n...'` and logs print them as text, and
100 * `\ngpmap` must still find `gpmap`.
101 */
102const startsWord = (text: string, i: number) =>
103 !isWordAt(text, i - 1) || (text[i - 2] === '\\' && 'ntr'.includes(text[i - 1]!))
104
105/** Matches a set of terms in text, leftmost first and longest at each start. */
106export class Matcher {
107 private readonly root: Node = { next: new Map() }
108 private count = 0
109
110 /** How many distinct folded terms the matcher holds. */
111 size(): number {
112 return this.count
113 }
114
115 /**
116 * Adds a term. A term folded the same as one already held keeps the first
117 * list's settings, unless the new one drops lines where the held one only
118 * replaces: the stronger hiding wins.
119 */
120 add(ref: TermRef): void {
121 let node = this.root
122 for (const unit of ref.folded) {
123 let child = node.next.get(unit)
124 if (child === undefined) {
125 child = { next: new Map() }
126 node.next.set(unit, child)
127 }
128 node = child
129 }
130
131 if (node.ref === undefined) {
132 node.ref = ref
133 this.count += 1
134 } else if (node.ref.mode === 'replace' && ref.mode === 'drop-line') {
135 node.ref = ref
136 }
137 }
138
139 /** Every term in `text`, left to right, none overlapping. */
140 find(text: string): Match[] {
141 if (this.count === 0 || text.length === 0) return []
142
143 const folded = fold(text)
144 const t = folded.text
145 const matches: Match[] = []
146 let i = 0
147
148 while (i < t.length) {
149 const found = this.longestAt(t, i)
150 if (found === undefined) {
151 i += 1
152 continue
153 }
154
155 const [endExclusive, ref, suffixLength] = found
156 const last = endExclusive + suffixLength - 1
157 const termEnd = folded.ends[endExclusive - 1]!
158 const end = folded.ends[last]!
159 matches.push({
160 start: folded.starts[i]!,
161 end,
162 ref,
163 suffix: text.slice(termEnd, end),
164 })
165 i = endExclusive + suffixLength
166 }
167
168 return matches
169 }
170
171 /** True when `text` holds any term; cheaper to say than `find` is to list. */
172 test(text: string): boolean {
173 return this.find(text).length > 0
174 }
175
176 /**
177 * The longest term starting at folded index `i` that stands where it is:
178 * `[end, ref, suffixLength]`, or undefined.
179 */
180 private longestAt(t: string, i: number): [number, TermRef, number] | undefined {
181 if (t[i] === ' ') return undefined
182
183 const boundaryBefore = startsWord(t, i)
184 let node: Node | undefined = this.root
185 let best: [number, TermRef, number] | undefined
186 let j = i
187
188 while (j < t.length) {
189 node = node.next.get(t[j]!)
190 if (node === undefined) break
191 j += 1
192
193 const ref = node.ref
194 if (ref === undefined) continue
195
196 if (!ref.isWordOnly) {
197 best = [j, ref, 0]
198 continue
199 }
200 if (!boundaryBefore) continue
201
202 if (!isWordAt(t, j)) {
203 best = [j, ref, 0]
204 } else if (t[j] === 's' && !isWordAt(t, j + 1)) {
205 best = [j, ref, 1]
206 } else if (t[j] === 'e' && t[j + 1] === 's' && !isWordAt(t, j + 2)) {
207 best = [j, ref, 2]
208 }
209 }
210
211 return best
212 }
213}
214hooks/codenames.ts 42 lines1// The words a hidden term's codename is picked from. Chosen to read as
2// ordinary names while being rare in code, logs and shell output, so a
3// codename in a tool call almost certainly came from a placeholder. When a
4// session needs more names than these, a number follows (`Bubblegum2`).
5// Keep every entry a single capitalized word of letters only: the guard
6// finds codenames by that shape.
7
8export const CODENAMES: readonly string[] = [
9 'Bubblegum', 'Marmalade', 'Pinwheel', 'Kazoo', 'Snorkel', 'Gumdrop', 'Waffle', 'Pretzel',
10 'Teacup', 'Walrus', 'Platypus', 'Porcupine', 'Accordion', 'Trombone', 'Tambourine', 'Lollipop',
11 'Marshmallow', 'Doughnut', 'Periwinkle', 'Buttercup', 'Dandelion', 'Huckleberry', 'Gooseberry', 'Kumquat',
12 'Persimmon', 'Rhubarb', 'Parsnip', 'Rutabaga', 'Artichoke', 'Butterscotch', 'Nougat', 'Toffee',
13 'Meringue', 'Crumpet', 'Scone', 'Strudel', 'Popover', 'Macaroon', 'Biscotti', 'Cannoli',
14 'Churro', 'Dumpling', 'Pancake', 'Flapjack', 'Muffin', 'Custard', 'Sherbet', 'Sorbet',
15 'Gelato', 'Tapioca', 'Licorice', 'Fudge', 'Truffle', 'Praline', 'Brioche', 'Baguette',
16 'Croissant', 'Bagel', 'Pierogi', 'Tamale', 'Empanada', 'Gnocchi', 'Ravioli', 'Tortellini',
17 'Anchovy', 'Barnacle', 'Pelican', 'Flamingo', 'Penguin', 'Puffin', 'Toucan', 'Cockatoo',
18 'Parakeet', 'Hummingbird', 'Kingfisher', 'Sandpiper', 'Heron', 'Albatross', 'Cormorant', 'Starling',
19 'Wombat', 'Koala', 'Kangaroo', 'Wallaby', 'Armadillo', 'Aardvark', 'Anteater', 'Hedgehog',
20 'Chipmunk', 'Marmot', 'Beaver', 'Otter', 'Badger', 'Ferret', 'Weasel', 'Mongoose',
21 'Meerkat', 'Lemur', 'Sloth', 'Tapir', 'Okapi', 'Narwhal', 'Manatee', 'Dugong',
22 'Octopus', 'Squid', 'Cuttlefish', 'Seahorse', 'Starfish', 'Jellyfish', 'Lobster', 'Crawfish',
23 'Tortoise', 'Iguana', 'Chameleon', 'Gecko', 'Salamander', 'Axolotl', 'Newt', 'Tadpole',
24 'Bumblebee', 'Ladybug', 'Dragonfly', 'Firefly', 'Grasshopper', 'Cricket', 'Caterpillar', 'Butterfly',
25 'Harmonica', 'Bagpipe', 'Banjo', 'Ukulele', 'Mandolin', 'Xylophone', 'Glockenspiel', 'Bassoon',
26 'Clarinet', 'Oboe', 'Piccolo', 'Tuba', 'Cowbell', 'Maraca', 'Didgeridoo', 'Ocarina',
27 'Teapot', 'Kettle', 'Colander', 'Ladle', 'Spatula', 'Whisk', 'Thimble', 'Doorknob',
28 'Lampshade', 'Umbrella', 'Galosh', 'Mitten', 'Earmuff', 'Scarf', 'Bonnet', 'Beret',
29 'Sombrero', 'Fedora', 'Bowtie', 'Suspender', 'Cufflink', 'Monocle', 'Pocketwatch', 'Hourglass',
30 'Telescope', 'Periscope', 'Kaleidoscope', 'Gyroscope', 'Metronome', 'Sextant', 'Astrolabe', 'Compass',
31 'Lighthouse', 'Windmill', 'Gazebo', 'Pergola', 'Treehouse', 'Igloo', 'Wigwam', 'Chalet',
32 'Carousel', 'Ferris', 'Trampoline', 'Seesaw', 'Hopscotch', 'Yoyo', 'Frisbee', 'Boomerang',
33 'Pogostick', 'Scooter', 'Tricycle', 'Unicycle', 'Rickshaw', 'Gondola', 'Zeppelin', 'Dirigible',
34 'Canoe', 'Kayak', 'Dinghy', 'Schooner', 'Tugboat', 'Paddleboat', 'Hovercraft', 'Submarine',
35 'Snowglobe', 'Pinecone', 'Acorn', 'Chestnut', 'Walnut', 'Hazelnut', 'Pistachio', 'Cashew',
36 'Coconut', 'Pineapple', 'Mango', 'Papaya', 'Guava', 'Lychee', 'Rambutan', 'Durian',
37 'Tangerine', 'Clementine', 'Grapefruit', 'Pomelo', 'Apricot', 'Nectarine', 'Plum', 'Quince',
38 'Cranberry', 'Blueberry', 'Raspberry', 'Blackberry', 'Elderberry', 'Mulberry', 'Boysenberry', 'Cloudberry',
39 'Paprika', 'Nutmeg', 'Cinnamon', 'Saffron', 'Cardamom', 'Turmeric', 'Oregano', 'Tarragon',
40 'Pumpernickel', 'Sourdough', 'Focaccia', 'Ciabatta', 'Pita', 'Naan', 'Tortilla', 'Chapati',
41]
42