@yugabytedb/meko-mcp 1.2.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +59 -25
  2. package/assets/hooks-handlers/before-submit-prompt.sh +2 -2
  3. package/assets/hooks-handlers/codex-session-end.sh +78 -0
  4. package/assets/hooks-handlers/lib/capture.js +366 -152
  5. package/assets/hooks-handlers/lib/checkpoint-timer.js +67 -13
  6. package/assets/hooks-handlers/session-end.sh +10 -0
  7. package/assets/hooks-handlers/session-start.sh +14 -3
  8. package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
  9. package/assets/scripts/meko-statusline.sh +119 -0
  10. package/assets/scripts/package-desktop-skill.sh +64 -30
  11. package/assets/skills/meko-mcp-tools/SKILL.md +84 -86
  12. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -4
  13. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +29 -25
  14. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
  15. package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
  16. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +126 -184
  17. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +31 -37
  18. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +10 -26
  19. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +26 -30
  20. package/assets/skills/meko-mcp-tools/references/tools-overview.md +60 -66
  21. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +28 -93
  22. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +5 -99
  23. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +9 -8
  24. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +138 -138
  25. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +46 -16
  26. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +28 -24
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +120 -174
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +16 -22
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +28 -34
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +60 -66
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +28 -93
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +4 -98
  35. package/assets/skills/meko-select-datapack-desktop/SKILL.md +208 -0
  36. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +121 -0
  37. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
  38. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +66 -0
  39. package/bin/create-meko-setup.mjs +74 -6
  40. package/lib/clients/claude-code.mjs +137 -6
  41. package/lib/clients/claude-desktop.mjs +51 -12
  42. package/lib/clients/codex.mjs +329 -13
  43. package/lib/clients/common.mjs +43 -1
  44. package/lib/clients/cursor.mjs +82 -21
  45. package/lib/config.mjs +26 -0
  46. package/lib/detect.mjs +7 -2
  47. package/lib/installer.mjs +24 -0
  48. package/lib/mcp-transport.mjs +5 -0
  49. package/lib/prompt.mjs +1 -1
  50. package/lib/url-selection.mjs +55 -0
  51. package/lib/validate.mjs +0 -1
  52. package/package.json +1 -1
  53. package/scripts/bundle-assets.mjs +8 -0
  54. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +0 -38
  55. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -75
  56. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +0 -30
  57. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +0 -75
@@ -211,10 +211,18 @@ function extractToolResultContent(msg) {
211
211
  }
212
212
  // Fallback to toolUseResult summary if no content blocks found
213
213
  if (parts.length === 0 && msg.toolUseResult != null) {
214
- const summary =
215
- typeof msg.toolUseResult === "string"
216
- ? msg.toolUseResult
217
- : 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
+ }
218
226
  parts.push(truncate(summary, MAX_TOOL_RESULT_LEN));
219
227
  }
220
228
  return parts.length > 0 ? "TOOL RESULT:\n" + parts.join("\n") : "";
@@ -323,20 +331,126 @@ function readWatermark(wmPath) {
323
331
  }
324
332
  }
325
333
 
326
- function writeWatermark(wmPath, convId, lineNum, agentId) {
334
+ function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null) {
327
335
  fs.mkdirSync(path.dirname(wmPath), { recursive: true });
328
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;
329
345
  fs.writeFileSync(
330
346
  wmPath,
331
347
  JSON.stringify({
332
348
  conversation_id: convId,
333
349
  last_line_number: lineNum,
334
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,
335
353
  updated_at: new Date().toISOString(),
336
354
  })
337
355
  );
338
356
  }
339
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
+ function captureErrorPath(agentId) {
371
+ return path.join(WATERMARK_DIR, `last-capture-error-${datapackPinSlug(agentId)}.json`);
372
+ }
373
+
374
+ function readCaptureError(agentId) {
375
+ try {
376
+ const error = JSON.parse(fs.readFileSync(captureErrorPath(agentId), "utf-8"));
377
+ return error && typeof error === "object" ? error : null;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ function writeCaptureError(agentId, error) {
384
+ try {
385
+ fs.mkdirSync(WATERMARK_DIR, { recursive: true });
386
+ fs.writeFileSync(
387
+ captureErrorPath(agentId),
388
+ JSON.stringify({
389
+ ...error,
390
+ agent_id: agentId,
391
+ occurred_at: new Date().toISOString(),
392
+ }),
393
+ );
394
+ } catch (err) {
395
+ process.stderr.write(`[meko-capture] Could not persist capture error: ${err.message}\n`);
396
+ }
397
+ }
398
+
399
+ function clearCaptureError(agentId) {
400
+ try {
401
+ fs.unlinkSync(captureErrorPath(agentId));
402
+ } catch (err) {
403
+ if (err && err.code !== "ENOENT") {
404
+ process.stderr.write(`[meko-capture] Could not clear capture error: ${err.message}\n`);
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Read the active-datapack pin written by the meko-select-datapack skill.
411
+ *
412
+ * The pin is a sidecar file at `<WATERMARK_DIR>/pin-<slug(agent_id)>.json`
413
+ * with shape `{ datapack_id, datapack_name, selected_at }`. Project-scoped:
414
+ * keyed by the same `agent_id` the hook derives for memory writes
415
+ * (`claude_code:<repo-basename>`), so a pin set in one Claude Code window
416
+ * for a repo applies to every other window in that repo, and survives
417
+ * `/clear`, `/compact`, and Claude Code restart.
418
+ *
419
+ * Hooks are read-only — only the skill writes this file. Returns null if
420
+ * the file is absent, malformed, or missing required fields, so SessionStart
421
+ * can proceed unchanged when no pin is set.
422
+ */
423
+ function readDatapackPin(agentId) {
424
+ const slug = datapackPinSlug(agentId);
425
+ if (!slug) return null;
426
+ const pinPath = path.join(WATERMARK_DIR, `pin-${slug}.json`);
427
+ try {
428
+ const obj = JSON.parse(fs.readFileSync(pinPath, "utf-8"));
429
+ const id = typeof obj.datapack_id === "string" ? obj.datapack_id.trim() : "";
430
+ const name = typeof obj.datapack_name === "string" ? obj.datapack_name.trim() : "";
431
+ if (!id || !name) return null;
432
+ return { datapack_id: id, datapack_name: name, selected_at: obj.selected_at || "" };
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+
438
+ function datapackPinFromWatermark(watermark) {
439
+ if (!watermark) return null;
440
+ const id = typeof watermark.datapack_id === "string" ? watermark.datapack_id.trim() : "";
441
+ const name = typeof watermark.datapack_name === "string" ? watermark.datapack_name.trim() : "";
442
+ return id && name ? { datapack_id: id, datapack_name: name } : null;
443
+ }
444
+
445
+ function buildActiveDatapackBlock(pin) {
446
+ if (!pin) return "";
447
+ return `
448
+
449
+ ### Active datapack
450
+
451
+ 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 that accepts it (memory_*, knowledgebase_search, conversation_*, artifact_*) 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.`;
452
+ }
453
+
340
454
  // note: Subagents cannot auto-discover the parent's watermark today.
341
455
  // The watermark dir is flat and keyed by session UUID, and a subagent
342
456
  // process has no way to learn its parent's session UUID:
@@ -366,6 +480,13 @@ function mcpPost(jsonBody) {
366
480
  "Content-Type": "application/json",
367
481
  Accept: "application/json, text/event-stream",
368
482
  "Content-Length": Buffer.byteLength(body),
483
+ // Cloud Meko sits behind a WAF that 403s requests with no User-Agent
484
+ // (Node's http/https send none by default). Without this the SessionStart
485
+ // / PreCompact / SessionEnd hooks all fail against mcp.mekodata.ai and
486
+ // automatic capture silently never runs — the hook falls back to the
487
+ // agent-driven path every session. Send an explicit UA so the WAF admits
488
+ // the request. (Verified: prod 403s without UA, 200s with one.)
489
+ "User-Agent": "meko-capture/1.0 (+https://github.com/yugabyte/meko-mcp-server)",
369
490
  };
370
491
  if (MEKO_API_KEY) {
371
492
  headers["Authorization"] = `Bearer ${MEKO_API_KEY}`;
@@ -388,6 +509,16 @@ function mcpPost(jsonBody) {
388
509
  res.on("data", (chunk) => (data += chunk));
389
510
  res.on("end", () => {
390
511
  inflightRequests.delete(req);
512
+ if (res.statusCode >= 400) {
513
+ const snippet = data.trim().replace(/\s+/g, " ").slice(0, 200);
514
+ const err = new Error(
515
+ `MCP HTTP ${res.statusCode}${snippet ? `: ${snippet}` : ""}`,
516
+ );
517
+ err.statusCode = res.statusCode;
518
+ err.responseBody = data.slice(0, 1000);
519
+ reject(err);
520
+ return;
521
+ }
391
522
  // Notifications return 202/204 with no body — that's OK
392
523
  if (!data.trim()) {
393
524
  resolve({ body: null, headers: res.headers });
@@ -453,6 +584,51 @@ function parseMcpResponseBody(data) {
453
584
  return JSON.parse(payload);
454
585
  }
455
586
 
587
+ function classifyPersistentCaptureFailure(err) {
588
+ const message = err && err.message ? String(err.message) : String(err || "");
589
+ const normalized = message.toLowerCase();
590
+ const statusCode = Number(err && err.statusCode);
591
+ const statusMatch = normalized.match(/\bmcp http (402|403)\b/);
592
+ let persistentStatus = null;
593
+ if (statusCode === 402 || statusCode === 403) {
594
+ persistentStatus = statusCode;
595
+ } else if (statusMatch) {
596
+ persistentStatus = Number(statusMatch[1]);
597
+ }
598
+
599
+ if (persistentStatus) {
600
+ return {
601
+ code: `http_${persistentStatus}`,
602
+ reason: `Meko rejected capture with HTTP ${persistentStatus}`,
603
+ };
604
+ }
605
+
606
+ const persistentPatterns = [
607
+ ["free_tier_limit_reached", /free[_\s-]?tier.*limit|free_tier_limit_reached/],
608
+ ["quota_exceeded", /quota[_\s-]?(reached|exceeded)|(?:reached|exceeded).*quota/],
609
+ ["usage_limit_reached", /usage[_\s-]?limit[_\s-]?(reached|exceeded)/],
610
+ ["plan_limit_reached", /plan[_\s-]?limit[_\s-]?(reached|exceeded)/],
611
+ ["payment_required", /payment[_\s-]?required/],
612
+ ["subscription_required", /subscription[_\s-]?(required|inactive|expired)/],
613
+ ["entitlement_denied", /entitlement.*(denied|required|missing)|(?:denied|required|missing).*entitlement/],
614
+ ];
615
+ for (const [code, pattern] of persistentPatterns) {
616
+ if (pattern.test(normalized)) {
617
+ const reasons = {
618
+ free_tier_limit_reached: "the free-tier capture limit was reached",
619
+ quota_exceeded: "the capture quota was exceeded",
620
+ usage_limit_reached: "the capture usage limit was reached",
621
+ plan_limit_reached: "the current plan's capture limit was reached",
622
+ payment_required: "capture requires payment",
623
+ subscription_required: "capture requires an active subscription",
624
+ entitlement_denied: "the account is not entitled to capture conversations",
625
+ };
626
+ return { code, reason: reasons[code] };
627
+ }
628
+ }
629
+ return null;
630
+ }
631
+
456
632
  /**
457
633
  * Abort all pending MCP requests. Used by the preload timeout path so the
458
634
  * SessionStart hook can return promptly instead of waiting for a slow server
@@ -570,13 +746,14 @@ function peekTranscriptMetadata(transcriptPath) {
570
746
  * On any error the caller falls back to an empty list — memory preload is a
571
747
  * best-effort enrichment, not a correctness requirement.
572
748
  */
573
- async function fetchRecentMemories(agentId, { limit = 10, budget = 2000 } = {}) {
574
- const response = await mcpCall("memory_get_all", {
575
- scope: "read",
749
+ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
750
+ const payload = {
576
751
  agent_id: agentId,
577
752
  conversation_id: "00000000-0000-0000-0000-000000000000",
578
753
  limit,
579
- });
754
+ };
755
+ if (datapackId) payload.datapack_id = datapackId;
756
+ const response = await mcpCall("memory_get_all", payload);
580
757
  const result = extractToolResult(response);
581
758
  if (!result) return [];
582
759
 
@@ -594,9 +771,8 @@ async function fetchRecentMemories(agentId, { limit = 10, budget = 2000 } = {})
594
771
  return summaries;
595
772
  }
596
773
 
597
- async function createConversation(sessionId, agentId, metadata = null) {
774
+ async function createConversation(sessionId, agentId, metadata = null, datapackId = null) {
598
775
  const payload = {
599
- scope: "write",
600
776
  agent_id: agentId,
601
777
  title: "Claude Code session (auto-captured)",
602
778
  session_id: sessionId,
@@ -609,46 +785,72 @@ async function createConversation(sessionId, agentId, metadata = null) {
609
785
  startedAt: metadata.startedAt ?? null,
610
786
  });
611
787
  }
788
+ if (datapackId) payload.datapack_id = datapackId;
612
789
  const response = await mcpCall("conversation_create", payload);
613
790
  const result = extractToolResult(response);
614
791
  return result && result.id ? result.id : null;
615
792
  }
616
793
 
617
- async function addMessage(convId, agentId, exchange) {
794
+ async function addMessage(convId, agentId, exchange, datapackId = null) {
618
795
  const seed = `${convId}:${exchange.user_uuid}`;
619
- await mcpCall("conversation_add_message", {
620
- scope: "write",
796
+ const payload = {
621
797
  conversation_id: convId,
622
798
  agent_id: agentId,
623
799
  input: exchange.input,
624
800
  output: exchange.output,
625
801
  reasoning: exchange.reasoning,
626
802
  seed: seed,
627
- });
803
+ };
804
+ if (datapackId) payload.datapack_id = datapackId;
805
+ const response = await mcpCall("conversation_add_message", payload);
806
+ // FAIL CLOSED. The MCP transport resolves successfully even when the tool
807
+ // failed, and failures take several shapes: an `isError` result envelope, a
808
+ // JSON body with an `error` field, or a non-JSON / missing-content / otherwise
809
+ // malformed body (which extractToolResult reports as null). If we treat any of
810
+ // these as success, the caller counts the turn as captured and advances the
811
+ // watermark — permanently dropping it. So only a *valid success body* counts;
812
+ // anything else throws into the caller's failed++/watermark-hold/retry path
813
+ // (dedup-by-seed makes the retry safe).
814
+ if (response && response.result && response.result.isError) {
815
+ throw new Error(
816
+ `conversation_add_message returned isError: ${JSON.stringify(response.result)}`,
817
+ );
818
+ }
819
+ const result = extractToolResult(response);
820
+ if (result === null) {
821
+ throw new Error(
822
+ "conversation_add_message: no parseable result body (non-JSON, empty, or malformed response)",
823
+ );
824
+ }
825
+ if (result.error) {
826
+ throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}`);
827
+ }
828
+ return result;
628
829
  }
629
830
 
630
831
  // --- Hook output ---
631
832
 
632
- function hookEventName(hookType) {
633
- switch (hookType) {
634
- case "session-start": return "SessionStart";
635
- case "pre-compact": return "PreCompact";
636
- default: return "SessionEnd";
637
- }
638
- }
639
-
640
833
  function hookOutput(additionalContext) {
641
- // Emit the native coding-agent harness field while preserving the nested
642
- // Claude-compatible shape for clients that still consume it.
834
+ // Codex PreCompact accepts only the common hook output fields.
835
+ // `additionalContext` is valid hook-specific output for SessionStart, but
836
+ // emitting it for lifecycle capture hooks makes Codex reject the JSON.
837
+ if (process.argv[2] !== "session-start") return "{}";
838
+
839
+ // Codex rejects unknown top-level fields, so its SessionStart response must
840
+ // contain only the hook-specific output defined by the Codex wire schema.
643
841
  const output = {
644
842
  hookSpecificOutput: {
645
- hookEventName: hookEventName(process.argv[2]),
843
+ hookEventName: "SessionStart",
646
844
  additionalContext: additionalContext || "",
647
845
  },
648
846
  };
649
- if (process.argv[2] === "session-start") {
650
- output.additional_context = additionalContext || "";
847
+ if (process.env.MEKO_HOOK_CLIENT === "codex") {
848
+ return JSON.stringify(output);
651
849
  }
850
+
851
+ // Preserve the native coding-agent harness field for clients that consume
852
+ // it alongside the nested Claude-compatible shape.
853
+ output.additional_context = additionalContext || "";
652
854
  return JSON.stringify(output);
653
855
  }
654
856
 
@@ -656,9 +858,9 @@ function nativeOutput(additionalContext) {
656
858
  return JSON.stringify({ additional_context: additionalContext || "" });
657
859
  }
658
860
 
659
- function beforeSubmitOutput(shouldContinue, userMessage = "") {
660
- const output = { continue: Boolean(shouldContinue) };
661
- if (userMessage) output.user_message = userMessage;
861
+ function beforeSubmitOutput(permission = "allow", userMessage = "") {
862
+ const output = { permission };
863
+ if (userMessage) output.userMessage = userMessage;
662
864
  return JSON.stringify(output);
663
865
  }
664
866
 
@@ -701,16 +903,6 @@ function writeSessionCache(sessionId, payload) {
701
903
  }
702
904
  }
703
905
 
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
906
  function workspaceKeyFromHookInput(hookInput, transcriptPath) {
715
907
  const roots = hookInput && Array.isArray(hookInput.workspace_roots)
716
908
  ? hookInput.workspace_roots
@@ -724,28 +916,6 @@ function workspaceKeyFromHookInput(hookInput, transcriptPath) {
724
916
  return cwd || process.cwd();
725
917
  }
726
918
 
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;
747
- }
748
-
749
919
  // --- Main ---
750
920
 
751
921
  // --- SessionStart: create conversation + watermark deterministically ---
@@ -768,6 +938,8 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
768
938
  sessionId: sid,
769
939
  conversation_id: wm.conversation_id,
770
940
  agent_id: wm.agent_id || "",
941
+ datapack_id: wm.datapack_id || null,
942
+ datapack_name: wm.datapack_name || null,
771
943
  updated_at: ts,
772
944
  };
773
945
  }
@@ -782,9 +954,18 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
782
954
  function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
783
955
  const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
784
956
  const resumed = Boolean(opts && opts.resumed);
957
+ const pin = opts && opts.datapackPin ? opts.datapackPin : null;
958
+ const captureError = opts && opts.captureError ? opts.captureError : null;
785
959
  const opening = resumed
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.`
787
- : `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.`;
960
+ ? `Resuming Meko conversation **${convId}** from a prior Claude Code session. The watermark file is written — PreCompact and SessionEnd hooks will attempt to append this session's new exchanges to the same conversation.`
961
+ : `Meko conversation **${convId}** was created automatically by the SessionStart hook. The watermark file is written — PreCompact and SessionEnd hooks are configured to capture this session's transcript.`;
962
+ const captureWarning = captureError
963
+ ? `
964
+
965
+ ### Automatic capture warning — tell the user
966
+
967
+ The last automatic capture attempt was persistently rejected because ${captureError.reason || "Meko rejected the write"}. **Do not claim that this session's turns are being saved.** The hook kept ${Number(captureError.held_exchanges || captureError.failed_exchanges) || 1} exchange(s) queued for retry instead of dropping them. Tell the user that automatic conversation capture is currently unhealthy and include this reason. This warning remains until a later hook write succeeds.`
968
+ : "";
788
969
  const memoryBlock =
789
970
  Array.isArray(memories) && memories.length > 0
790
971
  ? `
@@ -795,70 +976,59 @@ These facts were preloaded for you — inspect them before acting so you don't r
795
976
 
796
977
  ${memories.join("\n")}`
797
978
  : "";
979
+ const datapackBlock = buildActiveDatapackBlock(pin);
798
980
 
799
981
  return `## Meko Session Active
800
982
 
801
983
  ${opening}
984
+ ${captureWarning}
802
985
 
803
986
  ### What you MUST do with Meko tools
804
987
 
805
988
  - **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.
806
989
  - **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
807
- - **scope**: Only "read", "write", or "admin"
808
990
 
809
- ### Proactive storage (do NOT wait to be asked)
991
+ ### Memory capture is automatic only when hook delivery succeeds — do NOT proactively call memory_add
810
992
 
811
- - User shares personal info, preferences, or role → call memory_add immediately
812
- - User shares org conventions or team standards → call memory_add immediately
813
- - User asks "what do you know about X?" → call memory_search first
814
- - User provides structured/tabular data → call db_run_write_query (CREATE TABLE + INSERT)
993
+ The hooks above attempt to capture this session's turns, and the server extracts durable memories from successfully delivered turns on its own. Do not call memory_add for facts the user says out loud; when hook delivery succeeds, that would duplicate what extraction already stores. If the warning above is present, tell the user instead of pretending an explicit memory write will bypass the same server rejection.
815
994
 
816
- ### Before destructive actions
995
+ Call memory_add (with the session's agent_id + conversation_id) ONLY for the narrow cases extraction cannot reach:
996
+ - The user explicitly says "remember this" / "save this to memory".
997
+ - 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.
998
+ - 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).
817
999
 
818
- 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.
1000
+ ### Recall and before destructive actions
1001
+
1002
+ - User asks "what do you know about X?" → call memory_search first.
1003
+ - 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.
819
1004
 
820
1005
  ### Use Meko, not local storage
821
1006
 
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}`;
1007
+ 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}`;
823
1008
  }
824
1009
 
825
- function buildSessionStartFallbackContext(agentId) {
1010
+ function buildSessionStartFallbackContext(agentId, opts) {
826
1011
  const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
1012
+ const datapackBlock = buildActiveDatapackBlock(opts && opts.datapackPin ? opts.datapackPin : null);
827
1013
  return `## Meko Memory Instructions
828
1014
 
829
- The SessionStart hook could not reach the Meko MCP server to create a conversation. Automatic capture may not work for this session.
1015
+ 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.
830
1016
 
831
- ### What you should still do
1017
+ ### What you should do while capture is down
832
1018
 
833
- - **Use Meko MCP tools** for memory_add, memory_search, etc. when available
834
1019
  - **agent_id**: use "${resolvedAgentId}" for project-scoped writes; use "${COMMON_BUCKET_AGENT_ID}" for cross-project common facts
835
1020
  - **conversation_id**: Use nil UUID "00000000-0000-0000-0000-000000000000" as placeholder
836
- - **scope**: Only "read", "write", or "admin"
837
1021
 
838
- ### Proactive storage (do NOT wait to be asked)
1022
+ ### Proactive storage (capture is down — do NOT wait to be asked)
839
1023
 
1024
+ Because nothing is capturing this session, explicit saves are the only path — this is the exception, not the normal mode:
840
1025
  - User shares personal info, preferences, or role → call memory_add immediately
841
1026
  - User shares org conventions or team standards → call memory_add immediately
842
1027
  - User asks "what do you know about X?" → call memory_search first
843
1028
 
844
1029
  ### Use Meko, not local storage
845
1030
 
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.`;
847
- }
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.`;
1031
+ 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}`;
862
1032
  }
863
1033
 
864
1034
  /**
@@ -900,39 +1070,8 @@ function withTimeout(promise, ms, fallback, { onTimeout } = {}) {
900
1070
  });
901
1071
  }
902
1072
 
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
1073
  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));
1074
+ process.stdout.write(beforeSubmitOutput("allow"));
936
1075
  }
937
1076
 
938
1077
  async function handleSessionStart(hookInput) {
@@ -959,12 +1098,17 @@ async function handleSessionStart(hookInput) {
959
1098
  // upgraded users stop writing into the ghost namespace.
960
1099
  const cached = (existing.agent_id || "").trim();
961
1100
  const resumedAgentId = cached && cached !== "agent" ? cached : derivedAgentId;
1101
+ const existingDatapackPin = datapackPinFromWatermark(existing);
962
1102
  // Skip preload on resume (we don't know what's already in context).
963
1103
  const context = buildSessionStartContext(
964
1104
  existing.conversation_id,
965
1105
  sessionId,
966
1106
  resumedAgentId,
967
1107
  null,
1108
+ {
1109
+ datapackPin: existingDatapackPin,
1110
+ captureError: readCaptureError(resumedAgentId),
1111
+ },
968
1112
  );
969
1113
  writeSessionCache(sessionId, {
970
1114
  status: "ready",
@@ -997,14 +1141,25 @@ async function handleSessionStart(hookInput) {
997
1141
  const priorAgentId = (prior.agent_id || "").trim();
998
1142
  const resumedAgentId =
999
1143
  priorAgentId && priorAgentId !== "agent" ? priorAgentId : agentId;
1000
- writeWatermark(wmPath, prior.conversation_id, currentLineCount, resumedAgentId);
1144
+ const priorDatapackPin = datapackPinFromWatermark(prior);
1145
+ writeWatermark(
1146
+ wmPath,
1147
+ prior.conversation_id,
1148
+ currentLineCount,
1149
+ resumedAgentId,
1150
+ priorDatapackPin,
1151
+ );
1001
1152
  process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${prior.conversation_id} from ${prior.sessionId}, skipping ${currentLineCount} copied lines\n`);
1002
1153
  const context = buildSessionStartContext(
1003
1154
  prior.conversation_id,
1004
1155
  sessionId,
1005
1156
  resumedAgentId,
1006
1157
  null,
1007
- { resumed: true },
1158
+ {
1159
+ resumed: true,
1160
+ datapackPin: priorDatapackPin,
1161
+ captureError: readCaptureError(resumedAgentId),
1162
+ },
1008
1163
  );
1009
1164
  writeSessionCache(sessionId, {
1010
1165
  status: "ready",
@@ -1022,13 +1177,19 @@ async function handleSessionStart(hookInput) {
1022
1177
  // Create conversation via MCP. Attach session metadata (cwd / gitBranch /
1023
1178
  // startedAt) from the transcript header so hook traces match the migrator.
1024
1179
  const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
1180
+ const datapackPin = readDatapackPin(agentId);
1025
1181
  let convId = null;
1026
1182
  try {
1027
1183
  await mcpInitialize();
1028
- convId = await createConversation(sessionId || "unknown", agentId, transcriptMetadata);
1184
+ convId = await createConversation(
1185
+ sessionId || "unknown",
1186
+ agentId,
1187
+ transcriptMetadata,
1188
+ datapackPin && datapackPin.datapack_id,
1189
+ );
1029
1190
  } catch (err) {
1030
1191
  process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
1031
- const context = buildSessionStartFallbackContext(agentId);
1192
+ const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1032
1193
  if (sessionId) {
1033
1194
  writeSessionCache(sessionId, {
1034
1195
  status: "error",
@@ -1044,7 +1205,7 @@ async function handleSessionStart(hookInput) {
1044
1205
 
1045
1206
  if (!convId) {
1046
1207
  process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
1047
- const context = buildSessionStartFallbackContext(agentId);
1208
+ const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1048
1209
  if (sessionId) {
1049
1210
  writeSessionCache(sessionId, {
1050
1211
  status: "error",
@@ -1060,13 +1221,10 @@ async function handleSessionStart(hookInput) {
1060
1221
 
1061
1222
  // Preload recent memories with a 4s budget (SessionStart timeout is 15s).
1062
1223
  // 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).
1224
+ // context. agent_id is derived per-session from the cwd.
1067
1225
  const PRELOAD_BUDGET_MS = 4000;
1068
1226
  const memories = await withTimeout(
1069
- fetchRecentMemories(agentId),
1227
+ fetchRecentMemories(agentId, datapackPin && datapackPin.datapack_id),
1070
1228
  PRELOAD_BUDGET_MS,
1071
1229
  [],
1072
1230
  { onTimeout: abortInflightRequests },
@@ -1074,13 +1232,16 @@ async function handleSessionStart(hookInput) {
1074
1232
 
1075
1233
  if (sessionId) {
1076
1234
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1077
- writeWatermark(wmPath, convId, 0, agentId);
1235
+ writeWatermark(wmPath, convId, 0, agentId, datapackPin);
1078
1236
  process.stderr.write(
1079
1237
  `[meko-capture] SessionStart: created conversation ${convId}, agent_id=${agentId}, preloaded ${memories.length} memories, watermark at ${wmPath}\n`,
1080
1238
  );
1081
1239
  }
1082
1240
 
1083
- const context = buildSessionStartContext(convId, sessionId, agentId, memories);
1241
+ const context = buildSessionStartContext(convId, sessionId, agentId, memories, {
1242
+ datapackPin,
1243
+ captureError: readCaptureError(agentId),
1244
+ });
1084
1245
  if (sessionId) {
1085
1246
  writeSessionCache(sessionId, {
1086
1247
  status: "ready",
@@ -1097,8 +1258,8 @@ async function handleSessionStart(hookInput) {
1097
1258
 
1098
1259
  async function main() {
1099
1260
  const hookType = process.argv[2];
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");
1261
+ if (!hookType || !["session-start", "before-submit-prompt", "pre-compact", "session-end", "checkpoint"].includes(hookType)) {
1262
+ process.stderr.write("Usage: capture.js <session-start|before-submit-prompt|pre-compact|session-end|checkpoint>\n");
1102
1263
  process.exit(1);
1103
1264
  }
1104
1265
 
@@ -1119,11 +1280,6 @@ async function main() {
1119
1280
  return;
1120
1281
  }
1121
1282
 
1122
- if (hookType === "session-pending") {
1123
- handleSessionPending(hookInput);
1124
- return;
1125
- }
1126
-
1127
1283
  if (hookType === "before-submit-prompt") {
1128
1284
  handleBeforeSubmitPrompt(hookInput);
1129
1285
  return;
@@ -1154,6 +1310,7 @@ async function main() {
1154
1310
  const agentId = cachedAgentId && cachedAgentId !== "agent"
1155
1311
  ? cachedAgentId
1156
1312
  : resolveSessionAgentId(transcriptPath);
1313
+ const datapackPin = datapackPinFromWatermark(watermark);
1157
1314
 
1158
1315
  // Nothing new
1159
1316
  if (currentLines <= lastLine) {
@@ -1165,6 +1322,20 @@ async function main() {
1165
1322
  try {
1166
1323
  await mcpInitialize();
1167
1324
  } catch (err) {
1325
+ const persistent = classifyPersistentCaptureFailure(err);
1326
+ if (persistent) {
1327
+ const pendingExchanges = extractExchanges(transcriptPath, lastLine).length;
1328
+ writeCaptureError(agentId, {
1329
+ code: persistent.code,
1330
+ reason: persistent.reason,
1331
+ held_exchanges: pendingExchanges || 1,
1332
+ session_id: sessionId,
1333
+ conversation_id: convId || null,
1334
+ });
1335
+ process.stderr.write(
1336
+ `[meko-capture] Persistent capture rejection recorded for the next SessionStart: ${persistent.reason}.\n`,
1337
+ );
1338
+ }
1168
1339
  process.stderr.write(`[meko-capture] Failed to initialize MCP session: ${err.message}\n`);
1169
1340
  process.stdout.write(hookOutput(""));
1170
1341
  return;
@@ -1184,20 +1355,63 @@ async function main() {
1184
1355
  // Extract and send exchanges
1185
1356
  const exchanges = extractExchanges(transcriptPath, lastLine);
1186
1357
  let captured = 0;
1358
+ let failed = 0;
1359
+ const persistentFailures = [];
1187
1360
 
1188
1361
  for (const exchange of exchanges) {
1189
1362
  try {
1190
- await addMessage(convId, agentId, exchange);
1363
+ await addMessage(
1364
+ convId,
1365
+ agentId,
1366
+ exchange,
1367
+ datapackPin && datapackPin.datapack_id,
1368
+ );
1191
1369
  captured++;
1192
1370
  } catch (err) {
1371
+ failed++;
1372
+ const persistent = classifyPersistentCaptureFailure(err);
1373
+ if (persistent) persistentFailures.push(persistent);
1193
1374
  process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
1194
1375
  }
1195
1376
  }
1196
1377
 
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);
1378
+ if (persistentFailures.length > 0) {
1379
+ const persistent = persistentFailures[0];
1380
+ writeCaptureError(agentId, {
1381
+ code: persistent.code,
1382
+ reason: persistent.reason,
1383
+ // Every failed exchange remains held by the watermark, including any
1384
+ // transient failures that happened in the same persistently rejected batch.
1385
+ held_exchanges: failed,
1386
+ session_id: sessionId,
1387
+ conversation_id: convId,
1388
+ });
1389
+ process.stderr.write(
1390
+ `[meko-capture] Persistent capture rejection recorded for the next SessionStart: ${persistent.reason}.\n`,
1391
+ );
1392
+ } else if (captured > 0) {
1393
+ // A successful write proves the prior persistent rejection has cleared.
1394
+ // Transient failures may still hold this batch's watermark independently.
1395
+ clearCaptureError(agentId);
1396
+ }
1397
+
1398
+ // Advance the watermark to currentLines ONLY when every exchange was
1399
+ // captured. If any addMessage failed (typically a transient network error),
1400
+ // keep the watermark at lastLine so the next checkpoint / PreCompact /
1401
+ // SessionEnd hook re-extracts and retries this batch. Re-sending the
1402
+ // exchanges that already succeeded is safe: capture dedups by seed
1403
+ // (<conv_id>:<user_uuid>), so retries never create duplicates. Advancing on
1404
+ // partial failure is what would permanently drop the failed exchanges.
1405
+ // We still always persist the resolved agent_id (migrating any legacy literal
1406
+ // value) so later hooks see the derived bucket directly.
1407
+ const watermarkLine = failed === 0 ? currentLines : lastLine;
1408
+ if (failed > 0) {
1409
+ process.stderr.write(
1410
+ `[meko-capture] ${failed}/${exchanges.length} exchange(s) failed; ` +
1411
+ `holding watermark at line ${lastLine} for retry (dedup makes resends safe).\n`
1412
+ );
1413
+ }
1414
+ writeWatermark(wmPath, convId, watermarkLine, agentId, datapackPin);
1201
1415
 
1202
1416
  // Output
1203
1417
  const context =