claude-recall 0.31.0 → 0.32.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
@@ -206,7 +206,7 @@ claude-recall checkpoint save --completed "API layer" --remaining "wire the UI"
206
206
  claude-recall checkpoint load
207
207
  ```
208
208
 
209
- Auto-checkpoints are also saved on session exit in Claude Code and Pi (Pi has no `--resume`, so this is its main recovery path). Extraction uses Haiku via `ANTHROPIC_API_KEY`; without a key, only manual checkpoints work. A quality gate refuses to overwrite a manual checkpoint with a fabricated one when the task was already complete.
209
+ Auto-checkpoints are also saved on session exit in Claude Code and Pi (Pi has no `--resume`, so this is its main recovery path). Extraction runs on your **Claude subscription** (headless `claude -p`) — like capture, no API key needed; an exported `ANTHROPIC_API_KEY` is only a fallback. The same applies to the other background LLM features (failure hindsight hints, end-of-session lesson extraction). A quality gate refuses to overwrite a manual checkpoint with a fabricated one when the task was already complete.
210
210
 
211
211
  ### Troubleshooting
212
212
 
@@ -387,7 +387,7 @@ Defaults work out of the box; tune via environment variables as needed.
387
387
  | Variable | Default | Effect |
388
388
  | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
389
389
  | `CLAUDE_RECALL_DB_PATH` | `~/.claude-recall/` | Database directory. |
390
- | `ANTHROPIC_API_KEY` | _(unset)_ | Optional personal API key for Haiku-based classification. **Never required and never provided by Claude Code** — capture uses each runtime's own LLM first (Claude subscription via `claude -p`; Kiro credits via `kiro-cli`). A key you exported is only consulted as a fallback, or first with `CLAUDE_RECALL_PREFER_API_KEY=1`. Regex is the final fallback. |
390
+ | `ANTHROPIC_API_KEY` | _(unset)_ | Optional personal API key for Haiku-based LLM features. **Never required and never provided by Claude Code** — capture, checkpoint extraction, hindsight hints, and session lessons all use each runtime's own LLM first (Claude subscription via `claude -p`; Kiro credits via `kiro-cli`). A key you exported is only consulted as a fallback, or first with `CLAUDE_RECALL_PREFER_API_KEY=1`. Regex is the final fallback. |
391
391
  | `CLAUDE_RECALL_CC_MODEL` | `haiku` | Dedicated model for capture classification under Claude Code (passed to `claude -p --model`) — independent of your interactive session model. |
392
392
  | `CLAUDE_RECALL_CC_LLM_TIMEOUT_MS` | `30000` | Hard cap on the headless `claude -p` classify call before the capture worker gives up and falls through. |
393
393
  | `CLAUDE_RECALL_KIRO_MODEL` | `claude-haiku-4.5` | Dedicated model for Kiro-LLM capture classification — **independent of your interactive Kiro chat model**. Raise to `claude-sonnet-4.6` for steadier judgement at more credits. See [docs/kiro-llm-capture.md](docs/kiro-llm-capture.md). |
@@ -30,7 +30,9 @@ async function handleCcCapture(input) {
30
30
  // Recursion guard: if we're already inside a classifier's nested headless
31
31
  // session (claude -p or kiro-cli fired hooks of its own), do NOT spawn
32
32
  // another worker — that would classify the classify prompt, forever.
33
- if (process.env.CLAUDE_RECALL_CC_CLASSIFIER || process.env.CLAUDE_RECALL_KIRO_CLASSIFIER) {
33
+ if (process.env.CLAUDE_RECALL_NESTED
34
+ || process.env.CLAUDE_RECALL_CC_CLASSIFIER
35
+ || process.env.CLAUDE_RECALL_KIRO_CLASSIFIER) {
34
36
  return;
35
37
  }
36
38
  const prompt = input?.prompt ?? '';
@@ -57,6 +57,7 @@ var __importStar = (this && this.__importStar) || (function () {
57
57
  };
58
58
  })();
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.completeWithClaudeCli = completeWithClaudeCli;
60
61
  exports.classifyWithClaudeCli = classifyWithClaudeCli;
61
62
  const child_process_1 = require("child_process");
62
63
  const os = __importStar(require("os"));
@@ -65,18 +66,28 @@ const kiro_classifier_1 = require("./kiro-classifier");
65
66
  const DEFAULT_MODEL = 'haiku';
66
67
  const DEFAULT_TIMEOUT_MS = 30000;
67
68
  /**
68
- * Classify a prompt by invoking Claude Code's headless mode. Returns null on
69
- * any failure (claude not on PATH, timeout, non-zero exit, unparseable output)
70
- * so the caller falls back to the next backend. Never throws.
69
+ * Run one headless `claude -p` completion on the user's Claude SUBSCRIPTION
70
+ * and return raw stdout, or null on any failure (claude not on PATH, timeout,
71
+ * non-zero exit). Never throws. This is the generic primitive: capture
72
+ * classification wraps it below, and llm-classifier routes its secondary
73
+ * features (hindsight hints, session extraction, checkpoint extraction,
74
+ * batch classification) through it so none of them require an API key either.
71
75
  */
72
- function classifyWithClaudeCli(text) {
76
+ function completeWithClaudeCli(prompt, opts = {}) {
73
77
  const model = process.env.CLAUDE_RECALL_CC_MODEL || DEFAULT_MODEL;
74
- const timeoutMs = parseInt(process.env.CLAUDE_RECALL_CC_LLM_TIMEOUT_MS || '', 10);
75
- const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
78
+ const envTimeout = parseInt(process.env.CLAUDE_RECALL_CC_LLM_TIMEOUT_MS || '', 10);
79
+ const timeout = opts.timeoutMs
80
+ ?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : DEFAULT_TIMEOUT_MS);
76
81
  // Force subscription auth: with ANTHROPIC_API_KEY set, `claude -p` bills the
77
- // key instead of the login — the exact behavior this classifier exists to
78
- // avoid. CLAUDE_RECALL_CC_CLASSIFIER rides along as the recursion guard.
79
- const env = { ...process.env, CLAUDE_RECALL_CC_CLASSIFIER: '1' };
82
+ // key instead of the login — the exact behavior this backend exists to
83
+ // avoid. CLAUDE_RECALL_NESTED marks the child as a nested headless session
84
+ // (every CLI-backend consumer checks it before spawning — recursion guard);
85
+ // CLAUDE_RECALL_CC_CLASSIFIER rides along for the capture-hook guard too.
86
+ const env = {
87
+ ...process.env,
88
+ CLAUDE_RECALL_CC_CLASSIFIER: '1',
89
+ CLAUDE_RECALL_NESTED: '1',
90
+ };
80
91
  delete env.ANTHROPIC_API_KEY;
81
92
  return new Promise((resolve) => {
82
93
  let settled = false;
@@ -91,7 +102,7 @@ function classifyWithClaudeCli(text) {
91
102
  // args array (no shell) — the user's text is a single argv entry, so no
92
103
  // shell escaping or injection is possible. cwd is the temp dir so the
93
104
  // nested session loads no project settings or hooks.
94
- child = (0, child_process_1.spawn)('claude', ['-p', '--model', model, (0, kiro_classifier_1.buildClassifyPrompt)(text)], { cwd: os.tmpdir(), env, stdio: ['ignore', 'pipe', 'ignore'] });
105
+ child = (0, child_process_1.spawn)('claude', ['-p', '--model', model, prompt], { cwd: os.tmpdir(), env, stdio: ['ignore', 'pipe', 'ignore'] });
95
106
  }
96
107
  catch (err) {
97
108
  (0, shared_1.hookLog)('cc-classifier', `spawn threw: ${(0, shared_1.safeErrorMessage)(err)}`);
@@ -121,22 +132,34 @@ function classifyWithClaudeCli(text) {
121
132
  (0, shared_1.hookLog)('cc-classifier', `claude -p exited ${code}`);
122
133
  return done(null);
123
134
  }
124
- const result = (0, kiro_classifier_1.extractClassification)(stdout);
125
- if (!result) {
126
- // Distinguish a deliberate "none" verdict from unparseable output —
127
- // "the model said not a rule" and "the call broke" are different
128
- // diagnoses when reading the log.
129
- (0, shared_1.hookLog)('cc-classifier', /"type"\s*:\s*"none"/.test(stdout)
130
- ? 'classified as none (not a durable rule)'
131
- : 'no parseable classification in claude -p output');
132
- }
133
- else {
134
- // Same observability contract as kiro-classifier: spell out which
135
- // backend ran and on whose account, so "which LLM classified this?"
136
- // is always answerable from the log.
137
- (0, shared_1.hookLog)('cc-classifier', `classified via claude -p (model=${model}, Claude subscription, no API key): ${result.type} — ${result.extract.slice(0, 60)}`);
138
- }
139
- done(result);
135
+ done(stdout);
140
136
  });
141
137
  });
142
138
  }
139
+ /**
140
+ * Classify a prompt by invoking Claude Code's headless mode. Returns null on
141
+ * any failure (claude not on PATH, timeout, non-zero exit, unparseable output)
142
+ * so the caller falls back to the next backend. Never throws.
143
+ */
144
+ async function classifyWithClaudeCli(text) {
145
+ const model = process.env.CLAUDE_RECALL_CC_MODEL || DEFAULT_MODEL;
146
+ const stdout = await completeWithClaudeCli((0, kiro_classifier_1.buildClassifyPrompt)(text));
147
+ if (stdout === null)
148
+ return null;
149
+ const result = (0, kiro_classifier_1.extractClassification)(stdout);
150
+ if (!result) {
151
+ // Distinguish a deliberate "none" verdict from unparseable output —
152
+ // "the model said not a rule" and "the call broke" are different
153
+ // diagnoses when reading the log.
154
+ (0, shared_1.hookLog)('cc-classifier', /"type"\s*:\s*"none"/.test(stdout)
155
+ ? 'classified as none (not a durable rule)'
156
+ : 'no parseable classification in claude -p output');
157
+ }
158
+ else {
159
+ // Same observability contract as kiro-classifier: spell out which
160
+ // backend ran and on whose account, so "which LLM classified this?"
161
+ // is always answerable from the log.
162
+ (0, shared_1.hookLog)('cc-classifier', `classified via claude -p (model=${model}, Claude subscription, no API key): ${result.type} — ${result.extract.slice(0, 60)}`);
163
+ }
164
+ return result;
165
+ }
@@ -16,9 +16,17 @@ exports.extractHindsightHint = extractHindsightHint;
16
16
  exports.extractSessionLearningsWithLLM = extractSessionLearningsWithLLM;
17
17
  exports.extractCheckpointWithLLM = extractCheckpointWithLLM;
18
18
  exports.classifyBatchWithLLM = classifyBatchWithLLM;
19
+ const cc_classifier_1 = require("./cc-classifier");
19
20
  // Lazy singleton — avoid import cost when API key is absent
20
21
  let clientInstance; // undefined = not yet checked
21
22
  const MODEL = 'claude-haiku-4-5-20251001';
23
+ /**
24
+ * Per-call cap for `claude -p` in these SECONDARY features. Unlike capture
25
+ * (detached worker, 30s budget), hindsight hints / session extraction / batch
26
+ * classification run INLINE in the Stop hook (~40s total budget, possibly
27
+ * several calls) — a hung CLI must not eat the whole hook.
28
+ */
29
+ const SECONDARY_CLI_TIMEOUT_MS = 10000;
22
30
  const SYSTEM_PROMPT = `You are a memory classifier for a developer tool. Classify user text into one of these types:
23
31
 
24
32
  - correction: User durably correcting how something should ALWAYS be done ("no, use X not Y", "wrong, it should be..."). Correcting a one-off action in the current task is NOT durable — only store it if the rule generalizes beyond this moment.
@@ -113,11 +121,74 @@ function getClient() {
113
121
  }
114
122
  }
115
123
  /**
116
- * Parse a JSON response, stripping markdown fences if present.
124
+ * Parse a JSON response, stripping markdown fences if present. Falls back to
125
+ * slicing the first balanced-looking JSON region — `claude -p` occasionally
126
+ * wraps output in prose or a trailing fence the strict strip misses.
117
127
  */
118
128
  function parseJSON(text) {
119
129
  const cleaned = text.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '');
120
- return JSON.parse(cleaned);
130
+ try {
131
+ return JSON.parse(cleaned);
132
+ }
133
+ catch (err) {
134
+ const starts = [cleaned.indexOf('{'), cleaned.indexOf('[')].filter((i) => i !== -1);
135
+ if (starts.length === 0)
136
+ throw err;
137
+ const start = Math.min(...starts);
138
+ const end = Math.max(cleaned.lastIndexOf('}'), cleaned.lastIndexOf(']'));
139
+ if (end <= start)
140
+ throw err;
141
+ return JSON.parse(cleaned.slice(start, end + 1));
142
+ }
143
+ }
144
+ /**
145
+ * Run one completion for the SECONDARY LLM features (hindsight hints, session
146
+ * extraction, checkpoint extraction, batch classification) and return raw
147
+ * text, or null when no backend is available.
148
+ *
149
+ * Backend order matches capture: the user's Claude SUBSCRIPTION (headless
150
+ * `claude -p`) before a personally-exported ANTHROPIC_API_KEY, flipped by
151
+ * CLAUDE_RECALL_PREFER_API_KEY. So running out of Anthropic API credits — or
152
+ * never having a key at all — does not disable these features on any machine
153
+ * with the `claude` binary (Claude Code itself, or Pi running alongside it).
154
+ *
155
+ * The CLI backend is skipped inside a nested headless session
156
+ * (CLAUDE_RECALL_NESTED): if `claude -p` fires user-scope hooks of its own,
157
+ * those hooks must not spawn further `claude -p` calls.
158
+ */
159
+ async function completeText(systemPrompt, userContent, maxTokens) {
160
+ const viaApi = async () => {
161
+ const client = getClient();
162
+ if (!client)
163
+ return null;
164
+ try {
165
+ const response = await client.messages.create({
166
+ model: MODEL,
167
+ max_tokens: maxTokens,
168
+ system: systemPrompt,
169
+ messages: [{ role: 'user', content: userContent }],
170
+ });
171
+ const content = response.content?.[0];
172
+ return content?.type === 'text' ? content.text : null;
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ };
178
+ // `claude -p` takes a single argument (no system-prompt channel), so
179
+ // instruction and payload are combined — same shape as the Kiro backend.
180
+ const viaCli = () => process.env.CLAUDE_RECALL_NESTED
181
+ ? Promise.resolve(null)
182
+ : (0, cc_classifier_1.completeWithClaudeCli)(`${systemPrompt}\n\n${userContent}`, { timeoutMs: SECONDARY_CLI_TIMEOUT_MS });
183
+ const backends = process.env.CLAUDE_RECALL_PREFER_API_KEY
184
+ ? [viaApi, viaCli]
185
+ : [viaCli, viaApi];
186
+ for (const backend of backends) {
187
+ const text = await backend();
188
+ if (text !== null && text.trim().length > 0)
189
+ return text;
190
+ }
191
+ return null;
121
192
  }
122
193
  /**
123
194
  * Classify a single text using Claude Haiku.
@@ -159,20 +230,11 @@ async function classifyWithLLM(text) {
159
230
  * callers fall back to grounding the detector's generic remedy text.
160
231
  */
161
232
  async function extractHindsightHint(failureDescription, context) {
162
- const client = getClient();
163
- if (!client)
233
+ const text = await completeText('You extract actionable hindsight lessons from failures. Given a failure description and context, produce a JSON object with: hint_text (imperative rule to prevent recurrence), hint_kind (one of: rule, preference, anti_pattern, workflow, debug_fix, failure_preventer), applies_when (array of 1-3 situation tags). Respond with ONLY valid JSON.', `Failure: ${failureDescription}\nContext: ${context}`, 300);
234
+ if (!text)
164
235
  return null;
165
236
  try {
166
- const response = await client.messages.create({
167
- model: MODEL,
168
- max_tokens: 300,
169
- system: 'You extract actionable hindsight lessons from failures. Given a failure description and context, produce a JSON object with: hint_text (imperative rule to prevent recurrence), hint_kind (one of: rule, preference, anti_pattern, workflow, debug_fix, failure_preventer), applies_when (array of 1-3 situation tags). Respond with ONLY valid JSON.',
170
- messages: [{ role: 'user', content: `Failure: ${failureDescription}\nContext: ${context}` }],
171
- });
172
- const content = response.content?.[0];
173
- if (content?.type !== 'text')
174
- return null;
175
- const result = parseJSON(content.text);
237
+ const result = parseJSON(text);
176
238
  if (!result.hint_text || !result.hint_kind)
177
239
  return null;
178
240
  return {
@@ -220,24 +282,15 @@ Return [] if nothing durable was learned. Max 10 items. Each content should be a
220
282
  * Returns null if no API key or on any failure.
221
283
  */
222
284
  async function extractSessionLearningsWithLLM(summary, existingMemories) {
223
- const client = getClient();
224
- if (!client)
285
+ const memList = existingMemories.length > 0
286
+ ? existingMemories.map(m => `- ${m}`).join('\n')
287
+ : '(none)';
288
+ const systemPrompt = SESSION_EXTRACTION_PROMPT + `\n\nEXISTING MEMORIES (do not duplicate):\n${memList}`;
289
+ const text = await completeText(systemPrompt, summary, 1000);
290
+ if (!text)
225
291
  return null;
226
292
  try {
227
- const memList = existingMemories.length > 0
228
- ? existingMemories.map(m => `- ${m}`).join('\n')
229
- : '(none)';
230
- const systemPrompt = SESSION_EXTRACTION_PROMPT + `\n\nEXISTING MEMORIES (do not duplicate):\n${memList}`;
231
- const response = await client.messages.create({
232
- model: MODEL,
233
- max_tokens: 1000,
234
- system: systemPrompt,
235
- messages: [{ role: 'user', content: summary }],
236
- });
237
- const content = response.content?.[0];
238
- if (content?.type !== 'text')
239
- return null;
240
- const results = parseJSON(content.text);
293
+ const results = parseJSON(text);
241
294
  if (!Array.isArray(results))
242
295
  return null;
243
296
  const validTypes = ['project-knowledge', 'preference', 'devops', 'failure'];
@@ -304,23 +357,14 @@ Examples of BAD output (DO NOT DO THIS):
304
357
  {"completed":"explored architecture","remaining":"document findings","blockers":"none"} # FABRICATED — there was no documentation task
305
358
  {"completed":"finished everything","remaining":"finish everything","blockers":"none"} # nonsense filler`;
306
359
  async function extractCheckpointWithLLM(conversationSummary) {
307
- const client = getClient();
308
- if (!client)
309
- return null;
310
360
  if (!conversationSummary || conversationSummary.trim().length < 30) {
311
361
  return null;
312
362
  }
363
+ const text = await completeText(CHECKPOINT_EXTRACTION_PROMPT, conversationSummary, 600);
364
+ if (!text)
365
+ return null;
313
366
  try {
314
- const response = await client.messages.create({
315
- model: MODEL,
316
- max_tokens: 600,
317
- system: CHECKPOINT_EXTRACTION_PROMPT,
318
- messages: [{ role: 'user', content: conversationSummary }],
319
- });
320
- const content = response.content?.[0];
321
- if (content?.type !== 'text')
322
- return null;
323
- const result = parseJSON(content.text);
367
+ const result = parseJSON(text);
324
368
  if (typeof result !== 'object' || result === null)
325
369
  return null;
326
370
  return {
@@ -336,25 +380,16 @@ async function extractCheckpointWithLLM(conversationSummary) {
336
380
  async function classifyBatchWithLLM(texts) {
337
381
  if (texts.length === 0)
338
382
  return [];
339
- const client = getClient();
340
- if (!client)
383
+ // JSON array, not delimiter-joined text: a user message that happened to
384
+ // contain the old "---ITEM---" marker desynced item counts and silently
385
+ // dropped the entire batch to the regex fallback. JSON boundaries can't
386
+ // be forged by content.
387
+ const joined = JSON.stringify(texts);
388
+ const text = await completeText(BATCH_SYSTEM_PROMPT, joined, texts.length * 200);
389
+ if (!text)
341
390
  return null;
342
391
  try {
343
- // JSON array, not delimiter-joined text: a user message that happened to
344
- // contain the old "---ITEM---" marker desynced item counts and silently
345
- // dropped the entire batch to the regex fallback. JSON boundaries can't
346
- // be forged by content.
347
- const joined = JSON.stringify(texts);
348
- const response = await client.messages.create({
349
- model: MODEL,
350
- max_tokens: texts.length * 200,
351
- system: BATCH_SYSTEM_PROMPT,
352
- messages: [{ role: 'user', content: joined }],
353
- });
354
- const content = response.content?.[0];
355
- if (content?.type !== 'text')
356
- return null;
357
- const results = parseJSON(content.text);
392
+ const results = parseJSON(text);
358
393
  if (!Array.isArray(results) || results.length !== texts.length)
359
394
  return null;
360
395
  return results.map((r) => {
@@ -448,13 +448,17 @@ const VALID_LESSON_KINDS = new Set([
448
448
  async function generateCandidateLessons(failures, episodeId, projectId) {
449
449
  try {
450
450
  const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
451
+ // Each hint is an LLM call (via the subscription CLI it can take seconds),
452
+ // and this loop runs INLINE in the Stop hook's ~40s budget — cap the LLM
453
+ // calls per run; failures past the cap keep the grounded generic lesson.
454
+ let hintBudget = 5;
451
455
  for (const f of failures) {
452
456
  if (f.confidence < 0.7)
453
457
  continue;
454
458
  let lessonText = `${f.content.what_should_do} (failure: ${f.content.what_failed})`;
455
459
  let lessonKind = 'failure_preventer';
456
460
  let appliesWhen = extractTagsFromContext(f.content.context);
457
- const hint = await (0, llm_classifier_1.extractHindsightHint)(`${f.content.what_failed}${f.content.why_failed ? ` — ${f.content.why_failed}` : ''}`, f.content.context || '');
461
+ const hint = hintBudget-- > 0 ? await (0, llm_classifier_1.extractHindsightHint)(`${f.content.what_failed}${f.content.why_failed ? ` — ${f.content.why_failed}` : ''}`, f.content.context || '') : null;
458
462
  if (hint) {
459
463
  lessonText = hint.hint_text;
460
464
  if (VALID_LESSON_KINDS.has(hint.hint_kind)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
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": {