@tznthou/ccrecall 0.8.1 → 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 +3 -2
- package/scripts/backfill-extract.sh +27 -6
- package/scripts/post-session-extract.sh +146 -10
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tznthou/ccrecall",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"ccmem": "./dist/index.js",
|
|
9
|
-
"ccmem-mcp": "./dist/mcp/server.js"
|
|
9
|
+
"ccmem-mcp": "./dist/mcp/server.js",
|
|
10
|
+
"ccmem-backfill": "./scripts/backfill-extract.sh"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"dist",
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
# ccRecall backfill extraction — re-run memory extraction for sessions whose
|
|
3
3
|
# original run never happened.
|
|
4
4
|
#
|
|
5
|
-
# Usage:
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Usage (installed from npm — the bin entry is what makes it executable):
|
|
6
|
+
# ccmem-backfill <session-id> [session-id...]
|
|
7
|
+
# ccmem-backfill --from-log # sessions this bug killed
|
|
8
|
+
# ccmem-backfill --from-log --dry-run
|
|
9
|
+
# From a checkout, the same thing:
|
|
8
10
|
# scripts/backfill-extract.sh --from-log --dry-run
|
|
9
11
|
#
|
|
10
12
|
# Options:
|
|
@@ -40,7 +42,26 @@
|
|
|
40
42
|
|
|
41
43
|
set -uo pipefail
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
# Resolve through symlinks before taking the directory. As an npm `bin` entry
|
|
46
|
+
# this file is reached through ~/.npm-global/bin/ccmem-backfill, which is a
|
|
47
|
+
# symlink — while the siblings it needs (post-session-extract.sh,
|
|
48
|
+
# extraction-prompt.md) sit next to the REAL file, not next to the link. Taking
|
|
49
|
+
# dirname of the link lands in the bin directory and the script dies at "wrapper
|
|
50
|
+
# not found" the moment it is run the way it is installed.
|
|
51
|
+
#
|
|
52
|
+
# The loop rather than `readlink -f`: that flag is GNU-only and macOS readlink
|
|
53
|
+
# does not have it. `cd -P` on top, because it resolves symlinked PARENT
|
|
54
|
+
# directories too — a `npm link`-style install symlinks the whole package
|
|
55
|
+
# directory, so the link chain has a component the readlink loop never sees.
|
|
56
|
+
_bf_source="${BASH_SOURCE[0]}"
|
|
57
|
+
while [ -L "$_bf_source" ]; do
|
|
58
|
+
_bf_dir="$(cd -P "$(dirname "$_bf_source")" && pwd)"
|
|
59
|
+
_bf_source="$(readlink "$_bf_source")"
|
|
60
|
+
[[ "$_bf_source" != /* ]] && _bf_source="${_bf_dir}/${_bf_source}"
|
|
61
|
+
done
|
|
62
|
+
SCRIPT_DIR="$(cd -P "$(dirname "$_bf_source")" && pwd)"
|
|
63
|
+
# The real file, for --help to read its own header out of.
|
|
64
|
+
SELF="$_bf_source"
|
|
44
65
|
WRAPPER="${SCRIPT_DIR}/post-session-extract.sh"
|
|
45
66
|
PROMPT_FILE="${SCRIPT_DIR}/extraction-prompt.md"
|
|
46
67
|
CLAUDE_DATA_DIR="${HOME}/.claude"
|
|
@@ -72,7 +93,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
72
93
|
--from-log-all) FROM_LOG=1; FROM_LOG_ALL=1 ;;
|
|
73
94
|
# Usage + Options only. The rationale below it is for whoever reads the
|
|
74
95
|
# source; printing half of it (the range has to stop somewhere) helps nobody.
|
|
75
|
-
-h|--help) sed -n '2,
|
|
96
|
+
-h|--help) sed -n '2,17p' "$SELF"; exit 0 ;;
|
|
76
97
|
-*) die "unknown option: $1" ;;
|
|
77
98
|
*) SESSION_IDS+=("$1") ;;
|
|
78
99
|
esac
|
|
@@ -263,7 +284,7 @@ ${prompt}"
|
|
|
263
284
|
# so a failure can say what it was.
|
|
264
285
|
extract_stderr=$(cd "$session_cwd" && printf '%s' "$full_prompt" | command claude -p \
|
|
265
286
|
--no-session-persistence \
|
|
266
|
-
--model
|
|
287
|
+
--model sonnet \
|
|
267
288
|
${budget_args[@]+"${budget_args[@]}"} \
|
|
268
289
|
--max-turns 5 \
|
|
269
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
|
|
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
|
-
#
|
|
484
|
-
#
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
}
|