@lh8ppl/claude-memory-kit 0.1.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/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- package/template/user/fragments/INDEX.md.template +23 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// CompressorBackend interface + concrete impls (Task 23.6, T-020).
|
|
2
|
+
//
|
|
3
|
+
// The interface (compress + modelId + estimatedCostPerCall) is the
|
|
4
|
+
// pluggable backend boundary referenced by design §8.3. It is used by
|
|
5
|
+
// two callers in v0.1:
|
|
6
|
+
// 1. Task 23 — auto-extract subagent (this PR). Calls compress() with
|
|
7
|
+
// an extraction prompt + the just-captured turn.
|
|
8
|
+
// 2. Task 22 — SessionEnd hook (next PR). Calls compress() with a
|
|
9
|
+
// compression prompt + the live now.md buffer.
|
|
10
|
+
//
|
|
11
|
+
// v0.1 ships ONE production implementation (HaikuViaAnthropicApi) plus
|
|
12
|
+
// a test-only MockHaikuBackend used by every downstream test that needs
|
|
13
|
+
// to inject canned responses without spawning the real `claude` binary.
|
|
14
|
+
// v0.2 candidates per ADR-0008: BedrockHaiku, LocalLlama; selected via
|
|
15
|
+
// settings.json (`compressor.backend`).
|
|
16
|
+
//
|
|
17
|
+
// Sandbox flags (cd /tmp, env -u CLAUDECODE, --allowed-tools "",
|
|
18
|
+
// --max-turns 1, --mcp-config '{"mcpServers":{}}' --strict-mcp-config,
|
|
19
|
+
// stdin from temp file) are absorbed from claude-remember's verified
|
|
20
|
+
// pattern (see docs/research/2026-05-25-claude-remember-code-dive.md
|
|
21
|
+
// and SOURCES.md for the licensing posture — patterns/values absorbed,
|
|
22
|
+
// no code or prompts copied).
|
|
23
|
+
//
|
|
24
|
+
// Note on the allowedTools split: design.md §6.1 documents
|
|
25
|
+
// `--allowed-tools "Read"`; the code-dive note recommended tightening
|
|
26
|
+
// to fully empty per claude-remember's actual pattern. This PR
|
|
27
|
+
// implements empty per Lior's instruction (the auto-extract sub-Claude
|
|
28
|
+
// never needs Read either — the turn content arrives in the prompt).
|
|
29
|
+
|
|
30
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
31
|
+
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
const HAIKU_MODEL_ID = 'claude-haiku-4-5-20251001';
|
|
36
|
+
|
|
37
|
+
// On Windows, npm-installed CLI binaries ship as a `.cmd` shim. Node's
|
|
38
|
+
// child_process.spawn does NOT auto-resolve `.cmd`/`.bat` extensions
|
|
39
|
+
// (unlike shell PATH resolution), so `spawn('claude')` fails with
|
|
40
|
+
// ENOENT on Windows even though `where claude` finds it. The
|
|
41
|
+
// documented Node-on-Windows pattern is to either pass the explicit
|
|
42
|
+
// `.cmd` suffix or use `shell: true`. We use the explicit suffix so
|
|
43
|
+
// the args (which include JSON like `'{"mcpServers":{}}'`) pass
|
|
44
|
+
// through unescaped — `shell: true` would let cmd.exe re-tokenize.
|
|
45
|
+
//
|
|
46
|
+
// Live-test surface this bug: HaikuViaAnthropicApi.compress() crashed
|
|
47
|
+
// with ENOENT on every detached auto-extract invocation on Windows
|
|
48
|
+
// because the bin defaulted to 'claude' literally. See
|
|
49
|
+
// docs/journey/2026-05-26-live-test-findings.md.
|
|
50
|
+
const DEFAULT_CLAUDE_BIN = process.platform === 'win32' ? 'claude.cmd' : 'claude';
|
|
51
|
+
|
|
52
|
+
// Conservative cost estimate for Haiku 4.5 (USD per 1K input tokens +
|
|
53
|
+
// USD per 1K output tokens). Anthropic-published pricing as of
|
|
54
|
+
// 2026-05-25; revisit periodically. The estimator assumes 4 bytes/token
|
|
55
|
+
// average — close enough for "is this call going to cost cents or
|
|
56
|
+
// dollars" budgeting.
|
|
57
|
+
const HAIKU_INPUT_USD_PER_1K = 0.001;
|
|
58
|
+
const HAIKU_OUTPUT_USD_PER_1K = 0.005;
|
|
59
|
+
const BYTES_PER_TOKEN_ESTIMATE = 4;
|
|
60
|
+
// Assumed output:input token ratio when the actual output isn't known
|
|
61
|
+
// (used by estimatedCostPerCall before a call is made).
|
|
62
|
+
const ESTIMATED_OUTPUT_FRACTION = 0.25;
|
|
63
|
+
|
|
64
|
+
export class CompressorBackend {
|
|
65
|
+
async compress(_opts) {
|
|
66
|
+
throw new Error('CompressorBackend.compress must be implemented by subclass');
|
|
67
|
+
}
|
|
68
|
+
modelId() {
|
|
69
|
+
throw new Error('CompressorBackend.modelId must be implemented by subclass');
|
|
70
|
+
}
|
|
71
|
+
estimatedCostPerCall(_inputBytes) {
|
|
72
|
+
throw new Error('CompressorBackend.estimatedCostPerCall must be implemented by subclass');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Subprocess timeout error — distinguishes "the call took too long"
|
|
77
|
+
// from "the call exited with a non-zero status" (which produces a
|
|
78
|
+
// generic Error with the subprocess's stderr). Callers route on
|
|
79
|
+
// `err.category` per design §8.5; the kit's ERROR_CATEGORIES enum
|
|
80
|
+
// uses HAIKU_TIMEOUT for this case + HAIKU_FAILED for non-zero exit.
|
|
81
|
+
//
|
|
82
|
+
// Per the design §8.5 contract, every CompressorBackend implementation
|
|
83
|
+
// (HaikuViaAnthropicApi here; v0.2 BedrockHaiku / LocalLlama later)
|
|
84
|
+
// MUST honor the caller-supplied timeoutMs by rejecting with a
|
|
85
|
+
// HaikuTimeoutError (or category-equivalent). The "Haiku" in the
|
|
86
|
+
// name is historical — the contract applies to every backend.
|
|
87
|
+
export class HaikuTimeoutError extends Error {
|
|
88
|
+
constructor(message, { timeoutMs }) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = 'HaikuTimeoutError';
|
|
91
|
+
this.category = 'haiku_timeout';
|
|
92
|
+
this.timeoutMs = timeoutMs;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// SIGTERM → grace window → SIGKILL escalation. Exported so the kill
|
|
97
|
+
// chain itself is independently testable against real OS processes
|
|
98
|
+
// (see tests/spawn-smoke-kill-chain.test.js) — the production code
|
|
99
|
+
// path in compress() uses it internally.
|
|
100
|
+
//
|
|
101
|
+
// Returns {method: 'already-exited' | 'sigterm' | 'sigkill' |
|
|
102
|
+
// 'sigkill-no-confirm', exitCode}. The 'sigkill-no-confirm' case
|
|
103
|
+
// means we sent SIGKILL but the OS didn't deliver the 'exit' event
|
|
104
|
+
// within the secondary timeout — exceedingly rare; documented for
|
|
105
|
+
// completeness.
|
|
106
|
+
export function terminateSubprocess(child, { killGraceMs = 2000 } = {}) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
if (child.exitCode !== null && child.exitCode !== undefined) {
|
|
109
|
+
resolve({ method: 'already-exited', exitCode: child.exitCode });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let settled = false;
|
|
113
|
+
let sigkillSent = false;
|
|
114
|
+
const finish = (method) => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
resolve({ method, exitCode: child.exitCode ?? null });
|
|
118
|
+
};
|
|
119
|
+
const onExit = () => {
|
|
120
|
+
finish(sigkillSent ? 'sigkill' : 'sigterm');
|
|
121
|
+
};
|
|
122
|
+
child.once('exit', onExit);
|
|
123
|
+
try {
|
|
124
|
+
child.kill('SIGTERM');
|
|
125
|
+
} catch {
|
|
126
|
+
// Child already gone — onExit will fire (or has fired)
|
|
127
|
+
}
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
sigkillSent = true;
|
|
131
|
+
try {
|
|
132
|
+
child.kill('SIGKILL');
|
|
133
|
+
} catch {
|
|
134
|
+
// Best-effort
|
|
135
|
+
}
|
|
136
|
+
setTimeout(() => finish('sigkill-no-confirm'), 1000);
|
|
137
|
+
}, killGraceMs);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class HaikuViaAnthropicApi extends CompressorBackend {
|
|
142
|
+
constructor({ claudeBin, model, spawnFn } = {}) {
|
|
143
|
+
super();
|
|
144
|
+
this._bin = claudeBin ?? DEFAULT_CLAUDE_BIN;
|
|
145
|
+
this._model = model ?? HAIKU_MODEL_ID;
|
|
146
|
+
this._spawn = spawnFn ?? defaultSpawn;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
modelId() {
|
|
150
|
+
return this._model;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
estimatedCostPerCall(inputBytes) {
|
|
154
|
+
const inputTokens = Math.ceil(inputBytes / BYTES_PER_TOKEN_ESTIMATE);
|
|
155
|
+
const estOutputTokens = Math.ceil(inputTokens * ESTIMATED_OUTPUT_FRACTION);
|
|
156
|
+
return (
|
|
157
|
+
(inputTokens / 1000) * HAIKU_INPUT_USD_PER_1K +
|
|
158
|
+
(estOutputTokens / 1000) * HAIKU_OUTPUT_USD_PER_1K
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async compress({ input, maxOutputBytes, preserveCitationIds, instructions, timeoutMs, killGraceMs } = {}) {
|
|
163
|
+
if (typeof input !== 'string') {
|
|
164
|
+
throw new Error('HaikuViaAnthropicApi.compress: input must be a string');
|
|
165
|
+
}
|
|
166
|
+
// The kit hands the model the prompt body via stdin (not argv) so
|
|
167
|
+
// any `$`, backtick, `<`, `>` in the body is preserved verbatim —
|
|
168
|
+
// shell interpolation can't reach it.
|
|
169
|
+
const promptBody = instructions ? `${instructions}\n\n${input}` : input;
|
|
170
|
+
|
|
171
|
+
// Write empty MCP config to a temp file rather than passing inline
|
|
172
|
+
// JSON. Inline JSON via argv survives Linux/macOS shells but cmd.exe
|
|
173
|
+
// strips the double-quotes when shell:true is set on Windows,
|
|
174
|
+
// mangling `{"mcpServers":{}}` to `{mcpServers:{}}` and breaking
|
|
175
|
+
// --mcp-config parsing. Tempfile + path arg is the cross-platform
|
|
176
|
+
// pattern (live-test surface: see
|
|
177
|
+
// docs/journey/2026-05-26-live-test-findings.md).
|
|
178
|
+
const sandbox = mkdtempSync(join(tmpdir(), 'cmk-haiku-'));
|
|
179
|
+
const mcpConfigPath = join(sandbox, 'empty-mcp.json');
|
|
180
|
+
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), 'utf8');
|
|
181
|
+
|
|
182
|
+
// Build claude --print invocation with the documented sandbox flags.
|
|
183
|
+
// Empty allowedTools + empty MCP config = tightest possible sandbox;
|
|
184
|
+
// the sub-Claude can only respond, not act.
|
|
185
|
+
const args = [
|
|
186
|
+
'--print',
|
|
187
|
+
'--model',
|
|
188
|
+
this._model,
|
|
189
|
+
'--allowed-tools',
|
|
190
|
+
'',
|
|
191
|
+
'--max-turns',
|
|
192
|
+
'1',
|
|
193
|
+
'--mcp-config',
|
|
194
|
+
mcpConfigPath,
|
|
195
|
+
'--strict-mcp-config',
|
|
196
|
+
'--output-format',
|
|
197
|
+
'text',
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
// Strip CLAUDECODE env var (the marker Claude Code sets to identify
|
|
201
|
+
// itself to subagents) so Haiku doesn't pick up an "I'm inside
|
|
202
|
+
// Claude Code" assumption.
|
|
203
|
+
const env = { ...process.env };
|
|
204
|
+
delete env.CLAUDECODE;
|
|
205
|
+
|
|
206
|
+
// shell:true required on Windows so that .cmd shims (claude.cmd)
|
|
207
|
+
// resolve through cmd.exe. Without it, node's spawn fails with
|
|
208
|
+
// EINVAL/ENOENT because it won't auto-resolve .cmd extensions
|
|
209
|
+
// (CVE-2024-27980 hardening). On Linux/macOS shell:true is a
|
|
210
|
+
// no-op for argv-style invocation when the arguments don't contain
|
|
211
|
+
// shell metacharacters — ours don't (the prompt goes via stdin).
|
|
212
|
+
// spawn-discipline: caller-managed terminateSubprocess (kit's kill-chain helper) + setTimeout (per design §8.5; PR-A composition-verification instance #4; substance pinned by tests/cli-compressor-timeout.test.js + tests/spawn-smoke-kill-chain.test.js). The function signature `timeoutMs` parameter (line 162) is the caller-supplied bound; the setTimeout below (search "Timeout timer") fires the kill chain.
|
|
213
|
+
const child = this._spawn(this._bin, args, {
|
|
214
|
+
cwd: tmpdir(), // OS-native temp dir; replaces `/tmp` which fails to resolve on Windows
|
|
215
|
+
env,
|
|
216
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
217
|
+
shell: true,
|
|
218
|
+
// Suppress the transient cmd.exe console window on Windows —
|
|
219
|
+
// every shell:true spawn flashes a window otherwise (visible
|
|
220
|
+
// to the user when auto-extract / compress-session fires
|
|
221
|
+
// dozens of times per session). stdio is piped so we still
|
|
222
|
+
// capture the child's output through the regular handlers.
|
|
223
|
+
windowsHide: true,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const cleanupSandbox = () => {
|
|
227
|
+
// Single-use sandbox: the directory and the empty-mcp.json file
|
|
228
|
+
// inside it are created per-call; nothing else references them
|
|
229
|
+
// after the subprocess dies. On the timeout path,
|
|
230
|
+
// `terminateSubprocess` runs in the background AFTER we call
|
|
231
|
+
// `rmSync` here — if the dying subprocess is mid-read of
|
|
232
|
+
// mcpConfigPath when SIGTERM hits, Windows can emit a benign
|
|
233
|
+
// EBUSY which is swallowed by the catch. The ordering
|
|
234
|
+
// (rm-then-kill) is intentional: the kill chain only touches
|
|
235
|
+
// the child PID, never the sandbox path.
|
|
236
|
+
try {
|
|
237
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
238
|
+
} catch {
|
|
239
|
+
// Best-effort; OS cleans tmpdir eventually.
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return await new Promise((resolve, reject) => {
|
|
244
|
+
let stdout = '';
|
|
245
|
+
let stderr = '';
|
|
246
|
+
let settled = false;
|
|
247
|
+
// Timeout timer (set up below if caller supplied timeoutMs).
|
|
248
|
+
// Cleared on close/error so a child that exits cleanly within
|
|
249
|
+
// the window doesn't trigger the kill chain.
|
|
250
|
+
let timeoutTimer = null;
|
|
251
|
+
|
|
252
|
+
const settleReject = (err) => {
|
|
253
|
+
if (settled) return;
|
|
254
|
+
settled = true;
|
|
255
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
256
|
+
cleanupSandbox();
|
|
257
|
+
reject(err);
|
|
258
|
+
};
|
|
259
|
+
const settleResolve = (value) => {
|
|
260
|
+
if (settled) return;
|
|
261
|
+
settled = true;
|
|
262
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
263
|
+
cleanupSandbox();
|
|
264
|
+
resolve(value);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
child.stdout.on('data', (chunk) => {
|
|
268
|
+
stdout += chunk.toString('utf8');
|
|
269
|
+
});
|
|
270
|
+
child.stderr.on('data', (chunk) => {
|
|
271
|
+
stderr += chunk.toString('utf8');
|
|
272
|
+
});
|
|
273
|
+
child.on('error', (err) => {
|
|
274
|
+
settleReject(err);
|
|
275
|
+
});
|
|
276
|
+
child.on('close', (code) => {
|
|
277
|
+
if (settled) return; // timeout already fired
|
|
278
|
+
if (code !== 0) {
|
|
279
|
+
settleReject(
|
|
280
|
+
new Error(
|
|
281
|
+
`HaikuViaAnthropicApi: claude --print exit ${code}: ${stderr.trim() || '(no stderr)'}`,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const outputText = stdout.trim();
|
|
287
|
+
// Honor maxOutputBytes by truncating defensively — Haiku is
|
|
288
|
+
// instructed in the prompt to stay under the cap, but a
|
|
289
|
+
// misbehaved response shouldn't blow downstream consumers.
|
|
290
|
+
const trimmed =
|
|
291
|
+
typeof maxOutputBytes === 'number' && Buffer.byteLength(outputText, 'utf8') > maxOutputBytes
|
|
292
|
+
? outputText.slice(0, maxOutputBytes)
|
|
293
|
+
: outputText;
|
|
294
|
+
const preservedIds = preserveCitationIds ? extractIds(trimmed) : [];
|
|
295
|
+
settleResolve({
|
|
296
|
+
outputText: trimmed,
|
|
297
|
+
inputTokens: Math.ceil(Buffer.byteLength(promptBody, 'utf8') / BYTES_PER_TOKEN_ESTIMATE),
|
|
298
|
+
outputTokens: Math.ceil(Buffer.byteLength(trimmed, 'utf8') / BYTES_PER_TOKEN_ESTIMATE),
|
|
299
|
+
costUSD: this.estimatedCostPerCall(Buffer.byteLength(promptBody, 'utf8')),
|
|
300
|
+
preservedIds,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Optional timeout. Default (no timeoutMs supplied) preserves
|
|
305
|
+
// prior behavior — wait forever for the subprocess. Callers
|
|
306
|
+
// SHOULD pass timeoutMs in production paths per design §8.5
|
|
307
|
+
// (auto-extract 25_000, compress-session 50_000); the no-
|
|
308
|
+
// timeout default exists only for backwards compatibility with
|
|
309
|
+
// existing test fixtures that don't expect a timer.
|
|
310
|
+
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
|
311
|
+
timeoutTimer = setTimeout(() => {
|
|
312
|
+
if (settled) return;
|
|
313
|
+
// Fire the kill chain. Don't await it — settle the Promise
|
|
314
|
+
// immediately with the timeout error so the caller doesn't
|
|
315
|
+
// also have to wait the kill-grace window. The kill chain
|
|
316
|
+
// runs in the background to clean up the OS-level subprocess
|
|
317
|
+
// (terminateSubprocess returns a Promise we ignore here; the
|
|
318
|
+
// sandbox cleanup happens in settleReject).
|
|
319
|
+
terminateSubprocess(child, { killGraceMs: killGraceMs ?? 2000 }).catch(() => {
|
|
320
|
+
// Best-effort; can't do anything further
|
|
321
|
+
});
|
|
322
|
+
settleReject(
|
|
323
|
+
new HaikuTimeoutError(
|
|
324
|
+
`HaikuViaAnthropicApi: claude --print did not return within ${timeoutMs}ms`,
|
|
325
|
+
{ timeoutMs },
|
|
326
|
+
),
|
|
327
|
+
);
|
|
328
|
+
}, timeoutMs);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Send the prompt body via stdin and close.
|
|
332
|
+
child.stdin.write(promptBody);
|
|
333
|
+
child.stdin.end();
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function extractIds(text) {
|
|
339
|
+
// Fixed 8-char IDs per design §3.1. Previously `{6,8}` for slop
|
|
340
|
+
// tolerance, but the kit only emits 8-char IDs; the {6,8} range
|
|
341
|
+
// was a documented inconsistency. Tightened per PR-21 review.
|
|
342
|
+
const re = /[ULP]-[A-Za-z0-9]{8}/g;
|
|
343
|
+
const set = new Set();
|
|
344
|
+
let m;
|
|
345
|
+
while ((m = re.exec(text)) !== null) set.add(m[0]);
|
|
346
|
+
return [...set];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Test-only: deterministic stub for unit tests. Records every compress
|
|
350
|
+
// call on `this.calls` so downstream tests can spy on what was sent.
|
|
351
|
+
export class MockHaikuBackend extends CompressorBackend {
|
|
352
|
+
constructor({ responses = [], throwError = null, model = 'mock-haiku' } = {}) {
|
|
353
|
+
super();
|
|
354
|
+
this._responses = [...responses];
|
|
355
|
+
this._throw = throwError;
|
|
356
|
+
this._model = model;
|
|
357
|
+
this.calls = [];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
modelId() {
|
|
361
|
+
return this._model;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
estimatedCostPerCall(inputBytes) {
|
|
365
|
+
return inputBytes * 1e-6; // arbitrary; tests don't usually assert on this
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async compress(opts) {
|
|
369
|
+
this.calls.push(opts);
|
|
370
|
+
if (this._throw) throw this._throw;
|
|
371
|
+
if (this._responses.length === 0) {
|
|
372
|
+
throw new Error('MockHaikuBackend: no more canned responses');
|
|
373
|
+
}
|
|
374
|
+
return this._responses.shift();
|
|
375
|
+
}
|
|
376
|
+
}
|