@tznthou/ccrecall 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -16
- package/README_ZH.md +29 -16
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +25 -10
- package/dist/api/routes.js.map +1 -1
- package/dist/core/database.d.ts +52 -0
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +173 -23
- package/dist/core/database.js.map +1 -1
- package/dist/core/integrity-monitor.d.ts.map +1 -1
- package/dist/core/integrity-monitor.js +7 -3
- package/dist/core/integrity-monitor.js.map +1 -1
- package/dist/core/recall-telemetry.d.ts.map +1 -1
- package/dist/core/recall-telemetry.js +9 -3
- package/dist/core/recall-telemetry.js.map +1 -1
- package/dist/core/secure-dir.d.ts +7 -0
- package/dist/core/secure-dir.d.ts.map +1 -0
- package/dist/core/secure-dir.js +64 -0
- package/dist/core/secure-dir.js.map +1 -0
- package/dist/core/token-budget.d.ts +59 -3
- package/dist/core/token-budget.d.ts.map +1 -1
- package/dist/core/token-budget.js +115 -11
- package/dist/core/token-budget.js.map +1 -1
- package/dist/mcp/tools.d.ts +2 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +51 -3
- package/dist/mcp/tools.js.map +1 -1
- package/hooks/lib/secure-dir.mjs +52 -0
- package/hooks/session-start.mjs +20 -3
- package/package.json +3 -2
- package/scripts/backfill-extract.sh +311 -0
- package/scripts/extraction-prompt.md +52 -1
- package/scripts/l3-prescription-position.py +835 -0
- package/scripts/post-session-extract.sh +141 -16
|
@@ -168,7 +168,14 @@ ccrecall-extract() {
|
|
|
168
168
|
if [[ -f "$prompt_file" ]]; then
|
|
169
169
|
prompt=$(cat "$prompt_file")
|
|
170
170
|
else
|
|
171
|
-
|
|
171
|
+
# Keep the "lead with the prescription" rule in sync with
|
|
172
|
+
# extraction-prompt.md — only the first 149 chars of a memory survive
|
|
173
|
+
# injection, so a memory that opens with narrative arrives unactionable.
|
|
174
|
+
# tests/extraction-prompt-prescription.test.ts pins the rule AND the number
|
|
175
|
+
# below against token-budget.ts, in both copies. 🔴 Until 2026-09-08 this
|
|
176
|
+
# comment already said that while no test read this line's number at all:
|
|
177
|
+
# it could say 999 and stay green. Verified by mutation, both directions.
|
|
178
|
+
prompt="You are a memory extraction agent. Save 0-5 lasting insights via recall_save. Each memory must be self-contained with a key slug for dedup. Only the first 149 characters survive injection into a future session, so lead with what a future reader should DO and put the evidence, file names and war story after it. Set projectId to \"${project_id}\" for project-specific knowledge; omit for cross-project knowledge. Always pass origin=\"agent-inferred\": you are reading a finished transcript with nobody watching, and that flag is what stops your save from overwriting a memory the user wrote by hand under the same key. Pass messageId: the uuid in the \"--- human [<uuid>] ---\" header of the one message a memory came from, copied verbatim — omit it when no single message is the source, and never guess a uuid."
|
|
172
179
|
fi
|
|
173
180
|
|
|
174
181
|
# Append runtime context (projectId) to the prompt
|
|
@@ -196,21 +203,46 @@ ccrecall-extract() {
|
|
|
196
203
|
local session_transcript
|
|
197
204
|
# A2: -R + fromjson? skips malformed JSONL lines instead of aborting
|
|
198
205
|
# A1+A5: head -c 200000 + iconv -c strips incomplete UTF-8 at boundary
|
|
206
|
+
#
|
|
207
|
+
# Q1 (2026-09-11): each header carries the message uuid, so a memory can
|
|
208
|
+
# name the message it came from (`memories.message_id`, 0/1639 filled
|
|
209
|
+
# before this). The transcript was the blocker, not the schema: the
|
|
210
|
+
# column and the recall_save parameter both already existed, and the
|
|
211
|
+
# model had nothing to point at.
|
|
212
|
+
#
|
|
213
|
+
# Costs ~40 bytes per message against the 200KB cap. Measured on a real
|
|
214
|
+
# 151-message session: 119,507 -> 125,396 bytes, +4.93%. (The share is
|
|
215
|
+
# higher on an English-heavy session — that one is mostly CJK, where the
|
|
216
|
+
# body text costs 3 bytes a character and dilutes the ASCII uuids. head
|
|
217
|
+
# -c counts bytes, not characters.) A session already at the cap loses
|
|
218
|
+
# that much off its tail, which is a real cost, accepted because the tail
|
|
219
|
+
# is what the model has most recently read in full, while provenance is
|
|
220
|
+
# unrecoverable after the fact — 65.6% of stored memories have already
|
|
221
|
+
# been rewritten by compression.
|
|
222
|
+
#
|
|
223
|
+
# gsub keeps the header structurally intact: the transcript is untrusted
|
|
224
|
+
# data, and a uuid holding `] ---` would otherwise let a crafted JSONL
|
|
225
|
+
# forge message boundaries. Restricting the characters rather than
|
|
226
|
+
# matching a uuid shape is deliberate — a format gate would stop printing
|
|
227
|
+
# citations the day Claude Code changes its id format, silently. A value
|
|
228
|
+
# mangled by gsub simply fails verification on the write side.
|
|
199
229
|
session_transcript=$(jq -R -r '
|
|
200
230
|
fromjson? // empty |
|
|
231
|
+
(((.uuid | strings) // "") | gsub("[^A-Za-z0-9-]"; "")) as $u |
|
|
232
|
+
(if $u == "" then "" else " [" + $u + "]" end) as $cite |
|
|
201
233
|
if .type == "user" and .message then
|
|
202
234
|
.message.content |
|
|
203
235
|
if type == "array" then
|
|
204
236
|
[.[] | select(.type == "text") | .text // empty] | join("\n") |
|
|
205
|
-
if . != "" then "--- human ---\n" + . else empty end
|
|
237
|
+
if . != "" then "--- human" + $cite + " ---\n" + . else empty end
|
|
206
238
|
elif type == "string" then
|
|
207
|
-
if . != "" then "--- human ---\n" + . else empty end
|
|
239
|
+
if . != "" then "--- human" + $cite + " ---\n" + . else empty end
|
|
208
240
|
else empty end
|
|
209
241
|
elif .type == "assistant" and .message then
|
|
210
242
|
.message.content |
|
|
211
243
|
if type == "array" then
|
|
212
244
|
[.[] | select(.type == "text") | .text // empty] | join("\n") |
|
|
213
|
-
if . != "" then "--- assistant ---\n" + . else empty end
|
|
245
|
+
if . != "" then "--- assistant" + $cite + " ---\n" + . else empty end
|
|
214
246
|
else empty end
|
|
215
247
|
else empty end
|
|
216
248
|
' "$jsonl_path" 2>/dev/null | head -c 200000 | iconv -c -f utf-8 -t utf-8)
|
|
@@ -275,6 +307,14 @@ ${prompt}"
|
|
|
275
307
|
if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then
|
|
276
308
|
budget_args=(--max-budget-usd "${CCRECALL_EXTRACT_MAX_BUDGET_USD:-0.50}")
|
|
277
309
|
fi
|
|
310
|
+
# Expanded below as ${budget_args[@]+"${budget_args[@]}"}, never the bare
|
|
311
|
+
# "${budget_args[@]}". This file is SOURCED into the user's shell and inherits
|
|
312
|
+
# whatever they set: under `set -u`, bash 3.2 — still what /bin/bash is on
|
|
313
|
+
# macOS — treats an EMPTY array's "${a[@]}" as an unbound variable, and with no
|
|
314
|
+
# ANTHROPIC_API_KEY the array is empty on the ordinary subscription path. The
|
|
315
|
+
# shell would abort inside the command substitution and claude never exec.
|
|
316
|
+
# zsh and bash 4+ are both fine with the bare form, which is why this only
|
|
317
|
+
# shows up for a bash-3.2 caller who sets -u. Verified across all three.
|
|
278
318
|
|
|
279
319
|
# Haiku is prompted to emit NO text — only its recall_save MCP tool calls
|
|
280
320
|
# carry the result, and those travel over MCP, never through stdout. Small
|
|
@@ -299,8 +339,20 @@ ${prompt}"
|
|
|
299
339
|
# concurrency-safe where a blanket rm of a fixed name was not). No byte cap
|
|
300
340
|
# on the capture: model output is already bounded by Haiku's output-token
|
|
301
341
|
# ceiling. Only stderr — claude's own diagnostics — is captured, scrubbed,
|
|
302
|
-
# and logged.
|
|
303
|
-
#
|
|
342
|
+
# and logged.
|
|
343
|
+
#
|
|
344
|
+
# $? reflects claude's exit: it is the last command of the pipeline below.
|
|
345
|
+
# Under a user shell with pipefail set, what was actually verified (bash and
|
|
346
|
+
# zsh, 2026-09-16) is narrower than "the printf never interferes": a NON-ZERO
|
|
347
|
+
# claude survives, because pipefail reports the rightmost non-zero status and
|
|
348
|
+
# that is claude's. The case it does not cover is claude exiting ZERO without
|
|
349
|
+
# draining stdin — then the SIGPIPE'd printf's 141 is the only non-zero status
|
|
350
|
+
# and pipefail would report it, turning a successful extraction into a
|
|
351
|
+
# spurious failure. That needs `claude -p` to stop reading its prompt early,
|
|
352
|
+
# which it does not do (no prompt argument means it reads stdin to EOF), so
|
|
353
|
+
# this is a bound on the claim rather than a live failure path. Stated exactly
|
|
354
|
+
# because the earlier version of this comment generalised from the non-zero
|
|
355
|
+
# case it had tested, which is how a verified fact becomes a wrong one.
|
|
304
356
|
local log_dir
|
|
305
357
|
log_dir=$(dirname "$CCRECALL_EXTRACT_LOG")
|
|
306
358
|
# Same umask as the log guard uses. This line runs ~95 lines before the
|
|
@@ -329,23 +381,67 @@ ${prompt}"
|
|
|
329
381
|
# user's interactive shell, so a bare rm resolves through their aliases —
|
|
330
382
|
# a trash-style wrapper that rejects `--` intercepts it and the capture is
|
|
331
383
|
# stranded (#99). Same hazard `command claude` below already guards.
|
|
332
|
-
|
|
384
|
+
#
|
|
385
|
+
# The prompt goes over STDIN, not argv (#120). As one argv parameter it hit a
|
|
386
|
+
# per-argument ceiling that `head -c 200000` sits well above, so the cap we do
|
|
387
|
+
# enforce could never fire. Two different ceilings, and both are real:
|
|
388
|
+
#
|
|
389
|
+
# - Linux: the kernel refuses any single argument over MAX_ARG_STRLEN (32
|
|
390
|
+
# PAGES, so 131,072 bytes at a 4kB page size — but 2 MiB at 64kB pages,
|
|
391
|
+
# above our cap, so those systems were never affected) with E2BIG. No
|
|
392
|
+
# wrapper needed; at 4kB the effective transcript budget was ~123,000 bytes.
|
|
393
|
+
# - macOS: no per-argument ceiling of its own (a 900,000-byte argument
|
|
394
|
+
# passes; only ARG_MAX at 1,048,576 applies), but a terminal that installs
|
|
395
|
+
# its own `claude` shim can add one. cmux caps a single argument at
|
|
396
|
+
# 122,880 bytes and returns 2 before Claude Code ever starts.
|
|
397
|
+
#
|
|
398
|
+
# Either way the run exits in 0 seconds having saved nothing. `command claude`
|
|
399
|
+
# is not a defence against the shim case: it sits on PATH, and `command` skips
|
|
400
|
+
# shell functions and aliases, not PATH entries. Neither ceiling applies to
|
|
401
|
+
# stdin, so `head -c 200000` above is once again the only limit in play.
|
|
402
|
+
#
|
|
403
|
+
# `builtin printf`, not a bare one: this file is sourced into the user's
|
|
404
|
+
# interactive shell, where a shell function shadows a builtin (the same hazard
|
|
405
|
+
# as the rm alias in #99, one command over). A hijacked printf would substitute
|
|
406
|
+
# the prompt silently, and extraction would then succeed having read something
|
|
407
|
+
# other than the session. `command printf` would be wrong here — it forces the
|
|
408
|
+
# EXTERNAL /usr/bin/printf, putting the prompt back on an argv, which is the
|
|
409
|
+
# bug this whole change exists to fix.
|
|
410
|
+
#
|
|
411
|
+
# `1>|` overrides noclobber. $stdout_tmp is a file mktemp just created, so
|
|
412
|
+
# under a caller with `set -o noclobber` a plain `1>` refuses to open it and
|
|
413
|
+
# claude never execs at all.
|
|
414
|
+
#
|
|
415
|
+
# Wrapped in `if` rather than assigned and then read via $?: under a caller
|
|
416
|
+
# with `set -o errexit`, a non-zero command substitution aborts the shell
|
|
417
|
+
# right here — before the exit code is captured, before the capture file is
|
|
418
|
+
# deleted, and before either the terminal notice or the telemetry row. The
|
|
419
|
+
# failure reporting below is worth nothing in a shell it never reaches.
|
|
420
|
+
if extract_stderr=$(
|
|
333
421
|
trap 'command rm -f -- "$stdout_tmp" 2>/dev/null; exit 130' INT
|
|
334
422
|
trap 'command rm -f -- "$stdout_tmp" 2>/dev/null; exit 143' TERM
|
|
335
|
-
command claude -p \
|
|
423
|
+
builtin printf '%s' "$full_prompt" | command claude -p \
|
|
336
424
|
--no-session-persistence \
|
|
337
425
|
--model haiku \
|
|
338
|
-
"${budget_args[@]}" \
|
|
426
|
+
${budget_args[@]+"${budget_args[@]}"} \
|
|
339
427
|
--max-turns 5 \
|
|
340
|
-
--dangerously-skip-permissions
|
|
341
|
-
|
|
342
|
-
|
|
428
|
+
--dangerously-skip-permissions 2>&1 1>|"$stdout_tmp"
|
|
429
|
+
); then
|
|
430
|
+
extract_exit=0
|
|
431
|
+
else
|
|
432
|
+
extract_exit=$?
|
|
433
|
+
fi
|
|
343
434
|
# Reduce stdout to the diagnostic marker, then delete the full capture.
|
|
344
435
|
# Anchored to claude's full notice line: an unanchored match could be faked
|
|
345
436
|
# by the model echoing transcript content that merely discusses this exact
|
|
346
437
|
# phrase (guaranteed to occur in this repo's own sessions).
|
|
438
|
+
#
|
|
439
|
+
# `|| :` on both marker greps: grep exits 1 on no match, which is the ORDINARY
|
|
440
|
+
# outcome here, and under a caller with errexit that ordinary outcome aborts
|
|
441
|
+
# the shell — taking the terminal notice and the telemetry row with it. Same
|
|
442
|
+
# reason the claude invocation above is wrapped in `if`.
|
|
347
443
|
local stdout_marker
|
|
348
|
-
stdout_marker=$(grep -m1 -oE '^Error: Reached max turns \([0-9]+\)$' "$stdout_tmp" 2>/dev/null)
|
|
444
|
+
stdout_marker=$(grep -m1 -oE '^Error: Reached max turns \([0-9]+\)$' "$stdout_tmp" 2>/dev/null) || :
|
|
349
445
|
# Second marker (#75): the model sometimes PRINTS `recall_save(...)` instead of
|
|
350
446
|
# invoking the MCP tool. That run exits 0 with empty stderr and writes nothing,
|
|
351
447
|
# which in telemetry is byte-identical to a session that genuinely had nothing
|
|
@@ -365,7 +461,7 @@ ${prompt}"
|
|
|
365
461
|
# -c yields a bare count and never the matched line, so no session content can
|
|
366
462
|
# reach the telemetry log — the same constraint that governs the stdout capture.
|
|
367
463
|
local recall_save_text_count
|
|
368
|
-
recall_save_text_count=$(grep -cE '^[[:space:]]*recall_save[[:space:]]*\(' "$stdout_tmp" 2>/dev/null)
|
|
464
|
+
recall_save_text_count=$(grep -cE '^[[:space:]]*recall_save[[:space:]]*\(' "$stdout_tmp" 2>/dev/null) || :
|
|
369
465
|
# grep exits 1 with empty output on no match (and $stdout_tmp is /dev/null when
|
|
370
466
|
# mktemp failed); --argjson would abort the whole telemetry write on a non-number.
|
|
371
467
|
[[ "$recall_save_text_count" =~ ^[0-9]+$ ]] || recall_save_text_count=0
|
|
@@ -393,8 +489,37 @@ ${prompt}"
|
|
|
393
489
|
printf '✅ ccRecall: extraction complete (%ds).\n' "$extract_duration"
|
|
394
490
|
fi
|
|
395
491
|
else
|
|
396
|
-
|
|
397
|
-
|
|
492
|
+
# Carry the first line of stderr (#120). Without it the terminal said only
|
|
493
|
+
# "exited with code 2" while the actual reason — `cmux: argument too large`
|
|
494
|
+
# — sat in the telemetry log, so the one person who could act on it had to
|
|
495
|
+
# go and parse JSONL to find out anything at all. First line only, and it is
|
|
496
|
+
# the already-scrubbed copy: claude's diagnostics open with the reason, and
|
|
497
|
+
# a multi-line dump at session exit buries it again.
|
|
498
|
+
#
|
|
499
|
+
# Capped at 200 characters, because this is a SECOND sink for that stderr
|
|
500
|
+
# and it is the unprotected one: the telemetry log is 0600 precisely
|
|
501
|
+
# because these diagnostics can carry paths and account names, while
|
|
502
|
+
# terminal scrollback is captured by tmux logging, session recorders and
|
|
503
|
+
# screen sharing. The scrubber above is a best-effort denylist of known
|
|
504
|
+
# credential prefixes, so what reaches here is not guaranteed clean — and
|
|
505
|
+
# an MCP server failing its own auth can print whatever it likes. 200 is
|
|
506
|
+
# sized off real diagnostics ("cmux: argument too large (maximum 122880
|
|
507
|
+
# bytes)" is 44 characters); the longer capture stays in the log.
|
|
508
|
+
#
|
|
509
|
+
# Reduced to printable ASCII BEFORE truncation. `%s` stops the string being
|
|
510
|
+
# read as a format, but not from being read by the TERMINAL: an ESC, CR or
|
|
511
|
+
# OSC sequence in claude's diagnostics — or in a failing MCP server's — can
|
|
512
|
+
# overwrite this very notice, clear the screen, or reach an OSC handler. And
|
|
513
|
+
# cutting at a fixed length can sever a sequence partway, leaving whatever
|
|
514
|
+
# the fragment set. Substituting first makes the slice deterministic too:
|
|
515
|
+
# one byte, one character, no locale in the middle of it. LC_ALL=C keeps tr
|
|
516
|
+
# byte-oriented so a multi-byte character cannot survive half-translated.
|
|
517
|
+
local stderr_line
|
|
518
|
+
stderr_line=$(printf '%s' "${extract_stderr%%$'\n'*}" \
|
|
519
|
+
| LC_ALL=C command tr -c '\040-\176' '?') || :
|
|
520
|
+
printf '⚠️ ccRecall: extraction exited with code %d (%ds)%s%s.\n' \
|
|
521
|
+
"$extract_exit" "$extract_duration" "${stdout_marker:+ — $stdout_marker}" \
|
|
522
|
+
"${stderr_line:+ — ${stderr_line:0:200}}"
|
|
398
523
|
fi
|
|
399
524
|
|
|
400
525
|
# Telemetry log (-c = one compact JSON object per line = valid JSONL).
|