@lh8ppl/claude-memory-kit 0.3.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -50
- package/bin/cmk-approve-permission.mjs +62 -0
- package/bin/cmk-daily-distill.mjs +14 -0
- package/bin/cmk-guard-memory.mjs +57 -0
- package/bin/cmk-inject-context.mjs +12 -0
- package/bin/cmk-weekly-curate.mjs +12 -0
- package/package.json +4 -2
- package/src/agent-profile.mjs +115 -0
- package/src/agent-profiles.mjs +118 -0
- package/src/approve-permission.mjs +92 -0
- package/src/auto-extract.mjs +17 -10
- package/src/auto-persona.mjs +11 -4
- package/src/compaction-state.mjs +204 -0
- package/src/compress-session.mjs +13 -1
- package/src/config-core.mjs +7 -9
- package/src/decisions-journal.mjs +71 -3
- package/src/doctor.mjs +128 -5
- package/src/guard-memory.mjs +151 -0
- package/src/import-anthropic-memory.mjs +15 -1
- package/src/inject-context.mjs +42 -18
- package/src/install-agent.mjs +220 -0
- package/src/install-kiro.mjs +287 -0
- package/src/install.mjs +53 -7
- package/src/kiro-cli-agent.mjs +270 -0
- package/src/kiro-constants.mjs +19 -0
- package/src/kiro-hook-bin.mjs +105 -0
- package/src/kiro-hook-command.mjs +67 -0
- package/src/kiro-hook-dispatch.mjs +115 -0
- package/src/kiro-ide-hooks.mjs +219 -0
- package/src/kiro-permissions.mjs +175 -0
- package/src/kiro-skills.mjs +96 -0
- package/src/kiro-transcript.mjs +366 -0
- package/src/kiro-trusted-commands.mjs +130 -0
- package/src/lazy-compress.mjs +43 -110
- package/src/managed-block.mjs +138 -0
- package/src/memory-write.mjs +23 -8
- package/src/mutate-agent-config.mjs +243 -0
- package/src/read-json.mjs +43 -0
- package/src/register-crons.mjs +31 -0
- package/src/reindex.mjs +15 -2
- package/src/repair.mjs +39 -3
- package/src/result-shapes.mjs +8 -0
- package/src/review-queue.mjs +3 -0
- package/src/scratchpad.mjs +12 -2
- package/src/search.mjs +12 -5
- package/src/semantic-backend.mjs +7 -9
- package/src/settings-hooks.mjs +70 -3
- package/src/subcommands.mjs +360 -27
- package/src/tier-paths.mjs +82 -1
- package/src/weekly-curate.mjs +6 -2
- package/template/.claude/skills/memory-search/SKILL.md +14 -1
- package/template/.claude/skills/memory-write/SKILL.md +37 -1
- package/template/project/memory/INDEX.md.template +1 -1
package/src/lazy-compress.mjs
CHANGED
|
@@ -29,9 +29,7 @@ import {
|
|
|
29
29
|
existsSync,
|
|
30
30
|
mkdirSync,
|
|
31
31
|
readdirSync,
|
|
32
|
-
readFileSync,
|
|
33
32
|
statSync,
|
|
34
|
-
writeFileSync,
|
|
35
33
|
unlinkSync,
|
|
36
34
|
} from 'node:fs';
|
|
37
35
|
import { join } from 'node:path';
|
|
@@ -46,44 +44,58 @@ import { weeklyCurate } from './weekly-curate.mjs';
|
|
|
46
44
|
import { compressSession } from './compress-session.mjs';
|
|
47
45
|
import { CEILING_FREE_TIMEOUT_MS, CEILING_FREE_BACKOFF_MS } from './compress-retry.mjs';
|
|
48
46
|
import { syncDecisionsJournal } from './decisions-journal.mjs';
|
|
47
|
+
import {
|
|
48
|
+
isCompactionNeeded,
|
|
49
|
+
recordCronHeartbeat,
|
|
50
|
+
cronHeartbeatPath,
|
|
51
|
+
} from './compaction-state.mjs';
|
|
49
52
|
|
|
50
53
|
const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
51
54
|
const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
52
|
-
const SESSIONS_REL = ['context', 'sessions'];
|
|
53
55
|
const LOCKS_REL = ['context', '.locks'];
|
|
54
|
-
const NOW_MD_REL = ['context', 'sessions', 'now.md'];
|
|
55
|
-
const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
|
|
56
|
-
const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
|
|
57
56
|
const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
// Task 167 (D-206/D-207): the cron-liveness gate moved from a presence-only
|
|
59
|
+
// `cron-registered` sentinel to an anacron-style `cron-heartbeat` (in
|
|
60
|
+
// compaction-state.mjs), gated on AGE not existence — a registered-but-dead cron
|
|
61
|
+
// no longer disables the lazy roll. These three exports are kept as thin
|
|
62
|
+
// back-compat shims so existing callers (register-crons / doctor / subcommands /
|
|
63
|
+
// tests) keep working; they now operate on the heartbeat.
|
|
64
|
+
//
|
|
65
|
+
// **Decision-trail (the retired sentinel):** the old `cron-registered` marker
|
|
66
|
+
// recorded "a cron was REGISTERED" (written once at register-crons time, never
|
|
67
|
+
// updated). detectStaleness short-circuited to 'cron-active' on its mere
|
|
68
|
+
// existence — so a cron that never actually fired (laptop asleep at 23:00)
|
|
69
|
+
// disabled the lazy fallback forever and now.md grew to 410 KB (the v0.4.0
|
|
70
|
+
// dogfood). The heartbeat records "a run HAPPENED" and is re-stamped each fire.
|
|
60
71
|
|
|
61
72
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
73
|
+
* Back-compat alias for the cron-liveness marker path. Now points at the
|
|
74
|
+
* `cron-heartbeat` stamp (was `cron-registered`). Retained for callers that still
|
|
75
|
+
* import it; prefer `cronHeartbeatPath` from compaction-state.mjs in new code.
|
|
64
76
|
*/
|
|
65
77
|
export function cronSentinelPath(projectRoot) {
|
|
66
|
-
return
|
|
78
|
+
return cronHeartbeatPath(projectRoot);
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
82
|
+
* Record that the cron is alive. Now writes the anacron-style heartbeat (was: a
|
|
83
|
+
* one-time registration sentinel). `register-crons` calls it on registration so a
|
|
84
|
+
* just-registered cron reads alive until its first real run re-stamps it; the
|
|
85
|
+
* cron BINS also call `recordCronHeartbeat` on each fire (the durable liveness
|
|
86
|
+
* signal). Both go to the same stamp.
|
|
72
87
|
*/
|
|
73
88
|
export function markCronRegistered({ projectRoot }) {
|
|
74
|
-
|
|
75
|
-
const locksDir = join(projectRoot, ...LOCKS_REL);
|
|
76
|
-
mkdirSync(locksDir, { recursive: true });
|
|
77
|
-
writeFileSync(cronSentinelPath(projectRoot), nowIso() + '\n', 'utf8');
|
|
89
|
+
recordCronHeartbeat({ projectRoot });
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
/**
|
|
81
|
-
* Remove the cron-
|
|
82
|
-
*
|
|
93
|
+
* Remove the cron-liveness heartbeat (on unregister). Best-effort — a missing
|
|
94
|
+
* stamp is already the desired "no live cron" state.
|
|
83
95
|
*/
|
|
84
96
|
export function unmarkCronRegistered({ projectRoot }) {
|
|
85
97
|
if (!projectRoot) return;
|
|
86
|
-
const path =
|
|
98
|
+
const path = cronHeartbeatPath(projectRoot);
|
|
87
99
|
if (existsSync(path)) {
|
|
88
100
|
try {
|
|
89
101
|
unlinkSync(path);
|
|
@@ -93,18 +105,6 @@ export function unmarkCronRegistered({ projectRoot }) {
|
|
|
93
105
|
}
|
|
94
106
|
}
|
|
95
107
|
|
|
96
|
-
function listTodayFiles(projectRoot) {
|
|
97
|
-
const sessionsDir = join(projectRoot, ...SESSIONS_REL);
|
|
98
|
-
if (!existsSync(sessionsDir)) return [];
|
|
99
|
-
const matches = [];
|
|
100
|
-
for (const name of readdirSync(sessionsDir)) {
|
|
101
|
-
const m = TODAY_RE.exec(name);
|
|
102
|
-
if (!m) continue;
|
|
103
|
-
matches.push({ name, date: m[1], path: join(sessionsDir, name) });
|
|
104
|
-
}
|
|
105
|
-
return matches;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
108
|
// Task 105 (D-75): does now.md carry prior-session content? The now→today
|
|
109
109
|
// roll (compressSession) fires only at SessionEnd, and Claude Code fires
|
|
110
110
|
// SessionEnd ONLY on a clean window-close — so a never-cleanly-closed session
|
|
@@ -114,25 +114,10 @@ function listTodayFiles(projectRoot) {
|
|
|
114
114
|
// capture-turn writes haven't fired yet), so non-empty ⇒ stale. Emptiness must
|
|
115
115
|
// match compressSession's own `buffer.trim() === ''` check so the spawn verdict
|
|
116
116
|
// and the actual roll agree (else we'd spawn for a roll that immediately skips).
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return readFileSync(p, 'utf8').trim() !== '';
|
|
122
|
-
} catch {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function recentMdMtimeMs(projectRoot) {
|
|
128
|
-
const p = join(projectRoot, ...RECENT_MD_REL);
|
|
129
|
-
if (!existsSync(p)) return null;
|
|
130
|
-
try {
|
|
131
|
-
return statSync(p).mtimeMs;
|
|
132
|
-
} catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
117
|
+
// Task 167 (D-207): nowMdHasContent / recentMdMtimeMs / listTodayFiles + the
|
|
118
|
+
// now/daily/weekly derive logic moved to compaction-state.mjs (the deep module
|
|
119
|
+
// that owns the verdict). detectStaleness now delegates there; only isJournalStale
|
|
120
|
+
// (an INDEPENDENT signal, not part of the compaction verdict) stays here.
|
|
136
121
|
|
|
137
122
|
const MEMORY_REL = ['context', 'memory'];
|
|
138
123
|
const DECISIONS_MD_REL = ['context', 'DECISIONS.md'];
|
|
@@ -210,66 +195,14 @@ export function detectStaleness({
|
|
|
210
195
|
dailyTtlMs = DEFAULT_DAILY_TTL_MS,
|
|
211
196
|
weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
|
|
212
197
|
} = {}) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (!existsSync(sessionsDir)) {
|
|
222
|
-
return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Task 105 (D-75): a non-empty now.md is the now→today roll the SessionEnd
|
|
226
|
-
// hook would have done. It takes PRECEDENCE over daily/weekly because it's
|
|
227
|
-
// the FIRST pipeline level (now → today → recent → archive) — roll it this
|
|
228
|
-
// SessionStart; the today→recent + weekly levels cascade on subsequent
|
|
229
|
-
// SessionStarts once now.md is drained. (cron-active above still wins — a
|
|
230
|
-
// registered cron owns the whole pipeline.)
|
|
231
|
-
if (nowMdHasContent(projectRoot)) {
|
|
232
|
-
return { action: 'stale-now', reason: 'now-md-has-prior-session-content' };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const ts = now ?? nowIso();
|
|
236
|
-
const nowMs = new Date(ts).getTime();
|
|
237
|
-
const files = listTodayFiles(projectRoot);
|
|
238
|
-
|
|
239
|
-
// Weekly check: any today-*.md older than weeklyTtlMs by its date stamp
|
|
240
|
-
// (NOT mtime — the file's date is the canonical age signal; mtime can
|
|
241
|
-
// drift if someone touched the file).
|
|
242
|
-
const weeklyCutoffMs = nowMs - weeklyTtlMs;
|
|
243
|
-
const hasOldToday = files.some((f) => {
|
|
244
|
-
const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
|
|
245
|
-
return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
|
|
246
|
-
});
|
|
247
|
-
if (hasOldToday) {
|
|
248
|
-
return { action: 'stale-weekly', reason: 'today-file-older-than-7d' };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Task 36 I1 fix: if there are NO today-*.md files at all, the
|
|
252
|
-
// pipeline has nothing to compress — return fresh regardless of
|
|
253
|
-
// recent.md mtime. Previously this check only fired when recent.md
|
|
254
|
-
// was MISSING; for the stale-but-no-input case (e.g., right after
|
|
255
|
-
// weeklyCurate archived every today file), the daily-stale branch
|
|
256
|
-
// would fire and the SessionStart hook would spawn lazy-compress
|
|
257
|
-
// forever (no new today file means no work; dailyDistill would
|
|
258
|
-
// return skipped:no-input but not touch recent.md, so the next
|
|
259
|
-
// SessionStart sees the same stale verdict).
|
|
260
|
-
if (files.length === 0) {
|
|
261
|
-
return { action: 'fresh', reason: 'no-input' };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Daily check: recent.md missing OR older than dailyTtlMs.
|
|
265
|
-
const mtimeMs = recentMdMtimeMs(projectRoot);
|
|
266
|
-
if (mtimeMs === null) {
|
|
267
|
-
return { action: 'stale-daily', reason: 'recent-md-missing' };
|
|
268
|
-
}
|
|
269
|
-
if (nowMs - mtimeMs > dailyTtlMs) {
|
|
270
|
-
return { action: 'stale-daily', reason: 'recent-md-older-than-ttl' };
|
|
271
|
-
}
|
|
272
|
-
return { action: 'fresh', reason: 'within-ttl' };
|
|
198
|
+
// Task 167 (D-207): delegate to the compaction-state deep module. This is a
|
|
199
|
+
// thin back-compat adapter — it maps the rich `{verdict, cronStale,
|
|
200
|
+
// heartbeatAge}` return onto the legacy `{action, reason}` shape callers
|
|
201
|
+
// (inject-context, runLazyCompress) already consume. The KEY behavior change
|
|
202
|
+
// vs the old body: `cron-active` is now gated on heartbeat FRESHNESS (age), not
|
|
203
|
+
// sentinel existence, so a dead cron falls through to the stale verdicts.
|
|
204
|
+
const r = isCompactionNeeded({ projectRoot, now, dailyTtlMs, weeklyTtlMs });
|
|
205
|
+
return { action: r.verdict, reason: r.reason, cronStale: r.cronStale, heartbeatAge: r.heartbeatAge };
|
|
273
206
|
}
|
|
274
207
|
|
|
275
208
|
function writeLazyLogEntry({ projectRoot, entry }) {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// managed-block.mjs — shared helpers for agent-config + instruction-file writes.
|
|
2
|
+
//
|
|
3
|
+
// install-agent.mjs (the generic per-agent installer) and install-kiro.mjs (the
|
|
4
|
+
// Kiro 4-surface orchestrator) both need the same primitives: a managed
|
|
5
|
+
// marker-block instruction-file writer, JSON-key removal that preserves siblings,
|
|
6
|
+
// empty-parent pruning, and the non-regex newline trims (ReDoS-safe). These were
|
|
7
|
+
// duplicated verbatim across the two; centralizing them here removes the drift
|
|
8
|
+
// hazard (the kit's shared-module discipline — the same lesson the duplicated
|
|
9
|
+
// atomicWrite taught earlier in Task 50).
|
|
10
|
+
//
|
|
11
|
+
// Public surface:
|
|
12
|
+
// writeManagedBlock(path, {body, frontmatter, markStart, markEnd}) → changed:bool
|
|
13
|
+
// removeManagedBlock(path, {markStart, markEnd}) → changed:bool
|
|
14
|
+
// removeJsonKey(path, keyPath) → changed:bool (preserves siblings)
|
|
15
|
+
// pruneEmptyParent(path, keyPath) → void (drops an emptied {} we leave)
|
|
16
|
+
// escapeRe / trimLeadingNewlines / trimTrailingNewlines (ReDoS-safe utils)
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { atomicWrite } from './mutate-agent-config.mjs';
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_MARK_START = '<!-- claude-memory-kit:start -->';
|
|
22
|
+
export const DEFAULT_MARK_END = '<!-- claude-memory-kit:end -->';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Write/refresh a managed marker block in an instruction file, byte-preserving
|
|
26
|
+
* everything outside the markers. `frontmatter` (e.g. Kiro's `inclusion: always`)
|
|
27
|
+
* is prepended only when the file is created fresh. Idempotent: identical content
|
|
28
|
+
* → no write, returns false.
|
|
29
|
+
* @returns {boolean} whether a write happened.
|
|
30
|
+
*/
|
|
31
|
+
export function writeManagedBlock(path, { body, frontmatter = '', markStart = DEFAULT_MARK_START, markEnd = DEFAULT_MARK_END } = {}) {
|
|
32
|
+
const block = `${markStart}\n${body}\n${markEnd}`;
|
|
33
|
+
const desired = `${frontmatter}${block}\n`;
|
|
34
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
35
|
+
|
|
36
|
+
let next;
|
|
37
|
+
if (existing === '') {
|
|
38
|
+
next = desired;
|
|
39
|
+
} else if (existing.includes(markStart) && existing.includes(markEnd)) {
|
|
40
|
+
// refresh in place — replace only the managed block, byte-preserve the rest.
|
|
41
|
+
next = existing.replace(
|
|
42
|
+
new RegExp(`${escapeRe(markStart)}[\\s\\S]*?${escapeRe(markEnd)}`),
|
|
43
|
+
block,
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
// append our block to the user's existing file.
|
|
47
|
+
next = `${trimTrailingNewlines(existing)}\n\n${block}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (next === existing) return false;
|
|
51
|
+
atomicWrite(path, next);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strip our managed block (+ surrounding blank lines), byte-preserve the rest. */
|
|
56
|
+
export function removeManagedBlock(path, { markStart = DEFAULT_MARK_START, markEnd = DEFAULT_MARK_END } = {}) {
|
|
57
|
+
if (!existsSync(path)) return false;
|
|
58
|
+
const existing = readFileSync(path, 'utf8');
|
|
59
|
+
if (!existing.includes(markStart)) return false;
|
|
60
|
+
// The inner [\s\S]*? is lazy + bounded by two fixed literal delimiters → linear,
|
|
61
|
+
// not ReDoS-prone. Newline trims are non-regex (no `\n*$`/`^\n+` super-linear
|
|
62
|
+
// shapes); the `\n{3,}` collapse is a bounded quantifier, also linear.
|
|
63
|
+
const blockRe = new RegExp(`${escapeRe(markStart)}[\\s\\S]*?${escapeRe(markEnd)}`);
|
|
64
|
+
const stripped = trimLeadingNewlines(existing.replace(blockRe, '').replace(/\n{3,}/g, '\n\n'));
|
|
65
|
+
atomicWrite(path, stripped);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove a nested key from a JSON file, preserving everything else. Skips
|
|
71
|
+
* (returns false) on a missing file / parse error (never clobbers a corrupt file).
|
|
72
|
+
* @returns {boolean} whether a change was written.
|
|
73
|
+
*/
|
|
74
|
+
export function removeJsonKey(path, keyPath) {
|
|
75
|
+
if (!existsSync(path)) return false;
|
|
76
|
+
let root;
|
|
77
|
+
try {
|
|
78
|
+
root = JSON.parse(readFileSync(path, 'utf8'));
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
let cur = root;
|
|
83
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
84
|
+
if (!cur || typeof cur !== 'object' || !(keyPath[i] in cur)) return false;
|
|
85
|
+
cur = cur[keyPath[i]];
|
|
86
|
+
}
|
|
87
|
+
const last = keyPath[keyPath.length - 1];
|
|
88
|
+
if (!cur || typeof cur !== 'object' || !(last in cur)) return false;
|
|
89
|
+
delete cur[last];
|
|
90
|
+
atomicWrite(path, `${JSON.stringify(root, null, 2)}\n`);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* If the object at keyPath exists and is now empty ({}), remove it — so an
|
|
96
|
+
* uninstall doesn't leave a kit-shaped `{"mcpServers":{}}` husk. No-op on a
|
|
97
|
+
* missing file / parse error / non-empty target.
|
|
98
|
+
*/
|
|
99
|
+
export function pruneEmptyParent(path, keyPath) {
|
|
100
|
+
if (!existsSync(path)) return;
|
|
101
|
+
let root;
|
|
102
|
+
try {
|
|
103
|
+
root = JSON.parse(readFileSync(path, 'utf8'));
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let cur = root;
|
|
108
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
109
|
+
if (!cur || typeof cur !== 'object' || !(keyPath[i] in cur)) return;
|
|
110
|
+
cur = cur[keyPath[i]];
|
|
111
|
+
}
|
|
112
|
+
const last = keyPath[keyPath.length - 1];
|
|
113
|
+
const target = cur && typeof cur === 'object' ? cur[last] : undefined;
|
|
114
|
+
if (target && typeof target === 'object' && !Array.isArray(target) && Object.keys(target).length === 0) {
|
|
115
|
+
delete cur[last];
|
|
116
|
+
atomicWrite(path, `${JSON.stringify(root, null, 2)}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── ReDoS-safe string utils ──────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export function escapeRe(s) {
|
|
123
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Non-regex newline trims — avoid the `\n*$` / `^\n+` super-linear-backtracking
|
|
127
|
+
// shapes a static analyzer (rightly) flags as ReDoS-prone. O(n), no backtracking.
|
|
128
|
+
export function trimTrailingNewlines(s) {
|
|
129
|
+
let end = s.length;
|
|
130
|
+
while (end > 0 && s[end - 1] === '\n') end -= 1;
|
|
131
|
+
return s.slice(0, end);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function trimLeadingNewlines(s) {
|
|
135
|
+
let start = 0;
|
|
136
|
+
while (start < s.length && s[start] === '\n') start += 1;
|
|
137
|
+
return s.slice(start);
|
|
138
|
+
}
|
package/src/memory-write.mjs
CHANGED
|
@@ -203,6 +203,27 @@ function findMatchingBullet({ lines, substring, sectionTitle }) {
|
|
|
203
203
|
return null;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
// Strip a bullet + its provenance comment (lines bulletIdx..commentIdx) and
|
|
207
|
+
// collapse a resulting double blank line at the seam, so removing a bullet from
|
|
208
|
+
// a blank-padded scratchpad doesn't leave two adjacent blank lines (MD012
|
|
209
|
+
// no-multiple-blanks). Only the seam is touched — blanks elsewhere are
|
|
210
|
+
// preserved. The bullet↔comment pair is removed together (the adjacency
|
|
211
|
+
// invariant the whole kit depends on).
|
|
212
|
+
function stripBulletPair(lines, bulletIdx, commentIdx) {
|
|
213
|
+
const kept = [...lines.slice(0, bulletIdx), ...lines.slice(commentIdx + 1)];
|
|
214
|
+
// If the splice put a blank line directly after a blank line at the seam,
|
|
215
|
+
// drop one. The seam is at index `bulletIdx` in `kept`.
|
|
216
|
+
if (
|
|
217
|
+
bulletIdx > 0 &&
|
|
218
|
+
bulletIdx < kept.length &&
|
|
219
|
+
kept[bulletIdx - 1].trim() === '' &&
|
|
220
|
+
kept[bulletIdx].trim() === ''
|
|
221
|
+
) {
|
|
222
|
+
kept.splice(bulletIdx, 1);
|
|
223
|
+
}
|
|
224
|
+
return kept.join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
206
227
|
// --- Tombstone writer (design §6.5) --------------------------------
|
|
207
228
|
|
|
208
229
|
function writeTombstone({
|
|
@@ -438,10 +459,7 @@ function doReplace(opts) {
|
|
|
438
459
|
});
|
|
439
460
|
}
|
|
440
461
|
// Strip the matched bullet + provenance comment.
|
|
441
|
-
const stripped =
|
|
442
|
-
...lines.slice(0, match.bulletIdx),
|
|
443
|
-
...lines.slice(match.commentIdx + 1),
|
|
444
|
-
].join('\n');
|
|
462
|
+
const stripped = stripBulletPair(lines, match.bulletIdx, match.commentIdx);
|
|
445
463
|
writeFileSync(path, stripped, 'utf8');
|
|
446
464
|
|
|
447
465
|
// Append the new bullet via the GUARDED inner path. We already ran
|
|
@@ -550,10 +568,7 @@ function doRemove(opts) {
|
|
|
550
568
|
});
|
|
551
569
|
|
|
552
570
|
// Strip from scratchpad.
|
|
553
|
-
const stripped =
|
|
554
|
-
...lines.slice(0, match.bulletIdx),
|
|
555
|
-
...lines.slice(match.commentIdx + 1),
|
|
556
|
-
].join('\n');
|
|
571
|
+
const stripped = stripBulletPair(lines, match.bulletIdx, match.commentIdx);
|
|
557
572
|
writeFileSync(path, stripped, 'utf8');
|
|
558
573
|
|
|
559
574
|
appendAuditEntry(tierRoot, {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// mutate-agent-config.mjs — the shared per-agent config-write PRIMITIVE.
|
|
2
|
+
//
|
|
3
|
+
// Task 50 (cross-agent install). This is the one piece of machinery the whole
|
|
4
|
+
// `cmk install --ide <agent>` seam rests on: a single, tested function that
|
|
5
|
+
// writes the kit's entry (an MCP-server registration, a hook entry) into ANY
|
|
6
|
+
// agent's config file WITHOUT clobbering the user's other keys.
|
|
7
|
+
//
|
|
8
|
+
// The D-180 finding (research note 2026-06-20): do NOT build a per-agent
|
|
9
|
+
// `Installer` base class — claude-mem proved it breaks the moment agents differ
|
|
10
|
+
// in format/mechanism, and its ~6 bespoke config writers drifted in rigor (one
|
|
11
|
+
// surgical, one whole-file-clobber, one that discards user config on a JSON
|
|
12
|
+
// parse error). What actually generalizes is THIS primitive. Each per-agent
|
|
13
|
+
// profile is then just DATA (where the file is, which key, which format).
|
|
14
|
+
//
|
|
15
|
+
// The kit's existing disciplines, applied to third-party files:
|
|
16
|
+
// - touch-only-our-keys = the marker-block byte-preservation invariant
|
|
17
|
+
// - refuse-on-parse-error = the safe-write / Poison_Guard fail-closed rule
|
|
18
|
+
// - atomic (tmp + rename) = the same pattern compress-session/persona use
|
|
19
|
+
// - idempotent changed-bool = re-running install is a no-op (Codex's pattern)
|
|
20
|
+
//
|
|
21
|
+
// Public surface (one function — a deep module, narrow interface):
|
|
22
|
+
//
|
|
23
|
+
// mutateAgentConfig({ path, format, keyPath, entry, mode? }) → result
|
|
24
|
+
//
|
|
25
|
+
// path absolute path to the agent's config file (may not exist yet)
|
|
26
|
+
// format 'json' (v0.4.0; 'yaml' / 'toml' deferred until an agent needs them)
|
|
27
|
+
// keyPath array of keys to the slot we own, e.g. ['mcpServers', 'claude-memory-kit']
|
|
28
|
+
// entry the object to place at keyPath
|
|
29
|
+
// mode 'merge' (default — deep-merge into an existing entry) | 'replace'
|
|
30
|
+
//
|
|
31
|
+
// Result shape (result-shapes.mjs conventions):
|
|
32
|
+
// { action: 'created'|'updated'|'skipped'|'error', changed: boolean, path,
|
|
33
|
+
// errorCategory?, errors? }
|
|
34
|
+
// created — the file did not exist; we created it with just our key
|
|
35
|
+
// updated — the file existed; our key was added/changed (siblings preserved)
|
|
36
|
+
// skipped — our key already matched exactly; nothing written (changed:false)
|
|
37
|
+
// error — input invalid (schema) or target unparseable (config_parse);
|
|
38
|
+
// NEVER writes on error
|
|
39
|
+
|
|
40
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
41
|
+
import { dirname, basename, join } from 'node:path';
|
|
42
|
+
import { randomUUID } from 'node:crypto';
|
|
43
|
+
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
44
|
+
import { stripBom } from './read-json.mjs';
|
|
45
|
+
|
|
46
|
+
const SUPPORTED_FORMATS = new Set(['json']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write the kit's entry into an agent config file, preserving everything else.
|
|
50
|
+
* @returns {{action:string, changed:boolean, path:string, errorCategory?:string, errors?:string[]}}
|
|
51
|
+
*/
|
|
52
|
+
export function mutateAgentConfig({ path, format, keyPath, entry, mode = 'merge' }) {
|
|
53
|
+
// ── input validation (schema) ───────────────────────────────────────────
|
|
54
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
55
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['path must be a non-empty string'], changed: false });
|
|
56
|
+
}
|
|
57
|
+
if (!SUPPORTED_FORMATS.has(format)) {
|
|
58
|
+
return errorResult({
|
|
59
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
60
|
+
errors: [`unsupported format ${JSON.stringify(format)} — supported: ${[...SUPPORTED_FORMATS].join(', ')}`],
|
|
61
|
+
changed: false,
|
|
62
|
+
path,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(keyPath) || keyPath.length === 0 || !keyPath.every((k) => typeof k === 'string' && k.length > 0)) {
|
|
66
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['keyPath must be a non-empty array of non-empty strings'], changed: false, path });
|
|
67
|
+
}
|
|
68
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
69
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['entry must be a plain object'], changed: false, path });
|
|
70
|
+
}
|
|
71
|
+
if (mode !== 'merge' && mode !== 'replace') {
|
|
72
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: [`mode must be 'merge' or 'replace', got ${JSON.stringify(mode)}`], changed: false, path });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── read existing (refuse-to-clobber on parse error) ─────────────────────
|
|
76
|
+
const fileExists = existsSync(path);
|
|
77
|
+
let root;
|
|
78
|
+
if (fileExists) {
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
// stripBom: a user's config file written by a Windows editor may carry a
|
|
82
|
+
// leading UTF-8 BOM, which would otherwise make the JSON.parse below refuse
|
|
83
|
+
// a perfectly valid file as "corrupt" (D-187). Strip only the BOM; the
|
|
84
|
+
// refuse-to-clobber-on-real-corruption guarantee below is unchanged.
|
|
85
|
+
raw = stripBom(readFileSync(path, 'utf8'));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`could not read ${path}: ${err.message}`], changed: false, path });
|
|
88
|
+
}
|
|
89
|
+
if (raw.trim() === '') {
|
|
90
|
+
// an empty file is treated as an empty object (not a parse error)
|
|
91
|
+
root = {};
|
|
92
|
+
} else {
|
|
93
|
+
try {
|
|
94
|
+
root = JSON.parse(raw);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// The crucial guarantee: a corrupt target is NEVER overwritten.
|
|
97
|
+
return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`${path} is not valid JSON — refusing to overwrite: ${err.message}`], changed: false, path });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root)) {
|
|
101
|
+
return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`${path} is valid JSON but not an object — refusing to overwrite`], changed: false, path });
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
root = {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── compute the next entry at keyPath ────────────────────────────────────
|
|
108
|
+
const existing = getAt(root, keyPath);
|
|
109
|
+
const nextEntry =
|
|
110
|
+
mode === 'merge' && isPlainObject(existing) ? deepMerge(existing, entry) : entry;
|
|
111
|
+
|
|
112
|
+
// idempotent: if our slot already deep-equals what we'd write, do nothing.
|
|
113
|
+
if (existing !== undefined && deepEqual(existing, nextEntry)) {
|
|
114
|
+
return { action: 'skipped', changed: false, path };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── apply + atomic write ─────────────────────────────────────────────────
|
|
118
|
+
const next = cloneDeep(root);
|
|
119
|
+
setAt(next, keyPath, nextEntry);
|
|
120
|
+
|
|
121
|
+
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
122
|
+
atomicWrite(path, serialized);
|
|
123
|
+
|
|
124
|
+
return { action: fileExists ? 'updated' : 'created', changed: true, path };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── internal helpers ───────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function isPlainObject(v) {
|
|
130
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getAt(obj, keyPath) {
|
|
134
|
+
let cur = obj;
|
|
135
|
+
for (const k of keyPath) {
|
|
136
|
+
if (!isPlainObject(cur) || !(k in cur)) return undefined;
|
|
137
|
+
cur = cur[k];
|
|
138
|
+
}
|
|
139
|
+
return cur;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setAt(obj, keyPath, value) {
|
|
143
|
+
let cur = obj;
|
|
144
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
145
|
+
const k = keyPath[i];
|
|
146
|
+
if (!isPlainObject(cur[k])) cur[k] = {};
|
|
147
|
+
cur = cur[k];
|
|
148
|
+
}
|
|
149
|
+
cur[keyPath[keyPath.length - 1]] = value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Deep-merge `source` onto a copy of `target`. Objects merge recursively;
|
|
153
|
+
// arrays + scalars from source replace target's (no array concat — replacing
|
|
154
|
+
// args/command is the intended semantics).
|
|
155
|
+
function deepMerge(target, source) {
|
|
156
|
+
const out = cloneDeep(target);
|
|
157
|
+
for (const [k, v] of Object.entries(source)) {
|
|
158
|
+
if (isPlainObject(v) && isPlainObject(out[k])) {
|
|
159
|
+
out[k] = deepMerge(out[k], v);
|
|
160
|
+
} else {
|
|
161
|
+
out[k] = cloneDeep(v);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cloneDeep(v) {
|
|
168
|
+
return v === undefined ? undefined : JSON.parse(JSON.stringify(v));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function deepEqual(a, b) {
|
|
172
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Atomic write: serialize to a temp sibling, then rename over the target.
|
|
176
|
+
// rename is atomic on the same filesystem, so a reader never sees a partial file.
|
|
177
|
+
// Exported so install-agent.mjs (the per-agent wiring) shares ONE implementation
|
|
178
|
+
// — the kit's shared-module discipline (no two copies to drift).
|
|
179
|
+
//
|
|
180
|
+
// Two Windows-specific hazards this guards against (BOTH real, BOTH surfaced by
|
|
181
|
+
// the Task-50 stress runs — not "flakes": concrete EPERM-under-load failures):
|
|
182
|
+
// 1. The tmp suffix is UNIQUE PER CALL (randomUUID), not `process.pid` — a
|
|
183
|
+
// single install sequence writes the same path several times (write →
|
|
184
|
+
// uninstall → prune) and vitest runs files concurrently in one worker
|
|
185
|
+
// (shared pid), so a pid-keyed tmp name could collide.
|
|
186
|
+
// 2. `renameSync(tmp, target)` ONTO an existing file intermittently throws
|
|
187
|
+
// EPERM/EBUSY on Windows when the target is transiently locked (AV /
|
|
188
|
+
// indexer / another handle) under heavy parallel FS load. The kit already
|
|
189
|
+
// hit + documented this in persona-portability.mjs (restoreBackup); the
|
|
190
|
+
// proven fix is a short bounded retry. Without it, a `cmk install` on a
|
|
191
|
+
// busy Windows machine could spuriously fail mid-write.
|
|
192
|
+
export function atomicWrite(path, contents) {
|
|
193
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
194
|
+
const tmp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
195
|
+
try {
|
|
196
|
+
writeFileSync(tmp, contents, 'utf8');
|
|
197
|
+
renameWithRetry(tmp, path);
|
|
198
|
+
} finally {
|
|
199
|
+
if (existsSync(tmp)) {
|
|
200
|
+
try {
|
|
201
|
+
unlinkSync(tmp);
|
|
202
|
+
} catch {
|
|
203
|
+
/* best-effort cleanup; the rename already succeeded or threw */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// renameSync with a bounded retry + BACKOFF on transient Windows EPERM/EBUSY
|
|
210
|
+
// (the target briefly locked by AV/indexer/another handle under load). A
|
|
211
|
+
// no-delay spin doesn't work — the lock lasts milliseconds while 5 immediate
|
|
212
|
+
// retries finish in microseconds (the Task-50 stress proved this: retry fired
|
|
213
|
+
// but exhausted instantly). So we sleep between attempts with a synchronous
|
|
214
|
+
// backoff (Atomics.wait — real sync sleep, no busy-spin). A non-transient error
|
|
215
|
+
// (e.g. ENOENT on the source) reraises immediately so we don't mask a real bug.
|
|
216
|
+
//
|
|
217
|
+
// `rename` + `sleep` are injectable so the retry policy is unit-testable WITHOUT
|
|
218
|
+
// mocking node:fs or actually sleeping — production passes nothing.
|
|
219
|
+
export function renameWithRetry(from, to, attempts = 8, rename = renameSync, sleep = sleepMs) {
|
|
220
|
+
let lastErr;
|
|
221
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
222
|
+
try {
|
|
223
|
+
rename(from, to);
|
|
224
|
+
return;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err && (err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'EACCES')) {
|
|
227
|
+
lastErr = err; // transient lock under load — back off + retry
|
|
228
|
+
if (i < attempts - 1) sleep(Math.min(10 * 2 ** i, 250)); // 10,20,40…≤250ms
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
throw err; // anything else is a real error — don't mask it
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw lastErr;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Synchronous sleep via Atomics.wait on a throwaway SharedArrayBuffer — blocks
|
|
238
|
+
// the thread for `ms` without a CPU busy-spin. Used only on the (rare) Windows
|
|
239
|
+
// transient-lock retry path, so the block is bounded + infrequent.
|
|
240
|
+
function sleepMs(ms) {
|
|
241
|
+
if (ms <= 0) return;
|
|
242
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
243
|
+
}
|