@yugabytedb/meko-mcp 1.1.0 → 2.0.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 (46) hide show
  1. package/README.md +81 -23
  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 +404 -88
  5. package/assets/hooks-handlers/lib/checkpoint-timer.js +23 -11
  6. package/assets/hooks-handlers/session-start.sh +6 -2
  7. package/assets/scripts/meko-statusline.sh +119 -0
  8. package/assets/scripts/package-desktop-skill.sh +39 -0
  9. package/assets/skills/meko-mcp-tools/SKILL.md +42 -50
  10. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +75 -150
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +30 -36
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +6 -22
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +9 -8
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +35 -42
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +28 -92
  17. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +5 -9
  18. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +1 -81
  19. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +244 -0
  20. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +180 -0
  21. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  22. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +327 -0
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +63 -0
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +214 -0
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +105 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +70 -0
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +71 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +75 -0
  30. package/assets/skills/meko-select-datapack-desktop/SKILL.md +198 -0
  31. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +121 -0
  32. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +66 -0
  33. package/bin/create-meko-setup.mjs +33 -4
  34. package/lib/clients/claude-code.mjs +133 -6
  35. package/lib/clients/claude-desktop.mjs +48 -20
  36. package/lib/clients/codex.mjs +538 -5
  37. package/lib/clients/common.mjs +122 -1
  38. package/lib/clients/cursor.mjs +120 -27
  39. package/lib/config.mjs +52 -0
  40. package/lib/installer.mjs +26 -2
  41. package/lib/mcp-transport.mjs +5 -0
  42. package/lib/validate.mjs +0 -1
  43. package/package.json +1 -1
  44. package/scripts/bundle-assets.mjs +16 -0
  45. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +0 -38
  46. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +0 -30
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  const fs = require("fs");
24
+ const os = require("os");
24
25
  const path = require("path");
25
26
  const http = require("http");
26
27
  const https = require("https");
@@ -71,20 +72,46 @@ function deriveAgentId(opts) {
71
72
  * Resolution order:
72
73
  * 1. MEKO_AGENT_ID env override (verbatim).
73
74
  * 2. cwd from the transcript header (peekTranscriptMetadata).
74
- * 3. process.cwd() — last resort if no transcript metadata yet.
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.
75
78
  */
76
- function resolveSessionAgentId(transcriptPath) {
79
+ function resolveSessionAgentId(transcriptPath, hookInput = null) {
77
80
  const envOverride = process.env.MEKO_AGENT_ID;
78
81
  if (envOverride && envOverride.trim()) return envOverride.trim();
79
82
  const meta = transcriptPath ? peekTranscriptMetadata(transcriptPath) : null;
80
- const cwd = (meta && meta.cwd) || process.cwd();
81
- return deriveAgentId({ client: "claude_code", cwd, envOverride });
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 });
82
106
  }
83
107
  const MEKO_API_KEY = process.env.MEKO_API_KEY || "";
84
108
  const MEKO_API_TIMEOUT = parseInt(process.env.MEKO_API_TIMEOUT || "10", 10) * 1000;
85
109
  const WATERMARK_DIR =
86
110
  process.env.MEKO_WATERMARK_DIR ||
87
- 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");
88
115
 
89
116
  let mcpRequestId = 0;
90
117
  let mcpSessionId = null; // MCP Streamable HTTP session ID
@@ -184,10 +211,18 @@ function extractToolResultContent(msg) {
184
211
  }
185
212
  // Fallback to toolUseResult summary if no content blocks found
186
213
  if (parts.length === 0 && msg.toolUseResult != null) {
187
- const summary =
188
- typeof msg.toolUseResult === "string"
189
- ? msg.toolUseResult
190
- : JSON.stringify(msg.toolUseResult);
214
+ let summary;
215
+ if (typeof msg.toolUseResult === "string") {
216
+ summary = msg.toolUseResult;
217
+ } else {
218
+ // toolUseResult is arbitrary tool output; JSON.stringify can throw on
219
+ // circular refs or BigInt. A capture summary must never crash the hook.
220
+ try {
221
+ summary = JSON.stringify(msg.toolUseResult);
222
+ } catch {
223
+ summary = "(unserializable tool result)";
224
+ }
225
+ }
191
226
  parts.push(truncate(summary, MAX_TOOL_RESULT_LEN));
192
227
  }
193
228
  return parts.length > 0 ? "TOOL RESULT:\n" + parts.join("\n") : "";
@@ -296,20 +331,87 @@ function readWatermark(wmPath) {
296
331
  }
297
332
  }
298
333
 
299
- function writeWatermark(wmPath, convId, lineNum, agentId) {
334
+ function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null) {
300
335
  fs.mkdirSync(path.dirname(wmPath), { recursive: true });
301
336
  const existing = readWatermark(wmPath);
337
+ const existingPin =
338
+ existing.datapack_id && existing.datapack_name
339
+ ? {
340
+ datapack_id: existing.datapack_id,
341
+ datapack_name: existing.datapack_name,
342
+ }
343
+ : null;
344
+ const pin = datapackPin || existingPin;
302
345
  fs.writeFileSync(
303
346
  wmPath,
304
347
  JSON.stringify({
305
348
  conversation_id: convId,
306
349
  last_line_number: lineNum,
307
350
  agent_id: agentId != null ? agentId : existing.agent_id || "",
351
+ datapack_id: pin ? pin.datapack_id : null,
352
+ datapack_name: pin ? pin.datapack_name : null,
308
353
  updated_at: new Date().toISOString(),
309
354
  })
310
355
  );
311
356
  }
312
357
 
358
+ /**
359
+ * Convert an `agent_id` (e.g. `claude_code:meko-mcp-server`) into a
360
+ * filesystem-safe slug (`claude_code_meko-mcp-server`). The skill computes
361
+ * the same slug from the SessionStart-injected `agent_id`, so writer and
362
+ * reader agree on the path without sharing any session UUID.
363
+ */
364
+ function datapackPinSlug(agentId) {
365
+ const trimmed = (typeof agentId === "string" ? agentId : "").trim();
366
+ if (!trimmed) return COMMON_BUCKET_AGENT_ID;
367
+ return trimmed.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || COMMON_BUCKET_AGENT_ID;
368
+ }
369
+
370
+ /**
371
+ * Read the active-datapack pin written by the meko-select-datapack skill.
372
+ *
373
+ * The pin is a sidecar file at `<WATERMARK_DIR>/pin-<slug(agent_id)>.json`
374
+ * with shape `{ datapack_id, datapack_name, selected_at }`. Project-scoped:
375
+ * keyed by the same `agent_id` the hook derives for memory writes
376
+ * (`claude_code:<repo-basename>`), so a pin set in one Claude Code window
377
+ * for a repo applies to every other window in that repo, and survives
378
+ * `/clear`, `/compact`, and Claude Code restart.
379
+ *
380
+ * Hooks are read-only — only the skill writes this file. Returns null if
381
+ * the file is absent, malformed, or missing required fields, so SessionStart
382
+ * can proceed unchanged when no pin is set.
383
+ */
384
+ function readDatapackPin(agentId) {
385
+ const slug = datapackPinSlug(agentId);
386
+ if (!slug) return null;
387
+ const pinPath = path.join(WATERMARK_DIR, `pin-${slug}.json`);
388
+ try {
389
+ const obj = JSON.parse(fs.readFileSync(pinPath, "utf-8"));
390
+ const id = typeof obj.datapack_id === "string" ? obj.datapack_id.trim() : "";
391
+ const name = typeof obj.datapack_name === "string" ? obj.datapack_name.trim() : "";
392
+ if (!id || !name) return null;
393
+ return { datapack_id: id, datapack_name: name, selected_at: obj.selected_at || "" };
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+
399
+ function datapackPinFromWatermark(watermark) {
400
+ if (!watermark) return null;
401
+ const id = typeof watermark.datapack_id === "string" ? watermark.datapack_id.trim() : "";
402
+ const name = typeof watermark.datapack_name === "string" ? watermark.datapack_name.trim() : "";
403
+ return id && name ? { datapack_id: id, datapack_name: name } : null;
404
+ }
405
+
406
+ function buildActiveDatapackBlock(pin) {
407
+ if (!pin) return "";
408
+ return `
409
+
410
+ ### Active datapack
411
+
412
+ The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for this project via the \`meko-select-datapack\` skill. Pass \`datapack_id="${pin.datapack_id}"\` to every Meko MCP tool call (memory_*, db_*, knowledgebase_*, conversation_*) unless the user explicitly overrides for a single call. The pin is project-scoped (keyed by \`agent_id\`) and survives \`/clear\`, \`/compact\`, and Claude Code restart. Automatic capture keeps the datapack selected when this conversation was created; switching or clearing takes effect for automatic capture on the next new session.`;
413
+ }
414
+
313
415
  // note: Subagents cannot auto-discover the parent's watermark today.
314
416
  // The watermark dir is flat and keyed by session UUID, and a subagent
315
417
  // process has no way to learn its parent's session UUID:
@@ -339,6 +441,13 @@ function mcpPost(jsonBody) {
339
441
  "Content-Type": "application/json",
340
442
  Accept: "application/json, text/event-stream",
341
443
  "Content-Length": Buffer.byteLength(body),
444
+ // Cloud Meko sits behind a WAF that 403s requests with no User-Agent
445
+ // (Node's http/https send none by default). Without this the SessionStart
446
+ // / PreCompact / SessionEnd hooks all fail against mcp.mekodata.ai and
447
+ // automatic capture silently never runs — the hook falls back to the
448
+ // agent-driven path every session. Send an explicit UA so the WAF admits
449
+ // the request. (Verified: prod 403s without UA, 200s with one.)
450
+ "User-Agent": "meko-capture/1.0 (+https://github.com/yugabyte/meko-mcp-server)",
342
451
  };
343
452
  if (MEKO_API_KEY) {
344
453
  headers["Authorization"] = `Bearer ${MEKO_API_KEY}`;
@@ -367,7 +476,7 @@ function mcpPost(jsonBody) {
367
476
  return;
368
477
  }
369
478
  try {
370
- const parsed = JSON.parse(data);
479
+ const parsed = parseMcpResponseBody(data);
371
480
  if (parsed.error) {
372
481
  reject(new Error(JSON.stringify(parsed.error)));
373
482
  } else {
@@ -395,6 +504,37 @@ function mcpPost(jsonBody) {
395
504
  });
396
505
  }
397
506
 
507
+ function parseMcpResponseBody(data) {
508
+ const trimmed = data.trim();
509
+ if (!trimmed) return null;
510
+
511
+ if (!trimmed.startsWith("event:") && !trimmed.startsWith("data:")) {
512
+ return JSON.parse(trimmed);
513
+ }
514
+
515
+ const payloads = [];
516
+ let current = [];
517
+ for (const line of data.split(/\r?\n/)) {
518
+ if (line === "") {
519
+ if (current.length) {
520
+ payloads.push(current.join("\n"));
521
+ current = [];
522
+ }
523
+ continue;
524
+ }
525
+ if (line.startsWith("data:")) {
526
+ current.push(line.startsWith("data: ") ? line.slice(6) : line.slice(5));
527
+ }
528
+ }
529
+ if (current.length) payloads.push(current.join("\n"));
530
+
531
+ const payload = payloads.find((item) => item.trim()) || "";
532
+ if (!payload) {
533
+ throw new Error("SSE response did not include a data payload");
534
+ }
535
+ return JSON.parse(payload);
536
+ }
537
+
398
538
  /**
399
539
  * Abort all pending MCP requests. Used by the preload timeout path so the
400
540
  * SessionStart hook can return promptly instead of waiting for a slow server
@@ -512,13 +652,15 @@ function peekTranscriptMetadata(transcriptPath) {
512
652
  * On any error the caller falls back to an empty list — memory preload is a
513
653
  * best-effort enrichment, not a correctness requirement.
514
654
  */
515
- async function fetchRecentMemories(agentId, { limit = 10, budget = 2000 } = {}) {
516
- const response = await mcpCall("memory_get_all", {
655
+ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
656
+ const payload = {
517
657
  scope: "read",
518
658
  agent_id: agentId,
519
659
  conversation_id: "00000000-0000-0000-0000-000000000000",
520
660
  limit,
521
- });
661
+ };
662
+ if (datapackId) payload.datapack_id = datapackId;
663
+ const response = await mcpCall("memory_get_all", payload);
522
664
  const result = extractToolResult(response);
523
665
  if (!result) return [];
524
666
 
@@ -536,7 +678,7 @@ async function fetchRecentMemories(agentId, { limit = 10, budget = 2000 } = {})
536
678
  return summaries;
537
679
  }
538
680
 
539
- async function createConversation(sessionId, agentId, metadata = null) {
681
+ async function createConversation(sessionId, agentId, metadata = null, datapackId = null) {
540
682
  const payload = {
541
683
  scope: "write",
542
684
  agent_id: agentId,
@@ -551,14 +693,15 @@ async function createConversation(sessionId, agentId, metadata = null) {
551
693
  startedAt: metadata.startedAt ?? null,
552
694
  });
553
695
  }
696
+ if (datapackId) payload.datapack_id = datapackId;
554
697
  const response = await mcpCall("conversation_create", payload);
555
698
  const result = extractToolResult(response);
556
699
  return result && result.id ? result.id : null;
557
700
  }
558
701
 
559
- async function addMessage(convId, agentId, exchange) {
702
+ async function addMessage(convId, agentId, exchange, datapackId = null) {
560
703
  const seed = `${convId}:${exchange.user_uuid}`;
561
- await mcpCall("conversation_add_message", {
704
+ const payload = {
562
705
  scope: "write",
563
706
  conversation_id: convId,
564
707
  agent_id: agentId,
@@ -566,26 +709,114 @@ async function addMessage(convId, agentId, exchange) {
566
709
  output: exchange.output,
567
710
  reasoning: exchange.reasoning,
568
711
  seed: seed,
569
- });
712
+ };
713
+ if (datapackId) payload.datapack_id = datapackId;
714
+ const response = await mcpCall("conversation_add_message", payload);
715
+ // FAIL CLOSED. The MCP transport resolves successfully even when the tool
716
+ // failed, and failures take several shapes: an `isError` result envelope, a
717
+ // JSON body with an `error` field, or a non-JSON / missing-content / otherwise
718
+ // malformed body (which extractToolResult reports as null). If we treat any of
719
+ // these as success, the caller counts the turn as captured and advances the
720
+ // watermark — permanently dropping it. So only a *valid success body* counts;
721
+ // anything else throws into the caller's failed++/watermark-hold/retry path
722
+ // (dedup-by-seed makes the retry safe).
723
+ if (response && response.result && response.result.isError) {
724
+ throw new Error(
725
+ `conversation_add_message returned isError: ${JSON.stringify(response.result)}`,
726
+ );
727
+ }
728
+ const result = extractToolResult(response);
729
+ if (result === null) {
730
+ throw new Error(
731
+ "conversation_add_message: no parseable result body (non-JSON, empty, or malformed response)",
732
+ );
733
+ }
734
+ if (result.error) {
735
+ throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}`);
736
+ }
737
+ return result;
570
738
  }
571
739
 
572
740
  // --- Hook output ---
573
741
 
574
- function hookEventName(hookType) {
575
- switch (hookType) {
576
- case "session-start": return "SessionStart";
577
- case "pre-compact": return "PreCompact";
578
- default: return "SessionEnd";
579
- }
580
- }
581
-
582
742
  function hookOutput(additionalContext) {
583
- return JSON.stringify({
743
+ // Codex PreCompact accepts only the common hook output fields.
744
+ // `additionalContext` is valid hook-specific output for SessionStart, but
745
+ // emitting it for lifecycle capture hooks makes Codex reject the JSON.
746
+ if (process.argv[2] !== "session-start") return "{}";
747
+
748
+ // Emit the native coding-agent harness field while preserving the nested
749
+ // Claude-compatible shape for clients that still consume it.
750
+ const output = {
584
751
  hookSpecificOutput: {
585
- hookEventName: hookEventName(process.argv[2]),
752
+ hookEventName: "SessionStart",
586
753
  additionalContext: additionalContext || "",
587
754
  },
588
- });
755
+ };
756
+ output.additional_context = additionalContext || "";
757
+ return JSON.stringify(output);
758
+ }
759
+
760
+ function nativeOutput(additionalContext) {
761
+ return JSON.stringify({ additional_context: additionalContext || "" });
762
+ }
763
+
764
+ function beforeSubmitOutput(permission = "allow", userMessage = "") {
765
+ const output = { permission };
766
+ if (userMessage) output.userMessage = userMessage;
767
+ return JSON.stringify(output);
768
+ }
769
+
770
+ function extractSessionId(hookInput, transcriptPath) {
771
+ if (transcriptPath) return path.basename(transcriptPath, ".jsonl");
772
+ const nested = hookInput && hookInput.hookSpecificInput && typeof hookInput.hookSpecificInput === "object"
773
+ ? hookInput.hookSpecificInput
774
+ : {};
775
+ for (const value of [
776
+ hookInput && hookInput.session_id,
777
+ hookInput && hookInput.sessionId,
778
+ nested.session_id,
779
+ nested.sessionId,
780
+ ]) {
781
+ if (typeof value === "string" && value.trim()) return value.trim();
782
+ }
783
+ return null;
784
+ }
785
+
786
+ function sessionCachePath(sessionId) {
787
+ if (!sessionId) return null;
788
+ const safe = sessionId.replace(/[^A-Za-z0-9_.-]/g, "_");
789
+ return path.join(SESSION_CACHE_DIR, `${safe}.json`);
790
+ }
791
+
792
+ function writeSessionCache(sessionId, payload) {
793
+ const filePath = sessionCachePath(sessionId);
794
+ if (!filePath) return;
795
+ const workspaceKey = payload.workspace_key || "";
796
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
797
+ const record = {
798
+ session_id: sessionId,
799
+ updated_at: new Date().toISOString(),
800
+ ...payload,
801
+ };
802
+ fs.writeFileSync(filePath, JSON.stringify(record));
803
+ if (workspaceKey) {
804
+ const safe = workspaceKey.replace(/[^A-Za-z0-9_.-]/g, "_");
805
+ fs.writeFileSync(path.join(SESSION_CACHE_DIR, `workspace-${safe}.json`), JSON.stringify(record));
806
+ }
807
+ }
808
+
809
+ function workspaceKeyFromHookInput(hookInput, transcriptPath) {
810
+ const roots = hookInput && Array.isArray(hookInput.workspace_roots)
811
+ ? hookInput.workspace_roots
812
+ : [];
813
+ const firstRoot = roots.find((item) => typeof item === "string" && item.trim());
814
+ if (firstRoot) return firstRoot.trim();
815
+ if (transcriptPath) return path.dirname(transcriptPath);
816
+ const cwd = hookInput && typeof hookInput.cwd === "string" && hookInput.cwd.trim()
817
+ ? hookInput.cwd.trim()
818
+ : "";
819
+ return cwd || process.cwd();
589
820
  }
590
821
 
591
822
  // --- Main ---
@@ -610,6 +841,8 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
610
841
  sessionId: sid,
611
842
  conversation_id: wm.conversation_id,
612
843
  agent_id: wm.agent_id || "",
844
+ datapack_id: wm.datapack_id || null,
845
+ datapack_name: wm.datapack_name || null,
613
846
  updated_at: ts,
614
847
  };
615
848
  }
@@ -624,6 +857,7 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
624
857
  function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
625
858
  const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
626
859
  const resumed = Boolean(opts && opts.resumed);
860
+ const pin = opts && opts.datapackPin ? opts.datapackPin : null;
627
861
  const opening = resumed
628
862
  ? `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.`
629
863
  : `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.`;
@@ -637,6 +871,7 @@ These facts were preloaded for you — inspect them before acting so you don't r
637
871
 
638
872
  ${memories.join("\n")}`
639
873
  : "";
874
+ const datapackBlock = buildActiveDatapackBlock(pin);
640
875
 
641
876
  return `## Meko Session Active
642
877
 
@@ -648,44 +883,48 @@ ${opening}
648
883
  - **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
649
884
  - **scope**: Only "read", "write", or "admin"
650
885
 
651
- ### Proactive storage (do NOT wait to be asked)
886
+ ### Memory capture is automatic — do NOT proactively call memory_add
652
887
 
653
- - User shares personal info, preferences, or role → call memory_add immediately
654
- - User shares org conventions or team standards → call memory_add immediately
655
- - User asks "what do you know about X?" → call memory_search first
656
- - User provides structured/tabular data → call db_run_write_query (CREATE TABLE + INSERT)
888
+ This session's turns are captured to Meko by the hooks above, and the server extracts durable memories from them on its own. **Any fact the user states in conversation — their name, role, preferences, org conventions, corrections — is captured without you doing anything.** Do not call memory_add for facts the user says out loud; it duplicates what extraction already stores.
889
+
890
+ Call memory_add (with the session's agent_id + conversation_id) ONLY for the narrow cases extraction cannot reach:
891
+ - The user explicitly says "remember this" / "save this to memory".
892
+ - A durable fact lives only in YOUR output or a tool result, not in the user's words — extraction reads the user turn only, so it never sees these.
893
+ - The user negates or corrects a prior fact and the stale one must be overwritten — extraction is additive, so also fix the old memory (memory_search to find it, then memory_update / memory_delete_by_id).
657
894
 
658
- ### Before destructive actions
895
+ ### Recall and before destructive actions
659
896
 
660
- 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.
897
+ - User asks "what do you know about X?" → call memory_search first.
898
+ - 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.
661
899
 
662
900
  ### Use Meko, not local storage
663
901
 
664
- 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}`;
902
+ Never write memories to local files (e.g., ~/.claude/projects/.../memory/). Meko — via automatic capture plus the narrow memory_add cases above — is the only memory store.${memoryBlock}${datapackBlock}`;
665
903
  }
666
904
 
667
- function buildSessionStartFallbackContext(agentId) {
905
+ function buildSessionStartFallbackContext(agentId, opts) {
668
906
  const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
907
+ const datapackBlock = buildActiveDatapackBlock(opts && opts.datapackPin ? opts.datapackPin : null);
669
908
  return `## Meko Memory Instructions
670
909
 
671
- The SessionStart hook could not reach the Meko MCP server to create a conversation. Automatic capture may not work for this session.
910
+ The SessionStart hook could not reach the Meko MCP server to create a conversation. **Automatic conversation capture is NOT running this session**, so the server-side extraction that normally saves memories for you will not fire. Fall back to saving facts explicitly.
672
911
 
673
- ### What you should still do
912
+ ### What you should do while capture is down
674
913
 
675
- - **Use Meko MCP tools** for memory_add, memory_search, etc. when available
676
914
  - **agent_id**: use "${resolvedAgentId}" for project-scoped writes; use "${COMMON_BUCKET_AGENT_ID}" for cross-project common facts
677
915
  - **conversation_id**: Use nil UUID "00000000-0000-0000-0000-000000000000" as placeholder
678
916
  - **scope**: Only "read", "write", or "admin"
679
917
 
680
- ### Proactive storage (do NOT wait to be asked)
918
+ ### Proactive storage (capture is down — do NOT wait to be asked)
681
919
 
920
+ Because nothing is capturing this session, explicit saves are the only path — this is the exception, not the normal mode:
682
921
  - User shares personal info, preferences, or role → call memory_add immediately
683
922
  - User shares org conventions or team standards → call memory_add immediately
684
923
  - User asks "what do you know about X?" → call memory_search first
685
924
 
686
925
  ### Use Meko, not local storage
687
926
 
688
- 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.`;
927
+ Prefer Meko MCP tools over local file-based memory. If a Meko tool call also fails, fall back to local storage and tell the user.${datapackBlock}`;
689
928
  }
690
929
 
691
930
  /**
@@ -727,17 +966,20 @@ function withTimeout(promise, ms, fallback, { onTimeout } = {}) {
727
966
  });
728
967
  }
729
968
 
969
+ function handleBeforeSubmitPrompt(hookInput) {
970
+ process.stdout.write(beforeSubmitOutput("allow"));
971
+ }
972
+
730
973
  async function handleSessionStart(hookInput) {
731
974
  const transcriptPath =
732
975
  hookInput.transcript_path ||
733
976
  (hookInput.hookSpecificInput || {}).transcript_path;
734
977
 
735
- const sessionId = transcriptPath
736
- ? path.basename(transcriptPath, ".jsonl")
737
- : null;
978
+ const sessionId = extractSessionId(hookInput, transcriptPath);
738
979
 
739
980
  const source = hookInput.source || "";
740
- const derivedAgentId = resolveSessionAgentId(transcriptPath);
981
+ const derivedAgentId = resolveSessionAgentId(transcriptPath, hookInput);
982
+ const workspaceKey = workspaceKeyFromHookInput(hookInput, transcriptPath);
741
983
 
742
984
  // Check if watermark already exists (same session ID re-fires SessionStart, e.g. /clear, /compact)
743
985
  if (sessionId) {
@@ -752,17 +994,23 @@ async function handleSessionStart(hookInput) {
752
994
  // upgraded users stop writing into the ghost namespace.
753
995
  const cached = (existing.agent_id || "").trim();
754
996
  const resumedAgentId = cached && cached !== "agent" ? cached : derivedAgentId;
997
+ const existingDatapackPin = datapackPinFromWatermark(existing);
755
998
  // Skip preload on resume (we don't know what's already in context).
756
- process.stdout.write(
757
- hookOutput(
758
- buildSessionStartContext(
759
- existing.conversation_id,
760
- sessionId,
761
- resumedAgentId,
762
- null,
763
- ),
764
- ),
999
+ const context = buildSessionStartContext(
1000
+ existing.conversation_id,
1001
+ sessionId,
1002
+ resumedAgentId,
1003
+ null,
1004
+ { datapackPin: existingDatapackPin },
765
1005
  );
1006
+ writeSessionCache(sessionId, {
1007
+ status: "ready",
1008
+ conversation_id: existing.conversation_id,
1009
+ agent_id: resumedAgentId,
1010
+ workspace_key: workspaceKey,
1011
+ additional_context: context,
1012
+ });
1013
+ process.stdout.write(hookOutput(context));
766
1014
  return;
767
1015
  }
768
1016
  }
@@ -786,19 +1034,30 @@ async function handleSessionStart(hookInput) {
786
1034
  const priorAgentId = (prior.agent_id || "").trim();
787
1035
  const resumedAgentId =
788
1036
  priorAgentId && priorAgentId !== "agent" ? priorAgentId : agentId;
789
- writeWatermark(wmPath, prior.conversation_id, currentLineCount, resumedAgentId);
1037
+ const priorDatapackPin = datapackPinFromWatermark(prior);
1038
+ writeWatermark(
1039
+ wmPath,
1040
+ prior.conversation_id,
1041
+ currentLineCount,
1042
+ resumedAgentId,
1043
+ priorDatapackPin,
1044
+ );
790
1045
  process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${prior.conversation_id} from ${prior.sessionId}, skipping ${currentLineCount} copied lines\n`);
791
- process.stdout.write(
792
- hookOutput(
793
- buildSessionStartContext(
794
- prior.conversation_id,
795
- sessionId,
796
- resumedAgentId,
797
- null,
798
- { resumed: true },
799
- ),
800
- ),
1046
+ const context = buildSessionStartContext(
1047
+ prior.conversation_id,
1048
+ sessionId,
1049
+ resumedAgentId,
1050
+ null,
1051
+ { resumed: true, datapackPin: priorDatapackPin },
801
1052
  );
1053
+ writeSessionCache(sessionId, {
1054
+ status: "ready",
1055
+ conversation_id: prior.conversation_id,
1056
+ agent_id: resumedAgentId,
1057
+ workspace_key: workspaceKey,
1058
+ additional_context: context,
1059
+ });
1060
+ process.stdout.write(hookOutput(context));
802
1061
  return;
803
1062
  }
804
1063
  process.stderr.write(`[meko-capture] Session ${sessionId}: source=resume but no prior project watermark found; creating new conversation\n`);
@@ -807,31 +1066,54 @@ async function handleSessionStart(hookInput) {
807
1066
  // Create conversation via MCP. Attach session metadata (cwd / gitBranch /
808
1067
  // startedAt) from the transcript header so hook traces match the migrator.
809
1068
  const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
1069
+ const datapackPin = readDatapackPin(agentId);
810
1070
  let convId = null;
811
1071
  try {
812
1072
  await mcpInitialize();
813
- convId = await createConversation(sessionId || "unknown", agentId, transcriptMetadata);
1073
+ convId = await createConversation(
1074
+ sessionId || "unknown",
1075
+ agentId,
1076
+ transcriptMetadata,
1077
+ datapackPin && datapackPin.datapack_id,
1078
+ );
814
1079
  } catch (err) {
815
1080
  process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
816
- process.stdout.write(hookOutput(buildSessionStartFallbackContext(agentId)));
1081
+ const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1082
+ if (sessionId) {
1083
+ writeSessionCache(sessionId, {
1084
+ status: "error",
1085
+ agent_id: agentId,
1086
+ workspace_key: workspaceKey,
1087
+ error: err && err.message ? String(err.message).slice(0, 240) : "MCP unavailable",
1088
+ additional_context: context,
1089
+ });
1090
+ }
1091
+ process.stdout.write(hookOutput(context));
817
1092
  return;
818
1093
  }
819
1094
 
820
1095
  if (!convId) {
821
1096
  process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
822
- process.stdout.write(hookOutput(buildSessionStartFallbackContext(agentId)));
1097
+ const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1098
+ if (sessionId) {
1099
+ writeSessionCache(sessionId, {
1100
+ status: "error",
1101
+ agent_id: agentId,
1102
+ workspace_key: workspaceKey,
1103
+ error: "conversation_create returned no ID",
1104
+ additional_context: context,
1105
+ });
1106
+ }
1107
+ process.stdout.write(hookOutput(context));
823
1108
  return;
824
1109
  }
825
1110
 
826
1111
  // Preload recent memories with a 4s budget (SessionStart timeout is 15s).
827
1112
  // Best-effort enrichment — on timeout/failure we still deliver the core
828
- // context. agent_id is derived per-session from the cwd; the old
829
- // db_summarize_database discovery helper was removed (it depended on a
830
- // paid-tier tool and only worked "correctly" via its fallback branch
831
- // anyway — see scratch/2026-04-30-multi-tenant-mem0-audit.md Probe 6).
1113
+ // context. agent_id is derived per-session from the cwd.
832
1114
  const PRELOAD_BUDGET_MS = 4000;
833
1115
  const memories = await withTimeout(
834
- fetchRecentMemories(agentId),
1116
+ fetchRecentMemories(agentId, datapackPin && datapackPin.datapack_id),
835
1117
  PRELOAD_BUDGET_MS,
836
1118
  [],
837
1119
  { onTimeout: abortInflightRequests },
@@ -839,25 +1121,33 @@ async function handleSessionStart(hookInput) {
839
1121
 
840
1122
  if (sessionId) {
841
1123
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
842
- writeWatermark(wmPath, convId, 0, agentId);
1124
+ writeWatermark(wmPath, convId, 0, agentId, datapackPin);
843
1125
  process.stderr.write(
844
1126
  `[meko-capture] SessionStart: created conversation ${convId}, agent_id=${agentId}, preloaded ${memories.length} memories, watermark at ${wmPath}\n`,
845
1127
  );
846
1128
  }
847
1129
 
848
- process.stdout.write(
849
- hookOutput(
850
- buildSessionStartContext(convId, sessionId, agentId, memories),
851
- ),
852
- );
1130
+ const context = buildSessionStartContext(convId, sessionId, agentId, memories, {
1131
+ datapackPin,
1132
+ });
1133
+ if (sessionId) {
1134
+ writeSessionCache(sessionId, {
1135
+ status: "ready",
1136
+ conversation_id: convId,
1137
+ agent_id: agentId,
1138
+ workspace_key: workspaceKey,
1139
+ additional_context: context,
1140
+ });
1141
+ }
1142
+ process.stdout.write(hookOutput(context));
853
1143
  }
854
1144
 
855
1145
  // --- Main ---
856
1146
 
857
1147
  async function main() {
858
1148
  const hookType = process.argv[2];
859
- if (!hookType || !["session-start", "pre-compact", "session-end", "checkpoint"].includes(hookType)) {
860
- process.stderr.write("Usage: capture.js <session-start|pre-compact|session-end|checkpoint>\n");
1149
+ if (!hookType || !["session-start", "before-submit-prompt", "pre-compact", "session-end", "checkpoint"].includes(hookType)) {
1150
+ process.stderr.write("Usage: capture.js <session-start|before-submit-prompt|pre-compact|session-end|checkpoint>\n");
861
1151
  process.exit(1);
862
1152
  }
863
1153
 
@@ -878,6 +1168,11 @@ async function main() {
878
1168
  return;
879
1169
  }
880
1170
 
1171
+ if (hookType === "before-submit-prompt") {
1172
+ handleBeforeSubmitPrompt(hookInput);
1173
+ return;
1174
+ }
1175
+
881
1176
  // PreCompact / SessionEnd: capture transcript exchanges
882
1177
  const transcriptPath =
883
1178
  hookInput.transcript_path ||
@@ -903,6 +1198,7 @@ async function main() {
903
1198
  const agentId = cachedAgentId && cachedAgentId !== "agent"
904
1199
  ? cachedAgentId
905
1200
  : resolveSessionAgentId(transcriptPath);
1201
+ const datapackPin = datapackPinFromWatermark(watermark);
906
1202
 
907
1203
  // Nothing new
908
1204
  if (currentLines <= lastLine) {
@@ -933,20 +1229,40 @@ async function main() {
933
1229
  // Extract and send exchanges
934
1230
  const exchanges = extractExchanges(transcriptPath, lastLine);
935
1231
  let captured = 0;
1232
+ let failed = 0;
936
1233
 
937
1234
  for (const exchange of exchanges) {
938
1235
  try {
939
- await addMessage(convId, agentId, exchange);
1236
+ await addMessage(
1237
+ convId,
1238
+ agentId,
1239
+ exchange,
1240
+ datapackPin && datapackPin.datapack_id,
1241
+ );
940
1242
  captured++;
941
1243
  } catch (err) {
1244
+ failed++;
942
1245
  process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
943
1246
  }
944
1247
  }
945
1248
 
946
- // Update watermark with the resolved agent_id. If the cached value was a
947
- // legacy literal we now overwrite it so subsequent hooks see the derived
948
- // bucket directly.
949
- writeWatermark(wmPath, convId, currentLines, agentId);
1249
+ // Advance the watermark to currentLines ONLY when every exchange was
1250
+ // captured. If any addMessage failed (typically a transient network error),
1251
+ // keep the watermark at lastLine so the next checkpoint / PreCompact /
1252
+ // SessionEnd hook re-extracts and retries this batch. Re-sending the
1253
+ // exchanges that already succeeded is safe: capture dedups by seed
1254
+ // (<conv_id>:<user_uuid>), so retries never create duplicates. Advancing on
1255
+ // partial failure is what would permanently drop the failed exchanges.
1256
+ // We still always persist the resolved agent_id (migrating any legacy literal
1257
+ // value) so later hooks see the derived bucket directly.
1258
+ const watermarkLine = failed === 0 ? currentLines : lastLine;
1259
+ if (failed > 0) {
1260
+ process.stderr.write(
1261
+ `[meko-capture] ${failed}/${exchanges.length} exchange(s) failed; ` +
1262
+ `holding watermark at line ${lastLine} for retry (dedup makes resends safe).\n`
1263
+ );
1264
+ }
1265
+ writeWatermark(wmPath, convId, watermarkLine, agentId, datapackPin);
950
1266
 
951
1267
  // Output
952
1268
  const context =