@yugabytedb/meko-mcp 2.0.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 (48) hide show
  1. package/README.md +31 -6
  2. package/assets/hooks-handlers/codex-session-end.sh +78 -0
  3. package/assets/hooks-handlers/lib/capture.js +163 -14
  4. package/assets/hooks-handlers/lib/checkpoint-timer.js +44 -2
  5. package/assets/hooks-handlers/session-end.sh +10 -0
  6. package/assets/hooks-handlers/session-start.sh +8 -1
  7. package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
  8. package/assets/scripts/package-desktop-skill.sh +32 -1
  9. package/assets/skills/meko-mcp-tools/SKILL.md +47 -41
  10. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -4
  11. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +29 -25
  12. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
  13. package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
  14. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +63 -46
  15. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
  16. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +4 -4
  17. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +17 -22
  18. package/assets/skills/meko-mcp-tools/references/tools-overview.md +40 -39
  19. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +4 -18
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +9 -8
  22. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +49 -42
  23. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
  24. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +28 -24
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +61 -36
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -19
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +40 -39
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
  33. package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
  34. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  35. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
  36. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
  37. package/bin/create-meko-setup.mjs +45 -6
  38. package/lib/clients/claude-code.mjs +8 -4
  39. package/lib/clients/claude-desktop.mjs +14 -0
  40. package/lib/clients/codex.mjs +23 -8
  41. package/lib/clients/common.mjs +2 -0
  42. package/lib/clients/cursor.mjs +7 -2
  43. package/lib/detect.mjs +7 -2
  44. package/lib/prompt.mjs +1 -1
  45. package/lib/url-selection.mjs +55 -0
  46. package/package.json +1 -1
  47. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
  48. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +0 -71
package/README.md CHANGED
@@ -108,7 +108,7 @@ Each client is installed sequentially. A failure on one does not abort the other
108
108
 
109
109
  ```bash
110
110
  npx @yugabytedb/meko-mcp \
111
- --url https://your-mcp.mekodev.com/mcp \
111
+ --url https://mcp.mekodata.ai/mcp \
112
112
  --api-key "$MEKO_API_KEY" \
113
113
  --yes
114
114
  ```
@@ -121,7 +121,7 @@ The installer performs up to 5 steps (some are skippable via flags):
121
121
  |------|--------|------------------------|
122
122
  | 1 | Register MCP server | `claude mcp add meko <url> --transport http --scope user` (with `-H "Authorization: Bearer <key>"` for cloud) |
123
123
  | 2 | Set hook environment vars | Writes `MEKO_MCP_URL` and `MEKO_API_KEY` into `~/.claude/settings.json` under the `env` key. Also prunes any stale Meko hook entries in `settings.json.hooks` that point at a prior install path (e.g. a previous `npx` cache or homebrew-global copy) so only the current install's hooks fire. |
124
- | 3 | Register plugin marketplace | `claude plugin marketplace add yugabyte/meko-mcp-server --scope user` |
124
+ | 3 | Register public plugin marketplace | `claude plugin marketplace add https://github.com/yugabyte/meko-skills.git --scope user` |
125
125
  | 4 | Install skills plugin | `claude plugin install meko-agent-skills --scope user` |
126
126
  | 5 | Validate setup | Config check via `claude mcp list`, MCP `initialize` handshake, `tools/list` verification, and a canary `datapack_list` call |
127
127
 
@@ -173,7 +173,7 @@ Usage:
173
173
  | Flag | Description | Default |
174
174
  |------|-------------|---------|
175
175
  | `--client <list>` | Target client(s): one or more of `claude-code`, `claude-desktop`, `cursor`, `codex`. Comma-separated, or `all`. `codex` covers both the OpenAI Codex CLI and the Codex Desktop App (they share `~/.codex/config.toml`). | `claude-code` |
176
- | `--url <url>` | MCP server URL (from app.mekodev.com > Datapacks > Connect) | _(interactive prompt)_ |
176
+ | `--url <url>` | MCP server URL (from cloud.mekodata.ai > Datapacks > Connect) | Shared existing endpoint, otherwise production; conflicts prompt or require `--url` under `--yes` |
177
177
  | `--api-key <key>` | API key for Cloud Meko authentication. If a Cloud `--url` is supplied without this flag, the installer falls back to `$MEKO_API_KEY`, then to an interactive prompt on a TTY. Only fails hard when running under `--yes` or with no TTY attached. | _(interactive prompt or `$MEKO_API_KEY` env)_ |
178
178
  | `--local` | Use local Meko at `http://localhost:8000/mcp` (no API key needed) | `false` |
179
179
  | `--scope <scope>` | Config scope: `user` or `project` (`claude-desktop` uses user scope config) | `user` |
@@ -359,7 +359,7 @@ Cursor previously had a hybrid hook mode that also wrote Claude-compatible hooks
359
359
 
360
360
  | | Cloud Meko | Local Meko |
361
361
  |---|-----------|-----------|
362
- | **URL** | Provided by app.mekodev.com > Datapacks > Connect | `http://localhost:8000/mcp` |
362
+ | **URL** | `https://mcp.mekodata.ai/mcp` (or the endpoint shown by cloud.mekodata.ai) | `http://localhost:8000/mcp` |
363
363
  | **Authentication** | Requires `--api-key` or `$MEKO_API_KEY` env var | None |
364
364
  | **MCP registration** | Includes `-H "Authorization: Bearer <key>"` header | No auth header |
365
365
  | **Hook env vars** | Both `MEKO_MCP_URL` and `MEKO_API_KEY` written to settings | Only `MEKO_MCP_URL` written |
@@ -505,6 +505,31 @@ which claude
505
505
  claude --version
506
506
  ```
507
507
 
508
+ ### MCP server `meko` already exists in the user config
509
+
510
+ ```
511
+ [1/4] Failed to register MCP server.
512
+ MCP server meko already exists in the user config
513
+ Install for claude-code failed: MCP server registration failed.
514
+ ```
515
+
516
+ Claude Code's `claude mcp add` refuses to overwrite an existing registration, so a re-run — or a previous install that failed partway through — aborts at step 1. Remove the stale entry, then re-run the installer:
517
+
518
+ ```bash
519
+ claude mcp list # confirm the "meko" entry and which scope it is on
520
+ claude mcp remove meko --scope user # or --scope local / --scope project, matching what `list` showed
521
+ claude mcp list # confirm it is gone
522
+ npx @yugabytedb/meko-mcp@latest # re-run the installer
523
+ ```
524
+
525
+ If `claude mcp remove` errors or you want to edit the file directly, the user-scope config lives at `~/.claude.json` — open it, find the `"mcpServers"` block, and delete the `"meko": { ... }` entry. Claude Desktop and Claude Code keep **separate** configs, so a clean Desktop install does not imply a clean Code install.
526
+
527
+ Logs worth collecting before filing a bug:
528
+
529
+ - **Installer log** — `ls -lt ~/.meko*/logs 2>/dev/null; ls -lt /tmp/meko-installer* 2>/dev/null` — share the newest file.
530
+ - **Claude Code MCP log** (macOS) — `ls -lt ~/Library/Caches/claude-cli-nodejs/*/mcp-logs-meko/ 2>/dev/null` — most recent `*.txt` shows the registration attempt and any stderr from the spawned process.
531
+ - **Current config snippet** — `grep -A 20 '"meko"' ~/.claude.json` — confirms what state Claude Code was in when the install was attempted.
532
+
508
533
  ### Connection refused
509
534
 
510
535
  ```
@@ -537,7 +562,7 @@ The MCP server is enforcing strict Streamable HTTP compliance: clients must adve
537
562
  Server returned 401. Verify your API key is correct and has not expired.
538
563
  ```
539
564
 
540
- - Confirm the API key at app.mekodev.com > Datapacks > Connect.
565
+ - Confirm the API key at cloud.mekodata.ai > Datapacks > Connect.
541
566
  - If using `$MEKO_API_KEY` env var, ensure it is exported in the current shell.
542
567
  - Confirm the key environment matches the URL: PATs minted on `app.mekodev.com` only validate against `mcp.mekodev.com`; PATs minted on `cloud.mekodata.ai` only validate against `mcp.mekodata.ai`.
543
568
  - The installer normalizes API keys (trims surrounding whitespace and a single matching pair of quotes) so a paste with a trailing newline or a literal `"…"` from a `.env` file is fine — but your raw shell variable might still be contaminated. Echo it (`echo "[$MEKO_API_KEY]"`) and check the brackets bracket the key tightly with no extra characters.
@@ -714,7 +739,7 @@ npm run test:integration
714
739
 
715
740
  # Cloud round-trip tests (opt-in; regression coverage for MEKO-20).
716
741
  # Requires credentials in the shell:
717
- export MEKO_TEST_CLOUD_URL=https://<your-mcp>.mekodev.com/mcp
742
+ export MEKO_TEST_CLOUD_URL=https://mcp.mekodata.ai/mcp
718
743
  export MEKO_TEST_API_KEY=<your-cloud-api-key>
719
744
  npm run test:integration:cloud
720
745
 
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Codex SessionEnd hook.
4
+ #
5
+ # Codex caps SessionEnd hooks at three seconds. The foreground invocation
6
+ # therefore persists the hook input, starts a detached worker, and exits
7
+ # immediately. The worker reuses the shared capture.js implementation and
8
+ # removes the persisted input when capture finishes.
9
+
10
+ set -u
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13
+ CAPTURE_SCRIPT="$SCRIPT_DIR/lib/capture.js"
14
+ STATE_DIR="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}"
15
+ JOB_DIR="$STATE_DIR/session-end-jobs"
16
+ LOG_FILE="$STATE_DIR/session-end.log"
17
+
18
+ run_worker() {
19
+ JOB_FILE="${1:-}"
20
+ if [ -z "$JOB_FILE" ] || [ ! -f "$JOB_FILE" ]; then
21
+ printf '[meko-capture] Codex SessionEnd worker received no readable job file\n' >&2
22
+ return 1
23
+ fi
24
+
25
+ trap 'rm -f "$JOB_FILE"' EXIT HUP INT TERM
26
+ node "$CAPTURE_SCRIPT" session-end <"$JOB_FILE"
27
+ }
28
+
29
+ if [ "${1:-}" = "--worker" ]; then
30
+ run_worker "${2:-}"
31
+ exit $?
32
+ fi
33
+
34
+ # Hook input can contain local paths and session metadata. Keep the spool
35
+ # directory and files private to the current user.
36
+ umask 077
37
+ mkdir -p "$JOB_DIR"
38
+ JOB_FILE="$(mktemp "$JOB_DIR/session-end.XXXXXX")" || {
39
+ printf '[meko-capture] Codex SessionEnd could not create a job file\n' >&2
40
+ printf '{}'
41
+ exit 0
42
+ }
43
+
44
+ if ! cat >"$JOB_FILE"; then
45
+ rm -f "$JOB_FILE"
46
+ printf '[meko-capture] Codex SessionEnd could not persist hook input\n' >&2
47
+ printf '{}'
48
+ exit 0
49
+ fi
50
+
51
+ # Stop the periodic timer before handing final capture to the worker. This is
52
+ # deliberately best-effort: a missing or stale PID must not block SessionEnd.
53
+ TRANSCRIPT_PATH="$(node - "$JOB_FILE" <<'NODE' 2>/dev/null
54
+ const fs = require("fs");
55
+ const d = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
56
+ const p = d.transcript_path || (d.hookSpecificInput || {}).transcript_path || "";
57
+ process.stdout.write(p);
58
+ NODE
59
+ )"
60
+
61
+ if [ -n "$TRANSCRIPT_PATH" ]; then
62
+ SESSION_ID="$(basename "$TRANSCRIPT_PATH" .jsonl)"
63
+ PID_FILE="$STATE_DIR/${SESSION_ID}.timer.pid"
64
+ if [ -f "$PID_FILE" ]; then
65
+ kill "$(cat "$PID_FILE")" 2>/dev/null || true
66
+ rm -f "$PID_FILE"
67
+ fi
68
+ fi
69
+
70
+ # Redirect every inherited descriptor before returning to Codex. nohup keeps
71
+ # the worker alive after the hook shell exits; disown is available in bash but
72
+ # remains best-effort for non-interactive shells.
73
+ nohup bash "$0" --worker "$JOB_FILE" \
74
+ </dev/null >>"$LOG_FILE" 2>&1 &
75
+ WORKER_PID=$!
76
+ disown "$WORKER_PID" 2>/dev/null || true
77
+
78
+ printf '{}'
@@ -367,6 +367,45 @@ function datapackPinSlug(agentId) {
367
367
  return trimmed.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || COMMON_BUCKET_AGENT_ID;
368
368
  }
369
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
+
370
409
  /**
371
410
  * Read the active-datapack pin written by the meko-select-datapack skill.
372
411
  *
@@ -409,7 +448,7 @@ function buildActiveDatapackBlock(pin) {
409
448
 
410
449
  ### Active datapack
411
450
 
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.`;
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.`;
413
452
  }
414
453
 
415
454
  // note: Subagents cannot auto-discover the parent's watermark today.
@@ -470,6 +509,16 @@ function mcpPost(jsonBody) {
470
509
  res.on("data", (chunk) => (data += chunk));
471
510
  res.on("end", () => {
472
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
+ }
473
522
  // Notifications return 202/204 with no body — that's OK
474
523
  if (!data.trim()) {
475
524
  resolve({ body: null, headers: res.headers });
@@ -535,6 +584,51 @@ function parseMcpResponseBody(data) {
535
584
  return JSON.parse(payload);
536
585
  }
537
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
+
538
632
  /**
539
633
  * Abort all pending MCP requests. Used by the preload timeout path so the
540
634
  * SessionStart hook can return promptly instead of waiting for a slow server
@@ -654,7 +748,6 @@ function peekTranscriptMetadata(transcriptPath) {
654
748
  */
655
749
  async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
656
750
  const payload = {
657
- scope: "read",
658
751
  agent_id: agentId,
659
752
  conversation_id: "00000000-0000-0000-0000-000000000000",
660
753
  limit,
@@ -680,7 +773,6 @@ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, bud
680
773
 
681
774
  async function createConversation(sessionId, agentId, metadata = null, datapackId = null) {
682
775
  const payload = {
683
- scope: "write",
684
776
  agent_id: agentId,
685
777
  title: "Claude Code session (auto-captured)",
686
778
  session_id: sessionId,
@@ -702,7 +794,6 @@ async function createConversation(sessionId, agentId, metadata = null, datapackI
702
794
  async function addMessage(convId, agentId, exchange, datapackId = null) {
703
795
  const seed = `${convId}:${exchange.user_uuid}`;
704
796
  const payload = {
705
- scope: "write",
706
797
  conversation_id: convId,
707
798
  agent_id: agentId,
708
799
  input: exchange.input,
@@ -745,14 +836,20 @@ function hookOutput(additionalContext) {
745
836
  // emitting it for lifecycle capture hooks makes Codex reject the JSON.
746
837
  if (process.argv[2] !== "session-start") return "{}";
747
838
 
748
- // Emit the native coding-agent harness field while preserving the nested
749
- // Claude-compatible shape for clients that still consume it.
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.
750
841
  const output = {
751
842
  hookSpecificOutput: {
752
843
  hookEventName: "SessionStart",
753
844
  additionalContext: additionalContext || "",
754
845
  },
755
846
  };
847
+ if (process.env.MEKO_HOOK_CLIENT === "codex") {
848
+ return JSON.stringify(output);
849
+ }
850
+
851
+ // Preserve the native coding-agent harness field for clients that consume
852
+ // it alongside the nested Claude-compatible shape.
756
853
  output.additional_context = additionalContext || "";
757
854
  return JSON.stringify(output);
758
855
  }
@@ -858,9 +955,17 @@ function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
858
955
  const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
859
956
  const resumed = Boolean(opts && opts.resumed);
860
957
  const pin = opts && opts.datapackPin ? opts.datapackPin : null;
958
+ const captureError = opts && opts.captureError ? opts.captureError : null;
861
959
  const opening = resumed
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.`
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.`;
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
+ : "";
864
969
  const memoryBlock =
865
970
  Array.isArray(memories) && memories.length > 0
866
971
  ? `
@@ -876,16 +981,16 @@ ${memories.join("\n")}`
876
981
  return `## Meko Session Active
877
982
 
878
983
  ${opening}
984
+ ${captureWarning}
879
985
 
880
986
  ### What you MUST do with Meko tools
881
987
 
882
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.
883
989
  - **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
884
- - **scope**: Only "read", "write", or "admin"
885
990
 
886
- ### Memory capture is automatic — do NOT proactively call memory_add
991
+ ### Memory capture is automatic only when hook delivery succeeds — do NOT proactively call memory_add
887
992
 
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.
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.
889
994
 
890
995
  Call memory_add (with the session's agent_id + conversation_id) ONLY for the narrow cases extraction cannot reach:
891
996
  - The user explicitly says "remember this" / "save this to memory".
@@ -913,7 +1018,6 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
913
1018
 
914
1019
  - **agent_id**: use "${resolvedAgentId}" for project-scoped writes; use "${COMMON_BUCKET_AGENT_ID}" for cross-project common facts
915
1020
  - **conversation_id**: Use nil UUID "00000000-0000-0000-0000-000000000000" as placeholder
916
- - **scope**: Only "read", "write", or "admin"
917
1021
 
918
1022
  ### Proactive storage (capture is down — do NOT wait to be asked)
919
1023
 
@@ -1001,7 +1105,10 @@ async function handleSessionStart(hookInput) {
1001
1105
  sessionId,
1002
1106
  resumedAgentId,
1003
1107
  null,
1004
- { datapackPin: existingDatapackPin },
1108
+ {
1109
+ datapackPin: existingDatapackPin,
1110
+ captureError: readCaptureError(resumedAgentId),
1111
+ },
1005
1112
  );
1006
1113
  writeSessionCache(sessionId, {
1007
1114
  status: "ready",
@@ -1048,7 +1155,11 @@ async function handleSessionStart(hookInput) {
1048
1155
  sessionId,
1049
1156
  resumedAgentId,
1050
1157
  null,
1051
- { resumed: true, datapackPin: priorDatapackPin },
1158
+ {
1159
+ resumed: true,
1160
+ datapackPin: priorDatapackPin,
1161
+ captureError: readCaptureError(resumedAgentId),
1162
+ },
1052
1163
  );
1053
1164
  writeSessionCache(sessionId, {
1054
1165
  status: "ready",
@@ -1129,6 +1240,7 @@ async function handleSessionStart(hookInput) {
1129
1240
 
1130
1241
  const context = buildSessionStartContext(convId, sessionId, agentId, memories, {
1131
1242
  datapackPin,
1243
+ captureError: readCaptureError(agentId),
1132
1244
  });
1133
1245
  if (sessionId) {
1134
1246
  writeSessionCache(sessionId, {
@@ -1210,6 +1322,20 @@ async function main() {
1210
1322
  try {
1211
1323
  await mcpInitialize();
1212
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
+ }
1213
1339
  process.stderr.write(`[meko-capture] Failed to initialize MCP session: ${err.message}\n`);
1214
1340
  process.stdout.write(hookOutput(""));
1215
1341
  return;
@@ -1230,6 +1356,7 @@ async function main() {
1230
1356
  const exchanges = extractExchanges(transcriptPath, lastLine);
1231
1357
  let captured = 0;
1232
1358
  let failed = 0;
1359
+ const persistentFailures = [];
1233
1360
 
1234
1361
  for (const exchange of exchanges) {
1235
1362
  try {
@@ -1242,10 +1369,32 @@ async function main() {
1242
1369
  captured++;
1243
1370
  } catch (err) {
1244
1371
  failed++;
1372
+ const persistent = classifyPersistentCaptureFailure(err);
1373
+ if (persistent) persistentFailures.push(persistent);
1245
1374
  process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
1246
1375
  }
1247
1376
  }
1248
1377
 
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
+
1249
1398
  // Advance the watermark to currentLines ONLY when every exchange was
1250
1399
  // captured. If any addMessage failed (typically a transient network error),
1251
1400
  // keep the watermark at lastLine so the next checkpoint / PreCompact /
@@ -12,6 +12,11 @@
12
12
  *
13
13
  * Environment:
14
14
  * MEKO_CHECKPOINT_INTERVAL Seconds between ticks (default: 600)
15
+ * MEKO_TIMER_MAX_AGE Hard self-exit age in seconds (default: 86400 = 24h)
16
+ * MEKO_TIMER_IDLE_EXIT Self-exit after the transcript is untouched this
17
+ * long, in seconds (default: 7200 = 2h)
18
+ * MEKO_TIMER_STARTUP_GRACE Wait this long for the transcript to appear
19
+ * before self-exiting (default: 1800 = 30min)
15
20
  * MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
16
21
  * MEKO_MCP_URL Inherited by capture.js
17
22
  * MEKO_API_KEY Inherited by capture.js
@@ -22,6 +27,14 @@ const fs = require("fs");
22
27
  const path = require("path");
23
28
 
24
29
  const INTERVAL_SECS = parseInt(process.env.MEKO_CHECKPOINT_INTERVAL || "600", 10);
30
+ // Self-exit backstops so an orphaned daemon (its pid file removed out of band,
31
+ // a missed SessionEnd, a blocked SIGTERM) cannot tick forever. Both are
32
+ // generous enough not to interrupt a genuinely long, active session.
33
+ const MAX_AGE_SECS = parseInt(process.env.MEKO_TIMER_MAX_AGE, 10) || 86400;
34
+ const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
35
+ const STARTUP_GRACE_SECS =
36
+ parseInt(process.env.MEKO_TIMER_STARTUP_GRACE, 10) || 1800;
37
+ const startedAt = Date.now();
25
38
  const WATERMARK_DIR =
26
39
  process.env.MEKO_WATERMARK_DIR ||
27
40
  path.join(process.env.HOME || "~", ".claude", "meko-capture");
@@ -49,8 +62,37 @@ process.on("SIGTERM", cleanup);
49
62
  process.on("SIGINT", cleanup);
50
63
 
51
64
  function tick() {
52
- // Skip if transcript doesn't exist (session ended but we weren't killed yet)
53
- if (!fs.existsSync(transcriptPath)) {
65
+ // Self-exit backstop: never outlive the hard max age. Guards against orphans
66
+ // whose pid file was removed out of band (so session-end.sh can't kill them)
67
+ // or whose SessionEnd never fired (force-quit / terminal closed).
68
+ if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
69
+ process.stderr.write(
70
+ `[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting to avoid orphan.\n`,
71
+ );
72
+ cleanup();
73
+ return;
74
+ }
75
+
76
+ // Exit if the transcript is gone (session ended but we weren't killed yet).
77
+ // SessionStart can precede transcript creation by up to 20 minutes, so a
78
+ // missing file during the startup grace period is expected.
79
+ let stat;
80
+ try {
81
+ stat = fs.statSync(transcriptPath);
82
+ } catch {
83
+ if (Date.now() - startedAt < STARTUP_GRACE_SECS * 1000) {
84
+ return;
85
+ }
86
+ cleanup();
87
+ return;
88
+ }
89
+
90
+ // Exit if the transcript has been idle far longer than a checkpoint window —
91
+ // the session is effectively over even though we were never signalled.
92
+ if (Date.now() - stat.mtimeMs >= IDLE_EXIT_SECS * 1000) {
93
+ process.stderr.write(
94
+ `[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; self-exiting.\n`,
95
+ );
54
96
  cleanup();
55
97
  return;
56
98
  }
@@ -21,6 +21,16 @@ if [ -n "$TRANSCRIPT_PATH" ]; then
21
21
  kill "$(cat "$PID_FILE")" 2>/dev/null || true
22
22
  rm -f "$PID_FILE"
23
23
  fi
24
+ # Fallback: the pid file can go missing while the daemon lives (removed out of
25
+ # band, a missed/blocked signal). Match the running checkpoint-timer.js by the
26
+ # clean session id rather than the absolute transcript path, which may contain
27
+ # regex metacharacters that make pgrep fail or match incorrectly.
28
+ if command -v pgrep >/dev/null 2>&1; then
29
+ for pid in $(pgrep -f "checkpoint-timer.js .*${SESSION_ID}" 2>/dev/null); do
30
+ [ "$pid" = "$$" ] && continue
31
+ kill "$pid" 2>/dev/null || true
32
+ done
33
+ fi
24
34
  fi
25
35
 
26
36
  # Run final capture
@@ -16,7 +16,14 @@ TRANSCRIPT_PATH="$(printf '%s' "$HOOK_INPUT" | node -e '
16
16
  # Spawn background checkpoint timer (periodic capture without interrupting the agent).
17
17
  # Send the daemon's stderr to a log file instead of /dev/null so startup
18
18
  # failures and crashes are diagnosable; stdout is still discarded.
19
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
19
+ #
20
+ # NOTE: we deliberately do NOT gate on [ -f "$TRANSCRIPT_PATH" ]. SessionStart
21
+ # fires before Claude Code writes the transcript .jsonl, so that file usually
22
+ # does not exist yet at hook time (measured gaps of +3s to +20min). Gating on
23
+ # its existence meant the timer never spawned and the whole session went
24
+ # uncaptured. checkpoint-timer.js self-guards a missing transcript on each
25
+ # tick(), so spawning unconditionally (when the path is known) is safe.
26
+ if [ -n "$TRANSCRIPT_PATH" ]; then
20
27
  MEKO_LOG_DIR="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}"
21
28
  mkdir -p "$MEKO_LOG_DIR"
22
29
  nohup node "$SCRIPT_DIR/lib/checkpoint-timer.js" "$TRANSCRIPT_PATH" \