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 +21 -0
- package/README.md +253 -0
- package/SKILL.md +96 -0
- package/demo.mjs +110 -0
- package/docs/demo.gif +0 -0
- package/hooks/guard.mjs +165 -0
- package/hooks/install-hooks.mjs +78 -0
- package/hooks/prove.mjs +87 -0
- package/package.json +44 -0
- package/recall.mjs +453 -0
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
|
+

|
|
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
|
package/hooks/guard.mjs
ADDED
|
@@ -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.')
|
package/hooks/prove.mjs
ADDED
|
@@ -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()
|