glassbox-trace 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,9 @@ glassbox watch live tail: the viewer follows the newest s
40
40
  glassbox collect ./reports --format md --out fleet.md
41
41
  one report from a folder of redacted check reports, one per machine
42
42
  glassbox claims 81c4 --format md said vs did: every claim the agent made about its own work, with the tool result behind it — or the gap
43
+ glassbox recall login redirect what past sessions of this project asked, wrote, ran, committed and said about it
44
+ glassbox memory what the next session here will be told (hook --context), and the lessons behind it
45
+ glassbox memory audit everything loaded before the first prompt, its token cost, and what is dead or doubled
43
46
  glassbox clean remove the temp files `open` and the hook leave behind
44
47
  ```
45
48
 
@@ -55,7 +58,7 @@ The npm package is `glassbox-trace` (plain `glassbox` was already taken); the co
55
58
 
56
59
  **Let the agent read its own recorder.** `glassbox hook install` adds a Claude Code Stop hook (the command it writes is `npx -y glassbox-trace hook …`, which resolves the package on each run; pass `--command glassbox` after `npm i -g glassbox-trace@<version>` to pin it, or use the plugin below, which runs its bundled copy): every session ends with a one-screen Glassbox summary (turns, tool calls, context peak, cost, top findings). Add `--feedback` and the findings — with their evidence and the "next time" advice — are handed back to the agent; it reads them and says, in a sentence, what it would do differently. Each finding is handed back once per session, it never loops (`stop_hook_active` is respected), never blocks a clean session, and `glassbox hook uninstall` removes it, with a `.glassbox-backup` of `settings.json` kept.
57
60
 
58
- Feedback at the end of a session can only change that session's last reply. Add `--context` to carry it forward: the Stop hook keeps the findings (and, with `--feedback`, the agent's answer) in `<project>/.glassbox/last-session.md` — git-ignored, deleted after a clean session — and a SessionStart hook gives them to the next session in that project.
61
+ Feedback at the end of a session can only change that session's last reply. Add `--context` to carry it forward: the Stop hook keeps a memory of the project in `<project>/.glassbox/` — git-ignored, owner-only, secrets masked — and a SessionStart hook gives the next session there the lessons that keep coming back, a line for each recent session, and what the agent said it would do differently. See [Memory across sessions](#memory-across-sessions--glassbox-recall-and-glassbox-memory) below.
59
62
 
60
63
  **Stop a loop before it happens.** `--guard` adds a PreToolUse hook: when the agent is about to repeat a call that has already failed twice in a row, unchanged, in the current turn, it blocks the call and tells the agent why, with the last error, and to change something or ask you rather than try again. It is deliberately narrow. Anything that could change the outcome in between — an edit, another command, a success, a new prompt — resets it, so re-running the tests after a fix is never blocked; calls a person stopped don't count; it never approves anything, so your permission prompts are untouched. Replayed over 4,121 real tool calls it blocked none, so it stays out of the way until an agent is actually stuck. It runs before every tool call, so it needs an installed copy: `--guard` writes a direct `node "…/bin/glassbox.mjs" hook …` command (for the Stop hook too) and refuses to install from an `npx` cache, where each call would cost seconds. The plugin doesn't include the guard ([why](docs/PLUGIN.md#the-loop-guard-is-not-in-the-plugin)); if both are installed, the plugin sees the CLI hook and steps aside.
61
64
 
@@ -164,6 +167,36 @@ Glassbox claims · 38f81247
164
167
 
165
168
  Six kinds of claim (tests pass, shipped, verified, fixed, nothing changed, file written), judged on the same agent's tool calls in that turn: a test claim needs a test run after the last edit — from `Bash`, `PowerShell` or Cowork's `device_bash` — with its counts read and compared; a shipped claim needs the commit, push, publish or upload result, carrying the sha or version the sentence names; "nothing changed" needs a `git status` after the thing that could have changed it. An agent that says "not verified" gets a *declared* row and no finding. The same ledger runs inside `check` as three rules — `contradicted-claim` (error), `unverified-claim` (warn), `stale-claim` (info) — so the Stop hook hands the sentence back to the agent that wrote it. On sixteen real sessions (367 claims; three of them checked by hand first) the ledger found one unverified sentence — "the tests still pass", 24 minutes after the last run — five fixes never re-run after their edit, and no contradictions. A verdict is about the sentence, never the code. Details in [docs/CLAIMS.md](docs/CLAIMS.md).
166
169
 
170
+ ### Memory across sessions — `glassbox recall` and `glassbox memory`
171
+
172
+ A Claude Code session starts knowing nothing about the last one, and Claude Code deletes transcripts after 30 days by default. With `--context` (or the plugin's `context` option), the Stop hook keeps `<project>/.glassbox/memory.json`: a digest of each of the project's last 50 sessions — what was asked, the files written, the checks and pushes that ran and how they ended, the commits with their shas, the agent's last words, the findings — and **lessons**: findings counted across sessions by what they are about (`retry-loop · Bash npm test`, `failed-tool · Edit`, `unverified-claim · test`). The next session starts with this note (≤ 8,000 characters, under Claude Code's 10,000 cap):
173
+
174
+ ```
175
+ ## Recurring in this project (worst first)
176
+
177
+ - retry-loop · Bash npm test — in 3 of the last 12 sessions, last 2026-10-09: Bash called 3× with identical input, e.g. `npm test -- auth`. Next time: When a call fails, change something before calling again: …
178
+
179
+ ## Recent sessions (newest first)
180
+
181
+ - 2026-10-09 14:02 · a1b2c3d4 · "fix the login redirect" — wrote src/auth.js, test/auth.test.js · ran npm test (passed, last of 3) · committed 1a2b3c4 "fix: login redirect loop" · pushed
182
+ ```
183
+
184
+ A lesson is handed on until ten sessions in a row go by without it, then it retires (and comes back if it fires again). Each session that started with a lesson is counted, so `glassbox memory` can say what nobody could before: *told in 9 sessions, came back in 1*. Everything is copied or counted from the transcripts — no model call, no tokens — and whatever a `fence` detector recognises as a secret is masked before it is written.
185
+
186
+ ```
187
+ glassbox recall login redirect sessions of this project that mention it, best match first: asked / said / wrote / ran / commit
188
+ glassbox recall "dark mode" --since 30d --format md a quoted phrase is one term; --format md for pasting to an agent
189
+ glassbox recall no words: the latest sessions, one line each
190
+ glassbox memory what the next session will be told, and every lesson: sessions, told, came back, quiet
191
+ glassbox memory forget retry-loop drop a lesson (by rule or key); it starts over if it fires again
192
+ glassbox memory clear delete the project's memory
193
+ glassbox memory audit everything loaded before the first prompt, and what it costs
194
+ ```
195
+
196
+ `recall` works without the hook: it reads the project's transcripts still on disk, and the ledger supplies the sessions whose transcripts are gone. Every word must match; a word that isn't in a transcript's raw text skips parsing it. The plugin's skill tells Claude to run it before re-investigating something a past session may already have settled.
197
+
198
+ **`glassbox memory audit`** lists what Claude Code loads into every session before the first prompt — managed, user, parent-directory and project `CLAUDE.md` files, `CLAUDE.local.md`, unscoped `.claude/rules/`, `@imports` (four hops, relative to the importing file), the first 200 lines or 25 KB of the auto-memory `MEMORY.md`, and Glassbox's own note — with an estimated token cost (characters ÷ 4, HTML comments stripped as Claude Code strips them), then flags what a mechanical reader can: imports that load nothing, backticked paths and `npm run` scripts that don't exist, the same line in two places, rules that pull opposite ways (`use pnpm` here, `use npm, not pnpm` there — the same parser as `adhere`), files over the 200 lines Claude Code's docs recommend, and `MEMORY.md` lines past the cut-off that never load. `--fail-on warn` for CI. Details in [docs/MEMORY.md](docs/MEMORY.md).
199
+
167
200
  ## What it flags
168
201
 
169
202
  | rule | fires when |
@@ -181,6 +214,7 @@ Six kinds of claim (tests pass, shipped, verified, fixed, nothing changed, file
181
214
  | `low-cache-hit` | < 50% of input served from cache over ≥ 5 requests |
182
215
  | `slow-model` | ≥ 60 s for a response under 1,500 tokens (latency / rate limit / stall) |
183
216
  | `long-generation` | ≥ 60 s for a big response streamed below 15 tok/s |
217
+ | `missing-final-usage` | subagent responses whose final usage record never reached the transcript — the cost shown is a floor, with an estimate of the gap (info; a Claude Code bug, [#93620](https://github.com/anthropics/claude-code/issues/93620), not the agent's doing) |
184
218
  | `slow-tool` | a tool call ≥ 60 s (warn at 5 min); one line per tool when repeated; blocking `TaskOutput` waits roll up per background task as info; time spent on the human (questions, permission prompts) is excluded |
185
219
  | `permission-denied` | tool calls the human rejected, a permission rule or auto mode blocked, or an interrupt stopped (info) |
186
220
  | `max-tokens`, `api-error`, `hook-error`, `compaction`, `thinking-heavy`, `subagent-share`, `long-turn` | what they say (`long-turn`: ≥ 30 tool calls after one prompt in the main conversation) |
@@ -190,7 +224,7 @@ Identical findings print once with a `×N` count in `check`. All mechanical, no
190
224
 
191
225
  ## Token and cost accounting
192
226
 
193
- - Usage is counted once per API request (assistant records sharing a `requestId`), not once per record. Subagent transcripts rewrite `output_tokens` while a response streams, so the largest value across the request's records is used.
227
+ - Usage is counted once per API request (assistant records sharing a `requestId`), not once per record. Subagent transcripts rewrite `output_tokens` while a response streams, so the largest value across the request's records is used. Since Claude Code ~2.1.278 many subagent responses never get their final record written at all (every record has `stop_reason: null` and a partial count — [anthropics/claude-code#93620](https://github.com/anthropics/claude-code/issues/93620)); those output tokens are not on disk anywhere, so Glassbox counts the responses, shows the cost as **≥ $X** (a floor) and estimates the gap from the median of the session's own complete subagent tool-use responses (needs five). Compare with Claude Code's own total (`/cost`, the status line) before budgeting from a floor.
194
228
  - Context size = uncached input + cache read + cache write — the prompt the model actually saw.
195
229
  - Thinking tokens are part of output tokens; shown as a share, never added twice.
196
230
  - Cost is estimated from an editable rate card (defaults from the Claude pricing page, 2026-09-05; prefix-matched so dated model ids resolve). A `stream-json` `result` record with `total_cost_usd` overrides the estimate. Unknown models show "—" rather than a wrong number.
@@ -246,7 +280,7 @@ console.log(compare({ trace, findings, cost }, other)); // other = the same
246
280
  Your context is private — by construction, not by promise.
247
281
 
248
282
  - **Nothing leaves.** The viewer makes no network requests at all — fonts, scripts and the demo are inside the one file, and the test suite fails if a build references anything outside it. The CLI sends nothing anywhere. `glassbox watch` binds to `127.0.0.1` only and stops with the command. The one network activity is installation: `npx` fetching the package, or the plugin marketplace fetching this repo.
249
- - **What it touches.** It reads `~/.claude/projects/**` and writes only what you ask for: `open` writes a self-contained HTML to your temp folder (transcript included — delete it when done, or use `--out`), `hook install` edits `~/.claude/settings.json` with a backup beside it, `--context` writes `<project>/.glassbox/last-session.md`, `--legend` writes the legend. The full inventory — files, processes, hooks, network, uninstall — is in [docs/IT.md](docs/IT.md), written for whoever has to approve it.
283
+ - **What it touches.** It reads `~/.claude/projects/**` and writes only what you ask for: `open` writes a self-contained HTML to your temp folder (transcript included — delete it when done, or use `--out`), `hook install` edits `~/.claude/settings.json` with a backup beside it, `--context` writes `<project>/.glassbox/last-session.md` and `memory.json` (owner-only, git-ignored, secrets masked; they quote prompts and commands), `--legend` writes the legend. The full inventory — files, processes, hooks, network, uninstall — is in [docs/IT.md](docs/IT.md), written for whoever has to approve it.
250
284
  - **Counts, not text.** Transcripts contain everything the agent saw; that's why share mode and `--redact` exist. Redacted output keeps tool names, numbers, timings and cost and drops every string — prompts, commands, results, titles. Where a finding would quote something it says `«240 chars»`.
251
285
  - **What is already on disk.** `glassbox fence` finds credentials that reached a transcript (known key formats, secrets named by context, credential-file reads), reports them masked and fingerprinted, and `--shred` overwrites them in place. See above.
252
286
  - **The shape, not the names.** A redacted report still needs to say "this one file was read 48 times and 6 of those failed". `check --redact --legend audit.legend.json` replaces every path with a keyed hash (`file:b94b1a35`, HMAC-SHA256 with a random salt) and writes the key → path map to the legend file, which stays on your machine. The redacted JSON carries `summary.files` (reads, writes, failures, agents, chars per key) and `evidence.files` on findings; `duplicate-subagent-read` keeps its sentence with the key in it. `glassbox reveal report.md --legend audit.legend.json` turns the keys back into paths for you. Re-using the legend keeps keys stable across runs; two machines with two legends produce different keys for the same file. Treat the legend like a password file: local, git-ignored, never attached to the same email as the report. Design notes in DESIGN.md §12.
@@ -408,7 +408,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
408
408
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
409
409
  'use strict';
410
410
 
411
- const VERSION = '0.10.0';
411
+ const VERSION = '0.11.0';
412
412
 
413
413
  // ---------------------------------------------------------------------------
414
414
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -816,7 +816,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
816
816
  let turn = currentTurn(a.id);
817
817
  if (!turn) turn = ensureTurn(rec, 'implicit', '');
818
818
  req = {
819
- id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t,
819
+ id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t, version: rec.version || null,
820
820
  usage: emptyUsage(), blocks: [], turnIndex: turn.index, contextTokens: 0, records: 0,
821
821
  isApiError: !!rec.isApiErrorMessage, uuid: rec.uuid || null,
822
822
  };
@@ -831,6 +831,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
831
831
  maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
832
832
  req.records++;
833
833
  if (msg.stop_reason) req.stopReason = msg.stop_reason;
834
+ if (!req.version && rec.version) req.version = rec.version;
834
835
  if (rec.isApiErrorMessage) req.isApiError = true;
835
836
  const content = Array.isArray(msg.content) ? msg.content : (msg.content != null ? [{ type: 'text', text: String(msg.content) }] : []);
836
837
  const blockIndex = typeof rec.apiBlockIndex === 'number' ? rec.apiBlockIndex : req.blocks.length;
@@ -868,6 +869,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
868
869
  req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
869
870
  addUsage(turns[req.turnIndex].usage, req.usage);
870
871
  addUsage(agents.get(req.agent).usage, req.usage);
872
+ // A response whose records all have stop_reason null never got its final record written, so its
873
+ // output_tokens is a partial running count. Only subagent transcripts are judged: in the main
874
+ // transcript that shape is the live, unfinished last response (anthropics/claude-code#93620).
875
+ req.usageFinal = !(req.agent !== 'main' && !req.stopReason && !req.isApiError && req.model !== '<synthetic>' && !isEmptyUsage(req.usage));
871
876
  }
872
877
  for (const turn of turns) if (turn.end == null) turn.end = meta.end;
873
878
  // Response timing. Assistant record timestamps are written when a block finishes streaming,
@@ -927,12 +932,28 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
927
932
  toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
928
933
  modelTimeMs: agents.get('main').modelTimeMs || 0,
929
934
  cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
930
- attachments,
935
+ attachments, usagePartial: usagePartial(requests),
931
936
  };
932
937
 
933
938
  return { version: VERSION, meta, requests, toolCalls, turns, agents: agentList, events, problems, totals };
934
939
  }
935
940
 
941
+ // Subagent responses with no final usage record: how many, what was seen, and an estimate of the gap from the
942
+ // session's own complete subagent tool_use responses (median; needs at least five to go on).
943
+ function usagePartial(requests) {
944
+ const sub = requests.filter((r) => r.agent !== 'main' && r.model !== '<synthetic>' && !r.isApiError && !isEmptyUsage(r.usage));
945
+ const partial = sub.filter((r) => r.usageFinal === false);
946
+ const out = { responses: partial.length, of: sub.length, outputSeen: partial.reduce((n, r) => n + r.usage.output, 0), versions: Array.from(new Set(partial.map((r) => r.version).filter(Boolean))).sort(), agents: new Set(partial.map((r) => r.agent)).size, estimate: null };
947
+ if (!partial.length) return out;
948
+ const basis = sub.filter((r) => r.usageFinal && r.stopReason === 'tool_use').map((r) => r.usage.output).sort((a, b) => a - b);
949
+ if (basis.length >= 5) {
950
+ const mid = basis.length >> 1;
951
+ const median = basis.length % 2 ? basis[mid] : (basis[mid - 1] + basis[mid]) / 2;
952
+ out.estimate = { basis: basis.length, median, missingOutput: partial.reduce((n, r) => n + Math.max(0, median - r.usage.output), 0) };
953
+ }
954
+ return out;
955
+ }
956
+
936
957
  function pushModel(meta, m) { if (m && !meta.models.includes(m)) meta.models.push(m); }
937
958
 
938
959
  function mkOrphanResult(tr, rec, agentId, text, turnIndex) {
@@ -1218,6 +1239,21 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1218
1239
  if (e.kind === 'compact') push({ id: 'compaction', severity: 'info', title: 'Context was compacted', detail: e.detail, evidence: { at: e.at } });
1219
1240
  }
1220
1241
 
1242
+ // missing-final-usage: the transcript under-counts subagent output (Claude Code bug, not the agent's doing)
1243
+ const P = trace.totals.usagePartial;
1244
+ if (P && P.responses > 0) {
1245
+ const c = estimateCost(trace, o.rates);
1246
+ const est = P.estimate
1247
+ ? ` From the median of ${P.estimate.basis} complete subagent tool-use responses in this session (${fmtInt(P.estimate.median)} output tokens each), about ${fmtInt(P.estimate.missingOutput)} output tokens${c.missing != null ? ` (~${usd(c.missing)})` : ''} are not counted.`
1248
+ : ' Too few complete subagent responses in this session (fewer than 5) to estimate the gap.';
1249
+ push({
1250
+ id: 'missing-final-usage', severity: 'info',
1251
+ title: `Cost is a floor: ${P.responses} of ${P.of} subagent responses never recorded their final output tokens`,
1252
+ detail: `These responses were written to the subagent transcript with stop_reason null on every record and only a partial running output_tokens count (${fmtInt(P.outputSeen)} tokens across all of them), so their real output is not on disk.${est} Claude Code ${P.versions.join(', ') || '(version not recorded)'}; known transcript issue anthropics/claude-code#93620.`,
1253
+ evidence: { requestIds: trace.requests.filter((r) => r.usageFinal === false).slice(0, 20).map((r) => r.id) }, metric: P.responses,
1254
+ });
1255
+ }
1256
+
1221
1257
  // thinking-heavy
1222
1258
  const u = trace.totals.usage;
1223
1259
  if (u.output >= o.thinkingHeavyMinOutput && u.thinking / u.output > o.thinkingHeavyShare) push({
@@ -1301,7 +1337,17 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1301
1337
  byAgent[r.agent] = (byAgent[r.agent] || 0) + c;
1302
1338
  }
1303
1339
  const complete = known === trace.requests.length;
1340
+ // Floor: some subagent responses never recorded their final output (see usagePartial). A run-reported cost
1341
+ // is the real bill and stays as it is.
1342
+ const P = (trace.totals && trace.totals.usagePartial) || { responses: 0, estimate: null };
1343
+ const lowerBound = P.responses > 0 && trace.meta.reportedCost == null;
1344
+ let missing = null;
1345
+ if (lowerBound && P.estimate) {
1346
+ missing = 0;
1347
+ for (const r of trace.requests) { if (r.usageFinal !== false) continue; const rate = rateFor(r.model, rates); if (rate) missing += Math.max(0, P.estimate.median - r.usage.output) * rate.out / 1e6; }
1348
+ }
1304
1349
  return {
1350
+ lowerBound, partialResponses: P.responses, missing,
1305
1351
  total: known ? total : null, complete, byRequest, byModel, byAgent, unknownModels: Array.from(unknownModels),
1306
1352
  reported: trace.meta.reportedCost, source: trace.meta.reportedCost != null ? 'reported' : (known ? 'estimated' : 'unknown'),
1307
1353
  };
@@ -1329,6 +1375,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1329
1375
  'compaction': 'Context was compacted; state that matters later (decisions, file paths, test status) should be written to a file or note before it is summarised away.',
1330
1376
  'thinking-heavy': 'Most output tokens were thinking; fine for hard problems, but for routine tool loops a lower effort setting is cheaper and just as good.',
1331
1377
  'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
1378
+ 'missing-final-usage': "Not the agent's doing: Claude Code did not write these subagent responses' final usage to disk. Treat the transcript cost as a floor and check it against Claude Code's own total (/cost or the status line) before budgeting from it.",
1332
1379
  'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
1333
1380
  'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
1334
1381
  'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
@@ -1435,7 +1482,9 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1435
1482
  out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
1436
1483
  out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
1437
1484
  out.push(`- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turn${T.turns === 1 ? '' : 's'} · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
1438
- out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}`, '');
1485
+ out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${cost && cost.lowerBound ? 'at least ' : ''}${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}`);
1486
+ if (cost && cost.lowerBound) out.push(`- cost is a floor: ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} never recorded their final output tokens (anthropics/claude-code#93620)${cost.missing != null ? `; estimated ~${fmtInt(T.usagePartial.estimate.missingOutput)} output tokens, ~${usd(cost.missing)} more` : ''}.`);
1487
+ out.push('');
1439
1488
  if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
1440
1489
  else {
1441
1490
  out.push(`## Findings (${findings.length})`, '');
@@ -1672,8 +1721,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1672
1721
  const t = S.trace, T = t.totals, u = T.usage, cost = S.cost;
1673
1722
  const ctx = u.input + u.cacheRead + u.cacheWrite;
1674
1723
  const errs = S.findings.filter((f) => f.severity === 'error').length, warns = S.findings.filter((f) => f.severity === 'warn').length, infos = S.findings.filter((f) => f.severity === 'info').length;
1675
- const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? fmtUSD(cost.total) : '—';
1676
- const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
1724
+ const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? (cost.lowerBound ? '≥ ' : '') + fmtUSD(cost.total) : '—';
1725
+ const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.lowerBound ? `a floor — ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} missing final usage${cost.missing != null ? ` · ~${fmtUSD(cost.missing)} more` : ''}` : cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
1677
1726
  const tiles = [
1678
1727
  ['Wall time', fmtDur(T.wallMs), T.humanIdleMs ? `${fmtDur(T.activeMs)} active · ${fmtDur(T.humanIdleMs)} waiting on you` : (t.meta.hasTimestamps ? 'no human idle' : 'no timestamps in file')],
1679
1728
  ['Turns', fmtInt(T.turns), `${fmtInt(T.requests)} API requests`],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "glassbox-trace",
3
- "version": "0.10.0",
4
- "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a fleet view, a secrets scanner for transcripts (fence), a CLAUDE.md compliance report (adhere), a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
3
+ "version": "0.11.0",
4
+ "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a fleet view, a secrets scanner for transcripts (fence), a CLAUDE.md compliance report (adhere), a memory the next session starts with plus a search over past sessions (recall) and an audit of what loads before the first prompt, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
5
  "main": "src/trace-core.js",
6
6
  "scripts": {
7
7
  "test": "node --test test/*.test.mjs",
@@ -34,6 +34,8 @@
34
34
  "secret-scanning",
35
35
  "claude-md",
36
36
  "adherence",
37
+ "memory",
38
+ "recall",
37
39
  "compare",
38
40
  "live-tail",
39
41
  "claude",
package/src/adhere.mjs CHANGED
@@ -94,6 +94,17 @@ export function parseRules(text, file) {
94
94
  return { rules, unchecked };
95
95
  }
96
96
 
97
+ // Sessions of a project, newest first: the folder name Claude Code would use (every character that is not a letter
98
+ // or a digit becomes '-', so "my.app_v2" is "my-app-v2"; older builds only replaced separators). Failing that, a
99
+ // folder whose name ends with it — the same project reached through a symlinked parent (/tmp → /private/tmp). Never a
100
+ // substring: /home/aldo/app is not /home/aldo/app-v2, and a folder name can't tell app/v2 from app-v2.
101
+ export function projectSessions(project, home) {
102
+ const enc = [encodeProject(project), String(project).replace(/[^A-Za-z0-9]/g, '-')].map((e) => e.toLowerCase());
103
+ const all = findSessions({ home }), name = (s) => path.basename(s.projectDir).toLowerCase();
104
+ const list = all.filter((s) => enc.includes(name(s)));
105
+ return list.length ? list : all.filter((s) => enc.some((e) => name(s).endsWith(e)));
106
+ }
107
+
97
108
  export function findInstructionFiles(project, home) {
98
109
  const out = [];
99
110
  const add = (p, label) => { if (fs.existsSync(p) && fs.statSync(p).isFile()) out.push({ file: p, label }); };
@@ -145,7 +156,7 @@ function segmentTools(cmd) {
145
156
  return segments(cmd).map((seg) => { const toks = seg.split(/\s+/); let t = toks[0].replace(/^.*[\\/]/, '').toLowerCase(); if (/^python3?$/.test(t) && toks[1] === '-m' && toks[2]) t = toks[2].toLowerCase(); if (t === 'pip3') t = 'pip'; return t; });
146
157
  }
147
158
  const hasSeg = (cmd, re) => segments(cmd).some((seg) => re.test(seg));
148
- function commitMessage(cmd) {
159
+ export function commitMessage(cmd) {
149
160
  cmd = String(cmd);
150
161
  const hd = cmd.match(/<<-?\s*['"]?(\w+)['"]?\n([\s\S]*?)\n\1/); if (hd) return hd[2].split('\n')[0].trim();
151
162
  const q = cmd.match(/(?:^|\s)(?:-m|--message)(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|(\S+))/); if (q) return (q[1] != null ? q[1] : q[2] != null ? q[2] : q[3]).split('\n')[0].trim();
@@ -165,7 +176,7 @@ const pathHits = (rulePath, filePath, project) => {
165
176
  const OPTV = String.raw`(?:"[^"]*"|'[^']*'|\S)+`;
166
177
  const GIT = String.raw`^git\s+(?:(?:-c\s+${OPTV}|-C\s+${OPTV}|--no-pager|--git-dir=${OPTV}|--work-tree=${OPTV})\s+)*`;
167
178
  const git = (sub) => new RegExp(GIT + sub + '\\b');
168
- const actionRe = { commit: git('commit'), push: git('push'), delete: new RegExp('^(?:rm|rmdir|del|Remove-Item)\\b|' + GIT + 'rm\\b'), install: /^(?:npm|pnpm|yarn|bun|pip3?|uv|poetry|cargo|gem|brew|apt(?:-get)?|choco|winget)\s+(?:install|add|i)\b/, deploy: /^(?:vercel|netlify|fly\s+deploy|wrangler\s+(?:publish|deploy)|gcloud\s+run\s+deploy|serverless\s+deploy|eb\s+deploy)\b|^\S+\s+deploy\b/i, merge: git('merge') };
179
+ export const actionRe = { commit: git('commit'), push: git('push'), delete: new RegExp('^(?:rm|rmdir|del|Remove-Item)\\b|' + GIT + 'rm\\b'), install: /^(?:npm|pnpm|yarn|bun|pip3?|uv|poetry|cargo|gem|brew|apt(?:-get)?|choco|winget)\s+(?:install|add|i)\b/, deploy: /^(?:vercel|netlify|fly\s+deploy|wrangler\s+(?:publish|deploy)|gcloud\s+run\s+deploy|serverless\s+deploy|eb\s+deploy)\b|^\S+\s+deploy\b/i, merge: git('merge') };
169
180
  const askedRe = { commit: /\bcommit/i, push: /\bpush/i, delete: /\b(?:delete|remove|rm\b|clean up|get rid)/i, install: /\binstall|\badd (?:the )?(?:package|dependency|dep)/i, create: /\b(?:create|make|add|write|new)\b/i, deploy: /\bdeploy|ship|release/i, merge: /\bmerge/i };
170
181
 
171
182
  // Judge every rule against one session. Returns per-rule { occasions:[{obeyed, session, turn, prompt, detail, at}] }.
@@ -230,10 +241,7 @@ export function adhere(opts = {}) {
230
241
  if (!files.length) throw new Error(`adhere: no CLAUDE.md found for ${project}\n Looked for CLAUDE.md, .claude/CLAUDE.md, CLAUDE.local.md there and ~/.claude/CLAUDE.md.\n Point at one with --claude-md FILE, or at the project with --project DIR.`);
231
242
  const rules = [], unchecked = [], fileRows = [];
232
243
  for (const f of files) { if (!fs.existsSync(f.file)) throw new Error(`adhere: ${f.file} not found`); const r = parseRules(readTextFile(f.file), f.label); rules.push(...r.rules); unchecked.push(...r.unchecked); fileRows.push({ file: f.file, label: f.label, rules: r.rules.length, unchecked: r.unchecked.length }); }
233
- // Sessions of this project: the folder name Claude Code would use; else a decoded-path substring match.
234
- const enc = encodeProject(project).toLowerCase();
235
- let list = findSessions({ home }).filter((s) => path.basename(s.projectDir).toLowerCase() === enc);
236
- if (!list.length) list = findSessions({ home, project: project.replace(/\\/g, '/') });
244
+ let list = projectSessions(project, home);
237
245
  if (opts.since != null) list = list.filter((s) => s.mtime >= opts.since);
238
246
  const perRule = rules.map(() => []); const sessions = [];
239
247
  for (const s of list) { const j = judgeSession(rules, s, project); j.perRule.forEach((o, i) => perRule[i].push(...o)); sessions.push(j.session); }
package/src/claims.mjs CHANGED
@@ -89,14 +89,14 @@ const numbersIn = (s) => Array.from(new Set((s.match(NUM_RE) || []).map((n) => n
89
89
  const SHELL = (c) => c.name === 'Bash' || c.name === 'PowerShell' || /device_bash$/.test(c.name || '');
90
90
  const cmdOf = (c) => { const i = c.input || {}; return typeof i.command === 'string' ? i.command : typeof i.script === 'string' ? i.script : ''; };
91
91
  const inputText = (c) => { try { return JSON.stringify(c.input || {}); } catch (e) { return ''; } };
92
- const TEST_CMD = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|e2e|audit|lint|build|typecheck)\b|\bnode\s+--test\b|\bnode\s+(?:[\w./-]*\/)?test\/[\w.-]+\.m?js\b|\bpytest\b|\bpython3?\s+-m\s+(?:pytest|unittest)\b|\bcargo\s+test\b|\bgo\s+test\b|\bvitest\b|\bjest\b|\bmocha\b|\bplaywright\s+test\b|\btsc\b|\beslint\b|\bruff\b|\bmake\s+(?:test|check)\b|\bclaude\s+plugin\s+validate\b|\bdotnet\s+test\b|\bphpunit\b|\brspec\b|\bphp\s+-l\b|\bnode\s+(?:--check|-c)\b|\bpython3?\s+-m\s+py_compile\b|\bflake8\b|\bpylint\b|\bmypy\b|\bshellcheck\b|\bprettier\s+--check\b|\bbiome\s+(?:check|lint)\b|\bpython3?\s+(?:[\w./-]*\/)?(?:test_[\w-]+|[\w-]+_test|tests?)\.py\b|\bnode\s+(?:[\w./-]*\/)?[\w-]*test[\w-]*\.[cm]?js\b/i;
92
+ export const TEST_CMD = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|e2e|audit|lint|build|typecheck)\b|\bnode\s+--test\b|\bnode\s+(?:[\w./-]*\/)?test\/[\w.-]+\.m?js\b|\bpytest\b|\bpython3?\s+-m\s+(?:pytest|unittest)\b|\bcargo\s+test\b|\bgo\s+test\b|\bvitest\b|\bjest\b|\bmocha\b|\bplaywright\s+test\b|\btsc\b|\beslint\b|\bruff\b|\bmake\s+(?:test|check)\b|\bclaude\s+plugin\s+validate\b|\bdotnet\s+test\b|\bphpunit\b|\brspec\b|\bphp\s+-l\b|\bnode\s+(?:--check|-c)\b|\bpython3?\s+-m\s+py_compile\b|\bflake8\b|\bpylint\b|\bmypy\b|\bshellcheck\b|\bprettier\s+--check\b|\bbiome\s+(?:check|lint)\b|\bpython3?\s+(?:[\w./-]*\/)?(?:test_[\w-]+|[\w-]+_test|tests?)\.py\b|\bnode\s+(?:[\w./-]*\/)?[\w-]*test[\w-]*\.[cm]?js\b/i;
93
93
  const CI_RESULT = /completed[:\s]+success|conclusion["']?\s*[:=]\s*["']?success|completed\s+successfully|\bci\s+#?\d+[^.\n]{0,40}\b(?:green|success)|"status":\s*"completed"[^}]{0,80}"conclusion":\s*"success"|publish\s+run[^\n]{0,40}success/i;
94
94
  const CI_FAIL = /conclusion["']?\s*[:=]\s*["']?(?:failure|cancelled)|completed[:\s]+failure|\bfailed\b[^\n]{0,20}\bci\b/i;
95
95
  const FAIL_MARK = /\bnot ok\b|# fail [1-9]|\b[1-9]\d* fail(?:ed|ing|ures?)?\b|\bFAIL(?:ED)?\b|AssertionError|Error:|\bTypeError|\bReferenceError|\bSyntaxError|Tests:\s+\d+\s+failed|✗|✖|\bexit(?:ed|\s*code)?\s*[=:]?\s*[1-9]\d*\b|Cannot find module|MODULE_NOT_FOUND/;
96
96
  // A test-like run's identity: the runner and file it names, so a failing e2e run does not contradict "58 of 58 pass"
97
97
  // said about the unit run beside it.
98
98
  const testKey = (c) => { const m = cmdOf(c).match(TEST_CMD); return m ? m[0].toLowerCase().replace(/\s+/g, ' ') : (c.name + ':' + (c.resultText || '').slice(0, 40)); };
99
- function runFailed(c) {
99
+ export function runFailed(c) {
100
100
  const r = c.resultText || ''; const k = testCounts(r);
101
101
  return !!(c.isError || (k ? k.fail > 0 : FAIL_MARK.test(r)) || (CI_FAIL.test(r) && !CI_RESULT.test(r)));
102
102
  }
@@ -114,7 +114,7 @@ const SOURCE_FILE = /(?:^|[\\/])(?:src|lib|test|tests|spec|specs|app|pkg|cmd|int
114
114
 
115
115
  // A result that reads like a test run whatever the command was: a summary line, PASS/FAIL rows, a lint's verdict.
116
116
  const looksLikeRun = (r) => { r = String(r || ''); return !!testCounts(r) || /audit passed|browser test passed|Validation passed|No syntax errors detected|^\s*(?:PASS|FAIL)\b.*\n\s*(?:PASS|FAIL)\b/m.test(r); };
117
- function testCounts(text) {
117
+ export function testCounts(text) {
118
118
  const t = String(text || '');
119
119
  let m;
120
120
  if ((m = t.match(/# pass (\d+)[\s\S]*?# fail (\d+)/))) return { pass: +m[1], fail: +m[2], total: +m[1] + +m[2] };
package/src/cli.mjs CHANGED
@@ -9,6 +9,8 @@ import { fileURLToPath } from 'node:url';
9
9
  import { readTextFile } from './textfile.mjs';
10
10
  import { guardResponse } from './guard.mjs';
11
11
  import { judgeClaims, claimFindings, claimsText, claimsMarkdown } from './claims.mjs';
12
+ import { NOTES_FILE, ensureMemoryDir, recordSession, memoryNote, noteTold, maskedTrace, recall, recallText, recallMarkdown, memoryReport, memoryText, memoryMarkdown, forget, clearMemory } from './memory.mjs';
13
+ import { maskSecrets } from './fence.mjs';
12
14
  const require = createRequire(import.meta.url);
13
15
  const core = require('./trace-core.js');
14
16
  // fileURLToPath, not URL.pathname: on Windows the latter gives "/C:/…" which path.resolve turns into "\C:\…".
@@ -186,7 +188,7 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
186
188
  const redact = !!opts.redact;
187
189
  const legend = opts.legend || null;
188
190
  const keyed = (p) => legend ? legend.keyFor(p) : p;
189
- const summary = { session: m.sessionId, title: m.title ? (redact ? '«' + m.title.length + ' chars»' : m.title) : null, model: m.models, wallMs: T.wallMs, activeMs: T.activeMs, turns: T.turns, requests: T.requests, toolCalls: T.toolCalls, toolErrors: T.toolErrors, orphans: T.orphans, usage: T.usage, cacheHitRatio: T.cacheHitRatio, cost: cost.reported != null ? cost.reported : cost.total, costSource: cost.source, start: m.start != null ? new Date(m.start).toISOString() : null, end: m.end != null ? new Date(m.end).toISOString() : null };
191
+ const summary = { session: m.sessionId, title: m.title ? (redact ? '«' + m.title.length + ' chars»' : m.title) : null, model: m.models, wallMs: T.wallMs, activeMs: T.activeMs, turns: T.turns, requests: T.requests, toolCalls: T.toolCalls, toolErrors: T.toolErrors, orphans: T.orphans, usage: T.usage, cacheHitRatio: T.cacheHitRatio, cost: cost.reported != null ? cost.reported : cost.total, costSource: cost.source, costLowerBound: !!cost.lowerBound, usagePartial: { responses: T.usagePartial.responses, of: T.usagePartial.of, outputSeen: T.usagePartial.outputSeen, versions: T.usagePartial.versions, estimatedMissingOutput: T.usagePartial.estimate ? T.usagePartial.estimate.missingOutput : null, estimatedMissingCost: cost.missing }, start: m.start != null ? new Date(m.start).toISOString() : null, end: m.end != null ? new Date(m.end).toISOString() : null };
190
192
  // Per-file use, keyed when a legend is given; with --redact and no legend the paths would leak, so it is omitted.
191
193
  if (legend || !redact) summary.files = core.fileStats(trace).map((r) => ({ key: legend ? legend.keyFor(r.path) : r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agents, chars: r.chars }));
192
194
  if (claims) summary.claims = claims.summary; // said vs did, in one line of numbers (additive; schema stays 2)
@@ -204,7 +206,7 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
204
206
  const lines = [];
205
207
  lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
206
208
  lines.push(` wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
207
- lines.push(` context served ${fi(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} tokens (${T.cacheHitRatio != null ? Math.round(T.cacheHitRatio * 100) + '% cached' : 'no usage'}) · output ${fi(T.usage.output)} (${fi(T.usage.thinking)} thinking) · est. cost ${usd(summary.cost)}`);
209
+ lines.push(` context served ${fi(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} tokens (${T.cacheHitRatio != null ? Math.round(T.cacheHitRatio * 100) + '% cached' : 'no usage'}) · output ${fi(T.usage.output)} (${fi(T.usage.thinking)} thinking) · est. cost ${summary.costLowerBound ? '≥ ' : ''}${usd(summary.cost)}${summary.costLowerBound ? ' (floor)' : ''}`);
208
210
  lines.push('');
209
211
  if (!findings.length) lines.push(' no findings');
210
212
  for (const g of collapseFindings(findings)) lines.push(' ' + findingLine(g));
@@ -228,23 +230,29 @@ export function sessionFromTranscriptPath(p) {
228
230
  return { id: path.basename(file).replace(/\.jsonl$/, ''), file, project: null, projectDir: path.dirname(file), size: fs.existsSync(file) ? fs.statSync(file).size : 0, mtime: fs.existsSync(file) ? fs.statSync(file).mtimeMs : 0 };
229
231
  }
230
232
 
231
- // Persisted feedback (--context): the Stop hook leaves <project>/.glassbox/last-session.md behind and
232
- // the SessionStart hook hands it to the next session, so feedback reaches the session that can use it.
233
- export const NOTES_FILE = path.join('.glassbox', 'last-session.md');
233
+ // Persisted feedback (--context): the Stop hook leaves <project>/.glassbox/last-session.md behind and folds the
234
+ // session into <project>/.glassbox/memory.json (src/memory.mjs); the SessionStart hook hands the next session
235
+ // the lessons still recurring, a line per recent session and the last session's notes, so feedback reaches the
236
+ // session that can use it. Anything a fence detector recognises as a secret is masked before it is written.
237
+ export { NOTES_FILE };
234
238
  const NOTES_MAX = 6000;
235
239
  function notesPath(cwd) { return cwd && fs.existsSync(cwd) && fs.statSync(cwd).isDirectory() ? path.join(cwd, NOTES_FILE) : null; }
236
240
  function writeNotes(cwd, text) {
237
241
  const file = notesPath(cwd); if (!file) return null;
238
- fs.mkdirSync(path.dirname(file), { recursive: true });
239
- const ignore = path.join(path.dirname(file), '.gitignore'); if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '# written by glassbox hook --context; local notes, never committed\n*\n');
240
- fs.writeFileSync(file, text.length > NOTES_MAX ? text.slice(0, NOTES_MAX) + '\n\n… (truncated; run `glassbox check --format md` for the full report)\n' : text);
242
+ ensureMemoryDir(cwd);
243
+ text = maskSecrets(text);
244
+ fs.writeFileSync(file, text.length > NOTES_MAX ? text.slice(0, NOTES_MAX) + '\n\n… (truncated; run `glassbox check --format md` for the full report)\n' : text, { mode: 0o600 });
245
+ try { fs.chmodSync(file, 0o600); } catch (e) { } // it quotes the session; a file left by 0.10 was 0644
241
246
  return file;
242
247
  }
243
248
  export function sessionStartResponse(input) {
244
- const file = notesPath(input && input.cwd);
245
- if (!file || !fs.existsSync(file) || (input.source && !['startup', 'clear', 'compact'].includes(input.source))) return { suppressOutput: true };
246
- const text = fs.readFileSync(file, 'utf8');
247
- return { suppressOutput: true, hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `Notes from Glassbox on the previous session in this project (.glassbox/last-session.md). Keep them in mind; don't redo that work.\n\n${text}` } };
249
+ const cwd = input && input.cwd;
250
+ if (!notesPath(cwd) || (input.source && !['startup', 'clear', 'compact'].includes(input.source))) return { suppressOutput: true };
251
+ const note = memoryNote(cwd, { exclude: input.session_id });
252
+ if (!note) return { suppressOutput: true };
253
+ // Told counts at the start only: compaction hands the same note over again mid-session. The count is a nicety; the note is the job.
254
+ if (input.source !== 'compact') { try { noteTold(cwd, input.session_id, note.told); } catch (e) { } }
255
+ return { suppressOutput: true, hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: note.text } };
248
256
  }
249
257
 
250
258
  // Findings already handed back in this session, keyed by rule + title, kept in the temp dir.
@@ -267,7 +275,7 @@ export function hookResponse(input, opts = {}) {
267
275
  // Second Stop after a feedback block: record what the agent said it would do differently, then let it stop.
268
276
  if (opts.context && input && input.hook_event_name === 'Stop' && input.stop_hook_active) {
269
277
  const file = notesPath(input.cwd);
270
- if (file && fs.existsSync(file) && input.last_assistant_message && fs.readFileSync(file, 'utf8').includes(`session ${String(input.session_id || '').slice(0, 8)}`)) fs.appendFileSync(file, `\n## What the agent said it would do differently\n\n${String(input.last_assistant_message).trim().slice(0, 1500)}\n`);
278
+ if (file && fs.existsSync(file) && input.last_assistant_message && fs.readFileSync(file, 'utf8').includes(`session ${String(input.session_id || '').slice(0, 8)}`)) fs.appendFileSync(file, `\n## What the agent said it would do differently\n\n${maskSecrets(String(input.last_assistant_message).trim()).slice(0, 1500)}\n`);
271
279
  return { suppressOutput: true };
272
280
  }
273
281
  if (!input || !input.transcript_path || !fs.existsSync(input.transcript_path)) return { systemMessage: 'Glassbox: no transcript_path in hook input.', suppressOutput: true };
@@ -286,9 +294,11 @@ export function hookResponse(input, opts = {}) {
286
294
  const file = notesPath(input.cwd);
287
295
  if (top.length) {
288
296
  const when = new Date().toISOString().slice(0, 16).replace('T', ' ');
289
- const body = core.reportMarkdown(res.trace, top.slice(0, 8), null, { maxEvidence: 2, instruction: false, title: 'last session' }).replace(/^# [^\n]*\n\n/, '').replace(/\n## Tools[\s\S]*?(?=\n_Generated)/, '\n').replace(/\n_Generated[^\n]*\n?$/, '');
297
+ const body = core.reportMarkdown(maskedTrace(res.trace), top.slice(0, 8).map((f) => Object.assign({}, f, { title: maskSecrets(f.title), detail: maskSecrets(f.detail) })), null, { maxEvidence: 2, instruction: false, title: 'last session' }).replace(/^# [^\n]*\n\n/, '').replace(/\n## Tools[\s\S]*?(?=\n_Generated)/, '\n').replace(/\n_Generated[^\n]*\n?$/, '');
290
298
  writeNotes(input.cwd, `# Glassbox: last session in this project\n\n${when} UTC · session ${String(input.session_id || s.id).slice(0, 8)} · ${head.replace(/^Glassbox · /, '')}\n\n${body.trim()}\n`);
291
299
  } else if (file && fs.existsSync(file)) fs.unlinkSync(file); // a clean session: don't keep old advice around
300
+ // The memory ledger: this session's digest and lessons. A failure here must not cost the summary.
301
+ try { recordSession(input.cwd, res, { sessionId: input.session_id || s.id, failOn }); } catch (e) { out.systemMessage += `\nGlassbox memory: ${e.message}`; }
292
302
  }
293
303
  // Feedback loop: only on Stop, never while continuing from our own block (stop_hook_active), and each
294
304
  // finding only once per session. stop_hook_active resets every turn, so without the delivered set a
@@ -351,7 +361,7 @@ export function parseArgs(argv) {
351
361
  const args = { _: [], flags: {} };
352
362
  for (let i = 0; i < argv.length; i++) {
353
363
  const a = argv[i];
354
- if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under', 'key'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
364
+ if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under', 'key', 'limit'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
355
365
  else args._.push(a);
356
366
  }
357
367
  return args;
@@ -382,7 +392,8 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
382
392
  glassbox hook install [--feedback] [--context] [--guard] [--fail-on warn]
383
393
  add a Claude Code Stop hook so every session ends with a Glassbox summary;
384
394
  --feedback also hands the findings back to the agent once, so it can learn from them;
385
- --context keeps them in <project>/.glassbox/last-session.md and gives them to the next session;
395
+ --context keeps a memory in <project>/.glassbox/ (lessons that keep coming back, a digest
396
+ per session, the last session's notes) and gives the next session there what it needs;
386
397
  --guard blocks a tool call that already failed twice in a row, unchanged, and says why
387
398
  glassbox hook uninstall remove it (a .glassbox-backup of settings.json is kept)
388
399
  glassbox collect DIR [--since 30d] [--format text|md|json] [--out FILE] [--top N]
@@ -403,6 +414,16 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
403
414
  credential-file reads — with a masked preview and a fingerprint, never the value.
404
415
  no target = every session under the home; exit 1 when anything at/above --fail-on was found
405
416
  --shred overwrites each value in place with [FENCED:<rule>:<fingerprint>] (no backup)
417
+ glassbox recall [WORDS…] [--project DIR] [--since 30d] [--limit N] [--format text|md|json] [--out FILE]
418
+ search this project's past sessions: what was asked, written, run, committed and said,
419
+ from the transcripts still on disk and .glassbox/memory.json; no words = the latest ones
420
+ glassbox memory [--project DIR] [--format text|md|json]
421
+ what the next session in this project will be told (hook --context) and the lessons behind it
422
+ glassbox memory forget KEY|RULE · glassbox memory clear
423
+ drop a lesson (it starts over if it fires again) · delete the project's memory
424
+ glassbox memory audit [--project DIR] [--format text|md|json] [--out FILE] [--fail-on error|warn|info|none]
425
+ everything loaded before the first prompt (CLAUDE.md files, rules, @imports, auto memory,
426
+ Glassbox's own note) with its token cost, dead imports and paths, duplicates and conflicting rules
406
427
  glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
407
428
 
408
429
  Options --home DIR use DIR instead of ~/.claude (or set GLASSBOX_HOME)
@@ -602,6 +623,41 @@ export async function main(argv, io = {}) {
602
623
  else out(text.replace(/\n$/, ''));
603
624
  return failUnder != null && rep.summary.rate != null && rep.summary.rate * 100 < failUnder ? 1 : 0;
604
625
  }
626
+ const writeOut = (text, summary) => { if (args.flags.out) { const f = path.resolve(args.flags.out); fs.writeFileSync(f, text); out(`${f} (${summary})`); } else out(text.replace(/\n$/, '')); };
627
+ if (cmd === 'recall') {
628
+ // Search a project's past sessions. Each argument is a word, or a phrase when the shell kept it quoted.
629
+ const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
630
+ let limit = 10; if (args.flags.limit !== undefined) { limit = Number(args.flags.limit); if (!Number.isInteger(limit) || limit < 1) throw new Error(`--limit must be a whole number above 0 (got "${args.flags.limit}")`); }
631
+ const query = args._.slice(1).map((a) => /\s/.test(a) ? `"${a.replace(/"/g, '')}"` : a).join(' ');
632
+ const fmtOut = outputFormat(args.flags);
633
+ const rep = recall({ home, project: args.flags.project, query, since, limit });
634
+ writeOut(fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? recallMarkdown(rep) : recallText(rep), `${rep.total} of ${rep.searched.sessions} sessions`);
635
+ return 0;
636
+ }
637
+ if (cmd === 'memory') {
638
+ const sub = args._[1] || 'show';
639
+ const project = path.resolve(args.flags.project || process.cwd());
640
+ const fmtOut = outputFormat(args.flags);
641
+ if (sub === 'audit') {
642
+ const { auditMemory, auditText, auditMarkdown, auditFailed } = await import('./memory-audit.mjs');
643
+ const failOn = String(args.flags['fail-on'] || 'error');
644
+ const rep = auditMemory({ home, project });
645
+ const failed = auditFailed(rep, failOn);
646
+ writeOut(fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? auditMarkdown(rep) : auditText(rep), `${rep.summary.startupFiles} files at startup, ≈ ${core.fmtInt(rep.summary.startupTokens)} tokens, ${rep.findings.length} findings`);
647
+ return failed ? 1 : 0;
648
+ }
649
+ if (sub === 'forget') {
650
+ const what = args._.slice(2).join(' ');
651
+ const keys = forget(project, what);
652
+ out(keys.length ? `Forgot ${keys.length} lesson${keys.length === 1 ? '' : 's'} in ${project}:\n ${keys.join('\n ')}` : `No lesson matches "${what}" in ${project} — glassbox memory lists them`);
653
+ return keys.length ? 0 : 1;
654
+ }
655
+ if (sub === 'clear') { const removed = clearMemory(project); out(removed.length ? `Removed:\n ${removed.join('\n ')}` : `No Glassbox memory in ${project}`); return 0; }
656
+ if (sub !== 'show') throw new Error(`memory: unknown subcommand "${sub}" (show, forget, clear, audit)`);
657
+ const rep = memoryReport({ project });
658
+ writeOut(fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? memoryMarkdown(rep) : memoryText(rep), `${rep.sessions} sessions, ${rep.summary.active} active lessons`);
659
+ return 0;
660
+ }
605
661
  err('Unknown command: ' + cmd + '\n'); out(HELP); return 2;
606
662
  } catch (e) { err('glassbox: ' + e.message); return 2; }
607
663
  }
package/src/collect.mjs CHANGED
@@ -101,6 +101,9 @@ export function collect(sources, opts = {}) {
101
101
  findings: sum(sessions, (s) => s.findings.length), errors: sum(sessions, (s) => s.findings.filter((f) => f.severity === 'error').length), warns: sum(sessions, (s) => s.findings.filter((f) => f.severity === 'warn').length),
102
102
  failed: sessions.filter((s) => s.failed).length,
103
103
  estimated: sessions.some((s) => s.summary.costSource !== 'reported'),
104
+ // Sessions whose transcript cost is a floor (subagent responses with no final usage, anthropics/claude-code#93620).
105
+ lowerBoundSessions: sessions.filter((s) => s.summary.costLowerBound).length,
106
+ partialResponses: sum(sessions, (s) => s.summary.usagePartial && s.summary.usagePartial.responses),
104
107
  };
105
108
  const top = topN(opts.top || 5).map((s) => ({ source: s.source, session: s.summary.session, cost: s.summary.cost || 0, share: cost ? (s.summary.cost || 0) / cost : 0, wallMs: s.summary.wallMs, turns: s.summary.turns, requests: s.summary.requests, findings: s.findings.length, worst: s.findings.slice().sort((a, b) => SEV[a.severity] - SEV[b.severity])[0] || null, model: s.summary.model }));
106
109
  return {
@@ -121,7 +124,8 @@ export function collectMarkdown(r) {
121
124
  L.push(`_${T.sources} source${T.sources === 1 ? '' : 's'} · ${T.sessions} session${T.sessions === 1 ? '' : 's'}${r.since ? ` since ${r.since.slice(0, 10)}` : ''} · generated ${r.generated.slice(0, 16).replace('T', ' ')} UTC by Glassbox ${r.glassbox}. Counts, keys and findings only — no session text._`, '');
122
125
  L.push(`## Totals`, '');
123
126
  L.push(`| | |`, `|---|---|`);
124
- L.push(`| Cost${T.estimated ? ' (estimated from the rate card)' : ''} | **${usd(T.cost)}** |`);
127
+ L.push(`| Cost${T.estimated ? ' (estimated from the rate card)' : ''} | ${T.lowerBoundSessions ? 'at least ' : ''}**${usd(T.cost)}** |`);
128
+ if (T.lowerBoundSessions) L.push(`| Cost floor | ${T.lowerBoundSessions} session${T.lowerBoundSessions === 1 ? '' : 's'} a floor — ${fi(T.partialResponses)} subagent response${T.partialResponses === 1 ? '' : 's'} never recorded final usage (anthropics/claude-code#93620) |`);
125
129
  L.push(`| Wall / active time | ${fmt(T.wallMs)} / ${fmt(T.activeMs)} |`);
126
130
  L.push(`| Requests / tool calls / tool errors | ${fi(T.requests)} / ${fi(T.toolCalls)} / ${fi(T.toolErrors)} |`);
127
131
  L.push(`| Findings (error / warn) | ${fi(T.findings)} (${fi(T.errors)} / ${fi(T.warns)}) |`);
@@ -159,7 +163,7 @@ export function collectText(r) {
159
163
  const T = r.totals, fmt = core.fmtDur, fi = core.fmtInt;
160
164
  const L = [];
161
165
  L.push(`Glassbox fleet · ${T.sources} source${T.sources === 1 ? '' : 's'} · ${T.sessions} session${T.sessions === 1 ? '' : 's'}${r.since ? ` since ${r.since.slice(0, 10)}` : ''}`);
162
- L.push(` cost ${usd(T.cost)}${T.estimated ? ' (est.)' : ''} · wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${fi(T.requests)} requests · ${fi(T.toolCalls)} tool calls (${fi(T.toolErrors)} failed)`);
166
+ L.push(` cost ${T.lowerBoundSessions ? '≥ ' : ''}${usd(T.cost)}${T.estimated ? ' (est.)' : ''}${T.lowerBoundSessions ? ` (${T.lowerBoundSessions} session${T.lowerBoundSessions === 1 ? '' : 's'} a floor)` : ''} · wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${fi(T.requests)} requests · ${fi(T.toolCalls)} tool calls (${fi(T.toolErrors)} failed)`);
163
167
  L.push(` ${fi(T.findings)} findings (${fi(T.errors)} error, ${fi(T.warns)} warn) · ${fi(T.failed)} session${T.failed === 1 ? '' : 's'} failing`);
164
168
  if (T.sessions) L.push(` ${r.concentration.halfOfSpendSessions} session${r.concentration.halfOfSpendSessions === 1 ? '' : 's'} = half the spend; top ${r.concentration.top.length} = ${pct(r.concentration.topShare)}`);
165
169
  L.push('');
package/src/fence.mjs CHANGED
@@ -167,6 +167,25 @@ export function scanText(text, opts = {}) {
167
167
  return hits.concat(fileReads).sort((a, b) => a.start - b.start || SEV[a.severity] - SEV[b.severity]);
168
168
  }
169
169
 
170
+ // Text Glassbox keeps for later (memory notes, session digests): every value a detector recognises becomes
171
+ // [FENCED:<rule>], with no fingerprint — the note is for the next session to read, not for matching keys.
172
+ export function maskSecrets(text) {
173
+ const s = String(text == null ? '' : text); const spans = [];
174
+ for (const d of DETECTORS) {
175
+ d.re.lastIndex = 0; let m;
176
+ while ((m = d.re.exec(s))) {
177
+ const value = d.value ? d.value(m) : m[0];
178
+ const start = d.value ? m.index + m[0].length - value.length : m.index, end = start + value.length;
179
+ if ((d.ok && !d.ok(value)) || spans.some(([a, b]) => start < b && end > a)) continue;
180
+ spans.push([start, end, d.id]);
181
+ }
182
+ }
183
+ if (!spans.length) return s;
184
+ spans.sort((a, b) => a[0] - b[0]);
185
+ let out = '', at = 0; for (const [a, b, id] of spans) { out += s.slice(at, a) + `[FENCED:${id}]`; at = b; }
186
+ return out + s.slice(at);
187
+ }
188
+
170
189
  // Same value in the same place collapses to one finding with a count. Values are kept on the object (for shred)
171
190
  // but never serialised: `toJSON` drops them.
172
191
  function rows(hits) {