@yugabytedb/meko-mcp 1.0.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.
Files changed (51) hide show
  1. package/README.md +505 -0
  2. package/assets/hooks-handlers/lib/capture.js +863 -0
  3. package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
  4. package/assets/hooks-handlers/pre-compact.sh +3 -0
  5. package/assets/hooks-handlers/session-end.sh +27 -0
  6. package/assets/hooks-handlers/session-start.sh +24 -0
  7. package/assets/hooks-handlers/test_single_conversation.sh +169 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
  11. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
  12. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
  13. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
  14. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
  15. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
  16. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
  17. package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
  18. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
  19. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
  22. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
  23. package/bin/create-meko-setup.mjs +499 -0
  24. package/lib/claude-code.mjs +14 -0
  25. package/lib/clients/claude-code.mjs +265 -0
  26. package/lib/clients/claude-desktop.mjs +256 -0
  27. package/lib/clients/common.mjs +107 -0
  28. package/lib/clients/cursor.mjs +332 -0
  29. package/lib/clients/index.mjs +14 -0
  30. package/lib/config.mjs +369 -0
  31. package/lib/detect.mjs +125 -0
  32. package/lib/installer.mjs +255 -0
  33. package/lib/logger.mjs +97 -0
  34. package/lib/mcp-transport.mjs +204 -0
  35. package/lib/migrate/adapters/claude-code.mjs +165 -0
  36. package/lib/migrate/adapters/cursor.mjs +221 -0
  37. package/lib/migrate/adapters/memory-md.mjs +230 -0
  38. package/lib/migrate/claude-parser.mjs +306 -0
  39. package/lib/migrate/daemon.mjs +67 -0
  40. package/lib/migrate/id.mjs +83 -0
  41. package/lib/migrate/lockfile.mjs +121 -0
  42. package/lib/migrate/logger.mjs +79 -0
  43. package/lib/migrate/mcp-client.mjs +224 -0
  44. package/lib/migrate/orchestrator.mjs +443 -0
  45. package/lib/migrate/progress.mjs +76 -0
  46. package/lib/migrate/state.mjs +177 -0
  47. package/lib/migrate.mjs +328 -0
  48. package/lib/prompt.mjs +212 -0
  49. package/lib/validate.mjs +259 -0
  50. package/package.json +49 -0
  51. package/scripts/bundle-assets.mjs +49 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * adapters/claude-code.mjs — Enumerate + parse Claude Code sessions.
3
+ *
4
+ * Parser lives in ../claude-parser.mjs (vendored, streaming). We do NOT
5
+ * import from `skills/hooks-handlers/lib/capture.js` — that file is
6
+ * published in a different package, is CommonJS with side effects, and
7
+ * using `import.meta.dirname` to reach it broke under `npm install`.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import { agentIdForClaude, slugifyCwd } from "../id.mjs";
14
+ import { parseSessionStream, peekSessionMetadata } from "../claude-parser.mjs";
15
+
16
+ function homeDir() {
17
+ return process.env.HOME || os.homedir();
18
+ }
19
+
20
+ /**
21
+ * The projects root that Claude Code writes session files under.
22
+ *
23
+ * @param {object} [opts]
24
+ * @param {string} [opts.home]
25
+ * @returns {string}
26
+ */
27
+ export function projectsRoot(opts = {}) {
28
+ return path.join(opts.home ?? homeDir(), ".claude", "projects");
29
+ }
30
+
31
+ /**
32
+ * User-scope CLAUDE.md location.
33
+ *
34
+ * @param {object} [opts]
35
+ * @param {string} [opts.home]
36
+ * @returns {string}
37
+ */
38
+ export function userClaudeMdPath(opts = {}) {
39
+ return path.join(opts.home ?? homeDir(), ".claude", "CLAUDE.md");
40
+ }
41
+
42
+ /**
43
+ * @typedef {Object} SessionRef
44
+ * @property {string} sessionUuid
45
+ * @property {string} path
46
+ * @property {string} slug
47
+ * @property {string} cwd Derived from the first session in the slug dir.
48
+ * @property {string} agentId
49
+ * @property {number} size
50
+ * @property {number} mtimeMs
51
+ * @property {boolean} likelyInProgress
52
+ */
53
+
54
+ /**
55
+ * Enumerate session files under `~/.claude/projects/`.
56
+ *
57
+ * For `scope === "project"`, filter by matching the session's embedded
58
+ * `cwd` field against `projectCwd`. The slug→cwd mapping is lossy for
59
+ * paths containing `.` or `-`, so we only use the slug as a candidate
60
+ * shortlist; the authoritative match is on file content.
61
+ *
62
+ * @param {object} opts
63
+ * @param {"user"|"project"} opts.scope
64
+ * @param {string} [opts.projectCwd]
65
+ * @param {string} [opts.home]
66
+ * @returns {Promise<SessionRef[]>}
67
+ */
68
+ export async function listSessions(opts) {
69
+ const root = projectsRoot(opts);
70
+ if (!fs.existsSync(root)) return [];
71
+
72
+ const slugs = fs.readdirSync(root, { withFileTypes: true })
73
+ .filter((d) => d.isDirectory())
74
+ .map((d) => d.name);
75
+
76
+ const wantedCwd = opts.scope === "project" ? opts.projectCwd : null;
77
+ const wantedSlug = wantedCwd ? slugifyCwd(wantedCwd) : null;
78
+
79
+ const out = [];
80
+ for (const slug of slugs) {
81
+ if (wantedSlug && slug !== wantedSlug) continue;
82
+ const dir = path.join(root, slug);
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(dir, { withFileTypes: true });
86
+ } catch {
87
+ continue;
88
+ }
89
+ // Resolve the project cwd from the first readable session — authoritative.
90
+ const slugCwd = await cwdForSlugDir(dir, slug);
91
+ if (wantedCwd && slugCwd && slugCwd !== wantedCwd) continue;
92
+
93
+ for (const e of entries) {
94
+ if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
95
+ const full = path.join(dir, e.name);
96
+ let stat;
97
+ try { stat = fs.statSync(full); } catch { continue; }
98
+ const sessionUuid = e.name.replace(/\.jsonl$/, "");
99
+ out.push({
100
+ sessionUuid,
101
+ path: full,
102
+ slug,
103
+ cwd: slugCwd,
104
+ agentId: agentIdForClaude(slugCwd),
105
+ size: stat.size,
106
+ mtimeMs: stat.mtimeMs,
107
+ likelyInProgress: stat.mtimeMs > Date.now() - 60_000,
108
+ });
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Resolve the absolute project cwd for a slug directory by peeking at
116
+ * the first parseable line of any session file in it. Falls back to a
117
+ * naive `-` → `/` replacement if no session file has a cwd.
118
+ *
119
+ * Cached per call (each session dir is read at most once).
120
+ *
121
+ * @param {string} dir
122
+ * @param {string} slug
123
+ * @returns {Promise<string>}
124
+ */
125
+ export async function cwdForSlugDir(dir, slug) {
126
+ let entries;
127
+ try {
128
+ entries = fs.readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
129
+ } catch {
130
+ return fallbackCwd(slug);
131
+ }
132
+ for (const name of entries) {
133
+ try {
134
+ const meta = await peekSessionMetadata(path.join(dir, name));
135
+ if (meta.cwd) return meta.cwd;
136
+ } catch {
137
+ continue;
138
+ }
139
+ }
140
+ return fallbackCwd(slug);
141
+ }
142
+
143
+ function fallbackCwd(slug) {
144
+ if (!slug || !slug.startsWith("-")) return slug;
145
+ return slug.replace(/-/g, "/");
146
+ }
147
+
148
+ /**
149
+ * Parse a session file via the streaming parser.
150
+ *
151
+ * @param {string} sessionPath
152
+ * @returns {Promise<{
153
+ * sessionUuid: string,
154
+ * firstUserText: string,
155
+ * cwd: string,
156
+ * gitBranch: string|null,
157
+ * startedAt: string|null,
158
+ * messageCount: number,
159
+ * exchanges: Array<{user_uuid: string, input: string, output: string, reasoning: string, timestamp: string}>,
160
+ * partial: boolean,
161
+ * }>}
162
+ */
163
+ export function parseSession(sessionPath) {
164
+ return parseSessionStream(sessionPath);
165
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * adapters/cursor.mjs — Enumerate + parse Cursor agent transcripts.
3
+ *
4
+ * Cursor writes sessions under:
5
+ * ~/.cursor/projects/<projectName>/agent-transcripts/<sessionUuid>/...jsonl
6
+ *
7
+ * The transcript format differs from Claude Code's: each line is a
8
+ * `{role, message: {content: [blocks]}}` object. Blocks may be
9
+ * `{type:"text", text:"..."}` or `{type:"tool_use", name, input}` or
10
+ * `{type:"tool_result", content:...}`.
11
+ *
12
+ * Schema drift is common with Cursor; unknown block types are dropped
13
+ * with a warning and do not fail ingestion.
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { agentIdForCursor } from "../id.mjs";
19
+
20
+ function homeDir() {
21
+ return process.env.HOME || require("node:os").homedir();
22
+ }
23
+
24
+ export function projectsRoot(opts = {}) {
25
+ return path.join(opts.home ?? homeDir(), ".cursor", "projects");
26
+ }
27
+
28
+ /**
29
+ * @typedef {Object} CursorSessionRef
30
+ * @property {string} sessionUuid
31
+ * @property {string} path Directory containing transcript shards.
32
+ * @property {string} projectName
33
+ * @property {string} agentId `cursor:<projectName>`.
34
+ * @property {number} mtimeMs
35
+ * @property {boolean} likelyInProgress
36
+ */
37
+
38
+ /**
39
+ * @param {object} opts
40
+ * @param {"user"|"project"} opts.scope
41
+ * @param {string} [opts.projectCwd]
42
+ * @param {string} [opts.home]
43
+ * @returns {CursorSessionRef[]}
44
+ */
45
+ export function listSessions(opts) {
46
+ const root = projectsRoot(opts);
47
+ if (!fs.existsSync(root)) return [];
48
+
49
+ // Cursor's project folder name is NOT path-encoded the same way as
50
+ // Claude Code. For `--scope project`, we can only match heuristically
51
+ // on the basename of the cwd. That's acceptable for v1.
52
+ const wantedName = opts.scope === "project"
53
+ ? path.basename(opts.projectCwd || "")
54
+ : null;
55
+
56
+ const projects = fs.readdirSync(root, { withFileTypes: true })
57
+ .filter((d) => d.isDirectory())
58
+ .map((d) => d.name);
59
+
60
+ const out = [];
61
+ for (const projectName of projects) {
62
+ if (wantedName && projectName !== wantedName) continue;
63
+ const transcriptsDir = path.join(root, projectName, "agent-transcripts");
64
+ let sessionDirs;
65
+ try {
66
+ sessionDirs = fs.readdirSync(transcriptsDir, { withFileTypes: true });
67
+ } catch {
68
+ continue;
69
+ }
70
+ for (const sd of sessionDirs) {
71
+ if (!sd.isDirectory()) continue;
72
+ const sessionDir = path.join(transcriptsDir, sd.name);
73
+ let stat;
74
+ try {
75
+ stat = fs.statSync(sessionDir);
76
+ } catch {
77
+ continue;
78
+ }
79
+ out.push({
80
+ sessionUuid: sd.name,
81
+ path: sessionDir,
82
+ projectName,
83
+ agentId: agentIdForCursor(projectName),
84
+ mtimeMs: stat.mtimeMs,
85
+ likelyInProgress: stat.mtimeMs > Date.now() - 60_000,
86
+ });
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * Parse a session directory: pair user/assistant turns.
94
+ *
95
+ * @param {string} sessionPath
96
+ * @returns {{
97
+ * sessionUuid: string,
98
+ * firstUserText: string,
99
+ * startedAt: string|null,
100
+ * exchanges: Array<{user_uuid: string, input: string, output: string, reasoning: string, timestamp: string}>,
101
+ * partial: boolean,
102
+ * }}
103
+ */
104
+ export function parseSession(sessionPath) {
105
+ const entries = collectEntries(sessionPath);
106
+
107
+ let firstUserText = "";
108
+ let startedAt = null;
109
+ for (const e of entries) {
110
+ if (!startedAt && e.timestamp) startedAt = e.timestamp;
111
+ if (!firstUserText && e.role === "user") {
112
+ const txt = extractTextBlocks(e.content);
113
+ if (txt) firstUserText = txt.slice(0, 240);
114
+ }
115
+ }
116
+
117
+ // Pair user→assistant turns.
118
+ const exchanges = [];
119
+ let pendingUser = null;
120
+ let turnIdx = 0;
121
+ for (const e of entries) {
122
+ if (e.role === "user") {
123
+ if (pendingUser) {
124
+ // User-after-user: flush with empty output.
125
+ exchanges.push(makePair(pendingUser, null, turnIdx++));
126
+ }
127
+ pendingUser = e;
128
+ } else if (e.role === "assistant") {
129
+ if (!pendingUser) continue; // assistant with no preceding user — skip
130
+ exchanges.push(makePair(pendingUser, e, turnIdx++));
131
+ pendingUser = null;
132
+ }
133
+ }
134
+ if (pendingUser) {
135
+ exchanges.push(makePair(pendingUser, null, turnIdx++));
136
+ }
137
+
138
+ return {
139
+ sessionUuid: path.basename(sessionPath),
140
+ firstUserText,
141
+ startedAt,
142
+ exchanges: exchanges.filter((ex) => ex.input || ex.output),
143
+ partial: false, // Cursor directories don't give us a partial-line signal
144
+ };
145
+ }
146
+
147
+ function collectEntries(dir) {
148
+ let files;
149
+ try {
150
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
151
+ } catch {
152
+ return [];
153
+ }
154
+ const entries = [];
155
+ for (const f of files) {
156
+ let raw;
157
+ try {
158
+ raw = fs.readFileSync(path.join(dir, f), "utf8");
159
+ } catch {
160
+ continue;
161
+ }
162
+ for (const line of raw.split("\n")) {
163
+ const trimmed = line.trim();
164
+ if (!trimmed) continue;
165
+ try {
166
+ const obj = JSON.parse(trimmed);
167
+ const role = obj.role || obj.message?.role;
168
+ if (role !== "user" && role !== "assistant") continue;
169
+ entries.push({
170
+ role,
171
+ uuid: obj.uuid || obj.id || null,
172
+ timestamp: obj.timestamp || obj.createdAt || null,
173
+ content: obj.message?.content ?? obj.content ?? null,
174
+ });
175
+ } catch {
176
+ continue;
177
+ }
178
+ }
179
+ }
180
+ return entries;
181
+ }
182
+
183
+ function makePair(userEntry, assistantEntry, idx) {
184
+ const input = extractTextBlocks(userEntry.content);
185
+ const output = assistantEntry ? extractTextBlocks(assistantEntry.content) : "";
186
+ const reasoning = assistantEntry ? extractToolCallsText(assistantEntry.content) : "";
187
+ return {
188
+ user_uuid: userEntry.uuid || `cursor-${idx}`,
189
+ input,
190
+ output,
191
+ reasoning,
192
+ timestamp: assistantEntry?.timestamp || userEntry.timestamp || "",
193
+ };
194
+ }
195
+
196
+ function extractTextBlocks(content) {
197
+ if (typeof content === "string") return content;
198
+ if (!Array.isArray(content)) return "";
199
+ const parts = [];
200
+ for (const b of content) {
201
+ if (!b) continue;
202
+ if (typeof b === "string") { parts.push(b); continue; }
203
+ if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
204
+ }
205
+ return parts.join("\n");
206
+ }
207
+
208
+ function extractToolCallsText(content) {
209
+ if (!Array.isArray(content)) return "";
210
+ const parts = [];
211
+ for (const b of content) {
212
+ if (!b) continue;
213
+ if (b.type === "tool_use") {
214
+ const input = b.input ? JSON.stringify(b.input).slice(0, 500) : "";
215
+ parts.push(`TOOL CALL: ${b.name || "unknown"}(${input})`);
216
+ } else if (b.type === "tool_result" && typeof b.content === "string") {
217
+ parts.push(`TOOL RESULT:\n${b.content.slice(0, 3000)}`);
218
+ }
219
+ }
220
+ return parts.join("\n---\n");
221
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * adapters/memory-md.mjs — Enumerate and parse CLAUDE.md + per-file memory/*.md.
3
+ *
4
+ * Two shapes:
5
+ * 1. CLAUDE.md (user- or project-scope) — split on `^# ` level-1 headings;
6
+ * each section becomes one memory (metadata includes `heading`).
7
+ * 2. ~/.claude/projects/<slug>/memory/*.md — each file is one memory; the
8
+ * body (minus YAML frontmatter) is the text, frontmatter becomes metadata.
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import {
15
+ CLAUDE_CODE_USER_AGENT_ID,
16
+ agentIdForClaude,
17
+ memoryHash,
18
+ slugifyCwd,
19
+ } from "../id.mjs";
20
+ import { cwdForSlugDir } from "./claude-code.mjs";
21
+
22
+ function homeDir() {
23
+ return process.env.HOME || os.homedir();
24
+ }
25
+
26
+ /**
27
+ * @typedef {Object} MemoryItem
28
+ * @property {string} agentId
29
+ * @property {string} text
30
+ * @property {string} hash
31
+ * @property {{source: string, scope: "user"|"project", sourcePath: string, heading?: string, name?: string, description?: string, type?: string}} metadata
32
+ * @property {string} name Display/dedup name.
33
+ */
34
+
35
+ /**
36
+ * Enumerate memory items per scope.
37
+ *
38
+ * Derives project cwd by peeking at an actual session file in each slug
39
+ * directory (authoritative) rather than reverse-slugging, which is lossy
40
+ * for paths containing `-` or `.`.
41
+ *
42
+ * @param {object} opts
43
+ * @param {"user"|"project"} opts.scope
44
+ * @param {string} [opts.projectCwd]
45
+ * @param {string} [opts.home]
46
+ * @returns {Promise<MemoryItem[]>}
47
+ */
48
+ export async function enumerateMemoryItems(opts) {
49
+ const home = opts.home ?? homeDir();
50
+ const items = [];
51
+
52
+ if (opts.scope === "user") {
53
+ const userMd = path.join(home, ".claude", "CLAUDE.md");
54
+ items.push(...readClaudeMd(userMd, CLAUDE_CODE_USER_AGENT_ID, "user"));
55
+
56
+ const projectsRoot = path.join(home, ".claude", "projects");
57
+ if (fs.existsSync(projectsRoot)) {
58
+ for (const slug of fs.readdirSync(projectsRoot)) {
59
+ const dir = path.join(projectsRoot, slug);
60
+ let stat;
61
+ try { stat = fs.statSync(dir); } catch { continue; }
62
+ if (!stat.isDirectory()) continue;
63
+ const cwd = await cwdForSlugDir(dir, slug);
64
+ if (!cwd) continue;
65
+ const agentId = agentIdForClaude(cwd);
66
+ const projMd = path.join(cwd, ".claude", "CLAUDE.md");
67
+ items.push(...readClaudeMd(projMd, agentId, "project"));
68
+ const memDir = path.join(dir, "memory");
69
+ items.push(...readMemoryDir(memDir, agentId, "project"));
70
+ }
71
+ }
72
+ } else {
73
+ const cwd = opts.projectCwd;
74
+ if (!cwd) return [];
75
+ const agentId = agentIdForClaude(cwd);
76
+ const projMd = path.join(cwd, ".claude", "CLAUDE.md");
77
+ items.push(...readClaudeMd(projMd, agentId, "project"));
78
+ const slug = slugifyCwd(cwd);
79
+ const memDir = path.join(home, ".claude", "projects", slug, "memory");
80
+ items.push(...readMemoryDir(memDir, agentId, "project"));
81
+ }
82
+
83
+ return items;
84
+ }
85
+
86
+ /**
87
+ * Parse a CLAUDE.md file into one item per top-level `# ` heading. If the
88
+ * file has no top-level headings, the whole body is one item.
89
+ *
90
+ * @param {string} filePath
91
+ * @param {string} agentId
92
+ * @param {"user"|"project"} scope
93
+ * @returns {MemoryItem[]}
94
+ */
95
+ export function readClaudeMd(filePath, agentId, scope) {
96
+ let raw;
97
+ try {
98
+ raw = fs.readFileSync(filePath, "utf8");
99
+ } catch {
100
+ return [];
101
+ }
102
+ if (!raw.trim()) return [];
103
+
104
+ const sections = splitTopLevelSections(raw);
105
+ return sections.map((section) => {
106
+ const heading = section.heading;
107
+ const text = section.body.trim();
108
+ const name = heading || path.basename(filePath);
109
+ return {
110
+ agentId,
111
+ text,
112
+ hash: memoryHash(text, name),
113
+ name,
114
+ metadata: {
115
+ source: path.basename(filePath),
116
+ scope,
117
+ sourcePath: filePath,
118
+ ...(heading ? { heading } : {}),
119
+ },
120
+ };
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Split on `^# ` (level-1) headings. Returns [{heading, body}...].
126
+ * Content before the first heading becomes a section with empty heading
127
+ * (dropped only if the body is also empty).
128
+ *
129
+ * @param {string} md
130
+ * @returns {{heading: string, body: string}[]}
131
+ */
132
+ export function splitTopLevelSections(md) {
133
+ const lines = md.split("\n");
134
+ const sections = [];
135
+ let currentHeading = "";
136
+ let currentLines = [];
137
+ for (const line of lines) {
138
+ const m = /^#\s+(.*)$/.exec(line);
139
+ if (m) {
140
+ if (currentHeading || currentLines.some((l) => l.trim())) {
141
+ sections.push({ heading: currentHeading, body: currentLines.join("\n") });
142
+ }
143
+ currentHeading = m[1].trim();
144
+ currentLines = [];
145
+ } else {
146
+ currentLines.push(line);
147
+ }
148
+ }
149
+ if (currentHeading || currentLines.some((l) => l.trim())) {
150
+ sections.push({ heading: currentHeading, body: currentLines.join("\n") });
151
+ }
152
+ // If the file has zero headings, treat the whole thing as one anonymous section.
153
+ if (sections.length === 0) return [];
154
+ return sections.filter((s) => s.body.trim().length > 0);
155
+ }
156
+
157
+ /**
158
+ * Read `~/.claude/projects/<slug>/memory/*.md`. One item per file.
159
+ *
160
+ * @param {string} memDir
161
+ * @param {string} agentId
162
+ * @param {"user"|"project"} scope
163
+ * @returns {MemoryItem[]}
164
+ */
165
+ export function readMemoryDir(memDir, agentId, scope) {
166
+ let entries;
167
+ try {
168
+ entries = fs.readdirSync(memDir);
169
+ } catch {
170
+ return [];
171
+ }
172
+ const out = [];
173
+ for (const entry of entries) {
174
+ if (!entry.endsWith(".md")) continue;
175
+ if (entry === "MEMORY.md") continue; // index-only
176
+ const full = path.join(memDir, entry);
177
+ let raw;
178
+ try {
179
+ raw = fs.readFileSync(full, "utf8");
180
+ } catch {
181
+ continue;
182
+ }
183
+ const { frontmatter, body } = parseFrontmatter(raw);
184
+ const text = body.trim();
185
+ if (!text) continue;
186
+ const name = frontmatter.name || entry.replace(/\.md$/, "");
187
+ out.push({
188
+ agentId,
189
+ text,
190
+ hash: memoryHash(text, name),
191
+ name,
192
+ metadata: {
193
+ source: `memory/${entry}`,
194
+ scope,
195
+ sourcePath: full,
196
+ ...(frontmatter.name ? { name: frontmatter.name } : {}),
197
+ ...(frontmatter.description ? { description: frontmatter.description } : {}),
198
+ ...(frontmatter.type ? { type: frontmatter.type } : {}),
199
+ },
200
+ });
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * Minimal YAML frontmatter parser. Supports `key: value` on a single line
207
+ * (bare or quoted). Returns `{frontmatter: {...}, body: string}`.
208
+ *
209
+ * @param {string} raw
210
+ * @returns {{frontmatter: object, body: string}}
211
+ */
212
+ export function parseFrontmatter(raw) {
213
+ const m = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(raw);
214
+ if (!m) return { frontmatter: {}, body: raw };
215
+ const fmText = m[1];
216
+ const body = raw.slice(m[0].length);
217
+ const frontmatter = {};
218
+ for (const line of fmText.split("\n")) {
219
+ const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
220
+ if (!kv) continue;
221
+ let val = kv[2].trim();
222
+ // strip surrounding quotes
223
+ if ((val.startsWith('"') && val.endsWith('"')) ||
224
+ (val.startsWith("'") && val.endsWith("'"))) {
225
+ val = val.slice(1, -1);
226
+ }
227
+ frontmatter[kv[1]] = val;
228
+ }
229
+ return { frontmatter, body };
230
+ }