@lh8ppl/claude-memory-kit 0.3.3 → 0.3.4
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/package.json +1 -1
- package/src/claude-md.mjs +7 -2
- package/src/compress-retry.mjs +155 -0
- package/src/compress-session.mjs +29 -7
- package/src/compressor.mjs +19 -1
- package/src/daily-distill.mjs +22 -7
- package/src/doctor.mjs +23 -2
- package/src/lazy-compress.mjs +5 -0
- package/src/version-drift.mjs +72 -0
- package/src/weekly-curate.mjs +20 -7
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
- **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows. The session-buffer rollup self-heals at session start too, so memory stays bounded even if you never cleanly close the window.
|
|
21
21
|
- **Don't start empty — import the rules you already own** — `cmk import-claude-md` parses an existing `CLAUDE.md` / `.cursorrules` / `AGENTS.md` into typed, searchable facts through the same safe write path (secret screening, sanitization, dedup), with provenance back to source file + line. `--dry-run` previews first.
|
|
22
22
|
- **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
|
|
23
|
-
- **
|
|
23
|
+
- **9 health checks** — `cmk doctor` validates hook wiring, distill freshness, transcript firing, INDEX consistency, cron registration, native-memory coexistence, stale locks, native-binding health (npm 12 readiness), and version drift (a project scaffold behind your installed `cmk` after an update) — each failure with a repair command.
|
|
24
24
|
|
|
25
25
|
## Install — pick ONE route
|
|
26
26
|
|
|
@@ -62,7 +62,7 @@ Most-used commands (full list via `cmk --help`):
|
|
|
62
62
|
| Command | Purpose |
|
|
63
63
|
| --- | --- |
|
|
64
64
|
| `cmk install` | Scaffold `context/` + the `memory-write`/`memory-search` skills + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
|
|
65
|
-
| `cmk doctor` | Run HC-1..HC-
|
|
65
|
+
| `cmk doctor` | Run HC-1..HC-9 health checks, surface repair commands |
|
|
66
66
|
| `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
|
|
67
67
|
| `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts\|decisions]` | Search memory — by meaning with the embedder (hybrid default after `--with-semantic`); `--scope transcripts` = the raw session record; `--scope decisions` = the decision journal (history / "what did we reject") |
|
|
68
68
|
| `cmk get <id…>` / `cmk timeline <id>` / `cmk cite <id>` / `cmk recent-activity` | Read the index back — full fact bodies + provenance, sequential context around an observation, a canonical citation link, recent changes (the CLI side of the `mk_*` MCP read tools) |
|
package/package.json
CHANGED
package/src/claude-md.mjs
CHANGED
|
@@ -64,7 +64,10 @@ function buildBlock(content, version) {
|
|
|
64
64
|
* gracefully from a corrupted block (e.g. the user accidentally
|
|
65
65
|
* deleted the end marker by hand).
|
|
66
66
|
*/
|
|
67
|
-
|
|
67
|
+
// Exported (Task 162) for version-drift.mjs (HC-9) — reads the managed-block
|
|
68
|
+
// version marker without re-implementing the parser. Public contract: returns
|
|
69
|
+
// `{version, corrupted, ...}` or null.
|
|
70
|
+
export function findManagedBlock(text) {
|
|
68
71
|
const startMatch = text.match(MARKER_START_RE);
|
|
69
72
|
if (!startMatch) return null;
|
|
70
73
|
|
|
@@ -107,7 +110,9 @@ function parseVersion(v) {
|
|
|
107
110
|
* compareVersions('1.0.0', '1.0.0') === 0
|
|
108
111
|
* compareVersions('2.0.0', '1.9.9') === 1
|
|
109
112
|
*/
|
|
110
|
-
|
|
113
|
+
// Exported (Task 162) for version-drift.mjs (HC-9). Public contract: -1/0/1,
|
|
114
|
+
// strips a `-prerelease` suffix before comparing.
|
|
115
|
+
export function compareVersions(a, b) {
|
|
111
116
|
const av = parseVersion(a);
|
|
112
117
|
const bv = parseVersion(b);
|
|
113
118
|
for (let i = 0; i < 3; i++) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Bounded, transient-only retry for the Haiku compress call (Task 161 / D-175).
|
|
2
|
+
//
|
|
3
|
+
// WHY this exists: the v0.3.3 cut-gate surfaced `haiku_timeout` / `compress_failed`
|
|
4
|
+
// failures on the compression path. Measurement (D-174) proved the failure is
|
|
5
|
+
// ENVIRONMENTAL/TRANSIENT, not input-size-driven — the kit's own compress.log shows
|
|
6
|
+
// the largest SUCCESS (470 KB) bigger than the largest timeout (334 KB), and a 9 KB
|
|
7
|
+
// input timed out. So the fix is a RETRY (a re-call usually succeeds), not an input
|
|
8
|
+
// cap. The kit inherited the no-retry shape from claude-remember (its precedent —
|
|
9
|
+
// which doesn't retry either); the rest of the field does.
|
|
10
|
+
//
|
|
11
|
+
// The SHAPE is grounded in a 9-system code read
|
|
12
|
+
// (docs/research/2026-06-19-llm-call-retry-patterns-cross-system.md), which converges
|
|
13
|
+
// unanimously on: bounded attempts, exponential backoff, retry ONLY the transient
|
|
14
|
+
// class keyed on the error TYPE, NEVER the deterministic class, reraise after
|
|
15
|
+
// exhaustion. graphiti's `is_server_or_retry_error` predicate + Letta's
|
|
16
|
+
// ValueError(retry)-vs-RuntimeError(don't) split are the model.
|
|
17
|
+
//
|
|
18
|
+
// COMPOSITION (design §8.5 / D-42): the SessionEnd-hook `compressSession` runs under
|
|
19
|
+
// a 60s ceiling CONCURRENT with autoPersona — a 50s attempt + a 50s retry = 100s
|
|
20
|
+
// blows the ceiling. So callers under the ceiling pass `maxAttempts: 1` (no retry —
|
|
21
|
+
// they delegate the retry to the ceiling-free lazy path via the existing
|
|
22
|
+
// restore-on-failure, D-79); only the ceiling-free paths (daily-distill /
|
|
23
|
+
// weekly-curate / lazy compress) pass `maxAttempts: 2`.
|
|
24
|
+
//
|
|
25
|
+
// COOLDOWN INTERACTION (skill-review I-1): the 120s Haiku cooldown marker is touched
|
|
26
|
+
// on SUCCESS only, and the callers gate `isCooldownActive` ONCE before the retry loop.
|
|
27
|
+
// A retry WIDENS the existing "no marker until success" window (~50s → ~100s), so a
|
|
28
|
+
// second hook firing mid-retry could pass the gate and start its own compress. This
|
|
29
|
+
// is NOT a new bug class — the window pre-exists the retry — and the D-79 claim-rename
|
|
30
|
+
// mutex (renameSync of now.md is the real lock) still prevents any corruption: only
|
|
31
|
+
// one roll wins the rename; the other reads an empty buffer and skips. The retry only
|
|
32
|
+
// makes a pre-existing benign window ~2× wider; no marker change is warranted.
|
|
33
|
+
//
|
|
34
|
+
// NO JITTER (skill-review M-2): the field (graphiti) jitters its backoff
|
|
35
|
+
// (wait_random_exponential) to avoid thundering-herd across many concurrent clients.
|
|
36
|
+
// The kit's compress is a single low-concurrency local process (one detached child at
|
|
37
|
+
// a time, gated by the cooldown), so there is no herd to avoid — a plain exponential
|
|
38
|
+
// backoff is sufficient and keeps the timing deterministic for tests.
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Classify a compress() rejection as transient (worth a retry) or deterministic
|
|
42
|
+
* (a re-call re-fails identically — don't waste the attempt or the budget).
|
|
43
|
+
*
|
|
44
|
+
* Transient (retry):
|
|
45
|
+
* - HaikuTimeoutError (`category: 'haiku_timeout'`) — `claude --print` was slow;
|
|
46
|
+
* the D-174 environmental case. A re-call usually succeeds.
|
|
47
|
+
* - HaikuFailedError (`category: 'haiku_failed'`) whose stderr looks like a
|
|
48
|
+
* transient server/overload/rate-limit blip (the field's 5xx/429/overloaded
|
|
49
|
+
* class), classified from the `exit_code`/`stderr` that 161.6a now captures.
|
|
50
|
+
*
|
|
51
|
+
* Deterministic (do NOT retry):
|
|
52
|
+
* - A spawn error (`code: 'ENOENT'` etc.) — the binary isn't there; re-spawning
|
|
53
|
+
* re-fails.
|
|
54
|
+
* - A HaikuFailedError whose stderr is a known-deterministic class (auth /
|
|
55
|
+
* invalid-key / policy / bad-request) — retrying always re-fails (graphiti's
|
|
56
|
+
* explicit "retrying policy-violating content will always fail").
|
|
57
|
+
* - Anything unrecognized — default to NOT retryable (conservative: an unknown
|
|
58
|
+
* failure is more likely a real bug than a blip, and a wasted retry costs the
|
|
59
|
+
* hook budget).
|
|
60
|
+
*
|
|
61
|
+
* @param {unknown} err
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function isRetryableCompressError(err) {
|
|
65
|
+
if (!err || typeof err !== 'object') return false;
|
|
66
|
+
|
|
67
|
+
// Timeout = the transient/environmental case (D-174). Always retryable.
|
|
68
|
+
if (err.category === 'haiku_timeout') return true;
|
|
69
|
+
|
|
70
|
+
// Spawn-level failure (ENOENT / EACCES / EINVAL) — the binary/permissions are
|
|
71
|
+
// wrong; re-spawning re-fails identically. Never retryable.
|
|
72
|
+
if (typeof err.code === 'string' && /^E[A-Z]+$/.test(err.code)) return false;
|
|
73
|
+
|
|
74
|
+
// Non-zero exit — conditional on WHY (the exit_code/stderr 161.6a captures).
|
|
75
|
+
if (err.category === 'haiku_failed') {
|
|
76
|
+
const stderr = String(err.stderr ?? '').toLowerCase();
|
|
77
|
+
// Known-DETERMINISTIC classes: a re-call re-fails. Never retry these.
|
|
78
|
+
// (Skill-review I-2: `not found` was DROPPED — it appears in transient
|
|
79
|
+
// contexts too, e.g. a transient "host not found" / "upstream not found,
|
|
80
|
+
// retrying"; a deterministic 404 from `claude --print` is unlikely, and the
|
|
81
|
+
// conservative default below already catches a genuine unknown deterministic
|
|
82
|
+
// failure. Keeping only HIGH-CONFIDENCE deterministic markers.)
|
|
83
|
+
if (
|
|
84
|
+
/auth|invalid[_ -]?(api[_ -]?)?key|unauthor|forbidden|permission|policy|invalid[_ -]?request|bad[_ -]?request/.test(
|
|
85
|
+
stderr,
|
|
86
|
+
)
|
|
87
|
+
) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
// Known-TRANSIENT classes: server/overload/rate blips recover on a re-call.
|
|
91
|
+
if (/overload|rate[_ -]?limit|429|5\d\d|timeout|timed[_ -]?out|temporar|unavailable|connection|network|reset/.test(stderr)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
// Unknown non-zero exit → conservative: do NOT retry (treat as deterministic).
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Call `backend.compress(opts)` with a bounded, transient-only retry.
|
|
103
|
+
*
|
|
104
|
+
* @param {{compress: (opts: object) => Promise<any>}} backend
|
|
105
|
+
* @param {object} opts — passed verbatim to backend.compress on every attempt.
|
|
106
|
+
* @param {object} [config]
|
|
107
|
+
* @param {number} [config.maxAttempts=2] — TOTAL attempts (1 = no retry; the ceiling-bound contract). Field range is 2–4; the kit uses ≤2 (one retry) to fit the budget.
|
|
108
|
+
* @param {number} [config.baseBackoffMs=600] — exponential backoff base: wait `baseBackoffMs * 2**(attempt-1)` before attempt N+1. 0 disables the wait (tests).
|
|
109
|
+
* @param {(err: unknown) => boolean} [config.isRetryable=isRetryableCompressError]
|
|
110
|
+
* @param {(ms: number) => Promise<void>} [config.sleep] — injectable for tests.
|
|
111
|
+
* @param {(info: {attempt: number, error: unknown}) => void} [config.onRetry] — fired once
|
|
112
|
+
* per retry (Task 161.12 observability), BEFORE the backoff, with the FAILED attempt
|
|
113
|
+
* number + the (transient) error. Callers use it to record a `retries` count on their
|
|
114
|
+
* compress.log entry so a frequent-retry rate (the degrading-environment signal D-174
|
|
115
|
+
* is about) is visible. Not fired on a first-try success or a non-retryable failure.
|
|
116
|
+
* @returns {Promise<any>} the backend.compress result; reraises the last error after exhaustion.
|
|
117
|
+
*/
|
|
118
|
+
export async function compressWithRetry(
|
|
119
|
+
backend,
|
|
120
|
+
opts,
|
|
121
|
+
{
|
|
122
|
+
maxAttempts = 2,
|
|
123
|
+
baseBackoffMs = 600,
|
|
124
|
+
isRetryable = isRetryableCompressError,
|
|
125
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
126
|
+
onRetry,
|
|
127
|
+
} = {},
|
|
128
|
+
) {
|
|
129
|
+
const attempts = Math.max(1, maxAttempts);
|
|
130
|
+
let lastErr;
|
|
131
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
return await backend.compress(opts);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
lastErr = err;
|
|
136
|
+
// Stop immediately if this is the last attempt OR the error isn't transient.
|
|
137
|
+
if (attempt >= attempts || !isRetryable(err)) {
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
// We're going to retry — surface it for observability (161.12), before the wait.
|
|
141
|
+
if (typeof onRetry === 'function') {
|
|
142
|
+
try {
|
|
143
|
+
onRetry({ attempt, error: err });
|
|
144
|
+
} catch {
|
|
145
|
+
// onRetry is best-effort instrumentation — never let it break the retry.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Exponential backoff before the next attempt (skip the wait when base is 0).
|
|
149
|
+
const delay = baseBackoffMs > 0 ? baseBackoffMs * 2 ** (attempt - 1) : 0;
|
|
150
|
+
if (delay > 0) await sleep(delay);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Unreachable (the loop either returns or throws), but satisfies control-flow analysis.
|
|
154
|
+
throw lastErr;
|
|
155
|
+
}
|
package/src/compress-session.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { join, dirname } from 'node:path';
|
|
|
37
37
|
import { nowIso } from './audit-log.mjs';
|
|
38
38
|
import { ERROR_CATEGORIES } from './result-shapes.mjs';
|
|
39
39
|
import { HaikuTimeoutError } from './compressor.mjs';
|
|
40
|
+
import { compressWithRetry } from './compress-retry.mjs';
|
|
40
41
|
import {
|
|
41
42
|
DEFAULT_COOLDOWN_MS,
|
|
42
43
|
isCooldownActive,
|
|
@@ -225,6 +226,12 @@ export async function compressSession({
|
|
|
225
226
|
now,
|
|
226
227
|
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
227
228
|
maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
|
|
229
|
+
// Task 161 / D-175: retry policy. DEFAULT 1 = NO retry — the SessionEnd-hook
|
|
230
|
+
// contract: this fn runs under the 60s ceiling CONCURRENT with autoPersona, where
|
|
231
|
+
// a 50s attempt + a 50s retry = 100s blows the ceiling. The ceiling-free LAZY
|
|
232
|
+
// caller (runLazyCompress) passes maxAttempts:2 to opt into one retry; the hook
|
|
233
|
+
// keeps its restore-on-failure (D-79) and delegates the retry to that lazy path.
|
|
234
|
+
maxAttempts = 1,
|
|
228
235
|
} = {}) {
|
|
229
236
|
const ts = now ?? nowIso();
|
|
230
237
|
const date = dateFromIso(ts);
|
|
@@ -325,14 +332,21 @@ export async function compressSession({
|
|
|
325
332
|
// restoreRolling call, so the buffer is never stranded in the rolling file.
|
|
326
333
|
// See design §8.5 for the composition rationale.
|
|
327
334
|
let result;
|
|
335
|
+
let retries = 0; // Task 161.12: count retries (only the lazy maxAttempts:2 path can retry).
|
|
328
336
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
337
|
+
// maxAttempts default 1 (hook contract: no retry); the lazy caller passes 2.
|
|
338
|
+
// compressWithRetry is a no-op wrapper at maxAttempts:1 (single attempt, reraise).
|
|
339
|
+
result = await compressWithRetry(
|
|
340
|
+
backend,
|
|
341
|
+
{
|
|
342
|
+
input: wrapBufferForPrompt(buffer),
|
|
343
|
+
instructions,
|
|
344
|
+
preserveCitationIds: true,
|
|
345
|
+
maxOutputBytes,
|
|
346
|
+
timeoutMs: 50_000,
|
|
347
|
+
},
|
|
348
|
+
{ maxAttempts, onRetry: () => { retries += 1; } },
|
|
349
|
+
);
|
|
336
350
|
} catch (err) {
|
|
337
351
|
// Distinguish HAIKU_TIMEOUT (slow Anthropic) from COMPRESS_FAILED
|
|
338
352
|
// (non-zero subprocess exit / spawn ENOENT / etc). Analytics
|
|
@@ -357,6 +371,13 @@ export async function compressSession({
|
|
|
357
371
|
duration_ms,
|
|
358
372
|
success: false,
|
|
359
373
|
error_category: errorCategory,
|
|
374
|
+
// Task 161 (D-173 observability): capture the STRUCTURED failure reason
|
|
375
|
+
// (subprocess exit code + stderr) so a `compress_failed` is diagnosable.
|
|
376
|
+
// Pre-161 the log kept only error_category — the WHY was discarded, which
|
|
377
|
+
// is why the kit's own 329-byte compress_failed could not be explained.
|
|
378
|
+
...(err?.exitCode != null ? { exit_code: err.exitCode } : {}),
|
|
379
|
+
...(err?.stderr ? { error_detail: String(err.stderr).slice(0, 500) } : {}),
|
|
380
|
+
...(retries > 0 ? { retries } : {}), // 161.12: failed AFTER retrying (lazy path)
|
|
360
381
|
};
|
|
361
382
|
writeCompressLogEntry({ projectRoot, date, entry });
|
|
362
383
|
return {
|
|
@@ -397,6 +418,7 @@ export async function compressSession({
|
|
|
397
418
|
cost_usd: result?.costUSD ?? 0,
|
|
398
419
|
duration_ms,
|
|
399
420
|
success: true,
|
|
421
|
+
...(retries > 0 ? { retries } : {}), // 161.12: succeeded after a transient retry (lazy path)
|
|
400
422
|
};
|
|
401
423
|
writeCompressLogEntry({ projectRoot, date, entry });
|
|
402
424
|
|
package/src/compressor.mjs
CHANGED
|
@@ -94,6 +94,23 @@ export class HaikuTimeoutError extends Error {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Non-zero subprocess exit (the `compress_failed` category). Carries the
|
|
98
|
+
// STRUCTURED exit code + captured stderr so callers can write the real
|
|
99
|
+
// failure reason into compress.log — pre-161 this was a plain Error with
|
|
100
|
+
// the detail buried in `.message`, and the log kept only `error_category`,
|
|
101
|
+
// making a `compress_failed` undiagnosable (the 329-byte failure in the
|
|
102
|
+
// kit's own log that the D-173 investigation could not explain). Mirrors
|
|
103
|
+
// HaikuTimeoutError so the two failure modes carry parallel diagnostics.
|
|
104
|
+
export class HaikuFailedError extends Error {
|
|
105
|
+
constructor(message, { exitCode, stderr }) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.name = 'HaikuFailedError';
|
|
108
|
+
this.category = 'haiku_failed';
|
|
109
|
+
this.exitCode = exitCode ?? null;
|
|
110
|
+
this.stderr = stderr ?? '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
// SIGTERM → grace window → SIGKILL escalation. Exported so the kill
|
|
98
115
|
// chain itself is independently testable against real OS processes
|
|
99
116
|
// (see tests/spawn-smoke-kill-chain.test.js) — the production code
|
|
@@ -292,8 +309,9 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
|
|
|
292
309
|
if (settled) return; // timeout already fired
|
|
293
310
|
if (code !== 0) {
|
|
294
311
|
settleReject(
|
|
295
|
-
new
|
|
312
|
+
new HaikuFailedError(
|
|
296
313
|
`HaikuViaAnthropicApi: claude --print exit ${code}: ${stderr.trim() || '(no stderr)'}`,
|
|
314
|
+
{ exitCode: code, stderr: stderr.trim() },
|
|
297
315
|
),
|
|
298
316
|
);
|
|
299
317
|
return;
|
package/src/daily-distill.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { join } from 'node:path';
|
|
|
28
28
|
import { nowIso } from './audit-log.mjs';
|
|
29
29
|
import { ERROR_CATEGORIES } from './result-shapes.mjs';
|
|
30
30
|
import { HaikuTimeoutError } from './compressor.mjs';
|
|
31
|
+
import { compressWithRetry } from './compress-retry.mjs';
|
|
31
32
|
import {
|
|
32
33
|
DEFAULT_COOLDOWN_MS,
|
|
33
34
|
isCooldownActive,
|
|
@@ -195,14 +196,23 @@ export async function dailyDistill({
|
|
|
195
196
|
const instructions = buildDistillInstructions(maxOutputBytes);
|
|
196
197
|
|
|
197
198
|
let result;
|
|
199
|
+
let retries = 0; // Task 161.12: count retries so the log shows the retry RATE.
|
|
198
200
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
201
|
+
// Task 161 / D-175: ceiling-free path (cron/detached child, NO 60s hook ceiling)
|
|
202
|
+
// → bounded transient-only retry. A re-call recovers the D-174 environmental
|
|
203
|
+
// timeout / transient non-zero exit; a deterministic failure (ENOENT/auth) fails
|
|
204
|
+
// fast (isRetryableCompressError). maxAttempts:2 = one retry.
|
|
205
|
+
result = await compressWithRetry(
|
|
206
|
+
backend,
|
|
207
|
+
{
|
|
208
|
+
input: buffer,
|
|
209
|
+
instructions,
|
|
210
|
+
preserveCitationIds: true,
|
|
211
|
+
maxOutputBytes,
|
|
212
|
+
timeoutMs: 50_000,
|
|
213
|
+
},
|
|
214
|
+
{ maxAttempts: 2, onRetry: () => { retries += 1; } },
|
|
215
|
+
);
|
|
206
216
|
touchCooldownMarker({ projectRoot, now: ts });
|
|
207
217
|
} catch (err) {
|
|
208
218
|
touchCooldownMarker({ projectRoot, now: ts });
|
|
@@ -217,6 +227,10 @@ export async function dailyDistill({
|
|
|
217
227
|
ts, scope: 'daily-distill', input_bytes, output_bytes: 0,
|
|
218
228
|
model_id: typeof backend.modelId === 'function' ? backend.modelId() : null,
|
|
219
229
|
cost_usd: 0, duration_ms, success: false, error_category: errorCategory,
|
|
230
|
+
// Task 161 (D-173 observability): structured failure reason — see compress-session.mjs.
|
|
231
|
+
...(err?.exitCode != null ? { exit_code: err.exitCode } : {}),
|
|
232
|
+
...(err?.stderr ? { error_detail: String(err.stderr).slice(0, 500) } : {}),
|
|
233
|
+
...(retries > 0 ? { retries } : {}), // 161.12: failed AFTER retrying
|
|
220
234
|
},
|
|
221
235
|
});
|
|
222
236
|
return {
|
|
@@ -246,6 +260,7 @@ export async function dailyDistill({
|
|
|
246
260
|
(typeof backend.modelId === 'function' ? backend.modelId() : null),
|
|
247
261
|
cost_usd: result?.costUSD ?? 0,
|
|
248
262
|
duration_ms, success: true, source_days: files.length,
|
|
263
|
+
...(retries > 0 ? { retries } : {}), // 161.12: succeeded after a transient retry
|
|
249
264
|
},
|
|
250
265
|
});
|
|
251
266
|
return {
|
package/src/doctor.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `cmk doctor` — health checks HC-1..HC-
|
|
1
|
+
// `cmk doctor` — health checks HC-1..HC-9 (Task 37, T-031; memsearch HC-1/HC-7 removed in Task 120; HC-8 native bindings added in Task 141a; HC-9 version-drift/update-path added in Task 162 / D-176).
|
|
2
2
|
//
|
|
3
3
|
// Public boundary:
|
|
4
4
|
// async runDoctor({projectRoot, userDir, now, promptUser?, ...overrides})
|
|
@@ -46,6 +46,8 @@ import { cronSentinelPath } from './lazy-compress.mjs';
|
|
|
46
46
|
import { getNativeAutoMemoryState } from './native-memory.mjs';
|
|
47
47
|
import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
|
|
48
48
|
import { resolveDefaultSearchMode } from './semantic-backend.mjs';
|
|
49
|
+
import { checkVersionDrift } from './version-drift.mjs';
|
|
50
|
+
import { getKitVersion } from './install.mjs';
|
|
49
51
|
|
|
50
52
|
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
|
|
51
53
|
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
|
@@ -541,10 +543,27 @@ async function hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBinding
|
|
|
541
543
|
* parameter lands at that PR alongside the actual consent flow — not
|
|
542
544
|
* pre-empted in v0.1.0 to avoid the "forward-compat hooks rot" pattern.
|
|
543
545
|
*/
|
|
546
|
+
// --- HC-9: project scaffold version matches the installed cmk (Task 162 / D-176) ---
|
|
547
|
+
// After `npm i -g @latest`, a project's version-stamped scaffold stays at the OLD
|
|
548
|
+
// version until `cmk install` re-runs there (the easily-forgotten per-project step).
|
|
549
|
+
// HC-9 reads the project's CLAUDE.md managed-block version + the installed binary
|
|
550
|
+
// version and tells the user to re-run `cmk install` when the project is behind.
|
|
551
|
+
function hc9VersionDrift({ projectRoot, kitVersion }) {
|
|
552
|
+
const claudeMdPath = join(projectRoot, 'CLAUDE.md');
|
|
553
|
+
let claudeMdText = null;
|
|
554
|
+
try {
|
|
555
|
+
if (existsSync(claudeMdPath)) claudeMdText = readFileSync(claudeMdPath, 'utf8');
|
|
556
|
+
} catch {
|
|
557
|
+
claudeMdText = null; // unreadable → skip (treated as not-installed)
|
|
558
|
+
}
|
|
559
|
+
return checkVersionDrift({ claudeMdText, kitVersion });
|
|
560
|
+
}
|
|
561
|
+
|
|
544
562
|
export async function runDoctor({
|
|
545
563
|
projectRoot,
|
|
546
564
|
userDir,
|
|
547
565
|
now,
|
|
566
|
+
kitVersion,
|
|
548
567
|
kitBindingProbe,
|
|
549
568
|
embedderBindingProbe,
|
|
550
569
|
} = {}) {
|
|
@@ -569,10 +588,12 @@ export async function runDoctor({
|
|
|
569
588
|
const c6 = hc6NativeAutoMemory({ projectRoot, now: ts });
|
|
570
589
|
const c7 = hc7StaleLocks({ projectRoot, userDir: resolvedUserDir });
|
|
571
590
|
const c8 = await hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe });
|
|
591
|
+
// HC-9: kitVersion injectable for tests; defaults to the installed binary's version.
|
|
592
|
+
const c9 = hc9VersionDrift({ projectRoot, kitVersion: kitVersion ?? getKitVersion() });
|
|
572
593
|
|
|
573
594
|
return {
|
|
574
595
|
action: 'completed',
|
|
575
|
-
checks: [c1, c2, c3, c4, c5, c6, c7, c8],
|
|
596
|
+
checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9],
|
|
576
597
|
duration_ms: Date.now() - t0,
|
|
577
598
|
};
|
|
578
599
|
}
|
package/src/lazy-compress.mjs
CHANGED
|
@@ -411,6 +411,11 @@ export async function runLazyCompress({
|
|
|
411
411
|
backend,
|
|
412
412
|
now: ts,
|
|
413
413
|
cooldownMs: 0,
|
|
414
|
+
// Task 161 / D-175: the lazy path is a DETACHED SessionStart child with NO 60s
|
|
415
|
+
// hook ceiling, so it opts into the one retry the hook path can't afford. This
|
|
416
|
+
// is where the SessionEnd-hook's failed roll (which restored now.md, D-79) gets
|
|
417
|
+
// its real bounded retry.
|
|
418
|
+
maxAttempts: 2,
|
|
414
419
|
});
|
|
415
420
|
} else if (verdict.action === 'stale-weekly') {
|
|
416
421
|
delegatedTo = 'weekly-curate';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// HC-9: version-drift detection (Task 162 / D-176).
|
|
2
|
+
//
|
|
3
|
+
// WHY: after a user updates the global `cmk` (npm i -g @latest), a project's
|
|
4
|
+
// version-stamped scaffold — the CLAUDE.md managed block, the hooks, the skills —
|
|
5
|
+
// stays at the OLD version until `cmk install` re-runs in that project. Updating the
|
|
6
|
+
// npm package ALONE does not touch a project (the per-project re-install is the
|
|
7
|
+
// easily-forgotten step). Pre-162 the kit was silent about it (D-172: no update path).
|
|
8
|
+
// HC-9 makes `cmk doctor` TELL the user the project is behind + the exact command.
|
|
9
|
+
//
|
|
10
|
+
// The project's installed version lives in the CLAUDE.md managed-block start marker
|
|
11
|
+
// (`<!-- claude-memory-kit:start v0.3.3 -->`); the installed binary version is
|
|
12
|
+
// getKitVersion(). Drift = binary NEWER than the project marker → "run cmk install".
|
|
13
|
+
// A project marker NEWER than the binary is a downgrade (older global cli opening a
|
|
14
|
+
// newer-scaffolded project), NOT drift — flag pass, not a false alarm.
|
|
15
|
+
|
|
16
|
+
import { findManagedBlock, compareVersions } from './claude-md.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pure HC-9 check. Injectable inputs (no disk read here) so the logic is unit-tested
|
|
20
|
+
* without a fixture tree; the doctor wiring reads CLAUDE.md + getKitVersion() and
|
|
21
|
+
* passes them in.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} args
|
|
24
|
+
* @param {string|null} args.claudeMdText — the project's CLAUDE.md content, or null if absent.
|
|
25
|
+
* @param {string} args.kitVersion — the installed binary version (getKitVersion()).
|
|
26
|
+
* @returns {{id:'HC-9', name:string, status:'pass'|'fail'|'skip', message:string, recoveryCommand?:string}}
|
|
27
|
+
*/
|
|
28
|
+
export function checkVersionDrift({ claudeMdText, kitVersion } = {}) {
|
|
29
|
+
const id = 'HC-9';
|
|
30
|
+
const name = 'Project scaffold version matches the installed cmk';
|
|
31
|
+
|
|
32
|
+
// No CLAUDE.md, or no managed block → the project isn't kit-installed (or the block
|
|
33
|
+
// was hand-removed). Not a drift signal; skip (HC-1/repair owns the missing-block case).
|
|
34
|
+
if (!claudeMdText) {
|
|
35
|
+
return { id, name, status: 'skip', message: 'no CLAUDE.md found — project not kit-installed' };
|
|
36
|
+
}
|
|
37
|
+
const block = findManagedBlock(claudeMdText);
|
|
38
|
+
if (!block) {
|
|
39
|
+
return { id, name, status: 'skip', message: 'no claude-memory-kit managed block in CLAUDE.md' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `block.version` is the `:start vX` marker value (findManagedBlock recovers it
|
|
43
|
+
// even from a corrupted/orphan-start block — a stale corrupted block still earns
|
|
44
|
+
// the `cmk install` advice, which fixes both). compareVersions strips any
|
|
45
|
+
// `-prerelease` tag, so a `v0.3.4-beta` scaffold reads as `0.3.4` (the kit ships
|
|
46
|
+
// no prereleases today; this is the intended "close enough" behavior).
|
|
47
|
+
const projectVersion = block.version;
|
|
48
|
+
const cmp = compareVersions(kitVersion, projectVersion);
|
|
49
|
+
|
|
50
|
+
if (cmp <= 0) {
|
|
51
|
+
// Binary == project (match) OR binary < project (a downgrade — older cli, newer
|
|
52
|
+
// scaffold). Neither is "re-run install to catch up." Pass.
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
name,
|
|
56
|
+
status: 'pass',
|
|
57
|
+
message:
|
|
58
|
+
cmp === 0
|
|
59
|
+
? `project scaffold (v${projectVersion}) matches the installed cmk (v${kitVersion})`
|
|
60
|
+
: `project scaffold (v${projectVersion}) is newer than the installed cmk (v${kitVersion}) — likely an older global cli; not drift`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Binary NEWER than the project marker → the project is stale. THE drift case.
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
name,
|
|
68
|
+
status: 'fail',
|
|
69
|
+
message: `this project's scaffold is v${projectVersion} but your installed cmk is v${kitVersion} — re-run \`cmk install\` here to refresh the CLAUDE.md block, hooks, and skills (then restart Claude Code)`,
|
|
70
|
+
recoveryCommand: 'cmk install',
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/weekly-curate.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { canonicalize } from '@lh8ppl/cmk-canonicalize';
|
|
|
39
39
|
import { nowIso } from './audit-log.mjs';
|
|
40
40
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
41
41
|
import { HaikuTimeoutError } from './compressor.mjs';
|
|
42
|
+
import { compressWithRetry } from './compress-retry.mjs';
|
|
42
43
|
import {
|
|
43
44
|
DEFAULT_COOLDOWN_MS,
|
|
44
45
|
isCooldownActive,
|
|
@@ -388,14 +389,21 @@ export async function weeklyCurate({
|
|
|
388
389
|
const sourceDates = old.map((f) => f.date);
|
|
389
390
|
|
|
390
391
|
let result;
|
|
392
|
+
let retries = 0; // Task 161.12: count retries so the log shows the retry RATE.
|
|
391
393
|
try {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
394
|
+
// Task 161 / D-175: ceiling-free path (cron/detached child, NO 60s hook ceiling)
|
|
395
|
+
// → bounded transient-only retry (maxAttempts:2 = one retry). See compress-retry.mjs.
|
|
396
|
+
result = await compressWithRetry(
|
|
397
|
+
backend,
|
|
398
|
+
{
|
|
399
|
+
input: buffer,
|
|
400
|
+
instructions,
|
|
401
|
+
preserveCitationIds: true,
|
|
402
|
+
maxOutputBytes: archiveMaxBytes,
|
|
403
|
+
timeoutMs: 50_000,
|
|
404
|
+
},
|
|
405
|
+
{ maxAttempts: 2, onRetry: () => { retries += 1; } },
|
|
406
|
+
);
|
|
399
407
|
touchCooldownMarker({ projectRoot, now: ts });
|
|
400
408
|
} catch (err) {
|
|
401
409
|
touchCooldownMarker({ projectRoot, now: ts });
|
|
@@ -418,6 +426,10 @@ export async function weeklyCurate({
|
|
|
418
426
|
duration_ms,
|
|
419
427
|
success: false,
|
|
420
428
|
error_category: errorCategory,
|
|
429
|
+
// Task 161 (D-173 observability): structured failure reason — see compress-session.mjs.
|
|
430
|
+
...(err?.exitCode != null ? { exit_code: err.exitCode } : {}),
|
|
431
|
+
...(err?.stderr ? { error_detail: String(err.stderr).slice(0, 500) } : {}),
|
|
432
|
+
...(retries > 0 ? { retries } : {}), // 161.12: failed AFTER retrying
|
|
421
433
|
},
|
|
422
434
|
});
|
|
423
435
|
return errorResult({
|
|
@@ -487,6 +499,7 @@ export async function weeklyCurate({
|
|
|
487
499
|
archived_days: old.length,
|
|
488
500
|
current_days: current.length,
|
|
489
501
|
recent_rebuild_action: recentResult?.action ?? 'skipped',
|
|
502
|
+
...(retries > 0 ? { retries } : {}), // 161.12: succeeded after a transient retry
|
|
490
503
|
...(deletionErrors.length > 0 ? { deletion_errors: deletionErrors } : {}),
|
|
491
504
|
},
|
|
492
505
|
});
|