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