claude-recall 0.28.8 → 0.29.1
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 +4 -2
- package/dist/cli/commands/hook-commands.js +12 -0
- package/dist/cli/commands/kiro-commands.js +86 -4
- package/dist/hooks/kiro-classifier.js +160 -0
- package/dist/hooks/kiro-hooks.js +59 -0
- package/dist/hooks/shared.js +15 -2
- package/docs/kiro-llm-capture.md +128 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ tail -5 ~/.claude-recall/hook-logs/hook-dispatcher.log
|
|
|
123
123
|
|
|
124
124
|
The log should show a `scope [...] → project=your-project` line; `claude-recall kiro doctor` gives a fuller health report.
|
|
125
125
|
|
|
126
|
-
> **
|
|
126
|
+
> **Capture uses Kiro's own LLM — no API key needed.** To decide what's worth remembering, the capture hook classifies each prompt with Kiro's model via a headless `kiro-cli chat --no-interactive` call (through a bundled bare `claude-recall-classifier` agent). So natural statements like "my favourite color is green" are captured without any `ANTHROPIC_API_KEY` and without the `store_memory` MCP tool — which matters under enterprise governance that blocks the MCP server. It runs in a detached background worker, so your turn is never blocked; it spends ~0.06 Kiro credits per prompt. Precedence: `ANTHROPIC_API_KEY` (if you set one) → Kiro's LLM → a regex fallback. Tune with `CLAUDE_RECALL_KIRO_MODEL` (default `claude-haiku-4.5`). Details in [docs/kiro-llm-capture.md](docs/kiro-llm-capture.md).
|
|
127
127
|
|
|
128
128
|
**Option B — MCP tools only (no hooks, works in Kiro's default agent).**
|
|
129
129
|
|
|
@@ -562,7 +562,9 @@ Runtime behavior can be tuned via environment variables. Defaults are chosen so
|
|
|
562
562
|
| Variable | Default | Effect |
|
|
563
563
|
| ---------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
|
|
564
564
|
| `CLAUDE_RECALL_DB_PATH` | `~/.claude-recall/` | Database directory. |
|
|
565
|
-
| `ANTHROPIC_API_KEY` | _(unset)_ | Enables LLM-based classification (Haiku).
|
|
565
|
+
| `ANTHROPIC_API_KEY` | _(unset)_ | Enables LLM-based classification (Haiku). Under Kiro, falls back to Kiro's own headless LLM when missing; otherwise falls back to regex. |
|
|
566
|
+
| `CLAUDE_RECALL_KIRO_MODEL` | `claude-haiku-4.5` | Model for Kiro-LLM capture classification. Raise to `claude-sonnet-4.6` for steadier judgement at more credits. See [docs/kiro-llm-capture.md](docs/kiro-llm-capture.md). |
|
|
567
|
+
| `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. |
|
|
566
568
|
| `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`. |
|
|
567
569
|
| `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>`. |
|
|
568
570
|
| `CLAUDE_RECALL_DEMOTE_MIN_LOADS` | `20` | Minimum load count before a rule qualifies for auto-demotion. |
|
|
@@ -53,6 +53,8 @@ const AVAILABLE_HOOKS = [
|
|
|
53
53
|
'kiro-agent-spawn',
|
|
54
54
|
'kiro-rule-injector',
|
|
55
55
|
'kiro-tool-outcome',
|
|
56
|
+
'kiro-capture',
|
|
57
|
+
'kiro-capture-worker',
|
|
56
58
|
];
|
|
57
59
|
/**
|
|
58
60
|
* Hook CLI Commands
|
|
@@ -155,6 +157,16 @@ class HookCommands {
|
|
|
155
157
|
await handleKiroToolOutcome(input);
|
|
156
158
|
break;
|
|
157
159
|
}
|
|
160
|
+
case 'kiro-capture': {
|
|
161
|
+
const { handleKiroCapture } = await Promise.resolve().then(() => __importStar(require('../../hooks/kiro-hooks')));
|
|
162
|
+
await handleKiroCapture(input);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'kiro-capture-worker': {
|
|
166
|
+
const { handleKiroCaptureWorker } = await Promise.resolve().then(() => __importStar(require('../../hooks/kiro-hooks')));
|
|
167
|
+
await handleKiroCaptureWorker(input);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
158
170
|
default:
|
|
159
171
|
console.error(`Unknown hook: ${name}`);
|
|
160
172
|
console.error(`Available: ${AVAILABLE_HOOKS.join(', ')}`);
|
|
@@ -57,6 +57,30 @@ class KiroCommands {
|
|
|
57
57
|
timeout: 120000,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
/** The bare classifier agent config — no MCP, no hooks, no tools. */
|
|
61
|
+
static buildClassifierAgentConfig() {
|
|
62
|
+
return {
|
|
63
|
+
name: KiroCommands.CLASSIFIER_AGENT_NAME,
|
|
64
|
+
description: 'Headless memory classifier for Claude Recall. No MCP servers, hooks, or tools — invoked by the capture worker to decide what to remember, using Kiro\'s own LLM.',
|
|
65
|
+
mcpServers: {},
|
|
66
|
+
includeMcpJson: false,
|
|
67
|
+
tools: [],
|
|
68
|
+
allowedTools: [],
|
|
69
|
+
hooks: {},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Write the bare classifier agent to ~/.kiro/agents/ (always global, so any
|
|
74
|
+
* project's capture worker can invoke `--agent claude-recall-classifier`).
|
|
75
|
+
* Idempotent overwrite — the file is entirely ours. Returns its path.
|
|
76
|
+
*/
|
|
77
|
+
static writeClassifierAgent() {
|
|
78
|
+
const dir = path.join(os.homedir(), '.kiro', 'agents');
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
const p = path.join(dir, `${KiroCommands.CLASSIFIER_AGENT_NAME}.json`);
|
|
81
|
+
fs.writeFileSync(p, JSON.stringify(KiroCommands.buildClassifierAgentConfig(), null, 2) + '\n');
|
|
82
|
+
return p;
|
|
83
|
+
}
|
|
60
84
|
/** The four lifecycle hook entries (shared by fresh config and merge). */
|
|
61
85
|
static buildHookEntries(hookCmd) {
|
|
62
86
|
return {
|
|
@@ -64,9 +88,12 @@ class KiroCommands {
|
|
|
64
88
|
agentSpawn: [
|
|
65
89
|
{ command: `${hookCmd} kiro-agent-spawn`, timeout_ms: 10000 },
|
|
66
90
|
],
|
|
67
|
-
//
|
|
91
|
+
// Capture. kiro-capture spawns a detached worker that classifies the
|
|
92
|
+
// prompt via Kiro's own headless LLM (no ANTHROPIC_API_KEY needed) and
|
|
93
|
+
// stores in the background — see src/hooks/kiro-classifier.ts. The hook
|
|
94
|
+
// itself returns in milliseconds, so the short timeout is ample.
|
|
68
95
|
userPromptSubmit: [
|
|
69
|
-
{ command: `${hookCmd}
|
|
96
|
+
{ command: `${hookCmd} kiro-capture`, timeout_ms: 8000 },
|
|
70
97
|
],
|
|
71
98
|
preToolUse: [
|
|
72
99
|
{ matcher: '*', command: `${hookCmd} kiro-rule-injector`, timeout_ms: 5000 },
|
|
@@ -134,15 +161,37 @@ class KiroCommands {
|
|
|
134
161
|
}
|
|
135
162
|
const handlerMarkers = {
|
|
136
163
|
agentSpawn: 'kiro-agent-spawn',
|
|
137
|
-
userPromptSubmit: '
|
|
164
|
+
userPromptSubmit: 'kiro-capture',
|
|
138
165
|
preToolUse: 'kiro-rule-injector',
|
|
139
166
|
postToolUse: 'kiro-tool-outcome',
|
|
140
167
|
};
|
|
168
|
+
// Superseded claude-recall hooks to strip from an event before wiring the
|
|
169
|
+
// current one — prevents stacking when the handler for an event changes
|
|
170
|
+
// across versions. Pre-0.29 wired userPromptSubmit to `correction-detector`
|
|
171
|
+
// directly; 0.29 replaced it with `kiro-capture`. Leaving the old entry
|
|
172
|
+
// makes BOTH fire, double-capturing (often as near-duplicate memories).
|
|
173
|
+
// Only our own commands (containing 'claude-recall' / 'hook run') are
|
|
174
|
+
// touched — a user's unrelated hook on the same event is preserved.
|
|
175
|
+
const supersededMarkers = {
|
|
176
|
+
userPromptSubmit: ['correction-detector'],
|
|
177
|
+
};
|
|
178
|
+
const isOurCommand = (cmd) => cmd.includes('claude-recall') || cmd.includes('hook run');
|
|
141
179
|
for (const [event, entries] of Object.entries(KiroCommands.buildHookEntries(hookCmd))) {
|
|
142
180
|
if (!Array.isArray(config.hooks[event])) {
|
|
143
181
|
config.hooks[event] = [];
|
|
144
182
|
}
|
|
145
183
|
const marker = handlerMarkers[event];
|
|
184
|
+
// Strip superseded entries first (but never the current marker).
|
|
185
|
+
for (const stale of supersededMarkers[event] ?? []) {
|
|
186
|
+
if (stale === marker)
|
|
187
|
+
continue;
|
|
188
|
+
const before = config.hooks[event].length;
|
|
189
|
+
config.hooks[event] = config.hooks[event].filter((h) => !(typeof h?.command === 'string' && h.command.includes(stale) && isOurCommand(h.command)));
|
|
190
|
+
const removed = before - config.hooks[event].length;
|
|
191
|
+
if (removed > 0) {
|
|
192
|
+
changes.push(`hooks.${event}: removed ${removed} superseded ${stale} entr${removed === 1 ? 'y' : 'ies'}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
146
195
|
const alreadyWired = config.hooks[event].some((h) => typeof h?.command === 'string' && h.command.includes(marker));
|
|
147
196
|
if (alreadyWired) {
|
|
148
197
|
changes.push(`hooks.${event}: ${marker} already wired — skipped`);
|
|
@@ -214,8 +263,11 @@ class KiroCommands {
|
|
|
214
263
|
const backupPath = `${agentPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
215
264
|
fs.writeFileSync(backupPath, raw);
|
|
216
265
|
fs.writeFileSync(agentPath, JSON.stringify(config, null, 2) + '\n');
|
|
266
|
+
// Capture uses Kiro's own headless LLM via a bare classifier agent.
|
|
267
|
+
const classifierPath = KiroCommands.writeClassifierAgent();
|
|
217
268
|
console.log(`✅ Merged Claude Recall into: ${agentPath}`);
|
|
218
269
|
console.log(` Backup: ${backupPath}`);
|
|
270
|
+
console.log(` Classifier agent: ${classifierPath}`);
|
|
219
271
|
console.log('');
|
|
220
272
|
for (const c of changes)
|
|
221
273
|
console.log(` • ${c}`);
|
|
@@ -251,7 +303,10 @@ class KiroCommands {
|
|
|
251
303
|
fs.mkdirSync(baseDir, { recursive: true });
|
|
252
304
|
const config = KiroCommands.buildAgentConfig(hookCmd, mcpCommand, mcpArgs);
|
|
253
305
|
fs.writeFileSync(agentPath, JSON.stringify(config, null, 2) + '\n');
|
|
306
|
+
// Capture uses Kiro's own headless LLM via a bare classifier agent.
|
|
307
|
+
const classifierPath = KiroCommands.writeClassifierAgent();
|
|
254
308
|
console.log(`✅ Wrote Kiro agent config: ${agentPath}`);
|
|
309
|
+
console.log(`✅ Wrote classifier agent: ${classifierPath}`);
|
|
255
310
|
console.log('');
|
|
256
311
|
console.log('No mcp.json changes needed — the agent config carries its own claude-recall');
|
|
257
312
|
console.log('MCP server entry (and includeMcpJson keeps your other servers working).');
|
|
@@ -266,7 +321,9 @@ class KiroCommands {
|
|
|
266
321
|
console.log('');
|
|
267
322
|
console.log('Rules load into context automatically at agent start; corrections and');
|
|
268
323
|
console.log('preferences you state are captured; memories are shared with Claude Code');
|
|
269
|
-
console.log('(same database, same per-project scoping).');
|
|
324
|
+
console.log('(same database, same per-project scoping). Capture classifies each prompt');
|
|
325
|
+
console.log('with Kiro\'s own LLM — no ANTHROPIC_API_KEY needed (spends ~0.06 Kiro');
|
|
326
|
+
console.log('credits/prompt; set CLAUDE_RECALL_KIRO_MODEL to change the model).');
|
|
270
327
|
console.log('');
|
|
271
328
|
console.log('⚠️ Kiro snapshots the agent config into each conversation at creation —');
|
|
272
329
|
console.log('conversations created before this setup never run the hooks, even when');
|
|
@@ -406,6 +463,25 @@ class KiroCommands {
|
|
|
406
463
|
line('⚠', 'none found. Run `claude-recall kiro setup` (new agent) or');
|
|
407
464
|
line(' ', ' `claude-recall kiro setup --merge-into <agent>` (existing agent).');
|
|
408
465
|
}
|
|
466
|
+
// --- Capture backend (Kiro LLM classifier) ---
|
|
467
|
+
console.log('\nCapture backend (no ANTHROPIC_API_KEY needed under Kiro)');
|
|
468
|
+
const classifierPath = path.join(os.homedir(), '.kiro', 'agents', `${KiroCommands.CLASSIFIER_AGENT_NAME}.json`);
|
|
469
|
+
if (fs.existsSync(classifierPath)) {
|
|
470
|
+
line('✓', `classifier agent present: ${classifierPath}`);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
line('⚠', `classifier agent missing (${KiroCommands.CLASSIFIER_AGENT_NAME}.json) — re-run \`claude-recall kiro setup\` or \`--merge-into\`. Capture falls back to regex without it.`);
|
|
474
|
+
}
|
|
475
|
+
if ((0, repair_1.resolveOnPath)('kiro-cli')) {
|
|
476
|
+
const model = process.env.CLAUDE_RECALL_KIRO_MODEL || 'claude-haiku-4.5';
|
|
477
|
+
line('✓', `kiro-cli on PATH — capture classifies with Kiro's LLM (model: ${model})`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
line('⚠', 'kiro-cli not on PATH — capture cannot reach Kiro\'s LLM and falls back to regex.');
|
|
481
|
+
}
|
|
482
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
483
|
+
line('•', 'ANTHROPIC_API_KEY is set — it takes precedence over the Kiro LLM for capture.');
|
|
484
|
+
}
|
|
409
485
|
// --- Hook activity ---
|
|
410
486
|
console.log('\nRecent hook activity');
|
|
411
487
|
const dir = process.env.CLAUDE_RECALL_DB_PATH || path.join(os.homedir(), '.claude-recall');
|
|
@@ -462,3 +538,9 @@ KiroCommands.ALLOWED_TOOLS = [
|
|
|
462
538
|
'@claude-recall/search_memory',
|
|
463
539
|
'@claude-recall/load_checkpoint',
|
|
464
540
|
];
|
|
541
|
+
/**
|
|
542
|
+
* Name of the bare agent used for headless memory classification. Kept in
|
|
543
|
+
* sync with CLASSIFIER_AGENT in src/hooks/kiro-classifier.ts (duplicated as a
|
|
544
|
+
* plain string so this module doesn't pull the hooks graph into jest).
|
|
545
|
+
*/
|
|
546
|
+
KiroCommands.CLASSIFIER_AGENT_NAME = 'claude-recall-classifier';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Kiro-LLM memory classifier.
|
|
4
|
+
*
|
|
5
|
+
* When Claude Recall runs under Kiro CLI there is no ANTHROPIC_API_KEY, so the
|
|
6
|
+
* usual Haiku classifier (llm-classifier.ts) is unavailable and capture would
|
|
7
|
+
* degrade to regex. But Kiro CLI ships its own LLM and exposes it headlessly:
|
|
8
|
+
* `kiro-cli chat --no-interactive "<prompt>"` runs a one-shot completion on
|
|
9
|
+
* Kiro's model using Kiro's own auth — no API key, no personal subscription.
|
|
10
|
+
* See docs/kiro-llm-capture.md for the full findings.
|
|
11
|
+
*
|
|
12
|
+
* This module shells out to that headless mode to classify a user prompt into
|
|
13
|
+
* the same ClassifyResult shape the rest of the pipeline expects. It runs from
|
|
14
|
+
* a DETACHED worker (kiro-capture-worker), never inline on the user's turn,
|
|
15
|
+
* because a cold `kiro-cli` boot can take ~15s.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.CLASSIFIER_AGENT = void 0;
|
|
19
|
+
exports.extractClassification = extractClassification;
|
|
20
|
+
exports.classifyWithKiro = classifyWithKiro;
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const shared_1 = require("./shared");
|
|
23
|
+
/** Bare Kiro agent used for classification — no MCP, no hooks, no tools. */
|
|
24
|
+
exports.CLASSIFIER_AGENT = 'claude-recall-classifier';
|
|
25
|
+
const DEFAULT_MODEL = 'claude-haiku-4.5';
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
27
|
+
const VALID_TYPES = new Set([
|
|
28
|
+
'correction',
|
|
29
|
+
'preference',
|
|
30
|
+
'failure',
|
|
31
|
+
'devops',
|
|
32
|
+
'project-knowledge',
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* The classify instruction, prepended to the user's message. kiro-cli takes a
|
|
36
|
+
* single INPUT argument (no separate system prompt), so instruction + payload
|
|
37
|
+
* are combined. Mirrors the contract of llm-classifier's SYSTEM_PROMPT so
|
|
38
|
+
* downstream thresholds/dedup behave identically regardless of which LLM ran.
|
|
39
|
+
*/
|
|
40
|
+
function buildPrompt(text) {
|
|
41
|
+
return ('You are a memory classifier for a developer tool. Classify the USER MESSAGE ' +
|
|
42
|
+
'into exactly one type and respond with ONLY minified JSON — no markdown, no ' +
|
|
43
|
+
'prose, no code fence:\n' +
|
|
44
|
+
'{"type":"correction|preference|failure|devops|project-knowledge|none","confidence":0.0-1.0,"extract":"<concise imperative rule to remember, or empty>"}\n\n' +
|
|
45
|
+
'Types:\n' +
|
|
46
|
+
'- correction: user correcting a mistake ("no, use X not Y")\n' +
|
|
47
|
+
'- 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
|
+
'- failure: something broke ("build failed")\n' +
|
|
49
|
+
'- devops: durable CI/CD, git, deployment, or Docker rules\n' +
|
|
50
|
+
'- 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' +
|
|
52
|
+
'Be conservative: when in doubt use "none" with confidence 0. Use confidence >= 0.75 for correction/preference/devops. ' +
|
|
53
|
+
'extract must be a clean standalone rule (e.g. "Favourite colour is green"), or empty when type is none.\n\n' +
|
|
54
|
+
'USER MESSAGE: ' + text);
|
|
55
|
+
}
|
|
56
|
+
/** Strip ANSI/VT control sequences that kiro-cli writes around its output. */
|
|
57
|
+
function stripAnsi(s) {
|
|
58
|
+
// Anchor every strip to the ESC byte (\x1b) so ordinary JSON text —
|
|
59
|
+
// uppercase letters, hyphens, braces — is never touched. First form: CSI
|
|
60
|
+
// (ESC [ ... final byte); second: two-byte ESC <char>. The control char in
|
|
61
|
+
// the pattern is deliberate — that's exactly what we're stripping.
|
|
62
|
+
// eslint-disable-next-line no-control-regex
|
|
63
|
+
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b[@-_]/g, '');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Pull the first balanced JSON object out of kiro-cli's decorated stdout.
|
|
67
|
+
* The output looks like `> {"type":...}` or `> json\n{...}`, possibly wrapped
|
|
68
|
+
* in a ```json fence and coloured with ANSI. Returns null if no object parses.
|
|
69
|
+
*/
|
|
70
|
+
function extractClassification(raw) {
|
|
71
|
+
const cleaned = stripAnsi(raw);
|
|
72
|
+
const start = cleaned.indexOf('{');
|
|
73
|
+
const end = cleaned.lastIndexOf('}');
|
|
74
|
+
if (start === -1 || end === -1 || end <= start)
|
|
75
|
+
return null;
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(cleaned.slice(start, end + 1));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!parsed || typeof parsed !== 'object')
|
|
84
|
+
return null;
|
|
85
|
+
if (parsed.type === 'none' || !VALID_TYPES.has(parsed.type))
|
|
86
|
+
return null;
|
|
87
|
+
if (typeof parsed.extract !== 'string' || parsed.extract.trim().length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
const confidence = typeof parsed.confidence === 'number' ? parsed.confidence : 0.7;
|
|
90
|
+
return {
|
|
91
|
+
type: parsed.type,
|
|
92
|
+
confidence,
|
|
93
|
+
extract: parsed.extract.trim(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Classify a prompt by invoking Kiro's headless LLM. Returns null on any
|
|
98
|
+
* failure (kiro-cli absent, timeout, non-zero exit, unparseable output) so the
|
|
99
|
+
* caller falls back to regex. Never throws.
|
|
100
|
+
*/
|
|
101
|
+
function classifyWithKiro(text) {
|
|
102
|
+
const model = process.env.CLAUDE_RECALL_KIRO_MODEL || DEFAULT_MODEL;
|
|
103
|
+
const timeoutMs = parseInt(process.env.CLAUDE_RECALL_KIRO_LLM_TIMEOUT_MS || '', 10);
|
|
104
|
+
const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
let settled = false;
|
|
107
|
+
const done = (result) => {
|
|
108
|
+
if (settled)
|
|
109
|
+
return;
|
|
110
|
+
settled = true;
|
|
111
|
+
resolve(result);
|
|
112
|
+
};
|
|
113
|
+
let child;
|
|
114
|
+
try {
|
|
115
|
+
// args array (no shell) — the user's text is passed as a single argv
|
|
116
|
+
// entry, so no shell escaping or injection is possible.
|
|
117
|
+
child = (0, child_process_1.spawn)('kiro-cli', [
|
|
118
|
+
'chat',
|
|
119
|
+
'--no-interactive',
|
|
120
|
+
'--agent', exports.CLASSIFIER_AGENT,
|
|
121
|
+
'--model', model,
|
|
122
|
+
buildPrompt(text),
|
|
123
|
+
], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
(0, shared_1.hookLog)('kiro-classifier', `spawn threw: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
127
|
+
return done(null);
|
|
128
|
+
}
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
(0, shared_1.hookLog)('kiro-classifier', `timeout after ${timeout}ms — killing kiro-cli`);
|
|
131
|
+
try {
|
|
132
|
+
child.kill('SIGKILL');
|
|
133
|
+
}
|
|
134
|
+
catch { /* already gone */ }
|
|
135
|
+
done(null);
|
|
136
|
+
}, timeout);
|
|
137
|
+
let stdout = '';
|
|
138
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
139
|
+
child.on('error', (err) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
// ENOENT = kiro-cli not on PATH; anything else = spawn failure
|
|
142
|
+
(0, shared_1.hookLog)('kiro-classifier', `kiro-cli error: ${err?.code ?? ''} ${err?.message ?? err}`);
|
|
143
|
+
done(null);
|
|
144
|
+
});
|
|
145
|
+
child.on('close', (code) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
if (settled)
|
|
148
|
+
return;
|
|
149
|
+
if (code !== 0) {
|
|
150
|
+
(0, shared_1.hookLog)('kiro-classifier', `kiro-cli exited ${code}`);
|
|
151
|
+
return done(null);
|
|
152
|
+
}
|
|
153
|
+
const result = extractClassification(stdout);
|
|
154
|
+
if (!result) {
|
|
155
|
+
(0, shared_1.hookLog)('kiro-classifier', 'no parseable classification in kiro-cli output');
|
|
156
|
+
}
|
|
157
|
+
done(result);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
package/dist/hooks/kiro-hooks.js
CHANGED
|
@@ -31,12 +31,16 @@ exports.normalizeKiroInput = normalizeKiroInput;
|
|
|
31
31
|
exports.handleKiroAgentSpawn = handleKiroAgentSpawn;
|
|
32
32
|
exports.handleKiroRuleInjector = handleKiroRuleInjector;
|
|
33
33
|
exports.handleKiroToolOutcome = handleKiroToolOutcome;
|
|
34
|
+
exports.handleKiroCapture = handleKiroCapture;
|
|
35
|
+
exports.handleKiroCaptureWorker = handleKiroCaptureWorker;
|
|
36
|
+
const child_process_1 = require("child_process");
|
|
34
37
|
const shared_1 = require("./shared");
|
|
35
38
|
const directives_1 = require("../shared/directives");
|
|
36
39
|
const memory_1 = require("../services/memory");
|
|
37
40
|
const config_1 = require("../services/config");
|
|
38
41
|
const rule_injector_1 = require("./rule-injector");
|
|
39
42
|
const tool_outcome_watcher_1 = require("./tool-outcome-watcher");
|
|
43
|
+
const correction_detector_1 = require("./correction-detector");
|
|
40
44
|
const memory_tools_1 = require("../mcp/tools/memory-tools");
|
|
41
45
|
const HOOK_NAME = 'kiro';
|
|
42
46
|
/** Kiro built-in tool names → the Claude Code names our handlers understand. */
|
|
@@ -180,3 +184,58 @@ async function handleKiroToolOutcome(input) {
|
|
|
180
184
|
(0, shared_1.hookLog)(HOOK_NAME, `tool-outcome error: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
181
185
|
}
|
|
182
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* userPromptSubmit — the synchronous gate Kiro waits on. Under Kiro there is no
|
|
189
|
+
* ANTHROPIC_API_KEY, so classification uses Kiro's own headless LLM
|
|
190
|
+
* (`kiro-cli chat --no-interactive`), which cold-boots in ~3–15s — too slow to
|
|
191
|
+
* run inline within Kiro's hook timeout, and a poor UX blocking every turn.
|
|
192
|
+
*
|
|
193
|
+
* So this handler does NOT classify. It spawns a DETACHED worker
|
|
194
|
+
* (kiro-capture-worker) that survives this process, pipes the prompt payload to
|
|
195
|
+
* it over stdin, and returns in milliseconds. The worker performs the slow Kiro
|
|
196
|
+
* classify call and stores the memory in the background. Same pattern as
|
|
197
|
+
* session-end-checkpoint. See docs/kiro-llm-capture.md.
|
|
198
|
+
*/
|
|
199
|
+
async function handleKiroCapture(input) {
|
|
200
|
+
const prompt = input?.prompt ?? '';
|
|
201
|
+
// Cheap pre-checks mirror correction-detector so we don't spawn a worker (and
|
|
202
|
+
// spend Kiro credits) on input that could never be stored.
|
|
203
|
+
if (prompt.length < 20 || prompt.length > 2000)
|
|
204
|
+
return;
|
|
205
|
+
if (prompt.startsWith('```') || prompt.startsWith('{'))
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
const cliPath = process.argv[1]; // absolute path to claude-recall-cli.js
|
|
209
|
+
const child = (0, child_process_1.spawn)(process.execPath, [cliPath, 'hook', 'run', 'kiro-capture-worker'], { detached: true, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
210
|
+
child.on('error', (err) => {
|
|
211
|
+
(0, shared_1.hookLog)(HOOK_NAME, `capture worker spawn error: ${err?.message ?? err}`);
|
|
212
|
+
});
|
|
213
|
+
if (child.stdin) {
|
|
214
|
+
child.stdin.on('error', (err) => {
|
|
215
|
+
(0, shared_1.hookLog)(HOOK_NAME, `capture worker stdin error: ${err?.message ?? err}`);
|
|
216
|
+
});
|
|
217
|
+
child.stdin.write(JSON.stringify(input));
|
|
218
|
+
child.stdin.end();
|
|
219
|
+
}
|
|
220
|
+
child.unref();
|
|
221
|
+
(0, shared_1.hookLog)(HOOK_NAME, `capture: spawned detached worker (pid=${child.pid})`);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
(0, shared_1.hookLog)(HOOK_NAME, `capture spawn failed: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* kiro-capture-worker — the background half of handleKiroCapture. Enables the
|
|
229
|
+
* Kiro-LLM classifier path (CLAUDE_RECALL_KIRO_CLASSIFIER) and delegates to the
|
|
230
|
+
* standard correction-detector, which now classifies via Kiro's headless LLM
|
|
231
|
+
* and stores. Runs detached, so its output goes nowhere and capture is silent.
|
|
232
|
+
*/
|
|
233
|
+
async function handleKiroCaptureWorker(input) {
|
|
234
|
+
process.env.CLAUDE_RECALL_KIRO_CLASSIFIER = '1';
|
|
235
|
+
try {
|
|
236
|
+
await (0, correction_detector_1.handleCorrectionDetector)(input);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
(0, shared_1.hookLog)(HOOK_NAME, `capture worker error: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
package/dist/hooks/shared.js
CHANGED
|
@@ -133,13 +133,26 @@ function classifyContentRegex(text) {
|
|
|
133
133
|
}
|
|
134
134
|
/**
|
|
135
135
|
* Classify text content — LLM-first, regex fallback.
|
|
136
|
-
*
|
|
137
|
-
*
|
|
136
|
+
* Precedence:
|
|
137
|
+
* 1. Claude Haiku via ANTHROPIC_API_KEY (Claude Code sets this automatically).
|
|
138
|
+
* 2. Kiro's headless LLM (`kiro-cli chat --no-interactive`) when running under
|
|
139
|
+
* Kiro — no API key needed. Gated on CLAUDE_RECALL_KIRO_CLASSIFIER, which
|
|
140
|
+
* the kiro-capture-worker sets; the classify call is ~3s so it only runs
|
|
141
|
+
* from that detached worker, never inline. See docs/kiro-llm-capture.md.
|
|
142
|
+
* 3. Regex patterns, if neither LLM path yields a result.
|
|
138
143
|
*/
|
|
139
144
|
async function classifyContent(text) {
|
|
140
145
|
const llmResult = await (0, llm_classifier_1.classifyWithLLM)(text);
|
|
141
146
|
if (llmResult)
|
|
142
147
|
return llmResult;
|
|
148
|
+
if (process.env.CLAUDE_RECALL_KIRO_CLASSIFIER) {
|
|
149
|
+
// Dynamic import keeps kiro-classifier (and child_process) out of the
|
|
150
|
+
// module graph for every non-Kiro hook invocation.
|
|
151
|
+
const { classifyWithKiro } = await Promise.resolve().then(() => __importStar(require('./kiro-classifier')));
|
|
152
|
+
const kiroResult = await classifyWithKiro(text);
|
|
153
|
+
if (kiroResult)
|
|
154
|
+
return kiroResult;
|
|
155
|
+
}
|
|
143
156
|
return classifyContentRegex(text);
|
|
144
157
|
}
|
|
145
158
|
/**
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Using Kiro's LLM for memory capture (no API key)
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
Claude Recall captures memories with an LLM classifier: it reads each user
|
|
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.
|
|
10
|
+
|
|
11
|
+
Kiro CLI does **not** set `ANTHROPIC_API_KEY`. So under Kiro the classifier fell
|
|
12
|
+
back to a conservative regex that only fires on explicit phrasings
|
|
13
|
+
("remember …", "always …", "I prefer …"). A natural statement like
|
|
14
|
+
"my favourite color is green" was examined and rejected
|
|
15
|
+
(`no rule detected in prompt` in `correction-detector.log`), and nothing was
|
|
16
|
+
stored. Worse, under enterprise MCP governance the `store_memory` tool is
|
|
17
|
+
dropped from the agent's toolset, so the agent itself can't store on demand
|
|
18
|
+
either — leaving regex as the *only* capture path.
|
|
19
|
+
|
|
20
|
+
Two non-answers:
|
|
21
|
+
|
|
22
|
+
- **"Just export `ANTHROPIC_API_KEY`."** Works, but forces the user to bring
|
|
23
|
+
their own Claude subscription/credits when Kiro already includes an LLM.
|
|
24
|
+
- **"Broaden the regex."** Trades one brittle heuristic for a slightly larger
|
|
25
|
+
brittle heuristic; still misses anything not phrased as a known trigger.
|
|
26
|
+
|
|
27
|
+
## The finding
|
|
28
|
+
|
|
29
|
+
**`kiro-cli chat --no-interactive "<prompt>"` is a headless one-shot completion
|
|
30
|
+
that runs on Kiro's own model and Kiro's own authentication.** It is exactly the
|
|
31
|
+
subprocess-callable LLM I had wrongly assumed didn't exist. A background hook can
|
|
32
|
+
shell out to it to classify text, with no API key and no personal subscription.
|
|
33
|
+
|
|
34
|
+
Verified on Kiro CLI 2.7.0 (Kiro PRO):
|
|
35
|
+
|
|
36
|
+
| Property | Result |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| Auth | Uses the Kiro login. No `ANTHROPIC_API_KEY`. Consumes Kiro credits (~0.06 per call on Haiku). |
|
|
39
|
+
| Model | `--list-models` exposes `claude-haiku-4.5` (rate 0.4 — cheapest Claude), plus Sonnet/Opus tiers. |
|
|
40
|
+
| Classify **and** extract in one call | `{"store": true, "type": "preference", "value": "favourite color is green"}` |
|
|
41
|
+
| Latency (bare agent, warm) | ~2.9–3.4 s |
|
|
42
|
+
| Latency (default agent, cold) | ~16 s — dominated by loading the session's MCP servers and firing that agent's own hooks |
|
|
43
|
+
| Reachable from a minimal `PATH` | Yes — `~/.local/bin/kiro-cli` resolves |
|
|
44
|
+
|
|
45
|
+
### Why a dedicated "bare" agent matters
|
|
46
|
+
|
|
47
|
+
Running the classify call through the *default* agent is slow and unsafe:
|
|
48
|
+
|
|
49
|
+
- It loads all of the session's MCP servers (the enterprise setup loads five),
|
|
50
|
+
adding ~5 s.
|
|
51
|
+
- It fires that agent's own `agentSpawn`/hook chain — including Claude Recall's
|
|
52
|
+
own hooks if they're wired — which risks **recursion** (a capture call that
|
|
53
|
+
triggers another capture call).
|
|
54
|
+
|
|
55
|
+
So `kiro setup` writes a minimal `claude-recall-classifier` agent to
|
|
56
|
+
`~/.kiro/agents/` with **no MCP servers, no hooks, and no tools**. The classify
|
|
57
|
+
call runs `--agent claude-recall-classifier`, which cold-boots in ~3 s and
|
|
58
|
+
cannot recurse.
|
|
59
|
+
|
|
60
|
+
### Why capture must run asynchronously
|
|
61
|
+
|
|
62
|
+
Kiro enforces a per-hook timeout (we wire `userPromptSubmit` at 8 s). A cold
|
|
63
|
+
classify call can exceed that, which would kill the hook on the first — and most
|
|
64
|
+
important — prompt of a session. Even when it fits, blocking every turn on a
|
|
65
|
+
~3 s LLM call is a poor experience for an interactive session.
|
|
66
|
+
|
|
67
|
+
So the `userPromptSubmit` hook does **not** classify inline. It spawns a
|
|
68
|
+
**detached worker** (the same pattern as `session-end-checkpoint`), pipes the
|
|
69
|
+
prompt to it over stdin, and returns in milliseconds. The worker performs the
|
|
70
|
+
~3 s Kiro classify call and stores the memory in the background. The trade-off:
|
|
71
|
+
no synchronous "captured" echo, and a brief window where a memory stated in one
|
|
72
|
+
turn isn't queryable until the worker finishes (~3 s later) — acceptable for
|
|
73
|
+
capture.
|
|
74
|
+
|
|
75
|
+
## The design
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
Kiro userPromptSubmit
|
|
79
|
+
│
|
|
80
|
+
▼
|
|
81
|
+
hook run kiro-capture (returns in <100 ms)
|
|
82
|
+
│ spawn detached, pipe payload via stdin
|
|
83
|
+
▼
|
|
84
|
+
hook run kiro-capture-worker (background, survives parent exit)
|
|
85
|
+
│ sets CLAUDE_RECALL_KIRO_CLASSIFIER=1
|
|
86
|
+
▼
|
|
87
|
+
handleCorrectionDetector → classifyContent()
|
|
88
|
+
│
|
|
89
|
+
├─ classifyWithLLM() → ANTHROPIC_API_KEY (unset under Kiro) → null
|
|
90
|
+
├─ classifyWithKiro() → kiro-cli chat --no-interactive --agent
|
|
91
|
+
│ claude-recall-classifier --model claude-haiku-4.5
|
|
92
|
+
│ → {"type","confidence","extract"}
|
|
93
|
+
└─ regex fallback (only if both above yield nothing)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Capture precedence under Kiro becomes: **your own `ANTHROPIC_API_KEY` if you set
|
|
97
|
+
one → Kiro's headless LLM → regex as a last resort.** In the governance-locked,
|
|
98
|
+
no-key setup the Kiro LLM is the primary path and regex is just a safety net.
|
|
99
|
+
|
|
100
|
+
### Output parsing
|
|
101
|
+
|
|
102
|
+
`kiro-cli` decorates stdout with a `> ` prompt marker, occasional ```` ```json ````
|
|
103
|
+
fences, and ANSI control sequences (the spinner and credits footer go to
|
|
104
|
+
stderr). `classifyWithKiro` strips ANSI, removes the marker and fences, extracts
|
|
105
|
+
the first balanced `{ … }` block, and `JSON.parse`s it. Any failure at any step
|
|
106
|
+
returns `null`, degrading to regex — a hook must never throw.
|
|
107
|
+
|
|
108
|
+
### Configuration
|
|
109
|
+
|
|
110
|
+
| Variable | Default | Effect |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| `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. |
|
|
113
|
+
| `CLAUDE_RECALL_KIRO_MODEL` | `claude-haiku-4.5` | Model for the classify call. Raise to `claude-sonnet-4.6` for steadier judgement at ~3× the credits. |
|
|
114
|
+
| `CLAUDE_RECALL_KIRO_LLM_TIMEOUT_MS` | `30000` | Hard cap on the headless call before the worker gives up and falls back to regex. |
|
|
115
|
+
|
|
116
|
+
## Caveats
|
|
117
|
+
|
|
118
|
+
- **Consistency.** Haiku returned YES then NO on near-identical prompt wording
|
|
119
|
+
during testing. The classify prompt is written to be explicit, and
|
|
120
|
+
`CLAUDE_RECALL_KIRO_MODEL` lets you trade credits for a steadier model.
|
|
121
|
+
- **Cost.** Each classified prompt spends ~0.06 Kiro credits on Haiku. That is
|
|
122
|
+
the deliberate trade for using Kiro's LLM instead of a personal subscription.
|
|
123
|
+
- **Concurrency.** The worker opens a second, throwaway headless conversation
|
|
124
|
+
while the interactive session runs. They use different agents and no
|
|
125
|
+
`--resume`, so they're independent; SQLite WAL handles concurrent writes.
|
|
126
|
+
- **Requires `kiro-cli` on `PATH`.** If it's absent the classify call errors and
|
|
127
|
+
the worker falls back to regex — no crash.
|
|
128
|
+
```
|
package/package.json
CHANGED