@tznthou/ccrecall 0.5.6 → 0.7.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/hooks/README.md CHANGED
@@ -23,6 +23,7 @@ If the daemon is not running, hooks log a warning to stderr and exit cleanly —
23
23
  |--------|------------------|--------|
24
24
  | `session-start.mjs` | `SessionStart` | GET `/memory/startup` with the project name (3-tier: cold + recent-confidence + FTS fallback), write matching memories to stdout (Claude prepends them to context) |
25
25
  | `session-end.mjs` | `SessionEnd` | POST `/session/end` to harvest the just-ended session into a memory |
26
+ | `user-prompt-submit.mjs` | `UserPromptSubmit` | GET `/memory/prompt` with the prompt's topics, write related memories to stdout. Runs on **every** prompt — see the restraint notes below |
26
27
 
27
28
  ## Finding the Hook Scripts
28
29
 
@@ -70,11 +71,24 @@ Add to `~/.claude/settings.json` (or a project-scoped `.claude/settings.json`).
70
71
  }
71
72
  ]
72
73
  }
74
+ ],
75
+ "UserPromptSubmit": [
76
+ {
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "node {HOOKS_DIR}/user-prompt-submit.mjs"
81
+ }
82
+ ]
83
+ }
73
84
  ]
74
85
  }
75
86
  }
76
87
  ```
77
88
 
89
+ Omit the `UserPromptSubmit` block if you only want memories at session start —
90
+ it is the one hook that runs on every prompt.
91
+
78
92
  Restart any running Claude Code sessions — settings changes don't hot-reload.
79
93
 
80
94
  ## Environment Variables
@@ -124,4 +138,5 @@ Check the daemon side: `~/Library/Logs/ccrecall/ccrecall.err.log` (macOS) or the
124
138
  - SessionStart: `source === 'resume'` means the context is already loaded
125
139
  - **SessionStart query strategy**: uses the last path segment of `cwd` as a keyword. Default strategy `startup-v1` calls `/memory/startup` (3-tier: cold + recent-confidence + FTS fallback); legacy strategy calls `/memory/query` (project-name FTS only). Controlled by `CCRECALL_SESSION_START_STRATEGY` env var
126
140
  - **SessionStart stdout = context injection**: only memories are written to stdout; all errors and diagnostics go to stderr to avoid polluting Claude's context
127
- - **Timeouts**: SessionEnd 5s, SessionStart 2s (tighter because it sits on the pre-prompt critical path)
141
+ - **Timeouts**: SessionEnd 5s, SessionStart 2s (tighter because it sits on the pre-prompt critical path), UserPromptSubmit 300ms (tightest — it blocks every prompt)
142
+ - **UserPromptSubmit restraint**: this hook fires on every prompt, and injected context accumulates in the conversation rather than being replaced ([claude-code#40216](https://github.com/anthropics/claude-code/issues/40216)), so each injection is permanent weight. It therefore skips short prompts and slash commands without any network call, suppresses memories already surfaced in the session, and stops entirely after 8 memories per session. Set `CCRECALL_PROMPT_RECALL=off` to disable it without editing `settings.json`
@@ -18,6 +18,10 @@ const HOST = '127.0.0.1'
18
18
  const TIMEOUT_MS = 2000
19
19
  const MEMORY_LIMIT = 5
20
20
  const MAX_TOKENS = 300
21
+ // A key longer than this is not rendered at all rather than truncated: a clipped
22
+ // key is a handle that silently resolves to nothing. Observed keys run to 52
23
+ // characters, while the write schema permits 100.
24
+ const MAX_KEY_CHARS = 60
21
25
  const STRATEGY = process.env.CCRECALL_SESSION_START_STRATEGY ?? 'startup-v1'
22
26
  const TELEMETRY_OFF = process.env.CCRECALL_TELEMETRY === 'off'
23
27
  const TELEMETRY_PATH = path.join(os.homedir(), '.ccrecall', 'startup-recall.log.jsonl')
@@ -132,18 +136,35 @@ function formatMemories(memories, query) {
132
136
  ].join('\n')
133
137
  }
134
138
 
135
- function formatStartupV1(memories, query, totalMemoryCount) {
139
+ function formatStartupV1(memories, totalMemoryCount) {
136
140
  if (memories.length === 0 && totalMemoryCount === 0) return ''
137
141
  const lines = memories.map((m) => {
138
142
  const conf = m.confidence != null && m.confidence !== 1
139
143
  ? ` (conf ${Number(m.confidence).toFixed(2)})`
140
144
  : ''
141
- return `- ${m.content}${conf}`
145
+ // #77: each line is a ~150-char excerpt of a memory averaging ~600, and the
146
+ // cut typically lands before the conclusion. The key is the only handle that
147
+ // makes the rest reachable — without it the reader can see that a line was
148
+ // clipped (the ellipsis) but has no way to ask for the remainder. Appended
149
+ // rather than prefixed so the content still leads.
150
+ const handle = m.key && m.key.length <= MAX_KEY_CHARS ? ` [key: ${m.key}]` : ''
151
+ return `- ${m.content}${conf}${handle}`
142
152
  })
143
153
  const parts = ['[ccRecall memory recall]', '']
144
- if (lines.length > 0) parts.push(...lines, '')
145
- if (totalMemoryCount > memories.length) {
146
- parts.push(`(${totalMemoryCount} memories available — use recall_query to search more)`)
154
+ if (lines.length > 0) {
155
+ // The old footer only reported how many rows existed, which implied the rows
156
+ // shown were whole. Say plainly that they are excerpts, and name the way out.
157
+ const available = totalMemoryCount > memories.length
158
+ ? ` ${totalMemoryCount} memories available.`
159
+ : ''
160
+ parts.push(
161
+ ...lines,
162
+ '',
163
+ `(Each line is a ~150-char excerpt — conclusions are often past the cut.`
164
+ + ` recall_query a [key] for the full text.${available})`,
165
+ )
166
+ } else if (totalMemoryCount > 0) {
167
+ parts.push(`(${totalMemoryCount} memories available — use recall_query to search.)`)
147
168
  }
148
169
  return parts.join('\n')
149
170
  }
@@ -182,7 +203,7 @@ async function main() {
182
203
  emittedIds: result.emittedIds,
183
204
  droppedCount: result.droppedCount,
184
205
  })
185
- text = formatStartupV1(memories, query, totalMemoryCount)
206
+ text = formatStartupV1(memories, totalMemoryCount)
186
207
  } else {
187
208
  const memories = await queryLegacy(query, projectId)
188
209
  text = formatMemories(memories, query)
@@ -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.6",
3
+ "version": "0.7.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",
@@ -1,29 +1,86 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  //
3
- // One-off script: backfill memory_topics for existing memories + clean orphans.
4
- // Phase 3.0 prerequisite — Tier 0 cross-project queries need memory_topics.
3
+ // Maintenance script for memory_topics.
4
+ //
5
+ // Two modes:
6
+ // backfill (default) — topics for memories that have none. Phase 3.0
7
+ // prerequisite: Tier 0 cross-project queries need memory_topics.
8
+ // --rebuild — recompute topics for EVERY memory. Required whenever
9
+ // extractTopicsFromContent changes (e.g. #80's Intl.Segmenter switch),
10
+ // because backfill skips any memory that already has rows and therefore
11
+ // leaves stale topics in place.
5
12
  //
6
13
  // Usage:
7
14
  // pnpm tsx scripts/backfill-memory-topics.ts
15
+ // pnpm tsx scripts/backfill-memory-topics.ts --rebuild --dry-run
16
+ // pnpm tsx scripts/backfill-memory-topics.ts --rebuild
8
17
  // pnpm tsx scripts/backfill-memory-topics.ts --db <path>
18
+ //
19
+ // After a --rebuild, run rebuildKnowledgeMap for each affected project so the
20
+ // knowledge_map aggregate reflects the new topic keys.
9
21
 
10
22
  import path from 'node:path'
11
23
  import os from 'node:os'
12
24
  import { Database } from '../src/core/database.js'
13
25
  import { extractTopicsFromContent } from '../src/core/topic-extractor.js'
14
26
 
15
- const dbPath = process.argv.includes('--db')
16
- ? process.argv[process.argv.indexOf('--db') + 1]
27
+ const argv = process.argv
28
+ const dbPath = argv.includes('--db')
29
+ ? argv[argv.indexOf('--db') + 1]
17
30
  : path.join(os.homedir(), '.ccrecall', 'ccrecall.db')
31
+ const rebuild = argv.includes('--rebuild')
32
+ const dryRun = argv.includes('--dry-run')
33
+
34
+ // backfillMemoryTopics has no dry-run path, so the flag cannot be honoured in
35
+ // default mode. Refuse rather than write while the user believes nothing is
36
+ // being written.
37
+ if (dryRun && !rebuild) {
38
+ console.error('--dry-run is only supported with --rebuild.')
39
+ console.error('Backfill mode has no preview path; re-run with --rebuild --dry-run,')
40
+ console.error('or drop --dry-run to backfill topic-less memories for real.')
41
+ process.exit(1)
42
+ }
18
43
 
19
44
  console.log(`DB: ${dbPath}`)
45
+ console.log(`Mode: ${rebuild ? 'rebuild (all memories)' : 'backfill (topic-less only)'}${dryRun ? ' [dry run]' : ''}`)
20
46
 
21
47
  const db = new Database(dbPath)
22
48
 
23
- const orphansRemoved = db.cleanOrphanedMemoryTopics()
24
- console.log(`Orphaned memory_topics removed: ${orphansRemoved}`)
49
+ const orphansRemoved = dryRun ? 0 : db.cleanOrphanedMemoryTopics()
50
+ if (!dryRun) console.log(`Orphaned memory_topics removed: ${orphansRemoved}`)
51
+
52
+ if (rebuild) {
53
+ const cjkBefore = db.rawAll<{ c: number }>(
54
+ "SELECT COUNT(*) AS c FROM memory_topics WHERE topic_key GLOB '*[一-龥]*'",
55
+ )[0].c
56
+
57
+ const r = db.rebuildMemoryTopics(extractTopicsFromContent, { dryRun })
58
+
59
+ console.log(`Memories scanned : ${r.scanned}`)
60
+ console.log(`Memories with changed topics: ${r.changed}`)
61
+ console.log(`memory_topics rows before : ${r.topicsBefore}`)
62
+ console.log(`memory_topics rows after : ${r.topicsAfter}`)
63
+ console.log(` ...of which CJK before : ${cjkBefore}`)
25
64
 
26
- const backfilled = db.backfillMemoryTopics(extractTopicsFromContent)
27
- console.log(`Memories backfilled with topics: ${backfilled}`)
65
+ if (!dryRun) {
66
+ const cjkAfter = db.rawAll<{ c: number }>(
67
+ "SELECT COUNT(*) AS c FROM memory_topics WHERE topic_key GLOB '*[一-龥]*'",
68
+ )[0].c
69
+ console.log(` ...of which CJK after : ${cjkAfter}`)
70
+ console.log('')
71
+ console.log('Next: rebuild knowledge_map for affected projects.')
72
+ } else {
73
+ console.log('')
74
+ console.log('Dry run — nothing written.')
75
+ }
76
+ } else {
77
+ const backfilled = db.backfillMemoryTopics(extractTopicsFromContent)
78
+ console.log(`Memories backfilled with topics: ${backfilled}`)
79
+ if (backfilled === 0) {
80
+ console.log('')
81
+ console.log('0 processed. If the extractor changed, this mode cannot help —')
82
+ console.log('it only sees memories with no topics at all. Use --rebuild.')
83
+ }
84
+ }
28
85
 
29
86
  db.close()
@@ -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