@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.
@@ -168,7 +168,14 @@ ccrecall-extract() {
168
168
  if [[ -f "$prompt_file" ]]; then
169
169
  prompt=$(cat "$prompt_file")
170
170
  else
171
- 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. Set projectId to \"${project_id}\" for project-specific knowledge; omit for cross-project knowledge."
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. $? still reflects claude's exit (the assignment is the last
303
- # command before it).
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
- extract_stderr=$(
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
- "$full_prompt" 2>&1 1>"$stdout_tmp")
342
- extract_exit=$?
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
- printf '⚠️ ccRecall: extraction exited with code %d (%ds)%s.\n' \
397
- "$extract_exit" "$extract_duration" "${stdout_marker:+ — $stdout_marker}"
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).