claude-recall 0.21.0 → 0.21.2

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.
@@ -8,14 +8,14 @@ source: claude-recall
8
8
 
9
9
  # Preferences
10
10
 
11
- Auto-generated from 5 memories. Last updated: 2026-04-10.
11
+ Auto-generated from 5 memories. Last updated: 2026-04-11.
12
12
 
13
13
  ## Rules
14
14
 
15
- - Session test preference 1775856590823
16
- - Test preference 1775856590766-2
17
- - Test preference 1775856590766-1
18
- - Test preference 1775856590766-0
15
+ - Session test preference 1775900146096
16
+ - Test preference 1775900146036-2
17
+ - Test preference 1775900146036-1
18
+ - Test preference 1775900146036-0
19
19
  - Test memory content
20
20
 
21
21
  ---
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "topicId": "preferences",
3
- "sourceHash": "bc008faff7f86df3f887d5949481aad102da2261a2872bb1b030bc4279eee9fd",
3
+ "sourceHash": "32712ec321c1c8e831bfb1d227b5682434e69ab3a0934115453c984b36866477",
4
4
  "memoryCount": 5,
5
- "generatedAt": "2026-04-10T21:29:50.846Z",
5
+ "generatedAt": "2026-04-11T09:35:46.110Z",
6
6
  "memoryKeys": [
7
- "memory_1775856590824_6gpdsqsh0",
8
- "memory_1775856590800_fv6xg43wx",
9
- "memory_1775856590782_kwl352n83",
10
- "memory_1775856590768_crx4xtq3i",
11
- "memory_1775856590726_t9ap1te0i"
7
+ "memory_1775900146097_ap6ffit4i",
8
+ "memory_1775900146071_8y0wnmbu6",
9
+ "memory_1775900146056_0tbld53h7",
10
+ "memory_1775900146038_czc25c8ra",
11
+ "memory_1775900145994_1cvxoyda8"
12
12
  ]
13
13
  }
@@ -137,13 +137,14 @@ a SKILL.md file that Claude Code loads automatically.
137
137
 
138
138
  ## Automatic Capture Hooks
139
139
 
140
- Claude Recall registers hooks on three Claude Code events to capture memories automatically — no MCP tool call needed:
140
+ Claude Recall registers hooks on four Claude Code events to capture memories automatically — no MCP tool call needed:
141
141
 
142
142
  | Hook | Event | What it captures |
143
143
  |------|-------|-----------------|
144
144
  | `correction-detector` | UserPromptSubmit | User corrections, preferences, and project knowledge from natural language |
145
145
  | `memory-stop` | Stop | Corrections, preferences, failures, and devops patterns from the last 6 transcript entries |
146
146
  | `precompact-preserve` | PreCompact | Broader sweep of up to 50 transcript entries before context compression |
147
+ | `session-end-checkpoint` | SessionEnd | Auto-saves a `{completed, remaining, blockers}` task checkpoint when the session ends voluntarily (`clear`, `prompt_input_exit`, `logout`). Spawns a detached worker so it stays within Claude Code's 1.5s SessionEnd timeout. Pi has the equivalent via the `session_shutdown` event handler. |
147
148
 
148
149
  **Key behaviors:**
149
150
  - **LLM-first classification** via Claude Haiku — detects natural statements like "we use tabs here" or "tests go in \_\_tests\_\_/" that regex would miss
@@ -152,9 +153,10 @@ Claude Recall registers hooks on three Claude Code events to capture memories au
152
153
  - Batch classification: Stop and PreCompact hooks send all texts in a single API call
153
154
  - Near-duplicate detection via Jaccard similarity (55% threshold) prevents redundant storage
154
155
  - Per-event limits: 3 (Stop), 5 (PreCompact) to prevent DB flooding
156
+ - Auto-checkpoint quality gate: refuses to save when the LLM detects the task was already complete — manual checkpoints stay sticky
155
157
  - Always exits 0 — hooks never block Claude
156
158
 
157
- **Setup:** Run `npx claude-recall setup --install` to register hooks in `.claude/settings.json`.
159
+ **Setup:** Run `npx claude-recall setup --install` to register hooks in `.claude/settings.json`. After upgrading to v0.21.2, re-run `setup --install` in each project to pick up the new SessionEnd hook (the `hooksVersion` bump to `13.0.0` signals that registration changed).
158
160
 
159
161
  ## Example Workflows
160
162
 
package/README.md CHANGED
@@ -90,6 +90,7 @@ Once installed, Claude Recall works automatically in the background:
90
90
  7. **After context compression** (Claude Code only) — rules are automatically re-injected into context so they're not lost when the window shrinks
91
91
  8. **Sub-agent recall** (Claude Code only) — when sub-agents are spawned, active rules are injected into their context automatically. Sub-agent outcomes (completed/failed/killed) are captured as events
92
92
  9. **Rules sync** (Claude Code only) — top 30 rules are exported as typed `.md` files to Claude Code's native memory directory
93
+ 10. **Auto-checkpoint on session exit** — when a session ends (Pi shutdown or Claude Code's `SessionEnd` for `clear`/`prompt_input_exit`/`logout`), the most recent task is extracted via Haiku into a structured `{completed, remaining, blockers}` checkpoint and saved for the next session. Critical for Pi (which has no `--resume` flag); a useful safety net for Claude Code users who exit without resuming. Conservative quality gate refuses to save when the LLM detects the task was already complete — manual checkpoints are never clobbered with garbage
93
94
 
94
95
  Classification uses Claude Haiku (via `ANTHROPIC_API_KEY`) with silent regex fallback. No configuration needed.
95
96
 
@@ -198,6 +199,22 @@ claude-recall checkpoint clear # Delete the checkpoint
198
199
 
199
200
  Agents can also save/load checkpoints via MCP tools (`mcp__claude-recall__save_checkpoint` / `mcp__claude-recall__load_checkpoint`) or Pi tools (`recall_save_checkpoint` / `recall_load_checkpoint`).
200
201
 
202
+ #### Auto-checkpoint on session exit (v0.21.2+)
203
+
204
+ Manual `checkpoint save` is the explicit path. **Auto-checkpoint** is the safety net: when a session ends, the most recent task is extracted into a checkpoint automatically so the next session can resume.
205
+
206
+ - **Pi** — fires from the `session_shutdown` event handler. In-process synchronous call, runs as part of the existing session-end pipeline. **Critical for Pi: there is no `pi --resume` equivalent, so without this, restarting Pi loses all session context.**
207
+ - **Claude Code** — fires from the `SessionEnd` hook for voluntary exit reasons (`clear`, `prompt_input_exit`, `logout`). Spawns a detached background worker (fork+unref) so it stays well within Claude Code's tight 1.5s `SessionEnd` timeout. Skips `bypass_permissions_disabled` and `other` reasons (those are system-driven, not user intent). Useful for users who exit and start fresh instead of using `claude --resume`.
208
+
209
+ Both runtimes share the same Haiku-backed extraction (`extractCheckpointWithLLM`) and the same quality gate:
210
+
211
+ - **Quality gate**: refuses to save if the LLM returns an empty or trivially-short `remaining` field. The model is prompted to detect completion signals (assistant said "Done.", user said "thanks", no follow-up question) and return empty `remaining` when the task is finished. **An empty checkpoint is far better than a fabricated one** — manual checkpoints are never overwritten with garbage.
212
+ - **Notes tag**: auto-saved checkpoints include `[auto-saved on <pi|cc> session exit at <iso-timestamp>]` in the notes field, so you can tell auto from manual via `checkpoint load`.
213
+ - **Requires `ANTHROPIC_API_KEY`**. Without it, `extractCheckpointWithLLM` returns `null` (graceful fallback) and no auto-checkpoint is saved. Manual `checkpoint save` still works.
214
+ - **Disable**: remove the `SessionEnd` block from `.claude/settings.json` (Claude Code) or, for Pi, no per-project disable flag exists yet — open an issue if you need one.
215
+
216
+ The auto-checkpoint never clobbers a useful manual checkpoint because of the quality gate. If the LLM doesn't see clear unfinished work, it returns empty and the gate refuses the save. Manual checkpoints stay sticky until you explicitly save over them.
217
+
201
218
  ### Troubleshooting
202
219
 
203
220
  ```bash
@@ -809,7 +809,7 @@ async function main() {
809
809
  // This avoids registry lookups on every hook invocation.
810
810
  const cliScript = path.join(packageDir, 'dist', 'cli', 'claude-recall-cli.js');
811
811
  const hookCmd = `node ${cliScript} hook run`;
812
- settings.hooksVersion = '12.0.0'; // v12 = add SubagentStart/Stop for sub-agent recall integration
812
+ settings.hooksVersion = '13.0.0'; // v13 = add SessionEnd for auto-checkpoint on session exit
813
813
  settings.hooks = {
814
814
  SubagentStart: [
815
815
  {
@@ -919,6 +919,20 @@ async function main() {
919
919
  }
920
920
  ]
921
921
  }
922
+ ],
923
+ // Auto-checkpoint on voluntary session exits. Worker is fire-and-forget,
924
+ // so the synchronous handler returns instantly (well within CC's tight
925
+ // 1.5s SessionEnd timeout). Symmetric with Pi's session_shutdown handler.
926
+ SessionEnd: [
927
+ {
928
+ hooks: [
929
+ {
930
+ type: "command",
931
+ command: `${hookCmd} session-end-checkpoint`,
932
+ timeout: 5
933
+ }
934
+ ]
935
+ }
922
936
  ]
923
937
  };
924
938
  if (!fs.existsSync(claudeDir)) {
@@ -106,9 +106,19 @@ class HookCommands {
106
106
  await handleBashFailureWatcher(input);
107
107
  break;
108
108
  }
109
+ case 'session-end-checkpoint': {
110
+ const { handleSessionEndCheckpoint } = await Promise.resolve().then(() => __importStar(require('../../hooks/session-end-checkpoint')));
111
+ await handleSessionEndCheckpoint(input);
112
+ break;
113
+ }
114
+ case 'session-end-checkpoint-worker': {
115
+ const { handleSessionEndCheckpointWorker } = await Promise.resolve().then(() => __importStar(require('../../hooks/session-end-checkpoint-worker')));
116
+ await handleSessionEndCheckpointWorker(input);
117
+ break;
118
+ }
109
119
  default:
110
120
  console.error(`Unknown hook: ${name}`);
111
- console.error('Available: correction-detector, memory-stop, precompact-preserve, memory-sync, tool-outcome-watcher');
121
+ console.error('Available: correction-detector, memory-stop, precompact-preserve, memory-sync, tool-outcome-watcher, session-end-checkpoint');
112
122
  }
113
123
  }
114
124
  catch {
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.classifyWithLLM = classifyWithLLM;
10
10
  exports.extractHindsightHint = extractHindsightHint;
11
11
  exports.extractSessionLearningsWithLLM = extractSessionLearningsWithLLM;
12
+ exports.extractCheckpointWithLLM = extractCheckpointWithLLM;
12
13
  exports.classifyBatchWithLLM = classifyBatchWithLLM;
13
14
  // Lazy singleton — avoid import cost when API key is absent
14
15
  let clientInstance; // undefined = not yet checked
@@ -217,6 +218,86 @@ async function extractSessionLearningsWithLLM(summary, existingMemories) {
217
218
  return null;
218
219
  }
219
220
  }
221
+ const CHECKPOINT_EXTRACTION_PROMPT = `You are extracting a "where I left off" checkpoint from a coding session that just ended. The next session — possibly minutes from now, possibly days later — needs a brief, accurate hint to resume from. Your output will overwrite any existing checkpoint, so it MUST be either accurate or empty. NEVER fabricate.
222
+
223
+ You will see the FINAL portion of a session transcript. Your job is to extract THREE fields:
224
+
225
+ - completed: what the user/agent finished in THIS recent task (concrete, brief, max 200 chars). Empty string if nothing was clearly completed.
226
+ - remaining: what was still in flight when the session ended — the actual hand-off (concrete, brief, max 300 chars). MUST be non-empty if there is real unfinished work. EMPTY STRING if the task is done.
227
+ - blockers: anything that was blocking progress (tools failing, decisions pending, dependencies). "none" if no blockers.
228
+
229
+ THE MOST IMPORTANT RULE — completion detection:
230
+ If the transcript ends with ANY signal that the task is finished, return remaining="". Completion signals include:
231
+ - assistant says "Done.", "All set.", "All done.", "Finished.", "That's it.", "Complete.", or similar terminal acknowledgement
232
+ - last user message is thanks/acknowledgement ("thanks", "perfect", "great") with no follow-up question
233
+ - tool calls succeeded and there is no explicit next step in the user's most recent prompt
234
+ - the conversation has reached a natural stopping point
235
+
236
+ When in doubt, prefer remaining="". An empty checkpoint is far better than a fabricated one.
237
+
238
+ THE SECOND MOST IMPORTANT RULE — no fabrication:
239
+ - ONLY use information present in the transcript. Do NOT extrapolate, do NOT invent follow-up work.
240
+ - If the most recent task is clearly complete, do NOT manufacture next steps from your imagination.
241
+ - If you cannot determine what was happening with high confidence, return all-empty: {"completed":"","remaining":"","blockers":""}
242
+ - "remaining" must be a SPECIFIC unfinished item visible in the transcript. Never generic ("continue work", "more testing", "documentation").
243
+
244
+ Other rules:
245
+ - Focus ONLY on the most recent coherent task. Ignore earlier work in the session.
246
+ - Return JSON: {"completed":"...","remaining":"...","blockers":"..."}
247
+ - Be terse and specific. Each field should help the future session pick up the thread.
248
+ - Do NOT include markdown fences. Respond with raw JSON only.
249
+
250
+ Examples of GOOD output:
251
+
252
+ Scenario A — task finished, agent said "Done.":
253
+ {"completed":"Copied cc-source-code into a dedicated dir under claude-recall/cc-source-code/","remaining":"","blockers":"none"}
254
+
255
+ Scenario B — task in progress, midway through implementation:
256
+ {"completed":"Added saveCheckpoint() to storage and MemoryService","remaining":"Wire CLI checkpoint command and add MCP/Pi tool wrappers","blockers":"none"}
257
+
258
+ Scenario C — task in progress, blocked on something:
259
+ {"completed":"Diagnosed [object Object] rendering bug in handleLoadRules","remaining":"Write failing test, extract formatRuleValue helper, replace 5 call sites","blockers":"none"}
260
+
261
+ Scenario D — uncertain, sparse context, can't tell what's happening:
262
+ {"completed":"","remaining":"","blockers":""}
263
+
264
+ Scenario E — just a question, no work done:
265
+ {"completed":"","remaining":"","blockers":""}
266
+
267
+ Examples of BAD output (DO NOT DO THIS):
268
+ {"completed":"various changes","remaining":"more work","blockers":"none"} # too vague
269
+ {"completed":"explored architecture","remaining":"document findings","blockers":"none"} # FABRICATED — there was no documentation task
270
+ {"completed":"finished everything","remaining":"finish everything","blockers":"none"} # nonsense filler`;
271
+ async function extractCheckpointWithLLM(conversationSummary) {
272
+ const client = getClient();
273
+ if (!client)
274
+ return null;
275
+ if (!conversationSummary || conversationSummary.trim().length < 30) {
276
+ return null;
277
+ }
278
+ try {
279
+ const response = await client.messages.create({
280
+ model: MODEL,
281
+ max_tokens: 600,
282
+ system: CHECKPOINT_EXTRACTION_PROMPT,
283
+ messages: [{ role: 'user', content: conversationSummary }],
284
+ });
285
+ const content = response.content?.[0];
286
+ if (content?.type !== 'text')
287
+ return null;
288
+ const result = parseJSON(content.text);
289
+ if (typeof result !== 'object' || result === null)
290
+ return null;
291
+ return {
292
+ completed: typeof result.completed === 'string' ? result.completed : '',
293
+ remaining: typeof result.remaining === 'string' ? result.remaining : '',
294
+ blockers: typeof result.blockers === 'string' ? result.blockers : '',
295
+ };
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
220
301
  async function classifyBatchWithLLM(texts) {
221
302
  if (texts.length === 0)
222
303
  return [];
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ /**
3
+ * session-end-checkpoint-worker — detached background worker spawned by the
4
+ * session-end-checkpoint hook handler.
5
+ *
6
+ * Runs OUTSIDE Claude Code's 1.5s SessionEnd hook timeout. Reads the transcript,
7
+ * extracts a most-recent-task checkpoint via Haiku, and saves it via
8
+ * MemoryService.saveCheckpoint(). The next CC session that calls load_rules
9
+ * will see the checkpoint hint.
10
+ *
11
+ * This worker is the symmetric counterpart to Pi's in-process auto-checkpoint
12
+ * (src/pi/extension.ts session_shutdown handler). Both call the same shared
13
+ * extractCheckpoint() function from event-processors.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.handleSessionEndCheckpointWorker = handleSessionEndCheckpointWorker;
17
+ const shared_1 = require("./shared");
18
+ const event_processors_1 = require("../shared/event-processors");
19
+ const config_1 = require("../services/config");
20
+ const TRANSCRIPT_TAIL_SIZE = 30;
21
+ async function handleSessionEndCheckpointWorker(input) {
22
+ // Wire event-processor logs through hookLog so extractCheckpoint diagnostics
23
+ // (LLM null, quality gate filter, save failure) end up in
24
+ // ~/.claude-recall/hook-logs/session-end-checkpoint-worker.log instead of
25
+ // being silently dropped by the default no-op logFn.
26
+ (0, event_processors_1.setLogFunction)((source, msg) => (0, shared_1.hookLog)('session-end-checkpoint-worker', `[${source}] ${msg}`));
27
+ const transcriptPath = input?.transcript_path ?? '';
28
+ if (!transcriptPath) {
29
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', 'No transcript_path provided');
30
+ return;
31
+ }
32
+ const cwd = input?.cwd;
33
+ if (cwd) {
34
+ try {
35
+ config_1.ConfigService.getInstance().updateConfig({ project: { rootDir: cwd } });
36
+ }
37
+ catch {
38
+ // Non-critical — getProjectId will fall back to process.cwd() basename
39
+ }
40
+ }
41
+ const projectId = config_1.ConfigService.getInstance().getProjectId();
42
+ if (!projectId) {
43
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', 'No project_id resolved — aborting');
44
+ return;
45
+ }
46
+ const entries = (0, shared_1.readTranscriptTail)(transcriptPath, TRANSCRIPT_TAIL_SIZE);
47
+ if (entries.length === 0) {
48
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', 'No transcript entries found');
49
+ return;
50
+ }
51
+ // Convert raw transcript JSONL entries to the ConversationEntry shape that
52
+ // extractCheckpoint expects. We include user prompts, assistant text blocks,
53
+ // AND tool interactions — Haiku needs the assistant's reasoning (especially
54
+ // completion signals like "Done.") to know whether the most recent task is
55
+ // finished or still in flight. Without assistant text the model hallucinates
56
+ // follow-up work from sparse tool-call context.
57
+ const converted = [];
58
+ for (let i = 0; i < entries.length; i++) {
59
+ const entry = entries[i];
60
+ const role = entry?.message?.role ?? entry?.role;
61
+ if (role === 'user') {
62
+ // Skip user entries that are pure tool_result wrappers — those will be
63
+ // captured via extractToolInteractions below
64
+ const content = entry?.message?.content ?? entry?.content;
65
+ const isPureToolResult = Array.isArray(content) && content.every((b) => b?.type === 'tool_result');
66
+ if (isPureToolResult)
67
+ continue;
68
+ const text = (0, shared_1.extractTextFromEntry)(entry);
69
+ if (text && text.trim().length > 0) {
70
+ converted.push({
71
+ entry: { role: 'user', text: text.trim() },
72
+ index: i,
73
+ });
74
+ }
75
+ }
76
+ }
77
+ // Add assistant text blocks with their original index — these include
78
+ // completion signals ("Done.", "All set", etc.) that help Haiku judge
79
+ // whether the most recent task is actually finished.
80
+ const assistantTexts = (0, shared_1.extractAssistantTexts)(entries);
81
+ for (const at of assistantTexts) {
82
+ if (!at.text || !at.text.trim())
83
+ continue;
84
+ converted.push({
85
+ entry: { role: 'assistant', text: at.text.trim().substring(0, 400) },
86
+ index: at.entryIndex,
87
+ });
88
+ }
89
+ // Add tool interactions as tool_result entries with their original index
90
+ const interactions = (0, shared_1.extractToolInteractions)(entries);
91
+ for (const interaction of interactions) {
92
+ if (!interaction.result)
93
+ continue;
94
+ const text = interaction.result.content.substring(0, 300);
95
+ if (!text)
96
+ continue;
97
+ converted.push({
98
+ entry: {
99
+ role: 'tool_result',
100
+ text,
101
+ toolName: interaction.call.name,
102
+ isError: interaction.result.isError,
103
+ },
104
+ index: interaction.result.entryIndex,
105
+ });
106
+ }
107
+ // Sort by original transcript index to preserve chronological order
108
+ converted.sort((a, b) => a.index - b.index);
109
+ const conversationEntries = converted.map(c => c.entry);
110
+ if (conversationEntries.length < 3) {
111
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', `Too few entries (${conversationEntries.length}) — skipping`);
112
+ return;
113
+ }
114
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', `Extracting checkpoint from ${conversationEntries.length} entries (project=${projectId})`);
115
+ const saved = await (0, event_processors_1.extractCheckpoint)(conversationEntries, projectId, 'cc');
116
+ if (saved) {
117
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', `Auto-checkpoint saved for ${projectId}`);
118
+ }
119
+ else {
120
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', `No checkpoint saved (LLM null, quality gate, or save failure)`);
121
+ }
122
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * session-end-checkpoint hook — fires on Claude Code SessionEnd event.
4
+ *
5
+ * Goal: auto-save a "where I left off" checkpoint when the session ends, so
6
+ * the next session can pick up from the most recent task. Less critical for
7
+ * Claude Code (which has `claude --resume`) than for Pi, but still useful
8
+ * for users who exit and start fresh instead of resuming.
9
+ *
10
+ * Architecture: this handler is the SYNCHRONOUS gate that Claude Code's hook
11
+ * runner waits on. SessionEnd hooks have a tight default timeout (1.5s — see
12
+ * cc-source-code/utils/hooks.ts:175 SESSION_END_HOOK_TIMEOUT_MS_DEFAULT) which
13
+ * is too short for transcript-read + Haiku call + DB write.
14
+ *
15
+ * Solution: this handler spawns a DETACHED worker process that does the real
16
+ * work in the background, then returns instantly. The worker survives the
17
+ * parent's exit and writes the checkpoint asynchronously. Worst case race:
18
+ * if the user starts a new session before the worker finishes, the new
19
+ * session sees no checkpoint — graceful degradation, not data loss.
20
+ *
21
+ * Reason filter: only fires for voluntary user exits (clear, prompt_input_exit,
22
+ * logout, resume). Skips system-driven exits (bypass_permissions_disabled, other)
23
+ * because those don't represent user intent to pause.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.handleSessionEndCheckpoint = handleSessionEndCheckpoint;
27
+ const child_process_1 = require("child_process");
28
+ const shared_1 = require("./shared");
29
+ const SKIP_REASONS = new Set(['bypass_permissions_disabled', 'other']);
30
+ async function handleSessionEndCheckpoint(input) {
31
+ const reason = input?.reason ?? 'unknown';
32
+ if (SKIP_REASONS.has(reason)) {
33
+ (0, shared_1.hookLog)('session-end-checkpoint', `Skipping (reason=${reason})`);
34
+ return;
35
+ }
36
+ if (!input?.transcript_path) {
37
+ (0, shared_1.hookLog)('session-end-checkpoint', 'No transcript_path in input — nothing to extract');
38
+ return;
39
+ }
40
+ try {
41
+ // process.argv[1] is the absolute path to claude-recall-cli.js (whichever
42
+ // entry the parent hook ran from). Reuse the same binary so the worker
43
+ // picks up the same dist version, MemoryService singleton path, env, etc.
44
+ const cliPath = process.argv[1];
45
+ const child = (0, child_process_1.spawn)(process.execPath, // node binary path
46
+ [cliPath, 'hook', 'run', 'session-end-checkpoint-worker'], {
47
+ detached: true,
48
+ stdio: ['pipe', 'ignore', 'ignore'],
49
+ // Inherit cwd so getProjectId() resolves correctly in the worker
50
+ });
51
+ if (child.stdin) {
52
+ child.stdin.write(JSON.stringify(input));
53
+ child.stdin.end();
54
+ }
55
+ // Detach so the worker outlives the parent. The hook handler returns
56
+ // immediately, well within Claude Code's 1.5s SessionEnd timeout.
57
+ child.unref();
58
+ (0, shared_1.hookLog)('session-end-checkpoint', `Spawned detached worker (pid=${child.pid}, reason=${reason})`);
59
+ }
60
+ catch (err) {
61
+ (0, shared_1.hookLog)('session-end-checkpoint', `Failed to spawn worker: ${err?.message ?? err}`);
62
+ // Best-effort — never block the hook
63
+ }
64
+ }
@@ -1,10 +1,67 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MemoryTools = void 0;
4
+ exports.formatRuleValue = formatRuleValue;
4
5
  const config_1 = require("../../services/config");
5
6
  const search_monitor_1 = require("../../services/search-monitor");
6
7
  const skill_generator_1 = require("../../services/skill-generator");
7
8
  const outcome_storage_1 = require("../../services/outcome-storage");
9
+ /**
10
+ * Render any memory.value shape as a readable string for load_rules output.
11
+ *
12
+ * Memory values land in the DB in several historical shapes. The previous
13
+ * rendering used `m.value.content || m.value.value || JSON.stringify(m.value)`
14
+ * which short-circuited on truthy non-string objects, producing "[object Object]"
15
+ * when string interpolation eventually called toString() on the returned object.
16
+ *
17
+ * Rules:
18
+ * 1. strings/numbers pass through (or coerce)
19
+ * 2. null/undefined → empty string
20
+ * 3. objects: prefer the first STRING field in order: content, value, title, description
21
+ * (only string — non-string `content` falls through to title)
22
+ * 4. nested failure shapes: extract `what_failed` (top-level or under `content`)
23
+ * 5. last resort: truncated JSON.stringify (never raw object)
24
+ *
25
+ * Exported for direct unit testing in tests/unit/format-rule-value.test.ts.
26
+ */
27
+ function formatRuleValue(value) {
28
+ if (value == null)
29
+ return '';
30
+ if (typeof value === 'string')
31
+ return value;
32
+ if (typeof value !== 'object')
33
+ return String(value);
34
+ const v = value;
35
+ // Prefer the first non-empty string field. Order matters:
36
+ // - `content` covers legacy hook failures and promoted lessons (lesson text)
37
+ // - `value` covers preference shape
38
+ // - `title` covers tool-outcome-watcher failures whose `content` is a nested object
39
+ // - `description` is a last-ditch human label
40
+ for (const field of ['content', 'value', 'title', 'description']) {
41
+ const candidate = v[field];
42
+ if (typeof candidate === 'string' && candidate.trim()) {
43
+ return candidate;
44
+ }
45
+ }
46
+ // Nested failure object — extract what_failed if present
47
+ if (typeof v.what_failed === 'string' && v.what_failed.trim()) {
48
+ return v.what_failed;
49
+ }
50
+ if (v.content && typeof v.content === 'object') {
51
+ const inner = v.content;
52
+ if (typeof inner.what_failed === 'string' && inner.what_failed.trim()) {
53
+ return inner.what_failed;
54
+ }
55
+ }
56
+ // Last resort: truncated JSON. Never return a raw object.
57
+ try {
58
+ const json = JSON.stringify(value);
59
+ return json.length > 200 ? json.substring(0, 200) + '…' : json;
60
+ }
61
+ catch {
62
+ return String(value);
63
+ }
64
+ }
8
65
  class MemoryTools {
9
66
  constructor(memoryService, logger, onMemoryChanged) {
10
67
  this.memoryService = memoryService;
@@ -287,7 +344,7 @@ class MemoryTools {
287
344
  const sections = [];
288
345
  if (rules.preferences.length > 0) {
289
346
  sections.push('## Preferences\n' + rules.preferences.map(m => {
290
- const val = typeof m.value === 'object' ? (m.value.content || m.value.value || JSON.stringify(m.value)) : m.value;
347
+ const val = formatRuleValue(m.value);
291
348
  // Only show key prefix if it's a meaningful name (not auto-generated)
292
349
  const key = m.preference_key || m.key || '';
293
350
  const isAutoKey = key.startsWith('memory_') || key.startsWith('auto_') || key.startsWith('pref_');
@@ -296,7 +353,7 @@ class MemoryTools {
296
353
  }
297
354
  if (rules.corrections.length > 0) {
298
355
  sections.push('## Corrections\n' + rules.corrections.map(m => {
299
- const val = typeof m.value === 'object' ? (m.value.content || m.value.value || JSON.stringify(m.value)) : m.value;
356
+ const val = formatRuleValue(m.value);
300
357
  const isPromoted = m.key.startsWith('promoted_') || m.value?.source === 'promotion-engine';
301
358
  const evidence = isPromoted && m.value?.evidence_count ? ` (learned from ${m.value.evidence_count} observations)` : '';
302
359
  return isPromoted ? `- [promoted lesson] ${val}${evidence}` : `- ${val}`;
@@ -308,21 +365,21 @@ class MemoryTools {
308
365
  const regularFailures = rules.failures.filter(m => !m.key.startsWith('promoted_') && m.value?.source !== 'promotion-engine');
309
366
  if (promotedLessons.length > 0) {
310
367
  sections.push('## Promoted Lessons (learned from repeated outcomes)\n' + promotedLessons.map(m => {
311
- const val = typeof m.value === 'object' ? (m.value.content || m.value.value || JSON.stringify(m.value)) : m.value;
368
+ const val = formatRuleValue(m.value);
312
369
  const evidence = m.value?.evidence_count ? ` (seen ${m.value.evidence_count}x)` : '';
313
370
  return `- ${val}${evidence}`;
314
371
  }).join('\n'));
315
372
  }
316
373
  if (regularFailures.length > 0) {
317
374
  sections.push('## Failures\n' + regularFailures.map(m => {
318
- const val = typeof m.value === 'object' ? (m.value.content || m.value.value || JSON.stringify(m.value)) : m.value;
375
+ const val = formatRuleValue(m.value);
319
376
  return `- ${val}`;
320
377
  }).join('\n'));
321
378
  }
322
379
  }
323
380
  if (rules.devops.length > 0) {
324
381
  sections.push('## DevOps Rules\n' + rules.devops.map(m => {
325
- const val = typeof m.value === 'object' ? (m.value.content || m.value.value || JSON.stringify(m.value)) : m.value;
382
+ const val = formatRuleValue(m.value);
326
383
  return `- ${val}`;
327
384
  }).join('\n'));
328
385
  }
@@ -67,6 +67,12 @@ function default_1(pi) {
67
67
  const collectedToolResults = [];
68
68
  let rulesLoaded = false;
69
69
  const collectedUserTexts = [];
70
+ // Chronologically interleaved entries (user input + tool results in order)
71
+ // for auto-checkpoint extraction at session end. Distinct from the two
72
+ // arrays above which are kept for backward compat with processSessionEnd
73
+ // and extractSessionLearnings.
74
+ const collectedEntries = [];
75
+ const MAX_COLLECTED_ENTRIES = 100;
70
76
  // Route logs through Pi's UI when available
71
77
  (0, event_processors_1.setLogFunction)((source, msg) => {
72
78
  try {
@@ -80,6 +86,7 @@ function default_1(pi) {
80
86
  rulesLoaded = false;
81
87
  collectedUserTexts.length = 0;
82
88
  collectedToolResults.length = 0;
89
+ collectedEntries.length = 0;
83
90
  (0, event_processors_1.resetPendingFailures)();
84
91
  try {
85
92
  config_1.ConfigService.getInstance().updateConfig({
@@ -121,6 +128,16 @@ function default_1(pi) {
121
128
  toolName: event.toolName,
122
129
  isError: event.isError,
123
130
  });
131
+ // Append to chronologically interleaved log for auto-checkpoint
132
+ collectedEntries.push({
133
+ role: 'tool_result',
134
+ text: output.substring(0, 300),
135
+ toolName: event.toolName,
136
+ isError: event.isError,
137
+ });
138
+ if (collectedEntries.length > MAX_COLLECTED_ENTRIES) {
139
+ collectedEntries.splice(0, collectedEntries.length - MAX_COLLECTED_ENTRIES);
140
+ }
124
141
  if (ctx.hasUI) {
125
142
  const label = event.input?.command
126
143
  ? truncateStr(event.input.command, 40)
@@ -139,6 +156,10 @@ function default_1(pi) {
139
156
  // --- Event: detect corrections from user input ---
140
157
  pi.on('input', (event, ctx) => {
141
158
  collectedUserTexts.push(event.text);
159
+ collectedEntries.push({ role: 'user', text: event.text });
160
+ if (collectedEntries.length > MAX_COLLECTED_ENTRIES) {
161
+ collectedEntries.splice(0, collectedEntries.length - MAX_COLLECTED_ENTRIES);
162
+ }
142
163
  (0, event_processors_1.processUserInput)(event.text, sessionId).then(msg => {
143
164
  if (msg && ctx.hasUI) {
144
165
  try {
@@ -179,6 +200,21 @@ function default_1(pi) {
179
200
  }
180
201
  }).catch(() => { });
181
202
  }
203
+ // Auto-checkpoint: extract "where I left off" hint for next Pi session.
204
+ // Critical for Pi which has no `--resume` flag — without this, the next
205
+ // Pi session has no memory of what came before. Uses chronologically
206
+ // interleaved entries (collectedEntries) so the LLM sees the actual
207
+ // most-recent task, not user texts followed by all tool results.
208
+ if (collectedEntries.length >= 3 && projectId) {
209
+ (0, event_processors_1.extractCheckpoint)(collectedEntries, projectId, 'pi').then(saved => {
210
+ if (saved && ctx.hasUI) {
211
+ try {
212
+ ctx.ui.notify('📌 Recall: saved task checkpoint for next session', 'info');
213
+ }
214
+ catch { /* non-critical */ }
215
+ }
216
+ }).catch(() => { });
217
+ }
182
218
  });
183
219
  // --- Event: pre-compaction — aggressive capture ---
184
220
  pi.on('session_before_compact', (event, _ctx) => {
@@ -48,6 +48,8 @@ exports.processSessionEnd = processSessionEnd;
48
48
  exports.processPreCompact = processPreCompact;
49
49
  exports.buildSummary = buildSummary;
50
50
  exports.extractSessionLearnings = extractSessionLearnings;
51
+ exports.buildRecentTaskSummary = buildRecentTaskSummary;
52
+ exports.extractCheckpoint = extractCheckpoint;
51
53
  const shared_1 = require("../hooks/shared");
52
54
  const llm_classifier_1 = require("../hooks/llm-classifier");
53
55
  const memory_1 = require("../services/memory");
@@ -493,3 +495,100 @@ async function extractSessionLearnings(entries, sessionId, projectId, maxStore =
493
495
  return 0;
494
496
  }
495
497
  }
498
+ // --- Auto-Checkpoint Extraction ---
499
+ const CHECKPOINT_SUMMARY_MAX_CHARS = 4000;
500
+ const CHECKPOINT_MIN_ENTRIES = 3;
501
+ const CHECKPOINT_MIN_REMAINING_LEN = 10;
502
+ /**
503
+ * Build a summary of the MOST RECENT entries (not the start) for checkpoint extraction.
504
+ * Walks backward from the end, accumulates lines until char budget is exhausted,
505
+ * then returns them in chronological order.
506
+ *
507
+ * Distinct from buildSummary() which prefers the START of the session.
508
+ */
509
+ function buildRecentTaskSummary(entries) {
510
+ const lines = [];
511
+ let totalChars = 0;
512
+ for (let i = entries.length - 1; i >= 0; i--) {
513
+ const entry = entries[i];
514
+ let line;
515
+ if (entry.role === 'tool_result') {
516
+ const status = entry.isError ? ' [ERROR]' : '';
517
+ const tool = entry.toolName ? `${entry.toolName}` : 'tool';
518
+ line = `[${tool}${status}] ${truncate(entry.text, 200)}`;
519
+ }
520
+ else if (entry.role === 'assistant' && entry.toolName) {
521
+ line = `[assistant → ${entry.toolName}] ${truncate(entry.text, 150)}`;
522
+ }
523
+ else {
524
+ line = `[${entry.role}] ${truncate(entry.text, 200)}`;
525
+ }
526
+ if (totalChars + line.length > CHECKPOINT_SUMMARY_MAX_CHARS)
527
+ break;
528
+ lines.unshift(line);
529
+ totalChars += line.length + 1;
530
+ }
531
+ return lines.join('\n');
532
+ }
533
+ /**
534
+ * Auto-extract a "where I left off" checkpoint from a session that is ending.
535
+ *
536
+ * Built for two callers with different runtime constraints:
537
+ * - Pi: in-process synchronous call from session_shutdown handler
538
+ * - Claude Code: detached worker process spawned from a SessionEnd hook
539
+ *
540
+ * Both runtimes pass the same ConversationEntry[] shape and get the same
541
+ * quality-gated behavior. Pi is the primary use case because Pi has no
542
+ * `--resume` equivalent — without this, restarted Pi sessions have no
543
+ * memory of what came before.
544
+ *
545
+ * Quality gate: skips the save if the LLM extraction has an empty/short
546
+ * `remaining` field. The whole point of a checkpoint is "what to resume
547
+ * from"; saving empty resumes would clobber any manual checkpoint with
548
+ * useless garbage.
549
+ *
550
+ * @param entries Recent conversation entries (last ~30 from session)
551
+ * @param projectId Current project ID
552
+ * @param runtime Tag for the notes field — distinguishes Pi vs CC auto-saves
553
+ * @returns true if a checkpoint was saved, false otherwise (no API key,
554
+ * insufficient entries, empty extraction, save failure)
555
+ */
556
+ async function extractCheckpoint(entries, projectId, runtime) {
557
+ if (!entries || entries.length < CHECKPOINT_MIN_ENTRIES) {
558
+ return false;
559
+ }
560
+ try {
561
+ const summary = buildRecentTaskSummary(entries);
562
+ if (!summary || summary.trim().length === 0)
563
+ return false;
564
+ const extraction = await (0, llm_classifier_1.extractCheckpointWithLLM)(summary);
565
+ if (extraction === null) {
566
+ logFn('event-processor', 'extractCheckpoint: LLM returned null (no API key, parse error, or empty extraction)');
567
+ return false;
568
+ }
569
+ // Quality gate: must have meaningful `remaining` content to be worth saving
570
+ const remaining = (extraction.remaining || '').trim();
571
+ if (remaining.length < CHECKPOINT_MIN_REMAINING_LEN) {
572
+ logFn('event-processor', `extractCheckpoint: skipping save — remaining field empty or too short (${remaining.length} chars)`);
573
+ return false;
574
+ }
575
+ const completed = (extraction.completed || '').trim();
576
+ const blockers = (extraction.blockers || '').trim();
577
+ const timestamp = new Date().toISOString();
578
+ const notes = `[auto-saved on ${runtime} session exit at ${timestamp}]`;
579
+ try {
580
+ const ms = memory_1.MemoryService.getInstance();
581
+ ms.saveCheckpoint(projectId, { completed, remaining, blockers, notes });
582
+ logFn('event-processor', `extractCheckpoint: saved auto-checkpoint for ${projectId} (runtime=${runtime})`);
583
+ return true;
584
+ }
585
+ catch (saveErr) {
586
+ logFn('event-processor', `extractCheckpoint: saveCheckpoint failed: ${(0, shared_1.safeErrorMessage)(saveErr)}`);
587
+ return false;
588
+ }
589
+ }
590
+ catch (err) {
591
+ logFn('event-processor', `extractCheckpoint error: ${(0, shared_1.safeErrorMessage)(err)}`);
592
+ return false;
593
+ }
594
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "Persistent memory for Claude Code and Pi with native Skills integration, automatic capture, failure learning, and project scoping",
5
5
  "main": "dist/index.js",
6
6
  "bin": {