@tznthou/ccrecall 0.9.2 → 0.10.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 CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  [中文版](README_ZH.md)
9
9
 
10
- A local memory service for Claude Code — indexes your conversation history, recalls relevant context on demand, and injects it into future sessions. Zero API cost for the core service; optional post-session extraction via Haiku adds ~$0.001/session.
10
+ A local memory service for Claude Code — indexes your conversation history, recalls relevant context on demand, and injects it into future sessions. Zero API cost for the core service. Optional post-session extraction runs on Sonnet: plan usage on a Claude subscription, or $0.21–0.39 per extraction (measured) with an API key.
11
11
 
12
12
  ---
13
13
 
@@ -18,7 +18,7 @@ Five stances, non-negotiable:
18
18
  1. **Local-first.** Your machine, your data. No cloud, no accounts, no services beyond localhost.
19
19
  2. **SQLite is the API.** One `.db` file. `sqlite3` to inspect anything. Your memories are never a black box.
20
20
  3. **FTS5, not vectors.** Code conversations search for file paths, error messages, tool names — keywords, not vibes. At hundreds of sessions, embeddings solve a problem that doesn't exist.
21
- 4. **LLM distills, human curates.** Haiku extracts post-session (~$0.001). Auto memory holds decisions you name. ccRecall holds the long tail no one writes down by hand.
21
+ 4. **LLM distills, human curates.** Sonnet extracts post-session (plan usage on a subscription, $0.21–0.39 a run on an API key). Auto memory holds decisions you name. ccRecall holds the long tail no one writes down by hand.
22
22
  5. **Read-only toward `~/.claude/`.** Reads your JSONL logs, never writes to Claude Code's state. Worst case: bad search results. Never: a broken setup.
23
23
 
24
24
  ---
@@ -96,7 +96,7 @@ flowchart TB
96
96
 
97
97
  subgraph Consumers["Consumers"]
98
98
  Hook["Claude Code Hooks<br/>SessionStart / UserPromptSubmit / SessionEnd"]
99
- Wrapper["Extraction wrapper<br/>post-session Haiku pass"]
99
+ Wrapper["Extraction wrapper<br/>post-session Sonnet pass"]
100
100
  MCP["MCP Server<br/>recall_query / recall_save"]
101
101
  end
102
102
 
@@ -119,7 +119,7 @@ sequenceDiagram
119
119
  participant H as Hooks
120
120
  participant R as ccRecall
121
121
  participant W as Wrapper
122
- participant X as Haiku extractor
122
+ participant X as Sonnet extractor
123
123
 
124
124
  rect rgb(235, 244, 255)
125
125
  Note over CC,R: Session start
@@ -163,7 +163,7 @@ The `notBefore` gate keeps a stale session from being extracted twice; the subag
163
163
  | FTS5 | Full-text search | Built into SQLite, trigram tokenizer with LIKE fallback for short CJK / mixed-script queries |
164
164
  | Native `http` | HTTP server | No Express — minimal surface, localhost only |
165
165
  | chokidar | Filesystem watcher | Cross-platform JSONL change detection with 2 s debounce + single-flight |
166
- | vitest | Testing | 651 tests across 40 files, integration-style |
166
+ | vitest | Testing | 858 tests across 54 files, integration-style |
167
167
  | `@modelcontextprotocol/sdk` | MCP server | stdio transport, shared SQLite via WAL |
168
168
 
169
169
  ---
@@ -281,7 +281,7 @@ ccRecall lives alongside Claude Code's built-in auto memory (`~/.claude/projects
281
281
 
282
282
  | | auto memory | ccRecall |
283
283
  |---|---|---|
284
- | **Write path** | Claude curates by hand — new `.md` file + MEMORY.md index line | Two tracks: rule-based session summaries (automatic, free) + opt-in Haiku extraction (~$0.001/session, where most searchable memories come from) |
284
+ | **Write path** | Claude curates by hand — new `.md` file + MEMORY.md index line | Two tracks: rule-based session summaries (automatic, free) + opt-in Sonnet extraction (plan usage, or $0.21–0.39 a run on an API key; where most searchable memories come from) |
285
285
  | **Read path** | Always in session context (MEMORY.md loads at session start) | On-demand MCP query when auto memory has no entry |
286
286
  | **Signal density** | High — facts worth naming | Long tail — everything the hook can extract |
287
287
  | **Typical use** | "Remember X" / "always Y" — durable preferences, decisions | "Didn't we fix that?" / "last time" — cross-session recall |
@@ -395,7 +395,7 @@ ccRecall/
395
395
  │ ├── tutorial.md # End-user walkthrough (install → MCP → usage)
396
396
  │ ├── architecture.md # Daemon design rationale (contributor-oriented)
397
397
  │ └── launchd.md # macOS LaunchAgent install/troubleshoot
398
- ├── tests/ # 651 tests across 40 files (parser, scanner,
398
+ ├── tests/ # 858 tests across 54 files (parser, scanner,
399
399
  │ │ # summarizer, database, indexer, e2e, MCP,
400
400
  │ │ # memories, hooks, watcher, CLI, migrations,
401
401
  │ │ # FTS5 CJK edge cases, integrity monitor, ...)
@@ -426,7 +426,7 @@ The real trigger was simpler: I kept re-explaining the same architecture to Clau
426
426
 
427
427
  Each flows from the manifesto. Each was a conscious choice against a popular alternative.
428
428
 
429
- **Rule-based, with a ceiling.** The daemon uses heuristic extraction (regex patterns, tool usage analysis, outcome inference) for session summaries — zero API cost. For that job, "Edit x8, 5 files, committed" is more useful than a paragraph of prose. But rule-based has a ceiling: it handles structured signals (tool calls, file edits, commit messages) well; it misses discussion-only decisions, nuanced trade-offs, and anything that lives in natural language. That ceiling is why v0.4.1 added post-session Haiku extraction (~$0.001/session) — an LLM pass that catches what heuristics can't. In practice, the memories I actually search for mostly come from Haiku extraction or manual `recall_save`, not the rule-based layer. The daemon itself never calls an LLM; the LLM pass is opt-in and runs outside the daemon as a separate process.
429
+ **Rule-based, with a ceiling.** The daemon uses heuristic extraction (regex patterns, tool usage analysis, outcome inference) for session summaries — zero API cost. For that job, "Edit x8, 5 files, committed" is more useful than a paragraph of prose. But rule-based has a ceiling: it handles structured signals (tool calls, file edits, commit messages) well; it misses discussion-only decisions, nuanced trade-offs, and anything that lives in natural language. That ceiling is why v0.4.1 added post-session Haiku extraction (~$0.001/session) — an LLM pass that catches what heuristics can't. In practice, the memories I actually search for mostly come from that post-session pass (on Sonnet since v0.9.0) or manual `recall_save`, not the rule-based layer. The daemon itself never calls an LLM; the LLM pass is opt-in and runs outside the daemon as a separate process.
430
430
 
431
431
  **FTS5, not vector search.** Semantic search sounds better on paper, but for conversation logs — specific tools, file paths, error messages — keyword matching wins. FTS5 queries run in <10ms locally. No embedding model, no Chroma, no Docker container. At hundreds of sessions (not millions of documents), Karpathy's own analysis confirms: "plain index + keyword search is already sufficient under 500 sources."
432
432
 
@@ -438,6 +438,15 @@ Each flows from the manifesto. Each was a conscious choice against a popular alt
438
438
 
439
439
  **No opinionated injection.** ccRecall doesn't decide what Claude should remember. It provides a search API — the injection layer presents results, Claude integrates them. Opinionated memory selection is a premature optimization that would be wrong in ways we can't predict.
440
440
 
441
+ ### What the transcript doesn't say
442
+
443
+ ccRecall knows only what Claude Code writes to disk, and the transcript is not quite the conversation. Neither of the gaps v0.9.2 closed ever raised an error:
444
+
445
+ - A message typed while Claude is working usually gets folded into Claude's next API call and stored as an attachment, with no user message on record. The extraction transcript never read those — 254 of them in a month of one person's sessions — until v0.9.2.
446
+ - Claude Code 2.1.300 began writing a copy of each API request into the transcript, and the parser counted those records as messages until v0.9.2.
447
+
448
+ The code reading the transcript was right for the format it assumed. Both gaps turned up by counting what the file holds against what was really said.
449
+
441
450
  ---
442
451
 
443
452
  ## Roadmap
@@ -449,6 +458,11 @@ Each flows from the manifesto. Each was a conscious choice against a popular alt
449
458
  | **v0.5.0** | The knife field: journal/scorer/harvester pipelines removed (zero promotions ever), endpoints 13 → 5, `message_uuids` dual-hash rebuild (DB 114MB → 42MB) | Released |
450
459
  | **v0.5.2** | Recall weighting: log-compressed half-life decay, FTS relevance-first ranking via `(-rank)*sqrt(EC)` | Released |
451
460
  | **v0.5.3** | CJK topic alignment: Han-aware topic extraction (`\p{Script=Han}` tokenizer + 32 Chinese stopwords + particle split), session-less rebuild fix | Released |
461
+ | **v0.5.4–v0.5.6** | Measuring injection: an `injection_log` table records every memory surfaced (#71), Tier 0 startup slots rotate instead of showing the same three memories every session, `/health` reports `mainSessionCount` | Released |
462
+ | **v0.6.0** | Mid-conversation recall: a `UserPromptSubmit` hook surfaces memories while you work, injected lines carry their key, and extraction flags runs that printed `recall_save` instead of calling it | Released |
463
+ | **v0.7.x** | CJK word segmentation via `Intl.Segmenter` (#80); the `cwd` → project id encoding drift that silently skipped extraction for whole projects (#89); English function words no longer run the topic index (#84); the telemetry log guard's leaks closed | Released |
464
+ | **v0.8.x** | Provenance: a memory records the message it came from and who wrote it, so extraction can't overwrite one you saved by hand; prescription-first extraction; Tier 0 picks other projects' memories per project instead of by confidence alone. Long sessions go to extraction over stdin instead of failing silently, and `backfill-extract.sh` recovers the ones already lost | Released |
465
+ | **v0.9.x** | Extraction on Sonnet, retried once when a run produces nothing usable; no more skipped sessions when a path's letter case differs (#131); Claude Code 2.1.300's request-replay records skipped, and messages sent mid-turn now reach extraction; extraction runs without memories injected into it; `recall_save` no longer hits "database is locked" | Released |
452
466
 
453
467
  Tracked in [GitHub Issues](https://github.com/tznthou/ccRecall/issues).
454
468
 
package/README_ZH.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  [English](README.md)
9
9
 
10
- Claude Code 的本地記憶服務——索引你的對話歷史,按需召回相關 context,注入到未來的 session。核心服務零 API 成本;選配的 post-session extraction 透過 Haiku 約 $0.001/session。
10
+ Claude Code 的本地記憶服務——索引你的對話歷史,按需召回相關 context,注入到未來的 session。核心服務零 API 成本。選配的 post-session extraction 跑在 Sonnet 上:用 Claude 訂閱方案就算進方案用量;用 API key 的話,我們實測每次抽取 0.21–0.39 美元。
11
11
 
12
12
  ---
13
13
 
@@ -18,7 +18,7 @@ Claude Code 的本地記憶服務——索引你的對話歷史,按需召回
18
18
  1. **本地優先。** 你的機器,你的資料。不上雲、不開帳號、不信任 localhost 以外的任何服務。
19
19
  2. **SQLite 就是 API。** 一個 `.db` 檔案。`sqlite3` 隨時可查。你的記憶從不是黑箱。
20
20
  3. **FTS5,不用向量。** 程式碼對話搜的是檔案路徑、錯誤訊息、工具名稱——關鍵詞就夠了。幾百個 session 的規模,embedding 解決的是不存在的問題。
21
- 4. **LLM 蒸餾,人類策展。** Haiku 在 session 結束後自動萃取(~$0.001)。auto memory 存你親手命名的決策。ccRecall 存沒人會手寫的長尾。
21
+ 4. **LLM 蒸餾,人類策展。** Sonnet 在 session 結束後自動萃取(訂閱方案吃方案用量,API key 實測每次 0.21–0.39 美元)。auto memory 存你親手命名的決策。ccRecall 存沒人會手寫的長尾。
22
22
  5. **對 `~/.claude/` 唯讀。** 讀 JSONL 記錄,從不寫入 Claude Code 的狀態。最壞情況:搜尋結果不好。永遠不會:搞壞你的設定。
23
23
 
24
24
  ---
@@ -94,7 +94,7 @@ flowchart TB
94
94
 
95
95
  subgraph Consumers["使用端"]
96
96
  Hook["Claude Code Hooks<br/>SessionStart / UserPromptSubmit / SessionEnd"]
97
- Wrapper["Extraction wrapper<br/>session 結束後的 Haiku 抽取"]
97
+ Wrapper["Extraction wrapper<br/>session 結束後的 Sonnet 抽取"]
98
98
  MCP["MCP Server<br/>recall_query / recall_save"]
99
99
  end
100
100
 
@@ -117,7 +117,7 @@ sequenceDiagram
117
117
  participant H as Hooks
118
118
  participant R as ccRecall
119
119
  participant W as Wrapper
120
- participant X as Haiku 抽取器
120
+ participant X as Sonnet 抽取器
121
121
 
122
122
  rect rgb(235, 244, 255)
123
123
  Note over CC,R: Session 開始
@@ -161,7 +161,7 @@ sequenceDiagram
161
161
  | FTS5 | 全文搜尋 | SQLite 內建、trigram tokenizer,短 token / 中英混合查詢透過 LIKE fallback 補齊 |
162
162
  | 原生 `http` | HTTP 伺服器 | 不用 Express——最小表面積、僅 localhost |
163
163
  | chokidar | 檔案系統 watcher | 跨平台 JSONL 變動偵測,2 秒 debounce + single-flight |
164
- | vitest | 測試 | 651 個測試(40 檔案)、整合式風格 |
164
+ | vitest | 測試 | 858 個測試(54 檔案)、整合式風格 |
165
165
  | `@modelcontextprotocol/sdk` | MCP server | stdio transport,透過 WAL 共用 SQLite |
166
166
 
167
167
  ---
@@ -276,7 +276,7 @@ ccRecall 和 Claude Code 內建的 auto memory(`~/.claude/projects/*/memory/`
276
276
 
277
277
  | | auto memory | ccRecall |
278
278
  |---|---|---|
279
- | **寫入路徑** | Claude 手動策展——新開一個 `.md` 檔 + 更新 MEMORY.md index | 雙軌:規則式 session 摘要(自動、免費)+ 選配 Haiku extraction(~$0.001/session,可搜尋的記憶主要來自這條路) |
279
+ | **寫入路徑** | Claude 手動策展——新開一個 `.md` 檔 + 更新 MEMORY.md index | 雙軌:規則式 session 摘要(自動、免費)+ 選配 Sonnet extraction(訂閱方案算進用量,API key 實測每次 0.21–0.39 美元;可搜尋的記憶主要來自這條路) |
280
280
  | **讀取路徑** | 永遠在 session context(MEMORY.md 啟動時就載入) | auto memory 沒答案時,才用 MCP 查詢 |
281
281
  | **訊號密度** | 高——值得被命名的決策和偏好 | 長尾——hook 能抓到的都留著 |
282
282
  | **適用情境** | 「記住 X」「以後都 Y」——重要偏好、明確決策 | 「上次那個怎麼修的?」——跨多個 session 的回憶 |
@@ -385,7 +385,7 @@ ccRecall/
385
385
  │ ├── tutorial_zh.md # 使用者教學(安裝 → MCP → 日常使用)
386
386
  │ ├── architecture_zh.md # Daemon 設計取捨(給 contributor 看)
387
387
  │ └── launchd.md # macOS LaunchAgent 安裝/troubleshoot
388
- ├── tests/ # 651 個測試橫跨 40 檔案(parser、scanner、
388
+ ├── tests/ # 858 個測試橫跨 54 檔案(parser、scanner、
389
389
  │ │ # summarizer、database、indexer、e2e、MCP、
390
390
  │ │ # memories、hooks、watcher、CLI、migrations、
391
391
  │ │ # FTS5 CJK edge cases、integrity monitor 等)
@@ -416,7 +416,7 @@ Anthropic Claude Code 團隊的 Thariq 在 2026 年 4 月[發表了 context 管
416
416
 
417
417
  每一條都從宣言延伸。每一條都是對流行做法的刻意拒絕。
418
418
 
419
- **規則式,有天花板。** daemon 用 heuristic 萃取(regex 模式、工具使用分析、outcome 推斷)做 session 摘要——零 API 成本。做這件事,「Edit×8, 5 files, committed」比一段散文更有用。但 rule-based 有天花板:結構化訊號(tool call、檔案編輯、commit message)抓得到;討論中的決策、微妙的取捨、任何活在自然語言裡的東西,它抓不到。這就是 v0.4.1 加入 post-session Haiku extraction(~$0.001/session)的原因——用一次 LLM pass 補上 heuristic 的盲區。實際上,我真正會搜到的記憶大多來自 Haiku extraction 或手動 `recall_save`,不是規則式那層。daemon 本體永遠不呼叫 LLM;LLM pass 是選配的,作為獨立進程在 daemon 之外執行。
419
+ **規則式,有天花板。** daemon 用 heuristic 萃取(regex 模式、工具使用分析、outcome 推斷)做 session 摘要——零 API 成本。做這件事,「Edit×8, 5 files, committed」比一段散文更有用。但 rule-based 有天花板:結構化訊號(tool call、檔案編輯、commit message)抓得到;討論中的決策、微妙的取捨、任何活在自然語言裡的東西,它抓不到。這就是 v0.4.1 加入 post-session Haiku extraction(~$0.001/session)的原因——用一次 LLM pass 補上 heuristic 的盲區。實際上,我真正會搜到的記憶大多來自 session 結束後的那次抽取(v0.9.0 起改用 Sonnet)或手動 `recall_save`,不是規則式那層。daemon 本體永遠不呼叫 LLM;LLM pass 是選配的,作為獨立進程在 daemon 之外執行。
420
420
 
421
421
  **FTS5,不用向量搜尋。** 語義搜尋聽起來更高級,但對話記錄搜的是具體的工具名、檔案路徑、錯誤訊息——關鍵詞匹配就夠了。FTS5 查詢在本地 <10ms。不需要 embedding model、不需要 Chroma、不需要 Docker container。在我們的規模(數百個 session,不是百萬文件),Karpathy 自己的分析也確認:「500 個來源以下,樸素索引 + 關鍵詞搜尋已經夠用。」
422
422
 
@@ -428,6 +428,15 @@ Anthropic Claude Code 團隊的 Thariq 在 2026 年 4 月[發表了 context 管
428
428
 
429
429
  **不做帶偏見的注入。** ccRecall 不替 Claude 決定該記住什麼。它提供搜尋 API——注入層呈現結果,Claude 自己整合。帶偏見的記憶篩選是過早優化,而且會以我們無法預測的方式出錯。
430
430
 
431
+ ### transcript 沒說的事
432
+
433
+ ccRecall 能知道的,只有 Claude Code 寫進硬碟的東西;可是 transcript 跟真正發生過的對話,並不完全是同一回事。v0.9.2 補上的兩個洞,都不曾報過錯:
434
+
435
+ - Claude 還在忙的時候打進去的訊息,多半會被併進它下一次的 API 呼叫,存成 attachment,不會留下使用者訊息的紀錄。抽取用的 transcript 一直沒讀到這些——光是一個人一個月的 session,就有 254 則——到 v0.9.2 才補上。
436
+ - Claude Code 2.1.300 開始把每次 API 請求的副本寫進 transcript,parser 一直把這些紀錄當成訊息算,到 v0.9.2 才擋掉。
437
+
438
+ 讀 transcript 的程式碼,照它假設的格式來看並沒有寫錯;兩個洞都是拿檔案裡實際存了什麼,去對照實際說過什麼,一筆一筆數出來的。
439
+
431
440
  ---
432
441
 
433
442
  ## 路線圖
@@ -439,6 +448,11 @@ Anthropic Claude Code 團隊的 Thariq 在 2026 年 4 月[發表了 context 管
439
448
  | **v0.5.0** | 砍刀場:journal/scorer/harvester 管線移除(史上零 promotion)、端點 13 → 5、`message_uuids` 雙 hash 重建(DB 114MB → 42MB) | 已釋出 |
440
449
  | **v0.5.2** | 召回權重:對數壓縮 half-life decay、FTS relevance-first 排序 `(-rank)*sqrt(EC)` | 已釋出 |
441
450
  | **v0.5.3** | CJK topic 對齊:Han-aware topic 抽取(`\p{Script=Han}` tokenizer + 中文 stopwords 32 條 + 助詞分割)、session-less rebuild 修正 | 已釋出 |
451
+ | **v0.5.4–v0.5.6** | 量測注入:`injection_log` 記下每一次送出的記憶(#71);Tier 0 的 startup 名額開始輪替,不再每場都是同樣三條;`/health` 改回報 `mainSessionCount` | 已釋出 |
452
+ | **v0.6.0** | 對話中召回:`UserPromptSubmit` hook 在對話進行中帶出相關記憶;注入的每一行都附上 key;抽取會標出「把 `recall_save` 印成文字、沒有真的呼叫」的 run | 已釋出 |
453
+ | **v0.7.x** | 用 `Intl.Segmenter` 做中文斷詞(#80);修掉 `cwd` → project id 的編碼漂移,這個 bug 讓整個專案的抽取被靜默跳過(#89);英文虛詞不再把持 topic 索引(#84);補上 telemetry log 防護的漏洞 | 已釋出 |
454
+ | **v0.8.x** | 出處與作者:記憶會記下出自哪則訊息、是誰寫的,自動抽取不會再蓋掉你手寫的記憶;抽取改成處方先行;Tier 0 挑別專案的記憶改依專案挑選,不再只看信心分數。長 session 改從 stdin 送進抽取,不再靜默失敗,另附 `backfill-extract.sh` 把已經漏掉的補回來 | 已釋出 |
455
+ | **v0.9.x** | 抽取改用 Sonnet,沒產出可用結果時自動重試一次;路徑大小寫不同也不會再跳過(#131);跳過 Claude Code 2.1.300 寫進 transcript 的請求副本,對話中途插打的訊息也讀得到了;抽取不再被注入記憶;`recall_save` 不再撞上「database is locked」 | 已釋出 |
442
456
 
443
457
  追蹤於 [GitHub Issues](https://github.com/tznthou/ccRecall/issues)。
444
458
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tznthou/ccrecall",
3
- "version": "0.9.2",
3
+ "version": "0.10.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",
@@ -17,6 +17,13 @@
17
17
  # --from-log-all every non-zero exit in the log — mostly ancient sessions
18
18
  # whose transcript Claude Code has since deleted
19
19
  #
20
+ # Environment:
21
+ # CCRECALL_EXTRACT_EFFORT effort for each run, as for ccrecall-extract:
22
+ # low, medium, high, xhigh or max (default high)
23
+ # CCRECALL_EXTRACT_MAX_BUDGET_USD spend cap per run, applied only when
24
+ # ANTHROPIC_API_KEY is set (default 0.50);
25
+ # raise it to run at max
26
+ #
20
27
  # Why this exists (2026-09-15): post-session-extract.sh used to pass the whole
21
28
  # transcript as one argv parameter (capped at 200,000 bytes), and a single argv
22
29
  # parameter has a ceiling well below that cap:
@@ -91,9 +98,10 @@ while [[ $# -gt 0 ]]; do
91
98
  --force) FORCE=1 ;;
92
99
  --from-log) FROM_LOG=1 ;;
93
100
  --from-log-all) FROM_LOG=1; FROM_LOG_ALL=1 ;;
94
- # Usage + Options only. The rationale below it is for whoever reads the
95
- # source; printing half of it (the range has to stop somewhere) helps nobody.
96
- -h|--help) sed -n '2,17p' "$SELF"; exit 0 ;;
101
+ # Usage, Options and Environment only. The rationale below them is for
102
+ # whoever reads the source; printing half of it (the range has to stop
103
+ # somewhere) helps nobody.
104
+ -h|--help) sed -n '2,25p' "$SELF"; exit 0 ;;
97
105
  -*) die "unknown option: $1" ;;
98
106
  *) SESSION_IDS+=("$1") ;;
99
107
  esac
@@ -105,6 +113,11 @@ done
105
113
  # shellcheck source=/dev/null
106
114
  source "$WRAPPER" >/dev/null 2>&1 || die "failed to source $WRAPPER"
107
115
  declare -F _ccrecall_log_append >/dev/null || die "_ccrecall_log_append not defined by $WRAPPER"
116
+ # The MCP configuration too, so a backfill run starts the same single server
117
+ # the wrapper's run does rather than a second copy of how to find it.
118
+ declare -F _ccrecall_extract_mcp_config >/dev/null || die "_ccrecall_extract_mcp_config not defined by $WRAPPER"
119
+ # And the effort, read from CCRECALL_EXTRACT_EFFORT the way the wrapper reads it.
120
+ declare -F _ccrecall_extract_effort >/dev/null || die "_ccrecall_extract_effort not defined by $WRAPPER"
108
121
 
109
122
  [[ -f "$PROMPT_FILE" ]] || die "extraction-prompt.md not found: $PROMPT_FILE
110
123
  (the wrapper's inline fallback is deliberately not duplicated here — a second
@@ -178,8 +191,11 @@ fi
178
191
 
179
192
  [[ ${#SESSION_IDS[@]} -gt 0 ]] || die "no session ids given (pass ids, or --from-log)"
180
193
 
181
- printf '\n🧠 ccRecall backfill — %d session(s)%s\n\n' \
182
- "${#SESSION_IDS[@]}" "$([[ $DRY_RUN -eq 1 ]] && printf ' [DRY RUN]')"
194
+ # Once for the whole batch, so a value that is not a level is warned about once.
195
+ EFFORT=$(_ccrecall_extract_effort)
196
+
197
+ printf '\n🧠 ccRecall backfill — %d session(s) (effort %s)%s\n\n' \
198
+ "${#SESSION_IDS[@]}" "$EFFORT" "$([[ $DRY_RUN -eq 1 ]] && printf ' [DRY RUN]')"
183
199
 
184
200
  ok=0; skipped=0; failed=0
185
201
 
@@ -288,13 +304,25 @@ ${prompt}"
288
304
  # same reason as in the wrapper: this is a headless Claude Code session of its
289
305
  # own, and ccRecall's hooks would otherwise inject memories into it — and
290
306
  # count each one as surfaced.
307
+ #
308
+ # Effort and permissions match the wrapper's run too, for the reasons given
309
+ # there: CLAUDE_CODE_EFFORT_LEVEL is set as a prefix because it outranks
310
+ # --effort (a caller exporting max ran each extraction at max, mostly
311
+ # thinking), and the run gets ccRecall's own MCP server, no built-in tools and
312
+ # an allowlist instead of --dangerously-skip-permissions.
313
+ mcp_config=$(_ccrecall_extract_mcp_config)
291
314
  extract_stderr=$(cd "$session_cwd" && printf '%s' "$full_prompt" |
292
- CCRECALL_SESSION_START_STRATEGY=off CCRECALL_PROMPT_RECALL=off command claude -p \
315
+ CCRECALL_SESSION_START_STRATEGY=off CCRECALL_PROMPT_RECALL=off CLAUDE_CODE_EFFORT_LEVEL="$EFFORT" \
316
+ command claude -p \
293
317
  --no-session-persistence \
294
318
  --model sonnet \
295
319
  ${budget_args[@]+"${budget_args[@]}"} \
296
320
  --max-turns 5 \
297
- --dangerously-skip-permissions 2>&1 1>/dev/null)
321
+ --strict-mcp-config --mcp-config "$mcp_config" \
322
+ --tools "" \
323
+ --allowedTools "mcp__ccrecall__recall_save,mcp__ccrecall__recall_query" \
324
+ --permission-mode dontAsk \
325
+ --disable-slash-commands 2>&1 1>/dev/null)
298
326
  extract_exit=$?
299
327
  duration=$(( $(date +%s) - start ))
300
328
 
@@ -1,7 +1,8 @@
1
1
  You are a memory extraction agent for ccRecall, running as an automated
2
2
  non-interactive pipeline. There is NO human reading your text output — only
3
3
  tool calls matter. Analyze the session transcript and save 0-5 lasting
4
- insights via the `recall_save` MCP tool. Produce no other output.
4
+ insights by calling the `mcp__ccrecall__recall_save` tool — use that exact
5
+ name; a bare `recall_save` does not exist in this run. Produce no other output.
5
6
 
6
7
  ## What to extract
7
8
 
@@ -129,7 +130,7 @@ expressed. Copy it verbatim out of the header.
129
130
 
130
131
  ## recall_save parameters
131
132
 
132
- For each memory, call `recall_save` with:
133
+ For each memory, call `mcp__ccrecall__recall_save` with:
133
134
  - `content`: the self-contained insight (see rules above)
134
135
  - `type`: one of `decision`, `discovery`, `pattern`, `preference`, `feedback`
135
136
  - `key`: stable hyphenated slug (see above)
@@ -5,12 +5,19 @@
5
5
  #
6
6
  # Usage: ccrecall-extract [claude args...]
7
7
  # Runs Claude Code with ccRecall startup injection, then extracts
8
- # memories via Haiku after the session ends.
8
+ # memories with Sonnet after the session ends.
9
9
  #
10
10
  # Environment:
11
- # CCRECALL_PORT — daemon port (default 7749)
12
- # CCRECALL_SKIP_EXTRACT — set to 1 to skip post-session extraction
13
- # CCRECALL_EXTRACT_LOG — telemetry log path (default ~/.ccrecall/extract.log.jsonl)
11
+ # CCRECALL_PORT — daemon port (default 7749)
12
+ # CCRECALL_SKIP_EXTRACT — set to 1 to skip post-session extraction
13
+ # CCRECALL_EXTRACT_LOG — telemetry log path (default ~/.ccrecall/extract.log.jsonl)
14
+ # CCRECALL_EXTRACT_EFFORT — effort for the extraction run: low, medium, high,
15
+ # xhigh or max (default high). Your interactive
16
+ # session keeps its own effort either way.
17
+ # CCRECALL_EXTRACT_MAX_BUDGET_USD — spend cap per extraction run, applied only
18
+ # when ANTHROPIC_API_KEY is set (default 0.50).
19
+ # Measured runs at max cost 0.64-0.97, so raise it
20
+ # along with the effort.
14
21
 
15
22
  CCRECALL_PORT="${CCRECALL_PORT:-7749}"
16
23
  CCRECALL_EXTRACT_LOG="${CCRECALL_EXTRACT_LOG:-$HOME/.ccrecall/extract.log.jsonl}"
@@ -178,6 +185,66 @@ fi
178
185
  CCRECALL_SCRIPT_DIR="$(cd "$(dirname "$_ccrecall_src")" && pwd)"
179
186
  unset _ccrecall_src
180
187
 
188
+ # The MCP configuration an extraction run starts with: ccRecall's own server
189
+ # and nothing else, which is what lets `--strict-mcp-config` drop every other
190
+ # server the user has registered — the claude.ai Gmail, Drive and Calendar
191
+ # connectors among them. backfill-extract.sh sources this file for it too.
192
+ #
193
+ # The server is this package's own build, found from where this file lives,
194
+ # not the `ccmem-mcp` bin. A checkout registered with `claude mcp add ... --
195
+ # <path>/dist/mcp/server.js` (the README's way) has no such bin on PATH, and
196
+ # naming it would leave that install's extraction without a tool to save with.
197
+ # The bin is the fallback only when no build sits next to this file. Either way
198
+ # the server inherits this shell's environment, CCRECALL_DB_PATH included —
199
+ # where the daemon installer and _ccrecall_count_extracted read it from too.
200
+ #
201
+ # jq builds the JSON so an install path holding a space or a quote stays one
202
+ # string.
203
+ _ccrecall_extract_mcp_config() {
204
+ local server="${CCRECALL_SCRIPT_DIR%/*}/dist/mcp/server.js"
205
+ if [ -f "$server" ]; then
206
+ jq -nc --arg s "$server" '{mcpServers:{ccrecall:{type:"stdio",command:"node",args:[$s]}}}'
207
+ else
208
+ jq -nc '{mcpServers:{ccrecall:{type:"stdio",command:"ccmem-mcp",args:[]}}}'
209
+ fi
210
+ }
211
+
212
+ # The effort an extraction run starts at: the level CCRECALL_EXTRACT_EFFORT
213
+ # names, or high when it names none. backfill-extract.sh sources this file for
214
+ # it too, so a backfill starts at the level the run it stands in for would have.
215
+ #
216
+ # The extraction gets a variable of its own because CLAUDE_CODE_EFFORT_LEVEL
217
+ # cannot be the knob: that is the caller's interactive setting, the very value
218
+ # this run has to override (see the effort comment in ccrecall-extract).
219
+ #
220
+ # Checked against the levels rather than passed on as given. Claude Code takes
221
+ # any value without complaint: on 2.1.300 `bogus` exited 0 with nothing on
222
+ # stderr and ran at whatever the settings or the model default said (medium,
223
+ # for Sonnet 5.5 with no settings; read off the Stop hook's effort.level). What
224
+ # this prints is also what the start message names, so an unchecked typo would
225
+ # be reported as the run's level while the run got another. Anything else
226
+ # becomes high, with a warning that leaves the value out: it goes to a
227
+ # terminal, and nothing has vetted what the environment holds.
228
+ #
229
+ # `auto` is left out although Claude Code documents it for this variable as
230
+ # "use the model default". On 2.1.300 it ran Sonnet 5.5 at high every time,
231
+ # with and without settings, where the model default is medium, so a start
232
+ # message saying "auto" would hide the level the run actually got.
233
+ #
234
+ # Always returns 0. The callers assign its output, and this file is sourced
235
+ # into shells that set errexit, where a non-zero status here would abort the
236
+ # caller's shell at that assignment, before extraction ever ran.
237
+ _ccrecall_extract_effort() {
238
+ case "${CCRECALL_EXTRACT_EFFORT:-}" in
239
+ low|medium|high|xhigh|max) builtin printf '%s' "$CCRECALL_EXTRACT_EFFORT" ;;
240
+ '') builtin printf 'high' ;;
241
+ *)
242
+ builtin printf '\n⚠️ ccRecall: CCRECALL_EXTRACT_EFFORT must be one of low, medium, high, xhigh, max — extracting at high.\n' >&2
243
+ builtin printf 'high'
244
+ ;;
245
+ esac
246
+ }
247
+
181
248
  ccrecall-extract() {
182
249
  # #89: this used to derive project_id here with `sed 's|/|-|g'`, which
183
250
  # diverged from Claude Code's char-wise [^A-Za-z0-9] encoding for any cwd
@@ -260,7 +327,7 @@ ccrecall-extract() {
260
327
  # below against token-budget.ts, in both copies. 🔴 Until 2026-09-08 this
261
328
  # comment already said that while no test read this line's number at all:
262
329
  # it could say 999 and stay green. Verified by mutation, both directions.
263
- prompt="You are a memory extraction agent. Save 0-5 lasting insights via recall_save. Each memory must be self-contained with a key slug for dedup. Only the first 149 characters survive injection into a future session, so lead with what a future reader should DO and put the evidence, file names and war story after it. Set projectId to \"${project_id}\" for project-specific knowledge; omit for cross-project knowledge. Always pass origin=\"agent-inferred\": you are reading a finished transcript with nobody watching, and that flag is what stops your save from overwriting a memory the user wrote by hand under the same key. Pass messageId: the uuid in the \"--- human [<uuid>] ---\" header of the one message a memory came from, copied verbatim — omit it when no single message is the source, and never guess a uuid. Turns headed \"--- side-agent note [<uuid>] ---\" or \"--- peer session [<uuid>] ---\" are a note the user forwarded or a message from another Claude session, not the user's own words: never save what they say as the user's decision."
330
+ prompt="You are a memory extraction agent. Save 0-5 lasting insights by calling the mcp__ccrecall__recall_save tool, by that exact name. Each memory must be self-contained with a key slug for dedup. Only the first 149 characters survive injection into a future session, so lead with what a future reader should DO and put the evidence, file names and war story after it. Set projectId to \"${project_id}\" for project-specific knowledge; omit for cross-project knowledge. Always pass origin=\"agent-inferred\": you are reading a finished transcript with nobody watching, and that flag is what stops your save from overwriting a memory the user wrote by hand under the same key. Pass messageId: the uuid in the \"--- human [<uuid>] ---\" header of the one message a memory came from, copied verbatim — omit it when no single message is the source, and never guess a uuid. Turns headed \"--- side-agent note [<uuid>] ---\" or \"--- peer session [<uuid>] ---\" are a note the user forwarded or a message from another Claude session, not the user's own words: never save what they say as the user's decision."
264
331
  fi
265
332
 
266
333
  # Append runtime context (projectId) to the prompt
@@ -398,7 +465,11 @@ ${prompt}"
398
465
  return $claude_exit
399
466
  fi
400
467
 
401
- printf '\n🧠 ccRecall: extracting memories from session...\n'
468
+ # Named in the start message because the level is now a setting: the line
469
+ # that says extraction began is where a run at the wrong one shows.
470
+ local effort
471
+ effort=$(_ccrecall_extract_effort)
472
+ printf '\n🧠 ccRecall: extracting memories from session (effort %s)...\n' "$effort"
402
473
 
403
474
  local extract_start
404
475
  extract_start=$(date +%s)
@@ -555,6 +626,33 @@ ${prompt}"
555
626
  # written. Prefix assignments, so they reach this command only — an export
556
627
  # would persist in the shell this file is sourced into and switch prompt
557
628
  # recall off for every later session.
629
+ #
630
+ # The effort is set the same way, and it has to be a prefix:
631
+ # CLAUDE_CODE_EFFORT_LEVEL outranks --effort, /effort and every effortLevel
632
+ # setting, so a user who exports `max` for their interactive sessions ran
633
+ # every extraction at max, and an --effort flag here would change nothing.
634
+ # Measured 2026-10-15 on two real transcripts: at max, 90-95% of a run was
635
+ # thinking — 32-46K tokens before the first save, 261-355 s end to end. At
636
+ # high the same two runs took 26-40 s and saved five memories each, where max
637
+ # saved four or five. medium and low were faster still, but saved one to
638
+ # three fewer per session. So $effort is high unless CCRECALL_EXTRACT_EFFORT
639
+ # names another level (see _ccrecall_extract_effort).
640
+ #
641
+ # And the run gets only what it uses. It reads a transcript this prompt calls
642
+ # untrusted, and used to do so under --dangerously-skip-permissions with every
643
+ # MCP server the user had registered: Bash and, on the machine this was
644
+ # measured on, Gmail, Google Drive and Calendar connectors, with nothing
645
+ # asking first. Now it gets ccRecall's own server alone (--strict-mcp-config,
646
+ # see _ccrecall_extract_mcp_config), no built-in tools (--tools ""), the two
647
+ # tools extraction calls on an allowlist, and dontAsk refusing anything else
648
+ # instead of prompting for it. Skills are switched off as well — their
649
+ # listing is tens of kilobytes the model never needs. Verified across four
650
+ # real extractions the same day: no permission denials, ~26K fewer input
651
+ # tokens per run, and the MCP tools load directly, so the ToolSearch round
652
+ # before the first save is gone. Without ToolSearch the model takes tool
653
+ # names from the prompt, which is why both copies of it spell the full name.
654
+ local mcp_config
655
+ mcp_config=$(_ccrecall_extract_mcp_config)
558
656
  local attempt=1 extract_retried=0 extracted_count=""
559
657
  local stdout_marker recall_save_text_count
560
658
  while : ; do
@@ -562,12 +660,17 @@ ${prompt}"
562
660
  trap 'command rm -f -- "$stdout_tmp" 2>/dev/null; exit 130' INT
563
661
  trap 'command rm -f -- "$stdout_tmp" 2>/dev/null; exit 143' TERM
564
662
  builtin printf '%s' "$full_prompt" |
565
- CCRECALL_SESSION_START_STRATEGY=off CCRECALL_PROMPT_RECALL=off command claude -p \
663
+ CCRECALL_SESSION_START_STRATEGY=off CCRECALL_PROMPT_RECALL=off CLAUDE_CODE_EFFORT_LEVEL="$effort" \
664
+ command claude -p \
566
665
  --no-session-persistence \
567
666
  --model sonnet \
568
667
  ${budget_args[@]+"${budget_args[@]}"} \
569
668
  --max-turns 5 \
570
- --dangerously-skip-permissions 2>&1 1>|"$stdout_tmp"
669
+ --strict-mcp-config --mcp-config "$mcp_config" \
670
+ --tools "" \
671
+ --allowedTools "mcp__ccrecall__recall_save,mcp__ccrecall__recall_query" \
672
+ --permission-mode dontAsk \
673
+ --disable-slash-commands 2>&1 1>|"$stdout_tmp"
571
674
  ); then
572
675
  extract_exit=0
573
676
  else
@@ -599,9 +702,14 @@ ${prompt}"
599
702
  # called). A false positive costs one row to check; a false negative restores
600
703
  # the status quo of total invisibility.
601
704
  #
705
+ # The `mcp__ccrecall__` prefix is optional because the prompt now teaches the
706
+ # full tool name, and a model that prints its calls prints the name it was
707
+ # taught. Matching only the bare name would have gone blind to #75 the day
708
+ # the prompt changed.
709
+ #
602
710
  # -c yields a bare count and never the matched line, so no session content can
603
711
  # reach the telemetry log — the same constraint that governs the stdout capture.
604
- recall_save_text_count=$(grep -cE '^[[:space:]]*recall_save[[:space:]]*\(' "$stdout_tmp" 2>/dev/null) || :
712
+ recall_save_text_count=$(grep -cE '^[[:space:]]*(mcp__ccrecall__)?recall_save[[:space:]]*\(' "$stdout_tmp" 2>/dev/null) || :
605
713
  # grep exits 1 with empty output on no match (and $stdout_tmp is /dev/null when
606
714
  # mktemp failed); --argjson would abort the whole telemetry write on a non-number.
607
715
  [[ "$recall_save_text_count" =~ ^[0-9]+$ ]] || recall_save_text_count=0
@@ -623,12 +731,12 @@ ${prompt}"
623
731
  done
624
732
  command rm -f -- "$stdout_tmp" 2>/dev/null
625
733
  # Scrub common credential formats before stderr reaches the telemetry log.
626
- # claude echoes its own key (sk-ant-) in auth errors, and any MCP server
627
- # loaded for extraction (this runs with --dangerously-skip-permissions) can
628
- # surface its own token in an init/auth failure. This is a best-effort
629
- # denylist of common prefixes, not exhaustive — but stdout, the higher-risk
630
- # stream where the model could echo transcript secrets, is already discarded
631
- # entirely, so this only hardens claude's own diagnostics.
734
+ # claude echoes its own key (sk-ant-) in auth errors. Only ccRecall's own MCP
735
+ # server is started for extraction now, so a third-party server can no longer
736
+ # print its token here, but claude's own diagnostics still can. This is a
737
+ # best-effort denylist of common prefixes, not exhaustive — but stdout, the
738
+ # higher-risk stream where the model could echo transcript secrets, is already
739
+ # discarded entirely, so this only hardens claude's own diagnostics.
632
740
  extract_stderr=$(printf '%s' "$extract_stderr" | sed -E 's/(sk-ant-|sk-proj-|ghp_|gho_|ghu_|ghs_|github_pat_|AKIA)[A-Za-z0-9_-]*/[REDACTED]/g' | head -c 2000)
633
741
 
634
742
  local extract_end