Replaces the built-in Explore subagent with a lean one on Haiku (Read/Grep/Glob only, short answers) and counts what it actually did: searches, reads, tokens…

Four Claude Code mods, published as a plugin marketplace named claude-super-mods.
| Mod | What you get | ||||
|---|---|---|---|---|---|
| goal-meter 1.2.1 | A progress bar for /goal, built from Claude's own task plan. One line by default. /goals opens the full task list. | ||||
| cache-keeper 1.3.0 | A band above the prompt with the model, effort, context bar, plan limits (with the 5h reset) and cache state, so no status line is needed. Keeps a long chat's prompt cache warm (/keepwarm 2), asks before an expensive cold send, shows every local chat (/board), and hands a chat off (/handoff). Every compaction without its own instructions is told what to keep, and the band shows how much it saved. | ||||
| lean-explore 1.6.0 | Replaces the built-in Explore subagent (the one that does codebase searches) with a lean one: Haiku, low effort, Read/Grep/Glob only, short answers with file:line. It counts what it really did (searches, reads, tokens delegated to Haiku) from your own transcripts. `/lean-explore on\ | off\ | status\ | stats\ | rescan`. |
| memory-guard 0.2.2 | Spots sessions worth remembering (files written to output folders, MCP writes, meeting transcripts, decisions, commits, accepted plans, long chats) and nudges once to run /memorize. A session that ends without it is queued, and the next session in that project offers to catch up. Files into your wiki if you point it at one, else Claude Code's own auto-memory. Ships its own /memorize skill. |
A plugin cannot replace a built-in agent with an agent of its own (plugin agents get a lean-explore: prefix). So on the first session the mod writes ~/.claude/agents/Explore.md itself, marked with a comment line so it never touches an Explore.md you wrote.
Explore: Haiku · 6 searches counts this chat's Explore runs; hover it for a card with a Turn off button. Amber Explore: built-in means it is off, with a Turn on button. After a toggle it adds what the next session will use. It shares one row with the TL;DR button (the mods-row convention), chip on the left.Explore: Haiku or Explore: built-in, with the same next-session note./lean-explore stats prints all-time searches, reads and tokens delegated to Haiku (measured from usage figures; the "kept out of chat" number is an estimate at 4 characters per token). /lean-explore rescan rebuilds the totals from your transcripts./lean-explore status shows whether it is on, off, or blocked by your own Explore.md./lean-explore off renames it to Explore.md.off; the built-in Explore comes back./lean-explore on brings it back./lean-explore off, or delete ~/.claude/agents/Explore.md. Uninstalling the mod does not remove the file.When a search needs judgment (a review, a design call), ask for a general-purpose agent or pass model: "opus": Haiku finds things well but checks less of what it reads.
Each session scores a point per kind of durable work: a file written to an output folder (docs, reports, deliverables, notes), a write through any MCP tool, a meeting or transcript read, a decision in your prompt ("let's go with", "from now on"), a git commit, an accepted plan. Ten turns or more adds one. At 2 points it nudges once: a toast, a band above the prompt with Memorize now and Not this session, and /memorize as the prompt suggestion. A session that ends without /memorize goes on that project's queue. The next session there shows Catch up, and the hint line says · 3 unmemorized.
With no setup it files into Claude Code's auto-memory (~/.claude/projects/<project>/memory/ and its MEMORY.md). To use a wiki, or your own /memorize skill, add .claude/memory-guard.json to the project (or ~/.claude/memory-guard.json for all of them). Every field is optional:
{
"kb": { "path": "wiki", "index": "wiki/index.md", "log": "wiki/log.md", "rules": "wiki/CLAUDE.md" },
"skill": "memorize",
"threshold": 2,
"longSessionTurns": 10,
"outputDirs": ["docs", "reports", "deliverables", "notes"],
"signals": { "decision": false },
"extraSignals": [{ "name": "Tickets", "tool": "^mcp__.*linear.*__(create|update)" }, { "name": "Release", "bash": "npm publish" }],
"ui": { "band": true, "hint": true, "suggest": true }
}
kb: your knowledge base. The bundled skill reads its rules file and index, files pages there, and logs each one. A write to kb.log counts as memorized.skill: the skill the buttons run. Leave it out to use the bundled memory-guard:memorize.extraSignals: your own signals, by tool-name or Bash-command regex.On desktop a chip above the prompt shows Memory guard: on · 1 of 2 (green) or off (grey); hover it for a Turn off here / Turn on here button. The terminal footer says memory guard on or off. "ui": { "chip": false } hides both.
If it finds a wiki/ folder or an Obsidian vault with no config, it says so once. The band and hint line show in the terminal and the desktop app; elsewhere the toast and /memorize-queue still work.
goal-meter and cache-keeper are built on Nate Herk's claude-code-mods (MIT). goal-meter is a fork of his goal-meter 1.0.0, and cache-keeper is based on his cache-keeper 1.0.0. Each mod's CHANGES.md lists what differs from upstream. cache-keeper 1.3.0 also borrows three ideas from JayGPTPro's Smart PreCompact. MIT, see LICENSE.
Needs a Claude Code build with mods (function-hook plugins). Works on Windows, macOS and Linux.
claude plugin marketplace add bStyler/claude-mods
claude plugin install goal-meter@claude-super-mods
claude plugin install cache-keeper@claude-super-mods
claude plugin install lean-explore@claude-super-mods
claude plugin install memory-guard@claude-super-mods
Then quit Claude Code and start it again. /reload-plugins and /clear do not load mod code. Check: type /goals. It should open the goal meter pane.
If Nate Herk's nateherk-mods is installed on that machine, turn off its copies first (same names, same tools):
claude plugin disable goal-meter@nateherk-mods
claude plugin disable cache-keeper@nateherk-mods
cache-keeper's band replaces a custom status line. If you had one, remove the statusLine block from ~/.claude/settings.json so the same figures don't show twice.
goal-meter
| Command | Does |
|---|---|
/goal <what done looks like> | Starts a goal. Claude plans it as sized tasks and the bar tracks them. |
/goals | Full task list, plus other chats' goals from the last 12 hours. |
/goals compact / /goals full | One-line bar (default) or the full bar. Saved for every chat. |
/goals hide / /goals show | Hide the bar and footer label in every chat, or bring them back. |
/goals clear | Remove this chat's goal, running or finished. |
/goals strict on | No file edits in a /goal until Claude has written the plan. Useful for coding. |
The g key on the bar only works after Ctrl+X Tab focuses the bar. From the prompt, type /goals.
cache-keeper
| Command | Does |
|---|---|
/keepwarm 2 / /keepwarm off | Keep this chat's cache warm for N hours (max 24). |
/cache big 100k | Size at which it asks before a cold send (default 150k). |
/cache guard off | Turn the cold-send question off. |
/cache ttl 5 / 60 / auto | Cache window, if it guesses wrong. |
/board | Every local chat on one board. |
/handoff | Write a handoff and continue in a fresh chat. |
memory-guard
| Command | Does |
|---|---|
/memorize | Save what this session learned (bundled skill: memory-guard:memorize). |
/memorize queue | Catch up on the queued sessions from their transcripts. |
/memorize-queue / /memorize-queue clear | Show this session's score and the queue, or clear it. |
/memory-guard off / on / status | Stop or start watching this project; show where it files and what it counts. |
claude plugin marketplace update claude-super-mods
claude plugin update goal-meter@claude-super-mods
claude plugin update cache-keeper@claude-super-mods
claude plugin update lean-explore@claude-super-mods
claude plugin update memory-guard@claude-super-mods
Restart Claude Code afterwards. When changing code, raise version in the mod's .claude-plugin/plugin.json, or plugin update will not pick it up.
claude plugin uninstall goal-meter@claude-super-mods
claude plugin uninstall cache-keeper@claude-super-mods
claude plugin uninstall lean-explore@claude-super-mods
claude plugin uninstall memory-guard@claude-super-mods
claude plugin marketplace remove claude-super-mods
Saved state lives in ~/.claude/mods-data/ and can be deleted.
hooks/register.mjs 356 lines1// Lean Explore: replaces Claude Code's built-in Explore subagent with a lean one on Haiku.
2// A plugin's own agents are namespaced (lean-explore:Explore) and cannot replace a built-in,
3// so the mod writes ~/.claude/agents/Explore.md itself. "off" parks it as Explore.md.off.
4// Agent files load at session start: every change applies from the next session.
5
6// OWNER is the stable part of the marker; files from 1.0 and 1.1 end in "/explore-haiku off parks it."
7import { KEY, emptyLedger, fromScan, addRun, summarize } from './stats.mjs'
8
9const OWNER = '# Written by the lean-explore mod.'
10const MARK = `${OWNER} /lean-explore off parks it.`
11
12export const AGENT = `---
13name: Explore
14${MARK}
15description: Read-only codebase search on Haiku. Use to find files, read code and docs, and answer "where is X / what does Y do" questions when you only need the conclusion, not the file dumps. Say how thorough to be ("quick", "medium", "very thorough").
16model: haiku
17effort: low
18tools: Read, Grep, Glob
19---
20
21You are a read-only search agent. Find what the caller asked for and report it briefly.
22
23How to work:
24- Start with Glob and Grep to locate candidates. Read only the parts of files you need (use offset and limit on big files).
25- Match the thoroughness the caller asked for. "Quick" means one or two searches. "Very thorough" means check other folders and naming variants too.
26- Never edit, write, or run anything. You have no tools for it, so do not try.
27
28What to return:
29- The files that matter, with line numbers (path:line), and the lines that matter, quoted short.
30- A direct answer to the question asked. No preamble, no restating the task.
31- Under 300 words unless the caller asks for more.
32- Say what you did not cover (a folder you skipped, a copy you did not check).
33- If the answer needs judgment (a design choice, a review, a recommendation), say so and stop. The main model decides.
34`
35
36const BACKSLASH = String.fromCharCode(92)
37const slash = p => String(p ?? '').split(BACKSLASH).join('/')
38
39async function paths($) {
40 const home = slash((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\/+$/, '')
41 const dir = `${home}/.claude/agents`
42 return { dir, live: `${dir}/Explore.md`, parked: `${dir}/Explore.md.off` }
43}
44
45async function isOurs($, path) {
46 try { return (await $.fs.read(path)).includes(OWNER) } catch { return false }
47}
48
49// on: our file is active · off: parked · custom: someone else's Explore.md, left alone · missing
50async function state($, p) {
51 if (await $.fs.exists(p.live)) return (await isOurs($, p.live)) ? 'on' : 'custom'
52 return (await $.fs.exists(p.parked)) ? 'off' : 'missing'
53}
54
55async function windows($) {
56 return (await $.env.get('OS')) === 'Windows_NT'
57}
58
59async function move($, from, to) {
60 const argv = (await windows($))
61 ? ['cmd', '/c', 'move', '/Y', from.split('/').join(BACKSLASH), to.split('/').join(BACKSLASH)]
62 : ['mv', '-f', from, to]
63 return $.process.run(argv, { timeoutMs: 10000 })
64}
65
66async function writeAgent($, p) {
67 try {
68 await $.fs.write(p.live, AGENT)
69 } catch {
70 const argv = (await windows($)) ? ['cmd', '/c', 'mkdir', p.dir.split('/').join(BACKSLASH)] : ['mkdir', '-p', p.dir]
71 await $.process.run(argv, { timeoutMs: 10000 })
72 await $.fs.write(p.live, AGENT)
73 }
74}
75
76const NEXT = 'Start a new session for it to apply.'
77
78// `loaded` is what this session started with (agent files load at start); `current` is
79// the file on disk now, which a toggle or the first install changes. `searches` counts
80// this session's Explore runs.
81const view = { loaded: undefined, current: undefined, searches: 0 }
82
83// All-time numbers per model, kept in $.store. The first load fills it from the subagent
84// transcripts already on disk (backfill.py); after that every finished Explore run adds one.
85let ledger = emptyLedger()
86const live = new Map() // agentId -> the run in progress
87
88const tok = n => {
89 const v = Number(n) || 0
90 if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
91 if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
92 return String(Math.round(v))
93}
94
95async function scan($) {
96 const r = await $.process.run(['python3', '-I', `${$.plugin.root}/hooks/backfill.py`], { timeoutMs: 60000 })
97 if (r.exitCode !== 0) throw new Error(String(r.stderr || r.stdout).trim())
98 ledger = fromScan(JSON.parse(String(r.stdout).trim().split('\n').pop()))
99 await $.store.set(KEY, ledger)
100}
101
102let refresher
103
104async function loadLedger($) {
105 try {
106 const saved = await $.store.get(KEY)
107 if (saved && saved.v === 1) ledger = saved
108 if (!ledger.scanned) await scan($)
109 } catch { /* the chip just shows no totals */ }
110}
111
112function statsText() {
113 const s = summarize(ledger)
114 if (!s.runs) return s.otherRuns ? `No Haiku Explore runs yet (${s.otherRuns} ran on the built-in agent).` : 'No Explore runs recorded yet.'
115 const lines = [
116 `Lean Explore, all time: ${s.runs} Haiku search${s.runs === 1 ? '' : 'es'}, ${s.reads} file reads/searches.`,
117 `Delegated to Haiku: ${tok(s.delegated)} tokens (${tok(s.fresh)} billed fresh, ${tok(s.cached)} served from cache), ${tok(s.avgPerRun)} per search.`,
118 `Kept out of your main chat: about ${tok(s.keptOutOfChat)} tokens of file text the agent read; only ~${tok(s.reportTokens)} of reports came back.`,
119 ]
120 if (s.otherRuns) lines.push(`${s.otherRuns} earlier search${s.otherRuns === 1 ? '' : 'es'} ran on the built-in agent and are not counted above.`)
121 return lines.join('\n')
122}
123const kind = s => (s === 'on' ? 'Haiku' : 'built-in')
124const pending = () => kind(view.current) !== kind(view.loaded)
125
126export function footerLabel() {
127 if (!view.loaded) return ''
128 const label = `Explore: ${kind(view.loaded)}`
129 return pending() ? `${label} (${kind(view.current)} next session)` : label
130}
131
132function track($, p) {
133 return state($, p).then(s => { view.current = s; $.ui.invalidate('ui.render') })
134}
135
136// Shared by /lean-explore and the desktop chip's buttons. Returns what to tell the person.
137async function turn($, want) {
138 const p = await paths($)
139 const now = await state($, p)
140 if (now === 'custom') return `${p.live} was not written by this mod, so it was left alone. Rename or remove it first.`
141 if (now === want) return `Lean Explore agent is already ${want}.`
142 if (want === 'off') {
143 if (now === 'missing') return 'Lean Explore agent is not installed, so there is nothing to turn off.'
144 const r = await move($, p.live, p.parked)
145 if (r.exitCode !== 0) return `Could not park the agent: ${String(r.stderr || r.stdout).trim()}`
146 await track($, p)
147 return `Lean Explore agent is now off; the built-in Explore is back. ${NEXT}`
148 }
149 if (now === 'off') {
150 const r = await move($, p.parked, p.live)
151 if (r.exitCode !== 0) return `Could not restore the agent: ${String(r.stderr || r.stdout).trim()}`
152 } else {
153 await writeAgent($, p)
154 }
155 await track($, p)
156 return `Lean Explore agent is now on. ${NEXT}`
157}
158
159// The desktop chip, one row in the band above the prompt. Green with a search count while
160// the lean agent runs; amber with a "Turn on" button while the built-in one does. Hovering
161// shows a card; the app reveals it, no hook runs.
162function desktopChip($, e, below) {
163 if (!view.loaded) return below
164 const { Box, Text, Button } = $.ui.resolve(e)
165 const lean = view.loaded === 'on'
166 // Lean on purpose: the dot, the model, one number. The card says what the number is.
167 const sum = summarize(ledger)
168 const words = [kind(view.loaded)]
169 if (lean && sum.delegated) words.push(tok(sum.delegated))
170 if (pending()) words.push(`${kind(view.current)} next session`)
171 const chip = Text({ children: [Text({ color: lean ? 'success' : 'warning', children: ['●'] }), ` ${words.join(' · ')}`] })
172
173 const card = [Text({ bold: true, children: [lean ? 'Explore on Haiku' : 'Built-in Explore agent'] })]
174 if (lean && sum.runs) {
175 card.push(Text({ children: [`All time: ${sum.runs} searches, ${sum.reads} reads${view.searches ? ` (${view.searches} this chat)` : ''}`] }))
176 card.push(Text({ children: [`${tok(sum.delegated)} tokens delegated to Haiku (${tok(sum.fresh)} fresh, ${tok(sum.cached)} cached)`] }))
177 card.push(Text({ children: [`~${tok(sum.keptOutOfChat)} tokens of file text kept out of your chat`] }))
178 } else if (lean) card.push(Text({ children: ['No searches recorded yet.'] }))
179 else if (view.loaded === 'custom') card.push(Text({ children: ['Your own ~/.claude/agents/Explore.md is in use, so lean-explore stays out of the way.'] }))
180 else card.push(Text({ children: ['Searches run on the full built-in agent. The lean one uses about 3x fewer tokens.'] }))
181 if (pending()) card.push(Text({ dimColor: true, children: ['A change is waiting: start a new session to apply it.'] }))
182
183 const act = (label, want) => Button({ key: `lean-explore-${want}`, label, plain: true, onPress: async () => { $.ui.toast(await turn($, want), { timeoutMs: 8000 }) } })
184 const buttons = []
185 if (lean && !pending()) buttons.push(act('Turn off', 'off'))
186 if (buttons.length) card.push(Box({ flexDirection: 'row', columnGap: 2, children: buttons }))
187
188 // The card opens inside the band, under the chip: a card floated outside it would be
189 // clipped to the band's one row.
190 const hoverCard = Box({ display: 'none', hover: { display: 'flex' }, flexDirection: 'column', rowGap: 1, padding: 'sm', borderStyle: 'round', children: card })
191 const row = [chip]
192 // The amber state needs action, so its button sits on the chip itself.
193 if (!lean && !pending() && view.loaded !== 'custom') row.push(act('Turn on', 'on'))
194 const mine = Box({ key: 'lean-explore', flexDirection: 'column', children: [Box({ flexDirection: 'row', columnGap: 2, children: row }), hoverCard] })
195
196 // Share the row other mods use (TL;DR's `mods-row`): the chip goes first, their buttons
197 // stay at the right end. With none beneath, draw that row so an outer mod can join it.
198 const shared = findKeyed(below, SHARED_ROW)
199 if (shared) return swapNode(below, shared, { ...shared, children: [mine, ...(shared.children ?? [])] })
200 // marginTop: the same gap the other rows in the band keep from Token Optimizer's card
201 const own = Box({ key: SHARED_ROW, flexDirection: 'row', alignItems: 'flex-start', columnGap: 2, marginTop: 'md', children: [mine, Box({ flexGrow: 1 })] })
202 return Box({ flexDirection: 'column', children: below ? [own, below] : [own] })
203}
204
205// Drawings beneath come from next(e) as plain { type, props, children } nodes.
206const SHARED_ROW = 'mods-row'
207
208function findKeyed(node, key) {
209 if (!node || typeof node !== 'object') return null
210 if (node.props && node.props.key === key) return node
211 for (const child of node.children ?? []) {
212 const hit = findKeyed(child, key)
213 if (hit) return hit
214 }
215 return null
216}
217
218function swapNode(node, target, next) {
219 if (node === target) return next
220 if (!node || typeof node !== 'object' || !node.children) return node
221 let changed = false
222 const children = node.children.map(child => {
223 const out = swapNode(child, target, next)
224 if (out !== child) changed = true
225 return out
226 })
227 return changed ? { ...node, children } : node
228}
229
230// Travels with the mod, so every machine that installs it gets the same habit. Only while the
231// lean agent is on: with it off, Explore is the built-in one and this nudge has no point.
232const GUIDANCE = `# Searching the codebase
233For open-ended questions ("where is X handled", "how does Y work across the app", "what uses Z"), delegate to the Explore agent instead of grepping and reading files in the main chat: it runs on a cheaper model and returns only the answer. Read files directly when you already know the exact path, or need the contents to edit them.`
234
235export const register = on => {
236 on('prompt.compose', async ($, e, next) => {
237 const out = await next(e)
238 if (view.loaded !== 'on' || !out || !Array.isArray(out.sections)) return out
239 return { ...out, sections: [...out.sections, { id: 'lean-explore:guidance', text: GUIDANCE, scope: 'session' }] }
240 })
241
242
243 // First run installs the agent; a newer mod version refreshes our own file. Never touches a custom one.
244 on('session.start', async ($, e, next) => {
245 const out = await next(e)
246 try {
247 await $.command.register({ name: 'lean-explore', description: 'Lean Haiku Explore agent: /lean-explore on|off|stats|rescan', immediate: true })
248 } catch { /* name taken */ }
249 try {
250 const p = await paths($)
251 const now = await state($, p)
252 view.loaded = now
253 view.current = now
254 view.searches = 0
255 if (now === 'missing') {
256 await writeAgent($, p)
257 $.ui.toast(`lean-explore: installed the Haiku Explore agent. ${NEXT} /lean-explore off undoes it.`, { timeoutMs: 10000 })
258 } else if (now === 'on' && (await $.fs.read(p.live)) !== AGENT) {
259 await writeAgent($, p)
260 }
261 await track($, p)
262 await loadLedger($)
263 $.ui.invalidate('ui.render')
264 // Pick up runs finished by sessions open at the same time.
265 refresher?.cancel()
266 refresher = $.clock.every(30000, async () => {
267 try {
268 const stored = await $.store.get(KEY)
269 if (stored && stored.v === 1 && JSON.stringify(stored) !== JSON.stringify(ledger)) { ledger = stored; $.ui.invalidate('ui.render') }
270 } catch { /* try again next period */ }
271 })
272 } catch { /* never block a session start */ }
273 return out
274 })
275
276 // Count Explore runs for the chip. Nothing about the spawn changes.
277 on('agent.spawn', async ($, e, next) => {
278 const r = await next(e)
279 if (e.subagentType === 'Explore' && r && r.deny === undefined) {
280 view.searches += 1
281 if (r.agentId) live.set(r.agentId, { model: r.model, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, readChars: 0, reportChars: 0, tools: {} })
282 $.ui.invalidate('ui.render')
283 }
284 return r
285 })
286
287 // What each Explore run costs, step by step: the API's own usage figures.
288 on('turn.step', async function* ($, e, next) {
289 const r = yield* next(e)
290 const run = e.agentId && live.get(e.agentId)
291 if (run && r && r.usage) {
292 run.model = r.usage.model || run.model
293 run.input += r.usage.input_tokens; run.cacheRead += r.usage.cache_read_input_tokens
294 run.cacheWrite += r.usage.cache_creation_input_tokens; run.output += r.usage.output_tokens
295 }
296 return r
297 })
298
299 // What it read: one count per tool call, and the size of the text it got back.
300 on('tool.call', async ($, e, next) => {
301 const r = await next(e)
302 const run = e.agentId && live.get(e.agentId)
303 if (run && r && r.deny === undefined) {
304 run.tools[e.tool] = (run.tools[e.tool] ?? 0) + 1
305 run.readChars += typeof r.text === 'string' ? r.text.length : 0
306 }
307 return r
308 })
309
310 on('turn.complete', async ($, e, next) => {
311 const run = e.agentId && live.get(e.agentId)
312 if (run) {
313 live.delete(e.agentId)
314 run.reportChars = String(e.answer ?? '').length
315 try {
316 // Other sessions write the same key: add this run to what is stored now, not to the
317 // copy loaded at session start, so parallel sessions do not overwrite each other.
318 const stored = await $.store.get(KEY)
319 if (stored && stored.v === 1) ledger = stored
320 addRun(ledger, run)
321 await $.store.set(KEY, ledger)
322 $.ui.invalidate('ui.render')
323 } catch { /* keep going */ }
324 }
325 return next(e)
326 })
327
328 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
329 const below = await next(e)
330 if (e.surface !== 'desktop' || (e.props && e.props.hasSurvey)) return below
331 return desktopChip($, e, below)
332 })
333
334 // The terminal gets a short footer label; on desktop the chip says it.
335 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
336 if (e.surface === 'desktop') return next(e)
337 const label = footerLabel()
338 if (!label) return next(e)
339 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
340 return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
341 })
342
343 on('command.run', { command: 'lean-explore' }, async ($, e) => {
344 const want = String(e.args ?? '').trim().toLowerCase()
345 if (want === 'on' || want === 'off') return { text: await turn($, want) }
346 if (want === 'stats') return { text: statsText() }
347 if (want === 'rescan') {
348 try { await scan($) } catch (err) { return { text: `Could not rescan the transcripts: ${err.message}` } }
349 return { text: `Rescanned every past Explore run.\n${statsText()}` }
350 }
351 const now = await state($, await paths($))
352 const said = { on: 'on', off: 'off (parked as Explore.md.off)', missing: 'not installed', custom: 'off - a custom Explore.md not written by this mod is in use' }[now]
353 return { text: `Lean Explore agent: ${said}. Type /lean-explore on or /lean-explore off. Changes apply from the next session. /lean-explore stats shows what it has saved.` }
354 })
355}
356hooks/stats.mjs 59 lines1// Lean Explore ledger: what Explore runs actually did, kept per model.
2// Shape (JSON, lives in $.store): { v, scanned, byModel: { <model id>: Row } }
3// Row: runs, input (uncached), cacheRead, cacheWrite, output, readChars (tool results the
4// agent read), reportChars (what it handed back), tools { name: count }.
5
6export const KEY = 'lean-explore.ledger'
7const CHARS_PER_TOKEN = 4
8
9export const emptyRow = () => ({ runs: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, readChars: 0, reportChars: 0, tools: {} })
10export const emptyLedger = () => ({ v: 1, scanned: false, byModel: {} })
11
12export function addRow(ledger, model, row) {
13 const into = (ledger.byModel[model] ??= emptyRow())
14 for (const k of ['runs', 'input', 'cacheRead', 'cacheWrite', 'output', 'readChars', 'reportChars']) into[k] += Number(row[k]) || 0
15 for (const [t, n] of Object.entries(row.tools ?? {})) into.tools[t] = (into.tools[t] ?? 0) + n
16 return ledger
17}
18
19// A finished live run, as collected by the hooks.
20export function addRun(ledger, run) {
21 return addRow(ledger, run.model || 'unknown', {
22 runs: 1, input: run.input, cacheRead: run.cacheRead, cacheWrite: run.cacheWrite, output: run.output,
23 readChars: run.readChars, reportChars: run.reportChars, tools: run.tools,
24 })
25}
26
27// Replace the ledger's contents with the totals a transcript scan found.
28export function fromScan(scan) {
29 const ledger = emptyLedger()
30 ledger.scanned = true
31 for (const [model, row] of Object.entries(scan.byModel ?? {})) addRow(ledger, model, row)
32 return ledger
33}
34
35export const isHaiku = m => /haiku/i.test(m)
36export const total = r => r.input + r.cacheRead + r.cacheWrite + r.output
37const sum = (rows, f) => rows.reduce((n, r) => n + f(r), 0)
38
39export function summarize(ledger) {
40 const rows = Object.entries(ledger.byModel)
41 const lean = rows.filter(([m]) => isHaiku(m)).map(([, r]) => r)
42 const other = rows.filter(([m]) => !isHaiku(m)).map(([, r]) => r)
43 const reads = r => (r.tools.Read ?? 0) + (r.tools.Grep ?? 0) + (r.tools.Glob ?? 0)
44 const kept = r => Math.max(0, r.readChars - r.reportChars) / CHARS_PER_TOKEN
45 const runs = sum(lean, r => r.runs)
46 return {
47 runs,
48 otherRuns: sum(other, r => r.runs),
49 delegated: sum(lean, total), // every token Haiku processed instead of the main model
50 fresh: sum(lean, r => r.input + r.cacheWrite + r.output), // the part billed at full price
51 cached: sum(lean, r => r.cacheRead),
52 reads: sum(lean, reads),
53 keptOutOfChat: Math.round(sum(lean, kept)), // file text read that never reached the main chat
54 reportTokens: Math.round(sum(lean, r => r.reportChars) / CHARS_PER_TOKEN),
55 avgPerRun: runs ? Math.round(sum(lean, total) / runs) : 0,
56 models: rows.map(([model, r]) => ({ model, runs: r.runs, tokens: total(r) })).sort((a, b) => b.runs - a.runs),
57 }
58}
59