claude-recall 0.31.0 → 0.33.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
@@ -92,6 +92,8 @@ tail -5 ~/.claude-recall/hook-logs/cc-classifier.log # which model ran, what w
92
92
  claude-recall search "something you said"
93
93
  ```
94
94
 
95
+ Design details (the `claude -p` key-precedence gotcha, recursion guards, which features run on the subscription): [docs/cc-llm-capture.md](docs/cc-llm-capture.md).
96
+
95
97
  ### Pi
96
98
 
97
99
  ```bash
@@ -169,7 +171,7 @@ Once installed, Claude Recall works in the background (CC = Claude Code):
169
171
  | **Session exit** | An auto-checkpoint (`{completed, remaining, blockers}`) is saved for next time | ✓ | ✓ | |
170
172
  | **End of session** | Failure patterns become candidate lessons; validated ones are promoted to rules | ✓ | ✓ | |
171
173
 
172
- Classification runs on each runtime's **own** LLM — Claude Code via headless `claude -p` on your subscription; Kiro via `kiro-cli chat --no-interactive` on Kiro credits — with `ANTHROPIC_API_KEY` (if you exported one *and* opted in) and regex as fallbacks. No API key is ever required; no configuration needed.
174
+ Classification runs on each runtime's **own** LLM — Claude Code via headless `claude -p` on your subscription; Kiro via `kiro-cli chat --no-interactive` on Kiro credits — with regex as the fallback. **An exported `ANTHROPIC_API_KEY` is never touched** unless you explicitly opt in with `CLAUDE_RECALL_PREFER_API_KEY=1`. No API key is ever required; no configuration needed.
173
175
 
174
176
  ```bash
175
177
  # Verify it's working
@@ -206,7 +208,7 @@ claude-recall checkpoint save --completed "API layer" --remaining "wire the UI"
206
208
  claude-recall checkpoint load
207
209
  ```
208
210
 
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.
211
+ 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 and no key touched. The same applies to the other background LLM features (failure hindsight hints, end-of-session lesson extraction). Pi-only machines without the `claude` binary can opt in to an `ANTHROPIC_API_KEY` with `CLAUDE_RECALL_PREFER_API_KEY=1`. A quality gate refuses to overwrite a manual checkpoint with a fabricated one when the task was already complete.
210
212
 
211
213
  ### Troubleshooting
212
214
 
@@ -387,11 +389,11 @@ Defaults work out of the box; tune via environment variables as needed.
387
389
  | Variable | Default | Effect |
388
390
  | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
389
391
  | `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. |
392
+ | `ANTHROPIC_API_KEY` | _(unset)_ | Optional personal API key for Haiku-based LLM features. **Never required, never provided by Claude Code, and never touched unless you opt in** with `CLAUDE_RECALL_PREFER_API_KEY=1` — capture, checkpoint extraction, hindsight hints, and session lessons all run on each runtime's own LLM (Claude subscription via `claude -p`; Kiro credits via `kiro-cli`), with regex as the fallback. |
391
393
  | `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
394
  | `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
395
  | `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). |
394
- | `CLAUDE_RECALL_PREFER_API_KEY` | _(unset)_ | Force `ANTHROPIC_API_KEY`-based classification ahead of the runtime's included LLM (uses your Anthropic API credits e.g. for a model you pay for). Applies under both Claude Code and Kiro. |
396
+ | `CLAUDE_RECALL_PREFER_API_KEY` | _(unset)_ | **The only switch that enables the `ANTHROPIC_API_KEY` backend** (and prefers it first). For Pi-only machines without the `claude` binary, or a stronger model you deliberately pay for. Applies to all LLM features, under Claude Code, Kiro, and Pi. |
395
397
  | `CLAUDE_RECALL_KIRO_LLM_TIMEOUT_MS` | `30000` | Hard cap on the headless `kiro-cli` classify call before the capture worker gives up and falls back to regex. |
396
398
  | `CLAUDE_RECALL_LOAD_BUDGET_TOKENS` | `2000` | Token budget for the `load_rules` payload. Rules are emitted in priority order (corrections → preferences by citation → devops by citation → failures) and dropped rules surface via `search_memory`. |
397
399
  | `CLAUDE_RECALL_AUTO_DEMOTE` | `false` | When `true`, auto-demote rules on MCP boot where `load_count >= CLAUDE_RECALL_DEMOTE_MIN_LOADS`, `cite_count = 0`, and age `> CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS`. Still reversible via `rules promote <id>`. |
@@ -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
+ }
@@ -5,10 +5,10 @@
5
5
  * Requires ANTHROPIC_API_KEY in the environment — a personal pay-as-you-go
6
6
  * API key the user exported themselves. Claude Code does NOT mint one from
7
7
  * the user's subscription (hooks just inherit the user's environment), which
8
- * is why this is a FALLBACK backend: the capture workers prefer each
9
- * runtime's included LLM (claude -p / kiro-cli — see cc-classifier.ts and
10
- * kiro-classifier.ts) and only land here when a key is present. Falls back
11
- * gracefully (returns null) when unavailable.
8
+ * is why this backend is STRICTLY OPT-IN (CLAUDE_RECALL_PREFER_API_KEY): the
9
+ * default chains use each runtime's included LLM only (claude -p / kiro-cli —
10
+ * see cc-classifier.ts and kiro-classifier.ts), and an exported key is never
11
+ * spent silently. Falls back gracefully (returns null) when unavailable.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.classifyWithLLM = classifyWithLLM;
@@ -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,75 @@ 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 policy matches capture: the user's Claude SUBSCRIPTION (headless
150
+ * `claude -p`) is the only default backend — a personally-exported
151
+ * ANTHROPIC_API_KEY is STRICTLY OPT-IN via CLAUDE_RECALL_PREFER_API_KEY and
152
+ * never consulted otherwise, not even as a fallback. The opt-in exists for
153
+ * Pi-only users (no `claude` binary; their key is how they run Pi itself)
154
+ * and anyone deliberately paying for a stronger model.
155
+ *
156
+ * The CLI backend is skipped inside a nested headless session
157
+ * (CLAUDE_RECALL_NESTED): if `claude -p` fires user-scope hooks of its own,
158
+ * those hooks must not spawn further `claude -p` calls.
159
+ */
160
+ async function completeText(systemPrompt, userContent, maxTokens) {
161
+ const viaApi = async () => {
162
+ const client = getClient();
163
+ if (!client)
164
+ return null;
165
+ try {
166
+ const response = await client.messages.create({
167
+ model: MODEL,
168
+ max_tokens: maxTokens,
169
+ system: systemPrompt,
170
+ messages: [{ role: 'user', content: userContent }],
171
+ });
172
+ const content = response.content?.[0];
173
+ return content?.type === 'text' ? content.text : null;
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ };
179
+ // `claude -p` takes a single argument (no system-prompt channel), so
180
+ // instruction and payload are combined — same shape as the Kiro backend.
181
+ const viaCli = () => process.env.CLAUDE_RECALL_NESTED
182
+ ? Promise.resolve(null)
183
+ : (0, cc_classifier_1.completeWithClaudeCli)(`${systemPrompt}\n\n${userContent}`, { timeoutMs: SECONDARY_CLI_TIMEOUT_MS });
184
+ const backends = process.env.CLAUDE_RECALL_PREFER_API_KEY
185
+ ? [viaApi, viaCli]
186
+ : [viaCli];
187
+ for (const backend of backends) {
188
+ const text = await backend();
189
+ if (text !== null && text.trim().length > 0)
190
+ return text;
191
+ }
192
+ return null;
121
193
  }
122
194
  /**
123
195
  * Classify a single text using Claude Haiku.
@@ -159,20 +231,11 @@ async function classifyWithLLM(text) {
159
231
  * callers fall back to grounding the detector's generic remedy text.
160
232
  */
161
233
  async function extractHindsightHint(failureDescription, context) {
162
- const client = getClient();
163
- if (!client)
234
+ 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);
235
+ if (!text)
164
236
  return null;
165
237
  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);
238
+ const result = parseJSON(text);
176
239
  if (!result.hint_text || !result.hint_kind)
177
240
  return null;
178
241
  return {
@@ -220,24 +283,15 @@ Return [] if nothing durable was learned. Max 10 items. Each content should be a
220
283
  * Returns null if no API key or on any failure.
221
284
  */
222
285
  async function extractSessionLearningsWithLLM(summary, existingMemories) {
223
- const client = getClient();
224
- if (!client)
286
+ const memList = existingMemories.length > 0
287
+ ? existingMemories.map(m => `- ${m}`).join('\n')
288
+ : '(none)';
289
+ const systemPrompt = SESSION_EXTRACTION_PROMPT + `\n\nEXISTING MEMORIES (do not duplicate):\n${memList}`;
290
+ const text = await completeText(systemPrompt, summary, 1000);
291
+ if (!text)
225
292
  return null;
226
293
  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);
294
+ const results = parseJSON(text);
241
295
  if (!Array.isArray(results))
242
296
  return null;
243
297
  const validTypes = ['project-knowledge', 'preference', 'devops', 'failure'];
@@ -304,23 +358,14 @@ Examples of BAD output (DO NOT DO THIS):
304
358
  {"completed":"explored architecture","remaining":"document findings","blockers":"none"} # FABRICATED — there was no documentation task
305
359
  {"completed":"finished everything","remaining":"finish everything","blockers":"none"} # nonsense filler`;
306
360
  async function extractCheckpointWithLLM(conversationSummary) {
307
- const client = getClient();
308
- if (!client)
309
- return null;
310
361
  if (!conversationSummary || conversationSummary.trim().length < 30) {
311
362
  return null;
312
363
  }
364
+ const text = await completeText(CHECKPOINT_EXTRACTION_PROMPT, conversationSummary, 600);
365
+ if (!text)
366
+ return null;
313
367
  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);
368
+ const result = parseJSON(text);
324
369
  if (typeof result !== 'object' || result === null)
325
370
  return null;
326
371
  return {
@@ -336,25 +381,16 @@ async function extractCheckpointWithLLM(conversationSummary) {
336
381
  async function classifyBatchWithLLM(texts) {
337
382
  if (texts.length === 0)
338
383
  return [];
339
- const client = getClient();
340
- if (!client)
384
+ // JSON array, not delimiter-joined text: a user message that happened to
385
+ // contain the old "---ITEM---" marker desynced item counts and silently
386
+ // dropped the entire batch to the regex fallback. JSON boundaries can't
387
+ // be forged by content.
388
+ const joined = JSON.stringify(texts);
389
+ const text = await completeText(BATCH_SYSTEM_PROMPT, joined, texts.length * 200);
390
+ if (!text)
341
391
  return null;
342
392
  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);
393
+ const results = parseJSON(text);
358
394
  if (!Array.isArray(results) || results.length !== texts.length)
359
395
  return null;
360
396
  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)) {
@@ -166,18 +166,17 @@ function classifyContentRegex(text) {
166
166
  * workers, which set the *_CLASSIFIER env vars — a ~4s CLI call can't run
167
167
  * inline on a turn):
168
168
  * - Under Claude Code (CLAUDE_RECALL_CC_CLASSIFIER set by cc-capture-worker):
169
- * headless `claude -p` on the user's Claude SUBSCRIPTION → ANTHROPIC_API_KEY
170
- * if present → regex.
169
+ * headless `claude -p` on the user's Claude SUBSCRIPTION → regex.
171
170
  * - Under Kiro (CLAUDE_RECALL_KIRO_CLASSIFIER set by kiro-capture-worker):
172
171
  * Kiro's own headless LLM (`kiro-cli chat --no-interactive`, Kiro credits)
173
- * → ANTHROPIC_API_KEY if present → regex.
174
- * - Inline callers (memory-stop batch, etc.): ANTHROPIC_API_KEY regex.
172
+ * → regex.
173
+ * - Inline callers: straight to regex.
175
174
  *
176
- * The included LLM is preferred so a stray exported key doesn't silently
177
- * spend the user's Anthropic API credits; CLAUDE_RECALL_PREFER_API_KEY flips
178
- * the order. NOTE: ANTHROPIC_API_KEY is always a personal key the user
179
- * exported — Claude Code does NOT provide one from the subscription.
180
- * See docs/kiro-llm-capture.md.
175
+ * The ANTHROPIC_API_KEY path is STRICTLY OPT-IN via
176
+ * CLAUDE_RECALL_PREFER_API_KEY an exported key is never consulted
177
+ * otherwise, not even as a fallback. (It is always a personal key the user
178
+ * exported — Claude Code does NOT provide one from the subscription.)
179
+ * See docs/cc-llm-capture.md and docs/kiro-llm-capture.md.
181
180
  */
182
181
  async function classifyContent(text) {
183
182
  // Guard the LLM paths the same way the regex path is guarded: a question or
@@ -202,30 +201,25 @@ async function classifyContentInner(text) {
202
201
  // module graph for hook invocations that don't run in a capture worker.
203
202
  const tryKiro = async () => (await Promise.resolve().then(() => __importStar(require('./kiro-classifier')))).classifyWithKiro(text);
204
203
  const tryCc = async () => (await Promise.resolve().then(() => __importStar(require('./cc-classifier')))).classifyWithClaudeCli(text);
205
- // Order the LLM backends. Each runtime's INCLUDED LLM comes before a stray
206
- // ANTHROPIC_API_KEY: a key exported for other tools should not silently
207
- // spend the user's Anthropic API credits when the runtime already provides
208
- // an LLM Kiro via `kiro-cli chat --no-interactive` (Kiro credits), Claude
209
- // Code via `claude -p` (the user's Claude subscription). Set
210
- // CLAUDE_RECALL_PREFER_API_KEY to force the key first (e.g. to use a
211
- // stronger model you pay for). The CLI backends only run inside their
212
- // detached capture workers (which set the *_CLASSIFIER env vars) inline
213
- // hook paths stay key regex, since a ~4s CLI call can't block a turn.
204
+ // Pick the LLM backends. The ANTHROPIC_API_KEY path is STRICTLY OPT-IN
205
+ // (CLAUDE_RECALL_PREFER_API_KEY): claude-recall was built for runtimes that
206
+ // bring their own LLM Kiro via `kiro-cli chat --no-interactive` (Kiro
207
+ // credits), Claude Code via `claude -p` (the user's Claude subscription)
208
+ // and a key exported for other tools must NEVER be spent silently, not even
209
+ // as a fallback. Without the opt-in, the chain is included LLM → regex.
210
+ // The opt-in exists for Pi-only users (no `claude` binary; their key is how
211
+ // they run Pi itself) and anyone deliberately paying for a stronger model.
212
+ // The CLI backends only run inside their detached capture workers (which
213
+ // set the *_CLASSIFIER env vars) — inline hook paths go straight to regex,
214
+ // since a ~4s CLI call can't block a turn.
214
215
  const backends = [];
215
- if (underKiro && !preferApiKey) {
216
- backends.push(tryKiro, tryApiKey);
217
- }
218
- else if (underKiro) {
219
- backends.push(tryApiKey, tryKiro);
220
- }
221
- else if (underCc && !preferApiKey) {
222
- backends.push(tryCc, tryApiKey);
216
+ if (preferApiKey)
217
+ backends.push(tryApiKey);
218
+ if (underKiro) {
219
+ backends.push(tryKiro);
223
220
  }
224
221
  else if (underCc) {
225
- backends.push(tryApiKey, tryCc);
226
- }
227
- else {
228
- backends.push(tryApiKey);
222
+ backends.push(tryCc);
229
223
  }
230
224
  for (const backend of backends) {
231
225
  const result = await backend();
@@ -0,0 +1,147 @@
1
+ # Using your Claude subscription for memory capture (no API key)
2
+
3
+ The Claude Code counterpart to [kiro-llm-capture.md](kiro-llm-capture.md): how
4
+ claude-recall's LLM features run on the user's **Claude subscription** — the
5
+ same login that powers their interactive session — instead of a personal
6
+ `ANTHROPIC_API_KEY`.
7
+
8
+ ## The problem
9
+
10
+ Claude Recall's LLM classifier originally called Claude Haiku through the
11
+ Anthropic SDK, which needs `ANTHROPIC_API_KEY` — a **personal pay-as-you-go
12
+ key**. For a long time the docs claimed Claude Code "provides the key to its
13
+ hooks." That was false: Claude Code does not mint a key from the user's
14
+ subscription; hooks merely inherit the user's environment. So the key path
15
+ only ever worked for users who had exported their own key, and it silently
16
+ spent **their API credits** — a separate wallet from the Claude subscription
17
+ they were already paying for.
18
+
19
+ The failure mode that exposed all of this: a user's exported key ran out of
20
+ credits. Every SDK call failed silently, capture degraded to the regex
21
+ fallback, and the regex promptly stored four junk memories in one session —
22
+ while the user reasonably believed "the LLM" was doing the classifying.
23
+
24
+ ## The finding
25
+
26
+ **`claude -p "<prompt>"` is a headless one-shot completion that runs on the
27
+ user's Claude Code login** — the direct analogue of the earlier
28
+ `kiro-cli chat --no-interactive` discovery. A background worker can shell out
29
+ to it to classify text with no API key and no extra cost beyond the
30
+ subscription usage the user already has.
31
+
32
+ One gotcha made this non-trivial, and it's the most important line in the
33
+ implementation:
34
+
35
+ > **`claude -p` prefers `ANTHROPIC_API_KEY` over subscription auth when the
36
+ > key is present in the environment.**
37
+
38
+ So a stray exported key — possibly dead, possibly billing a wallet the user
39
+ forgot about — would silently hijack every headless call. The fix:
40
+ `completeWithClaudeCli()` **strips `ANTHROPIC_API_KEY` from the child env**,
41
+ forcing subscription auth unconditionally. (Verified live: with a
42
+ credits-exhausted key in the env, `claude -p` returned
43
+ `Credit balance is too low`; with the key stripped, the same call succeeded on
44
+ the login.)
45
+
46
+ ## The design
47
+
48
+ ```
49
+ Claude Code UserPromptSubmit
50
+
51
+
52
+ hook run correction-detector (name unchanged in settings.json;
53
+ │ returns in <100 ms)
54
+ │ spawn detached, pipe payload via stdin
55
+
56
+ hook run cc-capture-worker (background, survives parent exit)
57
+ │ sets CLAUDE_RECALL_CC_CLASSIFIER=1
58
+
59
+ handleCorrectionDetector → classifyContent()
60
+
61
+ ├─ classifyWithClaudeCli() → claude -p --model haiku "<prompt>"
62
+ │ (subscription auth, key stripped)
63
+ └─ regex fallback
64
+
65
+ (classifyWithLLM() / ANTHROPIC_API_KEY exists but is OPT-IN only —
66
+ see below; it is never in the default chain)
67
+ ```
68
+
69
+ Precedence mirrors Kiro: **the runtime's included LLM → regex.** An exported
70
+ `ANTHROPIC_API_KEY` is **never consulted by default — not even as a
71
+ fallback**; `CLAUDE_RECALL_PREFER_API_KEY=1` is the one switch that enables
72
+ (and prefers) it. The registered hook name (`hook run correction-detector`)
73
+ was deliberately kept, so existing `settings.json` files work with no
74
+ re-setup — only the dispatch behind it changed.
75
+
76
+ **Trade-off:** the synchronous `📌 Recall: auto-captured …` echo is gone.
77
+ A cold `claude -p` takes ~4 s, far too slow to block the user's prompt, so
78
+ capture is silent and lands a few seconds later — the same contract Kiro has
79
+ always had. Verify via `~/.claude-recall/hook-logs/cc-classifier.log`
80
+ (`classified via claude -p (model=…, Claude subscription, no API key)`) or
81
+ `claude-recall search`.
82
+
83
+ ### Recursion guard
84
+
85
+ The nested headless session is itself a Claude Code session. If the user has
86
+ claude-recall hooks registered at **user scope** (`~/.claude/settings.json`),
87
+ the classify call would fire those hooks, which would spawn another worker,
88
+ which would run another `claude -p` — forever. Two layers prevent this:
89
+
90
+ 1. `claude -p` children run with `cwd = os.tmpdir()`, so **project**
91
+ settings/hooks never load.
92
+ 2. Children are marked with `CLAUDE_RECALL_NESTED=1` (plus the legacy
93
+ `CLAUDE_RECALL_CC_CLASSIFIER=1`). The capture hook refuses to spawn a
94
+ worker, and the secondary-feature backend refuses to invoke the CLI, when
95
+ either marker is present.
96
+
97
+ ### Beyond capture: the secondary features (0.32.0)
98
+
99
+ `completeWithClaudeCli()` is a generic completion primitive, and the four
100
+ features that previously knew only the key path now route through it with the
101
+ same precedence:
102
+
103
+ | Feature | Runs in | Without any key, now |
104
+ | --- | --- | --- |
105
+ | Auto-checkpoint extraction | Session-exit worker (CC + **Pi** — Pi's only recovery path, it has no `--resume`) | Works via `claude -p` |
106
+ | Failure hindsight hints | Stop hook, inline | Works via `claude -p` |
107
+ | End-of-session lesson extraction | Stop hook, inline | Works via `claude -p` |
108
+ | Batch classification | Stop hook, inline | Works via `claude -p` |
109
+
110
+ Two budget rules keep the inline ones inside the Stop hook's ~40 s window
111
+ (capture doesn't need them — it runs in a detached worker):
112
+
113
+ - each secondary CLI call is capped at **10 s** (vs the worker's 30 s), and
114
+ - hindsight-hint generation is capped at **5 LLM calls per run** (the loop
115
+ over detected failures is otherwise unbounded; failures past the cap keep
116
+ the grounded generic lesson text).
117
+
118
+ Under **Pi** these functions run in-process, so Pi benefits automatically on
119
+ any machine with the `claude` binary installed alongside. Under **Kiro** the
120
+ Stop-hook features mostly don't run (no transcript in its hooks), and capture
121
+ uses the Kiro backend.
122
+
123
+ ### Configuration
124
+
125
+ | Variable | Default | Effect |
126
+ | --- | --- | --- |
127
+ | `CLAUDE_RECALL_CC_MODEL` | `haiku` | Model passed to `claude -p --model` — a dedicated classifier model, independent of the user's interactive session model. |
128
+ | `CLAUDE_RECALL_CC_LLM_TIMEOUT_MS` | `30000` | Hard cap on the capture worker's `claude -p` call. (Secondary features use a fixed 10 s per call.) |
129
+ | `CLAUDE_RECALL_PREFER_API_KEY` | *(unset)* | **The only switch that enables the `ANTHROPIC_API_KEY` backend** (and prefers it first) — without it an exported key is never touched, not even as a fallback. For Pi-only machines without the `claude` binary, or a stronger model you deliberately pay for. Applies to capture and the secondary features, on every runtime. |
130
+ | `CLAUDE_RECALL_NESTED` | *(set on `claude -p` children)* | Recursion marker — never set it by hand. |
131
+
132
+ ## Caveats
133
+
134
+ - **Requires the `claude` binary on `PATH`.** Absent (e.g. a bare CI box, or a
135
+ Pi-only machine), the CLI backend errors out and the chain falls through to
136
+ regex — or to an `ANTHROPIC_API_KEY` if you opted in with
137
+ `CLAUDE_RECALL_PREFER_API_KEY=1`. No crash, no exception.
138
+ - **Subscription usage.** Each classified prompt is one small Haiku call
139
+ against the user's Claude plan limits. That is the deliberate trade — the
140
+ user's existing plan instead of a second wallet.
141
+ - **Latency.** ~4 s cold per call. Fine in a detached worker; the reason the
142
+ inline features carry the 10 s cap and hint budget.
143
+ - **Consistency.** Same probabilistic caveat as the Kiro backend: the
144
+ classifier is an LLM judgement call. The classify prompt carries a
145
+ "standalone rule" test with negative examples precisely because early
146
+ versions stored one-off task instructions ("first fix the sentence") as
147
+ corrections.
@@ -9,7 +9,7 @@ Claude Haiku through `ANTHROPIC_API_KEY` — a personal API key the user had
9
9
  exported themselves. (Claude Code does **not** mint a key from the user's
10
10
  subscription; hooks merely inherit the environment. The headless-CLI approach
11
11
  documented here was later applied back to Claude Code via `claude -p` on
12
- subscription auth — see `src/hooks/cc-classifier.ts`.)
12
+ subscription auth — see [cc-llm-capture.md](cc-llm-capture.md).)
13
13
 
14
14
  Kiro CLI does **not** set `ANTHROPIC_API_KEY`. So under Kiro the classifier fell
15
15
  back to a conservative regex that only fires on explicit phrasings
@@ -89,20 +89,24 @@ Kiro userPromptSubmit
89
89
 
90
90
  handleCorrectionDetector → classifyContent()
91
91
 
92
- ├─ classifyWithLLM() → ANTHROPIC_API_KEY (unset under Kiro) → null
93
92
  ├─ classifyWithKiro() → kiro-cli chat --no-interactive --agent
94
93
  │ claude-recall-classifier --model claude-haiku-4.5
95
94
  │ → {"type","confidence","extract"}
96
- └─ regex fallback (only if both above yield nothing)
95
+ └─ regex fallback (only if the above yields nothing)
96
+
97
+ (classifyWithLLM() / ANTHROPIC_API_KEY exists but is OPT-IN only —
98
+ never in the default chain)
97
99
  ```
98
100
 
99
- Capture precedence under Kiro: **Kiro's included LLM → `ANTHROPIC_API_KEY` if
100
- present → regex as a last resort.** The Kiro LLM comes first *even when a key is
101
- set*, so a key exported for other tools never silently spends the user's
102
- Anthropic credits Kiro already ships an LLM. `CLAUDE_RECALL_PREFER_API_KEY=1`
103
- flips the order back to key-first for anyone who deliberately wants to pay for a
104
- stronger model. (Under Claude Code the same pattern applies with `claude -p` on
105
- the user's subscription as the included LLM see `src/hooks/cc-classifier.ts`.)
101
+ Capture precedence under Kiro: **Kiro's included LLM → regex as a last
102
+ resort.** An exported `ANTHROPIC_API_KEY` is **never consulted by default
103
+ not even as a fallback** — so a key exported for other tools never silently
104
+ spends the user's Anthropic credits; Kiro already ships an LLM.
105
+ `CLAUDE_RECALL_PREFER_API_KEY=1` is the one explicit switch that enables (and
106
+ prefers) the key, for anyone who deliberately wants to pay for a stronger
107
+ model. (Under Claude Code the same pattern applies with `claude -p` on the
108
+ user's subscription as the included LLM — see
109
+ [cc-llm-capture.md](cc-llm-capture.md).)
106
110
 
107
111
  ### Output parsing
108
112
 
@@ -119,7 +123,7 @@ returns `null`, degrading to regex — a hook must never throw.
119
123
  | `CLAUDE_RECALL_KIRO_CLASSIFIER` | *(set by the worker)* | Enables the Kiro-LLM path in `classifyContent`. Set automatically by `kiro-capture-worker`; never needed by hand. |
120
124
  | `CLAUDE_RECALL_KIRO_MODEL` | `claude-haiku-4.5` | Model for the classify call, passed explicitly as `--model`. This is a **dedicated classifier model, independent of the user's interactive Kiro chat model** (e.g. `auto`) — the headless call always uses this value. Raise to `claude-sonnet-4.6` for steadier judgement at ~3× the credits. |
121
125
  | `CLAUDE_RECALL_KIRO_LLM_TIMEOUT_MS` | `30000` | Hard cap on the headless call before the worker gives up and falls back to regex. |
122
- | `CLAUDE_RECALL_PREFER_API_KEY` | *(unset)* | Force `ANTHROPIC_API_KEY`-based classification ahead of Kiro's included LLM (opt-in; uses your Anthropic credits). |
126
+ | `CLAUDE_RECALL_PREFER_API_KEY` | *(unset)* | **The only switch that enables the `ANTHROPIC_API_KEY` backend** without it an exported key is never touched, not even as a fallback. |
123
127
 
124
128
  ## Caveats
125
129
 
package/docs/kiro.md CHANGED
@@ -74,7 +74,7 @@ To decide what's worth remembering, the capture hook classifies each prompt via
74
74
 
75
75
  **The classifier uses a dedicated, fixed model — not your chat model.** It always runs the model in `CLAUDE_RECALL_KIRO_MODEL` (default `claude-haiku-4.5`, chosen because classification is cheap and high-volume), **independent of your interactive Kiro chat model** (e.g. `auto`). This keeps classification cost predictable no matter what your chat is set to. Set `CLAUDE_RECALL_KIRO_MODEL` to any model from `kiro-cli chat --list-models` to change it. Every successful classification is logged to `~/.claude-recall/hook-logs/kiro-classifier.log` as `classified via kiro-cli (model=…, Kiro credits, no API key)`, so you can always see which model ran.
76
76
 
77
- **Under Kiro the included LLM is used first even if you happen to have `ANTHROPIC_API_KEY` set** — so a key exported for other tools won't quietly spend your Anthropic credits. Order: Kiro's LLM → `ANTHROPIC_API_KEY` (if present) → regex; set `CLAUDE_RECALL_PREFER_API_KEY=1` to force your key first (e.g. for a stronger model you pay for).
77
+ **An exported `ANTHROPIC_API_KEY` is never touched** — a key exported for other tools won't quietly spend your Anthropic credits, not even as a fallback. Order: Kiro's LLM → regex. Setting `CLAUDE_RECALL_PREFER_API_KEY=1` is the one explicit switch that enables (and prefers) your key, e.g. for a stronger model you pay for.
78
78
 
79
79
  Design details, latency measurements, and output-parsing internals: [kiro-llm-capture.md](kiro-llm-capture.md).
80
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.31.0",
3
+ "version": "0.33.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": {