@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.
- package/README.md +2 -2
- package/README_ZH.md +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +124 -1
- package/dist/api/routes.js.map +1 -1
- package/dist/cli/hooks-installer.d.ts +2 -0
- package/dist/cli/hooks-installer.d.ts.map +1 -1
- package/dist/cli/hooks-installer.js +14 -3
- package/dist/cli/hooks-installer.js.map +1 -1
- package/dist/core/database.d.ts +67 -0
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +117 -1
- package/dist/core/database.js.map +1 -1
- package/dist/core/types.d.ts +4 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/index.js +1 -1
- package/hooks/README.md +16 -1
- package/hooks/session-end.mjs +13 -2
- package/hooks/session-start.mjs +27 -6
- package/hooks/user-prompt-submit.mjs +158 -0
- package/package.json +1 -1
- package/scripts/post-session-extract.sh +37 -2
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|