@yugabytedb/meko-mcp 1.0.8 → 1.2.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 (51) hide show
  1. package/README.md +242 -44
  2. package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
  3. package/assets/hooks-handlers/cursor-session-start.sh +42 -0
  4. package/assets/hooks-handlers/lib/capture.js +457 -107
  5. package/assets/hooks-handlers/lib/package.json +1 -0
  6. package/assets/hooks-handlers/test_single_conversation.sh +1 -1
  7. package/assets/scripts/package-desktop-skill.sh +36 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
  17. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
  18. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
  19. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
  20. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
  21. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
  22. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
  31. package/bin/create-meko-setup.mjs +151 -22
  32. package/lib/clients/claude-code.mjs +378 -68
  33. package/lib/clients/claude-desktop.mjs +45 -8
  34. package/lib/clients/codex.mjs +1209 -0
  35. package/lib/clients/common.mjs +223 -1
  36. package/lib/clients/cursor.mjs +118 -13
  37. package/lib/clients/index.mjs +2 -0
  38. package/lib/config.mjs +56 -0
  39. package/lib/detect.mjs +52 -44
  40. package/lib/installer.mjs +11 -4
  41. package/lib/mcp-transport.mjs +61 -2
  42. package/lib/migrate/adapters/memory-md.mjs +13 -6
  43. package/lib/migrate/datapack.mjs +167 -0
  44. package/lib/migrate/id.mjs +127 -9
  45. package/lib/migrate/orchestrator.mjs +68 -10
  46. package/lib/migrate.mjs +346 -3
  47. package/lib/prompt.mjs +22 -4
  48. package/lib/validate.mjs +313 -9
  49. package/lib/version-check.mjs +178 -0
  50. package/package.json +2 -1
  51. package/scripts/bundle-assets.mjs +8 -0
@@ -12,13 +12,16 @@
12
12
  *
13
13
  * Environment:
14
14
  * MEKO_MCP_URL MCP server URL (default: http://localhost:8000/mcp)
15
- * MEKO_AGENT_ID Agent identifier (default: claude_code)
15
+ * MEKO_AGENT_ID Agent identifier override; if unset, derived from the
16
+ * session's cwd as `claude_code:<repo-basename>` (or
17
+ * `meko_agent` if cwd is unknown). See deriveAgentId().
16
18
  * MEKO_API_KEY API key for Cloud Meko auth (optional, omit for local)
17
19
  * MEKO_API_TIMEOUT Request timeout in seconds (default: 10)
18
20
  * MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
19
21
  */
20
22
 
21
23
  const fs = require("fs");
24
+ const os = require("os");
22
25
  const path = require("path");
23
26
  const http = require("http");
24
27
  const https = require("https");
@@ -26,12 +29,89 @@ const https = require("https");
26
29
  // --- Config ---
27
30
 
28
31
  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";
32
+
33
+ // Server-side fallback for empty agent_id; the cross-project common bucket.
34
+ // Mirror of COMMON_BUCKET_AGENT_ID in installer/lib/migrate/id.mjs.
35
+ const COMMON_BUCKET_AGENT_ID = "meko_agent";
36
+ const AGENT_ID_MAX_LEN = 64;
37
+
38
+ /**
39
+ * Derive an `agent_id` for a Meko write. Mirror of `deriveAgentId` in
40
+ * installer/lib/migrate/id.mjs — kept inline because capture.js is published
41
+ * in a separate package with no module dependencies on the installer.
42
+ *
43
+ * - `envOverride` (typically MEKO_AGENT_ID) wins if non-empty.
44
+ * - Coding clients (claude_code, cursor) → `<client>:<repo-basename>`,
45
+ * with the basename lowercased and non-`[a-z0-9-]` runs collapsed to `-`.
46
+ * Outside a repo → bare client name (keeps coding-agent traffic out of
47
+ * the common bucket).
48
+ * - Loose clients (claude-desktop) → `claude_desktop`.
49
+ * - Anything else → `meko_agent` (common cross-project bucket).
50
+ */
51
+ function deriveAgentId(opts) {
52
+ const override = (opts && opts.envOverride ? String(opts.envOverride) : "").trim();
53
+ if (override) return override;
54
+ const client = (opts && opts.client ? String(opts.client) : "").trim();
55
+ if (client === "claude-desktop" || client === "claude_desktop") return "claude_desktop";
56
+ if (client !== "claude_code" && client !== "cursor") return COMMON_BUCKET_AGENT_ID;
57
+ const rawBase = opts && opts.cwd ? path.basename(opts.cwd) : "";
58
+ if (!rawBase || rawBase === "." || rawBase === "/") return client;
59
+ const project = rawBase.toLowerCase()
60
+ .replace(/[^a-z0-9-]+/g, "-")
61
+ .replace(/^-+|-+$/g, "")
62
+ .slice(0, AGENT_ID_MAX_LEN);
63
+ if (!project) return client;
64
+ return `${client}:${project}`;
65
+ }
66
+
67
+ /**
68
+ * Resolve the agent_id for the current session. Frozen at SessionStart;
69
+ * subsequent hook invocations re-derive from the watermark + env, not
70
+ * from a stale module-level cache.
71
+ *
72
+ * Resolution order:
73
+ * 1. MEKO_AGENT_ID env override (verbatim).
74
+ * 2. cwd from the transcript header (peekTranscriptMetadata).
75
+ * 3. explicit workspace/project roots from the hook input.
76
+ * 4. cwd from the hook input.
77
+ * 5. process.cwd() — last resort if no transcript metadata yet.
78
+ */
79
+ function resolveSessionAgentId(transcriptPath, hookInput = null) {
80
+ const envOverride = process.env.MEKO_AGENT_ID;
81
+ if (envOverride && envOverride.trim()) return envOverride.trim();
82
+ const meta = transcriptPath ? peekTranscriptMetadata(transcriptPath) : null;
83
+ const nested = hookInput && hookInput.hookSpecificInput && typeof hookInput.hookSpecificInput === "object"
84
+ ? hookInput.hookSpecificInput
85
+ : {};
86
+ const roots = hookInput && Array.isArray(hookInput.workspace_roots)
87
+ ? hookInput.workspace_roots
88
+ : [];
89
+ const nestedRoots = Array.isArray(nested.workspace_roots) ? nested.workspace_roots : [];
90
+ const workspaceRoot = [...roots, ...nestedRoots]
91
+ .find((item) => typeof item === "string" && item.trim());
92
+ const cwd =
93
+ (meta && meta.cwd) ||
94
+ hookInput?.workspace_path ||
95
+ hookInput?.workspacePath ||
96
+ hookInput?.project_root ||
97
+ nested.workspace_path ||
98
+ nested.workspacePath ||
99
+ nested.project_root ||
100
+ workspaceRoot ||
101
+ hookInput?.cwd ||
102
+ nested.cwd ||
103
+ process.cwd();
104
+ const client = process.env.MEKO_HOOK_CLIENT === "cursor" ? "cursor" : "claude_code";
105
+ return deriveAgentId({ client, cwd, envOverride });
106
+ }
30
107
  const MEKO_API_KEY = process.env.MEKO_API_KEY || "";
31
108
  const MEKO_API_TIMEOUT = parseInt(process.env.MEKO_API_TIMEOUT || "10", 10) * 1000;
32
109
  const WATERMARK_DIR =
33
110
  process.env.MEKO_WATERMARK_DIR ||
34
- path.join(process.env.HOME || "~", ".claude", "meko-capture");
111
+ path.join(os.homedir(), ".claude", "meko-capture");
112
+ const SESSION_CACHE_DIR =
113
+ process.env.MEKO_SESSION_CACHE_DIR ||
114
+ path.join(os.homedir(), ".cursor", "meko-session-cache");
35
115
 
36
116
  let mcpRequestId = 0;
37
117
  let mcpSessionId = null; // MCP Streamable HTTP session ID
@@ -257,6 +337,20 @@ function writeWatermark(wmPath, convId, lineNum, agentId) {
257
337
  );
258
338
  }
259
339
 
340
+ // note: Subagents cannot auto-discover the parent's watermark today.
341
+ // The watermark dir is flat and keyed by session UUID, and a subagent
342
+ // process has no way to learn its parent's session UUID:
343
+ // - no CLAUDE_SESSION_ID / CLAUDE_PARENT_SESSION_ID env var
344
+ // - ~/.claude/session-env/<uuid>/ directories exist but are empty
345
+ // - cwd is shared with the parent but isn't a unique session key
346
+ // (parent may have multiple concurrent sessions in one project;
347
+ // findMostRecentProjectWatermark() keys off transcript_path,
348
+ // which Agent / Task spawn prompts do not expose)
349
+ // Verified on Claude Code 2.1.126 via issue #49 spike (2026-05-01).
350
+ // Enforcement is shifted to the skill: parents prepend an inherited-
351
+ // context block to every Task / Agent spawn prompt. See
352
+ // skills/skills/meko-mcp-tools/SKILL.md "When spawning subagents".
353
+
260
354
  // --- MCP JSON-RPC client (Streamable HTTP with session init) ---
261
355
 
262
356
  /**
@@ -300,7 +394,7 @@ function mcpPost(jsonBody) {
300
394
  return;
301
395
  }
302
396
  try {
303
- const parsed = JSON.parse(data);
397
+ const parsed = parseMcpResponseBody(data);
304
398
  if (parsed.error) {
305
399
  reject(new Error(JSON.stringify(parsed.error)));
306
400
  } else {
@@ -328,6 +422,37 @@ function mcpPost(jsonBody) {
328
422
  });
329
423
  }
330
424
 
425
+ function parseMcpResponseBody(data) {
426
+ const trimmed = data.trim();
427
+ if (!trimmed) return null;
428
+
429
+ if (!trimmed.startsWith("event:") && !trimmed.startsWith("data:")) {
430
+ return JSON.parse(trimmed);
431
+ }
432
+
433
+ const payloads = [];
434
+ let current = [];
435
+ for (const line of data.split(/\r?\n/)) {
436
+ if (line === "") {
437
+ if (current.length) {
438
+ payloads.push(current.join("\n"));
439
+ current = [];
440
+ }
441
+ continue;
442
+ }
443
+ if (line.startsWith("data:")) {
444
+ current.push(line.startsWith("data: ") ? line.slice(6) : line.slice(5));
445
+ }
446
+ }
447
+ if (current.length) payloads.push(current.join("\n"));
448
+
449
+ const payload = payloads.find((item) => item.trim()) || "";
450
+ if (!payload) {
451
+ throw new Error("SSE response did not include a data payload");
452
+ }
453
+ return JSON.parse(payload);
454
+ }
455
+
331
456
  /**
332
457
  * Abort all pending MCP requests. Used by the preload timeout path so the
333
458
  * SessionStart hook can return promptly instead of waiting for a slow server
@@ -398,43 +523,43 @@ function extractToolResult(response) {
398
523
  }
399
524
 
400
525
  /**
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.
526
+ * Peek the first JSONL line of a transcript to harvest `cwd`, `gitBranch`,
527
+ * and `timestamp`. These are attached as `metadata` on the hook's
528
+ * `conversation_create` so hook-captured traces match the metadata shape
529
+ * the migrator emits (see installer/lib/migrate/orchestrator.mjs).
408
530
  *
409
- * Cached in the watermark file so subsequent SessionStart hooks skip the
410
- * `db_summarize_database` round-trip.
531
+ * Reads a bounded 4KB prefix, not the whole file — on PreCompact / SessionEnd
532
+ * the transcript can be several MB, and we only need the first line's JSON
533
+ * header. If the header happens to exceed 4KB (pathological; a Claude Code
534
+ * header is usually <1KB), JSON.parse will reject the truncated prefix and
535
+ * we fall back to empty metadata via the catch. Same graceful degradation
536
+ * as any other read/parse failure — metadata is best-effort enrichment.
411
537
  */
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 };
538
+ function peekTranscriptMetadata(transcriptPath) {
539
+ const empty = { cwd: null, gitBranch: null, startedAt: null };
540
+ if (!transcriptPath) return empty;
541
+ let fd;
542
+ try {
543
+ fd = fs.openSync(transcriptPath, "r");
544
+ const buffer = Buffer.alloc(4096);
545
+ const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
546
+ if (bytesRead === 0) return empty;
547
+ const content = buffer.toString("utf-8", 0, bytesRead);
548
+ const firstNewline = content.indexOf("\n");
549
+ const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
550
+ const obj = JSON.parse(firstLine);
551
+ return {
552
+ cwd: typeof obj.cwd === "string" ? obj.cwd : null,
553
+ gitBranch: typeof obj.gitBranch === "string" ? obj.gitBranch : null,
554
+ startedAt: typeof obj.timestamp === "string" ? obj.timestamp : null,
555
+ };
556
+ } catch {
557
+ return empty;
558
+ } finally {
559
+ if (fd !== undefined) {
560
+ try { fs.closeSync(fd); } catch { /* FD already gone; nothing to do */ }
435
561
  }
436
562
  }
437
- return best.rows > 0 ? best.agent : "agent";
438
563
  }
439
564
 
440
565
  /**
@@ -469,23 +594,32 @@ async function fetchRecentMemories(agentId, { limit = 10, budget = 2000 } = {})
469
594
  return summaries;
470
595
  }
471
596
 
472
- async function createConversation(sessionId) {
473
- const response = await mcpCall("conversation_create", {
597
+ async function createConversation(sessionId, agentId, metadata = null) {
598
+ const payload = {
474
599
  scope: "write",
475
- agent_id: MEKO_AGENT_ID,
600
+ agent_id: agentId,
476
601
  title: "Claude Code session (auto-captured)",
477
602
  session_id: sessionId,
478
- });
603
+ };
604
+ if (metadata) {
605
+ payload.metadata = JSON.stringify({
606
+ source: "hook",
607
+ cwd: metadata.cwd ?? null,
608
+ gitBranch: metadata.gitBranch ?? null,
609
+ startedAt: metadata.startedAt ?? null,
610
+ });
611
+ }
612
+ const response = await mcpCall("conversation_create", payload);
479
613
  const result = extractToolResult(response);
480
614
  return result && result.id ? result.id : null;
481
615
  }
482
616
 
483
- async function addMessage(convId, exchange) {
617
+ async function addMessage(convId, agentId, exchange) {
484
618
  const seed = `${convId}:${exchange.user_uuid}`;
485
619
  await mcpCall("conversation_add_message", {
486
620
  scope: "write",
487
621
  conversation_id: convId,
488
- agent_id: MEKO_AGENT_ID,
622
+ agent_id: agentId,
489
623
  input: exchange.input,
490
624
  output: exchange.output,
491
625
  reasoning: exchange.reasoning,
@@ -504,12 +638,112 @@ function hookEventName(hookType) {
504
638
  }
505
639
 
506
640
  function hookOutput(additionalContext) {
507
- return JSON.stringify({
641
+ // Emit the native coding-agent harness field while preserving the nested
642
+ // Claude-compatible shape for clients that still consume it.
643
+ const output = {
508
644
  hookSpecificOutput: {
509
645
  hookEventName: hookEventName(process.argv[2]),
510
646
  additionalContext: additionalContext || "",
511
647
  },
512
- });
648
+ };
649
+ if (process.argv[2] === "session-start") {
650
+ output.additional_context = additionalContext || "";
651
+ }
652
+ return JSON.stringify(output);
653
+ }
654
+
655
+ function nativeOutput(additionalContext) {
656
+ return JSON.stringify({ additional_context: additionalContext || "" });
657
+ }
658
+
659
+ function beforeSubmitOutput(shouldContinue, userMessage = "") {
660
+ const output = { continue: Boolean(shouldContinue) };
661
+ if (userMessage) output.user_message = userMessage;
662
+ return JSON.stringify(output);
663
+ }
664
+
665
+ function extractSessionId(hookInput, transcriptPath) {
666
+ if (transcriptPath) return path.basename(transcriptPath, ".jsonl");
667
+ const nested = hookInput && hookInput.hookSpecificInput && typeof hookInput.hookSpecificInput === "object"
668
+ ? hookInput.hookSpecificInput
669
+ : {};
670
+ for (const value of [
671
+ hookInput && hookInput.session_id,
672
+ hookInput && hookInput.sessionId,
673
+ nested.session_id,
674
+ nested.sessionId,
675
+ ]) {
676
+ if (typeof value === "string" && value.trim()) return value.trim();
677
+ }
678
+ return null;
679
+ }
680
+
681
+ function sessionCachePath(sessionId) {
682
+ if (!sessionId) return null;
683
+ const safe = sessionId.replace(/[^A-Za-z0-9_.-]/g, "_");
684
+ return path.join(SESSION_CACHE_DIR, `${safe}.json`);
685
+ }
686
+
687
+ function writeSessionCache(sessionId, payload) {
688
+ const filePath = sessionCachePath(sessionId);
689
+ if (!filePath) return;
690
+ const workspaceKey = payload.workspace_key || "";
691
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
692
+ const record = {
693
+ session_id: sessionId,
694
+ updated_at: new Date().toISOString(),
695
+ ...payload,
696
+ };
697
+ fs.writeFileSync(filePath, JSON.stringify(record));
698
+ if (workspaceKey) {
699
+ const safe = workspaceKey.replace(/[^A-Za-z0-9_.-]/g, "_");
700
+ fs.writeFileSync(path.join(SESSION_CACHE_DIR, `workspace-${safe}.json`), JSON.stringify(record));
701
+ }
702
+ }
703
+
704
+ function readSessionCache(sessionId) {
705
+ const filePath = sessionCachePath(sessionId);
706
+ if (!filePath) return null;
707
+ try {
708
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+
714
+ function workspaceKeyFromHookInput(hookInput, transcriptPath) {
715
+ const roots = hookInput && Array.isArray(hookInput.workspace_roots)
716
+ ? hookInput.workspace_roots
717
+ : [];
718
+ const firstRoot = roots.find((item) => typeof item === "string" && item.trim());
719
+ if (firstRoot) return firstRoot.trim();
720
+ if (transcriptPath) return path.dirname(transcriptPath);
721
+ const cwd = hookInput && typeof hookInput.cwd === "string" && hookInput.cwd.trim()
722
+ ? hookInput.cwd.trim()
723
+ : "";
724
+ return cwd || process.cwd();
725
+ }
726
+
727
+ function readWorkspaceSessionCache(workspaceKey) {
728
+ if (!workspaceKey) return null;
729
+ const safe = workspaceKey.replace(/[^A-Za-z0-9_.-]/g, "_");
730
+ try {
731
+ return JSON.parse(fs.readFileSync(path.join(SESSION_CACHE_DIR, `workspace-${safe}.json`), "utf-8"));
732
+ } catch {
733
+ return null;
734
+ }
735
+ }
736
+
737
+ function selectSessionCache(sessionId, workspaceKey) {
738
+ const exactCache = sessionId ? readSessionCache(sessionId) : null;
739
+ const workspaceCache = readWorkspaceSessionCache(workspaceKey);
740
+ const cache =
741
+ exactCache && exactCache.status === "ready"
742
+ ? exactCache
743
+ : workspaceCache && workspaceCache.status === "ready"
744
+ ? workspaceCache
745
+ : exactCache || workspaceCache;
746
+ return cache;
513
747
  }
514
748
 
515
749
  // --- Main ---
@@ -546,7 +780,7 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
546
780
  }
547
781
 
548
782
  function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
549
- const resolvedAgentId = agentId || "claude_code";
783
+ const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
550
784
  const resumed = Boolean(opts && opts.resumed);
551
785
  const opening = resumed
552
786
  ? `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.`
@@ -568,7 +802,7 @@ ${opening}
568
802
 
569
803
  ### What you MUST do with Meko tools
570
804
 
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).
805
+ - **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session. This was derived from the cwd as \`<client>:<repo-basename>\` so memories stay scoped to this project. For genuinely cross-project facts (user identity, global preferences) pass agent_id="${COMMON_BUCKET_AGENT_ID}" — that's the common bucket any agent can read regardless of project.
572
806
  - **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
573
807
  - **scope**: Only "read", "write", or "admin"
574
808
 
@@ -588,7 +822,8 @@ Before any action that deletes, overwrites, or restructures user content (Write-
588
822
  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
823
  }
590
824
 
591
- function buildSessionStartFallbackContext() {
825
+ function buildSessionStartFallbackContext(agentId) {
826
+ const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
592
827
  return `## Meko Memory Instructions
593
828
 
594
829
  The SessionStart hook could not reach the Meko MCP server to create a conversation. Automatic capture may not work for this session.
@@ -596,7 +831,7 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
596
831
  ### What you should still do
597
832
 
598
833
  - **Use Meko MCP tools** for memory_add, memory_search, etc. when available
599
- - **agent_id**: ALWAYS use "claude_code" (underscore, NEVER hyphens)
834
+ - **agent_id**: use "${resolvedAgentId}" for project-scoped writes; use "${COMMON_BUCKET_AGENT_ID}" for cross-project common facts
600
835
  - **conversation_id**: Use nil UUID "00000000-0000-0000-0000-000000000000" as placeholder
601
836
  - **scope**: Only "read", "write", or "admin"
602
837
 
@@ -611,6 +846,21 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
611
846
  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
847
  }
613
848
 
849
+ function buildBootstrapPendingContext(agentId) {
850
+ const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
851
+ return `## Meko Bootstrap Pending
852
+
853
+ The Cursor SessionStart hook has started Meko bootstrap work in the background, but the local Meko session cache is not ready yet. Do not guess a Meko conversation_id.
854
+
855
+ ### What you should do
856
+
857
+ - **agent_id**: use "${resolvedAgentId}" for project-scoped reads/writes if a Meko MCP call is necessary before bootstrap completes
858
+ - **conversation_id**: use nil UUID "00000000-0000-0000-0000-000000000000" only as a placeholder until the cache provides the real conversation_id
859
+ - **scope**: Only "read", "write", or "admin"
860
+
861
+ If the user explicitly needs Meko-backed continuity before proceeding, ask them to retry the prompt after bootstrap finishes.`;
862
+ }
863
+
614
864
  /**
615
865
  * Race a promise against a timeout; on timeout resolve to the fallback value.
616
866
  * Memory preload is best-effort — if it stalls, the SessionStart hook must
@@ -650,16 +900,51 @@ function withTimeout(promise, ms, fallback, { onTimeout } = {}) {
650
900
  });
651
901
  }
652
902
 
903
+ function handleSessionPending(hookInput) {
904
+ const transcriptPath =
905
+ hookInput.transcript_path ||
906
+ (hookInput.hookSpecificInput || {}).transcript_path;
907
+ const sessionId = extractSessionId(hookInput, transcriptPath);
908
+ const agentId = resolveSessionAgentId(transcriptPath, hookInput);
909
+ const workspaceKey = workspaceKeyFromHookInput(hookInput, transcriptPath);
910
+ if (sessionId) {
911
+ writeSessionCache(sessionId, {
912
+ status: "pending",
913
+ agent_id: agentId,
914
+ workspace_key: workspaceKey,
915
+ additional_context: buildBootstrapPendingContext(agentId),
916
+ });
917
+ }
918
+ process.stdout.write(nativeOutput(""));
919
+ }
920
+
921
+ function handleBeforeSubmitPrompt(hookInput) {
922
+ const transcriptPath =
923
+ hookInput.transcript_path ||
924
+ (hookInput.hookSpecificInput || {}).transcript_path;
925
+ const sessionId = extractSessionId(hookInput, transcriptPath);
926
+ const workspaceKey = workspaceKeyFromHookInput(hookInput, transcriptPath);
927
+ const cache = selectSessionCache(sessionId, workspaceKey);
928
+ if (cache && cache.status === "pending") {
929
+ process.stdout.write(beforeSubmitOutput(
930
+ false,
931
+ "Meko is still creating this session's conversation. Please retry in a moment so the agent starts with Meko context.",
932
+ ));
933
+ return;
934
+ }
935
+ process.stdout.write(beforeSubmitOutput(true));
936
+ }
937
+
653
938
  async function handleSessionStart(hookInput) {
654
939
  const transcriptPath =
655
940
  hookInput.transcript_path ||
656
941
  (hookInput.hookSpecificInput || {}).transcript_path;
657
942
 
658
- const sessionId = transcriptPath
659
- ? path.basename(transcriptPath, ".jsonl")
660
- : null;
943
+ const sessionId = extractSessionId(hookInput, transcriptPath);
661
944
 
662
945
  const source = hookInput.source || "";
946
+ const derivedAgentId = resolveSessionAgentId(transcriptPath, hookInput);
947
+ const workspaceKey = workspaceKeyFromHookInput(hookInput, transcriptPath);
663
948
 
664
949
  // Check if watermark already exists (same session ID re-fires SessionStart, e.g. /clear, /compact)
665
950
  if (sessionId) {
@@ -667,22 +952,32 @@ async function handleSessionStart(hookInput) {
667
952
  const existing = readWatermark(wmPath);
668
953
  if (existing.conversation_id) {
669
954
  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
- ),
955
+ // Prefer the watermark's cached agent_id so a user-set bucket survives
956
+ // re-fires of SessionStart (e.g. /clear, /compact). The exception is
957
+ // the legacy literal "agent" left by pre-contract installers — we
958
+ // override that with the freshly derived `claude_code:<repo>` so
959
+ // upgraded users stop writing into the ghost namespace.
960
+ const cached = (existing.agent_id || "").trim();
961
+ const resumedAgentId = cached && cached !== "agent" ? cached : derivedAgentId;
962
+ // Skip preload on resume (we don't know what's already in context).
963
+ const context = buildSessionStartContext(
964
+ existing.conversation_id,
965
+ sessionId,
966
+ resumedAgentId,
967
+ null,
682
968
  );
969
+ writeSessionCache(sessionId, {
970
+ status: "ready",
971
+ conversation_id: existing.conversation_id,
972
+ agent_id: resumedAgentId,
973
+ workspace_key: workspaceKey,
974
+ additional_context: context,
975
+ });
976
+ process.stdout.write(hookOutput(context));
683
977
  return;
684
978
  }
685
979
  }
980
+ const agentId = derivedAgentId;
686
981
 
687
982
  // Resume: Claude Code created a new session ID for a resumed thread. Find the most recent
688
983
  // prior watermark for this project and reuse its conversation_id so continuity is preserved.
@@ -693,83 +988,117 @@ async function handleSessionStart(hookInput) {
693
988
  if (prior && prior.conversation_id) {
694
989
  const currentLineCount = fs.existsSync(transcriptPath) ? countLines(transcriptPath) : 0;
695
990
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
696
- writeWatermark(wmPath, prior.conversation_id, currentLineCount);
991
+ // Preserve a non-legacy cached agent_id from the prior watermark so a
992
+ // user-set bucket (e.g. MEKO_AGENT_ID=foo, or a project-scoped
993
+ // claude_code:<other-repo> from the original session) survives the
994
+ // resume. Mirrors the same-session reuse branch above. The legacy
995
+ // literal "agent" is overridden with the freshly derived per-project
996
+ // bucket so upgraded users stop writing into the ghost namespace.
997
+ const priorAgentId = (prior.agent_id || "").trim();
998
+ const resumedAgentId =
999
+ priorAgentId && priorAgentId !== "agent" ? priorAgentId : agentId;
1000
+ writeWatermark(wmPath, prior.conversation_id, currentLineCount, resumedAgentId);
697
1001
  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
- ),
1002
+ const context = buildSessionStartContext(
1003
+ prior.conversation_id,
1004
+ sessionId,
1005
+ resumedAgentId,
1006
+ null,
1007
+ { resumed: true },
708
1008
  );
1009
+ writeSessionCache(sessionId, {
1010
+ status: "ready",
1011
+ conversation_id: prior.conversation_id,
1012
+ agent_id: resumedAgentId,
1013
+ workspace_key: workspaceKey,
1014
+ additional_context: context,
1015
+ });
1016
+ process.stdout.write(hookOutput(context));
709
1017
  return;
710
1018
  }
711
1019
  process.stderr.write(`[meko-capture] Session ${sessionId}: source=resume but no prior project watermark found; creating new conversation\n`);
712
1020
  }
713
1021
 
714
- // Create conversation via MCP
1022
+ // Create conversation via MCP. Attach session metadata (cwd / gitBranch /
1023
+ // startedAt) from the transcript header so hook traces match the migrator.
1024
+ const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
715
1025
  let convId = null;
716
1026
  try {
717
1027
  await mcpInitialize();
718
- convId = await createConversation(sessionId || "unknown");
1028
+ convId = await createConversation(sessionId || "unknown", agentId, transcriptMetadata);
719
1029
  } catch (err) {
720
1030
  process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
721
- process.stdout.write(hookOutput(buildSessionStartFallbackContext()));
1031
+ const context = buildSessionStartFallbackContext(agentId);
1032
+ if (sessionId) {
1033
+ writeSessionCache(sessionId, {
1034
+ status: "error",
1035
+ agent_id: agentId,
1036
+ workspace_key: workspaceKey,
1037
+ error: err && err.message ? String(err.message).slice(0, 240) : "MCP unavailable",
1038
+ additional_context: context,
1039
+ });
1040
+ }
1041
+ process.stdout.write(hookOutput(context));
722
1042
  return;
723
1043
  }
724
1044
 
725
1045
  if (!convId) {
726
1046
  process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
727
- process.stdout.write(hookOutput(buildSessionStartFallbackContext()));
1047
+ const context = buildSessionStartFallbackContext(agentId);
1048
+ if (sessionId) {
1049
+ writeSessionCache(sessionId, {
1050
+ status: "error",
1051
+ agent_id: agentId,
1052
+ workspace_key: workspaceKey,
1053
+ error: "conversation_create returned no ID",
1054
+ additional_context: context,
1055
+ });
1056
+ }
1057
+ process.stdout.write(hookOutput(context));
728
1058
  return;
729
1059
  }
730
1060
 
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.
1061
+ // Preload recent memories with a 4s budget (SessionStart timeout is 15s).
1062
+ // Best-effort enrichment — on timeout/failure we still deliver the core
1063
+ // context. agent_id is derived per-session from the cwd; the old
1064
+ // db_summarize_database discovery helper was removed (it depended on a
1065
+ // paid-tier tool and only worked "correctly" via its fallback branch
1066
+ // anyway — see scratch/2026-04-30-multi-tenant-mem0-audit.md Probe 6).
734
1067
  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
- })(),
1068
+ const memories = await withTimeout(
1069
+ fetchRecentMemories(agentId),
741
1070
  PRELOAD_BUDGET_MS,
742
- { agentId: MEKO_AGENT_ID, memories: [] },
1071
+ [],
743
1072
  { onTimeout: abortInflightRequests },
744
1073
  );
745
1074
 
746
- // Write watermark (now cachinG agent_id too)
747
1075
  if (sessionId) {
748
1076
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
749
- writeWatermark(wmPath, convId, 0, preloadResult.agentId);
1077
+ writeWatermark(wmPath, convId, 0, agentId);
750
1078
  process.stderr.write(
751
- `[meko-capture] SessionStart: created conversation ${convId}, agent_id=${preloadResult.agentId}, preloaded ${preloadResult.memories.length} memories, watermark at ${wmPath}\n`,
1079
+ `[meko-capture] SessionStart: created conversation ${convId}, agent_id=${agentId}, preloaded ${memories.length} memories, watermark at ${wmPath}\n`,
752
1080
  );
753
1081
  }
754
1082
 
755
- process.stdout.write(
756
- hookOutput(
757
- buildSessionStartContext(
758
- convId,
759
- sessionId,
760
- preloadResult.agentId,
761
- preloadResult.memories,
762
- ),
763
- ),
764
- );
1083
+ const context = buildSessionStartContext(convId, sessionId, agentId, memories);
1084
+ if (sessionId) {
1085
+ writeSessionCache(sessionId, {
1086
+ status: "ready",
1087
+ conversation_id: convId,
1088
+ agent_id: agentId,
1089
+ workspace_key: workspaceKey,
1090
+ additional_context: context,
1091
+ });
1092
+ }
1093
+ process.stdout.write(hookOutput(context));
765
1094
  }
766
1095
 
767
1096
  // --- Main ---
768
1097
 
769
1098
  async function main() {
770
1099
  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");
1100
+ if (!hookType || !["session-start", "session-pending", "before-submit-prompt", "pre-compact", "session-end", "checkpoint"].includes(hookType)) {
1101
+ process.stderr.write("Usage: capture.js <session-start|session-pending|before-submit-prompt|pre-compact|session-end|checkpoint>\n");
773
1102
  process.exit(1);
774
1103
  }
775
1104
 
@@ -790,6 +1119,16 @@ async function main() {
790
1119
  return;
791
1120
  }
792
1121
 
1122
+ if (hookType === "session-pending") {
1123
+ handleSessionPending(hookInput);
1124
+ return;
1125
+ }
1126
+
1127
+ if (hookType === "before-submit-prompt") {
1128
+ handleBeforeSubmitPrompt(hookInput);
1129
+ return;
1130
+ }
1131
+
793
1132
  // PreCompact / SessionEnd: capture transcript exchanges
794
1133
  const transcriptPath =
795
1134
  hookInput.transcript_path ||
@@ -807,6 +1146,15 @@ async function main() {
807
1146
  const lastLine = watermark.last_line_number || 0;
808
1147
  const currentLines = countLines(transcriptPath);
809
1148
 
1149
+ // Resolve agent_id for this hook invocation. Prefer the value the
1150
+ // SessionStart hook persisted in the watermark; if it's missing or a
1151
+ // legacy literal ("agent"), fall back to a fresh derivation from the
1152
+ // transcript's cwd so writes land in the right bucket.
1153
+ const cachedAgentId = (watermark.agent_id || "").trim();
1154
+ const agentId = cachedAgentId && cachedAgentId !== "agent"
1155
+ ? cachedAgentId
1156
+ : resolveSessionAgentId(transcriptPath);
1157
+
810
1158
  // Nothing new
811
1159
  if (currentLines <= lastLine) {
812
1160
  process.stdout.write(hookOutput(""));
@@ -839,15 +1187,17 @@ async function main() {
839
1187
 
840
1188
  for (const exchange of exchanges) {
841
1189
  try {
842
- await addMessage(convId, exchange);
1190
+ await addMessage(convId, agentId, exchange);
843
1191
  captured++;
844
1192
  } catch (err) {
845
1193
  process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
846
1194
  }
847
1195
  }
848
1196
 
849
- // Update watermark
850
- writeWatermark(wmPath, convId, currentLines);
1197
+ // Update watermark with the resolved agent_id. If the cached value was a
1198
+ // legacy literal we now overwrite it so subsequent hooks see the derived
1199
+ // bucket directly.
1200
+ writeWatermark(wmPath, convId, currentLines, agentId);
851
1201
 
852
1202
  // Output
853
1203
  const context =