SLOPSHOPPER

lean-explore

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…

newbandspinnerguardcommandtoast
v1.6.0MITupdated 2026-10-23bStyler/claude-mods/lean-explore
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lean-explore
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ lean-explore │ ⏺ Read(src/auth.ts) │ lean-explore: installed the Haiku Explore │ ⎿ Read 6 lines │ agent. Start a new session for it to │ ⏺ Update(src/auth.ts) │ apply. /lean-explore off undoes it. │ ⎿ 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 › /lean-explore ⎿ lean-explore: Lean Explore agent: not installed. Type /lean-explore on or /lean-explore off. Changes apply from the next ses ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Four Claude Code mods, published as a plugin marketplace named claude-super-mods.

ModWhat you get
goal-meter 1.2.1A 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.0A 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.0Replaces 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.2Spots 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.

lean-explore

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.

  • Desktop app: a chip above the prompt. Green 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.
  • Terminal: a footer label, 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.
  • While the lean agent is on, the mod adds a short "Searching the codebase" note to the system prompt: delegate open-ended searches to Explore, read directly when the path is known. No CLAUDE.md edit needed.
  • /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.
  • Agent files load when a session starts, so every change applies from the next session.
  • Before uninstalling, run /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.

memory-guard

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.

Credit

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.

Install

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.

Commands

goal-meter

CommandDoes
/goal <what done looks like>Starts a goal. Claude plans it as sized tasks and the bar tracks them.
/goalsFull task list, plus other chats' goals from the last 12 hours.
/goals compact / /goals fullOne-line bar (default) or the full bar. Saved for every chat.
/goals hide / /goals showHide the bar and footer label in every chat, or bring them back.
/goals clearRemove this chat's goal, running or finished.
/goals strict onNo 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

CommandDoes
/keepwarm 2 / /keepwarm offKeep this chat's cache warm for N hours (max 24).
/cache big 100kSize at which it asks before a cold send (default 150k).
/cache guard offTurn the cold-send question off.
/cache ttl 5 / 60 / autoCache window, if it guesses wrong.
/boardEvery local chat on one board.
/handoffWrite a handoff and continue in a fresh chat.

memory-guard

CommandDoes
/memorizeSave what this session learned (bundled skill: memory-guard:memorize).
/memorize queueCatch up on the queued sessions from their transcripts.
/memorize-queue / /memorize-queue clearShow this session's score and the queue, or clear it.
/memory-guard off / on / statusStop or start watching this project; show where it files and what it counts.

Update

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.

Uninstall

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.

Source 2 files
hooks/register.mjs 356 lines
1// 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}
356
hooks/stats.mjs 59 lines
1// 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