@lh8ppl/claude-memory-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- package/template/user/fragments/INDEX.md.template +23 -0
|
@@ -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
|
+
}
|