@tznthou/ccrecall 0.8.2 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tznthou/ccrecall",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "AI memory service for Claude Code — on-demand context injection with metacognition",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -284,7 +284,7 @@ ${prompt}"
284
284
  # so a failure can say what it was.
285
285
  extract_stderr=$(cd "$session_cwd" && printf '%s' "$full_prompt" | command claude -p \
286
286
  --no-session-persistence \
287
- --model haiku \
287
+ --model sonnet \
288
288
  ${budget_args[@]+"${budget_args[@]}"} \
289
289
  --max-turns 5 \
290
290
  --dangerously-skip-permissions 2>&1 1>/dev/null)
@@ -80,6 +80,91 @@ _ccrecall_log_append() {
80
80
  { jq -n -c "$@" >> "$f"; } 2>/dev/null
81
81
  }
82
82
 
83
+ # The wrapper's only database access, and the only one it may ever have:
84
+ # ccRecall is read-only over ~/.claude and the extraction path has no business
85
+ # writing to its own store either — the MCP server owns that. `-readonly` is
86
+ # the enforcement; tests/extract-wrapper-retry.test.ts asserts a write through
87
+ # this very handle fails rather than trusting the intent.
88
+ #
89
+ # `command sqlite3`, not a bare one: this file is sourced into an interactive
90
+ # shell where an alias or function shadows an external command (#99), and a
91
+ # hijacked sqlite3 here would decide whether a session gets retried.
92
+ #
93
+ # The busy timeout matters because the MCP server may be mid-write from another
94
+ # session ending at the same time; without it a locked database reads as "no
95
+ # rows" and a healthy run would be retried. A failure returns non-zero with no
96
+ # output, which the caller reads as "unknown" — never as zero.
97
+ _ccrecall_sqlite_ro() {
98
+ local db="$1" sql="$2"
99
+ command -v sqlite3 >/dev/null 2>&1 || return 1
100
+ [ -f "$db" ] || return 1
101
+ command sqlite3 -readonly -cmd '.timeout 2000' "$db" "$sql" 2>/dev/null
102
+ }
103
+
104
+ # How many memories THIS extraction wrote, which is not the same question as
105
+ # how many memories the session has. A session the user saved into by hand
106
+ # carries `explicit` rows, and counting those is exactly the error that made a
107
+ # broken run look successful when this was first measured (a run reported as
108
+ # 16/17 was really 9/11). Only `agent-inferred` rows come from extraction.
109
+ #
110
+ # Prints the count on success. On any failure — no sqlite3, no database, a
111
+ # locked database, a schema that does not match — prints nothing and returns
112
+ # non-zero, and the caller must treat that as unknown rather than as zero.
113
+ _ccrecall_count_extracted() {
114
+ local sid="$1"
115
+ local db="${CCRECALL_DB_PATH:-$HOME/.ccrecall/ccrecall.db}"
116
+ # The caller validated this against a uuid pattern before any filesystem
117
+ # path was built from it; the same validation is what makes interpolating
118
+ # it into SQL safe here. Re-checked rather than assumed, because this
119
+ # function is also reachable directly.
120
+ case "$sid" in
121
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-*) : ;;
122
+ *) return 1 ;;
123
+ esac
124
+ local n
125
+ n=$(_ccrecall_sqlite_ro "$db" \
126
+ "SELECT COUNT(*) FROM memories WHERE session_id='${sid}' AND origin='agent-inferred';") || return 1
127
+ [[ "$n" =~ ^[0-9]+$ ]] || return 1
128
+ printf '%s' "$n"
129
+ }
130
+
131
+ # Whether the run that just finished is worth spending one more extraction on.
132
+ #
133
+ # Returns 0 (retry) for the two shapes that mean the model did not do the job:
134
+ # it printed the calls as text, or it wrote nothing while holding a transcript
135
+ # with real content in it. Everything else returns non-zero.
136
+ #
137
+ # Measured 2026-09-18 over 257 clean runs: 19 wrote nothing, but only 7 of
138
+ # those held a substantial transcript — the other 12 were thin sessions where
139
+ # zero is the correct answer ("Save 0-5 insights" makes 0 legal) or transcripts
140
+ # already deleted. Retrying the thin ones would spend quota to re-confirm
141
+ # nothing, which is why the byte threshold exists rather than retrying every
142
+ # zero.
143
+ #
144
+ # Arguments: exit_code text_count transcript_bytes extracted_count attempt
145
+ # `extracted_count` is the empty string when it could not be determined.
146
+ _ccrecall_should_retry() {
147
+ local exit_code="$1" text_count="$2" bytes="$3" extracted="$4" attempt="$5"
148
+ local min_bytes="${CCRECALL_ZERO_WRITE_MIN_BYTES:-20000}"
149
+
150
+ # One retry, never a loop: a second failure is a signal to surface, not to
151
+ # keep paying for.
152
+ [ "$attempt" -ge 2 ] 2>/dev/null && return 1
153
+ # A non-zero exit already carries its own diagnostic (argv limits, auth,
154
+ # budget) and none of those causes are transient.
155
+ [ "$exit_code" -eq 0 ] 2>/dev/null || return 1
156
+
157
+ # The text marker stands on its own — it needs no database, which is what
158
+ # makes it the only detector that works when sqlite3 is missing.
159
+ [ "$text_count" -gt 0 ] 2>/dev/null && return 0
160
+
161
+ # Everything below needs to know the write count really was zero.
162
+ [ -n "$extracted" ] || return 1
163
+ [ "$extracted" -eq 0 ] 2>/dev/null || return 1
164
+ [ "$bytes" -ge "$min_bytes" ] 2>/dev/null || return 1
165
+ return 0
166
+ }
167
+
83
168
  # Resolve the directory containing this script (for prompt file).
84
169
  # zsh leaves BASH_SOURCE empty (the user's shell is zsh), so detect zsh and
85
170
  # use its %x prompt path; eval isolates the zsh-only ${(%):-%x} syntax from
@@ -298,6 +383,15 @@ ${prompt}"
298
383
  local extract_start
299
384
  extract_start=$(date +%s)
300
385
 
386
+ # Size of what the model is actually given, which is what separates "this
387
+ # session had nothing to say" from "the model did not do the job". Measured
388
+ # after the 200KB cap, in bytes: LC_ALL=C keeps wc byte-oriented so a CJK
389
+ # transcript is not undercounted by a third against the threshold.
390
+ local transcript_bytes
391
+ transcript_bytes=$(builtin printf '%s' "$session_transcript" \
392
+ | LC_ALL=C command wc -c | command tr -d ' ')
393
+ [[ "$transcript_bytes" =~ ^[0-9]+$ ]] || transcript_bytes=0
394
+
301
395
  # Spend cap only matters under API billing. With no ANTHROPIC_API_KEY,
302
396
  # `claude -p` runs on the Pro/Max subscription quota and --max-budget-usd
303
397
  # would gate on phantom API-equivalent cost — the root cause of the
@@ -417,12 +511,25 @@ ${prompt}"
417
511
  # right here — before the exit code is captured, before the capture file is
418
512
  # deleted, and before either the terminal notice or the telemetry row. The
419
513
  # failure reporting below is worth nothing in a shell it never reaches.
514
+ # One retry, at most (#75 follow-up). The loop exists because two failure
515
+ # shapes are both recoverable and both look like success from the outside:
516
+ # the model printing the calls as text, and the model doing nothing at all
517
+ # with a substantial transcript in hand. Re-running the 2026-09-17 miss with
518
+ # the SAME model produced four memories, so the failure is not a capability
519
+ # ceiling — it was simply never retried, and those memories sat outside the
520
+ # database until a human noticed a line of terminal output five hours later.
521
+ #
522
+ # `1>|` truncates $stdout_tmp on each pass, so the markers below always
523
+ # describe the attempt that just ran and never the previous one.
524
+ local attempt=1 extract_retried=0 extracted_count=""
525
+ local stdout_marker recall_save_text_count
526
+ while : ; do
420
527
  if extract_stderr=$(
421
528
  trap 'command rm -f -- "$stdout_tmp" 2>/dev/null; exit 130' INT
422
529
  trap 'command rm -f -- "$stdout_tmp" 2>/dev/null; exit 143' TERM
423
530
  builtin printf '%s' "$full_prompt" | command claude -p \
424
531
  --no-session-persistence \
425
- --model haiku \
532
+ --model sonnet \
426
533
  ${budget_args[@]+"${budget_args[@]}"} \
427
534
  --max-turns 5 \
428
535
  --dangerously-skip-permissions 2>&1 1>|"$stdout_tmp"
@@ -440,7 +547,6 @@ ${prompt}"
440
547
  # outcome here, and under a caller with errexit that ordinary outcome aborts
441
548
  # the shell — taking the terminal notice and the telemetry row with it. Same
442
549
  # reason the claude invocation above is wrapped in `if`.
443
- local stdout_marker
444
550
  stdout_marker=$(grep -m1 -oE '^Error: Reached max turns \([0-9]+\)$' "$stdout_tmp" 2>/dev/null) || :
445
551
  # Second marker (#75): the model sometimes PRINTS `recall_save(...)` instead of
446
552
  # invoking the MCP tool. That run exits 0 with empty stderr and writes nothing,
@@ -460,11 +566,26 @@ ${prompt}"
460
566
  #
461
567
  # -c yields a bare count and never the matched line, so no session content can
462
568
  # reach the telemetry log — the same constraint that governs the stdout capture.
463
- local recall_save_text_count
464
569
  recall_save_text_count=$(grep -cE '^[[:space:]]*recall_save[[:space:]]*\(' "$stdout_tmp" 2>/dev/null) || :
465
570
  # grep exits 1 with empty output on no match (and $stdout_tmp is /dev/null when
466
571
  # mktemp failed); --argjson would abort the whole telemetry write on a non-number.
467
572
  [[ "$recall_save_text_count" =~ ^[0-9]+$ ]] || recall_save_text_count=0
573
+
574
+ # Did this attempt actually put anything in the database? An empty result
575
+ # means undeterminable (no sqlite3, no database, a locked one) and is NOT
576
+ # read as zero — silence is not evidence of failure, and retrying on it
577
+ # would spend a second extraction every time the query simply could not run.
578
+ extracted_count=$(_ccrecall_count_extracted "$session_id") || extracted_count=""
579
+
580
+ if _ccrecall_should_retry "$extract_exit" "$recall_save_text_count" \
581
+ "$transcript_bytes" "$extracted_count" "$attempt"; then
582
+ attempt=$((attempt + 1))
583
+ extract_retried=1
584
+ printf '🔁 ccRecall: extraction produced nothing usable — retrying once...\n'
585
+ continue
586
+ fi
587
+ break
588
+ done
468
589
  command rm -f -- "$stdout_tmp" 2>/dev/null
469
590
  # Scrub common credential formats before stderr reaches the telemetry log.
470
591
  # claude echoes its own key (sk-ant-) in auth errors, and any MCP server
@@ -480,13 +601,25 @@ ${prompt}"
480
601
  local extract_duration=$(( extract_end - extract_start ))
481
602
 
482
603
  if [[ $extract_exit -eq 0 ]]; then
483
- # A clean exit that printed call syntax is the silent-miss shape (#75): say so
484
- # at the terminal, where it can still be acted on, instead of only in the log.
485
- if [[ $recall_save_text_count -gt 0 ]]; then
486
- printf '⚠️ ccRecall: extraction exited cleanly (%ds) but printed %d recall_save call(s) as text instead of invoking the tool — memories from this session may not have been saved.\n' \
487
- "$extract_duration" "$recall_save_text_count"
604
+ # Three outcomes now, not two. The retry means a run that reaches here
605
+ # having written something is a success even if the first attempt printed
606
+ # call syntax — reporting that as a failure would train the reader to
607
+ # ignore the notice, which is how the original miss went unacted on.
608
+ local retry_note=""
609
+ [[ $extract_retried -eq 1 ]] && retry_note=" after one retry"
610
+ if [[ -n "$extracted_count" && "$extracted_count" -gt 0 ]] 2>/dev/null; then
611
+ printf '✅ ccRecall: extraction complete (%ds)%s — %d memories saved.\n' \
612
+ "$extract_duration" "$retry_note" "$extracted_count"
613
+ elif [[ $recall_save_text_count -gt 0 ]]; then
614
+ printf '⚠️ ccRecall: extraction exited cleanly (%ds)%s but printed %d recall_save call(s) as text instead of invoking the tool — memories from this session were not saved.\n' \
615
+ "$extract_duration" "$retry_note" "$recall_save_text_count"
616
+ elif [[ -n "$extracted_count" && "$extracted_count" -eq 0 && $transcript_bytes -ge ${CCRECALL_ZERO_WRITE_MIN_BYTES:-20000} ]] 2>/dev/null; then
617
+ # The shape the text marker never caught. Previously indistinguishable
618
+ # from "nothing worth saving" — 7 of these in 257 runs, none reported.
619
+ printf '⚠️ ccRecall: extraction exited cleanly (%ds)%s but saved nothing from a %dKB transcript — likely a silent miss.\n' \
620
+ "$extract_duration" "$retry_note" "$(( transcript_bytes / 1024 ))"
488
621
  else
489
- printf '✅ ccRecall: extraction complete (%ds).\n' "$extract_duration"
622
+ printf '✅ ccRecall: extraction complete (%ds)%s.\n' "$extract_duration" "$retry_note"
490
623
  fi
491
624
  else
492
625
  # Carry the first line of stderr (#120). Without it the terminal said only
@@ -547,7 +680,10 @@ ${prompt}"
547
680
  --argjson exit "$extract_exit" \
548
681
  --argjson dur "$extract_duration" \
549
682
  --argjson textSaves "$recall_save_text_count" \
550
- '{ts:$ts,sessionId:$sid,projectId:$pid,mode:"jsonl",exitCode:$exit,durationSec:$dur,stderr:$stderr,stdoutMarker:$marker,recallSaveTextCount:$textSaves}' || :
683
+ --argjson retried "$extract_retried" \
684
+ --argjson trBytes "$transcript_bytes" \
685
+ --arg wrote "$extracted_count" \
686
+ '{ts:$ts,sessionId:$sid,projectId:$pid,mode:"jsonl",exitCode:$exit,durationSec:$dur,stderr:$stderr,stdoutMarker:$marker,recallSaveTextCount:$textSaves,retried:($retried==1),transcriptBytes:$trBytes,memoriesWritten:(if $wrote=="" then null else ($wrote|tonumber) end)}' || :
551
687
 
552
688
  return $claude_exit
553
689
  }