@yugabytedb/meko-mcp 2.0.0 → 2.2.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 (65) hide show
  1. package/README.md +183 -33
  2. package/assets/hooks-handlers/codex-session-end.sh +13 -0
  3. package/assets/hooks-handlers/cursor-session-start.sh +19 -1
  4. package/assets/hooks-handlers/lib/capture-notices.js +321 -0
  5. package/assets/hooks-handlers/lib/capture-state.js +2079 -0
  6. package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
  7. package/assets/hooks-handlers/lib/capture.js +1653 -225
  8. package/assets/hooks-handlers/lib/checkpoint-timer.js +455 -50
  9. package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
  10. package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
  11. package/assets/hooks-handlers/session-end.sh +3 -24
  12. package/assets/hooks-handlers/session-start.sh +8 -1
  13. package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
  14. package/assets/scripts/meko-statusline.sh +41 -1
  15. package/assets/scripts/package-desktop-skill.sh +32 -1
  16. package/assets/skills/meko-mcp-tools/SKILL.md +90 -137
  17. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -5
  18. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +31 -28
  19. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
  20. package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +69 -49
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -5
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +29 -92
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +42 -40
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +18 -11
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +10 -9
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +51 -44
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +34 -25
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +63 -37
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +19 -22
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +42 -40
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
  39. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
  40. package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
  41. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  42. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
  43. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
  44. package/bin/create-meko-setup.mjs +93 -16
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +8 -4
  48. package/lib/clients/claude-desktop.mjs +133 -4
  49. package/lib/clients/codex.mjs +29 -11
  50. package/lib/clients/common.mjs +2 -0
  51. package/lib/clients/cursor.mjs +7 -2
  52. package/lib/clients/index.mjs +2 -0
  53. package/lib/clients/kiro.mjs +705 -0
  54. package/lib/config.mjs +90 -0
  55. package/lib/detect.mjs +7 -2
  56. package/lib/installer.mjs +1 -0
  57. package/lib/migrate/adapters/codex.mjs +490 -0
  58. package/lib/migrate/id.mjs +28 -3
  59. package/lib/migrate/orchestrator.mjs +34 -7
  60. package/lib/migrate.mjs +4 -2
  61. package/lib/prompt.mjs +17 -2
  62. package/lib/url-selection.mjs +55 -0
  63. package/package.json +4 -4
  64. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
  65. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +0 -71
package/lib/config.mjs CHANGED
@@ -96,6 +96,96 @@ export function getClaudeCompatSettingsPath(scope, projectRoot) {
96
96
  return getClaudeCodeSettingsPath();
97
97
  }
98
98
 
99
+ /**
100
+ * Root of Kiro's global config tree.
101
+ *
102
+ * Kiro documents $KIRO_HOME as the override every global surface resolves
103
+ * against (agents, skills, steering, settings, sessions), so honoring it here
104
+ * keeps all Kiro path helpers consistent with the IDE and `kiro-cli`.
105
+ * @returns {string}
106
+ */
107
+ function getKiroHome() {
108
+ return process.env.KIRO_HOME || join(homedir(), ".kiro");
109
+ }
110
+
111
+ /**
112
+ * Return path to Kiro's MCP config file.
113
+ *
114
+ * Kiro merges MCP scopes agent -> project -> global, and both the IDE and
115
+ * `kiro-cli` read the same files, so one pair of paths covers both surfaces.
116
+ * @param {"user"|"project"} scope
117
+ * @param {string} projectRoot
118
+ * @returns {string}
119
+ */
120
+ export function getKiroMcpPath(scope, projectRoot) {
121
+ if (scope === "project") {
122
+ return join(projectRoot, ".kiro", "settings", "mcp.json");
123
+ }
124
+ return join(getKiroHome(), "settings", "mcp.json");
125
+ }
126
+
127
+ /** Return the directory containing standalone Kiro hook definitions. */
128
+ export function getKiroHooksDir(scope, projectRoot) {
129
+ if (scope === "project") {
130
+ return join(projectRoot, ".kiro", "hooks");
131
+ }
132
+ return join(getKiroHome(), "hooks");
133
+ }
134
+
135
+ /** Return the installer-owned, legacy CLI 2.x managed-agent path. */
136
+ export function getKiroManagedAgentPath(scope, projectRoot) {
137
+ if (scope === "project") {
138
+ return join(projectRoot, ".kiro", "agents", "meko-default.json");
139
+ }
140
+ return join(getKiroHome(), "agents", "meko-default.json");
141
+ }
142
+
143
+ /**
144
+ * Return Kiro's CLI settings path for the selected installer scope.
145
+ * CLI 2.x stores dotted setting names as top-level JSON keys.
146
+ */
147
+ export function getKiroCliSettingsPath(scope, projectRoot) {
148
+ if (scope === "project") {
149
+ return join(projectRoot, ".kiro", "settings", "cli.json");
150
+ }
151
+ return join(getKiroHome(), "settings", "cli.json");
152
+ }
153
+
154
+ /**
155
+ * Return path to the Meko-owned Kiro steering file.
156
+ *
157
+ * Kiro reads every file in `steering/` on each interaction, which makes it the
158
+ * analogue of Codex's AGENTS.md: the place to state behavior the agent must
159
+ * follow. Because steering is a *directory*, we own `meko.md` outright instead
160
+ * of upserting a fenced block into a file the user also edits.
161
+ * @param {"user"|"project"} scope
162
+ * @param {string} projectRoot
163
+ * @returns {string}
164
+ */
165
+ export function getKiroSteeringPath(scope, projectRoot) {
166
+ if (scope === "project") {
167
+ return join(projectRoot, ".kiro", "steering", "meko.md");
168
+ }
169
+ return join(getKiroHome(), "steering", "meko.md");
170
+ }
171
+
172
+ /**
173
+ * Return path to Kiro's skills directory.
174
+ *
175
+ * Kiro implements the Agent Skills standard (SKILL.md + name/description
176
+ * frontmatter), which is the same format our skills already ship in, so they
177
+ * install by copy with no transformation.
178
+ * @param {"user"|"project"} scope
179
+ * @param {string} projectRoot
180
+ * @returns {string}
181
+ */
182
+ export function getKiroSkillsDir(scope, projectRoot) {
183
+ if (scope === "project") {
184
+ return join(projectRoot, ".kiro", "skills");
185
+ }
186
+ return join(getKiroHome(), "skills");
187
+ }
188
+
99
189
  /**
100
190
  * Return path to Codex's user-level config (TOML).
101
191
  * Honors $CODEX_HOME if set, else defaults to ~/.codex/config.toml.
package/lib/detect.mjs CHANGED
@@ -110,7 +110,7 @@ export function getClaudeCodeConfigPath() {
110
110
  * `scope` — "user" (default) or "project". Project scope looks under `projects[projectRoot].mcpServers`.
111
111
  * `projectRoot` — absolute path used as the key into `projects[...]` when `scope === "project"`.
112
112
  * Ignored for user scope. Must be supplied by the caller for project scope.
113
- * @returns {{ found: boolean, status: string }}
113
+ * @returns {{ found: boolean, status: string, url?: string }}
114
114
  * `found` - true if a matching MCP entry exists at the scope-appropriate location.
115
115
  * `status` - "Configured" when found, "" otherwise. (Kept as a string for
116
116
  * API compatibility with the prior implementation; callers use
@@ -129,5 +129,10 @@ export function detectExistingMeko(serverName = "meko", options = {}) {
129
129
  if (!entry) {
130
130
  return { found: false, status: "" };
131
131
  }
132
- return { found: true, status: "Configured" };
132
+ const url = typeof entry.url === "string" ? entry.url : undefined;
133
+ return {
134
+ found: true,
135
+ status: "Configured",
136
+ ...(url ? { url } : {}),
137
+ };
133
138
  }
package/lib/installer.mjs CHANGED
@@ -136,6 +136,7 @@ export async function install(options) {
136
136
  adapter.setHookEnvironment({
137
137
  url,
138
138
  apiKey,
139
+ name,
139
140
  scope,
140
141
  dryRun,
141
142
  verbose,
@@ -0,0 +1,490 @@
1
+ /**
2
+ * adapters/codex.mjs — Enumerate + parse Codex CLI/Desktop session rollouts.
3
+ *
4
+ * Codex writes one JSONL "rollout" file per session under:
5
+ * <CODEX_HOME|~/.codex>/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<uuid>.jsonl
6
+ *
7
+ * Each line is `{timestamp, type, payload}`. The shapes we consume:
8
+ * - type "session_meta": payload has `session_id` and `cwd` (the project
9
+ * root the session ran in — the source of truth for agent_id derivation).
10
+ * - type "event_msg", payload.type "user_message": the AUTHORITATIVE marker
11
+ * for a real user prompt. Codex also writes `response_item`/`message`
12
+ * role=user records for injected context (`<recommended_plugins>`,
13
+ * AGENTS.md, the Meko SessionStart block); those have NO mirrored
14
+ * user_message event, so we use the event stream to tell a real prompt
15
+ * from injected context.
16
+ * - type "response_item", payload.type "message", role "assistant": one
17
+ * slice of the assistant's reply. A single turn emits several — phase
18
+ * "commentary" updates then phase "final_answer" — so we accumulate ALL
19
+ * of them into one assistant side and only close the turn at the
20
+ * completion boundary (next user prompt or `event_msg` task_complete).
21
+ * - type "response_item", payload.type "reasoning" /
22
+ * "custom_tool_call" / "custom_tool_call_output": reasoning + tool
23
+ * activity, folded into the turn's `reasoning` (parity with the
24
+ * Claude Code / Cursor adapters).
25
+ *
26
+ * Completion-boundary discriminator (kept consistent with the live parser in
27
+ * PR #234): assistant `phase: "final_answer"` and/or the `event_msg`
28
+ * `payload.type: "task_complete"` record close a turn.
29
+ *
30
+ * Unknown line/block shapes are skipped, never fatal — Codex's on-disk
31
+ * schema drifts across CLI versions.
32
+ */
33
+
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+ import os from "node:os";
37
+ import { agentIdForCodex } from "../id.mjs";
38
+
39
+ // Cap for the first-record read. Codex embeds `base_instructions` in the
40
+ // session_meta line, which routinely exceeds 16 KiB; read generously so the
41
+ // full first record parses. 4 MiB is far above any real header while still
42
+ // bounding a pathological file.
43
+ const HEADER_READ_CAP = 4 * 1024 * 1024;
44
+
45
+ function homeDir() {
46
+ return process.env.HOME || os.homedir();
47
+ }
48
+
49
+ /**
50
+ * The `.codex` home. Honors `CODEX_HOME` (production sets it to relocate all
51
+ * Codex state); otherwise `<home>/.codex`. The injectable `opts.home` override
52
+ * is preserved for tests and only applies to the default location.
53
+ *
54
+ * @param {object} [opts]
55
+ * @param {string} [opts.home]
56
+ * @returns {string}
57
+ */
58
+ export function codexHome(opts = {}) {
59
+ const envHome = (process.env.CODEX_HOME || "").trim();
60
+ if (envHome) return envHome;
61
+ return path.join(opts.home ?? homeDir(), ".codex");
62
+ }
63
+
64
+ /**
65
+ * The sessions root Codex writes rollout files under.
66
+ *
67
+ * @param {object} [opts]
68
+ * @param {string} [opts.home]
69
+ * @returns {string}
70
+ */
71
+ export function sessionsRoot(opts = {}) {
72
+ return path.join(codexHome(opts), "sessions");
73
+ }
74
+
75
+ /**
76
+ * @typedef {Object} CodexSessionRef
77
+ * @property {string} sessionUuid Rollout filename basename (matches
78
+ * capture.js::extractSessionId, so migrator + live capture dedup to the
79
+ * same conversation container).
80
+ * @property {string} path Absolute path to the rollout .jsonl.
81
+ * @property {string} cwd Project root from session_meta.
82
+ * @property {string} agentId `codex:<repo-basename>`.
83
+ * @property {"codex"} source
84
+ * @property {number} mtimeMs
85
+ * @property {boolean} likelyInProgress
86
+ */
87
+
88
+ /**
89
+ * Recursively collect rollout-*.jsonl paths under the date-partitioned tree.
90
+ *
91
+ * @param {string} dir
92
+ * @returns {string[]}
93
+ */
94
+ function collectRolloutFiles(dir) {
95
+ let entries;
96
+ try {
97
+ entries = fs.readdirSync(dir, { withFileTypes: true });
98
+ } catch {
99
+ return [];
100
+ }
101
+ const out = [];
102
+ for (const e of entries) {
103
+ const full = path.join(dir, e.name);
104
+ if (e.isDirectory()) {
105
+ out.push(...collectRolloutFiles(full));
106
+ } else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) {
107
+ out.push(full);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * Read the COMPLETE first JSONL record (through the first newline) and, if it
115
+ * is the `session_meta` header, harvest `session_id` + `cwd` + `timestamp`.
116
+ * Reads in chunks up to HEADER_READ_CAP so a large `base_instructions` block
117
+ * (>16 KiB, common in current rollouts) still parses — a bounded prefix would
118
+ * truncate the line, fail to parse, and silently drop the session.
119
+ *
120
+ * @param {string} filePath
121
+ * @returns {{ sessionId: string|null, cwd: string|null, startedAt: string|null }}
122
+ */
123
+ export function peekSessionMeta(filePath) {
124
+ const empty = { sessionId: null, cwd: null, startedAt: null };
125
+ let fd;
126
+ try {
127
+ fd = fs.openSync(filePath, "r");
128
+ const chunkSize = 65536;
129
+ const chunk = Buffer.alloc(chunkSize);
130
+ let acc = "";
131
+ let offset = 0;
132
+ let newlineIdx = -1;
133
+ while (offset < HEADER_READ_CAP) {
134
+ const bytesRead = fs.readSync(fd, chunk, 0, chunkSize, offset);
135
+ if (bytesRead === 0) break;
136
+ acc += chunk.toString("utf-8", 0, bytesRead);
137
+ offset += bytesRead;
138
+ newlineIdx = acc.indexOf("\n");
139
+ if (newlineIdx !== -1) break;
140
+ }
141
+ const firstLine = (newlineIdx === -1 ? acc : acc.slice(0, newlineIdx)).trim();
142
+ if (!firstLine) return empty;
143
+ const obj = JSON.parse(firstLine);
144
+ if (obj && obj.type === "session_meta" && obj.payload && typeof obj.payload === "object") {
145
+ const p = obj.payload;
146
+ const str = (v) => (typeof v === "string" ? v : null);
147
+ return {
148
+ sessionId: str(p.session_id) || str(p.id),
149
+ cwd: str(p.cwd),
150
+ startedAt: str(p.timestamp) || str(obj.timestamp),
151
+ };
152
+ }
153
+ return empty;
154
+ } catch {
155
+ return empty;
156
+ } finally {
157
+ if (fd !== undefined) {
158
+ try { fs.closeSync(fd); } catch { /* FD already gone */ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The session identifier used for conversation dedup. Standardized on the
165
+ * rollout FILENAME BASENAME so it matches capture.js::extractSessionId
166
+ * (`path.basename(transcriptPath, ".jsonl")`) — live hook capture and the
167
+ * migrator then create/reconnect the SAME conversation container instead of
168
+ * two divergent ones for the same session.
169
+ *
170
+ * @param {string} rolloutPath
171
+ * @returns {string}
172
+ */
173
+ export function sessionUuidForRollout(rolloutPath) {
174
+ return path.basename(rolloutPath, ".jsonl");
175
+ }
176
+
177
+ /**
178
+ * @param {object} opts
179
+ * @param {"user"|"project"} opts.scope
180
+ * @param {string} [opts.projectCwd]
181
+ * @param {string} [opts.home]
182
+ * @returns {CodexSessionRef[]}
183
+ */
184
+ export function listSessions(opts) {
185
+ const root = sessionsRoot(opts);
186
+ if (!fs.existsSync(root)) return [];
187
+
188
+ const wantedCwd = opts.scope === "project" ? opts.projectCwd : null;
189
+
190
+ const out = [];
191
+ for (const full of collectRolloutFiles(root)) {
192
+ const meta = peekSessionMeta(full);
193
+ if (!meta.cwd) continue;
194
+ if (wantedCwd && meta.cwd !== wantedCwd) continue;
195
+ let stat;
196
+ try {
197
+ stat = fs.statSync(full);
198
+ } catch {
199
+ continue;
200
+ }
201
+ out.push({
202
+ sessionUuid: sessionUuidForRollout(full),
203
+ path: full,
204
+ cwd: meta.cwd,
205
+ agentId: agentIdForCodex(meta.cwd),
206
+ source: "codex",
207
+ mtimeMs: stat.mtimeMs,
208
+ likelyInProgress: stat.mtimeMs > Date.now() - 60_000,
209
+ });
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * Parse a rollout file: pair real user prompts with the full assistant reply.
216
+ *
217
+ * @param {string} sessionPath
218
+ * @returns {{
219
+ * sessionUuid: string,
220
+ * firstUserText: string,
221
+ * cwd: string|null,
222
+ * startedAt: string|null,
223
+ * exchanges: Array<{user_uuid: string, input: string, output: string, reasoning: string, timestamp: string}>,
224
+ * partial: boolean,
225
+ * }}
226
+ */
227
+ export function parseSession(sessionPath) {
228
+ const { records, realPrompts, hasUserMessageEvents } = collectRecords(sessionPath);
229
+
230
+ let cwd = null;
231
+ let startedAt = null;
232
+ let firstUserText = "";
233
+
234
+ // Turn assembly. A real user prompt opens a turn; every assistant slice and
235
+ // reasoning/tool record until the next real prompt (or a task_complete)
236
+ // belongs to it. `turnIdx` — the positional id fallback for id-less Codex
237
+ // user records — increments ONLY when a turn is emitted, so a turn that is
238
+ // held on one run gets the SAME id when it is finally emitted on a later run
239
+ // (all preceding emitted turns are identical), keeping seed dedup stable.
240
+ const exchanges = [];
241
+ let turnIdx = 0;
242
+ // current: { input, uuid, timestamp, lastTimestamp, outputs, reasoning,
243
+ // hasAssistant, sawPhase, sawFinalAnswer, boundaryClosed }
244
+ let current = null;
245
+
246
+ // A turn is safe to emit once it has reached its completion boundary — the
247
+ // SAME boundary the live parser in PR #234 uses:
248
+ // - a `task_complete` event closed it (boundaryClosed), OR
249
+ // - an assistant slice with phase `final_answer` arrived, OR
250
+ // - it is the legacy/simple shape: assistant content but no phase info at
251
+ // all (older rollouts don't tag phases) — complete as soon as any
252
+ // assistant message exists, like non-Codex turns.
253
+ // A turn still streaming commentary with no boundary, or with no assistant
254
+ // reply yet, is NOT complete and must be held (see the EOF flush below) so
255
+ // the whole turn is captured once on a later run rather than sent early and
256
+ // then permanently skipped by lastUuid resume when its final answer arrives.
257
+ const isComplete = (t) =>
258
+ t.boundaryClosed || t.sawFinalAnswer || (t.hasAssistant && !t.sawPhase);
259
+
260
+ // Emit `current` as an exchange. `allowIncomplete` is true only when a new
261
+ // real prompt supersedes it (the turn is definitively over — no more content
262
+ // will ever be appended to it) or a task_complete closed it; at EOF we pass
263
+ // false so a still-in-progress trailing turn is HELD, not emitted.
264
+ const flush = (allowIncomplete) => {
265
+ if (!current) return;
266
+ const t = current;
267
+ current = null;
268
+ if (!allowIncomplete && !isComplete(t)) return; // hold: do not emit or advance turnIdx
269
+ const output = t.outputs.join("\n\n");
270
+ const reasoning = t.reasoning.join("\n---\n");
271
+ if (t.input || output) {
272
+ exchanges.push({
273
+ user_uuid: t.uuid || `codex-${turnIdx}`,
274
+ input: t.input,
275
+ output,
276
+ reasoning,
277
+ timestamp: t.lastTimestamp || t.timestamp || "",
278
+ });
279
+ turnIdx++;
280
+ }
281
+ };
282
+
283
+ for (const rec of records) {
284
+ if (rec.kind === "meta") {
285
+ if (!cwd) cwd = rec.cwd;
286
+ if (!startedAt) startedAt = rec.timestamp;
287
+ continue;
288
+ }
289
+ if (!startedAt && rec.timestamp) startedAt = rec.timestamp;
290
+
291
+ if (rec.kind === "user") {
292
+ // Only real prompts open a turn. When user_message events exist they are
293
+ // authoritative: a role=user response_item whose text isn't among them is
294
+ // injected context (plugins/AGENTS.md/Meko block) and is dropped.
295
+ if (hasUserMessageEvents && !realPrompts.has(rec.text)) continue;
296
+ // A new real prompt means the previous turn is definitively over — emit
297
+ // it even if it never saw an explicit boundary (it won't get more).
298
+ flush(true);
299
+ if (!firstUserText && rec.text) firstUserText = rec.text.slice(0, 240);
300
+ current = {
301
+ input: rec.text,
302
+ uuid: rec.uuid,
303
+ timestamp: rec.timestamp,
304
+ lastTimestamp: rec.timestamp,
305
+ outputs: [],
306
+ reasoning: [],
307
+ hasAssistant: false,
308
+ sawPhase: false,
309
+ sawFinalAnswer: false,
310
+ boundaryClosed: false,
311
+ };
312
+ } else if (rec.kind === "assistant") {
313
+ if (!current) continue; // assistant with no preceding real prompt — skip
314
+ current.hasAssistant = true;
315
+ if (rec.phase) current.sawPhase = true;
316
+ if (rec.phase === "final_answer") current.sawFinalAnswer = true;
317
+ if (rec.text) current.outputs.push(rec.text);
318
+ if (rec.timestamp) current.lastTimestamp = rec.timestamp;
319
+ } else if (rec.kind === "reasoning" || rec.kind === "tool_call" || rec.kind === "tool_result") {
320
+ if (!current) continue;
321
+ if (rec.text) current.reasoning.push(rec.text);
322
+ if (rec.timestamp) current.lastTimestamp = rec.timestamp;
323
+ } else if (rec.kind === "task_complete") {
324
+ // Completion boundary: the current turn is finished — emit it.
325
+ if (current) {
326
+ current.boundaryClosed = true;
327
+ if (rec.timestamp) current.lastTimestamp = rec.timestamp;
328
+ }
329
+ flush(true);
330
+ }
331
+ }
332
+ // EOF: hold a still-incomplete trailing turn (no boundary / no assistant yet)
333
+ // so it is captured whole on a later run once its completion is appended.
334
+ flush(false);
335
+
336
+ return {
337
+ sessionUuid: sessionUuidForRollout(sessionPath),
338
+ firstUserText,
339
+ cwd,
340
+ startedAt,
341
+ exchanges,
342
+ // Codex keeps a session's rollout open across turns and writes a
343
+ // `task_complete` after EACH one, so there is no reliable end-of-session
344
+ // marker: an interactive session can sit idle for minutes (well past any
345
+ // mtime window) and then append another prompt. Finalizing from a quiet
346
+ // period or a trailing task_complete would mark the session done and make
347
+ // the orchestrator skip it forever, dropping every later turn. So Codex
348
+ // rollouts stay rescan-capable (`partial: true`) unconditionally; the
349
+ // orchestrator re-reads them each run, lastUuid resume skips already-sent
350
+ // turns, and seed dedup (<conv>:<user_uuid>) makes the re-scan a no-op when
351
+ // nothing new was appended. Combined with the incomplete-trailing-turn hold
352
+ // above, this guarantees any appended turn is eventually migrated exactly
353
+ // once.
354
+ partial: true,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Read + classify every JSONL record we care about, and collect the set of
360
+ * authoritative real-prompt texts from `event_msg`/`user_message`.
361
+ */
362
+ function collectRecords(filePath) {
363
+ let raw;
364
+ try {
365
+ raw = fs.readFileSync(filePath, "utf8");
366
+ } catch {
367
+ return { records: [], realPrompts: new Set(), hasUserMessageEvents: false };
368
+ }
369
+ const records = [];
370
+ const realPrompts = new Set();
371
+ let hasUserMessageEvents = false;
372
+
373
+ for (const line of raw.split("\n")) {
374
+ const trimmed = line.trim();
375
+ if (!trimmed) continue;
376
+ let obj;
377
+ try {
378
+ obj = JSON.parse(trimmed);
379
+ } catch {
380
+ continue;
381
+ }
382
+ const payload = obj.payload;
383
+ if (!payload || typeof payload !== "object") continue;
384
+ const ts = obj.timestamp || null;
385
+
386
+ if (obj.type === "session_meta") {
387
+ records.push({
388
+ kind: "meta",
389
+ cwd: typeof payload.cwd === "string" ? payload.cwd : null,
390
+ timestamp: ts || (typeof payload.timestamp === "string" ? payload.timestamp : null),
391
+ });
392
+ continue;
393
+ }
394
+
395
+ if (obj.type === "event_msg") {
396
+ if (payload.type === "user_message" && typeof payload.message === "string") {
397
+ hasUserMessageEvents = true;
398
+ realPrompts.add(payload.message);
399
+ } else if (payload.type === "task_complete") {
400
+ records.push({ kind: "task_complete", timestamp: ts });
401
+ }
402
+ continue;
403
+ }
404
+
405
+ if (obj.type !== "response_item") continue;
406
+
407
+ if (payload.type === "message") {
408
+ const role = payload.role;
409
+ if (role === "user") {
410
+ records.push({
411
+ kind: "user", uuid: payload.id || null, timestamp: ts,
412
+ text: extractTextBlocks(payload.content),
413
+ });
414
+ } else if (role === "assistant") {
415
+ records.push({
416
+ kind: "assistant",
417
+ timestamp: ts,
418
+ phase: payload.phase || null,
419
+ text: extractTextBlocks(payload.content),
420
+ });
421
+ }
422
+ // developer / system messages are Codex-internal — dropped.
423
+ continue;
424
+ }
425
+
426
+ if (payload.type === "reasoning") {
427
+ const text = extractReasoningSummary(payload.summary);
428
+ if (text) records.push({ kind: "reasoning", timestamp: ts, text: `THINKING:\n${text}` });
429
+ continue;
430
+ }
431
+
432
+ if (payload.type === "custom_tool_call") {
433
+ const name = typeof payload.name === "string" ? payload.name : "unknown";
434
+ const input = typeof payload.input === "string"
435
+ ? payload.input.slice(0, 500)
436
+ : (payload.input ? JSON.stringify(payload.input).slice(0, 500) : "");
437
+ records.push({ kind: "tool_call", timestamp: ts, text: `TOOL CALL: ${name}(${input})` });
438
+ continue;
439
+ }
440
+
441
+ if (payload.type === "custom_tool_call_output") {
442
+ const output = typeof payload.output === "string"
443
+ ? payload.output
444
+ : (payload.output != null ? JSON.stringify(payload.output) : "");
445
+ if (output) {
446
+ records.push({ kind: "tool_result", timestamp: ts, text: `TOOL RESULT:\n${output.slice(0, 3000)}` });
447
+ }
448
+ continue;
449
+ }
450
+ }
451
+
452
+ return { records, realPrompts, hasUserMessageEvents };
453
+ }
454
+
455
+ /**
456
+ * Codex message content blocks are `{type:"input_text"|"output_text"|"text",
457
+ * text}`. Concatenate the text; ignore non-text blocks.
458
+ */
459
+ function extractTextBlocks(content) {
460
+ if (typeof content === "string") return content;
461
+ if (!Array.isArray(content)) return "";
462
+ const parts = [];
463
+ for (const b of content) {
464
+ if (!b) continue;
465
+ if (typeof b === "string") { parts.push(b); continue; }
466
+ if (
467
+ (b.type === "input_text" || b.type === "output_text" || b.type === "text")
468
+ && typeof b.text === "string"
469
+ ) {
470
+ parts.push(b.text);
471
+ }
472
+ }
473
+ return parts.join("\n");
474
+ }
475
+
476
+ /**
477
+ * Codex reasoning items carry a `summary` array of `{type:"summary_text",
478
+ * text}` blocks. Concatenate their text.
479
+ */
480
+ function extractReasoningSummary(summary) {
481
+ if (typeof summary === "string") return summary;
482
+ if (!Array.isArray(summary)) return "";
483
+ const parts = [];
484
+ for (const b of summary) {
485
+ if (!b) continue;
486
+ if (typeof b === "string") { parts.push(b); continue; }
487
+ if (typeof b.text === "string") parts.push(b.text);
488
+ }
489
+ return parts.join("\n");
490
+ }
@@ -44,7 +44,7 @@ export const COMMON_BUCKET_AGENT_ID = "meko_agent";
44
44
 
45
45
  const AGENT_ID_MAX_LEN = 64;
46
46
  const KNOWN_LOOSE_CLIENTS = new Set(["claude-desktop", "claude_desktop"]);
47
- const KNOWN_CODING_CLIENTS = new Set(["claude_code", "cursor"]);
47
+ const KNOWN_CODING_CLIENTS = new Set(["claude_code", "cursor", "codex", "kiro"]);
48
48
 
49
49
  /**
50
50
  * Sanitize a project segment: lowercase, collapse non-`[a-z0-9-]` to `-`,
@@ -67,13 +67,13 @@ function sanitizeProjectSegment(segment) {
67
67
  * Contract:
68
68
  * - `envOverride` (typically `MEKO_AGENT_ID`) wins if non-empty.
69
69
  * - Loose clients (Claude Desktop) → bare `claude_desktop`; no schema.
70
- * - Coding clients (Claude Code, Cursor) → `<client>:<repo-basename>`.
70
+ * - Coding clients (Claude Code, Cursor, Codex, Kiro) → `<client>:<repo-basename>`.
71
71
  * Outside a repo (basename is empty/`.`/`/`) → bare client name to
72
72
  * keep coding-agent traffic out of the common bucket.
73
73
  * - Anything else → `meko_agent` (the common cross-project bucket).
74
74
  *
75
75
  * @param {object} opts
76
- * @param {string} [opts.client] e.g. "claude_code", "cursor", "claude-desktop"
76
+ * @param {string} [opts.client] e.g. "claude_code", "cursor", "codex", "kiro", "claude-desktop"
77
77
  * @param {string} [opts.cwd] Absolute working directory, for coding clients
78
78
  * @param {string} [opts.envOverride] Raw value of `MEKO_AGENT_ID`, if set
79
79
  * @returns {string}
@@ -133,6 +133,31 @@ export function agentIdForCursor(cwd) {
133
133
  });
134
134
  }
135
135
 
136
+ /**
137
+ * @param {string} [cwd] Absolute project cwd of the Codex session (from the
138
+ * rollout's `session_meta.payload.cwd`). Omit for user-scope writes
139
+ * (returns the common bucket).
140
+ * @returns {string}
141
+ */
142
+ export function agentIdForCodex(cwd) {
143
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
144
+ return deriveAgentId({
145
+ client: "codex",
146
+ cwd,
147
+ envOverride: process.env.MEKO_AGENT_ID,
148
+ });
149
+ }
150
+
151
+ /** Derive the project-scoped owner used by Kiro hook capture. */
152
+ export function agentIdForKiro(cwd) {
153
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
154
+ return deriveAgentId({
155
+ client: "kiro",
156
+ cwd,
157
+ envOverride: process.env.MEKO_AGENT_ID,
158
+ });
159
+ }
160
+
136
161
  /**
137
162
  * Deterministic seed for `conversation_add_message` dedup.
138
163
  * sha256(agentId + ":" + sessionUuid + ":" + messageUuid), truncated.