@yugabytedb/meko-mcp 2.3.0 → 2.5.1

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 (42) hide show
  1. package/README.md +88 -35
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +735 -74
  3. package/assets/hooks-handlers/lib/capture-notices.js +94 -1
  4. package/assets/hooks-handlers/lib/capture-state.js +111 -0
  5. package/assets/hooks-handlers/lib/capture.js +130 -4
  6. package/assets/hooks-handlers/lib/checkpoint-timer.js +394 -8
  7. package/assets/hooks-handlers/lib/opencode-handler.js +29 -6
  8. package/assets/scripts/meko-statusline.sh +17 -1
  9. package/assets/skills/meko-mcp-tools/SKILL.md +6 -5
  10. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +194 -10
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -3
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +11 -6
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +4 -4
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +19 -14
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +69 -8
  17. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +3 -1
  18. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -7
  19. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
  20. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  21. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +194 -10
  22. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +10 -5
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +19 -14
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +68 -8
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -1
  27. package/assets/skills/meko-select-datapack-desktop/SKILL.md +91 -12
  28. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +39 -7
  29. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +8 -6
  30. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +13 -1
  31. package/bin/create-meko-setup.mjs +152 -35
  32. package/lib/autofix.mjs +100 -0
  33. package/lib/browser-login.mjs +495 -0
  34. package/lib/clients/codex.mjs +1 -24
  35. package/lib/clients/common.mjs +25 -0
  36. package/lib/clients/opencode.mjs +6 -1
  37. package/lib/migrate/datapack.mjs +254 -16
  38. package/lib/migrate/mcp-client.mjs +34 -0
  39. package/lib/migrate/orchestrator.mjs +1 -1
  40. package/lib/migrate.mjs +2 -6
  41. package/lib/validate.mjs +111 -22
  42. package/package.json +5 -5
@@ -17,7 +17,7 @@
17
17
 
18
18
  "use strict";
19
19
 
20
- const { execFile } = require("child_process");
20
+ const { execFile, spawn } = require("child_process");
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const crypto = require("crypto");
@@ -39,6 +39,12 @@ const {
39
39
  legacyErrorsSafeToRemove,
40
40
  isResumableState,
41
41
  readState,
42
+ atomicWriteJson,
43
+ autofixEnabled,
44
+ autofixIntervalSeconds,
45
+ autofixLastPath,
46
+ readAutofixLast,
47
+ envInt,
42
48
  } = require("./capture-state");
43
49
 
44
50
  const INTERVAL_SECS = Math.max(1, checkpointInterval());
@@ -47,6 +53,23 @@ const MAX_AGE_SECS = Math.max(1, timerMaxAge());
47
53
  const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
48
54
  const STARTUP_GRACE_SECS =
49
55
  parseInt(process.env.MEKO_TIMER_STARTUP_GRACE, 10) || 1800;
56
+ const FOLLOWUP_SECS =
57
+ parseInt(process.env.MEKO_TIMER_FOLLOWUP_INTERVAL, 10) || 30;
58
+
59
+ // Automatic repair: one `--capture-cleanup fix --auto` run per machine every
60
+ // MEKO_AUTOFIX_INTERVAL seconds (default 1800). Any timer may run it; a lock
61
+ // file keeps it to one at a time.
62
+ const AUTOFIX_CHECK_SECS = Math.max(1, envInt("MEKO_AUTOFIX_CHECK_INTERVAL", 60));
63
+ const AUTOFIX_FIRST_CHECK_SECS = Math.max(1, envInt("MEKO_AUTOFIX_FIRST_CHECK", 30));
64
+ const AUTOFIX_TIMEOUT_MS = Math.max(60, envInt("MEKO_AUTOFIX_TIMEOUT", 900)) * 1000;
65
+ const AUTOFIX_MAX_EXCHANGES = Math.max(1, envInt("MEKO_AUTOFIX_MAX_EXCHANGES", 100));
66
+ const cleanupScript = path.join(__dirname, "capture-cleanup-cli.js");
67
+ let autofixInFlight = false;
68
+ // The running automatic-repair child and its lock, so cleanup() can stop it:
69
+ // the timer exits through process.exit, which skips runAutofix's finally.
70
+ let autofixChild = null;
71
+ let autofixLockPath = null;
72
+ let exiting = false;
50
73
 
51
74
  const startedAt = Date.now();
52
75
  const STATE_DIR = watermarkDir();
@@ -153,7 +176,107 @@ function cleanup() {
153
176
  } catch {
154
177
  /* already gone */
155
178
  }
156
- process.exit(0);
179
+ if (exiting) return;
180
+ exiting = true;
181
+ // Stopping an automatic run waits for it to exit, which needs the event
182
+ // loop (a synchronous wait would leave it a zombie that still looks alive).
183
+ stopAutofixChild()
184
+ .catch(() => {})
185
+ .finally(() => process.exit(0));
186
+ }
187
+
188
+ function groupAlive(pgid) {
189
+ if (!Number.isInteger(pgid) || pgid <= 0) return false;
190
+ try {
191
+ process.kill(-pgid, 0);
192
+ return true;
193
+ } catch (err) {
194
+ return Boolean(err && err.code === "EPERM");
195
+ }
196
+ }
197
+
198
+ function signalGroup(pgid, signal) {
199
+ try {
200
+ process.kill(-pgid, signal);
201
+ } catch {
202
+ /* already gone */
203
+ }
204
+ }
205
+
206
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
207
+
208
+ /**
209
+ * Stop an in-flight automatic repair: its whole process group, so the
210
+ * capture.js child the cleanup CLI is blocked on stops too. The lock is
211
+ * released only once the group is gone; until then it names the run's pid,
212
+ * so no other timer starts a second run while this one is still sending.
213
+ */
214
+ async function stopAutofixChild() {
215
+ const child = autofixChild;
216
+ if (child && child.exitCode === null && child.signalCode === null) {
217
+ const closed = new Promise((r) => child.once("close", r));
218
+ signalGroup(child.pid, "SIGTERM");
219
+ const exited = await Promise.race([closed.then(() => true), sleep(3000).then(() => false)]);
220
+ if (!exited) {
221
+ signalGroup(child.pid, "SIGKILL");
222
+ await Promise.race([closed, sleep(1000)]);
223
+ }
224
+ }
225
+ if (child) {
226
+ // The rest of the group (capture.js) was re-parented when the CLI died.
227
+ for (let waited = 0; waited < 3000 && groupAlive(child.pid); waited += 100) await sleep(100);
228
+ if (groupAlive(child.pid)) signalGroup(child.pid, "SIGKILL");
229
+ }
230
+ releaseAutofixLock(child ? child.pid : null);
231
+ }
232
+
233
+ function releaseAutofixLock(runPid) {
234
+ if (!autofixLockPath) return;
235
+ if (runPid && groupAlive(runPid)) return;
236
+ try {
237
+ const held = JSON.parse(fs.readFileSync(autofixLockPath, "utf8"));
238
+ if (Number(held.pid) === process.pid) fs.unlinkSync(autofixLockPath);
239
+ } catch {
240
+ /* already gone */
241
+ }
242
+ autofixLockPath = null;
243
+ }
244
+
245
+ // MEKO-918: an OpenCode timer outlives edits to its hook env file. Without a
246
+ // re-read, a timer started during an outage sends every attempt to the dead
247
+ // URL until it exits: MEKO_TIMER_IDLE_EXIT after the session's last turn, or
248
+ // MEKO_TIMER_MAX_AGE (24 h) after it started.
249
+ //
250
+ // The installer always writes MEKO_MCP_URL and MEKO_API_KEY, so a file that
251
+ // lacks them was emptied by uninstall or by hand. Drop the inherited values
252
+ // then, so the timer stops sending with removed credentials. A file that
253
+ // can't be parsed keeps the inherited environment.
254
+ const RELOADED_CREDENTIAL_KEYS = ["MEKO_MCP_URL", "MEKO_API_KEY"];
255
+
256
+ function captureChildEnv() {
257
+ if (
258
+ process.env.MEKO_HOOK_CLIENT !== "opencode" ||
259
+ !process.env.MEKO_OPENCODE_ENV_FILE
260
+ ) {
261
+ return process.env;
262
+ }
263
+ try {
264
+ const { status, set, unset } = require("./opencode-handler").readHookEnvironment(
265
+ process.env.MEKO_OPENCODE_ENV_FILE,
266
+ );
267
+ if (status === "unreadable") return process.env;
268
+ const env = { ...process.env, ...set };
269
+ for (const key of unset) delete env[key];
270
+ for (const key of RELOADED_CREDENTIAL_KEYS) {
271
+ if (!(key in set)) delete env[key];
272
+ }
273
+ return env;
274
+ } catch (err) {
275
+ process.stderr.write(
276
+ `[meko-timer] Could not reload OpenCode hook environment: ${err.message}\n`,
277
+ );
278
+ return process.env;
279
+ }
157
280
  }
158
281
 
159
282
  function runCaptureChild(mode, payload, timeoutMs) {
@@ -161,7 +284,7 @@ function runCaptureChild(mode, payload, timeoutMs) {
161
284
  const child = execFile(
162
285
  process.execPath,
163
286
  [captureScript, mode],
164
- { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
287
+ { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: captureChildEnv() },
165
288
  (err, stdout, stderr) => {
166
289
  let result = null;
167
290
  const text = (stdout || "").trim();
@@ -264,7 +387,9 @@ function refreshStartupPersistentQueue(scan, forcePersistentRetry) {
264
387
  * Returns true if more recoverable work may remain for a later cycle.
265
388
  */
266
389
  async function runRecoveryCycle() {
267
- if (recoveryInFlight) return workRemains;
390
+ // Once cleanup() starts, start no new capture.js child: it would outlive
391
+ // this timer's exit.
392
+ if (recoveryInFlight || exiting) return workRemains;
268
393
  recoveryInFlight = true;
269
394
  const forcePersistentRetry = startupRecoveryPending;
270
395
  let moreWork = false;
@@ -399,10 +524,64 @@ function selfIsResumable() {
399
524
  return read.ok ? isResumableState(read.state) : false;
400
525
  }
401
526
 
527
+ // MEKO-918: recovery skips the daemon's own session, so without this check a
528
+ // failed live drain waits for the next full tick (600 s by default) instead
529
+ // of retrying at next_retry_at. Each scheduled retry runs once: a checkpoint
530
+ // that exits before the network leaves next_retry_at unchanged.
531
+ //
532
+ // The retry has its own in-flight promise rather than tickInFlight. A tick
533
+ // that fires during a retry waits for it and then runs, so the exit checks
534
+ // and recovery cycle are not skipped for a whole interval.
535
+ let ownRetryAttemptedFor = null;
536
+ let ownRetryInFlight = null;
537
+
538
+ function ownRetryDue(nowMs) {
539
+ const read = readState(sessionId);
540
+ if (!read.ok) return false;
541
+ const state = read.state;
542
+ // needs_conversation counts: on a watermark without a conversation_id,
543
+ // writeSessionIntent resets a failed attempt's retry_wait to
544
+ // needs_conversation and keeps next_retry_at. OpenCode does this on every
545
+ // snapshot (ensureCaptureIntent).
546
+ if (["idle", "complete", "draining"].includes(state.delivery)) return false;
547
+ if (!(Number(state.queued_exchanges || 0) > 0)) return false;
548
+ const retryMs = Date.parse(state.next_retry_at || "");
549
+ if (!Number.isFinite(retryMs) || retryMs > nowMs) return false;
550
+ return state.next_retry_at !== ownRetryAttemptedFor;
551
+ }
552
+
553
+ function retryOwnSession() {
554
+ if (exiting || tickInFlight || recoveryInFlight || ownRetryInFlight) {
555
+ return Promise.resolve();
556
+ }
557
+ ownRetryInFlight = runOwnRetry().finally(() => {
558
+ ownRetryInFlight = null;
559
+ });
560
+ return ownRetryInFlight;
561
+ }
562
+
563
+ async function runOwnRetry() {
564
+ const read = readState(sessionId);
565
+ ownRetryAttemptedFor = read.ok ? read.state.next_retry_at : null;
566
+ const live = await runCaptureChild(
567
+ "checkpoint",
568
+ { transcript_path: transcriptPath, force_retry: false },
569
+ CAPTURE_TIMEOUT_MS,
570
+ );
571
+ if (!live.ok) {
572
+ process.stderr.write(
573
+ `[meko-timer] Own-session retry failed: ${live.error}\n`,
574
+ );
575
+ }
576
+ rebuildHealth();
577
+ }
578
+
402
579
  async function tick() {
403
- if (tickInFlight) return;
580
+ if (tickInFlight || exiting) return;
404
581
  tickInFlight = true;
405
582
  try {
583
+ // Never run two checkpoints of this session at once.
584
+ if (ownRetryInFlight) await ownRetryInFlight.catch(() => {});
406
585
  if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
407
586
  process.stderr.write(
408
587
  `[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting (outbox left pending).\n`,
@@ -500,6 +679,192 @@ async function tick() {
500
679
  }
501
680
  }
502
681
 
682
+ function autofixDue(nowMs) {
683
+ if (!autofixEnabled() || !fs.existsSync(cleanupScript)) return false;
684
+ const last = readAutofixLast();
685
+ const ranAt = last ? Date.parse(last.ran_at) : NaN;
686
+ return !Number.isFinite(ranAt) || nowMs - ranAt >= autofixIntervalSeconds() * 1000;
687
+ }
688
+
689
+ function acquireAutofixLock(nowMs) {
690
+ const lockPath = path.join(STATE_DIR, "autofix.lock");
691
+ for (let attempt = 0; attempt < 2; attempt++) {
692
+ try {
693
+ fs.writeFileSync(
694
+ lockPath,
695
+ JSON.stringify({ pid: process.pid, at: new Date(nowMs).toISOString() }),
696
+ { flag: "wx", mode: 0o600 },
697
+ );
698
+ return lockPath;
699
+ } catch (err) {
700
+ if (!err || err.code !== "EEXIST") return null;
701
+ let held = null;
702
+ try {
703
+ held = JSON.parse(fs.readFileSync(lockPath, "utf8"));
704
+ } catch {
705
+ held = null;
706
+ }
707
+ const heldAt = held ? Date.parse(held.at) : NaN;
708
+ // Stale only when neither the timer that took it nor the run it
709
+ // started is alive: a run outlives its timer if the timer is killed.
710
+ const holderAlive = held && (isProcessAlive(Number(held.pid)) || groupAlive(Number(held.run_pid)));
711
+ const stale =
712
+ !held ||
713
+ !holderAlive ||
714
+ !Number.isFinite(heldAt) ||
715
+ nowMs - heldAt > AUTOFIX_TIMEOUT_MS * 2;
716
+ if (!stale) return null;
717
+ try {
718
+ fs.unlinkSync(lockPath);
719
+ } catch {
720
+ /* another timer removed it first */
721
+ }
722
+ }
723
+ }
724
+ return null;
725
+ }
726
+
727
+ function summarizeAutofix(stdout, err) {
728
+ let report = null;
729
+ try {
730
+ report = JSON.parse(stdout);
731
+ } catch {
732
+ report = null;
733
+ }
734
+ const errors = report && Array.isArray(report.errors) ? report.errors : [];
735
+ const sessions = report && Array.isArray(report.sessions) ? report.sessions : [];
736
+ const parts = [];
737
+ if (sessions.length) {
738
+ const byOutcome = {};
739
+ for (const x of sessions) byOutcome[x.outcome] = (byOutcome[x.outcome] || 0) + 1;
740
+ parts.push(Object.entries(byOutcome).map(([k, v]) => `${k}=${v}`).join(" "));
741
+ }
742
+ if (errors.length) parts.push(`errors: ${errors.join("; ")}`);
743
+ if (!report) parts.push(err ? `failed: ${err.message}` : "failed: no report");
744
+ else if (err && !errors.length) parts.push(`exit ${err.code != null ? err.code : "?"}`);
745
+ return parts.length ? parts.join("; ") : "nothing to fix";
746
+ }
747
+
748
+ /**
749
+ * A run that crashed or timed out never wrote its own summary. Replace the
750
+ * placeholder so status and the status bar don't show a run that never
751
+ * finished, or the previous run's needs_you list as current.
752
+ */
753
+ function finishAutofixPlaceholder(error) {
754
+ const last = readAutofixLast();
755
+ if (!last || !last.running) return;
756
+ try {
757
+ atomicWriteJson(autofixLastPath(), {
758
+ ...last,
759
+ running: false,
760
+ error,
761
+ counts: {},
762
+ needs_you: [],
763
+ });
764
+ } catch {
765
+ /* informational */
766
+ }
767
+ }
768
+
769
+ async function runAutofix() {
770
+ if (autofixInFlight || exiting) return;
771
+ const now = Date.now();
772
+ if (!autofixDue(now)) return;
773
+ // A client whose hook credentials were removed has nothing to repair with.
774
+ const childEnv = { ...captureChildEnv(), MEKO_CLEANUP_ENV_ONLY: "1" };
775
+ if (!childEnv.MEKO_MCP_URL) return;
776
+ const lockPath = acquireAutofixLock(now);
777
+ if (!lockPath) return;
778
+ autofixLockPath = lockPath;
779
+ autofixInFlight = true;
780
+ try {
781
+ // Another timer may have finished a run between the check and the lock.
782
+ if (!autofixDue(Date.now())) return;
783
+ // Record the attempt first: if the run crashes, the next one still
784
+ // waits a full interval instead of every timer retrying every minute.
785
+ const previous = readAutofixLast() || {};
786
+ try {
787
+ atomicWriteJson(autofixLastPath(), {
788
+ schema_version: 1,
789
+ ran_at: new Date().toISOString(),
790
+ mode: "auto",
791
+ running: true,
792
+ counts: {},
793
+ needs_you: Array.isArray(previous.needs_you) ? previous.needs_you : [],
794
+ });
795
+ } catch {
796
+ return;
797
+ }
798
+ await new Promise((resolve) => {
799
+ // Its own process group (execFile ignores `detached`), so stopping it
800
+ // also stops the capture.js child it is blocked on.
801
+ const child = spawn(
802
+ process.execPath,
803
+ [
804
+ cleanupScript,
805
+ "fix",
806
+ "--auto",
807
+ "--json",
808
+ "--max-exchanges",
809
+ String(AUTOFIX_MAX_EXCHANGES),
810
+ "--pace-ms",
811
+ "500",
812
+ ],
813
+ { env: childEnv, detached: true, stdio: ["ignore", "pipe", "ignore"] },
814
+ );
815
+ autofixChild = child;
816
+ let stdout = "";
817
+ child.stdout.on("data", (chunk) => {
818
+ if (stdout.length < 16 * 1024 * 1024) stdout += chunk;
819
+ });
820
+ let timedOut = false;
821
+ const timer = setTimeout(() => {
822
+ timedOut = true;
823
+ signalGroup(child.pid, "SIGTERM");
824
+ setTimeout(() => signalGroup(child.pid, "SIGKILL"), 3000).unref();
825
+ }, AUTOFIX_TIMEOUT_MS);
826
+ // Record the run in the lock, so the lock isn't taken as stale while
827
+ // the run is alive.
828
+ try {
829
+ atomicWriteJson(lockPath, { pid: process.pid, run_pid: child.pid, at: new Date(now).toISOString() });
830
+ } catch {
831
+ /* the lock still names this timer */
832
+ }
833
+ let finished = false;
834
+ const finish = (err) => {
835
+ if (finished) return;
836
+ finished = true;
837
+ clearTimeout(timer);
838
+ const failure = timedOut
839
+ ? new Error(`timed out after ${AUTOFIX_TIMEOUT_MS / 1000}s`)
840
+ : err;
841
+ // Anything left in the group (capture.js) after the CLI failed.
842
+ if (failure) signalGroup(child.pid, "SIGTERM");
843
+ const summary = summarizeAutofix(stdout, failure);
844
+ if (failure) finishAutofixPlaceholder(summary);
845
+ process.stderr.write(`[meko-timer] Automatic repair: ${summary}\n`);
846
+ resolve();
847
+ };
848
+ child.on("error", (err) => finish(err));
849
+ child.on("close", (code, signal) => {
850
+ if (code === 0 || code === 2) return finish(null);
851
+ const err = new Error(signal ? `stopped by ${signal}` : `exit ${code}`);
852
+ err.code = code;
853
+ finish(err);
854
+ });
855
+ });
856
+ } finally {
857
+ autofixInFlight = false;
858
+ const runPid = autofixChild ? autofixChild.pid : null;
859
+ autofixChild = null;
860
+ if (!exiting) {
861
+ // Wait for any capture.js left in the run's group before releasing.
862
+ for (let waited = 0; runPid && waited < 3000 && groupAlive(runPid); waited += 100) await sleep(100);
863
+ releaseAutofixLock(runPid);
864
+ }
865
+ }
866
+ }
867
+
503
868
  if (!acquirePidFile()) {
504
869
  process.exit(0);
505
870
  }
@@ -518,13 +883,34 @@ setInterval(() => {
518
883
  });
519
884
  }, INTERVAL_SECS * 1000);
520
885
 
886
+ // Automatic repair check, once a minute; the first one runs shortly after
887
+ // startup so a stuck machine is repaired without waiting a full interval.
888
+ setTimeout(() => {
889
+ runAutofix().catch((err) => {
890
+ process.stderr.write(`[meko-timer] Automatic repair failed: ${err.message}\n`);
891
+ });
892
+ }, AUTOFIX_FIRST_CHECK_SECS * 1000).unref();
893
+ setInterval(() => {
894
+ runAutofix().catch((err) => {
895
+ process.stderr.write(`[meko-timer] Automatic repair failed: ${err.message}\n`);
896
+ });
897
+ }, AUTOFIX_CHECK_SECS * 1000);
898
+
521
899
  // While backlog remains, also schedule a shorter recovery follow-up without
522
- // waiting for the full checkpoint interval (self-draining).
900
+ // waiting for the full checkpoint interval (self-draining). The same loop
901
+ // retries this daemon's own session once its next_retry_at passes.
523
902
  setInterval(() => {
524
- if (!workRemains || recoveryInFlight || tickInFlight) return;
903
+ if (exiting || recoveryInFlight || tickInFlight || ownRetryInFlight) return;
904
+ if (ownRetryDue(Date.now())) {
905
+ retryOwnSession().catch((err) => {
906
+ process.stderr.write(`[meko-timer] Own-session retry failed: ${err.message}\n`);
907
+ });
908
+ return;
909
+ }
910
+ if (!workRemains) return;
525
911
  runRecoveryCycle().catch((err) => {
526
912
  process.stderr.write(
527
913
  `[meko-timer] Follow-up recovery failed: ${err.message}\n`,
528
914
  );
529
915
  });
530
- }, Math.min(30, INTERVAL_SECS) * 1000);
916
+ }, Math.min(FOLLOWUP_SECS, INTERVAL_SECS) * 1000);
@@ -12,8 +12,17 @@ const DEFAULT_RECOVERY_MAX_TIMERS = 16;
12
12
  function captureLib() {
13
13
  return require("./capture");
14
14
  }
15
- function loadHookEnvironment(filePath) {
16
- const target = filePath || process.env.MEKO_OPENCODE_ENV_FILE || DEFAULT_ENV_PATH;
15
+ function hookEnvPath(filePath) {
16
+ return filePath || process.env.MEKO_OPENCODE_ENV_FILE || DEFAULT_ENV_PATH;
17
+ }
18
+ // Read the env file without touching process.env. The checkpoint timer calls
19
+ // this before each capture child so an edited URL or key takes effect.
20
+ // status is "loaded", "missing" (ENOENT), or "unreadable".
21
+ function readHookEnvironment(filePath) {
22
+ const target = hookEnvPath(filePath);
23
+ const set = {};
24
+ const unset = [];
25
+ let status = "loaded";
17
26
  try {
18
27
  const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
19
28
  for (const key of [
@@ -26,16 +35,25 @@ function loadHookEnvironment(filePath) {
26
35
  "MEKO_DATAPACK_NAME",
27
36
  ]) {
28
37
  if (typeof parsed[key] === "string" && parsed[key] !== "") {
29
- process.env[key] = parsed[key];
38
+ set[key] = parsed[key];
30
39
  } else if (key === "MEKO_API_KEY" && parsed[key] === "") {
31
- delete process.env[key];
40
+ unset.push(key);
32
41
  }
33
42
  }
34
43
  } catch (err) {
35
- if (!err || err.code !== "ENOENT") {
44
+ if (err && err.code === "ENOENT") {
45
+ status = "missing";
46
+ } else {
47
+ status = "unreadable";
36
48
  process.stderr.write(`[meko-opencode] Could not read hook environment: ${err.message}\n`);
37
49
  }
38
50
  }
51
+ return { target, status, set, unset };
52
+ }
53
+ function loadHookEnvironment(filePath) {
54
+ const { target, set, unset } = readHookEnvironment(filePath);
55
+ Object.assign(process.env, set);
56
+ for (const key of unset) delete process.env[key];
39
57
  process.env.MEKO_HOOK_CLIENT = "opencode";
40
58
  return target;
41
59
  }
@@ -412,7 +430,11 @@ function launchRecoveryTimer(journalPath) {
412
430
  detached: true,
413
431
  windowsHide: true,
414
432
  stdio: "ignore",
415
- env: { ...process.env, MEKO_HOOK_CLIENT: "opencode" },
433
+ env: {
434
+ ...process.env,
435
+ MEKO_HOOK_CLIENT: "opencode",
436
+ MEKO_OPENCODE_ENV_FILE: hookEnvPath(),
437
+ },
416
438
  });
417
439
  child.unref();
418
440
  }
@@ -602,6 +624,7 @@ async function main() {
602
624
  await handleStagedRequest(request);
603
625
  }
604
626
  module.exports = {
627
+ readHookEnvironment,
605
628
  loadHookEnvironment,
606
629
  eventPaths,
607
630
  appendCanonicalExchanges,
@@ -197,7 +197,23 @@ fi
197
197
  WATERMARK_DIR="$PIN_DIR"
198
198
  HEALTH_FILE="${WATERMARK_DIR}/capture-health.json"
199
199
  CAPTURE_SUFFIX=""
200
- if [[ -f "$HEALTH_FILE" ]]; then
200
+ # Hooks from 2.5.0 store a ready-made "statusline" text in the health cache
201
+ # (statuslineText() in capture-notices.js): needs you, paused, retrying with
202
+ # the next automatic repair, auto-fixed. An empty value means show nothing.
203
+ # Older caches fall back to the status/queued suffix below.
204
+ HAVE_STATUSLINE_FIELD=0
205
+ if [[ -f "$HEALTH_FILE" ]] && grep -qE '"statusline"[[:space:]]*:' "$HEALTH_FILE"; then
206
+ HAVE_STATUSLINE_FIELD=1
207
+ CAPTURE_SUFFIX="$(grep -oE '"statusline"[[:space:]]*:[[:space:]]*"[^"]*"' "$HEALTH_FILE" | sed -E 's/^"statusline"[[:space:]]*:[[:space:]]*"(.*)"$/\1/' | head -n 1)"
208
+ # The text can carry an expiry: after statusline_until (epoch seconds),
209
+ # show statusline_after instead ("auto-fixed N" fades, "auto-fix at"
210
+ # becomes "auto-fix soon").
211
+ UNTIL="$(grep -oE '"statusline_until"[[:space:]]*:[[:space:]]*[0-9]+' "$HEALTH_FILE" | sed -E 's/.*:[[:space:]]*([0-9]+)$/\1/' | head -n 1)"
212
+ if [[ -n "$UNTIL" && "$(date +%s)" -gt "$UNTIL" ]]; then
213
+ CAPTURE_SUFFIX="$(grep -oE '"statusline_after"[[:space:]]*:[[:space:]]*"[^"]*"' "$HEALTH_FILE" | sed -E 's/^"statusline_after"[[:space:]]*:[[:space:]]*"(.*)"$/\1/' | head -n 1)"
214
+ fi
215
+ fi
216
+ if [[ "$HAVE_STATUSLINE_FIELD" -eq 0 && -f "$HEALTH_FILE" ]]; then
201
217
  HEALTH_STATUS="$(grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]+"' "$HEALTH_FILE" | sed -E 's/.*"([^"]+)"$/\1/' | head -n 1)"
202
218
  QUEUED="$(grep -oE '"queued_exchanges"[[:space:]]*:[[:space:]]*[0-9]+' "$HEALTH_FILE" | sed -E 's/.*:[[:space:]]*([0-9]+)$/\1/' | head -n 1)"
203
219
  QUEUED="${QUEUED:-0}"
@@ -4,13 +4,13 @@ description: Behavioral guide for AI agents using Meko MCP tools. Triggers when
4
4
  license: MIT
5
5
  metadata:
6
6
  author: Meko
7
- version: "3.2.0"
7
+ version: "3.5.0"
8
8
  tags: mcp, tools, datapack, memory, conversation, rag, meko
9
9
  ---
10
10
 
11
11
  # Meko MCP Tools — Agent Behavioral Guide
12
12
 
13
- Meko is agent-native data infrastructure that enables continuous learning from context windows through collective memory and shared knowledge. This skill teaches you how to use Meko's 25 MCP tools available in production.
13
+ Meko is agent-native data infrastructure that enables continuous learning from context windows through collective memory and shared knowledge. This skill teaches you how to use Meko's 28 MCP tools available in production.
14
14
 
15
15
  **Read this first.** On Claude Code, conversation capture is automatic (SessionStart / PreCompact / SessionEnd hooks) and the Meko server extracts durable memories from the captured turns on its own. Facts the user states in conversation are saved for you — you do **not** proactively call `memory_add` for them. Your active jobs are: **recall** what's already known (`context_search` first, then `memory_search` or `knowledgebase_search` for a single source), and the **few** save cases automatic extraction cannot reach. Details below.
16
16
 
@@ -101,8 +101,9 @@ Recall first; save only what capture cannot reach; never re-store a just-stated
101
101
  | User asks "what do you know about X?" | `context_search`. One call returns separate memory, knowledge-base, and past-conversation buckets; report each. Confirm an empty memory or KB bucket with `memory_search` or `knowledgebase_search` before saying nothing is stored. |
102
102
  | User asks "what do we (as a team) know about X?" or about uploaded documents | `knowledgebase_search` (returns uploaded docs + promoted memories). |
103
103
  | User asks to share private memories | Follow the exact-ID `memory_promote` confirmation workflow below. |
104
- | User provides structured/tabular data (CSV, data dictionary) | Not natively supported via MCP. Point the user at the UI's Add Knowledge upload; do not `memory_add` row-by-row. |
105
- | User wants to add documents to the team's knowledge base | Point them at the Cloud UI flow — Datapack → Actions → **Add Knowledge** (PDF/TXT/MD/JSON/MP4, 5MB each, 10/batch). MCP ingestion tools are not available on Cloud. |
104
+ | User provides structured/tabular data (CSV, data dictionary) | If it's an actual file the user wants in the team's knowledge base, `knowledgebase_upload_document` accepts `.csv` directly (confirm first; see below). If it's pasted/described data with no file behind it, still don't `memory_add` it row-by-row — ask the user for it as a file, or point them at the UI's Add Knowledge upload. |
105
+ | User wants a file kept in Meko but didn't say how | KB (`knowledgebase_upload_document`) if its content should be searchable later — docs, manuals, policies, notes people will ask about. Artifact (`artifact_put`) to keep it exactly as-is and fetch it back whole — a generated report, a result CSV. If it is not clear whether the user needs semantic search or whole-file retrieval, ask. |
106
+ | User wants to add documents to the team's knowledge base | Uploading adds the file to the datapack's shared knowledge base, which every member can read, so ask first: name the file and the datapack, say the team will see it, and upload only after the user says yes. If you have the file's bytes — already on disk, or generated mid-conversation — and a shell, check that you can reach S3 first: run `curl -sS -o /dev/null -m 10 -w '%{http_code}' https://s3.amazonaws.com`; in Windows PowerShell, run `curl.exe` with `NUL` in place of `/dev/null`. A 2xx or 3xx status means yes (S3's root normally answers 307). `000`, a connection, DNS or proxy error, or any other status, such as a proxy's 403 or 407, means no. A pass shows outbound HTTPS to S3 works, not that the presigned bucket host does, so if the POST still fails, report it and stop. If it passes, call `knowledgebase_upload_document`. A sandboxed session may have the file but no outbound network; if the check fails, don't call it, since an unused URL holds storage quota for up to 15 minutes. Otherwise point the user at the Cloud UI flow — Datapack → Actions → **Add Knowledge** (PDF/TXT/MD/JSON/CSV/XML/HTML, 5MB Free / 50MB Standard / 60MB Pro+ each, 10/batch). KB *lifecycle* (index/source management) is still UI-only. |
106
107
 
107
108
  ### When to call memory_add
108
109
 
@@ -283,7 +284,7 @@ On the first Meko tool call in a fresh session (no prior Meko tool call since pr
283
284
 
284
285
  | File | What it covers |
285
286
  |------|---------------|
286
- | `tools-overview.md` | Complete catalog of all 25 tools available in production, with decision tree |
287
+ | `tools-overview.md` | Complete catalog of all 28 tools available in production, with decision tree |
287
288
  | `tools-cookbook.md` | Per-tool examples with correct parameters, responses, and error cases |
288
289
  | `tools-memory-vs-conversation.md` | When to use memory tools vs conversation tools |
289
290
  | `tools-datapack-workflow.md` | Datapack lifecycle and datapack_id routing |
@@ -6,7 +6,7 @@ description: Maps reference files to their topics for the meko-mcp-tools skill
6
6
  # Reference Sections
7
7
 
8
8
  ## Tool Selection & Catalog
9
- - [tools-overview.md](tools-overview.md) — Complete catalog of all 25 tools available in production, with a decision tree
9
+ - [tools-overview.md](tools-overview.md) — Complete catalog of all 28 tools available in production, with a decision tree
10
10
 
11
11
  ## Tool-Specific Guidance
12
12
  - [tools-memory-vs-conversation.md](tools-memory-vs-conversation.md) — Decision framework: memory_add vs conversation_create/conversation_add_message