@lh8ppl/claude-memory-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- package/template/user/fragments/INDEX.md.template +23 -0
package/src/forget.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Tombstone-write + tombstone-aware fact resolver (Task 9, refactored in
|
|
2
|
+
// cleanup-layer-2-cross-module-drift). Two public boundaries:
|
|
3
|
+
// forget(opts) → result — user-requested deletion
|
|
4
|
+
// resolveFact(opts) → state — read-side, knows live/tombstoned/superseded
|
|
5
|
+
//
|
|
6
|
+
// Uses shared modules: tier-paths, frontmatter, audit-log, result-shapes.
|
|
7
|
+
// See design §6.5 + CLAUDE.md "Shared modules" rule.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
statSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { canonicalize } from '@lh8ppl/cmk-canonicalize';
|
|
20
|
+
import {
|
|
21
|
+
VALID_TIERS,
|
|
22
|
+
ID_PATTERN,
|
|
23
|
+
resolveTierRoot,
|
|
24
|
+
resolveFactDir,
|
|
25
|
+
} from './tier-paths.mjs';
|
|
26
|
+
import { parse, format } from './frontmatter.mjs';
|
|
27
|
+
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
28
|
+
import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.mjs';
|
|
29
|
+
|
|
30
|
+
// Layer-2 review: PR-1 rejected \n / \r / : in the `reason` field as a
|
|
31
|
+
// minimum fix for the naive serializer (finding B2). PR-2's frontmatter.mjs
|
|
32
|
+
// (js-yaml CORE_SCHEMA) quotes those chars properly. The B2 restriction is
|
|
33
|
+
// LIFTED here — reasons may contain newlines, colons, etc. and round-trip
|
|
34
|
+
// correctly. Round-trip tests in cli-forget.test.js (`B2 relaxation`) prove it.
|
|
35
|
+
|
|
36
|
+
function listLiveFactFiles(factDir) {
|
|
37
|
+
if (!existsSync(factDir)) return [];
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const entry of readdirSync(factDir, { withFileTypes: true })) {
|
|
40
|
+
if (!entry.isFile()) continue;
|
|
41
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
42
|
+
if (entry.name === 'INDEX.md') continue;
|
|
43
|
+
out.push(entry.name);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readFactAt(filePath) {
|
|
49
|
+
const text = readFileSync(filePath, 'utf8');
|
|
50
|
+
const { frontmatter, body } = parse(text);
|
|
51
|
+
return { frontmatter, body, text };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveById(id, { projectRoot, userDir }) {
|
|
55
|
+
const tier = id[0];
|
|
56
|
+
if (!VALID_TIERS.has(tier)) return { matches: [] };
|
|
57
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
58
|
+
const factDir = resolveFactDir(tier, tierRoot);
|
|
59
|
+
for (const filename of listLiveFactFiles(factDir)) {
|
|
60
|
+
const p = join(factDir, filename);
|
|
61
|
+
if (!statSync(p).isFile()) continue;
|
|
62
|
+
const { frontmatter, body } = readFactAt(p);
|
|
63
|
+
if (frontmatter?.id === id && !frontmatter.deleted_at) {
|
|
64
|
+
return {
|
|
65
|
+
matches: [
|
|
66
|
+
{ id, tier, path: p, frontmatter, body, tierRoot, factDir },
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { matches: [] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveByQuery(query, { projectRoot, userDir }) {
|
|
75
|
+
const canonicalQuery = canonicalize(query);
|
|
76
|
+
if (!canonicalQuery) return { matches: [] };
|
|
77
|
+
const tiersToSearch = [];
|
|
78
|
+
if (projectRoot) tiersToSearch.push('P', 'L');
|
|
79
|
+
if (userDir) tiersToSearch.push('U');
|
|
80
|
+
|
|
81
|
+
const matches = [];
|
|
82
|
+
for (const tier of tiersToSearch) {
|
|
83
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
84
|
+
const factDir = resolveFactDir(tier, tierRoot);
|
|
85
|
+
for (const filename of listLiveFactFiles(factDir)) {
|
|
86
|
+
const p = join(factDir, filename);
|
|
87
|
+
if (!statSync(p).isFile()) continue;
|
|
88
|
+
const { frontmatter, body } = readFactAt(p);
|
|
89
|
+
if (!frontmatter?.id || frontmatter.deleted_at) continue;
|
|
90
|
+
if (canonicalize(body).includes(canonicalQuery)) {
|
|
91
|
+
matches.push({
|
|
92
|
+
id: frontmatter.id,
|
|
93
|
+
tier,
|
|
94
|
+
path: p,
|
|
95
|
+
frontmatter,
|
|
96
|
+
body,
|
|
97
|
+
tierRoot,
|
|
98
|
+
factDir,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Layer-2 review M3: sort matches deterministically so ambiguous-error
|
|
104
|
+
// messages list candidate ids in stable order across machines.
|
|
105
|
+
matches.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
106
|
+
return { matches };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function moveFactToTombstone(match, { deletedAt, reason, deletedBy }) {
|
|
110
|
+
const tombDir = join(match.factDir, 'archive', 'tombstones');
|
|
111
|
+
mkdirSync(tombDir, { recursive: true });
|
|
112
|
+
const tombPath = join(tombDir, `${match.id}.md`);
|
|
113
|
+
// Read + parse the original, inject deletion fields at the top of the
|
|
114
|
+
// frontmatter object, write via the canonical formatter. No regex hacks.
|
|
115
|
+
const { frontmatter, body } = parse(readFileSync(match.path, 'utf8'));
|
|
116
|
+
const updated = {
|
|
117
|
+
deleted_at: deletedAt,
|
|
118
|
+
deleted_reason: reason,
|
|
119
|
+
deleted_by: deletedBy,
|
|
120
|
+
...(frontmatter ?? {}),
|
|
121
|
+
};
|
|
122
|
+
writeFileSync(tombPath, format({ frontmatter: updated, body }), 'utf8');
|
|
123
|
+
unlinkSync(match.path);
|
|
124
|
+
return tombPath;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scrubScratchpadFile(filePath, id) {
|
|
128
|
+
const text = readFileSync(filePath, 'utf8');
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
const removeIdx = new Set();
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < lines.length; i++) {
|
|
133
|
+
if (removeIdx.has(i)) continue;
|
|
134
|
+
const line = lines[i];
|
|
135
|
+
|
|
136
|
+
if (line.startsWith('- ') && line.includes(id)) {
|
|
137
|
+
removeIdx.add(i);
|
|
138
|
+
const next = lines[i + 1];
|
|
139
|
+
if (next && /^\s*<!--/.test(next)) {
|
|
140
|
+
removeIdx.add(i + 1);
|
|
141
|
+
}
|
|
142
|
+
} else if (
|
|
143
|
+
/^\s*<!--/.test(line) &&
|
|
144
|
+
line.includes(id) &&
|
|
145
|
+
line.includes('-->')
|
|
146
|
+
) {
|
|
147
|
+
removeIdx.add(i);
|
|
148
|
+
if (i > 0 && lines[i - 1].startsWith('- ') && !removeIdx.has(i - 1)) {
|
|
149
|
+
removeIdx.add(i - 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (removeIdx.size === 0) return { changed: false, removed: 0 };
|
|
155
|
+
|
|
156
|
+
const bulletsRemoved = [...removeIdx].filter((i) =>
|
|
157
|
+
lines[i].startsWith('- '),
|
|
158
|
+
).length;
|
|
159
|
+
const out = lines.filter((_, i) => !removeIdx.has(i));
|
|
160
|
+
writeFileSync(filePath, out.join('\n'), 'utf8');
|
|
161
|
+
return { changed: true, removed: bulletsRemoved };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scrubAllScratchpads(tierRoot, id) {
|
|
165
|
+
if (!existsSync(tierRoot)) return [];
|
|
166
|
+
const edits = [];
|
|
167
|
+
for (const entry of readdirSync(tierRoot, { withFileTypes: true })) {
|
|
168
|
+
if (!entry.isFile()) continue;
|
|
169
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
170
|
+
if (entry.name === 'INDEX.md') continue;
|
|
171
|
+
const p = join(tierRoot, entry.name);
|
|
172
|
+
const r = scrubScratchpadFile(p, id);
|
|
173
|
+
if (r.changed) edits.push({ path: p, removed: r.removed });
|
|
174
|
+
}
|
|
175
|
+
return edits;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function forget(opts = {}) {
|
|
179
|
+
const {
|
|
180
|
+
idOrQuery,
|
|
181
|
+
projectRoot,
|
|
182
|
+
userDir,
|
|
183
|
+
reason,
|
|
184
|
+
deletedBy,
|
|
185
|
+
yes,
|
|
186
|
+
confirm,
|
|
187
|
+
now,
|
|
188
|
+
} = opts;
|
|
189
|
+
|
|
190
|
+
if (!idOrQuery || typeof idOrQuery !== 'string') {
|
|
191
|
+
return errorResult({
|
|
192
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
193
|
+
errors: ['idOrQuery: required, non-empty string'],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// PR-2: B2 restriction on reason is RELAXED (js-yaml quotes strings with
|
|
198
|
+
// \n / \r / :). Only type-check remains.
|
|
199
|
+
if (reason !== undefined && reason !== null && typeof reason !== 'string') {
|
|
200
|
+
return errorResult({
|
|
201
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
202
|
+
errors: ['reason: must be a string'],
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resolved = ID_PATTERN.test(idOrQuery)
|
|
207
|
+
? resolveById(idOrQuery, { projectRoot, userDir })
|
|
208
|
+
: resolveByQuery(idOrQuery, { projectRoot, userDir });
|
|
209
|
+
|
|
210
|
+
if (resolved.matches.length === 0) {
|
|
211
|
+
return notFoundResult({
|
|
212
|
+
errors: [`no matching fact for "${idOrQuery}"`],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (resolved.matches.length > 1) {
|
|
216
|
+
const ids = resolved.matches.map((m) => m.id);
|
|
217
|
+
return errorResult({
|
|
218
|
+
category: ERROR_CATEGORIES.COLLISION,
|
|
219
|
+
errors: [
|
|
220
|
+
`ambiguous query "${idOrQuery}" matched multiple facts: ${ids.join(', ')}`,
|
|
221
|
+
],
|
|
222
|
+
candidateIds: ids,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const match = resolved.matches[0];
|
|
227
|
+
|
|
228
|
+
if (!yes) {
|
|
229
|
+
if (typeof confirm !== 'function') {
|
|
230
|
+
throw new Error(
|
|
231
|
+
"forget(): must provide either yes: true or a confirm() callback (refusing to silently delete)",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const proceed = confirm({
|
|
235
|
+
id: match.id,
|
|
236
|
+
tier: match.tier,
|
|
237
|
+
path: match.path,
|
|
238
|
+
title: match.frontmatter?.title,
|
|
239
|
+
body: match.body,
|
|
240
|
+
});
|
|
241
|
+
if (!proceed) {
|
|
242
|
+
return {
|
|
243
|
+
action: 'cancelled',
|
|
244
|
+
id: match.id,
|
|
245
|
+
tier: match.tier,
|
|
246
|
+
originalPath: match.path,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const deletedAt = now ?? nowIso();
|
|
252
|
+
const tombstoneReason = reason ?? '';
|
|
253
|
+
const tombstoneDeletedBy = deletedBy ?? 'user-explicit';
|
|
254
|
+
const tombstonePath = moveFactToTombstone(match, {
|
|
255
|
+
deletedAt,
|
|
256
|
+
reason: tombstoneReason,
|
|
257
|
+
deletedBy: tombstoneDeletedBy,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const scratchpadEdits = scrubAllScratchpads(match.tierRoot, match.id);
|
|
261
|
+
|
|
262
|
+
appendAuditEntry(match.tierRoot, {
|
|
263
|
+
ts: deletedAt,
|
|
264
|
+
action: 'tombstoned',
|
|
265
|
+
tier: match.tier,
|
|
266
|
+
id: match.id,
|
|
267
|
+
reasonCode: REASON_CODES.USER_REQUESTED,
|
|
268
|
+
reasonText: tombstoneReason || undefined,
|
|
269
|
+
paths: { before: match.path, archive: tombstonePath },
|
|
270
|
+
extra: {
|
|
271
|
+
deletedBy: tombstoneDeletedBy,
|
|
272
|
+
scratchpadEdits: scratchpadEdits.map((e) => ({
|
|
273
|
+
path: e.path,
|
|
274
|
+
removed: e.removed,
|
|
275
|
+
})),
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
action: 'tombstoned',
|
|
281
|
+
id: match.id,
|
|
282
|
+
tier: match.tier,
|
|
283
|
+
originalPath: match.path,
|
|
284
|
+
tombstonePath,
|
|
285
|
+
scratchpadEdits,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function resolveFact({ id, projectRoot, userDir }) {
|
|
290
|
+
if (!id || !ID_PATTERN.test(id)) return { state: 'not-found' };
|
|
291
|
+
const tier = id[0];
|
|
292
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
293
|
+
const factDir = resolveFactDir(tier, tierRoot);
|
|
294
|
+
|
|
295
|
+
for (const filename of listLiveFactFiles(factDir)) {
|
|
296
|
+
const p = join(factDir, filename);
|
|
297
|
+
if (!statSync(p).isFile()) continue;
|
|
298
|
+
const { frontmatter, body } = readFactAt(p);
|
|
299
|
+
if (frontmatter?.id === id) {
|
|
300
|
+
return {
|
|
301
|
+
state: frontmatter.deleted_at ? 'tombstoned' : 'live',
|
|
302
|
+
path: p,
|
|
303
|
+
body,
|
|
304
|
+
frontmatter,
|
|
305
|
+
deletedAt: frontmatter.deleted_at,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tombPath = join(factDir, 'archive', 'tombstones', `${id}.md`);
|
|
311
|
+
if (existsSync(tombPath)) {
|
|
312
|
+
const { frontmatter, body } = readFactAt(tombPath);
|
|
313
|
+
return {
|
|
314
|
+
state: 'tombstoned',
|
|
315
|
+
path: tombPath,
|
|
316
|
+
body,
|
|
317
|
+
frontmatter,
|
|
318
|
+
deletedAt: frontmatter?.deleted_at,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const supersededPath = join(factDir, 'archive', 'superseded', `${id}.md`);
|
|
323
|
+
if (existsSync(supersededPath)) {
|
|
324
|
+
const { frontmatter, body } = readFactAt(supersededPath);
|
|
325
|
+
return {
|
|
326
|
+
state: 'superseded',
|
|
327
|
+
path: supersededPath,
|
|
328
|
+
body,
|
|
329
|
+
frontmatter,
|
|
330
|
+
supersededBy: frontmatter?.superseded_by,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { state: 'not-found' };
|
|
335
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Canonical frontmatter serializer/parser. Single js-yaml-backed pair that
|
|
2
|
+
// every kit module uses to read and write per-fact frontmatter + scratchpad
|
|
3
|
+
// HTML-comment provenance (Layer 3+ will join).
|
|
4
|
+
//
|
|
5
|
+
// Per the Layer-2 review's I2 finding, the previous code had THREE different
|
|
6
|
+
// naive parsers across four modules (split-on-first-colon read; verbatim
|
|
7
|
+
// stringify write). Output and input weren't symmetric: booleans round-tripped
|
|
8
|
+
// as strings, arrays didn't round-trip at all, strings with `:` truncated on
|
|
9
|
+
// read. js-yaml fixes all of these AND lifts the B2 minimum-fix restriction
|
|
10
|
+
// that PR-1 added — values with `\n` / `\r` / `:` are now quoted properly.
|
|
11
|
+
//
|
|
12
|
+
// Public surface:
|
|
13
|
+
// parse(text) → {frontmatter, body, parseError?}
|
|
14
|
+
// - text: full file contents (with or without `---` markers)
|
|
15
|
+
// - returns frontmatter as a typed object (string/number/bool/array/etc.)
|
|
16
|
+
// - returns body as the markdown after the closing `---\n` (or empty)
|
|
17
|
+
// - if no frontmatter block: frontmatter is null, body is the full text
|
|
18
|
+
// - if YAML parse fails: frontmatter is null, parseError carries the message
|
|
19
|
+
//
|
|
20
|
+
// format({frontmatter, body}) → text
|
|
21
|
+
// - frontmatter: typed object; key order preserved per insertion
|
|
22
|
+
// - body: markdown; written verbatim after the closing `---\n`
|
|
23
|
+
// - if frontmatter is null/empty: just returns body
|
|
24
|
+
//
|
|
25
|
+
// js-yaml schema: CORE_SCHEMA (no implicit timestamp/Date conversion;
|
|
26
|
+
// ISO strings stay as strings). Output uses flowLevel: 1 — top-level
|
|
27
|
+
// mapping is block style; nested arrays render as `[a, b]` (matches the
|
|
28
|
+
// pre-refactor visual format).
|
|
29
|
+
|
|
30
|
+
import yaml from 'js-yaml';
|
|
31
|
+
|
|
32
|
+
const DUMP_OPTIONS = Object.freeze({
|
|
33
|
+
schema: yaml.CORE_SCHEMA,
|
|
34
|
+
flowLevel: 1,
|
|
35
|
+
lineWidth: -1, // no line wrapping
|
|
36
|
+
noRefs: true, // never emit YAML anchors / refs
|
|
37
|
+
sortKeys: false, // preserve insertion order
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const LOAD_OPTIONS = Object.freeze({
|
|
41
|
+
schema: yaml.CORE_SCHEMA,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export function parse(text) {
|
|
45
|
+
if (typeof text !== 'string') return { frontmatter: null, body: '' };
|
|
46
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
47
|
+
if (!m) return { frontmatter: null, body: text };
|
|
48
|
+
let frontmatter;
|
|
49
|
+
try {
|
|
50
|
+
frontmatter = yaml.load(m[1], LOAD_OPTIONS);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { frontmatter: null, body: text, parseError: e.message };
|
|
53
|
+
}
|
|
54
|
+
if (frontmatter === undefined || frontmatter === null) {
|
|
55
|
+
return { frontmatter: null, body: m[2] ?? '' };
|
|
56
|
+
}
|
|
57
|
+
if (typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
58
|
+
return {
|
|
59
|
+
frontmatter: null,
|
|
60
|
+
body: text,
|
|
61
|
+
parseError: 'frontmatter is not a mapping',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { frontmatter, body: m[2] ?? '' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function format({ frontmatter, body }) {
|
|
68
|
+
if (!frontmatter || (typeof frontmatter === 'object' && Object.keys(frontmatter).length === 0)) {
|
|
69
|
+
return body ?? '';
|
|
70
|
+
}
|
|
71
|
+
const yamlBody = yaml.dump(frontmatter, DUMP_OPTIONS);
|
|
72
|
+
return `---\n${yamlBody}---\n${body ?? ''}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// `cmk import-anthropic-memory` (Task 38a, T-032).
|
|
2
|
+
//
|
|
3
|
+
// Public boundary:
|
|
4
|
+
// async importAnthropicMemory({projectRoot, now, dryRun?, acceptAll?, harnessRoot?})
|
|
5
|
+
// → {action, proposals, accepted, skipped, errors, duration_ms}
|
|
6
|
+
//
|
|
7
|
+
// Reads Anthropic's native auto-memory at
|
|
8
|
+
// `~/.claude/projects/<slug>/memory/MEMORY.md` (same slug pattern HC-8
|
|
9
|
+
// uses) and merges useful bullets into the project's MEMORY.md as
|
|
10
|
+
// `write_source: imported, trust: medium` entries.
|
|
11
|
+
//
|
|
12
|
+
// Dedup contract: a candidate whose canonicalize(text) collides with an
|
|
13
|
+
// existing entry in the project MEMORY.md is skipped. Audit-logged via
|
|
14
|
+
// the canonical appendAuditEntry shape with reasonCode IMPORT_SKIPPED_DUPLICATE.
|
|
15
|
+
// This is intentional — Anthropic's auto-memory and the kit's
|
|
16
|
+
// auto-extract often converge on the same facts; we don't want to
|
|
17
|
+
// silently double-write.
|
|
18
|
+
//
|
|
19
|
+
// Per design §11.2 + tasks.md 38a (38.1–38.5).
|
|
20
|
+
//
|
|
21
|
+
// B1 fix (Task 38 skill-review 2026-05-28): uses appendAuditEntry +
|
|
22
|
+
// REASON_CODES.IMPORT_* per CLAUDE.md "Shared modules" rule. Previous
|
|
23
|
+
// implementation wrote raw JSON to audit.log, missing schema/action/tier
|
|
24
|
+
// fields → re-introduced the format drift the I4 review codified.
|
|
25
|
+
// B2 fix: harnessRoot test-injection parameter mirrors discoverSessions.
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
appendFileSync, // platform-commands: ignore — used only for the in-place MEMORY.md append (not audit log; that goes through appendAuditEntry)
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { canonicalize, generateId } from '@lh8ppl/cmk-canonicalize';
|
|
36
|
+
import {
|
|
37
|
+
appendAuditEntry,
|
|
38
|
+
nowIso,
|
|
39
|
+
REASON_CODES,
|
|
40
|
+
} from './audit-log.mjs';
|
|
41
|
+
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
42
|
+
|
|
43
|
+
const MEMORY_REL = ['context', 'MEMORY.md'];
|
|
44
|
+
|
|
45
|
+
// Same slug pattern HC-8 uses (matches Python's
|
|
46
|
+
// `re.sub(r'[^a-zA-Z0-9]', '-', project_dir)`).
|
|
47
|
+
export function anthropicSlugFor(projectRoot) {
|
|
48
|
+
return String(projectRoot).replace(/[^a-zA-Z0-9]/g, '-');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where to find Anthropic's auto-memory for a given project root.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} projectRoot
|
|
55
|
+
* @param {string} [harnessRoot] override (test injection); defaults to ~/.claude/projects
|
|
56
|
+
*/
|
|
57
|
+
export function anthropicMemoryPath(projectRoot, harnessRoot) {
|
|
58
|
+
const root = harnessRoot ?? join(homedir(), '.claude', 'projects');
|
|
59
|
+
return join(root, anthropicSlugFor(projectRoot), 'memory', 'MEMORY.md');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// M2 (skill-review 2026-05-28): permissive regex accepts -/*/+ markers.
|
|
63
|
+
// Anthropic's MEMORY.md format isn't externally documented; the permissive
|
|
64
|
+
// match also pulls in list items from prose. v0.1.0 trade-off: noisy
|
|
65
|
+
// proposals are fine because --dry-run lets the user inspect first.
|
|
66
|
+
// TODO(v0.1.x): tighten if user feedback surfaces over-extraction noise.
|
|
67
|
+
function parseBullets(markdown) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const line of markdown.split('\n')) {
|
|
70
|
+
const m = /^\s*[-*+]\s+(.+?)\s*$/.exec(line);
|
|
71
|
+
if (m && m[1].trim().length > 0) out.push(m[1].trim());
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run the import pipeline.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} opts
|
|
80
|
+
* @param {string} opts.projectRoot
|
|
81
|
+
* @param {string} [opts.now]
|
|
82
|
+
* @param {boolean} [opts.dryRun] print proposals; no file modified
|
|
83
|
+
* @param {boolean} [opts.acceptAll] apply every proposal (non-interactive path; in v0.1.0 the CLI either does --dry-run OR --yes-accept-all; interactive y/N is a v0.1.x candidate per design §16)
|
|
84
|
+
* @param {string} [opts.harnessRoot] override (test injection); defaults to ~/.claude/projects
|
|
85
|
+
* @returns {Promise<object>}
|
|
86
|
+
*/
|
|
87
|
+
export async function importAnthropicMemory({
|
|
88
|
+
projectRoot,
|
|
89
|
+
now,
|
|
90
|
+
dryRun = false,
|
|
91
|
+
acceptAll = false,
|
|
92
|
+
harnessRoot,
|
|
93
|
+
} = {}) {
|
|
94
|
+
const ts = now ?? nowIso();
|
|
95
|
+
const t0 = Date.now();
|
|
96
|
+
|
|
97
|
+
if (!projectRoot) {
|
|
98
|
+
return errorResult({
|
|
99
|
+
category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
|
|
100
|
+
errors: ['projectRoot is required'],
|
|
101
|
+
duration_ms: Date.now() - t0,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sourcePath = anthropicMemoryPath(projectRoot, harnessRoot);
|
|
106
|
+
if (!existsSync(sourcePath)) {
|
|
107
|
+
return {
|
|
108
|
+
action: 'completed',
|
|
109
|
+
proposals: [],
|
|
110
|
+
accepted: 0,
|
|
111
|
+
skipped: 0,
|
|
112
|
+
errors: 0,
|
|
113
|
+
reason: 'no-source',
|
|
114
|
+
sourcePath,
|
|
115
|
+
duration_ms: Date.now() - t0,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let sourceText;
|
|
120
|
+
try {
|
|
121
|
+
sourceText = readFileSync(sourcePath, 'utf8');
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return {
|
|
124
|
+
action: 'completed',
|
|
125
|
+
proposals: [],
|
|
126
|
+
accepted: 0,
|
|
127
|
+
skipped: 0,
|
|
128
|
+
errors: 1,
|
|
129
|
+
reason: `read-source-failed: ${err?.message ?? err}`,
|
|
130
|
+
sourcePath,
|
|
131
|
+
duration_ms: Date.now() - t0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Read existing project MEMORY.md for dedup (canonicalize each bullet).
|
|
136
|
+
const targetPath = join(projectRoot, ...MEMORY_REL);
|
|
137
|
+
const existingCanonical = new Set();
|
|
138
|
+
if (existsSync(targetPath)) {
|
|
139
|
+
try {
|
|
140
|
+
const existing = readFileSync(targetPath, 'utf8');
|
|
141
|
+
for (const bullet of parseBullets(existing)) {
|
|
142
|
+
existingCanonical.add(canonicalize(bullet));
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// best-effort: empty set means we treat everything as new
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sourceBullets = parseBullets(sourceText);
|
|
150
|
+
const proposals = [];
|
|
151
|
+
let skippedDup = 0;
|
|
152
|
+
const tierRoot = join(projectRoot, 'context');
|
|
153
|
+
for (const bullet of sourceBullets) {
|
|
154
|
+
const canonical = canonicalize(bullet);
|
|
155
|
+
if (!canonical) continue;
|
|
156
|
+
const id = generateId('P', bullet);
|
|
157
|
+
if (existingCanonical.has(canonical)) {
|
|
158
|
+
skippedDup += 1;
|
|
159
|
+
try {
|
|
160
|
+
appendAuditEntry(tierRoot, {
|
|
161
|
+
ts,
|
|
162
|
+
action: 'import',
|
|
163
|
+
tier: 'P',
|
|
164
|
+
id,
|
|
165
|
+
reasonCode: REASON_CODES.IMPORT_SKIPPED_DUPLICATE,
|
|
166
|
+
extra: { source: 'anthropic-auto-memory' },
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
// best-effort — never block the import flow on audit-log failure
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
proposals.push({ text: bullet, canonical, id });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Dry-run: report proposals + don't touch files.
|
|
177
|
+
if (dryRun) {
|
|
178
|
+
return {
|
|
179
|
+
action: 'completed',
|
|
180
|
+
mode: 'dry-run',
|
|
181
|
+
proposals,
|
|
182
|
+
accepted: 0,
|
|
183
|
+
skipped: skippedDup,
|
|
184
|
+
errors: 0,
|
|
185
|
+
sourcePath,
|
|
186
|
+
targetPath,
|
|
187
|
+
duration_ms: Date.now() - t0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Apply mode: write each proposal as a new bullet under an
|
|
192
|
+
// `## Imported (Anthropic auto-memory, YYYY-MM-DD)` section.
|
|
193
|
+
if (!acceptAll && proposals.length > 0) {
|
|
194
|
+
// v0.1.0 has no interactive prompt at this layer — the CLI handler
|
|
195
|
+
// is responsible for the readline flow if it wants one. v0.1.0
|
|
196
|
+
// requires explicit `--yes` to apply.
|
|
197
|
+
return {
|
|
198
|
+
action: 'completed',
|
|
199
|
+
mode: 'requires-confirmation',
|
|
200
|
+
proposals,
|
|
201
|
+
accepted: 0,
|
|
202
|
+
skipped: skippedDup,
|
|
203
|
+
errors: 0,
|
|
204
|
+
sourcePath,
|
|
205
|
+
targetPath,
|
|
206
|
+
duration_ms: Date.now() - t0,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (proposals.length === 0) {
|
|
211
|
+
return {
|
|
212
|
+
action: 'completed',
|
|
213
|
+
mode: 'apply',
|
|
214
|
+
proposals: [],
|
|
215
|
+
accepted: 0,
|
|
216
|
+
skipped: skippedDup,
|
|
217
|
+
errors: 0,
|
|
218
|
+
sourcePath,
|
|
219
|
+
targetPath,
|
|
220
|
+
duration_ms: Date.now() - t0,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Append section to MEMORY.md.
|
|
225
|
+
// M3 (skill-review 2026-05-28): same-day re-runs create a NEW section
|
|
226
|
+
// header. Append-only is correct for audit fidelity; cosmetic
|
|
227
|
+
// deduplication of section headers is a v0.1.x candidate per design §16.
|
|
228
|
+
const today = ts.slice(0, 10);
|
|
229
|
+
const sectionHeader = `\n## Imported (Anthropic auto-memory, ${today})\n`;
|
|
230
|
+
const bulletLines = proposals.map((p) => `- (${p.id}) ${p.text}\n<!-- write_source: imported, trust: medium, source: anthropic-auto-memory, imported_at: ${ts} -->`).join('\n');
|
|
231
|
+
mkdirSync(join(projectRoot, 'context'), { recursive: true });
|
|
232
|
+
appendFileSync(targetPath, sectionHeader + '\n' + bulletLines + '\n', 'utf8');
|
|
233
|
+
|
|
234
|
+
let accepted = 0;
|
|
235
|
+
for (const p of proposals) {
|
|
236
|
+
accepted += 1;
|
|
237
|
+
try {
|
|
238
|
+
appendAuditEntry(tierRoot, {
|
|
239
|
+
ts,
|
|
240
|
+
action: 'import',
|
|
241
|
+
tier: 'P',
|
|
242
|
+
id: p.id,
|
|
243
|
+
reasonCode: REASON_CODES.IMPORT_APPLIED,
|
|
244
|
+
extra: {
|
|
245
|
+
source: 'anthropic-auto-memory',
|
|
246
|
+
trust: 'medium',
|
|
247
|
+
write_source: 'imported',
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
// best-effort
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
action: 'completed',
|
|
257
|
+
mode: 'apply',
|
|
258
|
+
proposals,
|
|
259
|
+
accepted,
|
|
260
|
+
skipped: skippedDup,
|
|
261
|
+
errors: 0,
|
|
262
|
+
sourcePath,
|
|
263
|
+
targetPath,
|
|
264
|
+
duration_ms: Date.now() - t0,
|
|
265
|
+
};
|
|
266
|
+
}
|