glassbox-trace 0.10.1 → 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 |
@@ -247,7 +280,7 @@ console.log(compare({ trace, findings, cost }, other)); // other = the same
247
280
  Your context is private — by construction, not by promise.
248
281
 
249
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.
250
- - **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.
251
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»`.
252
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.
253
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.1';
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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "glassbox-trace",
3
- "version": "0.10.1",
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:\…".
@@ -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/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) {
@@ -0,0 +1,236 @@
1
+ // glassbox memory audit — everything Claude Code loads into a session before the first prompt, and what it costs.
2
+ //
3
+ // CLAUDE.md files (managed policy, the user's, every directory from the root down to the project, the project's
4
+ // .claude/CLAUDE.md and CLAUDE.local.md), unscoped .claude/rules/*.md, the files they @import (up to four hops),
5
+ // the head of the auto-memory MEMORY.md and Glassbox's own note are loaded at the start of every session, and
6
+ // nobody prunes them. The audit lists them with an estimated token cost, then checks what a mechanical reader
7
+ // can: imports that load nothing, references to files and package scripts that don't exist, the same line in two
8
+ // places, rules that contradict each other, files longer than Claude Code's docs recommend, and MEMORY.md lines
9
+ // past the cut-off that never load at all. It reads files only: no transcript, no network. What Claude Code loads
10
+ // is taken from its docs (code.claude.com/docs/en/memory) as of 2026-10; `/context` in a session is the ground truth.
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { createRequire } from 'node:module';
15
+ import { claudeHome } from './cli.mjs';
16
+ import { parseRules } from './adhere.mjs';
17
+ import { memoryNote, MEMORY_FILE, NOTES_FILE } from './memory.mjs';
18
+ import { readTextFile } from './textfile.mjs';
19
+ const require = createRequire(import.meta.url);
20
+ const core = require('./trace-core.js');
21
+
22
+ const SEV = { error: 0, warn: 1, info: 2 };
23
+ // Claude Code's numbers (docs, 2026-10): "target under 200 lines per CLAUDE.md file"; the first 200 lines or 25 KB of
24
+ // MEMORY.md load at startup; a CLAUDE.md over 4 MiB is skipped; imports nest at most four hops.
25
+ export const AUDIT_LIMITS = { lines: 200, memoryLines: 200, memoryBytes: 25 * 1024, maxBytes: 4 * 1048576, importHops: 4, duplicateMin: 24 };
26
+ export const managedPath = (platform = process.platform) => platform === 'darwin' ? '/Library/Application Support/ClaudeCode/CLAUDE.md' : platform === 'win32' ? 'C:\\Program Files\\ClaudeCode\\CLAUDE.md' : '/etc/claude-code/CLAUDE.md';
27
+ const tokensOf = (chars) => Math.round(chars / 4);
28
+ const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch (e) { return false; } };
29
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch (e) { return false; } };
30
+ const lineCount = (t) => t ? String(t).replace(/\r?\n$/, '').split(/\r?\n/).length : 0; // a trailing newline is not a line
31
+
32
+ // Lines outside fenced code, with their 1-based numbers; `code` marks the lines inside fences.
33
+ function lines(text) {
34
+ const out = []; let fence = null;
35
+ String(text).split(/\r?\n/).forEach((raw, i) => {
36
+ const m = raw.match(/^\s*(```+|~~~+)/);
37
+ if (m) { if (!fence) fence = m[1][0]; else if (m[1][0] === fence) fence = null; out.push({ n: i + 1, raw, code: true }); return; }
38
+ out.push({ n: i + 1, raw, code: !!fence });
39
+ });
40
+ return out;
41
+ }
42
+ // What actually reaches the model: block-level HTML comments are stripped before injection (code blocks keep theirs).
43
+ // `blank` keeps their lines, empty, so the checks still report the right line numbers.
44
+ function loadedText(text, blank) { let inComment = false; const keep = (l) => { if (l.code) return true; const t = l.raw.trim(); if (inComment) { if (t.includes('-->')) inComment = false; return false; } if (/^<!--/.test(t)) { if (!t.includes('-->')) inComment = true; return false; } return true; }; return lines(text).map((l) => keep(l) ? l.raw : blank ? '' : null).filter((x) => x !== null).join('\n'); }
45
+ const withoutSpans = (s) => s.replace(/`[^`]*`/g, (m) => ' '.repeat(m.length));
46
+
47
+ // `@path` imports outside code spans and fences. Spaces in a path are escaped with a backslash. An npm scope
48
+ // (`@anthropic-ai/sdk`) is not a path; a bare name (`@README`, the docs' own example, or `@aldo`) is `bare`: an import
49
+ // when it names a file, a mention when it doesn't.
50
+ export function importsIn(text) {
51
+ const out = [];
52
+ for (const l of lines(text)) {
53
+ if (l.code) continue;
54
+ const re = /(^|\s)@((?:\\ |[^\s`])+)/g; let m; const s = withoutSpans(l.raw);
55
+ while ((m = re.exec(s))) {
56
+ const p = m[2].replace(/\\ /g, ' ').replace(/[.,;:!?)\]]+$/, '');
57
+ if (!p || /^[a-z0-9-~][\w.-]*\/[\w.-]+$/.test(p) && !/\.[A-Za-z0-9]{1,8}$/.test(p)) continue;
58
+ out.push(/[/\\]|\.[A-Za-z0-9]{1,8}$/.test(p) ? { line: l.n, path: p } : { line: l.n, path: p, bare: true });
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Backticked references that read as paths in this project: a folder and a known extension (`src/lib/utils.ts`), an
65
+ // explicit `./`, `../` or `~/`, or a folder with a trailing slash (`docs/`). Bare names (`utils.ts`, `Alpine.js`) may
66
+ // live anywhere; `origin/main`, `application/json`, `next/navigation`, `owner/repo` and `/api/health` are not files;
67
+ // domains, URLs, placeholders, globs, other platforms' absolute paths and build output folders are left alone.
68
+ const EXT = /\.(?:md|mdx|txt|json|jsonc|ya?ml|toml|ini|cfg|conf|lock|xml|csv|sql|html?|css|scss|less|[cm]?[jt]sx?|py|rb|go|rs|java|kts?|cs|php|swift|c|cc|cpp|h|hpp|sh|bash|zsh|ps1|bat|cmd|gradle|tf|proto|graphql|vue|svelte)$/i;
69
+ const GENERATED = /^(?:\.\/)?(?:node_modules|dist|build|out|target|coverage|vendor|\.next|\.venv|venv|__pycache__)(?:\/|$)/;
70
+ export function pathRefs(text, platform = process.platform) {
71
+ const out = [];
72
+ for (const l of lines(text)) {
73
+ if (l.code) continue;
74
+ for (const m of l.raw.matchAll(/`([^`\s]+)`/g)) {
75
+ const p = m[1].replace(/[.,;:]+$/, '');
76
+ if (/:\/\/|^https?:|[<>{}$*?[\]%|="'(),]/.test(p) || /^-/.test(p) || /^@/.test(p)) continue;
77
+ if (/^[A-Za-z]:[\\/]/.test(p) ? platform !== 'win32' : /\\/.test(p)) continue;
78
+ const first = p.replace(/^~?\/?/, '').split('/')[0];
79
+ if (/^[\w-]+(?:\.[\w-]+)+$/.test(first) && p.includes('/') && !EXT.test(first)) continue; // github.com/x, blueprintau.com/foreman/
80
+ if (GENERATED.test(p.replace(/^~\//, '')) || !p.includes('/') || !/^[\w.~-]*\/?[\w./-]*$/.test(p)) continue;
81
+ if (!(/^(?:~|\.{1,2})\//.test(p) || /\/$/.test(p) || EXT.test(p))) continue;
82
+ out.push({ line: l.n, path: p });
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // Package scripts named in a project's instructions: `npm run X`, `pnpm run X`, `yarn run X`, `bun run X`, `npm test`, `npm start`.
89
+ export function scriptRefs(text) {
90
+ const out = [];
91
+ String(text).split(/\r?\n/).forEach((raw, i) => {
92
+ for (const m of raw.matchAll(/\b(?:npm|pnpm|yarn|bun)\s+run\s+([\w:.-]+)|\bnpm\s+(test|start)\b/g)) out.push({ line: i + 1, script: m[1] || m[2] });
93
+ });
94
+ return out;
95
+ }
96
+
97
+ // Rules files: unscoped ones load at startup; `paths:` frontmatter makes one load only when a matching file is used.
98
+ function rulesIn(dir) {
99
+ const out = [];
100
+ const walk = (d) => { let es = []; try { es = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return; } for (const e of es.sort((a, b) => a.name.localeCompare(b.name))) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (/\.md$/i.test(e.name)) out.push(p); } };
101
+ if (isDir(dir)) walk(dir);
102
+ return out.map((file) => { const t = readTextFile(file); const fm = t.match(/^---\r?\n([\s\S]*?)\r?\n---/); return { file, scoped: !!(fm && /^paths\s*:/m.test(fm[1])) }; });
103
+ }
104
+
105
+ const encodings = (p) => Array.from(new Set([String(p).replace(/[^A-Za-z0-9]/g, '-'), String(p).replace(/[\\/:]/g, '-')]));
106
+ // The repository auto memory is keyed by: a worktree's `.git` is a file pointing into the main repository's
107
+ // .git/worktrees/, and worktrees share the main repository's memory.
108
+ function gitRoot(dir) {
109
+ for (let d = path.resolve(dir); ; d = path.dirname(d)) {
110
+ const g = path.join(d, '.git');
111
+ if (isFile(g)) { const m = readTextFile(g).match(/^gitdir:\s*(.+?)\s*$/m); const gd = m ? path.resolve(d, m[1]).replace(/\\/g, '/') : ''; const i = gd.lastIndexOf('/.git/worktrees/'); return i > -1 ? path.resolve(gd.slice(0, i)) : d; }
112
+ if (fs.existsSync(g)) return d;
113
+ if (path.dirname(d) === d) return null;
114
+ }
115
+ }
116
+ function settingsOf(home) { try { return JSON.parse(readTextFile(path.join(home, 'settings.json'))) || {}; } catch (e) { return {}; } }
117
+
118
+ export function auditMemory(opts = {}) {
119
+ const project = path.resolve(opts.project || process.cwd());
120
+ const home = opts.home || claudeHome();
121
+ const userHome = opts.userHome || os.homedir();
122
+ const L = Object.assign({}, AUDIT_LIMITS, opts.limits);
123
+ const show = (p) => { const f = path.resolve(p); const r = path.relative(project, f); if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r.replace(/\\/g, '/'); const h = path.relative(userHome, f); return h && !h.startsWith('..') && !path.isAbsolute(h) ? '~/' + h.replace(/\\/g, '/') : f; };
124
+ const files = [], findings = [];
125
+ const seen = new Set();
126
+ const add = (file, scope, label, extra = {}) => {
127
+ const key = path.resolve(file).toLowerCase(); if (seen.has(key) || !isFile(file)) return null; seen.add(key);
128
+ const bytes = fs.statSync(file).size;
129
+ const text = bytes > L.maxBytes ? '' : readTextFile(file);
130
+ const row = Object.assign({ file: path.resolve(file), show: show(file), scope, label, loaded: 'startup', bytes, lines: text ? lineCount(text) : null }, extra, { text });
131
+ if (bytes > L.maxBytes) { row.loaded = 'skipped'; findings.push({ id: 'too-large', severity: 'error', file: row.show, line: null, title: `${row.show} is ${(bytes / 1048576).toFixed(1)} MiB`, detail: 'Claude Code skips a CLAUDE.md over 4 MiB entirely: none of it loads.' }); }
132
+ const loaded = row.loaded === 'startup' ? loadedText(row.limitTo ? row.limitTo(text) : text) : '';
133
+ row.chars = loaded.length; row.tokens = tokensOf(loaded.length);
134
+ files.push(row); return row;
135
+ };
136
+ // 1. What loads at launch, in Claude Code's order: managed, user, the directories above the project, the project.
137
+ if (opts.managed !== false) add(opts.managed || managedPath(), 'managed', 'managed policy');
138
+ add(path.join(home, 'CLAUDE.md'), 'user', 'user (~/.claude/CLAUDE.md)');
139
+ for (const r of rulesIn(path.join(home, 'rules'))) add(r.file, 'user', 'user rule', r.scoped ? { loaded: 'on demand', why: 'paths: frontmatter' } : {});
140
+ const ancestors = []; for (let d = path.dirname(project); ; d = path.dirname(d)) { ancestors.unshift(d); if (path.dirname(d) === d) break; }
141
+ if (opts.ancestors !== false) for (const d of ancestors) { add(path.join(d, 'CLAUDE.md'), 'parent', 'parent directory'); add(path.join(d, 'CLAUDE.local.md'), 'parent', 'parent directory (local)'); }
142
+ add(path.join(project, 'CLAUDE.md'), 'project', 'project');
143
+ add(path.join(project, '.claude', 'CLAUDE.md'), 'project', 'project');
144
+ add(path.join(project, 'CLAUDE.local.md'), 'local', 'local (CLAUDE.local.md)');
145
+ if (!files.some((f) => /^(?:project|local|parent)$/.test(f.scope))) add(path.join(project, 'AGENTS.md'), 'project', 'AGENTS.md (read when there is no CLAUDE.md)');
146
+ for (const r of rulesIn(path.join(project, '.claude', 'rules'))) add(r.file, 'project', 'project rule', r.scoped ? { loaded: 'on demand', why: 'paths: frontmatter' } : {});
147
+ // 2. @imports, relative to the importing file, up to four hops; they load at launch with the file that imports them.
148
+ const queue = files.filter((f) => f.loaded === 'startup').map((f) => ({ row: f, hop: 0 }));
149
+ while (queue.length) {
150
+ const { row, hop } = queue.shift();
151
+ for (const im of importsIn(loadedText(row.text, true))) {
152
+ const target = im.path.startsWith('~/') ? path.join(userHome, im.path.slice(2)) : path.isAbsolute(im.path) ? im.path : path.resolve(path.dirname(row.file), im.path);
153
+ if (im.bare && !isFile(target)) continue; // @aldo: a mention, not an import
154
+ if (!isFile(target)) { findings.push({ id: 'dead-import', severity: 'warn', file: row.show, line: im.line, title: `@${im.path} in ${row.show}:${im.line} imports nothing`, detail: `No file at ${show(target)}. Claude Code skips a missing import without saying so; fix the path or drop the line.` }); continue; }
155
+ if (hop + 1 > L.importHops) { findings.push({ id: 'deep-import', severity: 'info', file: row.show, line: im.line, title: `@${im.path} in ${row.show}:${im.line} is ${hop + 1} imports deep`, detail: `Claude Code follows imports ${L.importHops} hops deep; this one is not loaded.` }); continue; }
156
+ const r = add(target, 'import', `@import from ${row.show}:${im.line}`);
157
+ if (r) queue.push({ row: r, hop: hop + 1 });
158
+ }
159
+ }
160
+ // 3. Auto memory: the first 200 lines or 25 KB of MEMORY.md at launch; topic files when Claude reads them.
161
+ const settings = settingsOf(home);
162
+ const autoOff = settings.autoMemoryEnabled === false;
163
+ const memDirs = opts.autoMemoryDir ? [opts.autoMemoryDir] : settings.autoMemoryDirectory ? [String(settings.autoMemoryDirectory).replace(/^~(?=\/|\\|$)/, userHome)] : encodings(gitRoot(project) || project).map((e) => path.join(home, 'projects', e, 'memory'));
164
+ const memDir = memDirs.find(isDir);
165
+ if (memDir && !autoOff) {
166
+ const cut = (t) => { let out = t.split(/\r?\n/).slice(0, L.memoryLines).join('\n'); if (Buffer.byteLength(out) > L.memoryBytes) out = Buffer.from(out).subarray(0, L.memoryBytes).toString('utf8'); return out; };
167
+ const idx = add(path.join(memDir, 'MEMORY.md'), 'auto-memory', 'auto memory index (first 200 lines / 25 KB)', { limitTo: cut });
168
+ if (idx && idx.text) {
169
+ const total = lineCount(idx.text), kept = lineCount(cut(idx.text));
170
+ if (total > kept || Buffer.byteLength(idx.text) > L.memoryBytes) findings.push({ id: 'memory-cut', severity: 'warn', file: idx.show, line: kept + 1, title: `MEMORY.md: ${total - kept} line${total - kept === 1 ? '' : 's'} past the cut never load`, detail: `Only the first ${L.memoryLines} lines or ${L.memoryBytes / 1024} KB of MEMORY.md load at the start of a session; line ${kept + 1} onwards is invisible unless Claude goes looking. Move what matters up, or into topic files the index points to.` });
171
+ }
172
+ for (const e of fs.readdirSync(memDir).filter((n) => /\.md$/i.test(n) && n !== 'MEMORY.md').sort()) add(path.join(memDir, e), 'auto-memory', 'auto memory topic', { loaded: 'on demand', why: 'read when relevant' });
173
+ }
174
+ // 4. Glassbox's own note, when the hook keeps one (--context).
175
+ if (fs.existsSync(path.join(project, MEMORY_FILE)) || fs.existsSync(path.join(project, NOTES_FILE))) {
176
+ const note = memoryNote(project);
177
+ if (note) { const chars = note.text.length; files.push({ file: path.join(project, '.glassbox'), show: '.glassbox/ (Glassbox note)', scope: 'glassbox', label: 'SessionStart hook, with --context', loaded: 'startup', bytes: chars, lines: lineCount(note.text), chars, tokens: tokensOf(chars), text: '' }); }
178
+ }
179
+
180
+ // Checks on what loads (and what would load on demand: a dead reference is dead either way).
181
+ const projectScoped = (f) => ['project', 'local', 'import'].includes(f.scope) && !f.show.startsWith('/') && !f.show.startsWith('~/');
182
+ let pkg = null; try { pkg = JSON.parse(readTextFile(path.join(project, 'package.json'))); } catch (e) { }
183
+ const texts = files.filter((f) => f.text);
184
+ // Checks read what loads: block-level HTML comments blanked, so a commented-out line is neither dead, doubled nor in conflict.
185
+ for (const f of texts) f.body = loadedText(f.text, true);
186
+ for (const f of texts) {
187
+ if (f.scope !== 'auto-memory' && f.lines > L.lines) findings.push({ id: 'long-file', severity: 'info', file: f.show, line: null, title: `${f.show}: ${f.lines} lines`, detail: `Claude Code's docs suggest keeping each CLAUDE.md under ${L.lines} lines: every line is loaded into every session${f.loaded === 'startup' ? ` (≈ ${core.fmtInt(f.tokens)} tokens here)` : ''}, and long files are followed less reliably. Split by topic into .claude/rules/ with paths: frontmatter, or trim.` });
188
+ for (const ref of pathRefs(f.body)) {
189
+ const p = ref.path, rel = !p.startsWith('~/') && !path.isAbsolute(p);
190
+ if (rel && !projectScoped(f)) continue; // a user-level file's relative paths are relative to whichever project is open
191
+ const cands = p.startsWith('~/') ? [path.join(userHome, p.slice(2))] : path.isAbsolute(p) ? [p] : [path.resolve(path.dirname(f.file), p), path.resolve(project, p)];
192
+ if (!cands.some((c) => fs.existsSync(c))) findings.push({ id: 'dead-path', severity: 'info', file: f.show, line: ref.line, title: `\`${p}\` in ${f.show}:${ref.line} does not exist`, detail: 'An instruction that names a file which is not there sends the agent looking for it, or makes it trust a layout that has moved. Fix the path or drop the line.' });
193
+ }
194
+ if (pkg && projectScoped(f)) for (const s of scriptRefs(f.body)) if (!(pkg.scripts && Object.prototype.hasOwnProperty.call(pkg.scripts, s.script))) findings.push({ id: 'dead-script', severity: 'info', file: f.show, line: s.line, title: `${f.show}:${s.line} runs \`${s.script}\`, which package.json does not define`, detail: `package.json has ${pkg.scripts ? Object.keys(pkg.scripts).length : 0} scripts and no "${s.script}". The agent will run a command that fails.` });
195
+ }
196
+ // The same line twice costs twice and says nothing new; one copy usually goes stale first.
197
+ const norm = (s) => s.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').replace(/\*\*|__|`/g, '').replace(/[.!;:,]+\s*$/, '').replace(/\s+/g, ' ').trim().toLowerCase();
198
+ const groups = new Map();
199
+ for (const f of texts.filter((x) => x.loaded === 'startup')) for (const l of lines(f.body)) { if (l.code || /^\s*(?:#|<!--|\||---)/.test(l.raw)) continue; const k = norm(l.raw); if (k.length < L.duplicateMin) continue; if (!groups.has(k)) groups.set(k, []); groups.get(k).push({ file: f.show, line: l.n, text: l.raw.trim() }); }
200
+ for (const [, at] of groups) if (at.length > 1) findings.push({ id: 'duplicate', severity: 'info', file: at[0].file, line: at[0].line, title: `The same line ${at.length} times: "${at[0].text.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').slice(0, 80)}"`, detail: `At ${at.map((a) => `${a.file}:${a.line}`).join(', ')}. Keep one; the copies are loaded every session and one of them goes stale first.`, locations: at.map((a) => `${a.file}:${a.line}`) });
201
+ // Rules that pull opposite ways, from the same parser `adhere` uses.
202
+ const rules = texts.filter((x) => x.loaded === 'startup').flatMap((f) => parseRules(f.body, f.show).rules);
203
+ const reported = new Set();
204
+ for (const a of rules) for (const b of rules) {
205
+ if (a === b) continue;
206
+ const clash = (a.kind === 'prefer-tool' && b.kind === 'prefer-tool' && a.prefer && (b.avoid || []).includes(a.prefer)) || (a.kind === 'never-run' && (b.kind === 'run-before' || b.kind === 'run-after') && String(b.what).toLowerCase() === String(a.pattern).toLowerCase());
207
+ const k = [a.id, b.id].sort().join('|'); if (!clash || reported.has(k)) continue; reported.add(k);
208
+ findings.push({ id: 'conflict', severity: 'warn', file: a.file, line: a.line, title: `${a.file}:${a.line} and ${b.file}:${b.line} pull opposite ways`, detail: `"${a.text}" vs "${b.text}". The agent gets both on every session and can only follow one; delete the one that is no longer true.`, locations: [`${a.file}:${a.line}`, `${b.file}:${b.line}`] });
209
+ }
210
+ findings.sort((x, y) => SEV[x.severity] - SEV[y.severity] || String(x.file).localeCompare(String(y.file)) || (x.line || 0) - (y.line || 0));
211
+ const startup = files.filter((f) => f.loaded === 'startup');
212
+ const count = (sev) => findings.filter((f) => f.severity === sev).length;
213
+ return { glassbox: core.VERSION, kind: 'memory-audit', schema: 1, project, generated: new Date().toISOString(), autoMemory: memDir ? { dir: memDir, enabled: !autoOff } : null,
214
+ files: files.map(({ text, body, limitTo, ...f }) => f), findings,
215
+ summary: { startupFiles: startup.length, startupChars: startup.reduce((s, f) => s + f.chars, 0), startupTokens: startup.reduce((s, f) => s + f.tokens, 0), onDemand: files.filter((f) => f.loaded === 'on demand').length, error: count('error'), warn: count('warn'), info: count('info') } };
216
+ }
217
+
218
+ export function auditFailed(rep, failOn = 'error') { if (failOn === 'none') return false; if (!(failOn in SEV)) throw new Error(`--fail-on must be error, warn, info or none (got "${failOn}")`); return rep.findings.some((f) => SEV[f.severity] <= SEV[failOn]); }
219
+ const fi = (n) => core.fmtInt(n);
220
+ export function auditText(rep) {
221
+ const s = rep.summary;
222
+ const L = [`Glassbox memory audit · ${rep.project}`, ` ${s.startupFiles} file${s.startupFiles === 1 ? '' : 's'} loaded before the first prompt · ≈ ${fi(s.startupTokens)} tokens (chars ÷ 4) on every session${s.onDemand ? ` · ${s.onDemand} more on demand` : ''} · ${s.error} error · ${s.warn} warn · ${s.info} info`, ''];
223
+ if (!rep.files.length) L.push(' nothing: no CLAUDE.md, rules, imports or auto memory for this project');
224
+ else { L.push(' ≈ tokens lines file'); for (const f of rep.files) L.push(` ${(f.loaded === 'startup' ? fi(f.tokens) : f.loaded === 'skipped' ? 'skipped' : '—').padStart(8)} ${String(f.lines == null ? '—' : f.lines).padStart(5)} ${f.show} · ${f.label}${f.loaded === 'on demand' ? ` (on demand: ${f.why})` : ''}`); }
225
+ if (rep.findings.length) { L.push(''); for (const f of rep.findings) L.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.id.padEnd(12)} ${f.title}`); }
226
+ L.push('', ' Token counts are estimates (characters ÷ 4) of what Claude Code would load, from its documented rules; `/context` in a session shows the real list.');
227
+ return L.join('\n') + '\n';
228
+ }
229
+ export function auditMarkdown(rep) {
230
+ const s = rep.summary;
231
+ const L = ['# What every session loads before the first prompt', '', `Project \`${rep.project}\` · ${rep.generated.slice(0, 10)}`, '', `**${s.startupFiles} files loaded at startup, ≈ ${fi(s.startupTokens)} tokens on every session** (characters ÷ 4)${s.onDemand ? ` · ${s.onDemand} more on demand` : ''} · ${s.error} error · ${s.warn} warn · ${s.info} info`, ''];
232
+ if (rep.files.length) { L.push('| file | loads | lines | ≈ tokens |', '|---|---|---:|---:|'); for (const f of rep.files) L.push(`| \`${f.show}\` <br><sub>${f.label}</sub> | ${f.loaded}${f.why ? ` (${f.why})` : ''} | ${f.lines == null ? '—' : f.lines} | ${f.loaded === 'startup' ? fi(f.tokens) : '—'} |`); L.push(''); }
233
+ if (rep.findings.length) { L.push('## Findings', ''); for (const f of rep.findings) L.push(`- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}. ${f.detail}`); L.push(''); }
234
+ L.push('## How to read this', '', 'The list follows Claude Code\'s documented load rules (managed, user, parent directories, project, local, unscoped rules, @imports up to four hops, the first 200 lines or 25 KB of the auto-memory index, and Glassbox\'s own SessionStart note). Token counts are characters ÷ 4 after block-level HTML comments are stripped, as Claude Code does. A dead path or script is a name in an instruction that nothing on disk answers to; check before deleting the line. `/context` inside a session lists what actually loaded.', '');
235
+ return L.join('\n');
236
+ }
package/src/memory.mjs ADDED
@@ -0,0 +1,428 @@
1
+ // glassbox memory — what the next session in a project should know, and what the last ones did.
2
+ //
3
+ // `--context` used to carry one session's findings to the next and forget everything else: the note was
4
+ // overwritten by every session with findings and deleted after a clean one, so Monday's lesson was gone by
5
+ // Wednesday. The ledger, <project>/.glassbox/memory.json, keeps a digest of each of the project's last 50 sessions
6
+ // — what was asked, what was written, what ran and how it ended, what was committed, which findings fired — and
7
+ // folds the findings into lessons keyed by rule and subject ("retry-loop · Bash npm test"). The SessionStart hook
8
+ // hands the next session the lessons still recurring and a line per recent session; `glassbox recall` searches
9
+ // the digests and the transcripts still on disk; `glassbox memory` prints exactly what the next session will be
10
+ // told, and how often each lesson came back in sessions that were told it. No AI: every line is copied or counted
11
+ // from a transcript, and anything a fence detector recognises as a secret is masked before it is written.
12
+ // Design: DESIGN.md §20, docs/MEMORY.md.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+ import { maskSecrets } from './fence.mjs';
17
+ import { segments, actionRe, isShell, projectSessions } from './adhere.mjs';
18
+ import { TEST_CMD, runFailed } from './claims.mjs';
19
+ import { loadSessionFiles } from './cli.mjs';
20
+ const require = createRequire(import.meta.url);
21
+ const core = require('./trace-core.js');
22
+
23
+ export const MEMORY_FILE = path.join('.glassbox', 'memory.json');
24
+ export const NOTES_FILE = path.join('.glassbox', 'last-session.md');
25
+ // window: sessions kept · retireAfter: sessions in a row without a lesson before it stops being handed on ·
26
+ // lessons / recent: lines in the note · maxChars: the whole note · pendingDays: how long a "told at the start" record
27
+ // waits for its session's first Stop.
28
+ export const MEMORY_DEFAULTS = { window: 50, retireAfter: 10, lessons: 5, recent: 5, maxChars: 8000, pendingDays: 7 };
29
+ // Not something the agent can do differently: API latency and errors, Claude Code's own missing usage records,
30
+ // a call still running when the hook read the transcript, a cache that expired while the human was away.
31
+ export const NOT_LESSONS = new Set(['slow-model', 'long-generation', 'api-error', 'missing-final-usage', 'orphan-tool', 'idle-cache-expiry']);
32
+ const SEV = { error: 0, warn: 1, info: 2 };
33
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
34
+
35
+ const clip = (s, n) => { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
36
+ // Mask, then cut: a token cut at the limit no longer matches its detector, so cutting first would keep half of it.
37
+ const mclip = (s, n) => clip(maskSecrets(s), n);
38
+ // What the agent wrote for a terminal, as one plain line: no code blocks, headings, bold, list markers or table pipes.
39
+ const plain = (s) => String(s || '').replace(/```[\s\S]*?```/g, ' ').replace(/^#{1,6}\s+/gm, '').replace(/\*\*|__/g, '').replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, '').replace(/\|/g, ' ');
40
+ const iso = (t) => t != null && Number.isFinite(t) ? new Date(t).toISOString() : null;
41
+ const maskDeep = (v) => typeof v === 'string' ? maskSecrets(v) : Array.isArray(v) ? v.map(maskDeep) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, maskDeep(x)])) : v;
42
+ // A path as the project sees it: relative when it is inside the project, forward slashes either way.
43
+ export function relPath(p, cwd) { const f = String(p || '').replace(/\\/g, '/'); const c = String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, ''); return c && f.toLowerCase().startsWith(c.toLowerCase() + '/') ? f.slice(c.length + 1) : f; }
44
+
45
+ // A command segment without the plumbing around it: a `timeout N` in front, redirections of its output behind.
46
+ export const cleanCmd = (seg) => String(seg).replace(/^timeout\s+(?:-\S+\s+)*\d+[smhd]?\s+/, '').replace(/\s+\d*>&\d+|\s+(?:\d|&)?>>?\s*\/dev\/null/g, '').trim();
47
+
48
+ // What a shell command does, in a word or two: the first segment that isn't navigation, its program and subcommand
49
+ // ("npm test", "npm run e2e", "git push", "python -m pytest").
50
+ export function commandHead(cmd) {
51
+ const seg = cleanCmd(segments(cmd).find((s) => !/^(?:cd|pushd|popd|export|set|source|\.)(?:\s|$)/.test(s)) || '');
52
+ const toks = seg.split(/\s+/).filter(Boolean); if (!toks.length) return '';
53
+ const prog = toks[0].replace(/^.*[\\/]/, '');
54
+ if (/^python3?$/.test(prog) && toks[1] === '-m' && toks[2]) return `${prog} -m ${toks[2]}`;
55
+ let i = 1; if (prog === 'git') while (i < toks.length && /^-/.test(toks[i])) i += /^-[Cc]$/.test(toks[i]) ? 2 : 1;
56
+ let sub = toks[i] && /^[A-Za-z][\w:.-]*$/.test(toks[i]) ? toks[i] : toks[1] && /^--[a-z][\w-]*$/.test(toks[1]) ? toks[1] : '';
57
+ if (/^(?:run|exec|x|dlx)$/.test(sub) && toks[i + 1] && /^[\w:.-]+$/.test(toks[i + 1])) sub += ' ' + toks[i + 1];
58
+ return sub ? `${prog} ${sub}` : prog;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------------------------
62
+ // Lessons: a finding, counted across sessions by what it is about. Titles carry counts and sizes, so the key is
63
+ // the rule plus the tool (for a repeated shell call, the command's head), the file, or the claim's kind.
64
+ // ---------------------------------------------------------------------------------------------
65
+ const CALL_RULES = new Set(['retry-loop', 'failed-tool', 'oversized-result', 'slow-tool', 'duplicate-subagent-read']);
66
+ export function lessonOf(f, trace, cwd) {
67
+ if (NOT_LESSONS.has(f.id)) return null;
68
+ const ev = f.evidence || {};
69
+ const c = (ev.toolCallIds || []).map((id) => trace.toolCalls.find((x) => x.id === id)).find(Boolean);
70
+ let subject = '';
71
+ if (f.id === 'retry-loop' && c) { const head = isShell(c) && c.input && typeof c.input.command === 'string' ? commandHead(c.input.command) : ''; subject = c.name + (head ? ' ' + head : ''); }
72
+ else if (['failed-tool', 'oversized-result', 'slow-tool'].includes(f.id) && c) subject = c.name;
73
+ else if (f.id === 'duplicate-subagent-read' && c) subject = relPath(core.filePathOf(c), cwd);
74
+ else if (f.claim) subject = f.claim.kind;
75
+ subject = mclip(subject, 80); // the key is written and shown: `echo <token> | gh auth login` must not put the token in it
76
+ // The example is the call itself where the rule is about one kind of call; elsewhere the finding's own detail.
77
+ const example = c && CALL_RULES.has(f.id) ? mclip(core.inputSummary(c), 120) + (f.id === 'failed-tool' && f.detail ? ' → ' + mclip(f.detail, 120) : '') : mclip(f.detail, 160);
78
+ return { key: f.id + (subject ? ' · ' + subject : ''), rule: f.id, subject, severity: f.severity, title: mclip(f.title, 120), example, call: !!(c && CALL_RULES.has(f.id)) };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------------------------
82
+ // Digests: what one session did, copied from its transcript. Prompts (the first two and the last three), the
83
+ // agent's last words in its last three turns, the files written, the checks and ships that ran with how they
84
+ // ended, the commits with their shas.
85
+ // ---------------------------------------------------------------------------------------------
86
+ const pickEnds = (list, n) => list.length <= n ? list : list.slice(0, 2).concat(list.slice(-(n - 2)));
87
+ // A ship is the real subcommand, never a dry run: `vercel env pull` and `netlify status` deploy nothing.
88
+ const DEPLOY = /^(?:vercel(?:\s+(?:deploy|--prod)\b|\s*$)|(?:netlify|fly|flyctl|firebase|serverless|sls|eb|wrangler|railway)\s+(?:deploy|publish|up)\b|gcloud\s+(?:app|run|functions)\s+deploy\b|(?:npm|pnpm|yarn|bun)\s+run\s+deploy\b|[\w.-]+\s+deploy\b)/i;
89
+ const live = (seg) => !/(?:^|\s)(?:--dry-run|-n)(?=\s|$)/.test(seg);
90
+ const SHIP = { push: (seg) => actionRe.push.test(seg) && live(seg), publish: (seg) => /^(?:npm|pnpm|yarn)\s+publish\b|^gh\s+release\s+create\b/.test(seg) && live(seg), deploy: (seg) => DEPLOY.test(seg) && live(seg) };
91
+ // Failure in the tool's own words, at the start of a line: a commit subject that says "error:" is not a failed push.
92
+ const SHIP_FAILED = { push: /^(?:error|fatal):|\[(?:remote )?rejected\]|failed to push/m, publish: /^npm (?:ERR!|error)\b|^error\b/im, deploy: /^error\b/im };
93
+ // A check is a command that starts with a runner (after npx, pnpm exec, uv run …): `npm install --save-dev vitest`
94
+ // and `cat jest.config.js` name one without running it.
95
+ const RUNNER_PREFIX = /^(?:npx|bunx|pnpx|(?:pnpm|yarn|bun)\s+(?:exec|dlx)|uv\s+run|poetry\s+run)\s+(?:-\S+\s+)*/;
96
+ const isCheck = (seg) => { const m = seg.replace(RUNNER_PREFIX, '').match(TEST_CMD); return !!m && m.index === 0; };
97
+ export function digestOf(trace, opts = {}) {
98
+ const cwd = opts.cwd || trace.meta.cwd || '';
99
+ const human = trace.turns.filter((t) => t.agent === 'main' && t.promptKind === 'human' && String(t.promptText || '').trim());
100
+ const reqs = new Map(trace.requests.map((r) => [r.id, r]));
101
+ const lastWords = (t) => { for (let i = t.requestIds.length - 1; i >= 0; i--) { const r = reqs.get(t.requestIds[i]); const txt = r && (r.blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim(); if (txt) return clip(plain(maskSecrets(txt)), 280); } return ''; };
102
+ const files = []; const seen = new Set();
103
+ const calls = trace.toolCalls.filter((c) => !c.unmatched).slice().sort((a, b) => (a.start || 0) - (b.start || 0));
104
+ for (const c of calls) { if (c.isError || !WRITE_TOOLS.has(c.name)) continue; const p = core.filePathOf(c); if (!p) continue; const r = relPath(maskSecrets(p), cwd); if (!seen.has(r.toLowerCase())) { seen.add(r.toLowerCase()); files.push(r); } }
105
+ const runs = new Map(), commits = [];
106
+ for (const c of calls) {
107
+ if (!isShell(c) || !c.input || typeof c.input.command !== 'string') continue;
108
+ const segs = segments(c.input.command).map(cleanCmd), out = c.resultText || '';
109
+ if (segs.some((s) => actionRe.commit.test(s)) && !c.isError) for (const m of out.matchAll(/^\[[^\]\n]*?\b([0-9a-f]{7,40})\]\s*([^\n]*)/gm)) commits.push({ sha: m[1].slice(0, 12), message: mclip(m[2], 100) });
110
+ if (c.input.run_in_background) continue; // its result is "running in the background", not how it ended
111
+ // A call that runs several checks ("npm test && npm run build") is one run: its result can't be split between them.
112
+ const checks = segs.filter(isCheck);
113
+ if (checks.length) { const cmd = mclip(checks.join(' && '), 80); const k = 'check|' + cmd.toLowerCase(); const prev = runs.get(k); runs.delete(k); runs.set(k, { kind: 'check', cmd, ok: !runFailed(c), runs: (prev ? prev.runs : 0) + 1 }); }
114
+ for (const [kind, test] of Object.entries(SHIP)) {
115
+ const seg = segs.find(test); if (!seg) continue;
116
+ const cmd = mclip(seg, 80), k = kind + '|' + cmd.toLowerCase(); const prev = runs.get(k); runs.delete(k);
117
+ runs.set(k, { kind, cmd, ok: !c.isError && !SHIP_FAILED[kind].test(out), runs: (prev ? prev.runs : 0) + 1 });
118
+ }
119
+ }
120
+ const prompts = human.map((t) => mclip(t.promptText, 200));
121
+ return maskDeep({
122
+ id: opts.id || trace.meta.sessionId || null, start: iso(trace.meta.start), end: iso(trace.meta.end),
123
+ title: trace.meta.title ? mclip(trace.meta.title, 90) : clip(prompts[0] || '', 90), prompts: pickEnds(prompts, 5), promptsTotal: prompts.length,
124
+ outcomes: Array.from(new Set(human.slice(-3).map(lastWords).filter(Boolean))),
125
+ files: files.slice(0, 30), filesTotal: files.length,
126
+ commands: Array.from(runs.values()).slice(-10), commits: commits.slice(-10),
127
+ turns: trace.totals.turns, toolCalls: trace.totals.toolCalls, toolErrors: trace.totals.toolErrors,
128
+ cost: opts.cost != null && Number.isFinite(opts.cost) ? Math.round(opts.cost * 1e4) / 1e4 : null,
129
+ });
130
+ }
131
+
132
+ // The trace with every tool input masked, for reports that are kept (last-session.md): they cut evidence to 100
133
+ // characters, and a token cut in half no longer matches its detector.
134
+ export function maskedTrace(trace) {
135
+ return Object.assign({}, trace, { meta: Object.assign({}, trace.meta, { title: trace.meta.title == null ? trace.meta.title : maskSecrets(trace.meta.title) }), toolCalls: trace.toolCalls.map((c) => c.input ? Object.assign({}, c, { input: maskDeep(c.input) }) : c) });
136
+ }
137
+
138
+ // What a session did, in a few short phrases; one line per session for the note and for recall without a query.
139
+ export function digestBits(d) {
140
+ const bits = [];
141
+ if (d.files && d.files.length) bits.push(`wrote ${d.files.slice(0, 3).join(', ')}${d.filesTotal > 3 ? ` +${d.filesTotal - 3} more` : ''}`);
142
+ for (const c of (d.commands || []).filter((x) => x.kind === 'check').slice(-2)) bits.push(`ran ${c.cmd} (${c.ok ? 'passed' : 'failed'}${c.runs > 1 ? `, last of ${c.runs}` : ''})`);
143
+ for (const c of (d.commits || []).slice(-2)) bits.push(`committed ${c.sha.slice(0, 7)} "${clip(c.message, 60)}"`);
144
+ for (const c of (d.commands || []).filter((x) => x.kind !== 'check').slice(-2)) bits.push(`${({ push: 'pushed', publish: 'published', deploy: 'deployed' })[c.kind]}${c.ok ? '' : ' (failed)'}`);
145
+ if (d.hit && d.hit.length) bits.push(`findings: ${d.hit.slice(0, 3).join(', ')}${d.hit.length > 3 ? ` +${d.hit.length - 3}` : ''}`);
146
+ return bits;
147
+ }
148
+ export function digestLine(d) {
149
+ const bits = digestBits(d);
150
+ return `${String(d.start || '').slice(0, 16).replace('T', ' ') || '—'} · ${String(d.id || '').slice(0, 8)} · "${clip(d.title, 70)}"${bits.length ? ' — ' + bits.join(' · ') : ''}`;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------------------------
154
+ // The ledger
155
+ // ---------------------------------------------------------------------------------------------
156
+ export function memoryPath(cwd) { return cwd && fs.existsSync(cwd) && fs.statSync(cwd).isDirectory() ? path.join(cwd, MEMORY_FILE) : null; }
157
+ export function emptyLedger() { return { glassbox: core.VERSION, kind: 'memory', schema: 1, note: 'Glassbox memory for this project, written by the Glassbox hook (--context) from its transcripts. Local and git-ignored; `glassbox memory` shows what the next session will be told.', updated: null, sessions: [], lessons: {}, pending: {}, forgotten: {} }; }
158
+ export function loadLedger(cwd) {
159
+ const file = memoryPath(cwd); if (!file || !fs.existsSync(file)) return emptyLedger();
160
+ // A ledger that doesn't parse starts over: it is rebuilt from transcripts, and the hook must never fail on it.
161
+ let j = null; try { j = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { }
162
+ if (!j || j.kind !== 'memory' || !Array.isArray(j.sessions) || !j.lessons || typeof j.lessons !== 'object') return emptyLedger();
163
+ return Object.assign(emptyLedger(), j, { pending: j.pending && typeof j.pending === 'object' ? j.pending : {}, forgotten: j.forgotten && typeof j.forgotten === 'object' ? j.forgotten : {} });
164
+ }
165
+ export function ensureMemoryDir(cwd) {
166
+ const dir = path.join(cwd, '.glassbox'); fs.mkdirSync(dir, { recursive: true });
167
+ const ignore = path.join(dir, '.gitignore'); if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '# written by glassbox hook --context; local notes, never committed\n*\n');
168
+ return dir;
169
+ }
170
+ // Temp file and rename, so a session reading the ledger never sees half of one; owner-only, it quotes prompts.
171
+ export function saveLedger(cwd, led) {
172
+ const file = memoryPath(cwd); if (!file) return null;
173
+ ensureMemoryDir(cwd);
174
+ led.glassbox = core.VERSION; led.updated = new Date().toISOString();
175
+ const tmp = `${file}.${process.pid}.tmp`;
176
+ fs.writeFileSync(tmp, JSON.stringify(led, null, 1) + '\n', { mode: 0o600 }); fs.renameSync(tmp, file);
177
+ return file;
178
+ }
179
+
180
+ // Fold one session into the ledger. The Stop hook runs after every turn, so this replaces the session's digest
181
+ // and lesson hits each time rather than adding to them: re-folding a session changes nothing.
182
+ export function recordSession(cwd, res, opts = {}) {
183
+ const file = memoryPath(cwd); if (!file) return null;
184
+ const o = Object.assign({}, MEMORY_DEFAULTS, opts), failOn = o.failOn || 'warn';
185
+ const led = loadLedger(cwd);
186
+ const cost = res.cost ? (res.cost.reported != null ? res.cost.reported : res.cost.total) : null;
187
+ const d = digestOf(res.trace, { cwd, cost, id: o.sessionId });
188
+ const lessons = []; const keys = new Set();
189
+ // A lesson forgotten while this session was running stays forgotten for it; sessions started later count again.
190
+ const forgotten = (k) => led.forgotten[k] && !(d.start && d.start > led.forgotten[k]);
191
+ for (const f of res.findings) { if (SEV[f.severity] > SEV[failOn]) continue; const l = lessonOf(f, res.trace, cwd); if (l && !keys.has(l.key) && !forgotten(l.key)) { keys.add(l.key); lessons.push(l); } }
192
+ d.hit = lessons.map((l) => l.key);
193
+ const i = led.sessions.findIndex((s) => s.id === d.id);
194
+ d.told = i >= 0 ? led.sessions[i].told || [] : (led.pending[d.id] && led.pending[d.id].told) || [];
195
+ delete led.pending[d.id];
196
+ if (i >= 0) led.sessions[i] = d; else led.sessions.push(d);
197
+ led.sessions.sort((a, b) => String(a.start || '').localeCompare(String(b.start || '')));
198
+ if (led.sessions.length > o.window) led.sessions = led.sessions.slice(-o.window);
199
+ const when = d.end || d.start || new Date().toISOString();
200
+ for (const l of lessons) { const p = led.lessons[l.key]; led.lessons[l.key] = { rule: l.rule, subject: l.subject, severity: l.severity, title: l.title, example: l.example, call: l.call, firstSeen: p ? p.firstSeen : when, lastSeen: when }; }
201
+ // A lesson no session in the window carries any more has aged out; so has a start record whose session never stopped.
202
+ const live = new Set(led.sessions.flatMap((s) => s.hit || []));
203
+ for (const k of Object.keys(led.lessons)) if (!live.has(k)) delete led.lessons[k];
204
+ const cutoff = Date.now() - o.pendingDays * 86400e3;
205
+ for (const [k, p] of Object.entries(led.pending)) if (!(Date.parse(p && p.at) >= cutoff)) delete led.pending[k];
206
+ saveLedger(cwd, led);
207
+ return { file, digest: d, lessons };
208
+ }
209
+
210
+ // Per lesson, over the sessions in the window: how many it fired in, how many started with it in their note, how
211
+ // many of those it fired in anyway, and how many sessions have gone by since it last fired.
212
+ export function lessonStats(led, opts = {}) {
213
+ const o = Object.assign({}, MEMORY_DEFAULTS, opts), S = led.sessions;
214
+ const out = Object.entries(led.lessons).map(([key, l]) => {
215
+ let sessions = 0, told = 0, recurred = 0, last = -1;
216
+ S.forEach((s, i) => { const h = (s.hit || []).includes(key), t = (s.told || []).includes(key); if (h) { sessions++; last = i; } if (t) { told++; if (h) recurred++; } });
217
+ const quiet = last < 0 ? S.length : S.length - 1 - last;
218
+ return Object.assign({ key }, l, { sessions, told, recurred, quiet, active: last >= 0 && quiet < o.retireAfter });
219
+ });
220
+ return out.sort((a, b) => (b.active - a.active) || SEV[a.severity] - SEV[b.severity] || b.sessions - a.sessions || String(b.lastSeen).localeCompare(String(a.lastSeen)));
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------------------------
224
+ // The note: what the SessionStart hook hands the next session. Lessons first, then a line per recent session,
225
+ // then the previous session's own findings and the agent's answer (last-session.md), cut to fit maxChars.
226
+ // ---------------------------------------------------------------------------------------------
227
+ const NOTE_HEAD = 'Notes from Glassbox on this project: copied and counted from the transcripts of its recent sessions by the Glassbox hook (no AI involved), kept in .glassbox/. Treat them as lessons, not tasks: avoid repeating what they describe, and don\'t redo past work.';
228
+ export function memoryNote(cwd, opts = {}) {
229
+ if (!memoryPath(cwd)) return null;
230
+ const o = Object.assign({}, MEMORY_DEFAULTS, opts);
231
+ const led = opts.ledger || loadLedger(cwd);
232
+ const S = led.sessions, n = S.length;
233
+ const active = lessonStats(led, o).filter((l) => l.active);
234
+ const shown = active.slice(0, o.lessons), advised = new Set();
235
+ const parts = [NOTE_HEAD];
236
+ if (shown.length) {
237
+ const L = [`## Recurring in this project (${shown.length < active.length ? `${shown.length} of ${active.length}, ` : ''}worst first)`, ''];
238
+ for (const l of shown) {
239
+ const adv = !advised.has(l.rule) && core.ADVICE[l.rule]; advised.add(l.rule);
240
+ const howOften = n === 1 ? 'in the only session so far' : `in ${l.sessions} of the last ${n} sessions`;
241
+ const eg = !l.example ? '' : l.call ? `, e.g. \`${clip(l.example, 100).replace(/`/g, "'")}\`` : `. ${clip(l.example, 160)}`;
242
+ L.push(`- ${l.key} — ${howOften}, last ${String(l.lastSeen || '').slice(0, 10)}: ${l.title}${eg}${/[.…]$/.test(eg || l.title) ? '' : '.'}${adv ? ` Next time: ${adv}` : ''}`);
243
+ }
244
+ parts.push(L.join('\n'));
245
+ }
246
+ const recent = S.slice().reverse().filter((d) => d.id !== opts.exclude && (d.toolCalls || (d.prompts && d.prompts.length))).slice(0, o.recent);
247
+ if (recent.length) parts.push(['## Recent sessions (newest first)', '', ...recent.map((d) => '- ' + digestLine(d))].join('\n'));
248
+ // The previous session's own notes. With lessons above, its findings are already there: keep its header line, the
249
+ // ones that didn't make the list, and what the agent said it would do differently. Without lessons (notes left
250
+ // by an older Glassbox, or a ledger that was cleared), the notes as they are.
251
+ const notes = path.join(cwd, NOTES_FILE);
252
+ const raw = fs.existsSync(notes) ? fs.readFileSync(notes, 'utf8') : '';
253
+ let last = '';
254
+ if (raw) {
255
+ const head = '## The previous session in this project (.glassbox/last-session.md)\n\n';
256
+ const line = (raw.match(/^# [^\n]*\n+([^\n#][^\n]*)\n/) || [])[1] || '';
257
+ const reply = ((raw.match(/\n## What the agent said it would do differently\n+([\s\S]*?)\s*(?=\n## |$)/) || [])[1] || '').trim();
258
+ if (shown.length && line) {
259
+ const id8 = (line.match(/session ([0-9a-f-]{4,})/i) || [])[1] || '';
260
+ const rec = id8 ? S.slice().reverse().find((d) => String(d.id || '').startsWith(id8)) : null;
261
+ const rest = rec ? (rec.hit || []).filter((k) => !shown.some((l) => l.key === k)) : [];
262
+ last = `${line}. Its findings are ${rest.length ? `the lessons above plus ${rest.join(', ')}` : 'among the lessons above'}${id8 ? `; \`glassbox check ${id8} --format md\` has the evidence` : ''}.` + (reply ? `\n\nWhat the agent said it would do differently: ${clip(reply, 1500)}` : '');
263
+ } else last = raw.replace(/^# [^\n]*\n+/, '').trim();
264
+ const room = o.maxChars - parts.join('\n\n').length - head.length - 2;
265
+ if (room < 600) last = '';
266
+ else if (last.length > room) last = last.slice(0, room - 90).replace(/\n[^\n]*$/, '') + '\n\n… (cut to fit; `glassbox check <session> --format md` has the full report)';
267
+ if (last) parts.push(head + last);
268
+ }
269
+ if (parts.length === 1) return null;
270
+ let text = parts.join('\n\n') + '\n';
271
+ if (text.length > o.maxChars) text = text.slice(0, o.maxChars - 2).replace(/\n[^\n]*$/, '') + '\n…\n';
272
+ return { text, told: shown.map((l) => l.key), lessons: shown.length, recent: recent.length, previous: !!last };
273
+ }
274
+
275
+ // A session that starts with lessons in its note is "told" them; counted only at the start (startup, /clear), never
276
+ // when compaction hands the note over again mid-session. Kept as pending until the session's first Stop.
277
+ export function noteTold(cwd, sessionId, keys) {
278
+ if (!sessionId || !keys || !keys.length || !memoryPath(cwd)) return false;
279
+ const led = loadLedger(cwd);
280
+ if (led.sessions.some((s) => s.id === sessionId)) return false;
281
+ led.pending[sessionId] = { at: new Date().toISOString(), told: keys };
282
+ saveLedger(cwd, led);
283
+ return true;
284
+ }
285
+
286
+ // Forget lessons by key, key prefix or rule id: the lesson and every count of it go, so it starts over if it fires again.
287
+ export function forget(cwd, what) {
288
+ const led = loadLedger(cwd), q = String(what || '').trim().toLowerCase();
289
+ if (!q) throw new Error('memory forget needs a lesson key or a rule id: glassbox memory forget retry-loop');
290
+ const keys = Object.keys(led.lessons).filter((k) => k.toLowerCase() === q || led.lessons[k].rule === q || k.toLowerCase().startsWith(q + ' · '));
291
+ if (!keys.length) return [];
292
+ const drop = (list) => (list || []).filter((k) => !keys.includes(k));
293
+ const at = new Date().toISOString();
294
+ for (const k of keys) { delete led.lessons[k]; led.forgotten[k] = at; }
295
+ for (const s of led.sessions) { s.hit = drop(s.hit); s.told = drop(s.told); }
296
+ for (const p of Object.values(led.pending)) p.told = drop(p.told);
297
+ saveLedger(cwd, led);
298
+ return keys;
299
+ }
300
+
301
+ export function clearMemory(cwd) {
302
+ const removed = [];
303
+ for (const f of [MEMORY_FILE, NOTES_FILE]) { const p = path.join(cwd, f); if (fs.existsSync(p)) { fs.unlinkSync(p); removed.push(p); } }
304
+ return removed;
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------------------------
308
+ // glassbox memory: the note the next session will get, and the lessons behind it.
309
+ // ---------------------------------------------------------------------------------------------
310
+ export function memoryReport(opts = {}) {
311
+ const project = path.resolve(opts.project || process.cwd());
312
+ const led = loadLedger(project), o = Object.assign({}, MEMORY_DEFAULTS, opts);
313
+ const lessons = lessonStats(led, o);
314
+ const note = memoryNote(project, Object.assign({}, o, { ledger: led }));
315
+ const told = lessons.reduce((s, l) => s + l.told, 0), recurred = lessons.reduce((s, l) => s + l.recurred, 0);
316
+ return { glassbox: core.VERSION, kind: 'memory', schema: 1, project, file: fs.existsSync(path.join(project, MEMORY_FILE)) ? path.join(project, MEMORY_FILE) : null, updated: led.updated,
317
+ sessions: led.sessions.length, window: o.window, retireAfter: o.retireAfter,
318
+ summary: { lessons: lessons.length, active: lessons.filter((l) => l.active).length, retired: lessons.filter((l) => !l.active).length, told, recurred },
319
+ lessons, recent: led.sessions.slice().reverse().slice(0, o.recent).map((d) => ({ id: d.id, start: d.start, line: digestLine(d) })), note: note ? note.text : null };
320
+ }
321
+ export function memoryText(rep) {
322
+ const s = rep.summary;
323
+ const L = [`Glassbox memory · ${rep.project}`];
324
+ if (!rep.file) L.push(' no memory yet — turn it on with `glassbox hook install --context` (or the plugin\'s context option); `glassbox recall` works without it');
325
+ else L.push(` ${rep.sessions} session${rep.sessions === 1 ? '' : 's'} remembered (window ${rep.window}) · ${s.lessons} lesson${s.lessons === 1 ? '' : 's'}: ${s.active} active, ${s.retired} retired after ${rep.retireAfter} quiet sessions${s.told ? ` · told ${s.told} times, came back ${s.recurred} of them` : ''}`);
326
+ if (rep.lessons.length) {
327
+ L.push('');
328
+ for (const l of rep.lessons) L.push(` ${(l.active ? 'ACTIVE' : 'RETIRED').padEnd(8)} ${l.key} · ${l.sessions} session${l.sessions === 1 ? '' : 's'}, last ${String(l.lastSeen || '').slice(0, 10)}${l.told ? ` · told ${l.told}, came back ${l.recurred}` : ''}${l.quiet ? ` · quiet ${l.quiet}` : ''}`);
329
+ }
330
+ L.push('', rep.note ? ' The next session in this project starts with this note:' : ' The next session in this project starts with no note.');
331
+ if (rep.note) { L.push(''); for (const line of rep.note.replace(/\n$/, '').split('\n')) L.push(' │ ' + line); }
332
+ L.push('', ' glassbox memory forget <key|rule> drops a lesson · glassbox memory clear deletes the memory · glassbox recall <words> searches it');
333
+ return L.join('\n') + '\n';
334
+ }
335
+ export function memoryMarkdown(rep) {
336
+ const s = rep.summary;
337
+ const L = ['# Glassbox memory', '', `Project \`${rep.project}\` · ${rep.sessions} sessions remembered · ${s.active} active lessons, ${s.retired} retired${s.told ? ` · told ${s.told} times, came back ${s.recurred}` : ''}`, ''];
338
+ if (rep.lessons.length) { L.push('| lesson | status | sessions | told | came back | quiet | last |', '|---|---|---:|---:|---:|---:|---|'); for (const l of rep.lessons) L.push(`| \`${l.key}\` | ${l.active ? 'active' : 'retired'} | ${l.sessions} | ${l.told} | ${l.recurred} | ${l.quiet} | ${String(l.lastSeen || '').slice(0, 10)} |`); L.push(''); }
339
+ L.push('## The note the next session gets', '', rep.note ? rep.note.replace(/\n$/, '').split('\n').map((x) => '> ' + x).join('\n') : '_none_', '');
340
+ return L.join('\n');
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------------------------
344
+ // glassbox recall: search a project's past sessions — the digests in the ledger and the transcripts still on disk
345
+ // (Claude Code deletes old transcripts; the ledger outlives them). Every word must match; a quoted phrase is one.
346
+ // ---------------------------------------------------------------------------------------------
347
+ export function parseQuery(q) { const out = []; String(q || '').replace(/"([^"]+)"|(\S+)/g, (m, ph, w) => { const t = (ph || w).toLowerCase().trim(); if (t) out.push(t); return ''; }); return out; }
348
+ const FIELDS = [['title', 3, (d) => [d.title]], ['prompt', 3, (d) => d.prompts || []], ['commit', 3, (d) => (d.commits || []).map((c) => `${c.sha.slice(0, 7)} ${c.message}`)], ['outcome', 2, (d) => d.outcomes || []], ['wrote', 2, (d) => d.files || []], ['ran', 1, (d) => (d.commands || []).map((c) => `${c.cmd} (${c.ok ? 'passed' : 'failed'})`)], ['finding', 1, (d) => d.hit || []]];
349
+ // A word missing from a transcript's raw text can't be in its digest; only words JSON would escape need parsing to tell.
350
+ const rawSafe = (w) => /^[\x20-\x7e]+$/.test(w) && !/["\\/]/.test(w) && !/^(?:passed|failed)$|fenced/i.test(w);
351
+ function around(text, term, n = 150) {
352
+ const t = String(text).replace(/\s+/g, ' ').trim(); const i = t.toLowerCase().indexOf(term); if (i < 0 || t.length <= n) return clip(t, n);
353
+ let a = Math.max(0, Math.min(i - 40, t.length - n)), b = Math.min(t.length, a + n);
354
+ if (a) { const sp = t.indexOf(' ', a); if (sp > -1 && sp < i) a = sp + 1; }
355
+ if (b < t.length) { const sp = t.lastIndexOf(' ', b); if (sp > i + term.length) b = sp; }
356
+ return (a ? '…' : '') + t.slice(a, b) + (b < t.length ? '…' : '');
357
+ }
358
+ export function recall(opts = {}) {
359
+ const project = path.resolve(opts.project || process.cwd());
360
+ const terms = parseQuery(opts.query), limit = opts.limit || 10;
361
+ const led = loadLedger(project);
362
+ const byId = new Map(led.sessions.map((d) => [d.id, Object.assign({ source: 'memory' }, d)]));
363
+ const onDisk = projectSessions(project, opts.home);
364
+ let list = opts.since != null ? onDisk.filter((s) => s.mtime >= opts.since) : onDisk;
365
+ const transcripts = list.length;
366
+ const inWindow = (d) => opts.since == null || !(Date.parse(d.end || d.start) < opts.since);
367
+ const universe = new Set([...led.sessions.filter(inWindow).map((d) => d.id), ...list.map((s) => s.id)]);
368
+ // Without words only the newest few can be shown, so only they are parsed (the list is newest first).
369
+ if (!terms.length) list = list.slice(0, limit);
370
+ let parsed = 0;
371
+ for (const s of list) {
372
+ const files = loadSessionFiles(s);
373
+ const words = terms.flatMap((t) => t.split(/\s+/)).filter(rawSafe);
374
+ if (words.length) { const raw = files.map((f) => f.text).join('\n').toLowerCase(); if (!words.every((w) => raw.includes(w))) continue; }
375
+ const d = digestOf(core.parseTrace(files), { cwd: project, id: s.id }); parsed++;
376
+ const prev = byId.get(s.id);
377
+ byId.set(s.id, Object.assign(d, { hit: prev ? prev.hit : [], cost: prev ? prev.cost : null, source: prev ? 'both' : 'transcript' }));
378
+ }
379
+ // A ledger digest whose transcript is still on disk is the same session: say so.
380
+ const disk = new Set(onDisk.map((s) => s.id));
381
+ for (const d of byId.values()) if (d.source === 'memory' && disk.has(d.id)) d.source = 'both';
382
+ const rows = Array.from(byId.values()).filter(inWindow);
383
+ const out = [];
384
+ for (const d of rows) {
385
+ let score = 0; const matches = [];
386
+ if (terms.length) {
387
+ const hay = FIELDS.map(([, , get]) => get(d).join('\n').toLowerCase()).join('\n');
388
+ if (!terms.every((t) => hay.includes(t))) continue;
389
+ const all = [];
390
+ for (const [field, w, get] of FIELDS) for (const v of get(d)) { const hits = terms.filter((t) => String(v).toLowerCase().includes(t)); if (!hits.length) continue; score += w * hits.length; if (!(field === 'title' && (d.prompts || []).includes(v))) all.push({ field, text: around(v, hits[0]) }); }
391
+ // One match per field before a second from any: what was asked, said, written and run, not five files.
392
+ const firsts = all.filter((m, i) => all.findIndex((x) => x.field === m.field) === i);
393
+ matches.push(...firsts.concat(all.filter((m) => !firsts.includes(m))).slice(0, 5));
394
+ }
395
+ out.push({ id: d.id, start: d.start, end: d.end, title: d.title, source: d.source, score, matches, bits: digestBits(d), digest: d });
396
+ }
397
+ out.sort((a, b) => b.score - a.score || String(b.start || '').localeCompare(String(a.start || '')));
398
+ const lessons = terms.length ? lessonStats(led).filter((l) => terms.every((t) => [l.key, l.title, l.example].join('\n').toLowerCase().includes(t))) : [];
399
+ return { glassbox: core.VERSION, kind: 'recall', schema: 1, project, query: terms, generated: new Date().toISOString(), searched: { sessions: universe.size, transcripts, parsed, memory: led.sessions.length }, total: out.length, sessions: out.slice(0, limit), lessons };
400
+ }
401
+ const LABEL = { title: 'title', prompt: 'asked', commit: 'commit', outcome: 'said', wrote: 'wrote', ran: 'ran', finding: 'finding' };
402
+ export function recallText(rep) {
403
+ const q = rep.query.length ? ` · ${rep.query.map((t) => t.includes(' ') ? `"${t}"` : t).join(' ')}` : '';
404
+ const L = [`Glassbox recall · ${rep.project}${q} · ${rep.query.length ? `${rep.total} of ${rep.searched.sessions}` : rep.searched.sessions} session${rep.searched.sessions === 1 ? '' : 's'}`];
405
+ if (!rep.searched.sessions) L.push(' nothing to search: no transcripts for this project under the Claude home and no .glassbox/memory.json (try --project DIR or --home DIR)');
406
+ else if (!rep.sessions.length) L.push(` no session matches${rep.query.length > 1 ? ' all of the words' : ''}`);
407
+ for (const r of rep.sessions) {
408
+ L.push('', ` ${String(r.start || '').slice(0, 16).replace('T', ' ')} ${String(r.id || '').slice(0, 8)} ${clip(r.title, 90)}${r.source === 'memory' ? ' (transcript gone; from memory)' : ''}`);
409
+ if (!rep.query.length) { L.push(' ' + (r.bits.join(' · ') || '(no writes, checks or commits)')); continue; }
410
+ for (const m of r.matches) L.push(` ${LABEL[m.field].padEnd(8)} ${m.text}`);
411
+ }
412
+ if (rep.total > rep.sessions.length) L.push('', ` … ${rep.total - rep.sessions.length} more (--limit N)`);
413
+ if (rep.lessons.length) { L.push('', ' lessons'); for (const l of rep.lessons) L.push(` ${l.key} — ${l.sessions} session${l.sessions === 1 ? '' : 's'}, last ${String(l.lastSeen || '').slice(0, 10)}${l.active ? '' : ' (retired)'}: ${l.title}`); }
414
+ if (rep.sessions.length) L.push('', ' glassbox open <id> shows a session\'s timeline · glassbox check <id> its findings');
415
+ return L.join('\n') + '\n';
416
+ }
417
+ export function recallMarkdown(rep) {
418
+ const q = rep.query.length ? rep.query.map((t) => t.includes(' ') ? `"${t}"` : t).join(' ') : '';
419
+ const L = [`# Glassbox recall${q ? ` — ${q}` : ''}`, '', `Project \`${rep.project}\` · ${rep.query.length ? `${rep.total} of ${rep.searched.sessions}` : rep.searched.sessions} session${rep.searched.sessions === 1 ? '' : 's'} · copied from transcripts and .glassbox/memory.json, ${rep.query.length ? 'best match' : 'newest'} first`, ''];
420
+ if (!rep.sessions.length) L.push(rep.searched.sessions ? '_No session matches._' : '_Nothing to search for this project._', '');
421
+ for (const r of rep.sessions) {
422
+ L.push(`## ${String(r.start || '').slice(0, 16).replace('T', ' ')} · \`${String(r.id || '').slice(0, 8)}\` · ${clip(r.title, 90)}`, '');
423
+ if (!rep.query.length) L.push(r.bits.join(' · ') || '_(no writes, checks or commits)_', '');
424
+ else { for (const m of r.matches) L.push(`- ${LABEL[m.field]}: ${m.text}`); L.push(''); }
425
+ }
426
+ if (rep.lessons.length) { L.push('## Lessons', ''); for (const l of rep.lessons) L.push(`- \`${l.key}\` — ${l.sessions} session${l.sessions === 1 ? '' : 's'}, last ${String(l.lastSeen || '').slice(0, 10)}${l.active ? '' : ' (retired)'}: ${l.title}`); L.push(''); }
427
+ return L.join('\n');
428
+ }
package/src/trace-core.js CHANGED
@@ -9,7 +9,7 @@
9
9
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
10
10
  'use strict';
11
11
 
12
- const VERSION = '0.10.1';
12
+ const VERSION = '0.11.0';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated