claude-session-recall 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Satyam Khatri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,253 @@
1
+ # session-recall
2
+
3
+ **Your AI coding session has been compacted. It did not tell you how many times, and it cannot tell
4
+ which of the things it "knows" it actually measured.**
5
+
6
+ ![recall compactions, run against a real Claude Code session: 28 compactions, and 495,488 characters of summary standing in for the record](docs/demo.gif)
7
+
8
+ Run it on your own session, before reading any further:
9
+
10
+ ```bash
11
+ git clone https://github.com/CavsSatyamKhatri/claude-session-recall.git
12
+ node claude-session-recall/recall.mjs compactions
13
+ ```
14
+
15
+ Your numbers will be different. That is the point — they are *yours*, and nothing else was going to
16
+ tell you.
17
+
18
+ A Claude Code skill that reads the session's own transcript, so a fact that arrived through a
19
+ compaction summary can be checked instead of repeated.
20
+
21
+ ---
22
+
23
+ ## The problem
24
+
25
+ When a conversation is compacted, what survives into the next context window is a **summary**:
26
+ chosen, compressed and written by the assistant. The full record is not deleted — it stays in the
27
+ transcript on disk — but nothing points at it.
28
+
29
+ So a fact that arrived through a summary is **indistinguishable** from a fact that was measured. It
30
+ reads the same, it is stated with the same confidence, and it gets acted on the same way. Repeat
31
+ that through several compactions and a claim nobody ever checked becomes something everybody
32
+ believes.
33
+
34
+ ## Find your own inherited claims
35
+
36
+ You do not have to guess what to look for. Ask what your last summary asserted:
37
+
38
+ ```bash
39
+ node recall.mjs claims
40
+ ```
41
+
42
+ ```
43
+ Summary #28 of 28, line 103216, 17,109 chars.
44
+ 16 sentence(s) in it assert something checkable.
45
+
46
+ This is a starting list, not a verdict: a sentence here is not wrong, and one that is
47
+ missing is not cleared. Trace the ones your next decision depends on.
48
+
49
+ 1. [version] ... the release chore is still pending ...
50
+ recall trace "the release chore is still pending"
51
+ ...
52
+ ```
53
+
54
+ Then trace the one your next decision rests on. It prints the command for you:
55
+
56
+ ```bash
57
+ node recall.mjs trace "the release chore is still pending"
58
+ ```
59
+
60
+ Two answers are possible, and they look nothing alike.
61
+
62
+ **A claim with nothing behind it:**
63
+
64
+ ```
65
+ First stated in a compaction summary at line 103216.
66
+ Occurrences in the record BEFORE that: 0
67
+
68
+ THE SUMMARY IS THE ORIGIN. Nothing in the record measured this before a summary
69
+ asserted it, so there is no evidence behind it in this session. Measure it now
70
+ rather than repeating it.
71
+ ```
72
+
73
+ **A claim that was actually established:**
74
+
75
+ ```
76
+ First stated in a compaction summary at line 95403.
77
+ Occurrences in the record BEFORE that: 9
78
+
79
+ It was in the record first, earliest at line 91905.
80
+ Read it with: recall around 91905
81
+ ```
82
+
83
+ That distinction is the whole tool.
84
+
85
+ ## Install
86
+
87
+ **To give it to Claude Code as a skill** (so it reaches for it on its own):
88
+
89
+ ```bash
90
+ git clone https://github.com/CavsSatyamKhatri/claude-session-recall.git \
91
+ ~/.claude/skills/session-recall
92
+ ```
93
+
94
+ That is the whole install. No dependencies, no build, no config, nothing running in the background —
95
+ just Node's standard library and a file you already have. Claude Code picks the skill up on the next
96
+ session, and `SKILL.md`'s description tells it when to reach for it.
97
+
98
+ ## What runs where
99
+
100
+ The tool itself is **cross-platform**: `compactions`, `trace`, `claims`, `turns`, `find` and
101
+ `around` read a transcript, and that is the same on macOS, Linux and Windows.
102
+
103
+ The optional guards are not all universal, and it is better to say so than to have you find out:
104
+
105
+ | guard | where it applies |
106
+ |---|---|
107
+ | an `Edit` whose text is not in the file | **everywhere** — and it is the one that fires most |
108
+ | a backslash before a quote in a Python heredoc | everywhere |
109
+ | `/tmp` crossing between Git Bash and a Windows interpreter | Windows only |
110
+ | `&&` / `\|\|` in Windows PowerShell 5.1 | Windows only |
111
+
112
+ On macOS and Linux the two Windows guards simply never fire; they check `process.platform` and stay
113
+ out of the way.
114
+
115
+ ## Commands
116
+
117
+ | | |
118
+ |---|---|
119
+ | `compactions` | how many times this session was compacted, when, and how much each summary carried |
120
+ | `claims [n] [max]` | what summary *n* asserts that can be checked, each with a ready-made `trace` |
121
+ | `trace "<text>"` | **the important one** — did the record contain this before a summary claimed it? |
122
+ | `turns [n]` | the last n things you asked for, with transcript line numbers |
123
+ | `find "<text>"` | every byte-exact occurrence, with surrounding context |
124
+ | `around <line>` | what was being worked on near that point |
125
+
126
+ `--file <path>` reads an older transcript instead of the live one.
127
+
128
+ It finds the live transcript by **modification time** — the `.jsonl` being appended to under
129
+ `~/.claude/projects/`. Not by deriving the folder name from the working directory: that folder is a
130
+ slug of the path whose casing is not consistent (`C--Users-Admin` sits beside `d--Projects-...`),
131
+ and a wrong guess reads somebody else's session.
132
+
133
+ ## Three things to know before trusting the output
134
+
135
+ **`claims` is a heuristic and says so.** It looks for the shapes that go wrong in practice — a
136
+ version number, a count, and the words that quietly turn a past observation into a present-tense
137
+ claim: *still*, *remains*, *pending*, *already*. A sentence it flags is not guilty, and a sentence
138
+ it misses is not cleared. It exists to give you somewhere to start, not a verdict.
139
+
140
+ **Trace a claim, not a token.** Tracing a bare version string can return hundreds of hits from code
141
+ comments and build files, and tells you almost nothing. Trace the distinctive phrase that carries
142
+ the assertion. `claims` builds that phrase for you.
143
+
144
+ **Your own searching lands in the record.** Running `trace` writes that command into the transcript,
145
+ so occurrences *after* the summary can include the query itself. The output separates before and
146
+ after for exactly this reason — only the "before" count is evidence. This was found by testing, not
147
+ by reasoning: a search for a sentence that had never been said still returned one hit.
148
+
149
+ ## The guards (optional, and a different thing)
150
+
151
+ Tracing an inherited claim fixes one failure. There is a second kind, and it is not a memory failure
152
+ at all. Count it on your own record:
153
+
154
+ ```bash
155
+ node recall.mjs errors
156
+ ```
157
+
158
+ ```
159
+ Mechanical failures in this record - each one a round-trip that could not have worked:
160
+
161
+ 148 a script that could not parse SyntaxError
162
+ 136 a command that is not on this machine command not found
163
+ 66 /tmp meaning two different places No such file or directory: '/tmp/
164
+ 62 unbalanced quoting in a shell command unexpected EOF while looking for matching
165
+ 61 an Edit whose text was not in the file String to replace not found
166
+ 61 a backslash inside a Python string unterminated string literal
167
+ 18 && in Windows PowerShell is not a valid statement separator
168
+ ...
169
+ ```
170
+
171
+ Again, your numbers will differ — the shape is what matters. **Every one of these was already
172
+ covered by a rule that was present and was broken anyway.** The PowerShell one is the clearest case:
173
+ *"`&&` is not available in this version"* sits in the tool description on **every single request**.
174
+ It was still broken, repeatedly.
175
+
176
+ So the problem is not that a rule is missing, or hard to find, or badly worded. A rule has to be
177
+ *applied*, and attention is not reliable.
178
+
179
+ A hook does not need attention. It runs outside the assistant's judgement, before the tool call, and
180
+ refuses. `hooks/guard.mjs` refuses four things:
181
+
182
+ - **`/tmp` crossing interpreters** — Git Bash resolves `/tmp` inside its own install; a
183
+ Windows-native `python`/`node` resolves it to `C:\tmp`. A file written by one and read by the
184
+ other is simply not there.
185
+ - **A backslash before a quote in a Python heredoc** — `'\'` and `.replace('\','/')` are an
186
+ unterminated literal or a silent escape.
187
+ - **`&&` or `||` in Windows PowerShell 5.1** — a parser error before anything runs.
188
+ - **An `Edit` whose text is not in the file** — and it names *why*: line endings, indentation, or
189
+ the block having changed since it was read.
190
+
191
+ ```bash
192
+ node hooks/install-hooks.mjs # you run this, not the assistant
193
+ ```
194
+
195
+ **You install it, deliberately.** A thing whose purpose is to limit the assistant's behaviour should
196
+ not be installed by the assistant — and it cannot be: writing your hook and permission settings is
197
+ refused, which is the correct design. The installer merges rather than replaces, backs the file up,
198
+ prints exactly what changed, and does nothing at all if your `settings.json` is not valid JSON.
199
+
200
+ ```bash
201
+ node hooks/prove.mjs # 11 cases: 5 that must be refused, 6 that must not
202
+ ```
203
+
204
+ Run that before trusting it. **A guard that cannot be seen to refuse is not a guard — and one that
205
+ refuses the wrong thing is worse than none**, because it gets switched off within a week and takes
206
+ the working guards with it. The allow cases are there for that reason and matter as much as the deny
207
+ cases: ordinary `/tmp` use in bash, a heredoc with no backslash, PowerShell using `;`, an `Edit`
208
+ that really does match.
209
+
210
+ Two of those eleven cases failed the first two times it was run, and **both times the guard was
211
+ right and the test was wrong** — searching with *less* indentation than the file has still matches,
212
+ because the shorter run of spaces sits inside the longer one. That is the sort of thing only running
213
+ it tells you.
214
+
215
+ ## What it deliberately is not
216
+
217
+ **It builds no index and caches nothing.** A transcript of several hundred million characters
218
+ searches in about two seconds. A stored summary of it would buy nothing measurable and would go
219
+ stale — which is the exact failure this exists to prevent. Reading the record directly cannot be
220
+ out of date.
221
+
222
+ **It never tells you whether a claim is true.** It tells you where the claim entered. The transcript
223
+ proves what was *said*, never what is *true now* — a version that was correct when it was written
224
+ may be wrong today. Where the running system can be asked, ask the running system; use this to find
225
+ out whether anyone ever did.
226
+
227
+ **It is not a memory system.** It does not persist anything between sessions, rank importance, or
228
+ decide what matters. It reads a file you already have.
229
+
230
+ ## Why not just grep the transcript?
231
+
232
+ You can, and for a known string you should — it is the same file. What `trace` adds is the one
233
+ distinction grep cannot make: **whether an occurrence sits inside a compaction summary or in the
234
+ record itself**, and therefore whether a claim had any evidence behind it before a summary asserted
235
+ it. Compaction boundaries are marked in the transcript (`isCompactSummary`); this reads them.
236
+
237
+ ## Where this came from
238
+
239
+ One long-running session, compacted 28 times. A summary carried a version number and the words
240
+ *"still pending"*. Both were wrong — the thing had already shipped — and the claim survived several
241
+ more compactions, was written into plans, and was repeated for hours before anyone thought to check
242
+ it. Tracing it afterwards took one command and showed zero occurrences in the record before the
243
+ summary that asserted it.
244
+
245
+ The number had never been measured. It had been inherited.
246
+
247
+ ## Requirements
248
+
249
+ Node 18+. Nothing else.
250
+
251
+ ## Licence
252
+
253
+ MIT.
package/SKILL.md ADDED
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: session-recall
3
+ description: Use when a fact arrived through a compaction summary rather than from something you measured this turn - versions, counts, "still pending", "already done", "last deployed on", anything inherited from earlier context. Reads the session's own transcript to show where the claim entered, so it can be checked instead of repeated. Also use when picking up a long-running session, when you cannot remember what was decided earlier, or when the user says "check this first".
4
+ ---
5
+
6
+ # Session Recall
7
+
8
+ ## The failure this exists for
9
+
10
+ When a conversation is compacted, what survives into the next context window is a **summary**:
11
+ chosen, compressed and written by the assistant. The full record is not deleted — it stays in the
12
+ session transcript on disk — but nothing points at it.
13
+
14
+ So a fact that arrived through a summary is **indistinguishable** from one that was measured. It
15
+ reads the same, it is stated with the same confidence, and it is acted on the same way. Repeat that
16
+ through several compactions and a claim nobody ever checked becomes something everybody believes —
17
+ written into plans, carried into the next summary, and repeated until somebody says "check this".
18
+
19
+ ## The rule
20
+
21
+ **A fact you did not measure this session is a claim until you check it.** Especially:
22
+
23
+ - version numbers, counts, sizes, dates
24
+ - "still pending", "already done", "not built yet", "last deployed on…"
25
+ - anything phrased as settled that you cannot point at a command for
26
+
27
+ Check it one of two ways, and the first is usually better:
28
+
29
+ 1. **Measure it again.** If it can be read off the running system in one command, read it.
30
+ 2. **Trace it.** `trace "<the claim>"` says whether the record contained it before a summary
31
+ asserted it. Zero occurrences before the summary means the summary is the origin.
32
+
33
+ ## Using it
34
+
35
+ ```bash
36
+ node ~/.claude/skills/session-recall/recall.mjs compactions
37
+ node ~/.claude/skills/session-recall/recall.mjs claims
38
+ node ~/.claude/skills/session-recall/recall.mjs trace "<a phrase from the summary>"
39
+ node ~/.claude/skills/session-recall/recall.mjs around <line>
40
+ ```
41
+
42
+ | | |
43
+ |---|---|
44
+ | `compactions` | how many times this session was compacted, when, and how much each summary carried |
45
+ | `claims [n] [max]` | what summary *n* asserts that can be checked, each with a ready-made `trace` |
46
+ | `trace "<text>"` | **the important one** — did the record contain this before a summary claimed it? |
47
+ | `turns [n]` | the last n things the user asked for, with transcript line numbers |
48
+ | `find "<text>"` | every byte-exact occurrence, with surrounding context |
49
+ | `around <line>` | what was being worked on near that point |
50
+
51
+ It finds the live transcript itself — the `.jsonl` being appended to under `~/.claude/projects/` —
52
+ so no path has to be supplied. `--file <path>` reads an older session instead.
53
+
54
+ **Start with `claims` when you do not know what to check.** It reads the latest summary and lists
55
+ what it asserts, printing the `trace` command for each. That removes the need to guess which phrase
56
+ to search for.
57
+
58
+ ## Three things to know before trusting the output
59
+
60
+ **`claims` is a heuristic.** It looks for version numbers, counts, and the words that turn a past
61
+ observation into a present-tense claim — *still*, *remains*, *pending*, *already*. A sentence it
62
+ flags is not guilty; a sentence it misses is not cleared. It gives you somewhere to start.
63
+
64
+ **Trace a claim, not a token.** A bare version string can return hundreds of hits from code comments
65
+ and build files, and tells you almost nothing. Trace the distinctive phrase that carries the
66
+ assertion — `claims` builds that phrase for you.
67
+
68
+ **Your own searching lands in the record.** Running `trace` writes that command into the transcript,
69
+ so occurrences *after* the summary can include the query itself. The output separates before and
70
+ after for exactly this reason — only the "before" count is evidence.
71
+
72
+ ## The guards beside it
73
+
74
+ `hooks/guard.mjs` is a PreToolUse hook, not part of this skill's instructions, and the distinction
75
+ is the point. Everything it refuses was already covered by a rule that was present and broken
76
+ anyway — `&&` in PowerShell sits in the tool description on every request. **A rule has to be
77
+ applied; a hook does not.**
78
+
79
+ It refuses `/tmp` paths that cross between Git Bash and a Windows-native interpreter, a backslash
80
+ before a quote in a Python heredoc, `&&`/`||` in Windows PowerShell, and an `Edit` whose text is not
81
+ in the file (naming whether it is line endings, indentation, or a block that has changed).
82
+
83
+ The user installs it with `node hooks/install-hooks.mjs` — not you. Writing hook and permission
84
+ settings is refused, and that is correct: a limit on your own behaviour is not yours to install.
85
+
86
+ ## What it deliberately is not
87
+
88
+ **It builds no index and caches nothing.** A transcript of several hundred million characters
89
+ searches in about two seconds, so a stored summary of it would buy nothing measurable and would go
90
+ stale — which is the exact failure this skill exists to prevent. Reading the record directly cannot
91
+ be out of date.
92
+
93
+ **It never says whether a claim is true.** It says where the claim entered. A tool that confidently
94
+ answers a question it cannot actually answer is the disease, not the cure — and the transcript
95
+ proves what was *said*, never what is *true now*. Where the running system can be asked, ask the
96
+ running system; use this to find out whether anyone ever did.
package/demo.mjs ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run the commands that tell the story, with the pauses a viewer needs.
4
+ *
5
+ * This exists so a recording does not depend on somebody typing accurately while being filmed. It
6
+ * prints each command as if it had been typed, waits long enough to read the output, and stops.
7
+ *
8
+ * node demo.mjs # all three steps, about 32 seconds
9
+ * node demo.mjs --fast # half the pauses
10
+ * node demo.mjs --only compactions # the first step only - shows no work of yours
11
+ * node demo.mjs --trace "<phrase>" # trace a phrase you choose rather than one it picks
12
+ * node demo.mjs --no-redact # leave your paths in
13
+ *
14
+ * Nothing here is special to the recording: it runs the real commands against your real transcript,
15
+ * so what is filmed is what a viewer will get.
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process'
19
+ import { join, dirname } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url))
23
+ const RECALL = join(HERE, 'recall.mjs')
24
+ const ARGV = process.argv.slice(2)
25
+ const FAST = ARGV.includes('--fast')
26
+ const REDACT = !ARGV.includes('--no-redact')
27
+ const ONLY = ARGV.includes('--only') ? ARGV[ARGV.indexOf('--only') + 1] : null
28
+ const PHRASE = ARGV.includes('--trace') ? ARGV[ARGV.indexOf('--trace') + 1] : null
29
+ const wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, FAST ? ms / 2 : ms)
30
+
31
+ function type(text) {
32
+ process.stdout.write('\n$ ')
33
+ for (const ch of text) {
34
+ process.stdout.write(ch)
35
+ wait(18)
36
+ }
37
+ process.stdout.write('\n')
38
+ wait(300)
39
+ }
40
+
41
+ /** Show the command the way a viewer would have to type it - quoted where it needs quoting. */
42
+ function shown(args) {
43
+ return 'recall ' + args
44
+ .filter((a) => a !== '')
45
+ .map((a) => (/[ "]/.test(a) ? '"' + a.replace(/"/g, '\\"') + '"' : a))
46
+ .join(' ')
47
+ }
48
+
49
+ /**
50
+ * Take your own name out of the picture.
51
+ *
52
+ * A recording of this runs against YOUR transcript, so the header carries your home directory and
53
+ * the slug of the project you were working in - which is an employer's name as often as not. The
54
+ * replacement is written to look like a placeholder, so nobody mistakes it for real output.
55
+ *
56
+ * The home directory is a PATH and not a pattern: `C:\Users\Admin` handed to RegExp raw turns \U
57
+ * and \A into escapes and matches nothing, which is exactly what happened the first time - the path
58
+ * printed in full while this function claimed to be redacting it. Escape it.
59
+ *
60
+ * It only touches paths. What a summary claimed is your own text and cannot be masked without
61
+ * lying about what the tool does; choose the claim with --trace, or record only `compactions`.
62
+ */
63
+ const escapeForRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
64
+ const home = process.env.USERPROFILE || process.env.HOME || ''
65
+ const HOME_RE = home ? new RegExp(escapeForRegex(home), 'gi') : null
66
+
67
+ function redact(s) {
68
+ if (!REDACT) return s
69
+ const withHome = HOME_RE ? s.replace(HOME_RE, '~') : s
70
+ return withHome.replace(/(\.claude[\\/]projects[\\/])[^\\/\s]+/gi, '$1<your-project>')
71
+ }
72
+
73
+ function run(args, hold) {
74
+ type(shown(args))
75
+ const r = spawnSync(process.execPath, [RECALL, ...args], { encoding: 'utf8' })
76
+ process.stdout.write(redact(r.stdout || r.stderr || ''))
77
+ wait(hold)
78
+ }
79
+
80
+ // 1. The thing nobody knows is happening. Counts and dates only - no work of yours appears here.
81
+ run(['compactions'], 4500)
82
+
83
+ if (ONLY === 'compactions') process.exit(0)
84
+
85
+ // 2. What the latest summary is asserting.
86
+ //
87
+ // This prints YOUR summary's own sentences, and no redaction can fix that without lying about what
88
+ // the tool does. If the recording is going somewhere public, either stop at step 1 with
89
+ // `--only compactions` or pick a harmless claim with `--trace`.
90
+ run(['claims', '', '3'], 5000)
91
+
92
+ // 3. The question that matters - shown on a claim where the answer is interesting.
93
+ //
94
+ // The first suggestion is often a claim that WAS measured, and tracing that proves the tool works
95
+ // while showing nothing worth watching. So try them in order and stop at the first one the record
96
+ // does not support. If every one is well-founded, that is a good session and the last result is
97
+ // shown as it is: a demo that manufactured a failure would be worse than a dull one.
98
+ let chosen = PHRASE
99
+ if (!chosen) {
100
+ const claims = spawnSync(process.execPath, [RECALL, 'claims'], { encoding: 'utf8' }).stdout || ''
101
+ const suggestions = [...claims.matchAll(/recall trace "([^"]+)"/g)].map((m) => m[1])
102
+ chosen = suggestions[0]
103
+ for (const s of suggestions.slice(0, 8)) {
104
+ const out = spawnSync(process.execPath, [RECALL, 'trace', s], { encoding: 'utf8' }).stdout || ''
105
+ if (out.includes('THE SUMMARY IS THE ORIGIN')) { chosen = s; break }
106
+ }
107
+ }
108
+ run(['trace', chosen || 'still pending'], 6000)
109
+
110
+ process.stdout.write('\n')
package/docs/demo.gif ADDED
Binary file
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop the mistakes that were already written down and made anyway.
4
+ *
5
+ * WHY A HOOK AND NOT INSTRUCTIONS
6
+ * -------------------------------
7
+ * Every failure below was already covered by a rule the assistant had in front of it. The
8
+ * PowerShell one is the clearest: "`&&` is not available" sits in the tool description on every
9
+ * single request, and it was broken anyway. So the problem is not that the rule is missing or hard
10
+ * to find - it is that a rule depends on being applied, and attention is not reliable.
11
+ *
12
+ * A hook does not depend on attention. It runs outside the assistant's judgement, before the tool
13
+ * call, and refuses. That is the whole reason this is a hook rather than more words in a skill.
14
+ *
15
+ * MEASURED, NOT GUESSED
16
+ * ---------------------
17
+ * Counts from one long Claude Code session's own transcript (467 million characters):
18
+ *
19
+ * SyntaxError 135
20
+ * command not found 124
21
+ * No such file or directory: '/tmp/ 56
22
+ * unexpected EOF while looking for matching 52
23
+ * String to replace not found 49
24
+ * unterminated string literal 48
25
+ * is not a valid statement separator 6
26
+ *
27
+ * Roughly 470 round-trips spent on mistakes a few lines of pattern matching can refuse. Each guard
28
+ * below names the count it is there for; if a guard cannot point at a number, it should not exist.
29
+ *
30
+ * SAFETY
31
+ * ------
32
+ * A hook that throws could block every tool call, so everything here is wrapped and the default is
33
+ * always to allow. A guard that is unsure says nothing.
34
+ */
35
+
36
+ import { readFileSync } from 'node:fs'
37
+
38
+ const IS_WINDOWS = process.platform === 'win32'
39
+
40
+ /** Deny with a reason the assistant can act on. Anything else allows. */
41
+ function deny(reason) {
42
+ process.stdout.write(JSON.stringify({
43
+ hookSpecificOutput: {
44
+ hookEventName: 'PreToolUse',
45
+ permissionDecision: 'deny',
46
+ permissionDecisionReason: reason,
47
+ },
48
+ }))
49
+ process.exit(0)
50
+ }
51
+
52
+ function allow() {
53
+ process.stdout.write(JSON.stringify({}))
54
+ process.exit(0)
55
+ }
56
+
57
+ /**
58
+ * `/tmp` is not one place. (56 failures)
59
+ *
60
+ * Git Bash resolves `/tmp` inside its own installation; a Windows-native `python`/`node` resolves
61
+ * the same string to `C:\tmp`. So a file written by one and read by the other is simply not there,
62
+ * and the error arrives as `FileNotFoundError`, several seconds later, pointing at a path that
63
+ * looks perfectly correct.
64
+ */
65
+ function tmpCrossesInterpreters(cmd) {
66
+ if (!IS_WINDOWS) return null
67
+ if (!/\/tmp\//.test(cmd)) return null
68
+ if (!/\b(python3?|node)\b/.test(cmd)) return null
69
+ return 'This command writes or reads /tmp AND runs python or node. Those are two different ' +
70
+ 'places on Windows: Git Bash resolves /tmp inside its own install, a Windows-native ' +
71
+ 'interpreter resolves it to C:\\tmp. The file will not be found. Use a path both agree on - ' +
72
+ 'the session scratchpad directory, or $env:TEMP / %TEMP% - and pass it explicitly.'
73
+ }
74
+
75
+ /**
76
+ * A lone backslash inside a Python string. (48 failures, part of 135 SyntaxErrors)
77
+ *
78
+ * Writing `'\'` or `.replace('\','/')` in a heredoc body is an unterminated string literal, and
79
+ * `'\r'`-style sequences inside a non-raw string silently become the control character rather than
80
+ * the two characters that were meant. Both were hit repeatedly while editing Windows paths.
81
+ */
82
+ function pythonBackslashInString(cmd) {
83
+ if (!/\bpython3?\b[^|]*<<'?\w+'?/.test(cmd)) return null
84
+ // A backslash immediately before a closing quote is the shape that breaks.
85
+ if (!/\\['"]/.test(cmd)) return null
86
+ return 'This Python heredoc contains a backslash directly before a quote. In a non-raw string ' +
87
+ "that is an unterminated literal or a silent escape - `'\\'` and `.replace('\\','/')` both " +
88
+ 'fail this way. Use chr(92) for a literal backslash, or a raw string (r\'...\'), or avoid the ' +
89
+ 'question: paths in this transcript format already use forward slashes.'
90
+ }
91
+
92
+ /**
93
+ * `&&` in Windows PowerShell 5.1. (6 failures)
94
+ *
95
+ * Pipeline chain operators arrived in PowerShell 7. In 5.1 this is a parser error before anything
96
+ * runs, and the message ("The token '&&' is not a valid statement separator in this version") does
97
+ * not say what to use instead.
98
+ */
99
+ function powershellChain(cmd) {
100
+ if (!/&&|\|\|/.test(cmd)) return null
101
+ return 'Windows PowerShell 5.1 has no && or || - they are a parser error before anything runs. ' +
102
+ 'Use `;` to run unconditionally, or `if ($?) { ... }` to run only when the previous command ' +
103
+ 'succeeded. If you are handing this to a person to paste, run it yourself first.'
104
+ }
105
+
106
+ /**
107
+ * The text being replaced is not in the file. (49 failures)
108
+ *
109
+ * An Edit that does not match is a whole round-trip spent learning that something invisible
110
+ * differed - almost always line endings or leading whitespace. That is checkable before the call,
111
+ * and the reason can be named instead of guessed at.
112
+ */
113
+ function editWillNotMatch(input) {
114
+ const { file_path: file, old_string: oldStr } = input || {}
115
+ if (!file || typeof oldStr !== 'string' || oldStr === '') return null
116
+ let text
117
+ try { text = readFileSync(file, 'utf8') } catch { return null } // a new file is not our business
118
+ if (text.includes(oldStr)) return null
119
+
120
+ // It does not match. Say WHY, because "not found" alone is what wasted the round-trip.
121
+ const why = []
122
+ if (text.replace(/\r\n/g, '\n').includes(oldStr.replace(/\r\n/g, '\n'))) {
123
+ why.push('line endings differ (the file has CRLF, or the search text does)')
124
+ }
125
+ const squash = (s) => s.replace(/[ \t]+/g, ' ').trim()
126
+ if (!why.length && squash(text).includes(squash(oldStr))) {
127
+ why.push('the text is there but the indentation or spacing differs')
128
+ }
129
+ const firstLine = oldStr.split('\n')[0].trim()
130
+ if (!why.length && firstLine.length > 8 && text.includes(firstLine)) {
131
+ why.push('the first line matches but the rest does not - the block has changed since it was read')
132
+ }
133
+ if (!why.length) why.push('no part of it is in the file - wrong file, or it was already changed')
134
+
135
+ return 'This Edit will not match: ' + why[0] + '. Read the exact bytes first ' +
136
+ '(Read, or grep with -n) and copy the target from what the file actually contains. ' +
137
+ 'File: ' + file
138
+ }
139
+
140
+ let raw = ''
141
+ process.stdin.on('data', (c) => { raw += c })
142
+ process.stdin.on('end', () => {
143
+ try {
144
+ const payload = JSON.parse(raw || '{}')
145
+ const tool = payload.tool_name || ''
146
+ const input = payload.tool_input || {}
147
+ const cmd = typeof input.command === 'string' ? input.command : ''
148
+
149
+ let reason = null
150
+ if (tool === 'Bash') {
151
+ reason = tmpCrossesInterpreters(cmd) || pythonBackslashInString(cmd)
152
+ } else if (tool === 'PowerShell') {
153
+ reason = (IS_WINDOWS ? powershellChain(cmd) : null) || tmpCrossesInterpreters(cmd)
154
+ } else if (tool === 'Edit' || tool === 'MultiEdit') {
155
+ reason = editWillNotMatch(input)
156
+ }
157
+
158
+ if (reason) deny(reason)
159
+ allow()
160
+ } catch {
161
+ // A guard that is unsure says nothing. Blocking every tool call because this script has a bug
162
+ // would be far worse than the mistakes it is here to prevent.
163
+ allow()
164
+ }
165
+ })
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Register the guards as PreToolUse hooks.
4
+ *
5
+ * You run this, not the assistant. That is deliberate: a thing whose whole purpose is to put a
6
+ * limit on the assistant's behaviour should not be installed by the assistant. (It also cannot be -
7
+ * writing your permission and hook settings is refused, which is the correct design.)
8
+ *
9
+ * It merges rather than replaces, backs the file up first, and prints exactly what changed. Run it
10
+ * again to update the path; it will not add a second copy.
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs'
14
+ import { join, dirname, resolve } from 'node:path'
15
+ import { homedir } from 'node:os'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const GUARD = resolve(join(dirname(fileURLToPath(import.meta.url)), 'guard.mjs'))
19
+ const SETTINGS = join(homedir(), '.claude', 'settings.json')
20
+ const MATCHER = 'Bash|PowerShell|Edit|MultiEdit'
21
+ const command = 'node "' + GUARD + '"'
22
+
23
+ if (!existsSync(GUARD)) {
24
+ console.error('guard.mjs is not beside this script: ' + GUARD)
25
+ process.exit(1)
26
+ }
27
+ if (!existsSync(SETTINGS)) {
28
+ console.error('No settings file at ' + SETTINGS + '. Start Claude Code once, then run this again.')
29
+ process.exit(1)
30
+ }
31
+
32
+ const before = readFileSync(SETTINGS, 'utf8')
33
+ let settings
34
+ try {
35
+ settings = JSON.parse(before)
36
+ } catch (e) {
37
+ console.error('settings.json is not valid JSON, so this will not touch it: ' + e.message)
38
+ process.exit(1)
39
+ }
40
+
41
+ const backup = SETTINGS + '.bak-before-guards'
42
+ copyFileSync(SETTINGS, backup)
43
+
44
+ const hooks = settings.hooks || (settings.hooks = {})
45
+ const pre = hooks.PreToolUse || (hooks.PreToolUse = [])
46
+
47
+ // Merge: never replace what is already there, and never register twice.
48
+ let entry = pre.find((h) => h && h.matcher === MATCHER)
49
+ if (!entry) {
50
+ entry = { matcher: MATCHER, hooks: [] }
51
+ pre.push(entry)
52
+ }
53
+ entry.hooks = entry.hooks || []
54
+ const already = entry.hooks.find((h) => h && typeof h.command === 'string' && h.command.includes('guard.mjs'))
55
+ if (already) {
56
+ already.command = command
57
+ console.log(' updated the existing guard entry to point at:')
58
+ } else {
59
+ entry.hooks.push({ type: 'command', command })
60
+ console.log(' added a PreToolUse guard on ' + MATCHER + ':')
61
+ }
62
+ console.log(' ' + command)
63
+
64
+ writeFileSync(SETTINGS, JSON.stringify(settings, null, 2))
65
+
66
+ // Say what changed, at the level that matters: nothing else should have moved.
67
+ const after = JSON.parse(readFileSync(SETTINGS, 'utf8'))
68
+ const beforeParsed = JSON.parse(before)
69
+ const keys = (o) => Object.keys(o).sort().join(',')
70
+ console.log()
71
+ console.log(' top-level keys before : ' + keys(beforeParsed))
72
+ console.log(' top-level keys after : ' + keys(after))
73
+ console.log(' permissions untouched : ' +
74
+ (JSON.stringify(beforeParsed.permissions) === JSON.stringify(after.permissions)))
75
+ console.log(' backup : ' + backup)
76
+ console.log()
77
+ console.log(' Open /hooks in Claude Code once (or restart) so the new configuration is read.')
78
+ console.log(' To remove it later: delete the entry from ' + SETTINGS + ', or restore the backup.')
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Prove each guard fires, and that ordinary work does not.
4
+ *
5
+ * A guard that cannot be seen to refuse is not a guard, and one that refuses the wrong thing is
6
+ * worse than none - it gets switched off within a week and takes the working guards with it. So
7
+ * every case below is run for real against `guard.mjs`, and the allow cases matter as much as the
8
+ * deny cases.
9
+ */
10
+
11
+ import { spawn } from 'node:child_process'
12
+ import { writeFileSync, mkdtempSync } from 'node:fs'
13
+ import { join, dirname } from 'node:path'
14
+ import { tmpdir } from 'node:os'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const GUARD = join(dirname(fileURLToPath(import.meta.url)), 'guard.mjs')
18
+
19
+ function ask(payload) {
20
+ return new Promise((resolve) => {
21
+ const p = spawn(process.execPath, [GUARD], { stdio: ['pipe', 'pipe', 'ignore'] })
22
+ let out = ''
23
+ p.stdout.on('data', (c) => { out += c })
24
+ p.on('close', () => {
25
+ let d = {}
26
+ try { d = JSON.parse(out || '{}') } catch { /* treated as allow */ }
27
+ const h = d.hookSpecificOutput || {}
28
+ resolve({ denied: h.permissionDecision === 'deny', reason: h.permissionDecisionReason || '' })
29
+ })
30
+ p.stdin.write(JSON.stringify(payload))
31
+ p.stdin.end()
32
+ })
33
+ }
34
+
35
+ // A real file, so the Edit guard has something true to check against.
36
+ const dir = mkdtempSync(join(tmpdir(), 'guard-proof-'))
37
+ const file = join(dir, 'sample.txt')
38
+ writeFileSync(file, 'first line\n indented line\nlast line\n')
39
+
40
+ const CASES = [
41
+ // --- must be REFUSED -------------------------------------------------------------------------
42
+ { deny: true, name: '/tmp written in bash, read by python',
43
+ p: { tool_name: 'Bash', tool_input: { command: "echo hi > /tmp/x.txt && python3 /tmp/x.txt" } } },
44
+ { deny: true, name: 'python heredoc with a backslash before a quote',
45
+ p: { tool_name: 'Bash', tool_input: { command: "python3 - <<'PY'\nprint('a'.replace('\\','/'))\nPY" } } },
46
+ { deny: true, name: 'PowerShell &&',
47
+ p: { tool_name: 'PowerShell', tool_input: { command: 'git add -A && git commit -m x' } } },
48
+ { deny: true, name: 'Edit whose text is not in the file',
49
+ p: { tool_name: 'Edit', tool_input: { file_path: file, old_string: 'this text was never there' } } },
50
+ // Six spaces where the file has four - deeper, so it cannot accidentally be a substring. Written
51
+ // this way after two wrong attempts: searching with LESS indentation than the file has still
52
+ // matches, because the shorter run of spaces sits inside the longer one. The guard was right both
53
+ // times and the test was wrong, which is the sort of thing only running it tells you.
54
+ { deny: true, name: 'Edit whose indentation differs (6 spaces vs 4)',
55
+ p: { tool_name: 'Edit', tool_input: { file_path: file, old_string: ' indented line\nlast line' } } },
56
+
57
+ // --- must be ALLOWED: ordinary work, and near-misses ------------------------------------------
58
+ { deny: false, name: 'bash using /tmp on its own (no python, no node)',
59
+ p: { tool_name: 'Bash', tool_input: { command: 'echo hi > /tmp/x.txt; cat /tmp/x.txt' } } },
60
+ { deny: false, name: 'python heredoc with no backslash at all',
61
+ p: { tool_name: 'Bash', tool_input: { command: "python3 - <<'PY'\nprint('hello')\nPY" } } },
62
+ { deny: false, name: 'PowerShell using ; instead of &&',
63
+ p: { tool_name: 'PowerShell', tool_input: { command: 'git add -A; if ($?) { git commit -m x }' } } },
64
+ { deny: false, name: 'Edit that really does match',
65
+ p: { tool_name: 'Edit', tool_input: { file_path: file, old_string: 'first line' } } },
66
+ { deny: false, name: 'Edit on a file that does not exist yet',
67
+ p: { tool_name: 'Edit', tool_input: { file_path: join(dir, 'nope.txt'), old_string: 'x' } } },
68
+ { deny: false, name: 'an ordinary command',
69
+ p: { tool_name: 'Bash', tool_input: { command: 'git status --short' } } },
70
+ ]
71
+
72
+ let bad = 0
73
+ console.log(' ' + 'expected'.padEnd(10) + 'got'.padEnd(10) + 'case')
74
+ for (const c of CASES) {
75
+ const r = await ask(c.p)
76
+ const ok = r.denied === c.deny
77
+ if (!ok) bad++
78
+ console.log(' ' + (c.deny ? 'deny' : 'allow').padEnd(10) +
79
+ (r.denied ? 'deny' : 'allow').padEnd(10) +
80
+ (ok ? ' ' : '!! ') + c.name)
81
+ if (r.denied && c.deny) console.log(' -> ' + r.reason.split('. ')[0] + '.')
82
+ }
83
+ console.log()
84
+ console.log(bad === 0
85
+ ? ' All ' + CASES.length + ' cases behaved as specified.'
86
+ : ' ' + bad + ' of ' + CASES.length + ' did NOT. The guard is not trustworthy until they do.')
87
+ process.exit(bad === 0 ? 0 : 1)
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "claude-session-recall",
3
+ "version": "1.0.0",
4
+ "description": "Read your Claude Code session's own transcript. See how many times it was compacted, and whether a fact you are relying on was ever measured or only inherited from a summary.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "claude-code-skill",
9
+ "compaction",
10
+ "context-window",
11
+ "transcript",
12
+ "ai-coding-assistant",
13
+ "anthropic",
14
+ "agent-memory"
15
+ ],
16
+ "homepage": "https://github.com/CavsSatyamKhatri/claude-session-recall#readme",
17
+ "bugs": "https://github.com/CavsSatyamKhatri/claude-session-recall/issues",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/CavsSatyamKhatri/claude-session-recall.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Satyam Khatri",
24
+ "type": "module",
25
+ "bin": {
26
+ "claude-session-recall": "recall.mjs",
27
+ "recall-session": "recall.mjs"
28
+ },
29
+ "files": [
30
+ "recall.mjs",
31
+ "demo.mjs",
32
+ "SKILL.md",
33
+ "hooks/",
34
+ "README.md",
35
+ "LICENSE",
36
+ "docs/"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "test": "node hooks/prove.mjs"
43
+ }
44
+ }
package/recall.mjs ADDED
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Read the session's own record, and find out where a "fact" came from.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * When a conversation is compacted, what survives is a SUMMARY - chosen, compressed and written by
8
+ * the assistant. The full record is not deleted; it stays in the session transcript. But nothing
9
+ * points at it, so a fact that arrived through a summary looks exactly like a fact that was
10
+ * measured, and gets acted on as though it were one.
11
+ *
12
+ * The session this came out of had been compacted 28 times, and nothing anywhere said so. One of
13
+ * its summaries carried a version number and the words "still pending". Both were wrong - the
14
+ * thing had already shipped - and the claim survived several more compactions, was written into
15
+ * plans, and was repeated for hours before anyone thought to check it.
16
+ *
17
+ * Tracing it afterwards took one command, and showed zero occurrences in the record before the
18
+ * summary that asserted it. The number had never been measured. It had been inherited.
19
+ *
20
+ * WHAT IT DELIBERATELY DOES NOT DO
21
+ * --------------------------------
22
+ * It builds no index and caches nothing. A 467-million-character transcript searches in about a
23
+ * second, so a stored summary of it would buy nothing measurable and would go stale - which is the
24
+ * exact failure this exists to prevent. It also never judges whether a claim is TRUE; it shows
25
+ * where the claim entered and leaves the judgement to a person, because a tool that confidently
26
+ * answers a question it cannot actually answer is the disease, not the cure.
27
+ */
28
+
29
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { homedir } from 'node:os'
32
+
33
+ const NL = String.fromCharCode(10)
34
+
35
+ /**
36
+ * The transcript being written right now.
37
+ *
38
+ * By modification time rather than by deriving the project folder from the working directory: that
39
+ * folder is a slug of the path whose casing is not consistent (`C--Users-Admin` sits beside
40
+ * `d--Projects-...`), and a wrong guess reads somebody else's session. The live transcript is the
41
+ * one being appended to, which is a fact rather than a convention.
42
+ */
43
+ function liveTranscript(explicit) {
44
+ if (explicit) return explicit
45
+ const root = join(homedir(), '.claude', 'projects')
46
+ if (!existsSync(root)) throw new Error('No transcripts at ' + root)
47
+ let best = null
48
+ for (const dir of readdirSync(root)) {
49
+ const d = join(root, dir)
50
+ let st
51
+ try { st = statSync(d) } catch { continue }
52
+ if (!st.isDirectory()) continue
53
+ for (const f of readdirSync(d)) {
54
+ if (!f.endsWith('.jsonl')) continue
55
+ const p = join(d, f)
56
+ const s = statSync(p)
57
+ if (!best || s.mtimeMs > best.mtimeMs) best = { path: p, mtimeMs: s.mtimeMs }
58
+ }
59
+ }
60
+ if (!best) throw new Error('No .jsonl transcript under ' + root)
61
+ return best.path
62
+ }
63
+
64
+ /** The whole file, once. Everything below works off this. */
65
+ function load(path) {
66
+ const text = readFileSync(path, 'utf8')
67
+ return { text, lines: text.split(NL) }
68
+ }
69
+
70
+ function textOf(entry) {
71
+ const c = entry && entry.message && entry.message.content
72
+ if (typeof c === 'string') return c
73
+ if (Array.isArray(c)) return c.filter((b) => b && b.type === 'text').map((b) => b.text).join(' ')
74
+ return ''
75
+ }
76
+
77
+ /** The compaction boundaries, and what each summary carried. */
78
+ function compactions(lines, withText) {
79
+ const out = []
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const l = lines[i]
82
+ if (!l || l.indexOf('"isCompactSummary"') < 0) continue
83
+ let d
84
+ try { d = JSON.parse(l) } catch { continue }
85
+ if (!d.isCompactSummary) continue
86
+ const t = textOf(d)
87
+ out.push({ line: i + 1, when: d.timestamp || '', chars: t.length, text: withText ? t : '' })
88
+ }
89
+ return out
90
+ }
91
+
92
+ /**
93
+ * Sentences in a summary that assert a checkable fact.
94
+ *
95
+ * This is a HEURISTIC and it is meant to be one: it produces a starting list, never a verdict. The
96
+ * shapes it looks for are the ones that went wrong in practice - a version number, a count, and the
97
+ * words that quietly turn a past observation into a present-tense claim ("still", "remains",
98
+ * "pending", "already"). A sentence it misses is not cleared; a sentence it flags is not guilty.
99
+ */
100
+ function claimSentences(text) {
101
+ const SHAPES = [
102
+ { name: 'version', re: /\bv?\d+\.\d+(\.\d+)?\b/ },
103
+ { name: 'state', re: /\b(still|remains?|pending|not yet|already|never|unchanged|outstanding)\b/i },
104
+ { name: 'count', re: /\b\d{1,6}\s+(of|out of)\s+\d{1,6}\b|\b\d{2,6}\s+(tests?|files?|screens?|rows?|commits?|devices?)\b/i },
105
+ { name: 'done', re: /\b(is|are|was|were)\s+(done|built|deployed|published|fixed|complete|live)\b/i },
106
+ ]
107
+ const out = []
108
+ // Split on sentence ends and on list-item boundaries: summaries are mostly bullets.
109
+ for (const raw of text.split(/(?:\r?\n)+|(?<=[.!?])\s+/)) {
110
+ const s = raw.replace(/\s+/g, ' ').trim().replace(/^[-*\d.)\s]+/, '')
111
+ if (s.length < 25 || s.length > 240) continue
112
+ const tags = SHAPES.filter((sh) => sh.re.test(s)).map((sh) => sh.name)
113
+ if (tags.length === 0) continue
114
+ out.push({ text: s, tags })
115
+ }
116
+ return out
117
+ }
118
+
119
+ /**
120
+ * The most distinctive run of words in a sentence - what to hand to `trace`.
121
+ *
122
+ * Markdown and quotes are stripped first. A summary is written in markdown, so a phrase lifted from
123
+ * it carries `**` and backticks that are not in the underlying record - and an embedded double
124
+ * quote would break the shell command this is printed as. Both were found by running it.
125
+ */
126
+ function tracePhrase(sentence) {
127
+ const clean = sentence.replace(/[*`"]/g, '').replace(/\s+/g, ' ').trim()
128
+ const words = clean.split(' ').filter(Boolean)
129
+ // Long enough to be unique, short enough to survive re-wording between summaries.
130
+ const n = Math.min(8, words.length)
131
+ let best = words.slice(0, n).join(' ')
132
+ for (let i = 0; i + n <= words.length; i++) {
133
+ const w = words.slice(i, i + n)
134
+ // Prefer a window that carries a number: that is the part that is checkable.
135
+ if (/\d/.test(w.join(' '))) { best = w.join(' '); break }
136
+ }
137
+ return best.replace(/^[^\w]+|[^\w.)%]+$/g, '')
138
+ }
139
+
140
+ /** The human's own messages - the natural boundaries of what was asked for. */
141
+ function humanTurns(lines) {
142
+ const out = []
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const l = lines[i]
145
+ if (!l || l.indexOf('"type":"user"') < 0) continue
146
+ let d
147
+ try { d = JSON.parse(l) } catch { continue }
148
+ if (d.type !== 'user' || d.isCompactSummary) continue
149
+ const c = d.message && d.message.content
150
+ let text = null
151
+ if (typeof c === 'string') text = c
152
+ else if (Array.isArray(c)) {
153
+ // A tool result is recorded as a user message too. It is not something a person said.
154
+ if (c.some((b) => b && b.type === 'tool_result')) continue
155
+ text = c.filter((b) => b && b.type === 'text').map((b) => b.text).join(' ')
156
+ }
157
+ if (!text || !text.trim()) continue
158
+ if (text.trimStart().startsWith('<system-reminder>')) continue
159
+ out.push({ line: i + 1, when: d.timestamp || '', text: text.trim().replace(/\s+/g, ' ') })
160
+ }
161
+ return out
162
+ }
163
+
164
+ /** Offset -> line number, built once so many hits cost no more than one. */
165
+ function lineIndex(text) {
166
+ const starts = [0]
167
+ let i = text.indexOf(NL)
168
+ while (i >= 0) { starts.push(i + 1); i = text.indexOf(NL, i + 1) }
169
+ return (off) => {
170
+ let lo = 0, hi = starts.length - 1, ans = 0
171
+ while (lo <= hi) {
172
+ const mid = (lo + hi) >> 1
173
+ if (starts[mid] <= off) { ans = mid; lo = mid + 1 } else hi = mid - 1
174
+ }
175
+ return ans + 1
176
+ }
177
+ }
178
+
179
+ /** Every byte-exact occurrence of a term. */
180
+ function occurrences(text, term, cap) {
181
+ const lineOf = lineIndex(text)
182
+ const hits = []
183
+ let from = 0
184
+ while (hits.length < (cap || 400)) {
185
+ const i = text.indexOf(term, from)
186
+ if (i < 0) break
187
+ from = i + term.length
188
+ hits.push({ off: i, line: lineOf(i) })
189
+ }
190
+ return hits
191
+ }
192
+
193
+ const args = process.argv.slice(2)
194
+ const cmd = args[0]
195
+ const fileArg = args.indexOf('--file') >= 0 ? args[args.indexOf('--file') + 1] : null
196
+ const path = liveTranscript(fileArg)
197
+ const say = (s) => console.log(s === undefined ? '' : s)
198
+
199
+ const COMMANDS = {}
200
+
201
+ COMMANDS.compactions = {
202
+ use: "compactions",
203
+ blurb: "how many times this session was compacted, and when",
204
+ run() {
205
+ const { lines } = load(path)
206
+ const cs = compactions(lines)
207
+ say(path)
208
+ say()
209
+ if (cs.length === 0) {
210
+ say(' Never compacted. Everything in context arrived first-hand.')
211
+ } else {
212
+ say(' Compacted ' + cs.length + ' time(s). Everything you "remember" from before the last one')
213
+ say(' reached you through a summary somebody wrote, not through the record.')
214
+ say()
215
+
216
+ // The most recent few, not all of them. A long session produces dozens, and printing every
217
+ // one scrolls the count - the only number that matters here - off the top of the terminal.
218
+ // `recall compactions all` prints the lot.
219
+ const all = args[1] === 'all'
220
+ const shown = all ? cs : cs.slice(-6)
221
+ if (shown.length < cs.length) {
222
+ say(' ... ' + (cs.length - shown.length) + ' earlier, back to ' +
223
+ cs[0].when.slice(0, 10) + ' (recall compactions all)')
224
+ }
225
+ for (const c of shown) {
226
+ say(' #' + String(cs.indexOf(c) + 1).padStart(2) + ' line ' + String(c.line).padStart(7) +
227
+ ' ' + c.when.slice(0, 16).replace('T', ' ') +
228
+ ' summary of ' + c.chars.toLocaleString('en-US') + ' chars')
229
+ }
230
+ say()
231
+ say(' ' + cs.reduce((n, c) => n + c.chars, 0).toLocaleString('en-US') +
232
+ ' characters of summary have stood in for the record so far.')
233
+ }
234
+ },
235
+ }
236
+
237
+ COMMANDS.errors = {
238
+ use: "errors",
239
+ blurb: "mechanical failures in this record - the ones a hook could refuse",
240
+ run() {
241
+ /**
242
+ * Count the mechanical failures in your own record.
243
+ *
244
+ * Not "mistakes" in general - only the ones a machine can recognise from the error it produced,
245
+ * and therefore the only ones a hook could have refused before they happened. Each is a wasted
246
+ * round-trip: a command that could not have worked, sent anyway.
247
+ */
248
+ const PATTERNS = [
249
+ ['SyntaxError', 'a script that could not parse'],
250
+ ['command not found', 'a command that is not on this machine'],
251
+ ["No such file or directory: '/tmp/", '/tmp meaning two different places'],
252
+ ['unexpected EOF while looking for matching', 'unbalanced quoting in a shell command'],
253
+ ['String to replace not found', 'an Edit whose text was not in the file'],
254
+ ['unterminated string literal', 'a backslash inside a Python string'],
255
+ ['is not a valid statement separator', '&& in Windows PowerShell'],
256
+ ['ModuleNotFoundError', 'a Python import that is not installed'],
257
+ ['is not recognized as the name of a cmdlet', 'a unix command typed into PowerShell'],
258
+ ]
259
+ const { text } = load(path)
260
+ say(path)
261
+ say()
262
+ say(' Mechanical failures in this record - each one a round-trip that could not have worked:')
263
+ say()
264
+ let total = 0
265
+ const rows = PATTERNS.map(([p, what]) => {
266
+ let n = 0, from = 0
267
+ for (;;) { const i = text.indexOf(p, from); if (i < 0) break; n++; from = i + p.length }
268
+ total += n
269
+ return { p, what, n }
270
+ }).sort((a, b) => b.n - a.n)
271
+ for (const r of rows) {
272
+ if (r.n === 0) continue
273
+ say(' ' + String(r.n).padStart(6) + ' ' + r.what)
274
+ say(' ' + r.p)
275
+ }
276
+ say()
277
+ say(' ' + String(total).padStart(6) + ' in total')
278
+ say()
279
+ say(' Every one of these is recognisable before the command runs, which is what hooks/guard.mjs')
280
+ say(' refuses. Counting them here rather than quoting somebody else\'s number: yours are the')
281
+ say(' ones that matter, and they are the argument for installing the guards or not.')
282
+ say()
283
+ say(' (Some hits are the error being discussed rather than thrown - this counts text, not events.)')
284
+ },
285
+ }
286
+
287
+ COMMANDS.claims = {
288
+ use: "claims [n] [max]",
289
+ blurb: "what a summary asserts, each with a ready-made trace",
290
+ run() {
291
+ const { lines } = load(path)
292
+ const cs = compactions(lines, true)
293
+ say(path)
294
+ say()
295
+ if (cs.length === 0) {
296
+ say(' Never compacted, so nothing in your context was inherited. Nothing to check.')
297
+ } else {
298
+ const which = Number(args[1]) || cs.length
299
+ const c = cs[Math.max(0, Math.min(cs.length, which) - 1)]
300
+ const found = claimSentences(c.text)
301
+ say(' Summary #' + which + ' of ' + cs.length + ', line ' + c.line + ', ' +
302
+ c.when.slice(0, 16).replace('T', ' ') + ', ' + c.chars.toLocaleString('en-US') + ' chars.')
303
+ say(' ' + found.length + ' sentence(s) in it assert something checkable.')
304
+ say()
305
+ say(' This is a starting list, not a verdict: a sentence here is not wrong, and one that is')
306
+ say(' missing is not cleared. Trace the ones your next decision depends on.')
307
+ say()
308
+ found.slice(0, Number(args[2]) || 15).forEach((f, i) => {
309
+ say(' ' + String(i + 1).padStart(2) + '. [' + f.tags.join(',') + '] ' + f.text.slice(0, 150))
310
+ say(' recall trace "' + tracePhrase(f.text) + '"')
311
+ say()
312
+ })
313
+ if (found.length > (Number(args[2]) || 15)) {
314
+ say(' ... ' + (found.length - (Number(args[2]) || 15)) + ' more; pass a count: recall claims ' + which + ' 40')
315
+ }
316
+ }
317
+ },
318
+ }
319
+
320
+ COMMANDS.trace = {
321
+ use: "trace \"<text>\"",
322
+ blurb: "where did this fact enter? the record, or only a summary?",
323
+ run() {
324
+ const term = args[1]
325
+ if (!term) { console.error('usage: recall trace "<exact text>"'); process.exit(2) }
326
+ const { text, lines } = load(path)
327
+ const summaryLines = new Set(compactions(lines).map((c) => c.line))
328
+ const hits = occurrences(text, term)
329
+
330
+ say(path)
331
+ say()
332
+ if (hits.length === 0) {
333
+ say(' "' + term + '" does not appear in the record at all.')
334
+ say(' If something in your context asserts it, that assertion is its only source.')
335
+ } else {
336
+ const inSummary = hits.filter((h) => summaryLines.has(h.line))
337
+ const inRecord = hits.filter((h) => !summaryLines.has(h.line))
338
+ say(' "' + term + '" - ' + hits.length + ' occurrence(s): ' +
339
+ inRecord.length + ' in the record, ' + inSummary.length + ' inside compaction summaries.')
340
+ say()
341
+
342
+ if (inSummary.length === 0) {
343
+ say(' Never carried by a summary. It is in the record itself, first at line ' +
344
+ inRecord[0].line + '.')
345
+ say(' Read it with: recall around ' + inRecord[0].line)
346
+ say(' and ask whether it was MEASURED there, or merely written down.')
347
+ } else {
348
+ // The question worth asking is not "does it appear" but "was it in the record BEFORE a
349
+ // summary asserted it". A summary that is the earliest source is a summary that invented,
350
+ // or inherited, the claim - and everything after it is repetition, not evidence.
351
+ const firstSummary = inSummary[0].line
352
+ const earlier = inRecord.filter((h) => h.line < firstSummary)
353
+ say(' First stated in a compaction summary at line ' + firstSummary + '.')
354
+ say(' Occurrences in the record BEFORE that: ' + earlier.length)
355
+ say()
356
+ if (earlier.length === 0) {
357
+ say(' THE SUMMARY IS THE ORIGIN. Nothing in the record measured this before a summary')
358
+ say(' asserted it, so there is no evidence behind it in this session. Measure it now')
359
+ say(' rather than repeating it.')
360
+ } else {
361
+ say(' It was in the record first, earliest at line ' + earlier[0].line + '.')
362
+ say(' Read it with: recall around ' + earlier[0].line)
363
+ say(' and ask whether it was MEASURED there, or merely written down.')
364
+ }
365
+ const later = inRecord.filter((h) => h.line > firstSummary)
366
+ if (later.length) {
367
+ say()
368
+ say(' (' + later.length + ' occurrence(s) after the summary - repetition, and possibly this')
369
+ say(' very command: your own search lands in the record too.)')
370
+ }
371
+ }
372
+ }
373
+ },
374
+ }
375
+
376
+ COMMANDS.turns = {
377
+ use: "turns [n]",
378
+ blurb: "the last n things the operator asked for, with line numbers",
379
+ run() {
380
+ const limit = Number(args[1]) || 40
381
+ const { lines } = load(path)
382
+ const turns = humanTurns(lines)
383
+ say(path)
384
+ say(turns.length + ' things the operator asked for; newest ' + Math.min(limit, turns.length) + ':')
385
+ say()
386
+ for (const t of turns.slice(-limit)) {
387
+ say(' line ' + String(t.line).padStart(7) + ' ' + t.when.slice(0, 16).replace('T', ' ') +
388
+ ' ' + t.text.slice(0, 110))
389
+ }
390
+ },
391
+ }
392
+
393
+ COMMANDS.find = {
394
+ use: "find \"<text>\"",
395
+ blurb: "every byte-exact occurrence, with surrounding context",
396
+ run() {
397
+ const term = args[1]
398
+ if (!term) { console.error('usage: recall find "<exact text>"'); process.exit(2) }
399
+ const pad = Number(args[2]) || 90
400
+ const { text } = load(path)
401
+ const hits = occurrences(text, term, 30)
402
+ say(path)
403
+ say('"' + term + '" - ' + hits.length + (hits.length >= 30 ? '+' : '') + ' occurrence(s)')
404
+ say()
405
+ for (const h of hits) {
406
+ const s = text.slice(Math.max(0, h.off - pad), h.off + term.length + pad).replace(/\s+/g, ' ')
407
+ say(' line ' + String(h.line).padStart(7) + ' ...' + s + '...')
408
+ }
409
+ if (hits.length === 0) say(' Not in the record.')
410
+ },
411
+ }
412
+
413
+ COMMANDS.around = {
414
+ use: "around <line>",
415
+ blurb: "what was being worked on near that point",
416
+ run() {
417
+ const target = Number(args[1])
418
+ if (!target) { console.error('usage: recall around <line>'); process.exit(2) }
419
+ const { lines } = load(path)
420
+ const turns = humanTurns(lines)
421
+ const before = turns.filter((t) => t.line <= target).slice(-3)
422
+ const after = turns.filter((t) => t.line > target).slice(0, 2)
423
+ say(path)
424
+ say('what was being asked for around line ' + target + ':')
425
+ say()
426
+ for (const t of before.concat(after)) {
427
+ say(' ' + (t.line <= target ? ' ' : '>') + ' line ' + String(t.line).padStart(7) +
428
+ ' ' + t.when.slice(0, 16).replace('T', ' ') + ' ' + t.text.slice(0, 110))
429
+ }
430
+ },
431
+ }
432
+
433
+ /**
434
+ * One table, so a command that exists is listed and a listed command exists.
435
+ *
436
+ * The help used to be written out separately, and drifted the first time commands were added: two
437
+ * of them worked and appeared nowhere. Generating it from the same object the dispatch reads makes
438
+ * that impossible rather than unlikely.
439
+ */
440
+ function help() {
441
+ const width = Math.max(...Object.values(COMMANDS).map((c) => c.use.length)) + 2
442
+ say('recall - read this session\'s own record, so an inherited "fact" can be checked.')
443
+ say()
444
+ for (const c of Object.values(COMMANDS)) say(' recall ' + c.use.padEnd(width) + c.blurb)
445
+ say()
446
+ say(' --file ' + '<path>'.padEnd(width) + 'an older transcript instead of the live one')
447
+ say()
448
+ say('Nothing is written and nothing is cached; the record is searched directly.')
449
+ }
450
+
451
+ const chosen = COMMANDS[cmd]
452
+ if (chosen) chosen.run()
453
+ else help()