@lh8ppl/claude-memory-kit 0.3.2 → 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 +8 -6
- package/package.json +1 -1
- package/src/auto-extract.mjs +16 -5
- 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/import-claude-md.mjs +7 -2
- package/src/index-rebuild.mjs +91 -3
- package/src/inject-context.mjs +16 -6
- package/src/lazy-compress.mjs +86 -0
- package/src/mcp-server.mjs +9 -1
- package/src/read-core.mjs +65 -3
- package/src/remember-core.mjs +15 -15
- package/src/sanitize.mjs +30 -0
- package/src/search.mjs +119 -4
- package/src/session-end-tasks.mjs +40 -3
- package/src/subcommands.mjs +39 -5
- package/src/version-drift.mjs +72 -0
- package/src/weekly-curate.mjs +20 -7
- package/template/.claude/skills/memory-search/SKILL.md +20 -0
- package/template/CLAUDE.md.template +1 -0
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
|
|
|
@@ -47,11 +47,13 @@ cmk doctor # verify, then restart Claude Code
|
|
|
47
47
|
Inside Claude Code:
|
|
48
48
|
|
|
49
49
|
```text
|
|
50
|
-
/plugin marketplace add LH8PPL/claude-memory-kit
|
|
51
|
-
/plugin install claude-memory-kit
|
|
50
|
+
/plugin marketplace add LH8PPL/claude-memory-kit # add this repo as a plugin source (once per machine)
|
|
51
|
+
/plugin install claude-memory-kit # install the global machinery — hooks + skills (once per machine)
|
|
52
|
+
cd ~/my-project # the project you want memory in — bootstrap scaffolds into the CURRENT dir
|
|
53
|
+
/claude-memory-kit:bootstrap # scaffold this project's context/ memory tree (once per project)
|
|
52
54
|
```
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
The first two commands are **global** (per machine); `bootstrap` is **per project** — run it again (after a `cd`) in each project. The plugin bundles the hooks + the `bootstrap`, `memory-write`, and `memory-search` skills, so it's complete without the npm CLI (add the CLI later only if you want `cmk search` / `cmk doctor` / cron).
|
|
55
57
|
|
|
56
58
|
## CLI
|
|
57
59
|
|
|
@@ -60,9 +62,9 @@ Most-used commands (full list via `cmk --help`):
|
|
|
60
62
|
| Command | Purpose |
|
|
61
63
|
| --- | --- |
|
|
62
64
|
| `cmk install` | Scaffold `context/` + the `memory-write`/`memory-search` skills + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
|
|
63
|
-
| `cmk doctor` | Run HC-1..HC-
|
|
65
|
+
| `cmk doctor` | Run HC-1..HC-9 health checks, surface repair commands |
|
|
64
66
|
| `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
|
|
65
|
-
| `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts]` | Search memory — by meaning with the embedder (hybrid default after `--with-semantic`); `--scope transcripts` = the raw session record |
|
|
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") |
|
|
66
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) |
|
|
67
69
|
| `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
|
|
68
70
|
| `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
|
package/package.json
CHANGED
package/src/auto-extract.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import { hashContent } from './content-hash.mjs';
|
|
|
53
53
|
import { memoryWrite } from './memory-write.mjs';
|
|
54
54
|
import { writeFact } from './write-fact.mjs';
|
|
55
55
|
import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
|
|
56
|
+
import { sanitizeForTitle } from './sanitize.mjs';
|
|
56
57
|
import { HaikuTimeoutError } from './compressor.mjs';
|
|
57
58
|
import { pidIsAlive } from './lock-discipline.mjs';
|
|
58
59
|
import { nowIso } from './audit-log.mjs';
|
|
@@ -638,9 +639,16 @@ function routeMedium({ candidate, projectRoot, ts }) {
|
|
|
638
639
|
// Direct-to-fact-store (NOT the review queue the terse medium-trust path uses):
|
|
639
640
|
// the point of Task 103 is AUTOMATIC native-parity capture — native writes its
|
|
640
641
|
// fact files with no approval step, so parity requires the same. The fact store
|
|
641
|
-
// is searchable-but-not-full-trust-injected, writeFact
|
|
642
|
-
//
|
|
643
|
-
// later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
|
|
642
|
+
// is searchable-but-not-full-trust-injected, writeFact screens the body +
|
|
643
|
+
// frontmatter (Poison_Guard + home-path sanitize + schema + INDEX/reindex), and
|
|
644
|
+
// a later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
|
|
645
|
+
//
|
|
646
|
+
// CAVEAT (F-V0.3.3-2): writeFact does NOT sanitize the SLUG/filename — the slug
|
|
647
|
+
// is `slugifyFact(title)` derived HERE, before writeFact runs. So the title MUST
|
|
648
|
+
// be routed through sanitizeForTitle first, or a home path in Haiku's candidate
|
|
649
|
+
// title (auto-extract runs every turn, no user action) leaks the username into a
|
|
650
|
+
// COMMITTED filename. This was the same bug as cmk remember — the old comment
|
|
651
|
+
// here wrongly assumed "writeFact already sanitizes" the whole write.
|
|
644
652
|
//
|
|
645
653
|
// trust:medium / write_source:auto-extract marks it as a Haiku synthesis
|
|
646
654
|
// (proposal-grade), below the explicit-high tier. The body is built by the SAME
|
|
@@ -652,11 +660,14 @@ function routeRichFact({ candidate, projectRoot, ts }) {
|
|
|
652
660
|
why: candidate.why,
|
|
653
661
|
how: candidate.how,
|
|
654
662
|
});
|
|
663
|
+
// Sanitize the title BEFORE deriving the slug (F-V0.3.3-2) — writeFact won't
|
|
664
|
+
// catch a home path in the slug/filename. One helper, same as cmk remember.
|
|
665
|
+
const title = sanitizeForTitle(candidate.title);
|
|
655
666
|
return writeFact({
|
|
656
667
|
tier: 'P',
|
|
657
668
|
type: candidate.type,
|
|
658
|
-
slug: slugifyFact(
|
|
659
|
-
title
|
|
669
|
+
slug: slugifyFact(title),
|
|
670
|
+
title,
|
|
660
671
|
body,
|
|
661
672
|
writeSource: 'auto-extract',
|
|
662
673
|
trust: 'medium',
|
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/import-claude-md.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
|
32
32
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
33
33
|
import { writeFact } from './write-fact.mjs';
|
|
34
34
|
import { slugifyFact } from './rich-fact.mjs';
|
|
35
|
-
import { sanitizeHomePaths } from './sanitize.mjs';
|
|
35
|
+
import { sanitizeHomePaths, sanitizeForTitle } from './sanitize.mjs';
|
|
36
36
|
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
37
37
|
|
|
38
38
|
const DEFAULT_FILE = 'CLAUDE.md';
|
|
@@ -280,7 +280,12 @@ export async function importClaudeMd({
|
|
|
280
280
|
// absolute --file argument (the D-51 name-privacy class).
|
|
281
281
|
const sourceFileField = sanitizeHomePaths(fileRel);
|
|
282
282
|
for (const p of proposals) {
|
|
283
|
-
|
|
283
|
+
// Sanitize BEFORE deriving the title/slug (F-V0.3.3-2) — p.text is the RAW
|
|
284
|
+
// rule text (the `sanitized` above feeds only the dedup key/id), and the
|
|
285
|
+
// slug becomes the committed filename, which writeFact won't sanitize. A
|
|
286
|
+
// CLAUDE.md rule mentioning C:\Users\you\ would otherwise leak the username
|
|
287
|
+
// into the imported fact's filename. Same one helper as the other slug sites.
|
|
288
|
+
const title = sanitizeForTitle(p.text).split('\n')[0].slice(0, 80);
|
|
284
289
|
let slug = slugifyFact(title);
|
|
285
290
|
if (usedSlugs.has(`${p.type}/${slug}`)) slug = `${slug}-l${p.line}`;
|
|
286
291
|
usedSlugs.add(`${p.type}/${slug}`);
|
package/src/index-rebuild.mjs
CHANGED
|
@@ -295,6 +295,40 @@ function parseSource(source, { projectRoot, userDir }) {
|
|
|
295
295
|
|
|
296
296
|
// --- DB write helpers -------------------------------------------------
|
|
297
297
|
|
|
298
|
+
// Bug 1 (2026-06-16, fact P-UCG4RKNL): the kit dual-writes a fact to BOTH the
|
|
299
|
+
// MEMORY.md scratchpad bullet AND its granular archive file, both carrying the
|
|
300
|
+
// SAME content-addressed id. `observations.id` is a global PRIMARY KEY, so a
|
|
301
|
+
// plain INSERT of the second source's row collided (`UNIQUE constraint failed:
|
|
302
|
+
// observations.id`) and aborted the whole reindex. The fix is id-keyed upsert
|
|
303
|
+
// with deterministic ARCHIVE-BEATS-SCRATCHPAD precedence — validated against
|
|
304
|
+
// three markdown-first analogs that all key replacement on the id, never the
|
|
305
|
+
// file (TencentDB `ON CONFLICT(record_id) DO UPDATE`; basic-memory
|
|
306
|
+
// resolve-permalink precedence + partial unique index; memweave content-hash
|
|
307
|
+
// dedup). See docs/research/2026-06-16-index-uniqueness-id-vs-file-scoped-delete.md.
|
|
308
|
+
//
|
|
309
|
+
// Two precedence-keyed paths, order-INDEPENDENT (the source walk order must not
|
|
310
|
+
// change the surviving row):
|
|
311
|
+
// - fact (granular archive = the canonical Why/How home) → explicit
|
|
312
|
+
// DELETE-by-id then INSERT: always wins, overwriting any scratchpad row for
|
|
313
|
+
// the id.
|
|
314
|
+
// - scratchpad (the hot working-copy bullet) → ON CONFLICT(id) DO NOTHING:
|
|
315
|
+
// inserts only when no row exists yet; never overwrites a fact row.
|
|
316
|
+
// Whichever is walked first, the fact row is the one that survives.
|
|
317
|
+
//
|
|
318
|
+
// FTS5 CORRECTNESS (the self-review catch): the fact path uses an explicit
|
|
319
|
+
// DELETE-by-id, NOT `INSERT OR REPLACE`. `observations_fts` is an
|
|
320
|
+
// external-content FTS5 table whose only safe delete path is the
|
|
321
|
+
// `obs_after_delete` trigger firing the 'delete' SENTINEL with the OLD row's
|
|
322
|
+
// column values (index-db.mjs §4.4.3 comment). `INSERT OR REPLACE` reuses the
|
|
323
|
+
// conflicting row's rowid, so its internal delete+insert leaves the OLD
|
|
324
|
+
// scratchpad body orphaned in the FTS index (it keeps MATCH-ing with no backing
|
|
325
|
+
// row — silent stale-hit corruption). An explicit `DELETE FROM observations
|
|
326
|
+
// WHERE id = ?` fires obs_after_delete cleanly (sentinel removes the old terms),
|
|
327
|
+
// then the plain INSERT fires obs_after_insert. This is the same delete-then-
|
|
328
|
+
// insert pattern every other writer in the kit uses against this table.
|
|
329
|
+
|
|
330
|
+
const DELETE_OBSERVATION_BY_ID_SQL = `DELETE FROM observations WHERE id = ?`;
|
|
331
|
+
|
|
298
332
|
const INSERT_OBSERVATION_SQL = `
|
|
299
333
|
INSERT INTO observations
|
|
300
334
|
(id, tier, source_file, source_line, source_sha1, heading_path, body,
|
|
@@ -304,6 +338,16 @@ VALUES
|
|
|
304
338
|
@write_source, @trust, @created_at, @superseded_by, @deleted_at)
|
|
305
339
|
`;
|
|
306
340
|
|
|
341
|
+
const INSERT_SCRATCHPAD_OBSERVATION_SQL = `
|
|
342
|
+
INSERT INTO observations
|
|
343
|
+
(id, tier, source_file, source_line, source_sha1, heading_path, body,
|
|
344
|
+
write_source, trust, created_at, superseded_by, deleted_at)
|
|
345
|
+
VALUES
|
|
346
|
+
(@id, @tier, @source_file, @source_line, @source_sha1, @heading_path, @body,
|
|
347
|
+
@write_source, @trust, @created_at, @superseded_by, @deleted_at)
|
|
348
|
+
ON CONFLICT(id) DO NOTHING
|
|
349
|
+
`;
|
|
350
|
+
|
|
307
351
|
const UPSERT_FILE_SQL = `
|
|
308
352
|
INSERT INTO files (path, mtime, sha1, indexed_at)
|
|
309
353
|
VALUES (@path, @mtime, @sha1, @indexed_at)
|
|
@@ -322,10 +366,48 @@ const DELETE_OBSERVATIONS_FOR_PATH_SQL = `DELETE FROM observations WHERE source_
|
|
|
322
366
|
*/
|
|
323
367
|
function replaceObservationsForFile(db, { source, observations, mtime, sha1, projectRoot, userDir, now }) {
|
|
324
368
|
const source_file = relativeSource(source.path, { projectRoot, userDir });
|
|
369
|
+
// File-scoped delete clears THIS file's own rows so a re-index of a changed
|
|
370
|
+
// file is idempotent. It only matches rows whose source_file is this path, so
|
|
371
|
+
// a fact's row (source_file = context/memory/*.md) is untouched when the
|
|
372
|
+
// scratchpad (context/MEMORY.md) is re-indexed, and vice versa — the
|
|
373
|
+
// cross-file id collision is handled by the precedence-keyed insert below,
|
|
374
|
+
// NOT by this delete (Bug 1).
|
|
325
375
|
db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(source_file);
|
|
326
|
-
|
|
327
|
-
for
|
|
328
|
-
|
|
376
|
+
// Archive-beats-scratchpad precedence (Bug 1): a fact row wins the id by
|
|
377
|
+
// explicitly deleting any existing row for that id first (firing the FTS
|
|
378
|
+
// 'delete' sentinel cleanly) then inserting; a scratchpad row yields via
|
|
379
|
+
// ON CONFLICT(id) DO NOTHING. Within a FULL pass (reindexFull, or a
|
|
380
|
+
// reindexBoot that re-walks both sources) this is order-independent — the
|
|
381
|
+
// fact row always wins (listObservationSources walks scratchpad-before-facts
|
|
382
|
+
// per tier, but either order lands the same surviving row).
|
|
383
|
+
//
|
|
384
|
+
// INCREMENTAL caveat (skill-review I1): on the mtime-skip boot path / the
|
|
385
|
+
// single-file watcher path, only the CHANGED source is re-processed. If a
|
|
386
|
+
// fact file is removed while its scratchpad twin (same id) is untouched, the
|
|
387
|
+
// orphan-prune drops the fact row and the skipped scratchpad's DO-NOTHING
|
|
388
|
+
// insert never re-fires — so the id momentarily vanishes from search until
|
|
389
|
+
// the scratchpad is next edited (which re-inserts it). `cmk forget` does NOT
|
|
390
|
+
// hit this: it tombstones the fact AND scrubs the scratchpad bullet in the
|
|
391
|
+
// same op (forget.mjs scrubAllScratchpads), so the only window is a manual
|
|
392
|
+
// hand-`rm` of a context/memory/*.md leaving the bullet behind — a rare,
|
|
393
|
+
// self-healing transition, documented + tested rather than resurrected.
|
|
394
|
+
//
|
|
395
|
+
// The DELETE-by-id is UNQUALIFIED (no tier/source_file filter) by design and
|
|
396
|
+
// safe: ids are content-addressed WITH the tier as a prefix (`P-`/`L-`/`U-`),
|
|
397
|
+
// so a P-tier and U-tier fact can never share an id — no cross-tier delete is
|
|
398
|
+
// possible. (Defended by the P/U-same-content tier test below.)
|
|
399
|
+
if (source.kind === 'fact') {
|
|
400
|
+
const deleteById = db.prepare(DELETE_OBSERVATION_BY_ID_SQL);
|
|
401
|
+
const insert = db.prepare(INSERT_OBSERVATION_SQL);
|
|
402
|
+
for (const obs of observations) {
|
|
403
|
+
deleteById.run(obs.id);
|
|
404
|
+
insert.run(obs);
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
const insert = db.prepare(INSERT_SCRATCHPAD_OBSERVATION_SQL);
|
|
408
|
+
for (const obs of observations) {
|
|
409
|
+
insert.run(obs);
|
|
410
|
+
}
|
|
329
411
|
}
|
|
330
412
|
db.prepare(UPSERT_FILE_SQL).run({
|
|
331
413
|
path: source_file,
|
|
@@ -370,6 +452,9 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
|
370
452
|
userDir,
|
|
371
453
|
now: ts,
|
|
372
454
|
});
|
|
455
|
+
// observationsAffected counts insert-ATTEMPTS, not net rows: a fact that
|
|
456
|
+
// displaces a same-id scratchpad row (Bug 1 precedence) is net-zero but
|
|
457
|
+
// counts as one here. It's a "work done" metric, not a row-count invariant.
|
|
373
458
|
return result.observations.length;
|
|
374
459
|
});
|
|
375
460
|
|
|
@@ -537,6 +622,9 @@ export function reindexFull({ projectRoot, userDir, db, now }) {
|
|
|
537
622
|
userDir,
|
|
538
623
|
now: ts,
|
|
539
624
|
});
|
|
625
|
+
// observationsAffected counts insert-ATTEMPTS, not net rows: a fact that
|
|
626
|
+
// displaces a same-id scratchpad row (Bug 1 precedence) is net-zero but
|
|
627
|
+
// counts as one here. It's a "work done" metric, not a row-count invariant.
|
|
540
628
|
return result.observations.length;
|
|
541
629
|
});
|
|
542
630
|
|