@tznthou/ccrecall 0.5.5 → 0.6.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.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // ccRecall UserPromptSubmit hook — mid-conversation recall (L1).
3
+ // Wired via ~/.claude/settings.json hooks.UserPromptSubmit. Stdout is added to
4
+ // Claude's context for this turn; errors go to stderr only.
5
+ //
6
+ // Why this exists: SessionStart injects once, at second zero. Everything after
7
+ // it — however long the session runs, whatever subject it turns to — had no
8
+ // memory access at all, which is why recall_query accounted for under 1% of all
9
+ // memory surfacing. This is the second trigger point.
10
+ //
11
+ // Why it is deliberately timid:
12
+ // - It runs on EVERY prompt, blocking the user's path to a response. A slow
13
+ // or dead daemon must cost nothing, so the timeout is short and every
14
+ // failure path exits 0 with no output.
15
+ // - additionalContext accumulates in history rather than being request-scoped
16
+ // (anthropics/claude-code#40216), so each injection is permanent context
17
+ // weight. Per-session dedup and a hard ceiling live in the daemon; the
18
+ // cheap skips (short prompts, slash commands) live here so they cost no
19
+ // round trip at all.
20
+ //
21
+ // Plain stdout is used rather than hookSpecificOutput JSON: the docs support
22
+ // both for this event, and the JSON form errors on the first message of a new
23
+ // session (anthropics/claude-code#17550).
24
+ import http from 'node:http'
25
+
26
+ const PORT = Number(process.env.CCRECALL_PORT ?? 7749)
27
+ const HOST = '127.0.0.1'
28
+ // This hook runs on every prompt, so it needs a kill switch that does not
29
+ // require editing settings.json — same shape as CCRECALL_SESSION_START_STRATEGY.
30
+ const DISABLED = process.env.CCRECALL_PROMPT_RECALL === 'off'
31
+ // 300ms: a localhost topic lookup over an indexed table answers in well under
32
+ // 50ms. This is generous headroom, and still short enough that a hung daemon is
33
+ // imperceptible rather than a stall the user feels on every prompt.
34
+ const TIMEOUT_MS = 300
35
+ // Below this a prompt is almost always a continuation that introduces no new
36
+ // subject, so there is nothing to recall against and no reason to pay for a
37
+ // round trip. Two thresholds because one code-point count cannot serve both
38
+ // scripts: 「這個 bug 怎麼修」 carries a full question in 10 characters while
39
+ // "ok go ahead now" carries none in 15. The codebase already draws this
40
+ // distinction (MIN_TOPIC_LENGTH 3 vs MIN_TOPIC_LENGTH_CJK 2 in topic-extractor).
41
+ const MIN_PROMPT_CHARS = 12
42
+ const MIN_PROMPT_CHARS_CJK = 6
43
+ const CJK_REGEX = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
44
+ // Prompt text is only a topic-extraction seed; the opening sentences carry the
45
+ // subject, and a pasted file would otherwise push a huge query string.
46
+ const MAX_QUERY_CHARS = 300
47
+ // A key longer than this is not rendered at all rather than truncated: a clipped
48
+ // key is a handle that silently resolves to nothing. Observed keys run to 52
49
+ // characters, while the write schema permits 100.
50
+ const MAX_KEY_CHARS = 60
51
+
52
+ async function readStdin() {
53
+ const chunks = []
54
+ for await (const chunk of process.stdin) chunks.push(chunk)
55
+ return Buffer.concat(chunks).toString('utf8')
56
+ }
57
+
58
+ function httpGet(pathWithQuery) {
59
+ return new Promise((resolve) => {
60
+ const req = http.request({
61
+ hostname: HOST,
62
+ port: PORT,
63
+ path: pathWithQuery,
64
+ method: 'GET',
65
+ timeout: TIMEOUT_MS,
66
+ }, (res) => {
67
+ let body = ''
68
+ res.on('data', (c) => { body += c })
69
+ res.on('end', () => {
70
+ if (res.statusCode !== 200) {
71
+ console.error(`[ccRecall] prompt recall failed ${res.statusCode}`)
72
+ resolve(null)
73
+ return
74
+ }
75
+ try {
76
+ resolve(JSON.parse(body))
77
+ } catch {
78
+ resolve(null)
79
+ }
80
+ })
81
+ })
82
+ // Diagnostics go to stderr, never stdout: stdout is context. Without these
83
+ // a dead daemon is indistinguishable from "nothing relevant" — the same
84
+ // silent-failure shape this release fixes on the extraction side.
85
+ req.on('error', (err) => {
86
+ console.error(`[ccRecall] prompt recall error: ${err.message} (service on :${PORT}?)`)
87
+ resolve(null)
88
+ })
89
+ req.on('timeout', () => {
90
+ req.destroy()
91
+ console.error(`[ccRecall] prompt recall timeout after ${TIMEOUT_MS}ms`)
92
+ resolve(null)
93
+ })
94
+ req.end()
95
+ })
96
+ }
97
+
98
+ // Matches Claude Code's project-directory encoding under ~/.claude/projects/
99
+ function projectIdFromCwd(cwd) {
100
+ if (!cwd || typeof cwd !== 'string') return ''
101
+ return cwd.replace(/\//g, '-')
102
+ }
103
+
104
+ function formatRecall(memories) {
105
+ if (!Array.isArray(memories) || memories.length === 0) return ''
106
+ const lines = memories.map((m) => {
107
+ const handle = m.key && m.key.length <= MAX_KEY_CHARS ? ` [key: ${m.key}]` : ''
108
+ return `- ${m.content}${handle}`
109
+ })
110
+ return [
111
+ '[ccRecall — related to what you just asked]',
112
+ '',
113
+ ...lines,
114
+ '',
115
+ '(Excerpts — recall_query a [key] for the full text.)',
116
+ ].join('\n')
117
+ }
118
+
119
+ async function main() {
120
+ if (DISABLED) return
121
+ let input
122
+ try {
123
+ const raw = await readStdin()
124
+ input = raw ? JSON.parse(raw) : {}
125
+ } catch {
126
+ return
127
+ }
128
+
129
+ const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : ''
130
+ if (!prompt) return
131
+ // Slash commands address the harness, not the subject matter.
132
+ if (prompt.startsWith('/')) return
133
+ const minChars = CJK_REGEX.test(prompt) ? MIN_PROMPT_CHARS_CJK : MIN_PROMPT_CHARS
134
+ if (Array.from(prompt).length < minChars) return
135
+
136
+ const projectId = projectIdFromCwd(input.cwd)
137
+ if (!projectId) return
138
+
139
+ // Neither limit nor maxTokens is sent: the daemon owns budget policy, and it
140
+ // can be changed by upgrading the service rather than by rewriting a hook
141
+ // file already installed in the user's settings.
142
+ const params = new URLSearchParams({
143
+ project: projectId,
144
+ q: Array.from(prompt).slice(0, MAX_QUERY_CHARS).join(''),
145
+ })
146
+ if (input.session_id) params.set('sessionId', input.session_id)
147
+
148
+ const result = await httpGet(`/memory/prompt?${params.toString()}`)
149
+ if (!result || result.throttled) return
150
+
151
+ const text = formatRecall(result.memories)
152
+ if (text) process.stdout.write(text + '\n')
153
+ }
154
+
155
+ main().catch((err) => {
156
+ // Never let a recall failure interfere with the user's prompt.
157
+ console.error(`[ccRecall] prompt recall failed: ${err.message}`)
158
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tznthou/ccrecall",
3
- "version": "0.5.5",
3
+ "version": "0.6.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",
@@ -251,6 +251,29 @@ ${prompt}"
251
251
  # phrase (guaranteed to occur in this repo's own sessions).
252
252
  local stdout_marker
253
253
  stdout_marker=$(grep -m1 -oE '^Error: Reached max turns \([0-9]+\)$' "$stdout_tmp" 2>/dev/null)
254
+ # Second marker (#75): the model sometimes PRINTS `recall_save(...)` instead of
255
+ # invoking the MCP tool. That run exits 0 with empty stderr and writes nothing,
256
+ # which in telemetry is byte-identical to a session that genuinely had nothing
257
+ # worth saving — the reason this failure mode stayed invisible for months.
258
+ #
259
+ # Anchored at line start (leading indentation allowed, since the observed
260
+ # captures were fenced code blocks) so prose that merely mentions the tool
261
+ # — guaranteed to occur in this repo's own sessions — does not match. The
262
+ # anchoring bar is deliberately lower than the max-turns marker above: that
263
+ # one decides whether a run was a total loss, so a forged match would corrupt
264
+ # the verdict. This one only flags a row for follow-up and is meant to be read
265
+ # against the origin session's actual memory count (marker hit + zero writes =
266
+ # silent miss; marker hit + writes present = the model narrated as well as
267
+ # called). A false positive costs one row to check; a false negative restores
268
+ # the status quo of total invisibility.
269
+ #
270
+ # -c yields a bare count and never the matched line, so no session content can
271
+ # reach the telemetry log — the same constraint that governs the stdout capture.
272
+ local recall_save_text_count
273
+ recall_save_text_count=$(grep -cE '^[[:space:]]*recall_save[[:space:]]*\(' "$stdout_tmp" 2>/dev/null)
274
+ # grep exits 1 with empty output on no match (and $stdout_tmp is /dev/null when
275
+ # mktemp failed); --argjson would abort the whole telemetry write on a non-number.
276
+ [[ "$recall_save_text_count" =~ ^[0-9]+$ ]] || recall_save_text_count=0
254
277
  rm -f -- "$stdout_tmp" 2>/dev/null
255
278
  # Scrub common credential formats before stderr reaches the telemetry log.
256
279
  # claude echoes its own key (sk-ant-) in auth errors, and any MCP server
@@ -266,7 +289,14 @@ ${prompt}"
266
289
  local extract_duration=$(( extract_end - extract_start ))
267
290
 
268
291
  if [[ $extract_exit -eq 0 ]]; then
269
- printf '✅ ccRecall: extraction complete (%ds).\n' "$extract_duration"
292
+ # A clean exit that printed call syntax is the silent-miss shape (#75): say so
293
+ # at the terminal, where it can still be acted on, instead of only in the log.
294
+ if [[ $recall_save_text_count -gt 0 ]]; then
295
+ 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' \
296
+ "$extract_duration" "$recall_save_text_count"
297
+ else
298
+ printf '✅ ccRecall: extraction complete (%ds).\n' "$extract_duration"
299
+ fi
270
300
  else
271
301
  printf '⚠️ ccRecall: extraction exited with code %d (%ds)%s.\n' \
272
302
  "$extract_exit" "$extract_duration" "${stdout_marker:+ — $stdout_marker}"
@@ -275,6 +305,10 @@ ${prompt}"
275
305
  # Telemetry log (-c = one compact JSON object per line = valid JSONL).
276
306
  # stdoutMarker: "Error: Reached max turns (N)" when the turn budget was hit,
277
307
  # else "" — splits exit-1 rows into total-loss vs unclean-finish post-hoc (#56).
308
+ # recallSaveTextCount: how many recall_save calls the model printed as text
309
+ # rather than invoking — splits exit-0 zero-write rows into silent miss vs
310
+ # genuine no-op (#75). Absent on rows written before this marker shipped, so
311
+ # analysis must treat missing as unknown, not as 0.
278
312
  jq -n -c \
279
313
  --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
280
314
  --arg sid "$session_id" \
@@ -283,7 +317,8 @@ ${prompt}"
283
317
  --arg marker "$stdout_marker" \
284
318
  --argjson exit "$extract_exit" \
285
319
  --argjson dur "$extract_duration" \
286
- '{ts:$ts,sessionId:$sid,projectId:$pid,mode:"jsonl",exitCode:$exit,durationSec:$dur,stderr:$stderr,stdoutMarker:$marker}' \
320
+ --argjson textSaves "$recall_save_text_count" \
321
+ '{ts:$ts,sessionId:$sid,projectId:$pid,mode:"jsonl",exitCode:$exit,durationSec:$dur,stderr:$stderr,stdoutMarker:$marker,recallSaveTextCount:$textSaves}' \
287
322
  >> "$CCRECALL_EXTRACT_LOG"
288
323
 
289
324
  return $claude_exit