@lh8ppl/claude-memory-kit 0.2.1 → 0.2.3
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 +7 -6
- package/bin/cmk-capture-prompt.mjs +17 -17
- package/bin/cmk-capture-turn.mjs +22 -21
- package/bin/cmk-compress-session.mjs +2 -2
- package/bin/cmk-inject-context.mjs +11 -11
- package/bin/cmk-observe-edit.mjs +17 -16
- package/package.json +1 -1
- package/src/audit-log.mjs +1 -0
- package/src/auto-extract.mjs +258 -6
- package/src/auto-persona.mjs +40 -8
- package/src/capture-turn.mjs +48 -1
- package/src/compress-session.mjs +89 -26
- package/src/compressor.mjs +1 -1
- package/src/conflict-queue.mjs +14 -0
- package/src/doctor.mjs +3 -3
- package/src/forget.mjs +29 -0
- package/src/graduation.mjs +1 -1
- package/src/index-rebuild.mjs +42 -0
- package/src/inject-context.mjs +5 -1
- package/src/install.mjs +29 -6
- package/src/lazy-compress.mjs +58 -9
- package/src/mcp-server.mjs +353 -124
- package/src/merge-facts.mjs +4 -0
- package/src/persona-portability.mjs +24 -1
- package/src/read-core.mjs +87 -0
- package/src/register-crons.mjs +64 -33
- package/src/remember-core.mjs +91 -0
- package/src/review-queue.mjs +13 -0
- package/src/rich-fact.mjs +46 -0
- package/src/settings-hooks.mjs +56 -2
- package/src/subcommands.mjs +419 -182
- package/src/weekly-curate.mjs +5 -0
- package/src/write-fact.mjs +25 -1
- package/template/.claude/skills/memory-write/SKILL.md +52 -35
- package/template/.gitignore.fragment +9 -3
- package/template/CLAUDE.md.template +2 -2
- package/template/docs/journey/journey-log.md.template +1 -1
package/src/mcp-server.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// MCP server (Task 31, T-027). Layer 5's final task — closes Layer 5.
|
|
2
2
|
//
|
|
3
|
-
// Per design §10 + tasks.md 31:
|
|
3
|
+
// Per design §10 + tasks.md 31 + ADR-0014 (Task 108b parity):
|
|
4
4
|
// - stdio JSON-RPC transport per MCP 2025-06-18 spec
|
|
5
|
-
// -
|
|
6
|
-
// mk_recent_activity
|
|
5
|
+
// - Eleven tools (full CLI parity): READ — mk_search, mk_get, mk_timeline,
|
|
6
|
+
// mk_cite, mk_recent_activity; WRITE/MUTATE — mk_remember, mk_trust,
|
|
7
|
+
// mk_lessons_promote, mk_forget, mk_queue_list, mk_queue_resolve
|
|
7
8
|
// - Path-traversal validation on every read/write surface
|
|
8
9
|
// - All logs to stderr (or sessions/{date}.mcp.log); stdout pure
|
|
9
10
|
//
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
// handshake + tool listing. We register tool handlers; the SDK handles
|
|
19
20
|
// the protocol envelope.
|
|
20
21
|
//
|
|
21
|
-
//
|
|
22
|
+
// The user's 2026-05-23 decision: @modelcontextprotocol/sdk library naming
|
|
22
23
|
// goes in tasks.md Task 31 implementation, NOT in design.md. This
|
|
23
24
|
// module is where the dep choice lands.
|
|
24
25
|
//
|
|
@@ -32,12 +33,29 @@
|
|
|
32
33
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
33
34
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
34
35
|
import { z } from 'zod';
|
|
35
|
-
import {
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { resolve as resolvePath, isAbsolute, dirname, join } from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
36
39
|
import { openIndexDb } from './index-db.mjs';
|
|
37
40
|
import { reindexBoot } from './index-rebuild.mjs';
|
|
38
41
|
import { search, SEARCH_MODES } from './search.mjs';
|
|
39
42
|
import { memoryWrite } from './memory-write.mjs';
|
|
40
|
-
import {
|
|
43
|
+
import { rememberRich, nonProjectTierNote } from './remember-core.mjs';
|
|
44
|
+
import { forget } from './forget.mjs';
|
|
45
|
+
import { overrideTrust } from './trust.mjs';
|
|
46
|
+
import { lessonsPromote } from './lessons-promote.mjs';
|
|
47
|
+
import { resolveReviewQueue, listReviewQueue } from './review-queue.mjs';
|
|
48
|
+
import { resolveConflictQueue, listConflictQueue } from './conflict-queue.mjs';
|
|
49
|
+
import { createHash } from 'node:crypto';
|
|
50
|
+
import { getObservations, citeLink, buildTimeline, recentActivity } from './read-core.mjs';
|
|
51
|
+
import { resolveTierRoot } from './tier-paths.mjs';
|
|
52
|
+
|
|
53
|
+
// The kit version, read from package.json — NOT hardcoded. A hardcoded '0.1.0'
|
|
54
|
+
// shipped through the v0.2.3 cut and advertised the WRONG version to the MCP
|
|
55
|
+
// client (D-102 / Task 121.1). PKG_ROOT is one level up from src/.
|
|
56
|
+
const PKG_VERSION = JSON.parse(
|
|
57
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
|
58
|
+
).version;
|
|
41
59
|
|
|
42
60
|
// --- Path-traversal validation (design §10.2; tasks.md 31.2) ----------
|
|
43
61
|
|
|
@@ -112,130 +130,106 @@ function makeMkSearch({ db, semanticBackend }) {
|
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
function makeMkGet({ db }) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`);
|
|
121
|
-
const rows = ids.map((id) => {
|
|
122
|
-
if (!ID_PATTERN.test(id)) {
|
|
123
|
-
return { id, error: 'invalid id format' };
|
|
124
|
-
}
|
|
125
|
-
const row = stmt.get(id);
|
|
126
|
-
if (!row) return { id, error: 'not found' };
|
|
127
|
-
return row;
|
|
128
|
-
});
|
|
129
|
-
return {
|
|
130
|
-
content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
|
|
131
|
-
};
|
|
132
|
-
};
|
|
133
|
+
// Thin adapter over the shared read core (read-core.getObservations) — the
|
|
134
|
+
// SAME logic the CLI `cmk get` calls (ADR-0014 parity).
|
|
135
|
+
return async ({ ids }) => ({
|
|
136
|
+
content: [{ type: 'text', text: JSON.stringify(getObservations(db, ids), null, 2) }],
|
|
137
|
+
});
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
function makeMkTimeline({ db }) {
|
|
136
|
-
//
|
|
137
|
-
// the implementation deliberately narrow: anchor by ID; return the
|
|
138
|
-
// N observations before + N after by created_at order.
|
|
141
|
+
// Thin adapter over read-core.buildTimeline (shared with CLI `cmk timeline`).
|
|
139
142
|
return async ({ anchor, depth_before, depth_after }) => {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
const anchorRow = db
|
|
149
|
-
.prepare('SELECT created_at, tier FROM observations WHERE id = ?')
|
|
150
|
-
.get(anchor);
|
|
151
|
-
if (!anchorRow) {
|
|
152
|
-
return {
|
|
153
|
-
content: [{ type: 'text', text: 'error: anchor not found' }],
|
|
154
|
-
isError: true,
|
|
155
|
-
};
|
|
143
|
+
const r = buildTimeline(db, {
|
|
144
|
+
anchor,
|
|
145
|
+
depthBefore: depth_before ?? 5,
|
|
146
|
+
depthAfter: depth_after ?? 5,
|
|
147
|
+
});
|
|
148
|
+
if (!r.ok) {
|
|
149
|
+
return { content: [{ type: 'text', text: `error: ${r.error}` }], isError: true };
|
|
156
150
|
}
|
|
157
|
-
|
|
158
|
-
// without it, observations created the same millisecond fall out
|
|
159
|
-
// of the timeline non-deterministically. Same fix in afterRows.
|
|
160
|
-
const beforeRows = db
|
|
161
|
-
.prepare(`
|
|
162
|
-
SELECT id, body, source_file, source_line, tier, trust, created_at
|
|
163
|
-
FROM observations
|
|
164
|
-
WHERE created_at < ? AND deleted_at IS NULL
|
|
165
|
-
ORDER BY created_at DESC, id DESC LIMIT ?
|
|
166
|
-
`)
|
|
167
|
-
.all(anchorRow.created_at, before);
|
|
168
|
-
const anchorFull = db
|
|
169
|
-
.prepare(`
|
|
170
|
-
SELECT id, body, source_file, source_line, tier, trust, created_at
|
|
171
|
-
FROM observations WHERE id = ?
|
|
172
|
-
`)
|
|
173
|
-
.get(anchor);
|
|
174
|
-
const afterRows = db
|
|
175
|
-
.prepare(`
|
|
176
|
-
SELECT id, body, source_file, source_line, tier, trust, created_at
|
|
177
|
-
FROM observations
|
|
178
|
-
WHERE created_at > ? AND deleted_at IS NULL
|
|
179
|
-
ORDER BY created_at ASC, id ASC LIMIT ?
|
|
180
|
-
`)
|
|
181
|
-
.all(anchorRow.created_at, after);
|
|
182
|
-
const timeline = [...beforeRows.reverse(), anchorFull, ...afterRows];
|
|
183
|
-
return {
|
|
184
|
-
content: [{ type: 'text', text: JSON.stringify(timeline, null, 2) }],
|
|
185
|
-
};
|
|
151
|
+
return { content: [{ type: 'text', text: JSON.stringify(r.timeline, null, 2) }] };
|
|
186
152
|
};
|
|
187
153
|
}
|
|
188
154
|
|
|
189
155
|
function makeMkCite() {
|
|
190
|
-
//
|
|
191
|
-
// form is
|
|
192
|
-
// `[#P-S79MJHFN](memkit://obs/P-S79MJHFN)`
|
|
156
|
+
// Thin adapter over read-core.citeLink (shared with CLI `cmk cite`). The
|
|
157
|
+
// canonical link form is `[#P-S79MJHFN](memkit://obs/P-S79MJHFN)`.
|
|
193
158
|
return async ({ id }) => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
isError: true,
|
|
198
|
-
};
|
|
159
|
+
const r = citeLink(id);
|
|
160
|
+
if (!r.ok) {
|
|
161
|
+
return { content: [{ type: 'text', text: `error: ${r.error}` }], isError: true };
|
|
199
162
|
}
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
content: [{ type: 'text', text: link }],
|
|
203
|
-
};
|
|
163
|
+
return { content: [{ type: 'text', text: r.link }] };
|
|
204
164
|
};
|
|
205
165
|
}
|
|
206
166
|
|
|
207
167
|
function makeMkRemember({ projectRoot, userDir }) {
|
|
208
|
-
return async ({ text, tier, cites }) => {
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// memoryWrite gains a cites parameter (v0.1.x).
|
|
214
|
-
// - tier 'U': the kit's user-tier templates (USER.md / HABITS.md /
|
|
215
|
-
// LESSONS.md) don't have MEMORY.md + 'Active Threads' section,
|
|
216
|
-
// so memoryWrite would fail with NOT_FOUND. v0.1.0 mk_remember
|
|
217
|
-
// only writes to project-tier MEMORY.md. (v0.1.x: parameterize
|
|
218
|
-
// scratchpad routing per tier.)
|
|
168
|
+
return async ({ text, tier, cites, why, how, type, title, links }) => {
|
|
169
|
+
// cites: memoryWrite doesn't wire cites → provenance yet. Silently dropping
|
|
170
|
+
// the array would tell the model "your citation was recorded" — false — so
|
|
171
|
+
// reject it clearly (the fact's own text is still captured if resubmitted
|
|
172
|
+
// without cites). Tracked in design §16.39.
|
|
219
173
|
if (Array.isArray(cites) && cites.length > 0) {
|
|
220
174
|
return {
|
|
221
175
|
content: [
|
|
222
176
|
{
|
|
223
177
|
type: 'text',
|
|
224
|
-
text: 'error: cites parameter not yet
|
|
178
|
+
text: 'error: the `cites` parameter is not recorded yet — resubmit the fact without it (reference related facts via `links` for [[cross-links]]).',
|
|
225
179
|
},
|
|
226
180
|
],
|
|
227
181
|
isError: true,
|
|
228
182
|
};
|
|
229
183
|
}
|
|
230
|
-
|
|
184
|
+
// tier U/L: mk_remember writes the PROJECT tier (P) regardless; a fact
|
|
185
|
+
// becomes cross-project via mk_lessons_promote, not a direct tier write
|
|
186
|
+
// (direct U/L routing is the deferred feature in design §16.40). We do NOT error — we
|
|
187
|
+
// capture at P and attach the note, CONSISTENTLY with `cmk remember`. The
|
|
188
|
+
// three adapter paths had diverged (MCP error / CLI-rich warn / CLI-terse
|
|
189
|
+
// error); the note now comes from ONE shared source (D-102 / design §16.40).
|
|
190
|
+
const tierNote = tier === 'U' || tier === 'L' ? nonProjectTierNote(tier) : null;
|
|
191
|
+
// Task 108b — MCP write parity: when rich fields (why/how/type/title/links)
|
|
192
|
+
// are present, route to the SAME shared core (remember-core.rememberRich)
|
|
193
|
+
// the CLI `cmk remember --why/--how` uses → a granular Why/How fact file, not
|
|
194
|
+
// a terse MEMORY.md bullet. Identical fact files from both surfaces (ADR-0014).
|
|
195
|
+
if (why || how || type || title || links) {
|
|
196
|
+
const rr = rememberRich(text, { why, how, type, title, links }, { projectRoot });
|
|
197
|
+
if (rr.action === 'error') {
|
|
198
|
+
return {
|
|
199
|
+
content: [
|
|
200
|
+
{
|
|
201
|
+
type: 'text',
|
|
202
|
+
text: `error (${rr.errorCategory ?? 'unknown'}): ${(rr.errors ?? [rr.errorCategory ?? 'error']).join('; ')}`,
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
isError: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (rr.action === 'skipped') {
|
|
209
|
+
return {
|
|
210
|
+
content: [
|
|
211
|
+
{
|
|
212
|
+
type: 'text',
|
|
213
|
+
text: JSON.stringify(
|
|
214
|
+
{ accepted: true, status: 'skipped', skip_reason: rr.skipReason, id: rr.id, written_to: rr.path },
|
|
215
|
+
null,
|
|
216
|
+
2,
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
231
222
|
return {
|
|
232
223
|
content: [
|
|
233
224
|
{
|
|
234
225
|
type: 'text',
|
|
235
|
-
text:
|
|
226
|
+
text: JSON.stringify(
|
|
227
|
+
{ id: rr.id, written_to: rr.path, accepted: true, action: rr.action, kind: 'rich', ...(tierNote && { tier_note: tierNote }) },
|
|
228
|
+
null,
|
|
229
|
+
2,
|
|
230
|
+
),
|
|
236
231
|
},
|
|
237
232
|
],
|
|
238
|
-
isError: true,
|
|
239
233
|
};
|
|
240
234
|
}
|
|
241
235
|
const r = memoryWrite({
|
|
@@ -291,7 +285,7 @@ function makeMkRemember({ projectRoot, userDir }) {
|
|
|
291
285
|
{
|
|
292
286
|
type: 'text',
|
|
293
287
|
text: JSON.stringify(
|
|
294
|
-
{ id: r.id, written_to: r.path, accepted: true, action: r.action },
|
|
288
|
+
{ id: r.id, written_to: r.path, accepted: true, action: r.action, ...(tierNote && { tier_note: tierNote }) },
|
|
295
289
|
null,
|
|
296
290
|
2,
|
|
297
291
|
),
|
|
@@ -302,35 +296,195 @@ function makeMkRemember({ projectRoot, userDir }) {
|
|
|
302
296
|
}
|
|
303
297
|
|
|
304
298
|
function makeMkRecentActivity({ db }) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
'24h': 24 * 60 * 60 * 1000,
|
|
308
|
-
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
309
|
-
};
|
|
299
|
+
// Thin adapter over read-core.recentActivity (shared with CLI
|
|
300
|
+
// `cmk recent-activity`).
|
|
310
301
|
return async ({ window, limit }) => {
|
|
311
|
-
const
|
|
312
|
-
if (!
|
|
302
|
+
const r = recentActivity(db, { window: window ?? '24h', limit: limit ?? 20 });
|
|
303
|
+
if (!r.ok) {
|
|
304
|
+
return { content: [{ type: 'text', text: `error: ${r.error}` }], isError: true };
|
|
305
|
+
}
|
|
306
|
+
return { content: [{ type: 'text', text: JSON.stringify(r.rows, null, 2) }] };
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- Mutate tools (Task 108b — MCP parity with the CLI) ---------------
|
|
311
|
+
//
|
|
312
|
+
// These wrap the existing CLI cores (forget / overrideTrust / lessonsPromote)
|
|
313
|
+
// so the model can perform the same mutations the user could via `cmk`. Per
|
|
314
|
+
// D-85: the user never types `cmk`; the conversation is the interface, so every
|
|
315
|
+
// mutate the CLI offers needs an MCP path the model can drive on their behalf.
|
|
316
|
+
|
|
317
|
+
/** Map a core error / not-found result to an MCP isError envelope. */
|
|
318
|
+
function mcpToolError(r) {
|
|
319
|
+
const msg = r.action === 'error'
|
|
320
|
+
? `error (${r.errorCategory ?? 'unknown'}): ${(r.errors ?? ['operation failed']).join('; ')}`
|
|
321
|
+
: `error: ${(r.errors ?? ['not found']).join('; ')}`;
|
|
322
|
+
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Deterministic confirm token for a destructive op. Derived from id + body so a
|
|
327
|
+
* caller CANNOT produce it without first seeing the preview (the digest is not
|
|
328
|
+
* knowable from the id alone) — that forces the two-step preview→confirm flow.
|
|
329
|
+
* Stable across re-calls (idempotent), so a retried confirm with the same token
|
|
330
|
+
* still works. sha256 (not sha1) — not because this is a crypto-sensitive
|
|
331
|
+
* context (it's a preview-fingerprint nonce, not auth/signing), but so static
|
|
332
|
+
* analysis isn't tripped by a "weak hash" smell on a brand-new code path.
|
|
333
|
+
*/
|
|
334
|
+
function forgetConfirmToken(id, body) {
|
|
335
|
+
return createHash('sha256').update(`${id}:${body ?? ''}`).digest('hex').slice(0, 8);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function makeMkTrust({ projectRoot, userDir }) {
|
|
339
|
+
return async ({ id, level }) => {
|
|
340
|
+
const r = overrideTrust({ id, level, projectRoot, userDir, actor: 'mcp-user-explicit' });
|
|
341
|
+
if (r.action !== 'trust-updated') return mcpToolError(r);
|
|
342
|
+
return {
|
|
343
|
+
content: [{ type: 'text', text: JSON.stringify(
|
|
344
|
+
{ accepted: true, action: r.action, id: r.id, tier: r.tier, level: r.level }, null, 2,
|
|
345
|
+
) }],
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function makeMkLessonsPromote({ projectRoot, userDir }) {
|
|
351
|
+
return async ({ id, to }) => {
|
|
352
|
+
const r = lessonsPromote({ id, projectRoot, userDir, to: to ?? 'LESSONS.md' });
|
|
353
|
+
if (r.action !== 'promoted' && r.action !== 'queued') return mcpToolError(r);
|
|
354
|
+
return {
|
|
355
|
+
content: [{ type: 'text', text: JSON.stringify(
|
|
356
|
+
{
|
|
357
|
+
accepted: true,
|
|
358
|
+
action: r.action,
|
|
359
|
+
id: r.id,
|
|
360
|
+
target: r.target,
|
|
361
|
+
section: r.section,
|
|
362
|
+
...(r.action === 'queued'
|
|
363
|
+
? { status: 'queued', hint: 'Promotion routed to the user-tier review/conflict queue — it lands once resolved.' }
|
|
364
|
+
: {}),
|
|
365
|
+
}, null, 2,
|
|
366
|
+
) }],
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function makeMkForget({ projectRoot, userDir }) {
|
|
372
|
+
return async ({ id, reason, confirm }) => {
|
|
373
|
+
// Dry pass: resolve + capture the preview via a confirm callback that
|
|
374
|
+
// refuses (returns false → action:'cancelled'), so nothing is deleted yet.
|
|
375
|
+
// A not-found / ambiguous / schema error short-circuits BEFORE the callback.
|
|
376
|
+
let preview = null;
|
|
377
|
+
const dry = forget({
|
|
378
|
+
idOrQuery: id,
|
|
379
|
+
projectRoot,
|
|
380
|
+
userDir,
|
|
381
|
+
reason,
|
|
382
|
+
confirm: (p) => { preview = p; return false; },
|
|
383
|
+
});
|
|
384
|
+
if (dry.action !== 'cancelled' || !preview) {
|
|
385
|
+
return mcpToolError(dry);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const token = forgetConfirmToken(preview.id, preview.body);
|
|
389
|
+
if (confirm !== token) {
|
|
390
|
+
// Step 1 — preview + issue the token; require a second deliberate call.
|
|
313
391
|
return {
|
|
314
|
-
content: [{ type: 'text', text:
|
|
315
|
-
|
|
392
|
+
content: [{ type: 'text', text: JSON.stringify(
|
|
393
|
+
{
|
|
394
|
+
status: 'confirm_required',
|
|
395
|
+
would_tombstone: {
|
|
396
|
+
id: preview.id,
|
|
397
|
+
tier: preview.tier,
|
|
398
|
+
title: preview.title ?? null,
|
|
399
|
+
path: preview.path,
|
|
400
|
+
body_preview: String(preview.body ?? '').slice(0, 280),
|
|
401
|
+
},
|
|
402
|
+
confirm_token: token,
|
|
403
|
+
hint: `Permanently tombstones this fact (audit trail preserved). To proceed, call mk_forget again with confirm: "${token}".`,
|
|
404
|
+
}, null, 2,
|
|
405
|
+
) }],
|
|
316
406
|
};
|
|
317
407
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
SELECT id, body, source_file, source_line, tier, trust, created_at
|
|
323
|
-
FROM observations
|
|
324
|
-
WHERE created_at >= ? AND deleted_at IS NULL
|
|
325
|
-
ORDER BY created_at DESC LIMIT ?
|
|
326
|
-
`)
|
|
327
|
-
.all(cutoff, lim);
|
|
408
|
+
|
|
409
|
+
// Step 2 — token matches → execute.
|
|
410
|
+
const r = forget({ idOrQuery: id, projectRoot, userDir, reason, yes: true });
|
|
411
|
+
if (r.action !== 'tombstoned') return mcpToolError(r);
|
|
328
412
|
return {
|
|
329
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
413
|
+
content: [{ type: 'text', text: JSON.stringify(
|
|
414
|
+
{ accepted: true, action: 'tombstoned', id: r.id, tier: r.tier, tombstoned_to: r.tombstonePath }, null, 2,
|
|
415
|
+
) }],
|
|
330
416
|
};
|
|
331
417
|
};
|
|
332
418
|
}
|
|
333
419
|
|
|
420
|
+
// The review/conflict queues resolve via interactive walkers that take a
|
|
421
|
+
// `prompter(entry) → decision` callback. Two callback shapes give a clean,
|
|
422
|
+
// non-interactive MCP surface over them WITHOUT duplicating the walk logic:
|
|
423
|
+
// - LIST: a prompter that records each entry + returns 'skip' (mutates
|
|
424
|
+
// nothing) — so the model can see what's pending and tell the user.
|
|
425
|
+
// - RESOLVE: a prompter that returns the requested action for the matching
|
|
426
|
+
// id and 'skip' for every other entry — resolves exactly one item.
|
|
427
|
+
// 'merge-both' is excluded from mk_queue_resolve: it composes the two facts'
|
|
428
|
+
// content (mergeFacts needs a merged body), which is an interactive decision —
|
|
429
|
+
// the model is pointed at `cmk queue conflicts` for that.
|
|
430
|
+
|
|
431
|
+
function makeMkQueueList({ projectRoot, userDir }) {
|
|
432
|
+
return async ({ queue }) => {
|
|
433
|
+
const q = queue ?? 'review';
|
|
434
|
+
if (q !== 'review' && q !== 'conflicts') {
|
|
435
|
+
return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`queue must be 'review' or 'conflicts' (got ${q})`] });
|
|
436
|
+
}
|
|
437
|
+
// PURE READ (code-review SR-1): list via the dedicated read helpers, NOT the
|
|
438
|
+
// resolve* walkers — those reserialize + rewrite the queue file on every call,
|
|
439
|
+
// so listing through them would mutate (mtime churn / reformat / concurrent-
|
|
440
|
+
// resolve race) on a read-only op. listReviewQueue / listConflictQueue parse
|
|
441
|
+
// the file without writing.
|
|
442
|
+
try {
|
|
443
|
+
const entries = q === 'review'
|
|
444
|
+
? listReviewQueue({ tier: 'P', projectRoot, userDir })
|
|
445
|
+
: listConflictQueue({ tier: 'P', projectRoot, userDir });
|
|
446
|
+
return {
|
|
447
|
+
content: [{ type: 'text', text: JSON.stringify({ queue: q, pending: entries.length, entries }, null, 2) }],
|
|
448
|
+
};
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return { content: [{ type: 'text', text: `error: ${err?.message ?? err}` }], isError: true };
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function makeMkQueueResolve({ projectRoot, userDir }) {
|
|
456
|
+
return async ({ queue, id, action }) => {
|
|
457
|
+
const q = queue ?? 'review';
|
|
458
|
+
if (q === 'review') {
|
|
459
|
+
if (action !== 'promote' && action !== 'discard') {
|
|
460
|
+
return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`review action must be 'promote' or 'discard' (got ${action})`] });
|
|
461
|
+
}
|
|
462
|
+
const prompter = async (e) => (e.id === id ? action : 'skip');
|
|
463
|
+
const r = await resolveReviewQueue({ tier: 'P', projectRoot, userDir, prompter });
|
|
464
|
+
if (r.action === 'error') return mcpToolError(r);
|
|
465
|
+
const count = action === 'promote' ? r.promoted : r.discarded;
|
|
466
|
+
return {
|
|
467
|
+
content: [{ type: 'text', text: JSON.stringify({ accepted: count > 0, queue: 'review', id, action, result: r }, null, 2) }],
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (q === 'conflicts') {
|
|
471
|
+
if (action === 'merge-both') {
|
|
472
|
+
return mcpToolError({ action: 'error', errorCategory: 'schema', errors: ["merge-both composes the two facts' content — run `cmk queue conflicts` for an interactive merge. mk_queue_resolve supports 'keep-old' / 'keep-new'."] });
|
|
473
|
+
}
|
|
474
|
+
if (action !== 'keep-old' && action !== 'keep-new') {
|
|
475
|
+
return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`conflict action must be 'keep-old' or 'keep-new' (got ${action})`] });
|
|
476
|
+
}
|
|
477
|
+
const prompter = async (e) => (e.proposedId === id ? action : 'skip');
|
|
478
|
+
const r = await resolveConflictQueue({ tier: 'P', projectRoot, userDir, prompter });
|
|
479
|
+
if (r.action === 'error') return mcpToolError(r);
|
|
480
|
+
return {
|
|
481
|
+
content: [{ type: 'text', text: JSON.stringify({ accepted: r.resolved > 0, queue: 'conflicts', id, action, result: r }, null, 2) }],
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`queue must be 'review' or 'conflicts' (got ${q})`] });
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
334
488
|
// --- Server build + run ----------------------------------------------
|
|
335
489
|
|
|
336
490
|
/**
|
|
@@ -344,7 +498,7 @@ function makeMkRecentActivity({ db }) {
|
|
|
344
498
|
export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
|
|
345
499
|
const server = new McpServer({
|
|
346
500
|
name: 'cmk',
|
|
347
|
-
version:
|
|
501
|
+
version: PKG_VERSION,
|
|
348
502
|
});
|
|
349
503
|
|
|
350
504
|
// mk_search
|
|
@@ -414,8 +568,15 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
|
|
|
414
568
|
description: 'Explicit user-driven save to kit memory with audit trail.',
|
|
415
569
|
inputSchema: {
|
|
416
570
|
text: z.string().min(1).max(5000).describe('the fact text (max 5000 chars)'),
|
|
417
|
-
tier: z.enum(['U', 'P', 'L']).optional(),
|
|
418
|
-
cites: z.array(z.string()).optional(),
|
|
571
|
+
tier: z.enum(['U', 'P', 'L']).optional().describe("target tier (default P). U/L are captured to the project tier (P) with a note — use mk_lessons_promote to make a fact cross-project"),
|
|
572
|
+
cites: z.array(z.string()).optional().describe('not recorded yet — omit it'),
|
|
573
|
+
// Task 108b — rich capture parity with the CLI `cmk remember --why/--how`.
|
|
574
|
+
// Any of these routes to a granular Why/How fact file (not a terse bullet).
|
|
575
|
+
why: z.string().max(5000).optional().describe('rich: the rationale (the **Why:** block)'),
|
|
576
|
+
how: z.string().max(5000).optional().describe('rich: how to apply it (the **How to apply:** block)'),
|
|
577
|
+
type: z.enum(['feedback', 'project', 'reference', 'user']).optional().describe('rich: fact type (default feedback)'),
|
|
578
|
+
title: z.string().max(200).optional().describe('rich: short title (also the fact-file slug)'),
|
|
579
|
+
links: z.array(z.string()).optional().describe('rich: related fact names for [[cross-links]]'),
|
|
419
580
|
},
|
|
420
581
|
},
|
|
421
582
|
makeMkRemember({ projectRoot, userDir }),
|
|
@@ -425,7 +586,7 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
|
|
|
425
586
|
server.registerTool(
|
|
426
587
|
'mk_recent_activity',
|
|
427
588
|
{
|
|
428
|
-
description: 'List
|
|
589
|
+
description: 'List recently added observations within a time window (by creation time).',
|
|
429
590
|
inputSchema: {
|
|
430
591
|
window: z.enum(['1h', '24h', '7d']).optional(),
|
|
431
592
|
limit: z.number().int().positive().max(1000).optional(),
|
|
@@ -434,6 +595,74 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
|
|
|
434
595
|
makeMkRecentActivity({ db }),
|
|
435
596
|
);
|
|
436
597
|
|
|
598
|
+
// mk_trust (Task 108b — mutate parity). Reversible; audited.
|
|
599
|
+
server.registerTool(
|
|
600
|
+
'mk_trust',
|
|
601
|
+
{
|
|
602
|
+
description: 'Override the trust level (low|medium|high) of a fact or bullet by ID. Reversible + audited. Parity with `cmk trust`.',
|
|
603
|
+
inputSchema: {
|
|
604
|
+
id: z.string().describe('kit observation ID'),
|
|
605
|
+
level: z.enum(['low', 'medium', 'high']).describe('the new trust level'),
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
makeMkTrust({ projectRoot, userDir }),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
// mk_lessons_promote (Task 108b — mutate parity). Sanitized + audited.
|
|
612
|
+
server.registerTool(
|
|
613
|
+
'mk_lessons_promote',
|
|
614
|
+
{
|
|
615
|
+
description: 'Promote a project-tier (P-) fact to the cross-project user tier so it applies in every project. Sanitized + secret-screened + audited. Parity with `cmk lessons promote`.',
|
|
616
|
+
inputSchema: {
|
|
617
|
+
id: z.string().describe('kit observation ID (a project-tier P- fact)'),
|
|
618
|
+
to: z.enum(['USER.md', 'HABITS.md', 'LESSONS.md']).optional().describe('target user-tier file (default LESSONS.md)'),
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
makeMkLessonsPromote({ projectRoot, userDir }),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
// mk_forget (Task 108b — DESTRUCTIVE mutate parity). Two-step confirm-token:
|
|
625
|
+
// the first call previews + returns a confirm_token; call again with that
|
|
626
|
+
// token to execute. Tombstones (audit trail preserved), never hard-deletes.
|
|
627
|
+
server.registerTool(
|
|
628
|
+
'mk_forget',
|
|
629
|
+
{
|
|
630
|
+
description: 'Tombstone (forget) a fact by ID. DESTRUCTIVE + two-step: the first call previews what would be removed and returns a confirm_token; call again with confirm set to that token to execute. Audit trail preserved. Parity with `cmk forget`.',
|
|
631
|
+
inputSchema: {
|
|
632
|
+
id: z.string().describe('kit observation ID to tombstone'),
|
|
633
|
+
reason: z.string().max(500).optional().describe('why it is being forgotten (audited)'),
|
|
634
|
+
confirm: z.string().optional().describe('the confirm_token from the preview call — required to actually delete'),
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
makeMkForget({ projectRoot, userDir }),
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
// mk_queue_list (Task 108b — queue parity). Read-only: show pending entries.
|
|
641
|
+
server.registerTool(
|
|
642
|
+
'mk_queue_list',
|
|
643
|
+
{
|
|
644
|
+
description: "List pending entries in the review queue (medium-trust auto-extracts awaiting promotion) or the conflict queue (writes that clashed with existing facts). Read-only. Parity with `cmk queue review` / `cmk queue conflicts`.",
|
|
645
|
+
inputSchema: {
|
|
646
|
+
queue: z.enum(['review', 'conflicts']).optional().describe("which queue (default 'review')"),
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
makeMkQueueList({ projectRoot, userDir }),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// mk_queue_resolve (Task 108b — queue parity). Resolve one entry by id.
|
|
653
|
+
server.registerTool(
|
|
654
|
+
'mk_queue_resolve',
|
|
655
|
+
{
|
|
656
|
+
description: "Resolve one queued entry by ID. review: 'promote' (land it in MEMORY.md at high trust) | 'discard'. conflicts: 'keep-old' | 'keep-new' (merge-both composes content — use `cmk queue conflicts`). Audited.",
|
|
657
|
+
inputSchema: {
|
|
658
|
+
queue: z.enum(['review', 'conflicts']).describe('which queue the entry is in'),
|
|
659
|
+
id: z.string().describe('the queued entry ID to resolve'),
|
|
660
|
+
action: z.enum(['promote', 'discard', 'keep-old', 'keep-new', 'merge-both']).describe('review: promote|discard; conflicts: keep-old|keep-new (merge-both → use `cmk queue conflicts`)'),
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
makeMkQueueResolve({ projectRoot, userDir }),
|
|
664
|
+
);
|
|
665
|
+
|
|
437
666
|
return server;
|
|
438
667
|
}
|
|
439
668
|
|
package/src/merge-facts.mjs
CHANGED
|
@@ -168,6 +168,10 @@ export function mergeFacts(opts = {}) {
|
|
|
168
168
|
tags: mergedTags,
|
|
169
169
|
projectRoot,
|
|
170
170
|
userDir,
|
|
171
|
+
// A merge emits its own richer `merged`/CURATED_MERGE audit below — suppress
|
|
172
|
+
// writeFact's default `created` entry so the merge logs exactly one event
|
|
173
|
+
// (Task 123.A opt-out).
|
|
174
|
+
audit: false,
|
|
171
175
|
});
|
|
172
176
|
if (writeResult.action === 'error') {
|
|
173
177
|
return errorResult({
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
statSync,
|
|
27
27
|
renameSync,
|
|
28
28
|
unlinkSync,
|
|
29
|
+
copyFileSync,
|
|
29
30
|
} from 'node:fs';
|
|
30
31
|
import { join, dirname } from 'node:path';
|
|
31
32
|
import { reindex } from './reindex.mjs';
|
|
@@ -142,7 +143,7 @@ function rollbackImport(created, renamed) {
|
|
|
142
143
|
try {
|
|
143
144
|
if (existsSync(bkp)) {
|
|
144
145
|
mkdirSync(dirname(dest), { recursive: true });
|
|
145
|
-
|
|
146
|
+
restoreBackup(bkp, dest);
|
|
146
147
|
}
|
|
147
148
|
} catch {
|
|
148
149
|
/* best-effort — the backup copy still exists for manual recovery */
|
|
@@ -150,6 +151,28 @@ function rollbackImport(created, renamed) {
|
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
// Restore a backed-up file over `dest` (Task 116.x). `dest` may already hold the
|
|
155
|
+
// half-applied NEW content (Phase-2 write before the failure), so this OVERWRITES
|
|
156
|
+
// it. The old code used `renameSync(bkp, dest)`, but renaming ONTO an existing
|
|
157
|
+
// file can intermittently throw EPERM/EBUSY on Windows under heavy parallel FS
|
|
158
|
+
// load — and rollbackImport's silent catch then left the un-rolled-back NEW file
|
|
159
|
+
// in place (the rare persona-portability rollback flake). `copyFileSync` overwrites
|
|
160
|
+
// the destination reliably on every platform; a short retry covers a transient
|
|
161
|
+
// lock. The backup is removed only after a confirmed copy.
|
|
162
|
+
export function restoreBackup(bkp, dest) {
|
|
163
|
+
let lastErr;
|
|
164
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
165
|
+
try {
|
|
166
|
+
copyFileSync(bkp, dest);
|
|
167
|
+
try { unlinkSync(bkp); } catch { /* backup cleanup best-effort */ }
|
|
168
|
+
return;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
lastErr = err; // transient EPERM/EBUSY under load — retry
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw lastErr;
|
|
174
|
+
}
|
|
175
|
+
|
|
153
176
|
// Apply the bundle's files TRANSACTIONALLY (the Task-91 rollback discipline):
|
|
154
177
|
// back up every existing target first, then write all files, and if ANY write
|
|
155
178
|
// fails partway, roll the whole thing back so a mid-import disk/permission error
|