SLOPSHOPPER

topic-filter

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

newpanespinnerguardcommandtoast
v0.8.0GPL-3.0-onlyupdated 2026-10-03lperezmo/topic-filter-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · topic-filter
│ ┃ topic-filter ✕ › fix the failing auth test and add an audit log call │ ┃ 0 hidden this session │ ┃ Nothing yet. ● topic-filter: topic-filter is off, nothing chosen to hide. Switch o │ ┃ ● topic-filter: Topics file: /Users/dev/.claude/topic-filter/topics.j │ ┃ 1: Lists 2: Feed whole session ⏺ Read(src/auth.ts) │ ┃ Hidden terms show here as Claude reads ⎿ Read 6 lines │ ┃ them. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /topic-filter │ ┃ ● topic-filter: Run /topic-filter packs to see every pack, /topic-fil │ ┃ ● topic-filter: and /topic-filter sidebar to watch it in a pane as it │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits & topic-filter: off, nothing chosen

Draws

Pane · topic-filter
0 hidden this session Nothing yet. 1: Lists 2: Feed whole session Hidden terms show here as Claude reads them.
README

<img src=".claude-plugin/icon.svg" alt="topic-filter logo: a benzene ring swapped for code braces" width="200">

test

topic-filter-mod

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.

A Claude Code session summarizing a demo project: its reply uses codenames in place of the hidden terms, while the sidebar lists each hidden term, its codename, and where the hits came from

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.

Install

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:

The /config screen listing topic-filter's rows: one switch per built-in pack, other packs, the sidebar switches and the topics file

SettingDefaultWhat it does
Hide anthropology, biology, chemistry, geneticsoffOne switch per built-in pack
Other packsemptyNames of your own packs, comma-separated
Start pausedoffEvery session starts paused; /topic-filter on turns it on for that session
SidebaroffOpens the sidebar at every start
Sidebar: counts onlyoffThe sidebar shows counts by list, never a word or file name
Extra words to hideemptyComma-separated words or names; kept in secure storage, not in settings.json
Topics file (advanced)emptyWhere 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 --version shows 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.

Update

Claude Code does not update plugins from third-party marketplaces on its own unless you turn that on. Pick one:

  • Automatic (recommended). Once, in Claude Code: /plugin, then Marketplaces, then topic-filter-mod, then enable auto-update. New versions install when Claude Code starts.
  • By hand, whenever you want the latest:
  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.

  • Placeholder names are drawn from a new random seed, so every term gets a new codename once. Sessions started before the upgrade keep the old ones.
  • New Start paused setting, off by default.

Upgrading to 0.7.0.

  • The built-in cybersecurity pack is gone. If a list in your topics file has "pack": "cybersecurity", tool calls pause until you take it out. Its /config switch is gone too, and a saved value is ignored.
  • A list that uses a pack can no longer set "restore": true. Such a topics file is now a settings problem: tool calls pause until the restore is removed.
  • The system prompt, Claude Code's own reminders and built-in tools' descriptions are no longer filtered (see Limits).

To remove it: claude plugin uninstall topic-filter@topic-filter-mod. Your topics file stays until you delete it.

Using 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:

  • Claude Code copies every such line into its debug log, so with --debug or --debug-file the terms end up in that file.
  • Another plugin that hooks 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.

The sidebar

/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.

  • Lists is a tree: each list with a bar for its share, then its terms and the placeholder each became, then the files and commands each term was hidden in. Click a row (or reach it with ctrl+x tab, then Tab, and press Enter) to open or close it. Expand opens the whole tree to one depth: lists only, their terms, or everything. From 30 distinct terms on, it starts at lists only.
  • Feed is each hit as it happened, newest first: when, what Claude was reading, and which terms it hid there.
  • Where it came from sums hits by the kind of source: files, prompts, web pages, commands, searches, skills, and context such as CLAUDE.md.

Open a subagent's transcript from the tasks list and the sidebar shows only what that subagent read.

  • Where it shows. In the fullscreen layout it docks beside the transcript; otherwise it opens above the prompt. Opened by the setting rather than the command, it waits for a terminal at least 144 columns wide.
  • Next to other plugins' panes. Claude Code shows one pane at a time and turns the others into tabs: click a tab to switch, or press ctrl+x tab to move into the panes, then Tab to a tab and Enter. ctrl+x x (or the pane's close mark) closes it, and /topic-filter sidebar toggles it.
  • Every start. The Sidebar switch in /config opens it at every start. Turning the switch off closes it.
  • Screen sharing. It shows the real words. Sidebar: counts only keeps it to counts by list, with no words, placeholders or file names, in the tree and the feed alike.

The sidebar is drawn in your terminal and is not part of the conversation.

Pausing it

/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.

  • Only you can pause it. The command pauses only when you type it at the prompt. Sent through Remote Control, whose sender Claude Code cannot confirm, or run by Claude, a subagent or another plugin, it leaves the filter on. Anything may turn it back on.
  • It never outlasts the session. The pause is kept in memory only: a restart, /clear or a reload of the plugin goes back to what the settings say.
  • Or start every session paused. Turn on Start paused in /config and nothing is hidden until you type /topic-filter on, which lasts for that session.
  • You can see it. The footer label reads topic-filter: paused in place of the count, and the sidebar says so at the top.
  • Claude is told. Pausing and resuming each leave Claude a one-line note, so it knows whether placeholders will be refused.
  • Two guards stay on. The topics file is still not read into the session, since that would bring every listed term back into context. A file Claude read with content hidden still cannot be overwritten whole until Claude reads it again, in full, while paused.

What Claude read before the pause keeps its placeholders.

The topics file

{
  "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"] }
  ]
}
FieldDefaultMeaning
placeholdercodenamecodename 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].
informModeltrueTell 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[].namelist NA label for you. It never reaches the model.
lists[].terms[]The terms to hide.
lists[].modereplacereplace 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[].matchwordword matches whole words only. substring matches inside words too (maya in Mayapan).
lists[].restorefalseWhen 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[].packnoneA 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.

Topic packs

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:

PackTermsCovers
anthropology2,167Ancient Mesoamerican, Andean and Egyptian sites, civilizations, rulers and deities; anthropology and archaeology vocabulary
biology270Molecular biology techniques, cellular processes, anatomical terms
chemistry940Chemical elements, named reactions, functional groups, laboratory equipment
genetics2,591Genetic 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.

Hiding repositories without deleting them

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.

What it covers

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 readsEvent
Tool results: Bash, Read, Grep, Glob, WebFetch, MCP tools, subagent answers, errorstool.call, on the way up
Your typed promptprompt.submit
CLAUDE.md and the other first-message context blocksprompt.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 outputskill.prompt, tool.describe, command.run
Remote Control and peer deliveriessession.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:

  • Guard. A tool call that uses a placeholder is refused, so the model cannot act on what it cannot see, and never writes 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. The one case where the mod changes a tool's input: for a list with "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.
  • No blind overwrites. Once the model has read a file with something hidden, a whole-file Write to it is refused: its copy lacks what it never saw. Edit still works, and fails safely if its text spans something hidden.
  • The topics file stays out of the session. It names your lists and packs, so reading it would bring every listed term back into context. Reading, editing or writing it is refused, as is any shell command that names it. Writing another file that merely mentions its path (docs, a script) is fine.
  • Fails closed. If filtering throws or runs out of time, what it was filtering is withheld, never passed through. The one exception is an MCP tool's description, which keeps its original text, so a failure never replaces another tool's instructions. A broken topics file keeps the last good list, or refuses tool calls until it is fixed, and the footer label turns yellow to say so.

Limits

Read these before relying on it.

  • Placeholders hide words, not meaning. "Teacup, the pyramid city north of Mexico City" gives it away. Use drop-line for items whose surroundings identify them.
  • Only text is filtered. Text inside images and PDFs gets through.
  • Encodings get through. Base64, hex, a word split across lines, or a file whose accents were saved in the wrong encoding (Teotihuac?n) does not match. This is a focus aid, not a security or privacy boundary.
  • Your local transcript keeps what you typed. The model receives your prompt with placeholders, but the engine's queue record in the session file on disk holds the text as typed.
  • Shell rewrites of filtered files are not caught. cat > notes.md built from a filtered read loses the hidden lines. Only Write is refused.
  • The system prompt is left as it is. The mod never hooks or changes it, so what Claude Code puts there, such as your memory index (MEMORY.md), reaches the model unfiltered. Memory files Claude reads with a tool are filtered like any other file.
  • Claude Code's own text is left as it is. Its reminders, mode changes and listings (skills, deferred tools) and the descriptions of built-in tools reach the model unfiltered, so a listed term in a skill's name or an MCP server's name in those listings gets through.
  • Sessions from before the mod was on already hold the raw terms.
  • Other plugins that hook tool.call beneath this one see raw results.
  • Your settings are readable. The pack switches and pack names you set in /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.

Troubleshooting

  • No footer label, and /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.
  • Status says off, nothing chosen to hide. Nothing is switched on. Open /config and search topic-filter.
  • Status says 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.
  • A switch set in /plugin did not take. That screen needs the word true; anything else is saved as false. /config has real switches.
  • Claude says a command was refused because of a placeholder. That is the guard working:
Source 8 files
hooks/register.ts 985 lines
1// 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}
985
hooks/config.ts 209 lines
1// 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}
209
hooks/log.ts 282 lines
1// 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}
282
hooks/placeholders.ts 115 lines
1// 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}
115
hooks/sidebar.tsx 306 lines
1/* @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}
306
hooks/redact.ts 352 lines
1// 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}
352
hooks/matcher.ts 214 lines
1// 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}
214
hooks/codenames.ts 42 lines
1// 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