@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
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import { createHash } from "node:crypto";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
6
8
  import path from "node:path";
7
9
 
8
10
  /**
@@ -44,7 +46,7 @@ export const COMMON_BUCKET_AGENT_ID = "meko_agent";
44
46
 
45
47
  const AGENT_ID_MAX_LEN = 64;
46
48
  const KNOWN_LOOSE_CLIENTS = new Set(["claude-desktop", "claude_desktop"]);
47
- const KNOWN_CODING_CLIENTS = new Set(["claude_code", "cursor", "codex", "kiro"]);
49
+ const KNOWN_CODING_CLIENTS = new Set(["claude_code", "cursor", "codex", "kiro", "opencode"]);
48
50
 
49
51
  /**
50
52
  * Sanitize a project segment: lowercase, collapse non-`[a-z0-9-]` to `-`,
@@ -61,21 +63,125 @@ function sanitizeProjectSegment(segment) {
61
63
  return collapsed.slice(0, AGENT_ID_MAX_LEN);
62
64
  }
63
65
 
66
+ /**
67
+ * The user's home directory, read fresh so tests (and shells that re-point
68
+ * `$HOME`) see the current value rather than a module-load snapshot.
69
+ *
70
+ * @returns {string|null}
71
+ */
72
+ function homeBoundary() {
73
+ const fromEnv = (process.env.HOME || "").trim();
74
+ if (fromEnv) return path.resolve(fromEnv);
75
+ try {
76
+ const h = os.homedir();
77
+ return h ? path.resolve(h) : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Walk parents of `cwd` looking for a `.git` entry (directory for a normal
85
+ * checkout, file for a submodule or linked worktree). Returns the absolute
86
+ * repo-root path, or `null` if none is found.
87
+ *
88
+ * The walk stops at `$HOME`: a directory under home that is not itself in a
89
+ * repo must NOT inherit home's repo-ness. Users who keep dotfiles under git
90
+ * would otherwise see every scratch directory collapse into
91
+ * `claude_code:<username>` — the same cross-project merge MEKO-590 exists to
92
+ * prevent, one level up. `$HOME` itself is still a valid repo root, so a
93
+ * session run directly in a git-tracked home resolves normally.
94
+ *
95
+ * Sync + best-effort: any I/O error at a level just skips that level. The
96
+ * caller treats `null` as "outside a repo" and falls back to the bare client
97
+ * name, per the MEKO-590 contract.
98
+ *
99
+ * @param {string} [cwd]
100
+ * @returns {string|null}
101
+ */
102
+ export function resolveRepoRoot(cwd) {
103
+ if (!cwd || typeof cwd !== "string") return null;
104
+ let dir;
105
+ try {
106
+ dir = path.resolve(cwd);
107
+ } catch {
108
+ return null;
109
+ }
110
+ const start = dir;
111
+ const home = homeBoundary();
112
+ let prev = null;
113
+ while (dir && dir !== prev) {
114
+ // A directory below a git-tracked home must not inherit home's `.git`.
115
+ if (home && dir === home && dir !== start) return null;
116
+ try {
117
+ fs.statSync(path.join(dir, ".git"));
118
+ return dir;
119
+ } catch {
120
+ // no .git here; keep walking
121
+ }
122
+ // Check home itself for `.git`, then stop. This lets a git-tracked home
123
+ // resolve as a repo root without allowing a non-git home to inherit a
124
+ // `.git` from /Users, /home, or /.
125
+ if (home && dir === home) return null;
126
+ prev = dir;
127
+ dir = path.dirname(dir);
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Repo root for a cwd that is HISTORICAL rather than live — a path recorded
134
+ * in an old transcript, or reverse-derived from a `~/.claude/projects/<slug>`
135
+ * directory name. Such a path may no longer exist (repo deleted, moved, or
136
+ * renamed) while its transcripts survive, so a filesystem walk is not a
137
+ * usable oracle.
138
+ *
139
+ * Walks first, so a repo still on disk gets the correct root. When the walk
140
+ * finds nothing, treats the recorded path's own basename as the project
141
+ * segment instead of reporting "outside a repo". That keeps each historical
142
+ * project in its own bucket: collapsing them all into a bare `claude_code`
143
+ * would re-create, inside the migrator, exactly the cross-repo merge
144
+ * MEKO-590 fixes in the hook.
145
+ *
146
+ * @param {string} [cwd]
147
+ * @returns {string|null}
148
+ */
149
+ export function repoRootForHistoricalCwd(cwd) {
150
+ if (!cwd || typeof cwd !== "string") return null;
151
+ const live = resolveRepoRoot(cwd);
152
+ if (live) return live;
153
+ try {
154
+ return path.resolve(cwd);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
64
160
  /**
65
161
  * Derive an `agent_id` for a Meko write.
66
162
  *
67
- * Contract:
163
+ * Contract (MEKO-590):
68
164
  * - `envOverride` (typically `MEKO_AGENT_ID`) wins if non-empty.
69
165
  * - Loose clients (Claude Desktop) → bare `claude_desktop`; no schema.
70
- * - Coding clients (Claude Code, Cursor, Codex, Kiro) → `<client>:<repo-basename>`.
71
- * Outside a repo (basename is empty/`.`/`/`) → bare client name to
72
- * keep coding-agent traffic out of the common bucket.
166
+ * - Coding clients (Claude Code, Cursor, Codex, Kiro, OpenCode) →
167
+ * `<client>:<repo-basename>`, resolved by walking parents of `cwd`
168
+ * for a `.git` entry. The basename is lowercased and non-`[a-z0-9-]`
169
+ * runs collapse to `-`. Outside a repo (no `.git` in any ancestor,
170
+ * or basename sanitizes to empty) → bare client name, so sibling
171
+ * directories named `docs` / `webapp` / `src` across unrelated repos
172
+ * cannot silently collapse into one agent_id.
73
173
  * - Anything else → `meko_agent` (the common cross-project bucket).
74
174
  *
175
+ * The `repoRoot` opt lets callers bypass the filesystem walk when they
176
+ * already know the repo root (or, for Cursor's flat project-name model,
177
+ * want to treat a supplied name as the basename directly). Passing
178
+ * `repoRoot: null` explicitly is an assertion of "outside a repo".
179
+ *
75
180
  * @param {object} opts
76
181
  * @param {string} [opts.client] e.g. "claude_code", "cursor", "codex", "kiro", "claude-desktop"
77
182
  * @param {string} [opts.cwd] Absolute working directory, for coding clients
78
183
  * @param {string} [opts.envOverride] Raw value of `MEKO_AGENT_ID`, if set
184
+ * @param {string|null} [opts.repoRoot] Pre-resolved repo root; bypasses the FS walk when set
79
185
  * @returns {string}
80
186
  */
81
187
  export function deriveAgentId(opts = {}) {
@@ -86,13 +192,45 @@ export function deriveAgentId(opts = {}) {
86
192
  if (KNOWN_LOOSE_CLIENTS.has(client)) return "claude_desktop";
87
193
  if (!KNOWN_CODING_CLIENTS.has(client)) return COMMON_BUCKET_AGENT_ID;
88
194
 
89
- const rawBase = opts.cwd ? path.basename(opts.cwd) : "";
90
- if (!rawBase || rawBase === "." || rawBase === "/") return client;
91
- const project = sanitizeProjectSegment(rawBase);
195
+ const repoRoot = Object.prototype.hasOwnProperty.call(opts, "repoRoot")
196
+ ? opts.repoRoot
197
+ : resolveRepoRoot(opts.cwd);
198
+ if (!repoRoot) return client;
199
+ const project = sanitizeProjectSegment(path.basename(repoRoot));
92
200
  if (!project) return client;
93
201
  return `${client}:${project}`;
94
202
  }
95
203
 
204
+ /**
205
+ * Derive the coding-agent id used before MEKO-590: from the cwd's leaf
206
+ * basename without a repo-root walk. Persisted migrator state and datapack
207
+ * pin filenames may still use this value, so callers use it as a compatibility
208
+ * alias while new sessions use `deriveAgentId`.
209
+ *
210
+ * @param {object} opts
211
+ * @param {string} [opts.client]
212
+ * @param {string} [opts.cwd]
213
+ * @param {string} [opts.envOverride]
214
+ * @returns {string}
215
+ */
216
+ export function deriveLegacyAgentId(opts = {}) {
217
+ const override = (opts.envOverride ?? "").trim();
218
+ if (override) return override;
219
+
220
+ const client = (opts.client ?? "").trim();
221
+ if (KNOWN_LOOSE_CLIENTS.has(client)) return "claude_desktop";
222
+ if (!KNOWN_CODING_CLIENTS.has(client)) return COMMON_BUCKET_AGENT_ID;
223
+
224
+ let rawBase = "";
225
+ try {
226
+ rawBase = opts.cwd ? path.basename(opts.cwd) : "";
227
+ } catch {
228
+ return client;
229
+ }
230
+ const project = sanitizeProjectSegment(rawBase);
231
+ return project ? `${client}:${project}` : client;
232
+ }
233
+
96
234
  /**
97
235
  * Back-compat alias. New code should call `deriveAgentId(...)`.
98
236
  */
@@ -101,14 +239,23 @@ export const AGENT_ID = COMMON_BUCKET_AGENT_ID;
101
239
  /**
102
240
  * @param {string} [cwd] Absolute project cwd. Required for project-scope
103
241
  * writes; omit for user-scope writes (returns the common bucket).
242
+ *
243
+ * Pass `{ historical: true }` when `cwd` came from a recorded transcript or
244
+ * a reverse-slugged project directory rather than the live process. The repo
245
+ * root is then resolved via `repoRootForHistoricalCwd`, which falls back to
246
+ * the recorded basename when the path no longer exists on disk, so projects
247
+ * whose repos were deleted or moved keep distinct buckets.
248
+ * @param {object} [opts]
249
+ * @param {boolean} [opts.historical] Treat `cwd` as a recorded path.
104
250
  * @returns {string}
105
251
  */
106
- export function agentIdForClaude(cwd) {
252
+ export function agentIdForClaude(cwd, opts = {}) {
107
253
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
108
254
  return deriveAgentId({
109
255
  client: "claude_code",
110
256
  cwd,
111
257
  envOverride: process.env.MEKO_AGENT_ID,
258
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
112
259
  });
113
260
  }
114
261
 
@@ -122,13 +269,19 @@ export const CLAUDE_CODE_USER_AGENT_ID = COMMON_BUCKET_AGENT_ID;
122
269
  /**
123
270
  * @param {string} [cwd] Absolute project cwd (or basename — Cursor's projects
124
271
  * live under a flat `~/.cursor/projects/<name>/`). Omit for user-scope.
272
+ *
273
+ * Cursor's persisted transcripts store a flat project name rather than a
274
+ * full absolute cwd, so we treat the supplied value as the repo basename
275
+ * directly (via `repoRoot`) instead of walking the filesystem for a `.git`
276
+ * that isn't there.
277
+ *
125
278
  * @returns {string}
126
279
  */
127
280
  export function agentIdForCursor(cwd) {
128
281
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
129
282
  return deriveAgentId({
130
283
  client: "cursor",
131
- cwd,
284
+ repoRoot: cwd,
132
285
  envOverride: process.env.MEKO_AGENT_ID,
133
286
  });
134
287
  }
@@ -137,24 +290,53 @@ export function agentIdForCursor(cwd) {
137
290
  * @param {string} [cwd] Absolute project cwd of the Codex session (from the
138
291
  * rollout's `session_meta.payload.cwd`). Omit for user-scope writes
139
292
  * (returns the common bucket).
293
+ *
294
+ * Pass `{ historical: true }` when `cwd` came from a recorded transcript or
295
+ * a reverse-slugged project directory rather than the live process. The repo
296
+ * root is then resolved via `repoRootForHistoricalCwd`, which falls back to
297
+ * the recorded basename when the path no longer exists on disk, so projects
298
+ * whose repos were deleted or moved keep distinct buckets.
299
+ * @param {object} [opts]
300
+ * @param {boolean} [opts.historical] Treat `cwd` as a recorded path.
140
301
  * @returns {string}
141
302
  */
142
- export function agentIdForCodex(cwd) {
303
+ export function agentIdForCodex(cwd, opts = {}) {
143
304
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
144
305
  return deriveAgentId({
145
306
  client: "codex",
146
307
  cwd,
147
308
  envOverride: process.env.MEKO_AGENT_ID,
309
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
148
310
  });
149
311
  }
150
312
 
151
- /** Derive the project-scoped owner used by Kiro hook capture. */
152
- export function agentIdForKiro(cwd) {
313
+ /**
314
+ * Derive the project-scoped owner used by Kiro hook capture.
315
+ *
316
+ * Pass `{ historical: true }` when `cwd` came from a recorded transcript or
317
+ * a reverse-slugged project directory rather than the live process. The repo
318
+ * root is then resolved via `repoRootForHistoricalCwd`, which falls back to
319
+ * the recorded basename when the path no longer exists on disk, so projects
320
+ * whose repos were deleted or moved keep distinct buckets.
321
+ */
322
+ export function agentIdForKiro(cwd, opts = {}) {
153
323
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
154
324
  return deriveAgentId({
155
325
  client: "kiro",
156
326
  cwd,
157
327
  envOverride: process.env.MEKO_AGENT_ID,
328
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
329
+ });
330
+ }
331
+
332
+ /** Derive the project-scoped owner used by OpenCode capture and migration. */
333
+ export function agentIdForOpenCode(cwd, opts = {}) {
334
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
335
+ return deriveAgentId({
336
+ client: "opencode",
337
+ cwd,
338
+ envOverride: process.env.MEKO_AGENT_ID,
339
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
158
340
  });
159
341
  }
160
342
 
@@ -93,7 +93,31 @@ export class McpClient {
93
93
  lastErr = err;
94
94
  const code = err.statusCode;
95
95
  if (code === 401 || code === 403) {
96
- // Auth is fatal; surface immediately.
96
+ // Auth errors are never worth retrying: the same credential will
97
+ // be rejected again. Whether they're fatal depends on the caller.
98
+ //
99
+ // A fire-and-forget call is one whose loss the run can absorb
100
+ // (conversation_add_message, memory_add). A token that expires
101
+ // partway through a multi-hour migration used to kill the whole
102
+ // run from inside one such call; now the call is dropped, the skip
103
+ // is recorded, and the run finishes with the gap visible.
104
+ //
105
+ // The initial auth probe is `connect()`, which does not come
106
+ // through here, so a bad key still aborts before any work starts.
107
+ if (opts.fireAndForget) {
108
+ this.log.warn?.("mcp_skipped_auth", {
109
+ tool: name,
110
+ statusCode: code,
111
+ error: err.message,
112
+ });
113
+ return {
114
+ ok: false,
115
+ skipped: true,
116
+ authSkipped: true,
117
+ statusCode: code,
118
+ error: { message: err.message },
119
+ };
120
+ }
97
121
  throw err;
98
122
  }
99
123
  if (attempt < maxRetries) {
@@ -0,0 +1,132 @@
1
+ /** OpenCode session → canonical exchanges. Keep in sync with the CJS handler mirror. */
2
+
3
+ const SYNTHETIC_PART_TYPES = new Set(["compaction", "subtask", "continuation"]);
4
+ const EXCLUDED_PART_TYPES = new Set([
5
+ "tool", "tool-invocation", "tool-result", "tool_result", "reasoning",
6
+ "file", "attachment", "step", "snapshot", "patch",
7
+ ]);
8
+ const INTERRUPTED_MARKER =
9
+ "[Meko capture: assistant response was interrupted before completion.]";
10
+ export function normalizeOpenCodeSession(exportShape, opts = {}) {
11
+ const root = exportShape && typeof exportShape === "object" ? exportShape : {};
12
+ const info = root.info && typeof root.info === "object" ? root.info : {};
13
+ const rawMessages = Array.isArray(root.messages) ? root.messages : [];
14
+ const sessionId = firstString(info.id, info.sessionID, info.sessionId, root.sessionId) || "unknown";
15
+ const cwd = firstString(info.cwd, info.directory, info.projectPath, root.cwd) || "";
16
+ const parentSessionId = firstString(info.parentID, info.parentId, info.parentSessionId) || null;
17
+ const title = firstString(info.title, info.name) || sessionId;
18
+ const startedAt = firstTimestamp(info.time?.created, info.created, info.startedAt, info.time);
19
+ const visibleMessages = rawMessages.map(normalizeMessage).filter(Boolean).sort(compareMessageOrder);
20
+ const exchanges = [];
21
+ let pendingUser = null;
22
+ let firstUserText = "";
23
+ for (const msg of visibleMessages) {
24
+ if (msg.role === "user") {
25
+ if (pendingUser) exchanges.push(makeExchange(pendingUser, null, { forceInterrupted: true }));
26
+ pendingUser = msg;
27
+ if (!firstUserText && msg.text) firstUserText = msg.text.slice(0, 240);
28
+ } else if (msg.role === "assistant" && pendingUser) {
29
+ exchanges.push(makeExchange(pendingUser, msg, {}));
30
+ pendingUser = null;
31
+ }
32
+ }
33
+ let partial = Boolean(pendingUser);
34
+ if (pendingUser && (opts.terminalError || opts.interrupted)) {
35
+ exchanges.push(makeExchange(pendingUser, null, { ...opts, interrupted: true }));
36
+ pendingUser = null;
37
+ partial = false;
38
+ } else if (!pendingUser && (opts.terminalError || opts.interrupted) && exchanges.length) {
39
+ markInterrupted(exchanges[exchanges.length - 1]);
40
+ }
41
+ return {
42
+ sessionId, cwd, parentSessionId, title,
43
+ startedAt: startedAt || visibleMessages[0]?.timestamp || null,
44
+ firstUserText,
45
+ exchanges: exchanges.filter((ex) => ex.input || ex.output),
46
+ partial,
47
+ };
48
+ }
49
+ function normalizeMessage(raw) {
50
+ if (!raw || typeof raw !== "object") return null;
51
+ const info = raw.info && typeof raw.info === "object" ? raw.info : raw;
52
+ const role = firstString(info.role, raw.role)?.toLowerCase();
53
+ if (role !== "user" && role !== "assistant") return null;
54
+ const synthetic = Boolean(
55
+ info.synthetic || info.compaction || info.subtask || info.continuation ||
56
+ SYNTHETIC_PART_TYPES.has(String(info.type || "").toLowerCase()),
57
+ );
58
+ if (synthetic) return null;
59
+ const parts = Array.isArray(raw.parts) ? raw.parts : [];
60
+ const text = joinVisibleText(parts);
61
+ if (!text && role === "assistant") return null;
62
+ return {
63
+ role,
64
+ id: firstString(info.id, raw.id, raw.uuid) || "",
65
+ timestamp: firstTimestamp(info.time?.created, info.createdAt, info.timestamp, raw.timestamp) || "",
66
+ text,
67
+ order: Number.isFinite(Number(info.order)) ? Number(info.order) : null,
68
+ };
69
+ }
70
+ function joinVisibleText(parts) {
71
+ const chunks = [];
72
+ for (const part of parts) {
73
+ if (!part || typeof part !== "object") continue;
74
+ if (part.synthetic || part.ignored) continue;
75
+ const type = String(part.type || "text").toLowerCase();
76
+ if (EXCLUDED_PART_TYPES.has(type) || SYNTHETIC_PART_TYPES.has(type)) continue;
77
+ if (type !== "text") continue;
78
+ const text = typeof part.text === "string" ? part.text : "";
79
+ if (!text.trim()) continue;
80
+ chunks.push(text);
81
+ }
82
+ return chunks.join("\n");
83
+ }
84
+ function compareMessageOrder(a, b) {
85
+ const ao = a.order; const bo = b.order;
86
+ if (ao != null && bo != null && ao !== bo) return ao - bo;
87
+ if (a.timestamp && b.timestamp && a.timestamp !== b.timestamp) {
88
+ return a.timestamp < b.timestamp ? -1 : 1;
89
+ }
90
+ return String(a.id).localeCompare(String(b.id));
91
+ }
92
+ function makeExchange(userMsg, assistantMsg, opts) {
93
+ const interrupted = Boolean(opts.interrupted || (!assistantMsg && opts.forceInterrupted));
94
+ return {
95
+ userId: userMsg.id || `opencode-user-${hashish(userMsg.text)}`,
96
+ assistantId: assistantMsg?.id || null,
97
+ input: userMsg.text,
98
+ output: interrupted ? INTERRUPTED_MARKER : (assistantMsg?.text || ""),
99
+ timestamp: assistantMsg?.timestamp || userMsg.timestamp || "",
100
+ interrupted,
101
+ };
102
+ }
103
+ function markInterrupted(exchange) {
104
+ exchange.interrupted = true;
105
+ exchange.output = exchange.output
106
+ ? `${exchange.output}\n\n${INTERRUPTED_MARKER}`
107
+ : INTERRUPTED_MARKER;
108
+ }
109
+ function firstString(...values) {
110
+ for (const value of values) {
111
+ if (typeof value === "string" && value.trim()) return value.trim();
112
+ }
113
+ return "";
114
+ }
115
+ function firstTimestamp(...values) {
116
+ for (const value of values) {
117
+ if (typeof value === "string" && value.trim()) return value.trim();
118
+ if (typeof value === "number" && Number.isFinite(value)) {
119
+ const date = new Date(value);
120
+ if (!Number.isNaN(date.getTime())) return date.toISOString();
121
+ }
122
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
123
+ }
124
+ return "";
125
+ }
126
+ function hashish(text) {
127
+ let h = 0;
128
+ const s = String(text || "");
129
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
130
+ return Math.abs(h).toString(36);
131
+ }
132
+ export { INTERRUPTED_MARKER };