@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.
Files changed (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,166 @@
1
+ // Lock-file discipline + stale-lock detection (Task 23.10, design §6.9).
2
+ //
3
+ // Public boundary:
4
+ // - pidIsAlive(pid) → boolean
5
+ // Liveness probe via `process.kill(pid, 0)`. POSIX: signal 0 is
6
+ // a permission/existence check (does not actually signal).
7
+ // Windows: node's process.kill maps signal 0 to OpenProcess →
8
+ // returns true if the process handle opens. ESRCH = no such
9
+ // process; EPERM = process exists but we can't signal it
10
+ // (still alive). Non-numeric / negative / NaN inputs return false.
11
+ //
12
+ // - detectStaleLocks(projectRoot, {userDir}) → Array<LockReport>
13
+ // Scans context/.locks/*.lock under projectRoot (and ~/.locks/*.lock
14
+ // under userDir if supplied), parses the PID inside each, and
15
+ // reports which locks are held vs. stale. Returns an empty
16
+ // array when no .locks/ directory exists. Skips non-lock files
17
+ // (audit.log, last-haiku-call.ts, etc.) by extension match.
18
+ //
19
+ // LockReport shape:
20
+ // {
21
+ // path: string, // absolute path to the lock file
22
+ // pid: number | null, // parsed pid; null on unparseable
23
+ // holderAlive: boolean, // pidIsAlive(pid); false on null pid
24
+ // stale: boolean, // !holderAlive (or unparseable)
25
+ // reason?: string, // explanation when stale (e.g. "unparseable pid")
26
+ // recoveryCommand?: string, // user-facing rm command to clear
27
+ // }
28
+ //
29
+ // Consumed by:
30
+ // - cmk doctor HC-9 (Task 37 when it ships) — surfaces stale locks
31
+ // in the diagnostic report with the recoveryCommand for each.
32
+ // - auto-extract.mjs stale-recovery path: uses the same pidIsAlive
33
+ // probe inline today; Layer 4 close will consolidate.
34
+ //
35
+ // Composition with PR-A's subprocess timeout (per design §6.9): PR-A
36
+ // closed the dominant lock-leak path (hook ceiling killing the parent
37
+ // mid-Haiku). This module is the defense for the residual cases —
38
+ // external SIGKILL, OS OOM, hardware failure, parent uncaught
39
+ // exception. Without HC-9 + the recovery command, a residual leak
40
+ // has no user-visible escape hatch.
41
+
42
+ import {
43
+ existsSync,
44
+ readdirSync,
45
+ readFileSync,
46
+ } from 'node:fs';
47
+ import { join } from 'node:path';
48
+ import { removeFile as removeFileCmd } from './platform-commands.mjs';
49
+
50
+ // Recovery command is delegated to the shared platform-commands
51
+ // helper (PR-E generalized this inline pattern PR-B established;
52
+ // see packages/cli/src/platform-commands.mjs + design §18).
53
+ // `removeFile` returns a copy-paste-ready command in the user's
54
+ // native shell — Remove-Item on Windows, rm on POSIX.
55
+ function recoveryCommandFor(lockPath) {
56
+ return removeFileCmd(lockPath);
57
+ }
58
+
59
+ // Note on pid=0: POSIX defines kill(0, sig) as "signal every process
60
+ // in the caller's process group" — calling it for a liveness probe
61
+ // is incorrect (and dangerous if the caller mistakenly uses a real
62
+ // signal later). The original inlined version in auto-extract.mjs
63
+ // passed pid 0 through to process.kill (which returned true on
64
+ // success), so `pidIsAlive(0)` returned true. This consolidation
65
+ // rejects pid 0 in the input-validation guard instead — kit lock
66
+ // files never legitimately hold pid 0 (auto-extract writes
67
+ // `String(process.pid)`, which is the live process's id > 0). The
68
+ // behavior change is intentional input hardening, pinned by the
69
+ // `pidIsAlive(0) → false` test case.
70
+ export function pidIsAlive(pid) {
71
+ if (!Number.isInteger(pid) || pid <= 0) return false;
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ } catch (err) {
76
+ // ESRCH = no such process. EPERM = process exists but we lack
77
+ // permission to signal it (still alive — count as alive).
78
+ return err.code === 'EPERM';
79
+ }
80
+ }
81
+
82
+ function parseLockPid(lockPath) {
83
+ // Returns the integer pid the lock contains, or null if the file
84
+ // is unreadable / empty / non-numeric. The current
85
+ // auto-extract.mjs writeFileSync writes `String(process.pid)`
86
+ // verbatim; future PID-reuse hardening (write `{pid, started_at}`
87
+ // JSON) would extend this parser to handle both shapes.
88
+ let raw;
89
+ try {
90
+ raw = readFileSync(lockPath, 'utf8');
91
+ } catch {
92
+ return null;
93
+ }
94
+ const trimmed = raw.trim();
95
+ if (trimmed === '') return null;
96
+ const n = Number.parseInt(trimmed, 10);
97
+ if (!Number.isInteger(n) || n <= 0) return null;
98
+ return n;
99
+ }
100
+
101
+ function scanLocksDir(locksDir) {
102
+ if (!existsSync(locksDir)) return [];
103
+ let entries;
104
+ try {
105
+ entries = readdirSync(locksDir, { withFileTypes: true });
106
+ } catch {
107
+ return [];
108
+ }
109
+ const out = [];
110
+ for (const entry of entries) {
111
+ if (!entry.isFile()) continue;
112
+ if (!entry.name.endsWith('.lock')) continue;
113
+ out.push(join(locksDir, entry.name));
114
+ }
115
+ return out;
116
+ }
117
+
118
+ function buildReport(lockPath) {
119
+ const pid = parseLockPid(lockPath);
120
+ if (pid === null) {
121
+ return {
122
+ path: lockPath,
123
+ pid: null,
124
+ holderAlive: false,
125
+ stale: true,
126
+ reason: 'unparseable pid (empty file or non-numeric contents)',
127
+ recoveryCommand: recoveryCommandFor(lockPath),
128
+ };
129
+ }
130
+ const alive = pidIsAlive(pid);
131
+ if (alive) {
132
+ return {
133
+ path: lockPath,
134
+ pid,
135
+ holderAlive: true,
136
+ stale: false,
137
+ };
138
+ }
139
+ return {
140
+ path: lockPath,
141
+ pid,
142
+ holderAlive: false,
143
+ stale: true,
144
+ reason: `pid ${pid} no longer alive (holder process died without releasing lock)`,
145
+ recoveryCommand: recoveryCommandFor(lockPath),
146
+ };
147
+ }
148
+
149
+ export function detectStaleLocks(projectRoot, { userDir } = {}) {
150
+ // Defensive guard: cmk doctor will call this with config-derived
151
+ // paths that might be undefined when the project root hasn't been
152
+ // resolved yet. Returning an empty report is safer than throwing
153
+ // — the report consumer treats "no stale locks" as a healthy
154
+ // state, and a missing projectRoot is itself a separate diagnostic
155
+ // (cmk doctor's other checks surface it).
156
+ if (typeof projectRoot !== 'string' || projectRoot === '') return [];
157
+ const projectLocksDir = join(projectRoot, 'context', '.locks');
158
+ const userLocksDir = userDir ? join(userDir, '.locks') : null;
159
+
160
+ const lockPaths = [
161
+ ...scanLocksDir(projectLocksDir),
162
+ ...(userLocksDir ? scanLocksDir(userLocksDir) : []),
163
+ ];
164
+
165
+ return lockPaths.map(buildReport);
166
+ }
@@ -0,0 +1,498 @@
1
+ // MCP server (Task 31, T-027). Layer 5's final task — closes Layer 5.
2
+ //
3
+ // Per design §10 + tasks.md 31:
4
+ // - stdio JSON-RPC transport per MCP 2025-06-18 spec
5
+ // - Six tools: mk_search, mk_get, mk_timeline, mk_cite, mk_remember,
6
+ // mk_recent_activity
7
+ // - Path-traversal validation on every read/write surface
8
+ // - All logs to stderr (or sessions/{date}.mcp.log); stdout pure
9
+ //
10
+ // Composes on top of:
11
+ // - Task 28 index-db (the SQLite cache the server queries)
12
+ // - Task 30 search (mk_search delegates to search())
13
+ // - Task 24 memory-write (mk_remember delegates to memoryWrite())
14
+ // - Task 13 provenance (citation IDs match ID_PATTERN)
15
+ //
16
+ // The MCP SDK (@modelcontextprotocol/sdk v1.29.0, official Anthropic
17
+ // TypeScript SDK) handles JSON-RPC framing + the initialize/initialized
18
+ // handshake + tool listing. We register tool handlers; the SDK handles
19
+ // the protocol envelope.
20
+ //
21
+ // Lior 2026-05-23 decision: @modelcontextprotocol/sdk library naming
22
+ // goes in tasks.md Task 31 implementation, NOT in design.md. This
23
+ // module is where the dep choice lands.
24
+ //
25
+ // High-risk surface per tasks.md 31 — individual PR review required.
26
+ // The risk class: MCP is a protocol implementation + security boundary
27
+ // (stdio with path-traversal validation). Subtle bugs in JSON-RPC
28
+ // framing, newline handling, or path validation can introduce real
29
+ // CVEs. The SDK handles framing; we own path validation + tool body
30
+ // + error mapping.
31
+
32
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
33
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
34
+ import { z } from 'zod';
35
+ import { resolve as resolvePath, isAbsolute } from 'node:path';
36
+ import { openIndexDb } from './index-db.mjs';
37
+ import { search, SEARCH_MODES } from './search.mjs';
38
+ import { memoryWrite } from './memory-write.mjs';
39
+ import { ID_PATTERN, resolveTierRoot } from './tier-paths.mjs';
40
+
41
+ // --- Path-traversal validation (design §10.2; tasks.md 31.2) ----------
42
+
43
+ /**
44
+ * Reject paths that escape the kit's three documented tier roots. Per
45
+ * NFR-6 + Kiro's stricter pattern:
46
+ * - canonicalize via path.resolve() so symlinks + .. are normalized
47
+ * - reject URL-encoded traversal (%2e%2e%2f)
48
+ * - require the canonical path to start with one of the three roots
49
+ *
50
+ * Returns the canonical resolved path on success; throws on rejection.
51
+ * The tool surface catches the throw and translates to a JSON-RPC error.
52
+ *
53
+ * Currently no MCP tool accepts a user-provided path directly (mk_get
54
+ * takes IDs which match ID_PATTERN, mk_remember writes via memoryWrite
55
+ * which constructs paths internally). This is defensive readiness for
56
+ * v0.1.x tools that may add path-accepting surfaces.
57
+ */
58
+ export function validatePath(p, { projectRoot, userDir }) {
59
+ if (typeof p !== 'string' || p.length === 0) {
60
+ throw new Error('validatePath: path must be a non-empty string');
61
+ }
62
+ // Reject URL-encoded traversal before path.resolve normalizes it.
63
+ if (/%2e%2e/i.test(p) || /%2f/i.test(p)) {
64
+ throw new Error('validatePath: URL-encoded traversal rejected');
65
+ }
66
+ const canonical = isAbsolute(p) ? resolvePath(p) : resolvePath(projectRoot, p);
67
+ // Per CLAUDE.md "Shared modules" rule: derive every tier root from
68
+ // tier-paths.mjs's resolveTierRoot rather than re-deriving inline.
69
+ // The earlier draft constructed the user-tier root as
70
+ // `resolvePath(userDir ?? homedir() + '/.claude-memory-kit')` —
71
+ // which silently drifted from resolveTierRoot's posture (honoring
72
+ // env vars + path normalization). Surfaced as Layer-5 checkpoint
73
+ // finding L5-I1 (2026-05-28); fixed by going through the shared
74
+ // helper for all three roots.
75
+ const roots = [
76
+ resolvePath(resolveTierRoot({ tier: 'P', projectRoot, userDir })),
77
+ resolvePath(resolveTierRoot({ tier: 'L', projectRoot, userDir })),
78
+ resolvePath(resolveTierRoot({ tier: 'U', projectRoot, userDir })),
79
+ ];
80
+ for (const root of roots) {
81
+ if (canonical === root || canonical.startsWith(root + (process.platform === 'win32' ? '\\' : '/'))) {
82
+ return canonical;
83
+ }
84
+ }
85
+ throw new Error(`validatePath: path escapes kit roots: ${p}`);
86
+ }
87
+
88
+ // --- Tool handlers ----------------------------------------------------
89
+
90
+ function makeMkSearch({ db, semanticBackend }) {
91
+ return async ({ query, mode, tier, since, limit, min_trust }) => {
92
+ const r = search({
93
+ db, query,
94
+ mode: mode ?? SEARCH_MODES.KEYWORD,
95
+ tier,
96
+ since,
97
+ limit,
98
+ minTrust: min_trust,
99
+ semanticBackend,
100
+ });
101
+ if (r.action === 'error') {
102
+ return {
103
+ content: [{ type: 'text', text: `error: ${r.errors.join('; ')}` }],
104
+ isError: true,
105
+ };
106
+ }
107
+ return {
108
+ content: [{ type: 'text', text: JSON.stringify(r.results, null, 2) }],
109
+ };
110
+ };
111
+ }
112
+
113
+ function makeMkGet({ db }) {
114
+ return async ({ ids }) => {
115
+ const stmt = db.prepare(`
116
+ SELECT id, body, heading_path, source_file, source_line, tier, trust,
117
+ write_source, created_at, superseded_by, deleted_at
118
+ FROM observations WHERE id = ?
119
+ `);
120
+ const rows = ids.map((id) => {
121
+ if (!ID_PATTERN.test(id)) {
122
+ return { id, error: 'invalid id format' };
123
+ }
124
+ const row = stmt.get(id);
125
+ if (!row) return { id, error: 'not found' };
126
+ return row;
127
+ });
128
+ return {
129
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
130
+ };
131
+ };
132
+ }
133
+
134
+ function makeMkTimeline({ db }) {
135
+ // Sequential context around an anchor ID or timestamp. v0.1.0 keeps
136
+ // the implementation deliberately narrow: anchor by ID; return the
137
+ // N observations before + N after by created_at order.
138
+ return async ({ anchor, depth_before, depth_after }) => {
139
+ const before = depth_before ?? 5;
140
+ const after = depth_after ?? 5;
141
+ if (!ID_PATTERN.test(anchor)) {
142
+ return {
143
+ content: [{ type: 'text', text: 'error: anchor must be a valid kit ID' }],
144
+ isError: true,
145
+ };
146
+ }
147
+ const anchorRow = db
148
+ .prepare('SELECT created_at, tier FROM observations WHERE id = ?')
149
+ .get(anchor);
150
+ if (!anchorRow) {
151
+ return {
152
+ content: [{ type: 'text', text: 'error: anchor not found' }],
153
+ isError: true,
154
+ };
155
+ }
156
+ // M2: id tiebreaker on observations with identical created_at —
157
+ // without it, observations created the same millisecond fall out
158
+ // of the timeline non-deterministically. Same fix in afterRows.
159
+ const beforeRows = db
160
+ .prepare(`
161
+ SELECT id, body, source_file, source_line, tier, trust, created_at
162
+ FROM observations
163
+ WHERE created_at < ? AND deleted_at IS NULL
164
+ ORDER BY created_at DESC, id DESC LIMIT ?
165
+ `)
166
+ .all(anchorRow.created_at, before);
167
+ const anchorFull = db
168
+ .prepare(`
169
+ SELECT id, body, source_file, source_line, tier, trust, created_at
170
+ FROM observations WHERE id = ?
171
+ `)
172
+ .get(anchor);
173
+ const afterRows = db
174
+ .prepare(`
175
+ SELECT id, body, source_file, source_line, tier, trust, created_at
176
+ FROM observations
177
+ WHERE created_at > ? AND deleted_at IS NULL
178
+ ORDER BY created_at ASC, id ASC LIMIT ?
179
+ `)
180
+ .all(anchorRow.created_at, after);
181
+ const timeline = [...beforeRows.reverse(), anchorFull, ...afterRows];
182
+ return {
183
+ content: [{ type: 'text', text: JSON.stringify(timeline, null, 2) }],
184
+ };
185
+ };
186
+ }
187
+
188
+ function makeMkCite() {
189
+ // Pure formatting — no DB query needed. The canonical citation link
190
+ // form is documented in design §10's tool table:
191
+ // `[#P-S79MJHFN](memkit://obs/P-S79MJHFN)`
192
+ return async ({ id }) => {
193
+ if (!ID_PATTERN.test(id)) {
194
+ return {
195
+ content: [{ type: 'text', text: 'error: id must match ID_PATTERN' }],
196
+ isError: true,
197
+ };
198
+ }
199
+ const link = `[#${id}](memkit://obs/${id})`;
200
+ return {
201
+ content: [{ type: 'text', text: link }],
202
+ };
203
+ };
204
+ }
205
+
206
+ function makeMkRemember({ projectRoot, userDir }) {
207
+ return async ({ text, tier, cites }) => {
208
+ // I1 + I2 boundary checks (Task 31 code-review):
209
+ // - cites: memory-write doesn't currently wire cites → provenance.
210
+ // Silently dropping the array would tell the model "your citation
211
+ // was recorded" — false. Reject with "not yet supported" until
212
+ // memoryWrite gains a cites parameter (v0.1.x).
213
+ // - tier 'U': the kit's user-tier templates (USER.md / HABITS.md /
214
+ // LESSONS.md) don't have MEMORY.md + 'Active Threads' section,
215
+ // so memoryWrite would fail with NOT_FOUND. v0.1.0 mk_remember
216
+ // only writes to project-tier MEMORY.md. (v0.1.x: parameterize
217
+ // scratchpad routing per tier.)
218
+ if (Array.isArray(cites) && cites.length > 0) {
219
+ return {
220
+ content: [
221
+ {
222
+ type: 'text',
223
+ text: 'error: cites parameter not yet supported by mk_remember (v0.1.x — see design §16.x). Submit the text without cites for now.',
224
+ },
225
+ ],
226
+ isError: true,
227
+ };
228
+ }
229
+ if (tier === 'U' || tier === 'L') {
230
+ return {
231
+ content: [
232
+ {
233
+ type: 'text',
234
+ text: `error: mk_remember in v0.1.0 only writes to tier 'P' (project). tier '${tier}' will be supported in v0.1.x when scratchpad routing is parameterized.`,
235
+ },
236
+ ],
237
+ isError: true,
238
+ };
239
+ }
240
+ const r = memoryWrite({
241
+ action: 'add',
242
+ text,
243
+ tier: 'P',
244
+ scratchpad: 'MEMORY.md',
245
+ section: 'Active Threads',
246
+ source: 'user-explicit', // mk_remember IS the user-explicit MCP write surface
247
+ sessionId: 'mcp-server',
248
+ projectRoot,
249
+ userDir,
250
+ });
251
+ if (r.action === 'error') {
252
+ return {
253
+ content: [
254
+ { type: 'text', text: `error (${r.errorCategory ?? 'unknown'}): ${(r.errors ?? []).join('; ')}` },
255
+ ],
256
+ isError: true,
257
+ };
258
+ }
259
+ // B1 (Task 31 code-review): memoryWrite has THREE outcomes — appended,
260
+ // queued (to queues/conflicts.md), or supersede/append (the v0.1.0
261
+ // fallthrough path). Don't report `accepted: true` when the write
262
+ // was actually queued for human review — the model would treat that
263
+ // as "fact saved" while in fact `cmk queue conflicts` is required
264
+ // to land the bullet. Same composition class as the Task 25 → 25b
265
+ // mergeScratchpadBullets lesson in CLAUDE.md.
266
+ if (r.action === 'queued') {
267
+ return {
268
+ content: [
269
+ {
270
+ type: 'text',
271
+ text: JSON.stringify(
272
+ {
273
+ accepted: false,
274
+ status: 'queued',
275
+ awaiting_review: true,
276
+ queue: 'conflicts',
277
+ id: r.id,
278
+ queued_to: r.path,
279
+ hint: 'Run `cmk queue conflicts` to resolve the conflict; the bullet is not yet in MEMORY.md.',
280
+ },
281
+ null,
282
+ 2,
283
+ ),
284
+ },
285
+ ],
286
+ };
287
+ }
288
+ return {
289
+ content: [
290
+ {
291
+ type: 'text',
292
+ text: JSON.stringify(
293
+ { id: r.id, written_to: r.path, accepted: true, action: r.action },
294
+ null,
295
+ 2,
296
+ ),
297
+ },
298
+ ],
299
+ };
300
+ };
301
+ }
302
+
303
+ function makeMkRecentActivity({ db }) {
304
+ const WINDOWS = {
305
+ '1h': 60 * 60 * 1000,
306
+ '24h': 24 * 60 * 60 * 1000,
307
+ '7d': 7 * 24 * 60 * 60 * 1000,
308
+ };
309
+ return async ({ window, limit }) => {
310
+ const w = window ?? '24h';
311
+ if (!WINDOWS[w]) {
312
+ return {
313
+ content: [{ type: 'text', text: 'error: window must be 1h|24h|7d' }],
314
+ isError: true,
315
+ };
316
+ }
317
+ const lim = limit ?? 20;
318
+ const cutoff = Date.now() - WINDOWS[w];
319
+ const rows = db
320
+ .prepare(`
321
+ SELECT id, body, source_file, source_line, tier, trust, created_at
322
+ FROM observations
323
+ WHERE created_at >= ? AND deleted_at IS NULL
324
+ ORDER BY created_at DESC LIMIT ?
325
+ `)
326
+ .all(cutoff, lim);
327
+ return {
328
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
329
+ };
330
+ };
331
+ }
332
+
333
+ // --- Server build + run ----------------------------------------------
334
+
335
+ /**
336
+ * Build the kit's MCP server. Caller passes context (projectRoot, userDir,
337
+ * db handle, optional semanticBackend). Returns the McpServer instance
338
+ * ready for `.connect(transport)`.
339
+ *
340
+ * Tests can build the server + invoke tool callbacks directly without
341
+ * spinning up the stdio transport.
342
+ */
343
+ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
344
+ const server = new McpServer({
345
+ name: 'cmk',
346
+ version: '0.1.0',
347
+ });
348
+
349
+ // mk_search
350
+ server.registerTool(
351
+ 'mk_search',
352
+ {
353
+ description: 'Search kit memory (FTS5 keyword by default; semantic + hybrid require Layer 5b memsearch install).',
354
+ inputSchema: {
355
+ query: z.string().min(1).describe('search query'),
356
+ mode: z.enum(['keyword', 'semantic', 'hybrid']).optional(),
357
+ tier: z.enum(['U', 'P', 'L']).optional(),
358
+ since: z.string().optional().describe('ISO 8601 timestamp'),
359
+ limit: z.number().int().positive().max(1000).optional(),
360
+ min_trust: z.enum(['low', 'medium', 'high']).optional(),
361
+ },
362
+ },
363
+ makeMkSearch({ db, semanticBackend }),
364
+ );
365
+
366
+ // mk_get
367
+ // M1: bounded `.max(100)` to prevent soft-DoS via a 100k-id request
368
+ // opening 100k prepared statements + writing 100k JSON-encoded rows.
369
+ server.registerTool(
370
+ 'mk_get',
371
+ {
372
+ description: 'Fetch full observation bodies + provenance + relations by ID.',
373
+ inputSchema: {
374
+ ids: z.array(z.string()).min(1).max(100).describe('kit observation IDs (max 100)'),
375
+ },
376
+ },
377
+ makeMkGet({ db }),
378
+ );
379
+
380
+ // mk_timeline
381
+ server.registerTool(
382
+ 'mk_timeline',
383
+ {
384
+ description: 'Sequential context around an anchor observation — N observations before + N after by created_at.',
385
+ inputSchema: {
386
+ anchor: z.string().describe('kit observation ID'),
387
+ depth_before: z.number().int().nonnegative().max(50).optional(),
388
+ depth_after: z.number().int().nonnegative().max(50).optional(),
389
+ },
390
+ },
391
+ makeMkTimeline({ db }),
392
+ );
393
+
394
+ // mk_cite
395
+ server.registerTool(
396
+ 'mk_cite',
397
+ {
398
+ description: 'Render a canonical Markdown citation link for a kit observation.',
399
+ inputSchema: {
400
+ id: z.string().describe('kit observation ID'),
401
+ },
402
+ },
403
+ makeMkCite(),
404
+ );
405
+
406
+ // mk_remember
407
+ // M1: bounded `.max(5000)` on text — a 10MB body would burn Poison_Guard
408
+ // regex time + index-fts size. 5000 chars matches the kit's per-bullet
409
+ // soft cap (design §2.1).
410
+ server.registerTool(
411
+ 'mk_remember',
412
+ {
413
+ description: 'Explicit user-driven save to kit memory with audit trail.',
414
+ inputSchema: {
415
+ text: z.string().min(1).max(5000).describe('the fact text (max 5000 chars)'),
416
+ tier: z.enum(['U', 'P', 'L']).optional(),
417
+ cites: z.array(z.string()).optional(),
418
+ },
419
+ },
420
+ makeMkRemember({ projectRoot, userDir }),
421
+ );
422
+
423
+ // mk_recent_activity
424
+ server.registerTool(
425
+ 'mk_recent_activity',
426
+ {
427
+ description: 'List recent observation changes within a time window.',
428
+ inputSchema: {
429
+ window: z.enum(['1h', '24h', '7d']).optional(),
430
+ limit: z.number().int().positive().max(1000).optional(),
431
+ },
432
+ },
433
+ makeMkRecentActivity({ db }),
434
+ );
435
+
436
+ return server;
437
+ }
438
+
439
+ /**
440
+ * Run the kit's MCP server over stdio (the production CLI path).
441
+ *
442
+ * Per design §10.1: stdout is reserved for JSON-RPC messages; ALL logs
443
+ * to stderr. The SDK's StdioServerTransport handles the stdout/stdin
444
+ * pipe — our concern is making sure we don't pollute stdout via
445
+ * console.log() anywhere in the tool callbacks. console.error() (which
446
+ * writes to stderr) is fine.
447
+ *
448
+ * Caller (`cmk mcp serve` subcommand) provides projectRoot + userDir.
449
+ * We open the index DB read-only-ish (better-sqlite3 doesn't expose
450
+ * a read-only flag mid-life; WAL + multi-connection makes this safe).
451
+ */
452
+ export async function runMcpServer({ projectRoot, userDir, db: dbOverride, semanticBackend } = {}) {
453
+ const db = dbOverride ?? openIndexDb({ projectRoot });
454
+ const server = buildMcpServer({ projectRoot, userDir, db, semanticBackend });
455
+ const transport = new StdioServerTransport();
456
+
457
+ // I4 (Task 31 code-review): graceful shutdown. Without this, the
458
+ // server holds the DB handle until the process is hard-killed —
459
+ // problematic on Windows where Node's process-exit doesn't flush
460
+ // SQLite WAL files synchronously. The kit's tests force `kill()`
461
+ // which doesn't exercise this path, but production Claude Code
462
+ // will simply close stdin when the session ends; we want to honor
463
+ // that as the "shut down cleanly" signal.
464
+ const closeOnce = (() => {
465
+ let closed = false;
466
+ return () => {
467
+ if (closed) return;
468
+ closed = true;
469
+ try {
470
+ db.close();
471
+ } catch (err) {
472
+ // Best-effort — log to stderr (stdout reserved for JSON-RPC).
473
+ process.stderr.write(
474
+ `cmk-mcp-server: db.close() failed: ${err?.message ?? err}\n`,
475
+ );
476
+ }
477
+ };
478
+ })();
479
+
480
+ // stdin close from Claude Code → graceful shutdown.
481
+ process.stdin.on('end', closeOnce);
482
+ process.stdin.on('close', closeOnce);
483
+ // SIGINT / SIGTERM — the user interrupted from the terminal OR the
484
+ // OS asked for a clean exit. Honor it.
485
+ process.once('SIGINT', () => {
486
+ closeOnce();
487
+ process.exit(0);
488
+ });
489
+ process.once('SIGTERM', () => {
490
+ closeOnce();
491
+ process.exit(0);
492
+ });
493
+
494
+ await server.connect(transport);
495
+ // The server now runs until stdin closes (Claude Code disconnects).
496
+ // Return the handle so callers in tests can close cleanly.
497
+ return { server, transport, db, close: closeOnce };
498
+ }