SLOPSHOPPER

auto-compact

Compacts the conversation once context passes 250k tokens, and saves the full transcript to ~/.claude/compact-saves before every compaction (/autocompact)

commandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-compact
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /autocompact ⎿ auto-compact: Auto-compact on, past 250k tokens. ⎿ auto-compact: This session: 97k of a 200k window. ⎿ auto-compact: Note: this model's window is no larger than the threshold, so the built-in auto-compact fires first (the trans ⎿ auto-compact: Saves go to /Users/dev/.claude/compact-saves. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

A small collection of Claude Code mods (plugins that hook into the session). Install only the ones you want.

ModWhat it does
agent-dashboardOne Dashboard pane with usage and context bars, tasks, tests, sessions and subagents
auto-handover/handover writes a handover note and starts (or proposes) a fresh session
auto-compactCompacts past 250k tokens and saves the full transcript before every compaction
build-trimmerTrims long xcodebuild / swift build output so it does not fill your context
prompt-upgrade/improve rewrites a rough prompt into a clear one
keep-goingStops Claude ending a turn on a vague "want me to…?": it keeps working or asks with options
memdbA searchable memory database Claude can query and add to
cache-taxKeeps the prompt cache warm during breaks and stops a cold send once with its price (third-party, MIT)
reflect-modTurns your corrections into CLAUDE.md rules, saved with one press (third-party, MIT)

Requirements

  • A Claude Code version that supports mods. Tested on 2.1.299.
  • Python 3.8 or newer for memdb (it also needs SQLite with FTS5, which most Python builds have) and for build-trimmer's xctrim.py.
  • build-trimmer is for Apple development (Xcode / SwiftPM). agent-dashboard and auto-handover work best in the desktop app, where panes and new sessions are available.

Set up a new machine

To get every mod and the settings this repo is used with (auto-compact at 200k, the built-in auto-compact at the same size), clone it and run the setup script:

git clone https://github.com/flyingpurplepizzaeater/claude-mods.git
cd claude-mods && ./setup.sh

It copies each mod to ~/.claude/mods/<mod> and adds three keys to the env of ~/.claude/settings.json: CLAUDE_CODE_PLUGIN_DIRS (the mods, kept alongside any plugin folders already listed), AUTO_COMPACT_THRESHOLD and CLAUDE_CODE_AUTO_COMPACT_WINDOW. Nothing else in the file changes, and the old one is kept as settings.json.bak. Name mods to install only those (./setup.sh memdb cache-tax), set AUTO_COMPACT_THRESHOLD=300k before it for another size, and run it again after git pull to update. New sessions load the mods. It needs rsync and python3, and follows CLAUDE_CONFIG_DIR if you set it.

Use either this or the marketplace install below for a mod, not both, or its hooks run twice.

Install

At the prompt of a terminal session (swap memdb for any mod name above):

/plugin install memdb --marketplace flyingpurplepizzaeater/claude-mods

Claude asks Add marketplace?: answer y, then pick a scope (user scope is the first one). If the mod has options, a screen lets you set them. You then see Installed memdb. Plugin is now active., and its hooks run from then on, with no reload needed.

From a shell:

claude plugin install memdb --marketplace flyingpurplepizzaeater/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with flyingpurplepizzaeater/claude-mods, then Add plugin.

If you have already added the marketplace, claude plugin install <mod>@claude-mods is enough.

The mods

agent-dashboard

Opens one Dashboard pane at session start and stacks everything in it:

  • Usage: your 5-hour and weekly limits with reset times, plus a context bar for the current session (yellow past 150k tokens, red past 400k, with a toast the first time).
  • Tasks: follows the session's task list (TodoWrite, TaskCreate or the desktop app's plan tool).
  • Tests: the last test runs (xcodebuild, swift, pytest, npm, go, cargo and similar), including background runs, with pass/fail.
  • Sessions: every Claude Code session running on the machine.
  • Subagents: running and recent subagents of this session.

/dashboard brings the pane to the front. The pane refreshes every 5 seconds.

Options (all can be changed later in /config):

OptionDefaultMeaning
planNoteonTell Claude to write long or multi-part requests into its task list first
queueMessagesonA message sent while Claude is working goes to the end of its task list instead of derailing it. Start the message with !now to act on it immediately
buildReminderoffAfter a long build or test run in the foreground, remind Claude to use the background next time

auto-handover

/handover asks Claude to write a short handover note (goal, state, done, next steps, key files, gotchas) to ~/.claude/handovers/ and then starts a fresh session that continues from it. In the desktop app it starts the session directly (or proposes it as a card); elsewhere it leaves the "continue" prompt in the prompt box for you to send after /clear.

/handover                    write the note and hand over now
/handover status             show whether auto mode is on, the threshold and this session's size
/handover on | off           automatic handover past a context threshold (off by default)
/handover threshold 200k     set the threshold (at least 1k; never above 80% of the window)

The threshold can also be set with the environment variable AUTO_HANDOVER_THRESHOLD or per project in .claude/auto-handover.json ({ "threshold": "120k" }). Notes go to $CLAUDE_CONFIG_DIR/handovers if that variable is set. No plugin options.

auto-compact

After each answer, if the context is past 250k tokens, it compacts the conversation, telling the summary to keep the goal, decisions, files changed and next steps. Before every compaction (your /compact, the built-in auto-compact, or its own) it saves the full conversation as markdown to ~/.claude/compact-saves/: every message and each tool call with its result, long ones clipped. Common secrets (API keys, tokens, JWTs, private keys, password: …) are redacted, and the folder is owner-only (700, files 600). A failed save never blocks the compaction. With memdb installed, the saves become searchable there.

/autocompact status            on or off, the threshold, this session's size and the last save
/autocompact on | off          automatic compaction (on by default; saves happen either way)
/autocompact threshold 250k    set the threshold (at least 10k)
/autocompact now               save and compact now

The threshold can also be set with the environment variable AUTO_COMPACT_THRESHOLD. Saves go to $CLAUDE_CONFIG_DIR/compact-saves if that variable is set. If the model's context window is no larger than the threshold, the built-in auto-compact fires first (the transcript is still saved). No plugin options.

Its own compaction runs between turns, so after one it sends a short prompt telling the session to carry on where it left off (or say in one line that it was done or waiting for you); /autocompact now doesn't. Long autonomous runs are smoother if the built-in auto-compact, which compacts mid-turn and keeps going, fires at the same size: set CLAUDE_CODE_AUTO_COMPACT_WINDOW to the threshold plus 13000 (e.g. 213000 for 200k). The mod then stays as a backup and still saves the transcript before every compaction.

build-trimmer

Wraps xcodebuild, swift build and swift test commands that Claude runs in Bash. The full output goes to a log file and Claude sees only the errors, failing tests, the summary and the tail, plus the log path. The exit code is preserved. Logs are in ~/.claude/build-trimmer/logs and are deleted after 7 days. Add # no-trim to a command to leave it alone.

Options:

OptionDefaultMeaning
developerDirempty (off)DEVELOPER_DIR to build with, e.g. /Applications/Xcode-beta.app/Contents/Developer
developerDirPathMatchemptyUse developerDir only when the command or working folder contains this text, e.g. /MyApp. Empty means every build
extraBuildScriptsemptyComma-separated script names that run xcodebuild or swift test themselves, e.g. test.sh,ci.sh

A command that already sets DEVELOPER_DIR is never changed. If python3 is missing the full log is shown instead.

prompt-upgrade

/improve <rough prompt> rewrites a rough prompt into a clear, specific one, using what the session already knows about your project, and puts it in the prompt box so you can review and edit it. Run /improve send to submit the last one. It keeps your intent and does not add goals you did not ask for. No options.

keep-going

When Claude ends a turn by handing the next move back in plain text ("Want me to…?", "Should I…?", "Let me know if…"), the mod blocks that stop once and tells Claude to either keep working, routing around anything blocked, or ask properly with AskUserQuestion (2–4 options, recommendation first). A turn that already asked with AskUserQuestion, plan approval or the desktop app's wrap-up buttons is let through, and so is the second stop in a row, so it never loops. Safety confirmations (push, send, delete) still get asked, as options. The phrases it looks for are in hooks/check.ts. No options.

memdb

A searchable memory database (SQLite full-text search, ranked and stemmed) for Claude. It indexes your Claude memory files (~/.claude/projects/*/memory/*.md) automatically, and also holds:

  • notes: small facts kept only in the database (memdb_add)
  • docs: long markdown files such as plans or old handover notes, split by heading (memdb_index)
  • transcripts: if you use auto-compact, each conversation it saves to ~/.claude/compact-saves/ is indexed automatically, one entry per exchange (your prompt and the work that answered it), titled with the date and the prompt's first line and filed under the folder the session ran in. Search ranks memories and notes above docs and docs above transcripts, shows at most two old conversations by default (the best exchange of each) and says how many more matched; search with kind transcript for all of them. Deleting a save removes it; the database file is owner-only (600).

Claude gets these tools: memdb_search, memdb_add, memdb_get, memdb_index and memdb_forget, and a short note in the system prompt telling it to search before rereading long notes. You get /memdb <words> to search, plus /memdb stats, /memdb list, /memdb get <name> and /memdb sync.

If Python is missing, older than 3.8, or lacks FTS5, the tools answer with a clear message instead of failing.

Options:

OptionDefaultMeaning
pythonpython3Python command to run the script
dbPathemptyDatabase file. Empty means ~/.claude/memdb/memory.db (or under $CLAUDE_CONFIG_DIR)
promptNoteonAdd the short "memdb is available" note to the system prompt

The script also works on its own, with no Claude involved. After installing, find it under the plugin folder (or clone this repo):

python3 plugins/memdb/bin/memdb.py --help
python3 plugins/memdb/bin/memdb.py search "database port"
python3 plugins/memdb/bin/memdb.py add staging-port "It is 5433" -d "staging DB port"

MEMDB_PATH overrides the database location.

cache-tax

cache-tax by Karan Bansal (MIT), copied here at upstream commit 432a08d (2.3.0) so it installs with the rest. /keepwarm keeps the one-hour prompt cache warm through a break by sending a tiny ping 50 minutes after the last request; /keepwarm always does that for every session, for up to six hours after each turn. When the cache has gone cold, it stops the first send once and shows what the rewrite will cost. /cache-tax shows the cache state and prices. See its README.

One change from upstream: after a compaction in the middle of a turn (the built-in auto-compact), it starts pinging again as soon as the next request comes back, rather than when the turn ends. Without it, a long autonomous turn that compacted and then sat at a permission prompt or a question went cold. Two tests at the end of tests/register.test.ts cover it.

reflect-mod

reflect-mod from claude-reflect by Bayram Annakov (MIT), copied unchanged at upstream commit b6c4232 (3.3.0). After a short prompt that corrects Claude, a model check decides whether it is a reusable rule; if so, a band above the prompt offers to save it to CLAUDE.md with one press. See its README.

Change options, update, uninstall

  • Change options: /config in a terminal session (each mod's options are rows there), or reinstall and answer the options screen.
  • Update: claude plugin update <mod> (or claude plugin marketplace update claude-mods first).
  • Turn off without removing: claude plugin disable <mod>; turn back on with claude plugin enable <mod>.
  • Uninstall: claude plugin uninstall <mod>. To drop the marketplace too: claude plugin marketplace remove claude-mods.
  • memdb keeps its database in ~/.claude/memdb/; delete that folder if you want the data gone. auto-handover notes stay in ~/.claude/handovers/, auto-compact saves in ~/.claude/compact-saves/ and build-trimmer logs in ~/.claude/build-trimmer/logs/.

Develop

Each mod is a folder under plugins/. Check one with:

claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
claude plugin validate .        # the marketplace file

MIT licensed, see LICENSE.

Source 3 files
hooks/register.ts 187 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Register, SessionMessage } from 'claude-code'
3
4import { DEFAULT_THRESHOLD, fmtTokens, parseTokens, saveDir, saveName, transcript } from './save'
5
6// Set while this mod's own compaction is queued or running, so one turn asks once.
7const compacting = atom({ plugin: 'auto-compact', key: 'compacting' } as const, false)
8const lastSave = atom({ plugin: 'auto-compact', key: 'lastSave' } as const, '')
9// A compaction that met a running turn: tried again when the main loop's next turn ends.
10const pending = atom({ plugin: 'auto-compact', key: 'pending' } as const, '')
11// What the last compaction this mod started came to, for /autocompact status.
12const lastResult = atom({ plugin: 'auto-compact', key: 'lastResult' } as const, '')
13// When this mod last saved ahead of a /compact it ran itself, so that compaction's hook does not save twice.
14const selfSavedAt = atom({ plugin: 'auto-compact', key: 'selfSavedAt' } as const, 0)
15const SELF_SAVE_WINDOW_MS = 10 * 60_000
16
17// ui.log reaches only the debug log in the desktop app: outcomes go to a toast and to status as well.
18async function report($: Engine, text: string) {
19  $.ui.log(`[auto-compact] ${text}`)
20  $.ui.toast(`auto-compact: ${text}`)
21  const at = new Date(await $.clock.now()).toTimeString().slice(0, 8)
22  await update($, lastResult, () => `${at} ${text}`)
23}
24
25const USAGE = '/autocompact [status | on | off | threshold <250k> | now]'
26
27// AUTO_COMPACT_THRESHOLD, then /autocompact threshold, then 250k.
28async function getThreshold($: Engine) {
29  const fromEnv = parseTokens((await $.env.get('AUTO_COMPACT_THRESHOLD')) ?? '')
30  if (fromEnv !== undefined) return fromEnv
31  const v = await $.store.get('threshold')
32  return typeof v === 'number' && v >= 1000 ? v : DEFAULT_THRESHOLD
33}
34const isEnabled = async ($: Engine) => (await $.store.get('enabled')) !== false
35
36const getSaveDir = async ($: Engine) =>
37  saveDir((await $.env.get('HOME').catch(() => undefined)) ?? '', await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined))
38
39const INSTRUCTIONS = 'Keep the current goal, decisions made, files changed and the next steps.'
40// Sent after a compaction the mod started on its own between turns, so autonomous work does not stall there.
41const RESUME =
42  'The conversation was just compacted automatically. Continue from where you left off: if you were in the middle of a task, ' +
43  'or waiting on a background job you said you would pick up, carry on with it now. If you had finished, or were waiting for ' +
44  'my answer, say so in one line and stop.'
45
46// Writes the conversation to compact-saves, owner-only. A failure is logged, never thrown.
47async function save($: Engine, messages: readonly SessionMessage[], trigger: string, instructions?: string): Promise<string | undefined> {
48  const [sessionId, cwd, now, use, dir] = await Promise.all([
49    $.session.id(),
50    $.session.cwd(),
51    $.clock.now(),
52    $.session.usage().catch(() => undefined),
53    getSaveDir($),
54  ])
55  // Ahead-of-time compute overwrites one file per session; a real compaction gets its own.
56  const name = trigger === 'precompute' ? `${sessionId}_precompute.md` : saveName(now, sessionId, trigger)
57  const path = `${dir}/${name}`
58  try {
59    // Owner-only: the folder 700 before the file lands, the file 600 after.
60    await $.process.run(['/bin/mkdir', '-p', '-m', '700', dir])
61    await $.process.run(['/bin/chmod', '700', dir])
62    await $.fs.write(path, transcript(messages, { sessionId, cwd, trigger, tokens: use?.context.tokens, now, instructions }))
63    await $.process.run(['/bin/chmod', '600', path])
64    if (trigger !== 'precompute') {
65      await update($, lastSave, () => path)
66      $.ui.log(`[auto-compact] saved ${messages.length} messages to ${path} before compacting`)
67    }
68    return path
69  } catch (err) {
70    $.ui.log(`[auto-compact] could not save before compacting: ${String(err)}`)
71    return undefined
72  }
73}
74
75// $.session.compact skips this plugin's own session.compact hook, so the save happens here first.
76async function compactNow($: Engine, why: string, opts: { retry?: boolean; resume?: boolean } = {}) {
77  const { retry = false, resume = false } = opts
78  await update($, compacting, () => true)
79  let saved: string | undefined
80  const before = await read($, lastSave)
81  try {
82    const messages = await $.session.messages().catch(() => [] as SessionMessage[])
83    if (messages.length) saved = await save($, messages, 'plugin', INSTRUCTIONS)
84    // The desktop app and other SDK hosts have no $.session.compact, and it rejects while a turn runs:
85    // either way /compact does it, queued by the engine until the session is idle.
86    const r = await $.session.compact({ instructions: INSTRUCTIONS }).catch(async () => {
87      const at = saved ? await $.clock.now() : 0
88      await update($, selfSavedAt, () => at)
89      await $.command.run({ command: 'compact', args: INSTRUCTIONS })
90      return { skip: undefined }
91    })
92    await update($, pending, () => '')
93    if (r.skip !== undefined) await report($, `compaction skipped: ${r.skip}`)
94    else {
95      await report($, `compacted (${why})${saved ? `, saved to ${saved}` : ', but the transcript could not be saved'}.`)
96      // Not awaited: the resumed turn may run long, and compacting must clear before it ends.
97      if (resume) void $.prompt.submit({ text: RESUME }).catch(err => $.ui.log(`[auto-compact] could not resume: ${String(err)}`))
98    }
99  } catch (err) {
100    // Rejected while a turn runs: drop the save it never compacted, try again once when the next turn ends.
101    if (saved) {
102      await $.process.run(['/bin/rm', '-f', saved]).catch(() => undefined)
103      await update($, lastSave, () => before)
104      await update($, selfSavedAt, () => 0)
105    }
106    await update($, pending, () => (retry ? '' : why))
107    await report($, `compaction ${retry ? 'failed' : 'deferred until the next answer ends'}: ${String(err)}`)
108  } finally {
109    await update($, compacting, () => false)
110  }
111}
112
113export const register: Register = on => {
114  on('session.start', async ($, e, next) => {
115    await $.command
116      .register({ name: 'autocompact', description: 'Auto-compact past a token threshold (250k), saving the transcript first', argumentHint: '[status|on|off|threshold 250k|now]' })
117      .catch(() => undefined)
118    await update($, compacting, () => false)
119    return next(e)
120  })
121
122  // After each main-loop answer: past the threshold, compact once the turn has let go.
123  on('turn.complete', async ($, e, next) => {
124    const done = await next(e)
125    if (e.agentId !== undefined || e.reason !== 'answer') return done
126    if (await read($, compacting)) return done
127    const deferred = await read($, pending)
128    if (deferred) {
129      await update($, compacting, () => true)
130      $.clock.after(1500, () => compactNow($, deferred, { retry: true, resume: !deferred.startsWith('asked') }))
131      return done
132    }
133    if (!(await isEnabled($))) return done
134    const tokens = (await $.session.usage().catch(() => undefined))?.context.tokens
135    const limit = await getThreshold($)
136    if (tokens === undefined || tokens < limit) return done
137    await update($, compacting, () => true)
138    $.clock.after(1500, () => compactNow($, `context ${fmtTokens(tokens)} past ${fmtTokens(limit)}`, { resume: true }))
139    return done
140  }).catch(($, e, next) => next(e))
141
142  // Before any compaction of the main conversation (/compact, built-in auto, this mod): save it whole.
143  on('session.compact', async ($, e, next) => {
144    if (e.agentId !== undefined || !e.messages?.length) return next(e)
145    const selfSaved = await read($, selfSavedAt)
146    if (e.trigger !== 'precompute' && selfSaved && (await $.clock.now()) - selfSaved < SELF_SAVE_WINDOW_MS) {
147      await update($, selfSavedAt, () => 0) // this mod's own /compact: saved already
148      return next(e)
149    }
150    await save($, e.messages, e.trigger, e.instructions)
151    return next(e)
152  }).catch(($, e, next) => next(e)) // a failed save never blocks the compaction
153
154  on('command.run', { command: 'autocompact' }, async ($, e) => {
155    const [verb = 'status', ...rest] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
156    if (verb === 'on' || verb === 'off') {
157      await $.store.set('enabled', verb === 'on')
158      return { text: `Auto-compact ${verb}. Transcripts are still saved before every compaction.` }
159    }
160    if (verb === 'threshold') {
161      const n = parseTokens(rest.join(''))
162      if (n === undefined) return { text: 'Give a token count, e.g. /autocompact threshold 250k (at least 10k).' }
163      await $.store.set('threshold', n)
164      return { text: `Auto-compact now fires past ${fmtTokens(n)} tokens.` }
165    }
166    if (verb === 'now') {
167      $.clock.after(500, () => compactNow($, 'asked with /autocompact now'))
168      return { text: 'Saving the transcript and compacting…' }
169    }
170    if (verb === 'status') {
171      const [enabled, limit, use, saved, dir, last, waiting] = [await isEnabled($), await getThreshold($), await $.session.usage().catch(() => undefined), await read($, lastSave), await getSaveDir($), await read($, lastResult), await read($, pending)]
172      const window = use?.context.window
173      return {
174        text: [
175          `Auto-compact ${enabled ? 'on' : 'off'}, past ${fmtTokens(limit)} tokens.`,
176          `This session: ${use?.context.tokens !== undefined ? fmtTokens(use.context.tokens) : 'no count yet'}${window ? ` of a ${fmtTokens(window)} window` : ''}.`,
177          ...(window && window <= limit ? [`Note: this model's window is no larger than the threshold, so the built-in auto-compact fires first (the transcript is still saved).`] : []),
178          `Saves go to ${dir}${saved ? `; last: ${saved}` : ''}.`,
179          ...(last ? [`Last run: ${last}`] : []),
180          ...(waiting ? [`Waiting to retry after the next answer (${waiting}).`] : []),
181        ].join('\n'),
182      }
183    }
184    return { text: `Usage: ${USAGE}` }
185  }).catch(($, e, next) => next(e))
186}
187
hooks/save.ts 82 lines
1import type { SessionMessage } from 'claude-code'
2
3export const DEFAULT_THRESHOLD = 250_000
4export const MIN_THRESHOLD = 10_000
5
6/** "250k", "1.2m", "250000" -> tokens; undefined when it is not a count. */
7export function parseTokens(s: string): number | undefined {
8  const m = s.trim().toLowerCase().replace(/[,_\s]/g, '').match(/^(\d+(?:\.\d+)?)(k|m)?$/)
9  if (!m) return undefined
10  const n = Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
11  return n >= MIN_THRESHOLD ? n : undefined
12}
13
14export const fmtTokens = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(2)}m` : `${Math.round(n / 1e3)}k`)
15
16export const saveDir = (home: string, configDir?: string) => `${configDir || `${home}/.claude`}/compact-saves`
17
18const pad = (n: number) => String(n).padStart(2, '0')
19
20/** Local time as ISO 8601 with its offset: 2026-10-20T09:08:03+11:00. */
21export function localIso(now: number): string {
22  const d = new Date(now)
23  const off = -d.getTimezoneOffset()
24  const sign = off >= 0 ? '+' : '-'
25  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
26    `${sign}${pad(Math.floor(Math.abs(off) / 60))}:${pad(Math.abs(off) % 60)}`
27}
28
29export const saveName = (now: number, sessionId: string, trigger: string) =>
30  `${localIso(now).slice(0, 19).replace(/:/g, '-')}_${sessionId.slice(0, 8)}_${trigger}.md`
31
32// Common credential shapes, replaced before anything reaches disk.
33const SECRETS = [
34  /\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs]|glpat)[-_][A-Za-z0-9_-]{8,}/gi,
35  /\bAKIA[0-9A-Z]{16}\b/g,
36  /\bAIza[0-9A-Za-z_-]{20,}/g,
37  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}(\.[A-Za-z0-9_-]+)?/g, // a JWT
38  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
39  /((?:password|passwd|passphrase|secret|token|api[ _-]?key|authorization)["']?\s*[:=]\s*["']?)(?:Bearer\s+)?[^\s"',]{6,}/gi,
40]
41
42export const redact = (s: string) =>
43  SECRETS.reduce((out, re) => out.replace(re, (m, lead) => (typeof lead === 'string' && /[:=]\s*["']?$/.test(lead) ? `${lead}[redacted]` : '[redacted]')), s)
44
45const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}… [${s.length - n} more chars]` : s)
46
47/** The transcript as markdown: every message's text, each tool call's input and a clipped result. */
48export function transcript(
49  messages: readonly SessionMessage[],
50  meta: { sessionId: string; cwd: string; trigger: string; tokens?: number; now: number; instructions?: string },
51): string {
52  const head = [
53    `# Pre-compaction save`,
54    ``,
55    `- Session: ${meta.sessionId}`,
56    `- Folder: ${meta.cwd}`,
57    `- Saved: ${localIso(meta.now)}`,
58    `- Trigger: ${meta.trigger}${meta.tokens !== undefined ? ` at ${fmtTokens(meta.tokens)} tokens` : ''}`,
59    ...(meta.instructions ? [`- Compact instructions: ${meta.instructions}`] : []),
60    `- Messages: ${messages.length}`,
61    ``,
62  ]
63  const body = messages.flatMap((m, i) => {
64    const out: string[] = []
65    const text = m.text.trim()
66    const results = m.toolResults ?? []
67    if (m.role === 'user' && !text && results.length > 0) {
68      for (const r of results) out.push(`> tool result${r.isError ? ' (error)' : ''}: ${clip(String(r.text ?? '').trim(), 2000)}`)
69      return [...out, '']
70    }
71    out.push(`## ${i + 1}. ${m.role}`, '')
72    if (text) out.push(text, '')
73    for (const t of m.toolUses) {
74      out.push(`**${t.tool}** \`${clip(JSON.stringify(t.input), 1500)}\``)
75      if (t.text !== undefined) out.push(`> ${t.isError ? 'error: ' : ''}${clip(t.text.trim(), 2000)}`)
76      out.push('')
77    }
78    return out
79  })
80  return redact([...head, ...body].join('\n'))
81}
82
types/index.d.ts 9 lines
1/** Where the last pre-compaction save went, '' before the first one. */
2export type LastSave = string
3
4declare module 'claude-code' {
5  interface PluginState {
6    'auto-compact': { compacting: boolean; lastSave: LastSave; pending: string; lastResult: string; selfSavedAt: number }
7  }
8}
9