@lh8ppl/claude-memory-kit 0.3.0 → 0.3.2
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 +13 -3
- package/package.json +2 -2
- package/src/audit-log.mjs +1 -0
- package/src/auto-drain.mjs +17 -1
- package/src/auto-extract.mjs +4 -5
- package/src/auto-persona.mjs +86 -1
- package/src/capture-prompt.mjs +2 -1
- package/src/config-core.mjs +161 -0
- package/src/conflict-queue.mjs +2 -2
- package/src/content-hash.mjs +30 -0
- package/src/decisions-journal.mjs +223 -0
- package/src/digest.mjs +89 -0
- package/src/doctor.mjs +62 -3
- package/src/forget.mjs +6 -0
- package/src/import-anthropic-memory.mjs +2 -2
- package/src/import-claude-md.mjs +333 -0
- package/src/index-rebuild.mjs +6 -2
- package/src/index.mjs +10 -0
- package/src/inject-context.mjs +130 -1
- package/src/install.mjs +75 -2
- package/src/mcp-server.mjs +6 -1
- package/src/memory-health.mjs +229 -0
- package/src/memory-write.mjs +32 -10
- package/src/native-binding.mjs +142 -0
- package/src/poison-guard.mjs +55 -0
- package/src/remember-core.mjs +53 -8
- package/src/repair.mjs +20 -3
- package/src/search.mjs +105 -2
- package/src/semantic-backend.mjs +114 -0
- package/src/subcommands.mjs +300 -27
- package/src/transcript-index.mjs +5 -2
- package/src/write-fact.mjs +34 -3
- package/template/.claude/skills/memory-search/SKILL.md +1 -1
- package/template/.gitattributes.fragment +16 -0
- package/template/CLAUDE.md.template +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// `cmk import-claude-md` (Task 142, D-130).
|
|
2
|
+
//
|
|
3
|
+
// Public boundary:
|
|
4
|
+
// async importClaudeMd({projectRoot, file?, now?, dryRun?, acceptAll?, writeFactImpl?})
|
|
5
|
+
// → {action, mode?, reason?, proposals, accepted, skipped, rejected, errors, sourcePath, duration_ms}
|
|
6
|
+
//
|
|
7
|
+
// Onboards a project from the rules file the user already owns (CLAUDE.md,
|
|
8
|
+
// .cursorrules, AGENTS.md, any markdown/plain rules file): parses it into
|
|
9
|
+
// TYPED fact candidates and writes each through writeFact() — the kit's one
|
|
10
|
+
// safe write path. That composition (not re-implementation) is the point:
|
|
11
|
+
// writeFact already gives Poison_Guard screening, home-path sanitization,
|
|
12
|
+
// content-addressed dedup, INDEX reindex, and create-audit. The D-125 bug
|
|
13
|
+
// (import-anthropic hand-rolling its provenance comment and breaking the next
|
|
14
|
+
// reindex) is the precedent this design avoids.
|
|
15
|
+
//
|
|
16
|
+
// Differences from `cmk import-anthropic-memory` (the structural template):
|
|
17
|
+
// - target is the GRANULAR fact archive (context/memory/), not MEMORY.md
|
|
18
|
+
// bullets — rules-file content is durable and typed, not scratchpad;
|
|
19
|
+
// - fact `type` is inferred from the nearest markdown heading
|
|
20
|
+
// (user / feedback / reference, default project);
|
|
21
|
+
// - candidates inside the kit's own managed CLAUDE.md block and inside
|
|
22
|
+
// code fences are never proposed (boilerplate / shell examples).
|
|
23
|
+
//
|
|
24
|
+
// Explicit user action only. Never automatic. `--dry-run` previews; apply
|
|
25
|
+
// requires explicit `--yes` (same confirmation contract as the precedent).
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
28
|
+
import { isAbsolute, join } from 'node:path';
|
|
29
|
+
import { canonicalize, generateId } from '@lh8ppl/cmk-canonicalize';
|
|
30
|
+
import { hashContent } from './content-hash.mjs';
|
|
31
|
+
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
32
|
+
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
33
|
+
import { writeFact } from './write-fact.mjs';
|
|
34
|
+
import { slugifyFact } from './rich-fact.mjs';
|
|
35
|
+
import { sanitizeHomePaths } from './sanitize.mjs';
|
|
36
|
+
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
37
|
+
|
|
38
|
+
const DEFAULT_FILE = 'CLAUDE.md';
|
|
39
|
+
const IMPORT_SOURCE = 'claude-md';
|
|
40
|
+
// Below this length a line is noise ("go", "etc."), not a rule.
|
|
41
|
+
const MIN_CANDIDATE_CHARS = 8;
|
|
42
|
+
|
|
43
|
+
const MANAGED_BLOCK_START = /<!--\s*claude-memory-kit:start\b/;
|
|
44
|
+
const MANAGED_BLOCK_END = /<!--\s*claude-memory-kit:end\s*-->/;
|
|
45
|
+
// Linear-time by construction (S5852, the D-128 class): every adjacent
|
|
46
|
+
// pair is disjoint — `[ \t]+` can never donate characters to the `\S` that
|
|
47
|
+
// starts the capture — so the regex engine has no backtracking ambiguity.
|
|
48
|
+
// Captures keep trailing whitespace; every consumer already calls .trim().
|
|
49
|
+
const HEADING = /^(#{1,6})[ \t]+(\S.*)$/;
|
|
50
|
+
const LIST_ITEM = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(\S.*)$/;
|
|
51
|
+
const CODE_FENCE = /^\s*(```|~~~)/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Infer the kit fact type from the heading a candidate sits under.
|
|
55
|
+
* Heuristic by design — `--dry-run` shows the inferred type so the user can
|
|
56
|
+
* inspect before applying. Order matters: user-profile phrasing wins over the
|
|
57
|
+
* broad rule/style class, and \b on "reference" keeps "Preferences" from
|
|
58
|
+
* matching it.
|
|
59
|
+
*
|
|
60
|
+
* @param {string|null} heading
|
|
61
|
+
* @returns {'user'|'feedback'|'project'|'reference'}
|
|
62
|
+
*/
|
|
63
|
+
export function inferFactType(heading) {
|
|
64
|
+
if (!heading) return 'project';
|
|
65
|
+
const h = String(heading).toLowerCase();
|
|
66
|
+
if (/prefer|about (me|the user)|profile|persona|communicat/.test(h)) return 'user';
|
|
67
|
+
if (/\b(link|reference|resource|url|bookmark)/.test(h)) return 'reference';
|
|
68
|
+
if (/rule|discipline|workflow|convention|anti-pattern|style|verification|review|testing|engineering|working/.test(h)) {
|
|
69
|
+
return 'feedback';
|
|
70
|
+
}
|
|
71
|
+
return 'project';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a rules file into typed fact candidates.
|
|
76
|
+
*
|
|
77
|
+
* Primary shape: markdown list items (-, *, +, 1.) with the nearest heading
|
|
78
|
+
* as type context. Fallback shape (.cursorrules and other plain-text rules
|
|
79
|
+
* files): when the file has NO list items at all, every non-empty,
|
|
80
|
+
* non-heading line outside code fences is a candidate.
|
|
81
|
+
*
|
|
82
|
+
* Skipped in both shapes: code-fence content (shell examples, not rules) and
|
|
83
|
+
* the kit's own managed CLAUDE.md block (importing our boilerplate back into
|
|
84
|
+
* memory would be noise for every kit user).
|
|
85
|
+
*
|
|
86
|
+
* @param {string} text - the rules-file content.
|
|
87
|
+
* @returns {Array<{text: string, line: number, heading: string|null, type: string}>}
|
|
88
|
+
*/
|
|
89
|
+
export function parseRulesFile(text) {
|
|
90
|
+
const lines = String(text).split(/\r?\n/);
|
|
91
|
+
const bullets = [];
|
|
92
|
+
const plain = [];
|
|
93
|
+
let heading = null;
|
|
94
|
+
let inFence = false;
|
|
95
|
+
let inManagedBlock = false;
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
const line = lines[i];
|
|
99
|
+
if (MANAGED_BLOCK_START.test(line)) {
|
|
100
|
+
inManagedBlock = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (inManagedBlock) {
|
|
104
|
+
if (MANAGED_BLOCK_END.test(line)) inManagedBlock = false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (CODE_FENCE.test(line)) {
|
|
108
|
+
inFence = !inFence;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (inFence) continue;
|
|
112
|
+
|
|
113
|
+
const h = HEADING.exec(line);
|
|
114
|
+
if (h) {
|
|
115
|
+
heading = h[2].trim();
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const item = { line: i + 1, heading, type: inferFactType(heading) };
|
|
120
|
+
const m = LIST_ITEM.exec(line);
|
|
121
|
+
if (m && m[1].trim().length >= MIN_CANDIDATE_CHARS) {
|
|
122
|
+
bullets.push({ ...item, text: m[1].trim() });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const t = line.trim();
|
|
126
|
+
if (!m && t.length >= MIN_CANDIDATE_CHARS && !t.startsWith('<!--')) {
|
|
127
|
+
plain.push({ ...item, text: t });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return bullets.length > 0 ? bullets : plain;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Canonical forms already present in memory: every MEMORY.md scratchpad
|
|
135
|
+
// bullet + every granular fact body. Imported fact bodies are the bare rule
|
|
136
|
+
// text, so a re-run canonicalize-matches its own first run here.
|
|
137
|
+
function collectExistingCanonical(projectRoot) {
|
|
138
|
+
const existing = new Set();
|
|
139
|
+
const memPath = join(projectRoot, 'context', 'MEMORY.md');
|
|
140
|
+
if (existsSync(memPath)) {
|
|
141
|
+
try {
|
|
142
|
+
for (const line of readFileSync(memPath, 'utf8').split(/\r?\n/)) {
|
|
143
|
+
const m = LIST_ITEM.exec(line);
|
|
144
|
+
if (m) {
|
|
145
|
+
const c = canonicalize(m[1].trim());
|
|
146
|
+
if (c) existing.add(c);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// best-effort: unreadable scratchpad means no dedup hits from it
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const factDir = join(projectRoot, 'context', 'memory');
|
|
154
|
+
if (existsSync(factDir)) {
|
|
155
|
+
for (const name of readdirSync(factDir)) {
|
|
156
|
+
if (!name.endsWith('.md') || name === 'INDEX.md') continue;
|
|
157
|
+
try {
|
|
158
|
+
const { body } = parseFrontmatter(readFileSync(join(factDir, name), 'utf8'));
|
|
159
|
+
const c = canonicalize(String(body ?? '').trim());
|
|
160
|
+
if (c) existing.add(c);
|
|
161
|
+
} catch {
|
|
162
|
+
// skip unparseable files; writeFact's own id dedup still backstops
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return existing;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run the import pipeline.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} opts
|
|
173
|
+
* @param {string} opts.projectRoot
|
|
174
|
+
* @param {string} [opts.file] - rules file, relative to projectRoot or absolute (default CLAUDE.md)
|
|
175
|
+
* @param {string} [opts.now]
|
|
176
|
+
* @param {boolean} [opts.dryRun] - preview proposals; no file modified
|
|
177
|
+
* @param {boolean} [opts.acceptAll] - apply every proposal (the CLI's --yes)
|
|
178
|
+
* @param {Function} [opts.writeFactImpl] - test seam (default: the real writeFact)
|
|
179
|
+
* @returns {Promise<object>}
|
|
180
|
+
*/
|
|
181
|
+
export async function importClaudeMd({
|
|
182
|
+
projectRoot,
|
|
183
|
+
file,
|
|
184
|
+
now,
|
|
185
|
+
dryRun = false,
|
|
186
|
+
acceptAll = false,
|
|
187
|
+
writeFactImpl = writeFact,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const ts = now ?? nowIso();
|
|
190
|
+
const t0 = Date.now();
|
|
191
|
+
|
|
192
|
+
if (!projectRoot) {
|
|
193
|
+
return errorResult({
|
|
194
|
+
category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
|
|
195
|
+
errors: ['projectRoot is required'],
|
|
196
|
+
duration_ms: Date.now() - t0,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const fileRel = file && String(file).trim() ? String(file).trim() : DEFAULT_FILE;
|
|
201
|
+
const sourcePath = isAbsolute(fileRel) ? fileRel : join(projectRoot, fileRel);
|
|
202
|
+
const done = (extra) => ({
|
|
203
|
+
action: 'completed',
|
|
204
|
+
proposals: [],
|
|
205
|
+
accepted: 0,
|
|
206
|
+
skipped: 0,
|
|
207
|
+
rejected: 0,
|
|
208
|
+
errors: 0,
|
|
209
|
+
sourcePath,
|
|
210
|
+
duration_ms: Date.now() - t0,
|
|
211
|
+
...extra,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (!existsSync(sourcePath)) return done({ reason: 'no-source' });
|
|
215
|
+
|
|
216
|
+
let sourceText;
|
|
217
|
+
try {
|
|
218
|
+
sourceText = readFileSync(sourcePath, 'utf8');
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return done({ errors: 1, reason: `read-source-failed: ${err?.message ?? err}` });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const existingCanonical = collectExistingCanonical(projectRoot);
|
|
224
|
+
const tierRoot = join(projectRoot, 'context');
|
|
225
|
+
const proposals = [];
|
|
226
|
+
let skipped = 0;
|
|
227
|
+
// Dry-run / requires-confirmation must not touch ANY file — including the
|
|
228
|
+
// audit log. Skip entries are only audited when the user actually applied.
|
|
229
|
+
const auditSkips = acceptAll && !dryRun;
|
|
230
|
+
|
|
231
|
+
for (const candidate of parseRulesFile(sourceText)) {
|
|
232
|
+
// Sanitize BEFORE canonicalizing so the dedup key matches what writeFact
|
|
233
|
+
// actually lands on disk (it ids the sanitized body).
|
|
234
|
+
const sanitized = sanitizeHomePaths(candidate.text);
|
|
235
|
+
const canonical = canonicalize(sanitized);
|
|
236
|
+
if (!canonical) continue;
|
|
237
|
+
const id = generateId('P', sanitized);
|
|
238
|
+
if (existingCanonical.has(canonical)) {
|
|
239
|
+
skipped += 1;
|
|
240
|
+
if (auditSkips) {
|
|
241
|
+
try {
|
|
242
|
+
appendAuditEntry(tierRoot, {
|
|
243
|
+
ts,
|
|
244
|
+
action: 'import',
|
|
245
|
+
tier: 'P',
|
|
246
|
+
id,
|
|
247
|
+
reasonCode: REASON_CODES.IMPORT_SKIPPED_DUPLICATE,
|
|
248
|
+
extra: { source: IMPORT_SOURCE },
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
// best-effort — never block the import flow on audit-log failure
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
existingCanonical.add(canonical); // same-file duplicates collapse to one proposal
|
|
257
|
+
proposals.push({
|
|
258
|
+
text: candidate.text,
|
|
259
|
+
line: candidate.line,
|
|
260
|
+
heading: candidate.heading,
|
|
261
|
+
type: candidate.type,
|
|
262
|
+
id,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (dryRun) return done({ mode: 'dry-run', proposals, skipped });
|
|
267
|
+
if (!acceptAll && proposals.length > 0) {
|
|
268
|
+
return done({ mode: 'requires-confirmation', proposals, skipped });
|
|
269
|
+
}
|
|
270
|
+
if (proposals.length === 0) return done({ mode: 'apply', skipped });
|
|
271
|
+
|
|
272
|
+
let accepted = 0;
|
|
273
|
+
let rejected = 0;
|
|
274
|
+
let errors = 0;
|
|
275
|
+
// Two distinct rules can share a 60-char slug prefix (slugifyFact caps);
|
|
276
|
+
// the second would hit writeFact's filename-collision error and be lost.
|
|
277
|
+
// De-collide within the run by suffixing the (unique) source line.
|
|
278
|
+
const usedSlugs = new Set();
|
|
279
|
+
// The committed source_file field must never carry a username from an
|
|
280
|
+
// absolute --file argument (the D-51 name-privacy class).
|
|
281
|
+
const sourceFileField = sanitizeHomePaths(fileRel);
|
|
282
|
+
for (const p of proposals) {
|
|
283
|
+
const title = p.text.split('\n')[0].slice(0, 80);
|
|
284
|
+
let slug = slugifyFact(title);
|
|
285
|
+
if (usedSlugs.has(`${p.type}/${slug}`)) slug = `${slug}-l${p.line}`;
|
|
286
|
+
usedSlugs.add(`${p.type}/${slug}`);
|
|
287
|
+
const r = writeFactImpl({
|
|
288
|
+
tier: 'P',
|
|
289
|
+
type: p.type,
|
|
290
|
+
slug,
|
|
291
|
+
title,
|
|
292
|
+
body: p.text,
|
|
293
|
+
writeSource: 'imported',
|
|
294
|
+
trust: 'medium',
|
|
295
|
+
sourceFile: sourceFileField,
|
|
296
|
+
sourceLine: p.line,
|
|
297
|
+
// Content fingerprint for provenance — NOT a security context. Routes
|
|
298
|
+
// through the shared hashContent (SHA-256, D-149); see remember-core.mjs.
|
|
299
|
+
sourceSha1: hashContent(p.text),
|
|
300
|
+
projectRoot,
|
|
301
|
+
// writeFact's default create-audit is replaced by the richer-semantic
|
|
302
|
+
// IMPORT_APPLIED entry below (the merge-facts precedent).
|
|
303
|
+
audit: false,
|
|
304
|
+
});
|
|
305
|
+
if (r.action === 'created') {
|
|
306
|
+
accepted += 1;
|
|
307
|
+
try {
|
|
308
|
+
appendAuditEntry(tierRoot, {
|
|
309
|
+
ts,
|
|
310
|
+
action: 'import',
|
|
311
|
+
tier: 'P',
|
|
312
|
+
id: r.id,
|
|
313
|
+
reasonCode: REASON_CODES.IMPORT_APPLIED,
|
|
314
|
+
paths: { after: r.path },
|
|
315
|
+
extra: { source: IMPORT_SOURCE, trust: 'medium', write_source: 'imported' },
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
// best-effort
|
|
319
|
+
}
|
|
320
|
+
} else if (r.action === 'skipped') {
|
|
321
|
+
skipped += 1;
|
|
322
|
+
} else if (r.errorCategory === ERROR_CATEGORIES.POISON_GUARD) {
|
|
323
|
+
// writeFact already logged the rejection to poison-guard.log (Door 4);
|
|
324
|
+
// count it honestly — a rejected secret is not an "error", it's the
|
|
325
|
+
// guard doing its job.
|
|
326
|
+
rejected += 1;
|
|
327
|
+
} else {
|
|
328
|
+
errors += 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return done({ mode: 'apply', proposals, accepted, skipped, rejected, errors });
|
|
333
|
+
}
|
package/src/index-rebuild.mjs
CHANGED
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
// established sources of truth and does NOT re-implement bullet/frontmatter
|
|
43
43
|
// parsing or path resolution.
|
|
44
44
|
|
|
45
|
-
import { createHash } from 'node:crypto';
|
|
46
45
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
47
46
|
import { basename, join, relative } from 'node:path';
|
|
48
47
|
import chokidar from 'chokidar';
|
|
49
48
|
import { INDEX_DB_SCHEMA } from './index-db.mjs';
|
|
49
|
+
import { hashContent } from './content-hash.mjs';
|
|
50
50
|
import { syncTranscriptChunks } from './transcript-index.mjs';
|
|
51
51
|
import { readBullet, parseBulletProvenance } from './provenance.mjs';
|
|
52
52
|
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
@@ -95,8 +95,12 @@ export function listObservationSources({ projectRoot, userDir }) {
|
|
|
95
95
|
|
|
96
96
|
// --- Helpers ----------------------------------------------------------
|
|
97
97
|
|
|
98
|
+
// Content fingerprint for the `files`-table mtime+sha1 diff key. The column
|
|
99
|
+
// name stays `sha1` for checkpoint back-compat; hashContent is SHA-256 (D-149).
|
|
100
|
+
// On the first boot after the algorithm change every checkpoint mismatches
|
|
101
|
+
// once and self-heals via the normal reindex.
|
|
98
102
|
function sha1OfContent(content) {
|
|
99
|
-
return
|
|
103
|
+
return hashContent(content);
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
function isoToEpochMs(iso) {
|
package/src/index.mjs
CHANGED
|
@@ -70,6 +70,16 @@ export function buildProgram() {
|
|
|
70
70
|
childCmd.action(() => sub.action(child.name));
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
// Task 129: a parent that has children AND its own action (e.g. `cmk
|
|
74
|
+
// config --show-origin <key>`, handled by the parent while get/set are
|
|
75
|
+
// children) must wire the parent action too — otherwise commander
|
|
76
|
+
// falls to the default "show help, exit 1" on a bare parent invocation
|
|
77
|
+
// with a flag. (Caught by the Task-129 live-test: `--show-origin`
|
|
78
|
+
// printed help instead of running.) Children still take precedence
|
|
79
|
+
// when a subcommand name is given.
|
|
80
|
+
if (typeof sub.action === 'function') {
|
|
81
|
+
cmd.action((...cmdArgs) => sub.action(...cmdArgs));
|
|
82
|
+
}
|
|
73
83
|
} else {
|
|
74
84
|
cmd.action((...cmdArgs) => sub.action(...cmdArgs));
|
|
75
85
|
}
|
package/src/inject-context.mjs
CHANGED
|
@@ -26,14 +26,19 @@ import {
|
|
|
26
26
|
readdirSync,
|
|
27
27
|
appendFileSync,
|
|
28
28
|
statSync,
|
|
29
|
+
openSync,
|
|
30
|
+
readSync,
|
|
31
|
+
closeSync,
|
|
29
32
|
} from 'node:fs';
|
|
30
33
|
import { spawn } from 'node:child_process';
|
|
31
34
|
import { join } from 'node:path';
|
|
32
35
|
import { homedir } from 'node:os';
|
|
33
|
-
import { SCRATCHPADS_BY_TIER, resolveTierRoot } from './tier-paths.mjs';
|
|
36
|
+
import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
|
|
34
37
|
import { nowIso } from './audit-log.mjs';
|
|
35
38
|
import { detectStaleness } from './lazy-compress.mjs';
|
|
36
39
|
import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
|
|
40
|
+
import { listConflictQueue } from './conflict-queue.mjs';
|
|
41
|
+
import { listReviewQueue } from './review-queue.mjs';
|
|
37
42
|
|
|
38
43
|
// Importance ranking for value-ordered inject eviction (Task 93 / design §19.3).
|
|
39
44
|
// When a tier exceeds its budget we drop the LOWEST-value sections first, not the
|
|
@@ -800,7 +805,14 @@ export function injectContext({
|
|
|
800
805
|
// 7. Emit the Anthropic SessionStart hook output shape (design §5.1 +
|
|
801
806
|
// Anthropic hook protocol). When the snapshot is empty, we still emit
|
|
802
807
|
// the shape so downstream tooling can rely on the field's presence.
|
|
808
|
+
//
|
|
809
|
+
// Task 145 (D-130): `systemMessage` is the USER-DISPLAY channel (the
|
|
810
|
+
// D-116 primary-source check: additionalContext is model-facing,
|
|
811
|
+
// systemMessage is shown to the user) — one status line per session
|
|
812
|
+
// start, zero model-token cost. The trust loop every silent system
|
|
813
|
+
// lacks: when the kit works, the user finally SEES it working.
|
|
803
814
|
const hookOutput = {
|
|
815
|
+
systemMessage: buildStatusLine({ snapshot, projectRoot, now: ts }),
|
|
804
816
|
hookSpecificOutput: {
|
|
805
817
|
hookEventName: HOOK_EVENT_NAME,
|
|
806
818
|
additionalContext: snapshot,
|
|
@@ -816,3 +828,120 @@ export function injectContext({
|
|
|
816
828
|
bytes: Buffer.byteLength(snapshot, 'utf8'),
|
|
817
829
|
};
|
|
818
830
|
}
|
|
831
|
+
|
|
832
|
+
// --- Task 145: the session-start status line (user-display) -------------
|
|
833
|
+
|
|
834
|
+
// Tail-read budget for audit.log: recency lives at the end; reading the
|
|
835
|
+
// whole file would grow with project age inside a 500ms-budget hook.
|
|
836
|
+
const STATUS_AUDIT_TAIL_BYTES = 64 * 1024;
|
|
837
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
838
|
+
// Derived from the shared ID_PATTERN (tier-paths.mjs) — strip its ^/$
|
|
839
|
+
// anchors and wrap in the `(id)` bullet form. One alphabet, one source.
|
|
840
|
+
const SNAPSHOT_ID_RE = new RegExp(`\\((${ID_PATTERN.source.slice(1, -1)})\\)`, 'g');
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* One user-facing line summarizing what the kit just did for this session.
|
|
844
|
+
* Best-effort everywhere: a status line must NEVER turn a working hook into
|
|
845
|
+
* a crash — every data source degrades to its zero independently.
|
|
846
|
+
*
|
|
847
|
+
* @param {object} opts
|
|
848
|
+
* @param {string} opts.snapshot - the composed injection snapshot.
|
|
849
|
+
* @param {string} opts.projectRoot
|
|
850
|
+
* @param {string} [opts.now]
|
|
851
|
+
* @param {Function} [opts.listConflictsImpl] - test seam (default: the real queue lister).
|
|
852
|
+
* @param {Function} [opts.listReviewImpl] - test seam.
|
|
853
|
+
* @returns {string} the status line (always a string, never throws).
|
|
854
|
+
*/
|
|
855
|
+
export function buildStatusLine({
|
|
856
|
+
snapshot,
|
|
857
|
+
projectRoot,
|
|
858
|
+
now,
|
|
859
|
+
listConflictsImpl,
|
|
860
|
+
listReviewImpl,
|
|
861
|
+
} = {}) {
|
|
862
|
+
const prefix = 'claude-memory-kit:';
|
|
863
|
+
try {
|
|
864
|
+
// 1. Unique injected fact ids — what the model can actually see.
|
|
865
|
+
const ids = new Set();
|
|
866
|
+
for (const m of String(snapshot ?? '').matchAll(SNAPSHOT_ID_RE)) ids.add(m[1]);
|
|
867
|
+
|
|
868
|
+
if (ids.size === 0) {
|
|
869
|
+
return `${prefix} memory is empty — capture starts this session`;
|
|
870
|
+
}
|
|
871
|
+
const parts = [`${ids.size} fact(s) in context`];
|
|
872
|
+
|
|
873
|
+
// 2. Captures in the last 24h, from the audit-log tail. A capture is a
|
|
874
|
+
// `created` entry or an APPLIED import — `action: 'import'` alone also
|
|
875
|
+
// covers skipped duplicates (reasonCode import-skipped-duplicate), and
|
|
876
|
+
// counting those would let a re-run import inflate the line by its
|
|
877
|
+
// whole dup count (skill-review finding, 2026-06-12).
|
|
878
|
+
const nowMs = Date.parse(now ?? nowIso());
|
|
879
|
+
let recent = 0;
|
|
880
|
+
try {
|
|
881
|
+
const auditPath = join(projectRoot, 'context', '.locks', 'audit.log');
|
|
882
|
+
if (existsSync(auditPath)) {
|
|
883
|
+
// Positioned read of the LAST 64KB only — recency lives at the end,
|
|
884
|
+
// and this runs inside the 500ms-budget SessionStart hook; reading a
|
|
885
|
+
// months-old multi-MB log in full would pay for history we discard.
|
|
886
|
+
const size = statSync(auditPath).size;
|
|
887
|
+
const start = Math.max(0, size - STATUS_AUDIT_TAIL_BYTES);
|
|
888
|
+
const buf = Buffer.alloc(size - start);
|
|
889
|
+
const fd = openSync(auditPath, 'r');
|
|
890
|
+
try {
|
|
891
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
892
|
+
} finally {
|
|
893
|
+
closeSync(fd);
|
|
894
|
+
}
|
|
895
|
+
// Drop the (possibly torn) first line when we started mid-file.
|
|
896
|
+
const tail = start > 0 ? buf.toString('utf8').replace(/^[^\n]*\n/, '') : buf.toString('utf8');
|
|
897
|
+
for (const line of tail.split(/\r?\n/)) {
|
|
898
|
+
if (!line.trim()) continue;
|
|
899
|
+
try {
|
|
900
|
+
const e = JSON.parse(line);
|
|
901
|
+
const isCapture =
|
|
902
|
+
e.action === 'created' ||
|
|
903
|
+
(e.action === 'import' && e.reasonCode === 'import-applied');
|
|
904
|
+
if (
|
|
905
|
+
isCapture &&
|
|
906
|
+
nowMs - Date.parse(e.ts) <= DAY_MS &&
|
|
907
|
+
nowMs - Date.parse(e.ts) >= 0
|
|
908
|
+
) {
|
|
909
|
+
recent += 1;
|
|
910
|
+
}
|
|
911
|
+
} catch {
|
|
912
|
+
// torn NDJSON line — skip
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
} catch {
|
|
917
|
+
// audit log unreadable — the count degrades to absent
|
|
918
|
+
}
|
|
919
|
+
if (recent > 0) parts.push(`${recent} captured in the last 24h`);
|
|
920
|
+
|
|
921
|
+
// 3. Pending curation — only mentioned when non-zero (a quiet queue
|
|
922
|
+
// earns a quiet line).
|
|
923
|
+
let conflicts = 0;
|
|
924
|
+
let review = 0;
|
|
925
|
+
try {
|
|
926
|
+
conflicts = (listConflictsImpl ?? listConflictQueue)({ tier: 'P', projectRoot }).length;
|
|
927
|
+
} catch {
|
|
928
|
+
// queue unreadable — degrade to zero
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
review = (listReviewImpl ?? listReviewQueue)({ tier: 'P', projectRoot }).length;
|
|
932
|
+
} catch {
|
|
933
|
+
// queue unreadable — degrade to zero
|
|
934
|
+
}
|
|
935
|
+
if (conflicts > 0 || review > 0) {
|
|
936
|
+
const q = [];
|
|
937
|
+
if (conflicts > 0) q.push(`${conflicts} conflict(s)`);
|
|
938
|
+
if (review > 0) q.push(`${review} review item(s)`);
|
|
939
|
+
parts.push(`${q.join(' + ')} pending — cmk queue`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return `${prefix} ${parts.join(', ')}`;
|
|
943
|
+
} catch {
|
|
944
|
+
// The line is decoration; the snapshot is the cargo. Never crash.
|
|
945
|
+
return `${prefix} memory loaded`;
|
|
946
|
+
}
|
|
947
|
+
}
|
package/src/install.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
43
43
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
45
|
import { injectClaudeMdBlock } from './claude-md.mjs';
|
|
46
|
+
import { checkKitBinding, npmSupportsAllowScripts } from './native-binding.mjs';
|
|
46
47
|
import { writeKitHooks, writeKitMcpServer } from './settings-hooks.mjs';
|
|
47
48
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
48
49
|
|
|
@@ -58,6 +59,13 @@ const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
|
|
|
58
59
|
// it must not show a stale hardcode (was `v0.1.0` in every install). Built per
|
|
59
60
|
// install from the kit version; see gitignoreStartMarker().
|
|
60
61
|
const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
|
|
62
|
+
// D-126 CRLF-prevention: the .gitattributes managed block uses the SAME
|
|
63
|
+
// marker discipline as .gitignore (version-stamped start, in-place refresh).
|
|
64
|
+
const GITATTRIBUTES_END = '# claude-memory-kit:gitattributes:end';
|
|
65
|
+
|
|
66
|
+
function gitattributesStartMarker(version) {
|
|
67
|
+
return `# claude-memory-kit:gitattributes:start v${version}`;
|
|
68
|
+
}
|
|
61
69
|
|
|
62
70
|
function gitignoreStartMarker(version) {
|
|
63
71
|
return `# claude-memory-kit:gitignore:start v${version}`;
|
|
@@ -233,6 +241,52 @@ function buildGitignoreBlock(templateDir, version = getKitVersion()) {
|
|
|
233
241
|
return `${gitignoreStartMarker(version)}\n${fragment}\n${GITIGNORE_END}\n`;
|
|
234
242
|
}
|
|
235
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Build the canonical .gitattributes managed block from
|
|
246
|
+
* template/.gitattributes.fragment (D-126 CRLF prevention — force LF on the
|
|
247
|
+
* committed memory tiers so default Windows git doesn't mangle the bytes at
|
|
248
|
+
* clone). Same marker discipline as the .gitignore block.
|
|
249
|
+
*/
|
|
250
|
+
function buildGitattributesBlock(templateDir, version = getKitVersion()) {
|
|
251
|
+
const fragmentPath = join(templateDir, '.gitattributes.fragment');
|
|
252
|
+
const fragment = existsSync(fragmentPath)
|
|
253
|
+
? readFileSync(fragmentPath, 'utf8').trim()
|
|
254
|
+
: 'context/**/*.md text eol=lf\ncontext/**/*.json text eol=lf';
|
|
255
|
+
return `${gitattributesStartMarker(version)}\n${fragment}\n${GITATTRIBUTES_END}\n`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Inject (or refresh) the managed .gitattributes block. Same algorithm as
|
|
260
|
+
* injectGitignore (create / append-if-no-markers / replace-in-place),
|
|
261
|
+
* byte-preserving everything outside the markers.
|
|
262
|
+
*
|
|
263
|
+
* Returns: { action: 'created' | 'replaced' | 'unchanged', path: string }
|
|
264
|
+
*/
|
|
265
|
+
function injectGitattributes(projectRoot, block) {
|
|
266
|
+
const gaPath = join(projectRoot, '.gitattributes');
|
|
267
|
+
const startRe = /# claude-memory-kit:gitattributes:start[^\n]*\n/;
|
|
268
|
+
const endRe = /# claude-memory-kit:gitattributes:end\n?/;
|
|
269
|
+
|
|
270
|
+
if (!existsSync(gaPath)) {
|
|
271
|
+
writeFileSync(gaPath, block, 'utf8');
|
|
272
|
+
return { action: 'created', path: gaPath };
|
|
273
|
+
}
|
|
274
|
+
const existing = readFileSync(gaPath, 'utf8');
|
|
275
|
+
const startMatch = existing.match(startRe);
|
|
276
|
+
const endMatch = existing.match(endRe);
|
|
277
|
+
if (!startMatch || !endMatch || startMatch.index > endMatch.index) {
|
|
278
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
279
|
+
writeFileSync(gaPath, existing + sep + block, 'utf8');
|
|
280
|
+
return { action: 'created', path: gaPath };
|
|
281
|
+
}
|
|
282
|
+
const before = existing.slice(0, startMatch.index);
|
|
283
|
+
const after = existing.slice(endMatch.index + endMatch[0].length);
|
|
284
|
+
const next = before + block + after;
|
|
285
|
+
if (next === existing) return { action: 'unchanged', path: gaPath };
|
|
286
|
+
writeFileSync(gaPath, next, 'utf8');
|
|
287
|
+
return { action: 'replaced', path: gaPath };
|
|
288
|
+
}
|
|
289
|
+
|
|
236
290
|
/**
|
|
237
291
|
* Inject (or refresh) the managed .gitignore block in `<projectRoot>/.gitignore`.
|
|
238
292
|
*
|
|
@@ -328,6 +382,10 @@ export async function install(options = {}) {
|
|
|
328
382
|
}
|
|
329
383
|
|
|
330
384
|
const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir, version));
|
|
385
|
+
// D-126 CRLF prevention: pin LF on the committed memory tiers so default
|
|
386
|
+
// Windows git can't mangle the bytes at clone (the read-side self-heal
|
|
387
|
+
// shipped in v0.3.0; this prevents the mangling in the first place).
|
|
388
|
+
const gitattributes = injectGitattributes(projectRoot, buildGitattributesBlock(templateDir, version));
|
|
331
389
|
|
|
332
390
|
// CLAUDE.md loader block — Task 4. Read the block content from the kit's
|
|
333
391
|
// template/ and inject (or refresh) it inside marker delimiters. Never
|
|
@@ -433,7 +491,14 @@ export async function install(options = {}) {
|
|
|
433
491
|
if (!r.ok) errors.push({ path: r.path, error: r.error });
|
|
434
492
|
}
|
|
435
493
|
|
|
436
|
-
|
|
494
|
+
// Task 141a (D-129): probe the kit's native binding so the CLI can ask the
|
|
495
|
+
// user to fix it INLINE (npm 12 blocks better-sqlite3's binding build on a
|
|
496
|
+
// fresh install). Reported, never an installer error — scaffold + hooks
|
|
497
|
+
// are fully functional without it; only search/reindex need the binding.
|
|
498
|
+
const bindingProbe = options.bindingProbe ?? checkKitBinding;
|
|
499
|
+
const nativeBinding = bindingProbe();
|
|
500
|
+
|
|
501
|
+
return { projectRoot, userTier, created, skipped, gitignore, gitattributes, claudeMd, hooks, mcpServer, semantic, nativeBinding, errors };
|
|
437
502
|
}
|
|
438
503
|
|
|
439
504
|
/**
|
|
@@ -470,10 +535,18 @@ export function mergeProjectSettings(projectRoot, patch) {
|
|
|
470
535
|
*/
|
|
471
536
|
export function buildDefaultNpmRunner({ spawnSyncImpl = spawnSync } = {}) {
|
|
472
537
|
return () => {
|
|
538
|
+
// Task 141a (D-129): on npm ≥ 11.16 the `allow-scripts` config exists
|
|
539
|
+
// and npm 12 BLOCKS onnxruntime-node's install script without it — the
|
|
540
|
+
// kit runs this install itself, so it carries the allow flag itself
|
|
541
|
+
// (no user friction). Older npm: plain command, no unknown-config noise.
|
|
542
|
+
const { supported } = npmSupportsAllowScripts({ spawnSyncImpl });
|
|
543
|
+
const cmd = supported
|
|
544
|
+
? 'npm install -g @huggingface/transformers --allow-scripts=onnxruntime-node'
|
|
545
|
+
: 'npm install -g @huggingface/transformers';
|
|
473
546
|
// One constant command string under shell:true (no user input — and
|
|
474
547
|
// an args array + shell:true trips Node's DEP0190). npm is npm.cmd
|
|
475
548
|
// on Windows; the shell resolves it cross-platform.
|
|
476
|
-
const r = spawnSyncImpl(
|
|
549
|
+
const r = spawnSyncImpl(cmd, {
|
|
477
550
|
encoding: 'utf8',
|
|
478
551
|
stdio: 'inherit',
|
|
479
552
|
shell: true,
|
package/src/mcp-server.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { openIndexDb } from './index-db.mjs';
|
|
|
40
40
|
import { reindexBoot } from './index-rebuild.mjs';
|
|
41
41
|
import { search, SEARCH_MODES } from './search.mjs';
|
|
42
42
|
import { memoryWrite } from './memory-write.mjs';
|
|
43
|
-
import { rememberRich, nonProjectTierNote } from './remember-core.mjs';
|
|
43
|
+
import { rememberRich, nonProjectTierNote, prepareNearDupGuard } from './remember-core.mjs';
|
|
44
44
|
import { forget } from './forget.mjs';
|
|
45
45
|
import { overrideTrust } from './trust.mjs';
|
|
46
46
|
import { lessonsPromote } from './lessons-promote.mjs';
|
|
@@ -281,6 +281,10 @@ function makeMkRemember({ projectRoot, userDir }) {
|
|
|
281
281
|
],
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
|
+
// Task 143 (D-130): the semantic near-dup guard (one embed of the
|
|
285
|
+
// incoming text when the project is semantic-configured + the embedder
|
|
286
|
+
// is available; {} = literal pipeline, never blocks capture).
|
|
287
|
+
const nearDup = await prepareNearDupGuard({ projectRoot, text });
|
|
284
288
|
const r = memoryWrite({
|
|
285
289
|
action: 'add',
|
|
286
290
|
text,
|
|
@@ -291,6 +295,7 @@ function makeMkRemember({ projectRoot, userDir }) {
|
|
|
291
295
|
sessionId: 'mcp-server',
|
|
292
296
|
projectRoot,
|
|
293
297
|
userDir,
|
|
298
|
+
...nearDup,
|
|
294
299
|
});
|
|
295
300
|
if (r.action === 'error') {
|
|
296
301
|
return {
|