@yugabytedb/meko-mcp 2.2.0 → 2.3.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 (63) hide show
  1. package/README.md +106 -38
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +131 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +95 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1237 -137
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +85 -18
  15. package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  40. package/bin/create-meko-setup.mjs +70 -7
  41. package/lib/capture-cleanup.mjs +63 -0
  42. package/lib/clients/claude-code.mjs +1 -0
  43. package/lib/clients/codex.mjs +80 -35
  44. package/lib/clients/common.mjs +7 -0
  45. package/lib/clients/index.mjs +2 -0
  46. package/lib/clients/kiro.mjs +5 -5
  47. package/lib/clients/opencode.mjs +532 -0
  48. package/lib/config.mjs +68 -1
  49. package/lib/installer.mjs +18 -2
  50. package/lib/migrate/adapters/claude-code.mjs +11 -2
  51. package/lib/migrate/adapters/codex.mjs +9 -2
  52. package/lib/migrate/adapters/memory-md.mjs +27 -8
  53. package/lib/migrate/adapters/opencode.mjs +148 -0
  54. package/lib/migrate/claude-parser.mjs +153 -9
  55. package/lib/migrate/id.mjs +195 -13
  56. package/lib/migrate/mcp-client.mjs +25 -1
  57. package/lib/migrate/opencode-normalizer.mjs +132 -0
  58. package/lib/migrate/orchestrator.mjs +293 -77
  59. package/lib/migrate/progress.mjs +239 -5
  60. package/lib/migrate/registry.mjs +72 -0
  61. package/lib/migrate.mjs +96 -8
  62. package/package.json +1 -1
  63. package/scripts/bundle-assets.mjs +4 -0
@@ -31,11 +31,14 @@ const {
31
31
  timerPidPath,
32
32
  watermarkDir,
33
33
  scanStates,
34
+ scanCompleteForDropClamp,
34
35
  sortOldestFirst,
35
36
  computeAggregateHealth,
36
37
  writeHealthCache,
37
38
  importLegacyCaptureErrors,
38
39
  legacyErrorsSafeToRemove,
40
+ isResumableState,
41
+ readState,
39
42
  } = require("./capture-state");
40
43
 
41
44
  const INTERVAL_SECS = Math.max(1, checkpointInterval());
@@ -185,11 +188,27 @@ function runCaptureChild(mode, payload, timeoutMs) {
185
188
  });
186
189
  }
187
190
 
191
+ // MEKO-635: settle OpenCode pending turns past the 30-minute default.
192
+ function sweepOpenCodeSettles() {
193
+ try {
194
+ const opencode = require("./opencode-handler");
195
+ if (typeof opencode.sweepExpiredSettles === "function") {
196
+ opencode.sweepExpiredSettles();
197
+ }
198
+ } catch (err) {
199
+ process.stderr.write(
200
+ `[meko-timer] OpenCode settle sweep failed: ${err.message}\n`,
201
+ );
202
+ }
203
+ }
204
+
188
205
  function rebuildHealth() {
189
206
  try {
190
207
  const scan = scanStates({ includeBlocked: true });
191
208
  const health = computeAggregateHealth(scan);
192
- writeHealthCache(health);
209
+ writeHealthCache(health, {
210
+ clampDropped: scanCompleteForDropClamp(scan),
211
+ });
193
212
  } catch (err) {
194
213
  process.stderr.write(
195
214
  `[meko-timer] Health rebuild failed: ${err.message}\n`,
@@ -250,6 +269,10 @@ async function runRecoveryCycle() {
250
269
  const forcePersistentRetry = startupRecoveryPending;
251
270
  let moreWork = false;
252
271
  try {
272
+ // Finalize any resumable session whose settling window has expired before
273
+ // the scan snapshots eligibility. Otherwise recovery keeps draining a
274
+ // pending user (queued_exchanges === 1) whose assistant will never arrive.
275
+ sweepOpenCodeSettles();
253
276
  const scan = scanStates({
254
277
  includeBlocked: true,
255
278
  forcePersistentRetry,
@@ -292,11 +315,12 @@ async function runRecoveryCycle() {
292
315
  }
293
316
 
294
317
  const state = entry.state || {};
318
+ const resumable = isResumableState(state);
295
319
  const payload = {
296
320
  session_id: entry.session_id,
297
321
  transcript_path: state.transcript_path || undefined,
298
- lifecycle: state.lifecycle === "closing" ? "closing" : "closing",
299
- confirmed_dead: true,
322
+ lifecycle: resumable ? "active" : "closing",
323
+ confirmed_dead: !resumable,
300
324
  recorded_owner_only: true,
301
325
  max_exchanges: Math.min(budget, maxCaptureBatchSize()),
302
326
  force_retry: Boolean(
@@ -321,7 +345,10 @@ async function runRecoveryCycle() {
321
345
  const captured = Number((result && result.captured) || 0);
322
346
  budget -= captured;
323
347
  const queued = Number((result && result.queued_remaining) || 0);
324
- if (queued > 0 || (result && result.status === "retry_wait")) {
348
+ if (
349
+ (queued > 0 || (result && result.status === "retry_wait")) &&
350
+ !(resumable && captured === 0 && queued <= 1 && result.status !== "retry_wait")
351
+ ) {
325
352
  moreWork = true;
326
353
  }
327
354
  if (result && (result.status === "partial" || result.status === "retry_wait" || result.status === "blocked")) {
@@ -346,7 +373,10 @@ async function runRecoveryCycle() {
346
373
  e.eligibility &&
347
374
  e.session_id !== sessionId &&
348
375
  Number(e.eligibility.queued_exchanges || 0) > 0 &&
349
- e.eligibility.retry_due === false,
376
+ e.eligibility.retry_due === false &&
377
+ !(isResumableState(e.state) &&
378
+ Number(e.eligibility.queued_exchanges || 0) <= 1 &&
379
+ e.state && e.state.delivery === "pending"),
350
380
  );
351
381
  }
352
382
 
@@ -361,6 +391,14 @@ async function runRecoveryCycle() {
361
391
  return moreWork;
362
392
  }
363
393
 
394
+ // MEKO-635: for the daemon's own session, look up whether its captured intent
395
+ // declared a resumable pending-turn policy. If so, timer-idle and
396
+ // transcript-gone paths must drain without finalizing the session as dead.
397
+ function selfIsResumable() {
398
+ const read = readState(sessionId);
399
+ return read.ok ? isResumableState(read.state) : false;
400
+ }
401
+
364
402
  async function tick() {
365
403
  if (tickInFlight) return;
366
404
  tickInFlight = true;
@@ -382,14 +420,16 @@ async function tick() {
382
420
  await runRecoveryCycle();
383
421
  return;
384
422
  }
385
- // Transcript gone after grace — run one closing recovery for self, then exit.
423
+ // Transcript gone after grace. For a resumable session drain without
424
+ // finalizing so a later snapshot can complete its pending turn.
425
+ const resumable = selfIsResumable();
386
426
  await runCaptureChild(
387
427
  "drain",
388
428
  {
389
429
  session_id: sessionId,
390
430
  transcript_path: transcriptPath,
391
- lifecycle: "closing",
392
- confirmed_dead: true,
431
+ lifecycle: resumable ? "active" : "closing",
432
+ confirmed_dead: !resumable,
393
433
  recorded_owner_only: true,
394
434
  },
395
435
  CAPTURE_TIMEOUT_MS,
@@ -403,13 +443,14 @@ async function tick() {
403
443
  process.stderr.write(
404
444
  `[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; final drain then exit.\n`,
405
445
  );
446
+ const resumable = selfIsResumable();
406
447
  await runCaptureChild(
407
448
  "drain",
408
449
  {
409
450
  session_id: sessionId,
410
451
  transcript_path: transcriptPath,
411
- lifecycle: "closing",
412
- confirmed_dead: true,
452
+ lifecycle: resumable ? "active" : "closing",
453
+ confirmed_dead: !resumable,
413
454
  recorded_owner_only: true,
414
455
  },
415
456
  CAPTURE_TIMEOUT_MS,
@@ -0,0 +1,218 @@
1
+ /** Fsynced OpenCode journal primitives; Kiro keeps a separate implementation. */
2
+ "use strict";
3
+ const fs = require("fs");
4
+ const LOCK_RETRIES = 100;
5
+ const LOCK_RETRY_MS = 10;
6
+ function sleepSync(ms) {
7
+ const cell = new Int32Array(new SharedArrayBuffer(4));
8
+ Atomics.wait(cell, 0, 0, ms);
9
+ }
10
+ function pidAlive(pid) {
11
+ if (!Number.isInteger(pid) || pid <= 0) return false;
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+ function ensureEventDir(eventDir) {
20
+ fs.mkdirSync(eventDir, { recursive: true, mode: 0o700 });
21
+ try { fs.chmodSync(eventDir, 0o700); } catch { /* best effort */ }
22
+ }
23
+ function acquireEventLock(lockPath, eventDir) {
24
+ ensureEventDir(eventDir);
25
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
26
+ try {
27
+ const fd = fs.openSync(lockPath, "wx", 0o600);
28
+ try {
29
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, created_at: Date.now() }));
30
+ } finally {
31
+ fs.closeSync(fd);
32
+ }
33
+ return true;
34
+ } catch (err) {
35
+ if (!err || err.code !== "EEXIST") throw err;
36
+ let existing = null;
37
+ try { existing = JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch {
38
+ try {
39
+ const st = fs.statSync(lockPath);
40
+ if (Date.now() - st.mtimeMs < 2000) {
41
+ sleepSync(LOCK_RETRY_MS);
42
+ continue;
43
+ }
44
+ } catch { /* gone */ }
45
+ }
46
+ if (!existing || !pidAlive(Number(existing.pid))) {
47
+ try { fs.unlinkSync(lockPath); } catch { /* another process won */ }
48
+ continue;
49
+ }
50
+ sleepSync(LOCK_RETRY_MS);
51
+ }
52
+ }
53
+ throw new Error(`timed out acquiring event lock ${lockPath}`);
54
+ }
55
+ function releaseEventLock(lockPath) {
56
+ try {
57
+ const current = JSON.parse(fs.readFileSync(lockPath, "utf8"));
58
+ if (Number(current.pid) === process.pid) fs.unlinkSync(lockPath);
59
+ } catch { /* already gone */ }
60
+ }
61
+ function atomicWriteJson(filePath, value, eventDir) {
62
+ ensureEventDir(eventDir);
63
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
64
+ const fd = fs.openSync(tmp, "w", 0o600);
65
+ try {
66
+ fs.writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
67
+ fs.fsyncSync(fd);
68
+ } finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ fs.renameSync(tmp, filePath);
72
+ try { fs.chmodSync(filePath, 0o600); } catch { /* best effort */ }
73
+ }
74
+ function appendJournal(journalPath, record, eventDir) {
75
+ ensureEventDir(eventDir);
76
+ let separator = "";
77
+ try {
78
+ const contents = fs.readFileSync(journalPath);
79
+ if (contents.length > 0 && contents[contents.length - 1] !== 0x0a) {
80
+ separator = "\n";
81
+ }
82
+ } catch (err) {
83
+ if (!err || err.code !== "ENOENT") throw err;
84
+ }
85
+ const data = Buffer.from(`${separator}${JSON.stringify(record)}\n`, "utf8");
86
+ const fd = fs.openSync(journalPath, "a", 0o600);
87
+ try {
88
+ let offset = 0;
89
+ while (offset < data.length) {
90
+ const written = fs.writeSync(fd, data, offset, data.length - offset, null);
91
+ if (!Number.isInteger(written) || written <= 0) {
92
+ throw new Error(`could not append complete journal record to ${journalPath}`);
93
+ }
94
+ offset += written;
95
+ }
96
+ fs.fsyncSync(fd);
97
+ } finally {
98
+ fs.closeSync(fd);
99
+ }
100
+ try { fs.chmodSync(journalPath, 0o600); } catch { /* best effort */ }
101
+ }
102
+ function journalEmpty(journalPath) {
103
+ try { return fs.statSync(journalPath).size === 0; } catch { return true; }
104
+ }
105
+ function emptyEventState(sessionId) {
106
+ return {
107
+ schema_version: 1,
108
+ session_id: sessionId,
109
+ turn_index: 0,
110
+ pending: null,
111
+ seen_source_ids: [],
112
+ updated_at: new Date().toISOString(),
113
+ };
114
+ }
115
+ function rebuildEventState(journalPath, sessionId) {
116
+ const rebuilt = emptyEventState(sessionId);
117
+ let contents;
118
+ try {
119
+ contents = fs.readFileSync(journalPath, "utf8");
120
+ } catch {
121
+ return rebuilt;
122
+ }
123
+ for (const line of contents.split("\n")) {
124
+ if (!line.trim()) continue;
125
+ let record;
126
+ try {
127
+ record = JSON.parse(line);
128
+ } catch {
129
+ continue;
130
+ }
131
+ if (!record || record.session_id !== sessionId) continue;
132
+ if (record.type === "user") {
133
+ const content = record.message && record.message.content;
134
+ const text = Array.isArray(content) && content[0] && typeof content[0].text === "string"
135
+ ? content[0].text
136
+ : "";
137
+ const uuid = String(record.uuid || "");
138
+ rebuilt.turn_index += 1;
139
+ const mekoObservedMs =
140
+ record.meko && Number.isFinite(record.meko.observed_at_ms)
141
+ ? record.meko.observed_at_ms
142
+ : null;
143
+ const firstObservedAtMs = mekoObservedMs;
144
+ rebuilt.pending = {
145
+ user_uuid: uuid,
146
+ source_message_id: record.source_message_id || null,
147
+ turn_index: rebuilt.turn_index,
148
+ prompt_preview: text.slice(0, 80),
149
+ first_observed_at_ms: firstObservedAtMs,
150
+ };
151
+ if (record.source_message_id) rebuilt.seen_source_ids.push(record.source_message_id);
152
+ // Alias `${userId}:interrupted` so a later original id is already seen.
153
+ const legacySuffix = ":interrupted";
154
+ if (uuid.endsWith(legacySuffix)) {
155
+ const base = uuid.slice(0, -legacySuffix.length);
156
+ if (base) rebuilt.seen_source_ids.push(base);
157
+ }
158
+ } else if (record.type === "assistant" && rebuilt.pending) {
159
+ if (record.source_message_id) rebuilt.seen_source_ids.push(record.source_message_id);
160
+ rebuilt.pending = null;
161
+ }
162
+ }
163
+ return rebuilt;
164
+ }
165
+ function readEventState(statePath, journalPath, sessionId, eventDir) {
166
+ let persisted = null;
167
+ try {
168
+ const value = JSON.parse(fs.readFileSync(statePath, "utf8"));
169
+ if (value && value.schema_version === 1 && value.session_id === sessionId) {
170
+ persisted = value;
171
+ }
172
+ } catch { /* fresh or corrupt rebuildable sidecar */ }
173
+ const rebuilt = rebuildEventState(journalPath, sessionId);
174
+ // MEKO-635: prefer the persisted pending's first_observed_at_ms over the
175
+ // rebuild value from record.meko.observed_at_ms. Fall back to rebuild when
176
+ // the sidecar is missing or names a different pending user.
177
+ let pending = rebuilt.pending;
178
+ const persistedPending = persisted && persisted.pending;
179
+ if (
180
+ pending &&
181
+ persistedPending &&
182
+ persistedPending.user_uuid === pending.user_uuid
183
+ ) {
184
+ const persistedMs = persistedPending.first_observed_at_ms;
185
+ pending = {
186
+ ...pending,
187
+ first_observed_at_ms: Number.isFinite(persistedMs)
188
+ ? persistedMs
189
+ : pending.first_observed_at_ms,
190
+ };
191
+ }
192
+ return {
193
+ ...(persisted || emptyEventState(sessionId)),
194
+ turn_index: rebuilt.turn_index,
195
+ pending,
196
+ seen_source_ids: rebuilt.seen_source_ids,
197
+ tentative_observation:
198
+ pending &&
199
+ persisted &&
200
+ persisted.pending &&
201
+ persisted.pending.user_uuid === pending.user_uuid
202
+ ? persisted.tentative_observation || null
203
+ : null,
204
+ };
205
+ }
206
+ module.exports = {
207
+ LOCK_RETRIES,
208
+ LOCK_RETRY_MS,
209
+ ensureEventDir,
210
+ acquireEventLock,
211
+ releaseEventLock,
212
+ atomicWriteJson,
213
+ appendJournal,
214
+ journalEmpty,
215
+ emptyEventState,
216
+ rebuildEventState,
217
+ readEventState,
218
+ };
@@ -535,20 +535,42 @@ function journalEvent(event) {
535
535
  }
536
536
 
537
537
  function ensureCaptureIntent(event, journalPath) {
538
- const agentId = deriveAgentId({
538
+ const envOverride = (process.env.MEKO_AGENT_ID || "").trim();
539
+ const derivedAgentId = deriveAgentId({
539
540
  client: "kiro",
540
541
  cwd: event.cwd,
541
- envOverride: process.env.MEKO_AGENT_ID,
542
+ envOverride,
542
543
  });
544
+ const existing = captureState.readState(event.sessionId);
545
+ const existingAgentId = existing.ok && existing.state
546
+ ? String(existing.state.agent_id || "").trim()
547
+ : "";
548
+ const existingSource = existing.ok && existing.state
549
+ ? String(existing.state.agent_id_source || "").trim()
550
+ : "";
551
+
552
+ // Existing Kiro sessions keep their persisted owner. Changing it in place
553
+ // would orphan an already-created conversation and reset the capture intent.
554
+ // A live explicit override still wins, and stale pre-MEKO-385 client ids are
555
+ // allowed to upgrade to Kiro.
556
+ const preserveExisting = !envOverride && existingAgentId && (
557
+ existingSource === "explicit"
558
+ || existingAgentId === "kiro"
559
+ || existingAgentId.startsWith("kiro:")
560
+ );
561
+ const agentId = preserveExisting ? existingAgentId : derivedAgentId;
562
+ const agentIdSource = preserveExisting
563
+ ? (existingSource || "derived")
564
+ : (envOverride ? "explicit" : "derived");
543
565
  const intent = captureState.writeSessionIntent(event.sessionId, {
544
566
  client: "kiro",
545
567
  agent_id: agentId,
546
- agent_id_source: (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived",
568
+ agent_id_source: agentIdSource,
547
569
  transcript_path: journalPath,
548
570
  datapack_id: process.env.MEKO_DATAPACK_ID || null,
549
571
  datapack_name: process.env.MEKO_DATAPACK_NAME || null,
550
572
  });
551
- return { ...intent, agentId };
573
+ return { ...intent, agentId, derivedAgentId };
552
574
  }
553
575
 
554
576
  function launchCapture(mode, payload) {