@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,863 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * capture.js — Unified conversation capture for Meko MCP plugin.
4
+ *
5
+ * Handles transcript parsing, watermark management, and MCP API calls.
6
+ * No external dependencies — uses only Node.js built-ins (fs, http/https, crypto).
7
+ *
8
+ * Usage (called by hook shell wrappers):
9
+ * node capture.js session-start # creates conversation + watermark, outputs additionalContext
10
+ * node capture.js pre-compact # reads hook input from stdin
11
+ * node capture.js session-end # reads hook input from stdin
12
+ *
13
+ * Environment:
14
+ * MEKO_MCP_URL MCP server URL (default: http://localhost:8000/mcp)
15
+ * MEKO_AGENT_ID Agent identifier (default: claude_code)
16
+ * MEKO_API_KEY API key for Cloud Meko auth (optional, omit for local)
17
+ * MEKO_API_TIMEOUT Request timeout in seconds (default: 10)
18
+ * MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
19
+ */
20
+
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+ const http = require("http");
24
+ const https = require("https");
25
+
26
+ // --- Config ---
27
+
28
+ const MEKO_MCP_URL = process.env.MEKO_MCP_URL || "http://localhost:8000/mcp";
29
+ const MEKO_AGENT_ID = process.env.MEKO_AGENT_ID || "claude_code";
30
+ const MEKO_API_KEY = process.env.MEKO_API_KEY || "";
31
+ const MEKO_API_TIMEOUT = parseInt(process.env.MEKO_API_TIMEOUT || "10", 10) * 1000;
32
+ const WATERMARK_DIR =
33
+ process.env.MEKO_WATERMARK_DIR ||
34
+ path.join(process.env.HOME || "~", ".claude", "meko-capture");
35
+
36
+ let mcpRequestId = 0;
37
+ let mcpSessionId = null; // MCP Streamable HTTP session ID
38
+ const inflightRequests = new Set(); // tracked so preload timeouts can abort
39
+
40
+ // --- Transcript parsing ---
41
+
42
+ function countLines(filePath) {
43
+ const buf = fs.readFileSync(filePath);
44
+ let count = 0;
45
+ for (let i = 0; i < buf.length; i++) {
46
+ if (buf[i] === 0x0a) count++;
47
+ }
48
+ if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) count++;
49
+ return count;
50
+ }
51
+
52
+ // --- Truncation limits (configurable via env) ---
53
+
54
+ const MAX_THINKING_LEN = parseInt(process.env.MEKO_MAX_THINKING_LEN || "2000", 10);
55
+ const MAX_TOOL_INPUT_LEN = parseInt(process.env.MEKO_MAX_TOOL_INPUT_LEN || "500", 10);
56
+ const MAX_TOOL_RESULT_LEN = parseInt(process.env.MEKO_MAX_TOOL_RESULT_LEN || "3000", 10);
57
+
58
+ function truncate(str, max) {
59
+ if (!str || str.length <= max) return str;
60
+ return str.slice(0, max) + "... [truncated]";
61
+ }
62
+
63
+ // --- Content extraction helpers ---
64
+
65
+ function extractText(content) {
66
+ if (typeof content === "string") return content;
67
+ if (Array.isArray(content)) {
68
+ return content
69
+ .filter((b) => typeof b === "string" || (b && b.type === "text"))
70
+ .map((b) => (typeof b === "string" ? b : b.text || ""))
71
+ .join("\n");
72
+ }
73
+ return "";
74
+ }
75
+
76
+ function extractThinking(contentBlocks) {
77
+ if (!Array.isArray(contentBlocks)) return "";
78
+ const parts = [];
79
+ for (const block of contentBlocks) {
80
+ if (block && block.type === "thinking" && block.thinking) {
81
+ parts.push(truncate(block.thinking, MAX_THINKING_LEN));
82
+ }
83
+ }
84
+ return parts.join("\n");
85
+ }
86
+
87
+ function extractToolCalls(contentBlocks) {
88
+ if (!Array.isArray(contentBlocks)) return "";
89
+ const parts = [];
90
+ for (const block of contentBlocks) {
91
+ if (block && block.type === "tool_use") {
92
+ const name = block.name || "unknown";
93
+ let inputStr = "";
94
+ if (block.input && typeof block.input === "object") {
95
+ try {
96
+ inputStr = truncate(JSON.stringify(block.input), MAX_TOOL_INPUT_LEN);
97
+ } catch {
98
+ inputStr = "(unserializable)";
99
+ }
100
+ }
101
+ parts.push(`TOOL CALL: ${name}(${inputStr})`);
102
+ }
103
+ }
104
+ return parts.join("\n");
105
+ }
106
+
107
+ function isToolResultMessage(msg) {
108
+ if (msg.toolUseResult != null) return true;
109
+ const content = (msg.message || {}).content;
110
+ if (Array.isArray(content)) {
111
+ return content.some((b) => b && b.type === "tool_result");
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function extractToolResultContent(msg) {
117
+ const parts = [];
118
+ const content = (msg.message || {}).content;
119
+ if (Array.isArray(content)) {
120
+ for (const block of content) {
121
+ if (block && block.type === "tool_result") {
122
+ const resultContent = block.content;
123
+ if (typeof resultContent === "string") {
124
+ parts.push(truncate(resultContent, MAX_TOOL_RESULT_LEN));
125
+ } else if (Array.isArray(resultContent)) {
126
+ const text = extractText(resultContent);
127
+ if (text) parts.push(truncate(text, MAX_TOOL_RESULT_LEN));
128
+ }
129
+ }
130
+ }
131
+ }
132
+ // Fallback to toolUseResult summary if no content blocks found
133
+ if (parts.length === 0 && msg.toolUseResult != null) {
134
+ const summary =
135
+ typeof msg.toolUseResult === "string"
136
+ ? msg.toolUseResult
137
+ : JSON.stringify(msg.toolUseResult);
138
+ parts.push(truncate(summary, MAX_TOOL_RESULT_LEN));
139
+ }
140
+ return parts.length > 0 ? "TOOL RESULT:\n" + parts.join("\n") : "";
141
+ }
142
+
143
+ // --- Turn-assembly exchange extraction ---
144
+
145
+ function extractExchanges(filePath, startLine) {
146
+ const data = fs.readFileSync(filePath, "utf-8");
147
+ const lines = data.split("\n");
148
+
149
+ // Step 1: Parse relevant lines
150
+ const entries = [];
151
+ for (let i = startLine; i < lines.length; i++) {
152
+ const line = lines[i].trim();
153
+ if (!line) continue;
154
+ try {
155
+ const obj = JSON.parse(line);
156
+ if (obj.type === "user" || obj.type === "assistant") {
157
+ entries.push(obj);
158
+ }
159
+ } catch {
160
+ continue;
161
+ }
162
+ }
163
+
164
+ // Step 2: Group into exchanges using turn-assembly.
165
+ // A real user message (no toolUseResult) starts a new exchange.
166
+ // All subsequent assistant lines and tool-result user lines belong
167
+ // to that exchange until the next real user message.
168
+ const exchanges = [];
169
+ let current = null;
170
+
171
+ for (const entry of entries) {
172
+ if (entry.type === "user" && !isToolResultMessage(entry)) {
173
+ if (current) exchanges.push(current);
174
+ current = {
175
+ userMsg: entry,
176
+ assistantEntries: [],
177
+ toolResultEntries: [],
178
+ };
179
+ } else if (current) {
180
+ if (entry.type === "assistant") {
181
+ current.assistantEntries.push(entry);
182
+ } else if (entry.type === "user" && isToolResultMessage(entry)) {
183
+ current.toolResultEntries.push(entry);
184
+ }
185
+ }
186
+ }
187
+ if (current) exchanges.push(current);
188
+
189
+ // Step 3: Build output for each exchange
190
+ const skipPattern =
191
+ /^<(system-reminder|command-|local-command|available-deferred)/;
192
+ const results = [];
193
+
194
+ for (const ex of exchanges) {
195
+ const input = extractText((ex.userMsg.message || {}).content);
196
+ if (!input || skipPattern.test(input)) continue;
197
+ if (ex.assistantEntries.length === 0) continue;
198
+
199
+ const outputParts = [];
200
+ const reasoningParts = [];
201
+
202
+ for (const aEntry of ex.assistantEntries) {
203
+ const content = (aEntry.message || {}).content;
204
+ if (!Array.isArray(content)) continue;
205
+
206
+ const text = extractText(content);
207
+ if (text) outputParts.push(text);
208
+
209
+ const thinking = extractThinking(content);
210
+ if (thinking) reasoningParts.push("THINKING:\n" + thinking);
211
+
212
+ const toolCalls = extractToolCalls(content);
213
+ if (toolCalls) reasoningParts.push(toolCalls);
214
+ }
215
+
216
+ for (const trEntry of ex.toolResultEntries) {
217
+ const resultText = extractToolResultContent(trEntry);
218
+ if (resultText) reasoningParts.push(resultText);
219
+ }
220
+
221
+ results.push({
222
+ user_uuid: ex.userMsg.uuid || "unknown",
223
+ input: input,
224
+ output: outputParts.join("\n\n"),
225
+ reasoning: reasoningParts.join("\n---\n"),
226
+ timestamp:
227
+ ex.assistantEntries[ex.assistantEntries.length - 1].timestamp ||
228
+ ex.userMsg.timestamp ||
229
+ "",
230
+ });
231
+ }
232
+
233
+ return results;
234
+ }
235
+
236
+ // --- Watermark ---
237
+
238
+ function readWatermark(wmPath) {
239
+ try {
240
+ return JSON.parse(fs.readFileSync(wmPath, "utf-8"));
241
+ } catch {
242
+ return { conversation_id: "", last_line_number: 0, agent_id: "" };
243
+ }
244
+ }
245
+
246
+ function writeWatermark(wmPath, convId, lineNum, agentId) {
247
+ fs.mkdirSync(path.dirname(wmPath), { recursive: true });
248
+ const existing = readWatermark(wmPath);
249
+ fs.writeFileSync(
250
+ wmPath,
251
+ JSON.stringify({
252
+ conversation_id: convId,
253
+ last_line_number: lineNum,
254
+ agent_id: agentId != null ? agentId : existing.agent_id || "",
255
+ updated_at: new Date().toISOString(),
256
+ })
257
+ );
258
+ }
259
+
260
+ // --- MCP JSON-RPC client (Streamable HTTP with session init) ---
261
+
262
+ /**
263
+ * Low-level HTTP POST to the MCP endpoint.
264
+ * Returns { body: <parsed JSON>, headers: <response headers> }.
265
+ */
266
+ function mcpPost(jsonBody) {
267
+ const body = JSON.stringify(jsonBody);
268
+ return new Promise((resolve, reject) => {
269
+ const url = new URL(MEKO_MCP_URL);
270
+ const transport = url.protocol === "https:" ? https : http;
271
+ const headers = {
272
+ "Content-Type": "application/json",
273
+ Accept: "application/json, text/event-stream",
274
+ "Content-Length": Buffer.byteLength(body),
275
+ };
276
+ if (MEKO_API_KEY) {
277
+ headers["API_KEY"] = MEKO_API_KEY;
278
+ }
279
+ if (mcpSessionId) {
280
+ headers["Mcp-Session-Id"] = mcpSessionId;
281
+ }
282
+
283
+ const req = transport.request(
284
+ {
285
+ hostname: url.hostname,
286
+ port: url.port,
287
+ path: url.pathname + url.search,
288
+ method: "POST",
289
+ headers,
290
+ timeout: MEKO_API_TIMEOUT,
291
+ },
292
+ (res) => {
293
+ let data = "";
294
+ res.on("data", (chunk) => (data += chunk));
295
+ res.on("end", () => {
296
+ inflightRequests.delete(req);
297
+ // Notifications return 202/204 with no body — that's OK
298
+ if (!data.trim()) {
299
+ resolve({ body: null, headers: res.headers });
300
+ return;
301
+ }
302
+ try {
303
+ const parsed = JSON.parse(data);
304
+ if (parsed.error) {
305
+ reject(new Error(JSON.stringify(parsed.error)));
306
+ } else {
307
+ resolve({ body: parsed, headers: res.headers });
308
+ }
309
+ } catch {
310
+ reject(new Error(`Invalid JSON response: ${data.slice(0, 200)}`));
311
+ }
312
+ });
313
+ }
314
+ );
315
+
316
+ inflightRequests.add(req);
317
+ req.on("error", (err) => {
318
+ inflightRequests.delete(req);
319
+ reject(err);
320
+ });
321
+ req.on("timeout", () => {
322
+ inflightRequests.delete(req);
323
+ req.destroy();
324
+ reject(new Error("MCP request timed out"));
325
+ });
326
+ req.write(body);
327
+ req.end();
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Abort all pending MCP requests. Used by the preload timeout path so the
333
+ * SessionStart hook can return promptly instead of waiting for a slow server
334
+ * to finish responding.
335
+ */
336
+ function abortInflightRequests() {
337
+ for (const req of inflightRequests) {
338
+ try {
339
+ req.destroy();
340
+ } catch {
341
+ // no-op
342
+ }
343
+ }
344
+ inflightRequests.clear();
345
+ }
346
+
347
+ /**
348
+ * Initialize MCP session — required before any tools/call.
349
+ * Sends initialize + notifications/initialized per Streamable HTTP spec.
350
+ */
351
+ async function mcpInitialize() {
352
+ mcpRequestId++;
353
+ const initResult = await mcpPost({
354
+ jsonrpc: "2.0",
355
+ method: "initialize",
356
+ params: {
357
+ protocolVersion: "2025-03-26",
358
+ capabilities: {},
359
+ clientInfo: { name: "meko-capture", version: "1.0.0" },
360
+ },
361
+ id: mcpRequestId,
362
+ });
363
+
364
+ // Extract session ID from response header
365
+ const sid =
366
+ initResult.headers["mcp-session-id"] ||
367
+ initResult.headers["Mcp-Session-Id"];
368
+ if (sid) {
369
+ mcpSessionId = sid;
370
+ }
371
+
372
+ // Send initialized notification (fire-and-forget, no id)
373
+ await mcpPost({
374
+ jsonrpc: "2.0",
375
+ method: "notifications/initialized",
376
+ params: {},
377
+ });
378
+ }
379
+
380
+ async function mcpCall(toolName, args) {
381
+ mcpRequestId++;
382
+ const result = await mcpPost({
383
+ jsonrpc: "2.0",
384
+ method: "tools/call",
385
+ params: { name: toolName, arguments: args },
386
+ id: mcpRequestId,
387
+ });
388
+ return result.body;
389
+ }
390
+
391
+ function extractToolResult(response) {
392
+ try {
393
+ const text = response.result.content[0].text;
394
+ return JSON.parse(text);
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Discover the user's agent_id namespace from existing Mem0 collections.
402
+ *
403
+ * Mirrors the protocol in skills/skills/meko-mcp-tools/SKILL.md lines 41-49:
404
+ * call `db_summarize_database`, scan for `<agent>_mem0_collection` tables,
405
+ * pick the one with the highest `row_count`. Falls back to "agent" when the
406
+ * user has no prior memories. Never returns null — always returns a usable
407
+ * string.
408
+ *
409
+ * Cached in the watermark file so subsequent SessionStart hooks skip the
410
+ * `db_summarize_database` round-trip.
411
+ */
412
+ async function discoverAgentId() {
413
+ const response = await mcpCall("db_summarize_database", {
414
+ scope: "read",
415
+ conversation_id: "00000000-0000-0000-0000-000000000000",
416
+ });
417
+ const result = extractToolResult(response);
418
+ if (!result) return "agent";
419
+
420
+ // The tool may return tables in several shapes depending on server version.
421
+ // Defensive: look for any array of { name, row_count } under common keys.
422
+ const tables = []
423
+ .concat(result.tables || [])
424
+ .concat(result.schemas?.flatMap?.((s) => s.tables || []) || []);
425
+ if (!Array.isArray(tables) || tables.length === 0) return "agent";
426
+
427
+ let best = { agent: "", rows: -1 };
428
+ for (const t of tables) {
429
+ const name = t.name || t.table_name || "";
430
+ const rows = Number(t.row_count ?? t.rows ?? 0);
431
+ const m = /^(.+)_mem0_collection$/.exec(name);
432
+ if (!m) continue;
433
+ if (rows > best.rows) {
434
+ best = { agent: m[1], rows };
435
+ }
436
+ }
437
+ return best.rows > 0 ? best.agent : "agent";
438
+ }
439
+
440
+ /**
441
+ * Fetch recent memories for the chosen agent_id namespace.
442
+ *
443
+ * Returns an array of short summaries (one per memory), truncated to a global
444
+ * byte budget so the SessionStart `additionalContext` payload stays compact.
445
+ * On any error the caller falls back to an empty list — memory preload is a
446
+ * best-effort enrichment, not a correctness requirement.
447
+ */
448
+ async function fetchRecentMemories(agentId, { limit = 10, budget = 2000 } = {}) {
449
+ const response = await mcpCall("memory_get_all", {
450
+ scope: "read",
451
+ agent_id: agentId,
452
+ conversation_id: "00000000-0000-0000-0000-000000000000",
453
+ limit,
454
+ });
455
+ const result = extractToolResult(response);
456
+ if (!result) return [];
457
+
458
+ const entries = Array.isArray(result) ? result : result.memories || result.results || [];
459
+ const summaries = [];
460
+ let used = 0;
461
+ for (const entry of entries) {
462
+ const text = entry.memory || entry.text || entry.content || "";
463
+ if (!text) continue;
464
+ const line = `- ${String(text).replace(/\s+/g, " ").trim()}`;
465
+ if (used + line.length + 1 > budget) break;
466
+ summaries.push(line);
467
+ used += line.length + 1;
468
+ }
469
+ return summaries;
470
+ }
471
+
472
+ async function createConversation(sessionId) {
473
+ const response = await mcpCall("conversation_create", {
474
+ scope: "write",
475
+ agent_id: MEKO_AGENT_ID,
476
+ title: "Claude Code session (auto-captured)",
477
+ session_id: sessionId,
478
+ });
479
+ const result = extractToolResult(response);
480
+ return result && result.id ? result.id : null;
481
+ }
482
+
483
+ async function addMessage(convId, exchange) {
484
+ const seed = `${convId}:${exchange.user_uuid}`;
485
+ await mcpCall("conversation_add_message", {
486
+ scope: "write",
487
+ conversation_id: convId,
488
+ agent_id: MEKO_AGENT_ID,
489
+ input: exchange.input,
490
+ output: exchange.output,
491
+ reasoning: exchange.reasoning,
492
+ seed: seed,
493
+ });
494
+ }
495
+
496
+ // --- Hook output ---
497
+
498
+ function hookEventName(hookType) {
499
+ switch (hookType) {
500
+ case "session-start": return "SessionStart";
501
+ case "pre-compact": return "PreCompact";
502
+ default: return "SessionEnd";
503
+ }
504
+ }
505
+
506
+ function hookOutput(additionalContext) {
507
+ return JSON.stringify({
508
+ hookSpecificOutput: {
509
+ hookEventName: hookEventName(process.argv[2]),
510
+ additionalContext: additionalContext || "",
511
+ },
512
+ });
513
+ }
514
+
515
+ // --- Main ---
516
+
517
+ // --- SessionStart: create conversation + watermark deterministically ---
518
+
519
+ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
520
+ try {
521
+ const projectDir = path.dirname(transcriptPath);
522
+ const files = fs.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl"));
523
+ let best = null;
524
+ for (const f of files) {
525
+ const sid = path.basename(f, ".jsonl");
526
+ if (sid === currentSessionId) continue;
527
+ const wmPath = path.join(WATERMARK_DIR, `${sid}.watermark.json`);
528
+ if (!fs.existsSync(wmPath)) continue;
529
+ const wm = readWatermark(wmPath);
530
+ if (!wm.conversation_id) continue;
531
+ const ts = wm.updated_at || "";
532
+ if (!best || ts > best.updated_at) {
533
+ best = {
534
+ sessionId: sid,
535
+ conversation_id: wm.conversation_id,
536
+ agent_id: wm.agent_id || "",
537
+ updated_at: ts,
538
+ };
539
+ }
540
+ }
541
+ return best;
542
+ } catch (err) {
543
+ process.stderr.write(`[meko-capture] findMostRecentProjectWatermark failed: ${err.message}\n`);
544
+ return null;
545
+ }
546
+ }
547
+
548
+ function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
549
+ const resolvedAgentId = agentId || "claude_code";
550
+ const resumed = Boolean(opts && opts.resumed);
551
+ const opening = resumed
552
+ ? `Resuming Meko conversation **${convId}** from a prior Claude Code session. The watermark file is written — PreCompact and SessionEnd hooks will append this session's new exchanges to the same conversation.`
553
+ : `Meko conversation **${convId}** was created automatically by the SessionStart hook. The watermark file is written — PreCompact and SessionEnd hooks will capture this session's transcript verbatim.`;
554
+ const memoryBlock =
555
+ Array.isArray(memories) && memories.length > 0
556
+ ? `
557
+
558
+ ### Memories from prior sessions
559
+
560
+ These facts were preloaded for you — inspect them before acting so you don't re-ask questions the user has already answered:
561
+
562
+ ${memories.join("\n")}`
563
+ : "";
564
+
565
+ return `## Meko Session Active
566
+
567
+ ${opening}
568
+
569
+ ### What you MUST do with Meko tools
570
+
571
+ - **agent_id**: use "${resolvedAgentId}" (discovered from the user's existing namespace — DO NOT substitute "claude_code" just because you're running in Claude Code; see the meko-mcp-tools skill for why).
572
+ - **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
573
+ - **scope**: Only "read", "write", or "admin"
574
+
575
+ ### Proactive storage (do NOT wait to be asked)
576
+
577
+ - User shares personal info, preferences, or role → call memory_add immediately
578
+ - User shares org conventions or team standards → call memory_add immediately
579
+ - User asks "what do you know about X?" → call memory_search first
580
+ - User provides structured/tabular data → call db_run_write_query (CREATE TABLE + INSERT)
581
+
582
+ ### Before destructive actions
583
+
584
+ Before any action that deletes, overwrites, or restructures user content (Write-overwrite, large Edit deletions, rm, git reset --hard, destructive SQL), call memory_search for relevant feedback and confirm with the user if any prior guidance applies.
585
+
586
+ ### Use Meko, not local storage
587
+
588
+ When Meko MCP tools are available, ALWAYS use them instead of local file-based memory (e.g., writing to ~/.claude/projects/.../memory/). If a Meko tool call fails, fall back to local storage and tell the user.${memoryBlock}`;
589
+ }
590
+
591
+ function buildSessionStartFallbackContext() {
592
+ return `## Meko Memory Instructions
593
+
594
+ The SessionStart hook could not reach the Meko MCP server to create a conversation. Automatic capture may not work for this session.
595
+
596
+ ### What you should still do
597
+
598
+ - **Use Meko MCP tools** for memory_add, memory_search, etc. when available
599
+ - **agent_id**: ALWAYS use "claude_code" (underscore, NEVER hyphens)
600
+ - **conversation_id**: Use nil UUID "00000000-0000-0000-0000-000000000000" as placeholder
601
+ - **scope**: Only "read", "write", or "admin"
602
+
603
+ ### Proactive storage (do NOT wait to be asked)
604
+
605
+ - User shares personal info, preferences, or role → call memory_add immediately
606
+ - User shares org conventions or team standards → call memory_add immediately
607
+ - User asks "what do you know about X?" → call memory_search first
608
+
609
+ ### Use Meko, not local storage
610
+
611
+ When Meko MCP tools are available, ALWAYS use them instead of local file-based memory. If a Meko tool call fails, fall back to local storage and tell the user.`;
612
+ }
613
+
614
+ /**
615
+ * Race a promise against a timeout; on timeout resolve to the fallback value.
616
+ * Memory preload is best-effort — if it stalls, the SessionStart hook must
617
+ * still deliver the conversation + instructions under the 15 s harness budget.
618
+ */
619
+ function withTimeout(promise, ms, fallback, { onTimeout } = {}) {
620
+ return new Promise((resolve) => {
621
+ let settled = false;
622
+ const timer = setTimeout(() => {
623
+ if (!settled) {
624
+ settled = true;
625
+ if (typeof onTimeout === "function") {
626
+ try {
627
+ onTimeout();
628
+ } catch {
629
+ // no-op
630
+ }
631
+ }
632
+ resolve(fallback);
633
+ }
634
+ }, ms);
635
+ promise
636
+ .then((value) => {
637
+ if (!settled) {
638
+ settled = true;
639
+ clearTimeout(timer);
640
+ resolve(value);
641
+ }
642
+ })
643
+ .catch(() => {
644
+ if (!settled) {
645
+ settled = true;
646
+ clearTimeout(timer);
647
+ resolve(fallback);
648
+ }
649
+ });
650
+ });
651
+ }
652
+
653
+ async function handleSessionStart(hookInput) {
654
+ const transcriptPath =
655
+ hookInput.transcript_path ||
656
+ (hookInput.hookSpecificInput || {}).transcript_path;
657
+
658
+ const sessionId = transcriptPath
659
+ ? path.basename(transcriptPath, ".jsonl")
660
+ : null;
661
+
662
+ const source = hookInput.source || "";
663
+
664
+ // Check if watermark already exists (same session ID re-fires SessionStart, e.g. /clear, /compact)
665
+ if (sessionId) {
666
+ const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
667
+ const existing = readWatermark(wmPath);
668
+ if (existing.conversation_id) {
669
+ process.stderr.write(`[meko-capture] Session ${sessionId}: reusing existing conversation ${existing.conversation_id}\n`);
670
+ // Reuse cached agent_id + skip preload on resume (we don't know what's
671
+ // already in context). The skill still has the discovery protocol as a
672
+ // safety net.
673
+ process.stdout.write(
674
+ hookOutput(
675
+ buildSessionStartContext(
676
+ existing.conversation_id,
677
+ sessionId,
678
+ existing.agent_id || "",
679
+ null,
680
+ ),
681
+ ),
682
+ );
683
+ return;
684
+ }
685
+ }
686
+
687
+ // Resume: Claude Code created a new session ID for a resumed thread. Find the most recent
688
+ // prior watermark for this project and reuse its conversation_id so continuity is preserved.
689
+ // Initialize last_line_number to the current line count so the copied history (which Claude
690
+ // Code replays into the new JSONL) is not re-ingested.
691
+ if (source === "resume" && transcriptPath && sessionId) {
692
+ const prior = findMostRecentProjectWatermark(transcriptPath, sessionId);
693
+ if (prior && prior.conversation_id) {
694
+ const currentLineCount = fs.existsSync(transcriptPath) ? countLines(transcriptPath) : 0;
695
+ const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
696
+ writeWatermark(wmPath, prior.conversation_id, currentLineCount);
697
+ process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${prior.conversation_id} from ${prior.sessionId}, skipping ${currentLineCount} copied lines\n`);
698
+ process.stdout.write(
699
+ hookOutput(
700
+ buildSessionStartContext(
701
+ prior.conversation_id,
702
+ sessionId,
703
+ prior.agent_id || "",
704
+ null,
705
+ { resumed: true },
706
+ ),
707
+ ),
708
+ );
709
+ return;
710
+ }
711
+ process.stderr.write(`[meko-capture] Session ${sessionId}: source=resume but no prior project watermark found; creating new conversation\n`);
712
+ }
713
+
714
+ // Create conversation via MCP
715
+ let convId = null;
716
+ try {
717
+ await mcpInitialize();
718
+ convId = await createConversation(sessionId || "unknown");
719
+ } catch (err) {
720
+ process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
721
+ process.stdout.write(hookOutput(buildSessionStartFallbackContext()));
722
+ return;
723
+ }
724
+
725
+ if (!convId) {
726
+ process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
727
+ process.stdout.write(hookOutput(buildSessionStartFallbackContext()));
728
+ return;
729
+ }
730
+
731
+ // Discover agent_id + preload memories with a combined 4s budget (SessionStart
732
+ // timeout is 15s). Both are enrichment — on timeout/failure we still deliver
733
+ // the core SessionStart context with the MEKO_AGENT_ID default.
734
+ const PRELOAD_BUDGET_MS = 4000;
735
+ const preloadResult = await withTimeout(
736
+ (async () => {
737
+ const agentId = await discoverAgentId();
738
+ const memories = await fetchRecentMemories(agentId);
739
+ return { agentId, memories };
740
+ })(),
741
+ PRELOAD_BUDGET_MS,
742
+ { agentId: MEKO_AGENT_ID, memories: [] },
743
+ { onTimeout: abortInflightRequests },
744
+ );
745
+
746
+ // Write watermark (now cachinG agent_id too)
747
+ if (sessionId) {
748
+ const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
749
+ writeWatermark(wmPath, convId, 0, preloadResult.agentId);
750
+ process.stderr.write(
751
+ `[meko-capture] SessionStart: created conversation ${convId}, agent_id=${preloadResult.agentId}, preloaded ${preloadResult.memories.length} memories, watermark at ${wmPath}\n`,
752
+ );
753
+ }
754
+
755
+ process.stdout.write(
756
+ hookOutput(
757
+ buildSessionStartContext(
758
+ convId,
759
+ sessionId,
760
+ preloadResult.agentId,
761
+ preloadResult.memories,
762
+ ),
763
+ ),
764
+ );
765
+ }
766
+
767
+ // --- Main ---
768
+
769
+ async function main() {
770
+ const hookType = process.argv[2];
771
+ if (!hookType || !["session-start", "pre-compact", "session-end", "checkpoint"].includes(hookType)) {
772
+ process.stderr.write("Usage: capture.js <session-start|pre-compact|session-end|checkpoint>\n");
773
+ process.exit(1);
774
+ }
775
+
776
+ // Read hook input from stdin
777
+ let hookInput;
778
+ try {
779
+ const stdin = fs.readFileSync(0, "utf-8");
780
+ hookInput = JSON.parse(stdin);
781
+ } catch (err) {
782
+ process.stderr.write(`[meko-capture] Failed to parse hook input from stdin: ${err.message}\n`);
783
+ process.stdout.write(hookOutput(""));
784
+ return;
785
+ }
786
+
787
+ // SessionStart: create conversation + watermark, output context
788
+ if (hookType === "session-start") {
789
+ await handleSessionStart(hookInput);
790
+ return;
791
+ }
792
+
793
+ // PreCompact / SessionEnd: capture transcript exchanges
794
+ const transcriptPath =
795
+ hookInput.transcript_path ||
796
+ (hookInput.hookSpecificInput || {}).transcript_path;
797
+
798
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
799
+ process.stdout.write(hookOutput(""));
800
+ return;
801
+ }
802
+
803
+ const sessionId = path.basename(transcriptPath, ".jsonl");
804
+ const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
805
+ const watermark = readWatermark(wmPath);
806
+ let convId = watermark.conversation_id || "";
807
+ const lastLine = watermark.last_line_number || 0;
808
+ const currentLines = countLines(transcriptPath);
809
+
810
+ // Nothing new
811
+ if (currentLines <= lastLine) {
812
+ process.stdout.write(hookOutput(""));
813
+ return;
814
+ }
815
+
816
+ // Initialize MCP session
817
+ try {
818
+ await mcpInitialize();
819
+ } catch (err) {
820
+ process.stderr.write(`[meko-capture] Failed to initialize MCP session: ${err.message}\n`);
821
+ process.stdout.write(hookOutput(""));
822
+ return;
823
+ }
824
+
825
+ // Only SessionStart creates conversations. All other modes require
826
+ // an existing watermark. Skip gracefully if missing.
827
+ if (!convId) {
828
+ process.stderr.write(
829
+ `[meko-capture] ${hookType}: No conversation in watermark for session ${sessionId}. ` +
830
+ `SessionStart hook may not have run yet.\n`
831
+ );
832
+ process.stdout.write(hookOutput(""));
833
+ return;
834
+ }
835
+
836
+ // Extract and send exchanges
837
+ const exchanges = extractExchanges(transcriptPath, lastLine);
838
+ let captured = 0;
839
+
840
+ for (const exchange of exchanges) {
841
+ try {
842
+ await addMessage(convId, exchange);
843
+ captured++;
844
+ } catch (err) {
845
+ process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
846
+ }
847
+ }
848
+
849
+ // Update watermark
850
+ writeWatermark(wmPath, convId, currentLines);
851
+
852
+ // Output
853
+ const context =
854
+ hookType === "pre-compact"
855
+ ? `Pre-compact: captured ${captured} exchanges to Meko conversation ${convId}`
856
+ : "";
857
+ process.stdout.write(hookOutput(context));
858
+ }
859
+
860
+ main().catch((err) => {
861
+ process.stderr.write(`[meko-capture] Fatal error: ${err.message}\n`);
862
+ process.stdout.write(hookOutput(""));
863
+ });