claude-recall 0.30.3 → 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
@@ -14,11 +14,13 @@ It works with **Claude Code**, **[Pi](https://github.com/mariozechner/pi)**, and
14
14
 
15
15
  > **You:** use pnpm here, not npm
16
16
 
17
+ Seconds later, silently in the background (`~/.claude-recall/hook-logs/cc-classifier.log`):
18
+
17
19
  ```text
18
- 📌 Recall: auto-captured correction — Use pnpm, not npm
20
+ classified via claude -p (model=haiku, Claude subscription, no API key): correction — Use pnpm, not npm
19
21
  ```
20
22
 
21
- That one line did everything. A hook classified your prompt with an LLM, decided it was a durable rule (not chit-chat), and stored it locally. No "remember this" incantation, no tool call, no config file to edit.
23
+ That's the whole workflow. A background hook classified your prompt with an LLM — **the agent's own LLM** (your Claude subscription under Claude Code, Kiro's credits under Kiro; no API key involved) — decided it was a durable rule and stored it locally. No "remember this" incantation, no tool call, no config file to edit.
22
24
 
23
25
  **Friday — brand-new session, no shared history:**
24
26
 
@@ -42,7 +44,7 @@ $ claude-recall search "pnpm"
42
44
 
43
45
  ## Features
44
46
 
45
- - **Automatic capture** — an LLM classifier detects preferences, corrections, and project facts in your normal prompts (regex fallback when no LLM is available)
47
+ - **Automatic capture** — an LLM classifier detects preferences, corrections, and project facts in your normal prompts, running on **the agent's own LLM** (Claude subscription / Kiro credits — never a separate API key), with regex fallback when no LLM is available
46
48
  - **Applied where it counts** — rules load at session start *and* are re-surfaced just-in-time before each tool call
47
49
  - **Project-scoped** — each project gets its own memory namespace; switch directories and the agent switches context
48
50
  - **Learns from failures** — records what broke, why, and what fixed it, so mistakes aren't repeated
@@ -83,6 +85,13 @@ claude mcp add --scope user claude-recall -- claude-recall mcp start
83
85
 
84
86
  Hook-based auto-capture remains a per-project opt-in via `claude-recall setup --install`.
85
87
 
88
+ **Capture runs on your Claude subscription — no API key.** The capture hook classifies each prompt via a headless `claude -p` call on the same login that powers your session, in a detached background worker (your turn is never blocked; capture is silent and lands a few seconds later). If you happen to have `ANTHROPIC_API_KEY` exported for other tools, it is deliberately **not** used unless you set `CLAUDE_RECALL_PREFER_API_KEY=1` — a stray key shouldn't quietly spend your Anthropic API credits. Verify captures any time:
89
+
90
+ ```bash
91
+ tail -5 ~/.claude-recall/hook-logs/cc-classifier.log # which model ran, what was stored
92
+ claude-recall search "something you said"
93
+ ```
94
+
86
95
  ### Pi
87
96
 
88
97
  ```bash
@@ -160,7 +169,7 @@ Once installed, Claude Recall works in the background (CC = Claude Code):
160
169
  | **Session exit** | An auto-checkpoint (`{completed, remaining, blockers}`) is saved for next time | ✓ | ✓ | |
161
170
  | **End of session** | Failure patterns become candidate lessons; validated ones are promoted to rules | ✓ | ✓ | |
162
171
 
163
- Classification uses an LLM wherever one is available — Claude Code provides `ANTHROPIC_API_KEY` to its hooks; Kiro uses its own included LLM — with silent regex fallback. No configuration needed.
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.
164
173
 
165
174
  ```bash
166
175
  # Verify it's working
@@ -197,7 +206,7 @@ claude-recall checkpoint save --completed "API layer" --remaining "wire the UI"
197
206
  claude-recall checkpoint load
198
207
  ```
199
208
 
200
- 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.
201
210
 
202
211
  ### Troubleshooting
203
212
 
@@ -378,9 +387,11 @@ Defaults work out of the box; tune via environment variables as needed.
378
387
  | Variable | Default | Effect |
379
388
  | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
380
389
  | `CLAUDE_RECALL_DB_PATH` | `~/.claude-recall/` | Database directory. |
381
- | `ANTHROPIC_API_KEY` | _(unset)_ | LLM classification via Haiku. Not required Claude Code provides it to its hooks, and under Kiro the included LLM is used instead (and is preferred even if this is set). 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
+ | `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
+ | `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. |
382
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). |
383
- | `CLAUDE_RECALL_PREFER_API_KEY` | _(unset)_ | Under Kiro, force `ANTHROPIC_API_KEY`-based classification ahead of Kiro's included LLM (uses your Anthropic credits — e.g. for a stronger model). No effect under Claude Code. |
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. |
384
395
  | `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. |
385
396
  | `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`. |
386
397
  | `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>`. |
@@ -37,6 +37,7 @@ exports.HookCommands = void 0;
37
37
  const shared_1 = require("../../hooks/shared");
38
38
  const AVAILABLE_HOOKS = [
39
39
  'correction-detector',
40
+ 'cc-capture-worker',
40
41
  'memory-stop',
41
42
  'memory-sync',
42
43
  'precompact-preserve',
@@ -70,8 +71,17 @@ class HookCommands {
70
71
  static async runOne(name, input) {
71
72
  switch (name) {
72
73
  case 'correction-detector': {
73
- const { handleCorrectionDetector } = await Promise.resolve().then(() => __importStar(require('../../hooks/correction-detector')));
74
- await handleCorrectionDetector(input);
74
+ // Kept as the registered name in every existing settings.json, but
75
+ // capture no longer classifies inline: the handler spawns a detached
76
+ // cc-capture-worker so the ~4s `claude -p` subscription classifier
77
+ // never blocks the user's turn. See src/hooks/cc-capture.ts.
78
+ const { handleCcCapture } = await Promise.resolve().then(() => __importStar(require('../../hooks/cc-capture')));
79
+ await handleCcCapture(input);
80
+ break;
81
+ }
82
+ case 'cc-capture-worker': {
83
+ const { handleCcCaptureWorker } = await Promise.resolve().then(() => __importStar(require('../../hooks/cc-capture')));
84
+ await handleCcCaptureWorker(input);
75
85
  break;
76
86
  }
77
87
  case 'memory-stop': {
@@ -141,7 +151,7 @@ class HookCommands {
141
151
  break;
142
152
  }
143
153
  // Kiro CLI adapters — see src/hooks/kiro-hooks.ts. Kiro's
144
- // userPromptSubmit needs no adapter: wire `correction-detector` directly.
154
+ // userPromptSubmit wires `kiro-capture` (detached-worker capture).
145
155
  case 'kiro-agent-spawn': {
146
156
  const { handleKiroAgentSpawn } = await Promise.resolve().then(() => __importStar(require('../../hooks/kiro-hooks')));
147
157
  await handleKiroAgentSpawn(input);
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Code capture — detached-worker pattern for UserPromptSubmit.
4
+ *
5
+ * Classification under Claude Code now prefers the user's SUBSCRIPTION (via
6
+ * headless `claude -p`, see cc-classifier.ts) over an ANTHROPIC_API_KEY. That
7
+ * call is ~4s cold — too slow to run inline while Claude Code blocks the
8
+ * user's prompt on the hook. So the registered hook (`hook run
9
+ * correction-detector`, unchanged in settings.json) spawns a DETACHED worker,
10
+ * pipes the payload to it over stdin, and returns in milliseconds — the same
11
+ * pattern as Kiro's kiro-capture (kiro-hooks.ts) and session-end-checkpoint.
12
+ *
13
+ * Trade-off: the synchronous "📌 Recall: auto-captured …" echo is gone —
14
+ * capture is silent and lands ~4s later. Verify via
15
+ * ~/.claude-recall/hook-logs/cc-classifier.log or `claude-recall search`.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.handleCcCapture = handleCcCapture;
19
+ exports.handleCcCaptureWorker = handleCcCaptureWorker;
20
+ const child_process_1 = require("child_process");
21
+ const shared_1 = require("./shared");
22
+ const correction_detector_1 = require("./correction-detector");
23
+ const HOOK_NAME = 'cc-capture';
24
+ /**
25
+ * The inline half — fires on UserPromptSubmit, spawns the worker, returns
26
+ * immediately. Pre-checks mirror correction-detector so we don't spawn a
27
+ * process (or spend subscription usage) on input that could never be stored.
28
+ */
29
+ async function handleCcCapture(input) {
30
+ // Recursion guard: if we're already inside a classifier's nested headless
31
+ // session (claude -p or kiro-cli fired hooks of its own), do NOT spawn
32
+ // another worker — that would classify the classify prompt, forever.
33
+ if (process.env.CLAUDE_RECALL_NESTED
34
+ || process.env.CLAUDE_RECALL_CC_CLASSIFIER
35
+ || process.env.CLAUDE_RECALL_KIRO_CLASSIFIER) {
36
+ return;
37
+ }
38
+ const prompt = input?.prompt ?? '';
39
+ if (prompt.length < 20 || prompt.length > 2000)
40
+ return;
41
+ if (prompt.startsWith('```') || prompt.startsWith('{'))
42
+ return;
43
+ try {
44
+ const cliPath = process.argv[1]; // absolute path to claude-recall-cli.js
45
+ const child = (0, child_process_1.spawn)(process.execPath, [cliPath, 'hook', 'run', 'cc-capture-worker'], { detached: true, stdio: ['pipe', 'ignore', 'ignore'] });
46
+ child.on('error', (err) => {
47
+ (0, shared_1.hookLog)(HOOK_NAME, `capture worker spawn error: ${err?.message ?? err}`);
48
+ });
49
+ if (child.stdin) {
50
+ child.stdin.on('error', (err) => {
51
+ (0, shared_1.hookLog)(HOOK_NAME, `capture worker stdin error: ${err?.message ?? err}`);
52
+ });
53
+ child.stdin.write(JSON.stringify(input));
54
+ child.stdin.end();
55
+ }
56
+ child.unref();
57
+ (0, shared_1.hookLog)(HOOK_NAME, `capture: spawned detached worker (pid=${child.pid})`);
58
+ }
59
+ catch (err) {
60
+ (0, shared_1.hookLog)(HOOK_NAME, `capture spawn failed: ${(0, shared_1.safeErrorMessage)(err)}`);
61
+ }
62
+ }
63
+ /**
64
+ * cc-capture-worker — the background half. Enables the Claude-CLI classifier
65
+ * path (CLAUDE_RECALL_CC_CLASSIFIER) and delegates to the standard
66
+ * correction-detector, which classifies via `claude -p` (subscription) →
67
+ * ANTHROPIC_API_KEY → regex, and stores. Runs detached, so output goes
68
+ * nowhere and capture is silent.
69
+ */
70
+ async function handleCcCaptureWorker(input) {
71
+ process.env.CLAUDE_RECALL_CC_CLASSIFIER = '1';
72
+ try {
73
+ await (0, correction_detector_1.handleCorrectionDetector)(input);
74
+ }
75
+ catch (err) {
76
+ (0, shared_1.hookLog)(HOOK_NAME, `capture worker error: ${(0, shared_1.safeErrorMessage)(err)}`);
77
+ }
78
+ }
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ /**
3
+ * Claude-Code-LLM memory classifier.
4
+ *
5
+ * Mirrors kiro-classifier for the Claude Code runtime: `claude -p "<prompt>"`
6
+ * is a headless one-shot completion that runs on the user's Claude Code
7
+ * SUBSCRIPTION auth (the same login powering their interactive session) — no
8
+ * ANTHROPIC_API_KEY, no separate pay-as-you-go credits. This makes the "each
9
+ * runtime brings its own LLM" promise true under Claude Code, not just Kiro.
10
+ *
11
+ * Two hard-won details:
12
+ *
13
+ * 1. `claude -p` prefers ANTHROPIC_API_KEY over subscription auth when the
14
+ * key is present in the environment. A stray key exported for other tools
15
+ * (possibly dead or out of credits) would silently hijack the call — so
16
+ * the key is STRIPPED from the child env to force subscription auth.
17
+ * 2. The child inherits CLAUDE_RECALL_CC_CLASSIFIER=1 (set by the capture
18
+ * worker), so if the nested headless session fires claude-recall hooks of
19
+ * its own, handleCcCapture sees the flag and refuses to spawn another
20
+ * worker — no recursion. The call also runs from the OS temp dir so the
21
+ * current project's hooks/settings don't load at all.
22
+ *
23
+ * Cold latency is ~4s, so this runs only from a DETACHED worker
24
+ * (cc-capture-worker), never inline on the user's turn.
25
+ */
26
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ var desc = Object.getOwnPropertyDescriptor(m, k);
29
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
30
+ desc = { enumerable: true, get: function() { return m[k]; } };
31
+ }
32
+ Object.defineProperty(o, k2, desc);
33
+ }) : (function(o, m, k, k2) {
34
+ if (k2 === undefined) k2 = k;
35
+ o[k2] = m[k];
36
+ }));
37
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
38
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
39
+ }) : function(o, v) {
40
+ o["default"] = v;
41
+ });
42
+ var __importStar = (this && this.__importStar) || (function () {
43
+ var ownKeys = function(o) {
44
+ ownKeys = Object.getOwnPropertyNames || function (o) {
45
+ var ar = [];
46
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
47
+ return ar;
48
+ };
49
+ return ownKeys(o);
50
+ };
51
+ return function (mod) {
52
+ if (mod && mod.__esModule) return mod;
53
+ var result = {};
54
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
55
+ __setModuleDefault(result, mod);
56
+ return result;
57
+ };
58
+ })();
59
+ Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.completeWithClaudeCli = completeWithClaudeCli;
61
+ exports.classifyWithClaudeCli = classifyWithClaudeCli;
62
+ const child_process_1 = require("child_process");
63
+ const os = __importStar(require("os"));
64
+ const shared_1 = require("./shared");
65
+ const kiro_classifier_1 = require("./kiro-classifier");
66
+ const DEFAULT_MODEL = 'haiku';
67
+ const DEFAULT_TIMEOUT_MS = 30000;
68
+ /**
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.
75
+ */
76
+ function completeWithClaudeCli(prompt, opts = {}) {
77
+ const model = process.env.CLAUDE_RECALL_CC_MODEL || DEFAULT_MODEL;
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);
81
+ // Force subscription auth: with ANTHROPIC_API_KEY set, `claude -p` bills the
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
+ };
91
+ delete env.ANTHROPIC_API_KEY;
92
+ return new Promise((resolve) => {
93
+ let settled = false;
94
+ const done = (result) => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ resolve(result);
99
+ };
100
+ let child;
101
+ try {
102
+ // args array (no shell) — the user's text is a single argv entry, so no
103
+ // shell escaping or injection is possible. cwd is the temp dir so the
104
+ // nested session loads no project settings or hooks.
105
+ child = (0, child_process_1.spawn)('claude', ['-p', '--model', model, prompt], { cwd: os.tmpdir(), env, stdio: ['ignore', 'pipe', 'ignore'] });
106
+ }
107
+ catch (err) {
108
+ (0, shared_1.hookLog)('cc-classifier', `spawn threw: ${(0, shared_1.safeErrorMessage)(err)}`);
109
+ return done(null);
110
+ }
111
+ const timer = setTimeout(() => {
112
+ (0, shared_1.hookLog)('cc-classifier', `timeout after ${timeout}ms — killing claude -p`);
113
+ try {
114
+ child.kill('SIGKILL');
115
+ }
116
+ catch { /* already gone */ }
117
+ done(null);
118
+ }, timeout);
119
+ let stdout = '';
120
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
121
+ child.on('error', (err) => {
122
+ clearTimeout(timer);
123
+ // ENOENT = claude not on PATH; anything else = spawn failure
124
+ (0, shared_1.hookLog)('cc-classifier', `claude error: ${err?.code ?? ''} ${err?.message ?? err}`);
125
+ done(null);
126
+ });
127
+ child.on('close', (code) => {
128
+ clearTimeout(timer);
129
+ if (settled)
130
+ return;
131
+ if (code !== 0) {
132
+ (0, shared_1.hookLog)('cc-classifier', `claude -p exited ${code}`);
133
+ return done(null);
134
+ }
135
+ done(stdout);
136
+ });
137
+ });
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,6 +16,7 @@
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.CLASSIFIER_AGENT = void 0;
19
+ exports.buildClassifyPrompt = buildClassifyPrompt;
19
20
  exports.extractClassification = extractClassification;
20
21
  exports.classifyWithKiro = classifyWithKiro;
21
22
  const child_process_1 = require("child_process");
@@ -36,21 +37,32 @@ const VALID_TYPES = new Set([
36
37
  * single INPUT argument (no separate system prompt), so instruction + payload
37
38
  * are combined. Mirrors the contract of llm-classifier's SYSTEM_PROMPT so
38
39
  * downstream thresholds/dedup behave identically regardless of which LLM ran.
40
+ *
41
+ * Exported for cc-classifier: `claude -p` is the same single-argument headless
42
+ * shape, so both CLI backends share one prompt (and one parser, below).
39
43
  */
40
- function buildPrompt(text) {
44
+ function buildClassifyPrompt(text) {
41
45
  return ('You are a memory classifier for a developer tool. Classify the USER MESSAGE ' +
42
46
  'into exactly one type and respond with ONLY minified JSON — no markdown, no ' +
43
47
  'prose, no code fence:\n' +
44
48
  '{"type":"correction|preference|failure|devops|project-knowledge|none","confidence":0.0-1.0,"extract":"<concise imperative rule to remember, or empty>"}\n\n' +
45
49
  'Types:\n' +
46
- '- correction: user correcting a mistake ("no, use X not Y")\n' +
50
+ '- correction: user durably correcting how something should ALWAYS be done ("no, use X not Y"). Correcting a one-off action in the current task is NOT durable.\n' +
47
51
  '- preference: a reusable directive about how the user wants things done ("I prefer X", "we use tabs", "my favourite color is green"). Must apply beyond this one message.\n' +
48
52
  '- failure: something broke ("build failed")\n' +
49
53
  '- devops: durable CI/CD, git, deployment, or Docker rules\n' +
50
54
  '- project-knowledge: architecture, stack, database, or API facts\n' +
51
- '- none: questions, chitchat, task instructions, observations, or anything not worth remembering across sessions\n\n' +
55
+ '- none: questions, chitchat, task instructions, meta-conversation, sentence fragments, observations, or anything not worth remembering across sessions\n\n' +
56
+ 'THE STANDALONE TEST — apply before choosing correction/preference/devops: a durable rule must make complete sense on its own, read cold in a future session with no knowledge of this chat. If the message needs the surrounding conversation to be understood, use "none".\n' +
57
+ '- Reject text referencing the current moment with no standalone subject ("this", "that", "the sentence", "here", "it", "first", "now").\n' +
58
+ '- Reject one-off imperatives aimed at the task in progress ("fix the sentence", "change that", "do X first") even when forceful.\n' +
59
+ '- Reject sentence fragments and mid-thought clauses ("is used in practice", "then run the setup command").\n' +
60
+ '- Reject meta-conversation about this tool or what to write/do next.\n\n' +
52
61
  'Be conservative: when in doubt use "none" with confidence 0. Use confidence >= 0.75 for correction/preference/devops. ' +
53
62
  'extract must be a clean standalone rule (e.g. "Favourite colour is green"), or empty when type is none.\n\n' +
63
+ 'Examples: "we use pnpm, not npm" → {"type":"preference","confidence":0.9,"extract":"Use pnpm, not npm"}; ' +
64
+ '"first fix the sentence" → {"type":"none","confidence":0,"extract":""}; ' +
65
+ '"then run claude-recall kiro setup" → {"type":"none","confidence":0,"extract":""}.\n\n' +
54
66
  'USER MESSAGE: ' + text);
55
67
  }
56
68
  /** Strip ANSI/VT control sequences that kiro-cli writes around its output. */
@@ -119,7 +131,7 @@ function classifyWithKiro(text) {
119
131
  '--no-interactive',
120
132
  '--agent', exports.CLASSIFIER_AGENT,
121
133
  '--model', model,
122
- buildPrompt(text),
134
+ buildClassifyPrompt(text),
123
135
  ], { stdio: ['ignore', 'pipe', 'ignore'] });
124
136
  }
125
137
  catch (err) {
@@ -152,7 +164,12 @@ function classifyWithKiro(text) {
152
164
  }
153
165
  const result = extractClassification(stdout);
154
166
  if (!result) {
155
- (0, shared_1.hookLog)('kiro-classifier', 'no parseable classification in kiro-cli output');
167
+ // Distinguish a deliberate "none" verdict from unparseable output
168
+ // "the model said not a rule" and "the call broke" are different
169
+ // diagnoses when reading the log.
170
+ (0, shared_1.hookLog)('kiro-classifier', /"type"\s*:\s*"none"/.test(stdout)
171
+ ? 'classified as none (not a durable rule)'
172
+ : 'no parseable classification in kiro-cli output');
156
173
  }
157
174
  else {
158
175
  // Log success too, not just failures — otherwise a successful Kiro
@@ -1,9 +1,14 @@
1
1
  "use strict";
2
2
  /**
3
- * LLM-powered memory classification using Claude Haiku.
3
+ * LLM-powered memory classification using Claude Haiku via the Anthropic API.
4
4
  *
5
- * Picks up ANTHROPIC_API_KEY from the environment (Claude Code sets it
6
- * automatically for child processes). Falls back gracefully when unavailable.
5
+ * Requires ANTHROPIC_API_KEY in the environment a personal pay-as-you-go
6
+ * API key the user exported themselves. Claude Code does NOT mint one from
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.
7
12
  */
8
13
  Object.defineProperty(exports, "__esModule", { value: true });
9
14
  exports.classifyWithLLM = classifyWithLLM;
@@ -11,50 +16,78 @@ exports.extractHindsightHint = extractHindsightHint;
11
16
  exports.extractSessionLearningsWithLLM = extractSessionLearningsWithLLM;
12
17
  exports.extractCheckpointWithLLM = extractCheckpointWithLLM;
13
18
  exports.classifyBatchWithLLM = classifyBatchWithLLM;
19
+ const cc_classifier_1 = require("./cc-classifier");
14
20
  // Lazy singleton — avoid import cost when API key is absent
15
21
  let clientInstance; // undefined = not yet checked
16
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;
17
30
  const SYSTEM_PROMPT = `You are a memory classifier for a developer tool. Classify user text into one of these types:
18
31
 
19
- - correction: User correcting a mistake ("no, use X not Y", "wrong, it should be...")
20
- - preference: User stating a clear, reusable directive about how they want things done going forward ("we use tabs", "always use TypeScript", "I prefer X"). Must be a rule that applies beyond this conversation. NOT observations, complaints, questions, debugging statements, or one-off instructions like "fix this" or "tell me about X"
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.
33
+ - preference: User stating a clear, reusable directive about how they want things done going forward ("we use tabs", "always use TypeScript", "I prefer X"). Must be a rule that applies beyond this conversation.
21
34
  - failure: Something broke or failed ("build failed", "error in deployment")
22
35
  - devops: CI/CD, deployment, Docker, git workflow patterns. ONLY durable rules — not transient operational events like "sandbox rebuilt" or "background task exited"
23
36
  - project-knowledge: Architecture, stack, database, API patterns
24
- - none: Casual conversation, questions, code snippets, or anything not worth remembering
37
+ - none: Casual conversation, questions, code snippets, one-off task instructions, meta-conversation, sentence fragments, or anything not worth remembering across sessions
25
38
 
26
39
  Respond with ONLY valid JSON (no markdown fences). Format:
27
40
  {"type":"<type>","confidence":<0.0-1.0>,"extract":"<the key fact to remember, concise>"}
28
41
 
42
+ THE STANDALONE TEST — apply this BEFORE classifying anything as correction/preference/devops:
43
+ A durable rule must make complete sense on its own, read cold in a future session with NO knowledge of this conversation. If the text needs the surrounding chat to be understood, it is "none".
44
+ - Reject text that references the current task or moment with no concrete standalone subject: "this", "that", "the sentence", "here", "it", "again", "first", "now".
45
+ - Reject one-off imperatives aimed at the task in progress ("fix the sentence", "change that", "add a line", "do X first") — even when phrased forcefully. A preference describes what the user ALWAYS wants, not what to do right now.
46
+ - Reject sentence fragments and mid-thought clauses (e.g. "is used in practice", "then run the setup command"). A rule is a complete directive, not a snippet.
47
+ - Reject meta-conversation about this tool, this chat, or what to write/say/do next.
48
+
29
49
  Rules:
30
50
  - Be very conservative — when in doubt, classify as "none". Only store things worth remembering across sessions
31
51
  - Questions, observations, complaints, task instructions, and sentence fragments are "none" — not preferences
32
- - If the text doesn't make sense as a standalone rule or directive, classify as "none"
33
52
  - confidence >= 0.7 for corrections and preferences
34
53
  - confidence >= 0.6 for other types
35
54
  - "none" type should have confidence 0.0
36
- - extract should be a clean, imperative statement of the rule/fact (e.g. "Use tabs for indentation")
37
- - If the text is a question, greeting, or code block, classify as "none"`;
55
+ - extract should be a clean, imperative statement of the rule/fact, understandable standalone (e.g. "Use tabs for indentation")
56
+ - If the text is a question, greeting, or code block, classify as "none"
57
+
58
+ Examples:
59
+ - "we use pnpm here, not npm" → {"type":"preference","confidence":0.9,"extract":"Use pnpm, not npm"}
60
+ - "always run tests before pushing" → {"type":"preference","confidence":0.85,"extract":"Run tests before pushing"}
61
+ - "first fix the sentence" → {"type":"none","confidence":0.0,"extract":""}
62
+ - "is used in practice" → {"type":"none","confidence":0.0,"extract":""}
63
+ - "then run claude-recall kiro setup" → {"type":"none","confidence":0.0,"extract":""}
64
+ - "what memories do you have?" → {"type":"none","confidence":0.0,"extract":""}`;
38
65
  const BATCH_SYSTEM_PROMPT = `You are a memory classifier for a developer tool. You will receive a JSON array of texts. Classify each into one of these types:
39
66
 
40
- - correction: User correcting a mistake ("no, use X not Y", "wrong, it should be...")
41
- - preference: User stating a clear, reusable directive about how they want things done going forward ("we use tabs", "always use TypeScript", "I prefer X"). Must be a rule that applies beyond this conversation. NOT observations, complaints, questions, debugging statements, or one-off instructions like "fix this" or "tell me about X"
67
+ - 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.
68
+ - preference: User stating a clear, reusable directive about how they want things done going forward ("we use tabs", "always use TypeScript", "I prefer X"). Must be a rule that applies beyond this conversation.
42
69
  - failure: Something broke or failed ("build failed", "error in deployment")
43
70
  - devops: CI/CD, deployment, Docker, git workflow patterns. ONLY durable rules — not transient operational events like "sandbox rebuilt" or "background task exited"
44
71
  - project-knowledge: Architecture, stack, database, API patterns
45
- - none: Casual conversation, questions, code snippets, or anything not worth remembering
72
+ - none: Casual conversation, questions, code snippets, one-off task instructions, meta-conversation, sentence fragments, or anything not worth remembering across sessions
46
73
 
47
74
  Respond with ONLY a valid JSON array (no markdown fences). One object per input text, in order:
48
75
  [{"type":"<type>","confidence":<0.0-1.0>,"extract":"<the key fact to remember, concise>"}, ...]
49
76
 
77
+ THE STANDALONE TEST — apply this BEFORE classifying anything as correction/preference/devops:
78
+ A durable rule must make complete sense on its own, read cold in a future session with NO knowledge of this conversation. If a text needs the surrounding chat to be understood, it is "none".
79
+ - Reject text that references the current task or moment with no concrete standalone subject: "this", "that", "the sentence", "here", "it", "again", "first", "now".
80
+ - Reject one-off imperatives aimed at the task in progress ("fix the sentence", "change that", "add a line", "do X first") — even when phrased forcefully.
81
+ - Reject sentence fragments and mid-thought clauses (e.g. "is used in practice", "then run the setup command").
82
+ - Reject meta-conversation about this tool, this chat, or what to write/say/do next.
83
+
50
84
  Rules:
51
85
  - Be very conservative — when in doubt, classify as "none". Only store things worth remembering across sessions
52
86
  - Questions, observations, complaints, task instructions, and sentence fragments are "none" — not preferences
53
- - If the text doesn't make sense as a standalone rule or directive, classify as "none"
54
87
  - confidence >= 0.7 for corrections and preferences
55
88
  - confidence >= 0.6 for other types
56
89
  - "none" type should have confidence 0.0
57
- - extract should be a clean, imperative statement of the rule/fact
90
+ - extract should be a clean, imperative statement of the rule/fact, understandable standalone
58
91
  - If a text is a question, greeting, or code block, classify as "none"`;
59
92
  /**
60
93
  * Lazy-init the Anthropic client. Returns null if SDK or API key unavailable.
@@ -88,11 +121,74 @@ function getClient() {
88
121
  }
89
122
  }
90
123
  /**
91
- * 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.
92
127
  */
93
128
  function parseJSON(text) {
94
129
  const cleaned = text.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '');
95
- 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;
96
192
  }
97
193
  /**
98
194
  * Classify a single text using Claude Haiku.
@@ -134,20 +230,11 @@ async function classifyWithLLM(text) {
134
230
  * callers fall back to grounding the detector's generic remedy text.
135
231
  */
136
232
  async function extractHindsightHint(failureDescription, context) {
137
- const client = getClient();
138
- 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)
139
235
  return null;
140
236
  try {
141
- const response = await client.messages.create({
142
- model: MODEL,
143
- max_tokens: 300,
144
- 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.',
145
- messages: [{ role: 'user', content: `Failure: ${failureDescription}\nContext: ${context}` }],
146
- });
147
- const content = response.content?.[0];
148
- if (content?.type !== 'text')
149
- return null;
150
- const result = parseJSON(content.text);
237
+ const result = parseJSON(text);
151
238
  if (!result.hint_text || !result.hint_kind)
152
239
  return null;
153
240
  return {
@@ -195,24 +282,15 @@ Return [] if nothing durable was learned. Max 10 items. Each content should be a
195
282
  * Returns null if no API key or on any failure.
196
283
  */
197
284
  async function extractSessionLearningsWithLLM(summary, existingMemories) {
198
- const client = getClient();
199
- 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)
200
291
  return null;
201
292
  try {
202
- const memList = existingMemories.length > 0
203
- ? existingMemories.map(m => `- ${m}`).join('\n')
204
- : '(none)';
205
- const systemPrompt = SESSION_EXTRACTION_PROMPT + `\n\nEXISTING MEMORIES (do not duplicate):\n${memList}`;
206
- const response = await client.messages.create({
207
- model: MODEL,
208
- max_tokens: 1000,
209
- system: systemPrompt,
210
- messages: [{ role: 'user', content: summary }],
211
- });
212
- const content = response.content?.[0];
213
- if (content?.type !== 'text')
214
- return null;
215
- const results = parseJSON(content.text);
293
+ const results = parseJSON(text);
216
294
  if (!Array.isArray(results))
217
295
  return null;
218
296
  const validTypes = ['project-knowledge', 'preference', 'devops', 'failure'];
@@ -279,23 +357,14 @@ Examples of BAD output (DO NOT DO THIS):
279
357
  {"completed":"explored architecture","remaining":"document findings","blockers":"none"} # FABRICATED — there was no documentation task
280
358
  {"completed":"finished everything","remaining":"finish everything","blockers":"none"} # nonsense filler`;
281
359
  async function extractCheckpointWithLLM(conversationSummary) {
282
- const client = getClient();
283
- if (!client)
284
- return null;
285
360
  if (!conversationSummary || conversationSummary.trim().length < 30) {
286
361
  return null;
287
362
  }
363
+ const text = await completeText(CHECKPOINT_EXTRACTION_PROMPT, conversationSummary, 600);
364
+ if (!text)
365
+ return null;
288
366
  try {
289
- const response = await client.messages.create({
290
- model: MODEL,
291
- max_tokens: 600,
292
- system: CHECKPOINT_EXTRACTION_PROMPT,
293
- messages: [{ role: 'user', content: conversationSummary }],
294
- });
295
- const content = response.content?.[0];
296
- if (content?.type !== 'text')
297
- return null;
298
- const result = parseJSON(content.text);
367
+ const result = parseJSON(text);
299
368
  if (typeof result !== 'object' || result === null)
300
369
  return null;
301
370
  return {
@@ -311,25 +380,16 @@ async function extractCheckpointWithLLM(conversationSummary) {
311
380
  async function classifyBatchWithLLM(texts) {
312
381
  if (texts.length === 0)
313
382
  return [];
314
- const client = getClient();
315
- 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)
316
390
  return null;
317
391
  try {
318
- // JSON array, not delimiter-joined text: a user message that happened to
319
- // contain the old "---ITEM---" marker desynced item counts and silently
320
- // dropped the entire batch to the regex fallback. JSON boundaries can't
321
- // be forged by content.
322
- const joined = JSON.stringify(texts);
323
- const response = await client.messages.create({
324
- model: MODEL,
325
- max_tokens: texts.length * 200,
326
- system: BATCH_SYSTEM_PROMPT,
327
- messages: [{ role: 'user', content: joined }],
328
- });
329
- const content = response.content?.[0];
330
- if (content?.type !== 'text')
331
- return null;
332
- const results = parseJSON(content.text);
392
+ const results = parseJSON(text);
333
393
  if (!Array.isArray(results) || results.length !== texts.length)
334
394
  return null;
335
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)) {
@@ -67,9 +67,16 @@ const llm_classifier_1 = require("./llm-classifier");
67
67
  // "I want", "I use" — were deleted rather than promoted because they match
68
68
  // ordinary conversation far too often.)
69
69
  const CORRECTION_PATTERNS = [
70
- { regex: /^no[,.]?\s+(.+)/i, confidence: 0.8 },
70
+ // "no ..." only counts as a correction when the remainder carries a durable-
71
+ // rule signal (use/not/instead/always/...). Without the lookahead, one-off
72
+ // task imperatives were stored: "no first fix the sentence" became the
73
+ // correction "first fix the sentence".
74
+ { regex: /^no[,.]?\s+(?=.*\b(?:use|not|instead|never|always|don'?t|stop|should|must)\b)(.+)/i, confidence: 0.8 },
71
75
  { regex: /^wrong[,.]?\s+(.+)/i, confidence: 0.8 },
72
- { regex: /\bnever\s+(.+)/i, confidence: 0.75 },
76
+ // "never" must start the message or a sentence. Mid-clause "never" fired on
77
+ // QUOTED text the user was discussing: pasting "it never touches your token
78
+ // allowance" stored "touches your token allowance..." as a correction.
79
+ { regex: /(?:^|[.!?;:]\s+)never\s+(.+)/i, confidence: 0.75 },
73
80
  { regex: /\bdon'?t\s+ever\s+(.+)/i, confidence: 0.8 },
74
81
  { regex: /\bstop\s+(doing|using|adding)\s+(.+)/i, confidence: 0.75 },
75
82
  ];
@@ -80,7 +87,16 @@ const PREFERENCE_PATTERNS = [
80
87
  // no-MCP) capture path, which matters under enterprise Kiro governance that
81
88
  // blocks the MCP tools. The interrogative/question-mark guards below keep
82
89
  // "do you remember that config file?" / "do you recall X?" out.
83
- { regex: /\b(?:remember|recall)\s+(?:that\s+|this\s+|to\s+)?(.+)/i, confidence: 0.8 },
90
+ //
91
+ // Two hazards from talking ABOUT this tool (both observed in the wild):
92
+ // 1. The product name: "claude-recall" / "claude recall" contains the
93
+ // trigger word — "how claude recall is used" stored "is used...". The
94
+ // lookbehind skips "recall" when preceded by "claude-"/"claude ".
95
+ // 2. Non-imperative uses where "recall" is a noun/subject followed by a
96
+ // copula or auxiliary ("the recall is broken") — an imperative
97
+ // "remember/recall X" is never followed by a bare auxiliary, so the
98
+ // negative lookahead rejects those.
99
+ { regex: /(?<!claude[- ])\b(?:remember|recall)\s+(?:that\s+|this\s+|to\s+)?(?!(?:is|was|are|were|has|have|had|does|did|will|would|can|could|should|may|might)\b)(.+)/i, confidence: 0.8 },
84
100
  { regex: /\bfrom\s+now\s+on[,.]?\s+(.+)/i, confidence: 0.8 },
85
101
  { regex: /\bgoing\s+forward[,.]?\s+(.+)/i, confidence: 0.8 },
86
102
  { regex: /\balways\s+(.+)/i, confidence: 0.75 },
@@ -146,15 +162,22 @@ function classifyContentRegex(text) {
146
162
  * Classify text content — LLM-first, regex fallback. No API key is ever
147
163
  * required: each runtime brings its own LLM.
148
164
  *
149
- * Precedence:
150
- * - Under Claude Code: Claude Haiku via ANTHROPIC_API_KEY (Claude Code
151
- * provides this to its hooks) → regex.
152
- * - Under Kiro (CLAUDE_RECALL_KIRO_CLASSIFIER set by the kiro-capture-worker):
153
- * Kiro's own headless LLM (`kiro-cli chat --no-interactive`)ANTHROPIC_
154
- * API_KEY if present → regex. Kiro's included LLM is preferred so a stray
155
- * key doesn't spend the user's Anthropic credits; CLAUDE_RECALL_PREFER_API_
156
- * KEY flips the order. The Kiro call is ~3s, so it runs only from the
157
- * detached worker, never inline. See docs/kiro-llm-capture.md.
165
+ * Precedence (the included-LLM backends run only inside the detached capture
166
+ * workers, which set the *_CLASSIFIER env vars a ~4s CLI call can't run
167
+ * inline on a turn):
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.
171
+ * - Under Kiro (CLAUDE_RECALL_KIRO_CLASSIFIER set by kiro-capture-worker):
172
+ * 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.
175
+ *
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.
158
181
  */
159
182
  async function classifyContent(text) {
160
183
  // Guard the LLM paths the same way the regex path is guarded: a question or
@@ -172,17 +195,22 @@ async function classifyContent(text) {
172
195
  }
173
196
  async function classifyContentInner(text) {
174
197
  const underKiro = !!process.env.CLAUDE_RECALL_KIRO_CLASSIFIER;
198
+ const underCc = !!process.env.CLAUDE_RECALL_CC_CLASSIFIER;
175
199
  const preferApiKey = !!process.env.CLAUDE_RECALL_PREFER_API_KEY;
176
200
  const tryApiKey = () => (0, llm_classifier_1.classifyWithLLM)(text);
177
- // Dynamic import keeps kiro-classifier (and child_process) out of the module
178
- // graph for every non-Kiro hook invocation.
201
+ // Dynamic imports keep the CLI classifiers (and child_process) out of the
202
+ // module graph for hook invocations that don't run in a capture worker.
179
203
  const tryKiro = async () => (await Promise.resolve().then(() => __importStar(require('./kiro-classifier')))).classifyWithKiro(text);
180
- // Order the two LLM backends. Under Kiro, prefer Kiro's INCLUDED LLM over a
181
- // stray ANTHROPIC_API_KEY: a key exported for other tools should not silently
182
- // spend the user's Anthropic credits when Kiro already ships an LLM. Set
183
- // CLAUDE_RECALL_PREFER_API_KEY to force the key first (e.g. to use a stronger
184
- // model you pay for). Under Claude Code the Kiro backend isn't available, so
185
- // the key path is the only LLM classifier.
204
+ 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.
186
214
  const backends = [];
187
215
  if (underKiro && !preferApiKey) {
188
216
  backends.push(tryKiro, tryApiKey);
@@ -190,6 +218,12 @@ async function classifyContentInner(text) {
190
218
  else if (underKiro) {
191
219
  backends.push(tryApiKey, tryKiro);
192
220
  }
221
+ else if (underCc && !preferApiKey) {
222
+ backends.push(tryCc, tryApiKey);
223
+ }
224
+ else if (underCc) {
225
+ backends.push(tryApiKey, tryCc);
226
+ }
193
227
  else {
194
228
  backends.push(tryApiKey);
195
229
  }
@@ -4,9 +4,12 @@
4
4
 
5
5
  Claude Recall captures memories with an LLM classifier: it reads each user
6
6
  prompt and decides whether it contains a durable preference, correction, or
7
- project fact worth storing. That classifier normally calls Claude Haiku through
8
- `ANTHROPIC_API_KEY`, which Claude Code sets automatically for its hook
9
- subprocesses.
7
+ project fact worth storing. At the time of this finding, that classifier called
8
+ Claude Haiku through `ANTHROPIC_API_KEY` a personal API key the user had
9
+ exported themselves. (Claude Code does **not** mint a key from the user's
10
+ subscription; hooks merely inherit the environment. The headless-CLI approach
11
+ documented here was later applied back to Claude Code via `claude -p` on
12
+ subscription auth — see `src/hooks/cc-classifier.ts`.)
10
13
 
11
14
  Kiro CLI does **not** set `ANTHROPIC_API_KEY`. So under Kiro the classifier fell
12
15
  back to a conservative regex that only fires on explicit phrasings
@@ -98,8 +101,8 @@ present → regex as a last resort.** The Kiro LLM comes first *even when a key
98
101
  set*, so a key exported for other tools never silently spends the user's
99
102
  Anthropic credits — Kiro already ships an LLM. `CLAUDE_RECALL_PREFER_API_KEY=1`
100
103
  flips the order back to key-first for anyone who deliberately wants to pay for a
101
- stronger model. (Under Claude Code there is no Kiro backend, so the key path is
102
- the only LLM classifier Claude Code provides the key to its hooks.)
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`.)
103
106
 
104
107
  ### Output parsing
105
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.30.3",
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": {