@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.
- package/README.md +183 -33
- package/assets/hooks-handlers/codex-session-end.sh +13 -0
- package/assets/hooks-handlers/cursor-session-start.sh +19 -1
- package/assets/hooks-handlers/lib/capture-notices.js +321 -0
- package/assets/hooks-handlers/lib/capture-state.js +2079 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
- package/assets/hooks-handlers/lib/capture.js +1653 -225
- package/assets/hooks-handlers/lib/checkpoint-timer.js +455 -50
- package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
- package/assets/hooks-handlers/session-end.sh +3 -24
- package/assets/hooks-handlers/session-start.sh +8 -1
- package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
- package/assets/scripts/meko-statusline.sh +41 -1
- package/assets/scripts/package-desktop-skill.sh +32 -1
- package/assets/skills/meko-mcp-tools/SKILL.md +90 -137
- package/assets/skills/meko-mcp-tools/references/_sections.md +3 -5
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +31 -28
- package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
- package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +69 -49
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -5
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +29 -92
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +42 -40
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +18 -11
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +10 -9
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +51 -44
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +34 -25
- package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +63 -37
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +19 -22
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +42 -40
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
- package/bin/create-meko-setup.mjs +93 -16
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-code.mjs +8 -4
- package/lib/clients/claude-desktop.mjs +133 -4
- package/lib/clients/codex.mjs +29 -11
- package/lib/clients/common.mjs +2 -0
- package/lib/clients/cursor.mjs +7 -2
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/config.mjs +90 -0
- package/lib/detect.mjs +7 -2
- package/lib/installer.mjs +1 -0
- package/lib/migrate/adapters/codex.mjs +490 -0
- package/lib/migrate/id.mjs +28 -3
- package/lib/migrate/orchestrator.mjs +34 -7
- package/lib/migrate.mjs +4 -2
- package/lib/prompt.mjs +17 -2
- package/lib/url-selection.mjs +55 -0
- package/package.json +4 -4
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
- 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
|
-
|
|
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
|
@@ -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
|
+
}
|
package/lib/migrate/id.mjs
CHANGED
|
@@ -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.
|