@yugabytedb/meko-mcp 2.1.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.
- package/README.md +233 -40
- package/assets/hooks-handlers/codex-session-end.sh +4 -69
- package/assets/hooks-handlers/cursor-session-start.sh +19 -1
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
- package/assets/hooks-handlers/lib/capture-notices.js +438 -0
- package/assets/hooks-handlers/lib/capture-state.js +2173 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
- package/assets/hooks-handlers/lib/capture.js +2782 -403
- package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
- package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
- package/assets/hooks-handlers/session-end.sh +3 -34
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +123 -16
- package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
- package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +15 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
- package/bin/create-meko-setup.mjs +113 -12
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/claude-desktop.mjs +120 -5
- package/lib/clients/codex.mjs +85 -37
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +4 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/clients/opencode.mjs +532 -0
- package/lib/config.mjs +158 -1
- package/lib/installer.mjs +18 -1
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +497 -0
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/id.mjs +217 -10
- package/lib/migrate/mcp-client.mjs +25 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +299 -56
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +100 -10
- package/lib/prompt.mjs +16 -1
- package/package.json +4 -4
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { agentIdForOpenCode } from "../id.mjs";
|
|
4
|
+
import { normalizeOpenCodeSession } from "../opencode-normalizer.mjs";
|
|
5
|
+
function defaultRunExport(sessionId, opts = {}) {
|
|
6
|
+
const runner = opts.runExport ?? defaultRunExportCommand;
|
|
7
|
+
return runner(sessionId, opts);
|
|
8
|
+
}
|
|
9
|
+
function defaultRunExportCommand(sessionId, opts = {}) {
|
|
10
|
+
const bin = opts.opencodeBin ?? probeOpenCode()?.path ?? "opencode";
|
|
11
|
+
const result = spawnSync(bin, ["export", sessionId], {
|
|
12
|
+
encoding: "utf8",
|
|
13
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
14
|
+
timeout: 10_000,
|
|
15
|
+
});
|
|
16
|
+
if (result.error) throw result.error;
|
|
17
|
+
if (result.status !== 0) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`opencode export ${sessionId} failed (${result.status}): ${(result.stderr || "").trim()}`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return parseJsonStdout(result.stdout);
|
|
23
|
+
}
|
|
24
|
+
function defaultRunList(opts = {}) {
|
|
25
|
+
const runner = opts.runList ?? defaultRunListCommand;
|
|
26
|
+
return runner(opts);
|
|
27
|
+
}
|
|
28
|
+
function defaultRunListCommand(opts = {}) {
|
|
29
|
+
// OpenCode 1.18.x exposes roots only here; child sessions are not
|
|
30
|
+
// enumerable through the supported CLI.
|
|
31
|
+
const bin = opts.opencodeBin ?? probeOpenCode()?.path ?? "opencode";
|
|
32
|
+
const result = spawnSync(bin, ["session", "list", "--format", "json"], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
35
|
+
timeout: 10_000,
|
|
36
|
+
});
|
|
37
|
+
if (result.error) throw result.error;
|
|
38
|
+
if (result.status !== 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`opencode session list failed (${result.status}): ${(result.stderr || "").trim()}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return parseJsonStdout(result.stdout);
|
|
44
|
+
}
|
|
45
|
+
function parseJsonStdout(stdout) {
|
|
46
|
+
const trimmed = String(stdout || "").trim();
|
|
47
|
+
if (!trimmed) return [];
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(trimmed);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error(`invalid OpenCode JSON output: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function probeOpenCode(opts = {}) {
|
|
55
|
+
const probe = opts.probe ?? opts.probeOpenCode ?? defaultProbeOpenCode;
|
|
56
|
+
return probe();
|
|
57
|
+
}
|
|
58
|
+
export function isSupportedOpenCodeVersion(version) {
|
|
59
|
+
const match = String(version || "").match(/(\d+)\.(\d+)\.(\d+)/);
|
|
60
|
+
return Boolean(match && Number(match[1]) === 1 && Number(match[2]) === 18);
|
|
61
|
+
}
|
|
62
|
+
function defaultProbeOpenCode() {
|
|
63
|
+
try {
|
|
64
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
65
|
+
const pathResult = execFileSync(which, ["opencode"], { encoding: "utf8", timeout: 10_000 }).trim().split(/\r?\n/)[0];
|
|
66
|
+
const version = execFileSync(pathResult, ["--version"], { encoding: "utf8", timeout: 10_000 }).trim();
|
|
67
|
+
return { found: true, path: pathResult, version };
|
|
68
|
+
} catch {
|
|
69
|
+
return { found: false, path: "", version: "" };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function sessionScopeMatches(sessionDir, opts) {
|
|
73
|
+
if (opts.scope !== "project") return true;
|
|
74
|
+
return path.resolve(sessionDir) === path.resolve(opts.projectCwd || process.cwd());
|
|
75
|
+
}
|
|
76
|
+
function firstString(...values) {
|
|
77
|
+
for (const value of values) {
|
|
78
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
export async function listSessions(opts = {}) {
|
|
83
|
+
const installation = probeOpenCode(opts);
|
|
84
|
+
if (!installation?.found) return [];
|
|
85
|
+
if (!isSupportedOpenCodeVersion(installation.version)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const listed = await Promise.resolve(defaultRunList(opts));
|
|
89
|
+
const rows = Array.isArray(listed) ? listed : (listed?.sessions || listed?.data);
|
|
90
|
+
if (!Array.isArray(rows)) {
|
|
91
|
+
throw new Error("invalid OpenCode session list shape: expected an array");
|
|
92
|
+
}
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
if (!row || typeof row !== "object") continue;
|
|
96
|
+
const sessionId = firstString(row.id, row.sessionID, row.sessionId);
|
|
97
|
+
if (!sessionId) continue;
|
|
98
|
+
const cwd = firstString(row.directory, row.cwd, row.projectPath);
|
|
99
|
+
if (!cwd || !sessionScopeMatches(cwd, opts)) continue;
|
|
100
|
+
const mtimeMs = timestampMs(row.updated, row.updatedAt, row.time?.updated);
|
|
101
|
+
out.push({
|
|
102
|
+
sessionUuid: sessionId,
|
|
103
|
+
path: sessionId,
|
|
104
|
+
cwd,
|
|
105
|
+
agentId: agentIdForOpenCode(cwd, { historical: true }),
|
|
106
|
+
mtimeMs,
|
|
107
|
+
likelyInProgress: mtimeMs > Date.now() - 60_000,
|
|
108
|
+
parentSessionId: firstString(row.parentID, row.parentId) || null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function timestampMs(...values) {
|
|
114
|
+
for (const value of values) {
|
|
115
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
116
|
+
if (typeof value === "string" && value.trim()) {
|
|
117
|
+
const parsed = Date.parse(value);
|
|
118
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
export async function parseSession(ref, opts = {}) {
|
|
124
|
+
let exportShape = ref.exportData;
|
|
125
|
+
if (!exportShape) {
|
|
126
|
+
exportShape = await Promise.resolve(defaultRunExport(ref.sessionUuid || ref.path, opts));
|
|
127
|
+
}
|
|
128
|
+
const normalized = normalizeOpenCodeSession(exportShape, {
|
|
129
|
+
terminalError: Boolean(ref.terminalError),
|
|
130
|
+
interrupted: Boolean(ref.interrupted),
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
sessionUuid: normalized.sessionId,
|
|
134
|
+
firstUserText: normalized.firstUserText,
|
|
135
|
+
startedAt: normalized.startedAt,
|
|
136
|
+
exchanges: normalized.exchanges.map((ex) => ({
|
|
137
|
+
user_uuid: ex.userId,
|
|
138
|
+
input: ex.input,
|
|
139
|
+
output: ex.output,
|
|
140
|
+
reasoning: "",
|
|
141
|
+
timestamp: ex.timestamp,
|
|
142
|
+
interrupted: ex.interrupted,
|
|
143
|
+
})),
|
|
144
|
+
partial: Boolean(normalized.partial),
|
|
145
|
+
parentSessionId: normalized.parentSessionId,
|
|
146
|
+
cwd: normalized.cwd,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* - no CLI / side effects; only exports
|
|
6
6
|
* - streaming via readline so sessions larger than available RAM
|
|
7
7
|
* don't need to be loaded whole
|
|
8
|
-
* - filters isSidechain /
|
|
9
|
-
*
|
|
8
|
+
* - filters isSidechain / attachment-type entries up front, and drops
|
|
9
|
+
* `isMeta` entries other than skill context and subagent hand-backs
|
|
10
|
+
* - folds skill bodies, task notifications and subagent hand-backs into
|
|
11
|
+
* the turn they belong to, with the same rules as capture.js
|
|
12
|
+
* (MEKO-710). The whole session is parsed at once, so there is no parse
|
|
13
|
+
* window; a session still being written holds its trailing turn.
|
|
10
14
|
*
|
|
11
15
|
* Kept deliberately independent of `skills/hooks-handlers/lib/capture.js`:
|
|
12
16
|
* installer packages ship without that sibling directory, and mixing
|
|
@@ -15,6 +19,7 @@
|
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
18
23
|
import readline from "node:readline";
|
|
19
24
|
|
|
20
25
|
export const MAX_THINKING_LEN = Number(process.env.MEKO_MAX_THINKING_LEN ?? 2000);
|
|
@@ -24,6 +29,8 @@ export const MAX_TOOL_RESULT_LEN = Number(process.env.MEKO_MAX_TOOL_RESULT_LEN ?
|
|
|
24
29
|
const SKIP_INPUT_PATTERN =
|
|
25
30
|
/^<(system-reminder|command-|local-command|available-deferred)/;
|
|
26
31
|
|
|
32
|
+
const SKILL_BODY_PATTERN = /^Base directory for this skill: *(\S+)/;
|
|
33
|
+
|
|
27
34
|
export function truncate(str, max) {
|
|
28
35
|
if (!str || str.length <= max) return str;
|
|
29
36
|
return str.slice(0, max) + "... [truncated]";
|
|
@@ -104,6 +111,84 @@ export function extractToolResultContent(msg) {
|
|
|
104
111
|
return "TOOL RESULT:\n" + parts.join("\n---\n");
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Render a Claude Code skill or prompt command entry as the command the user
|
|
116
|
+
* typed (`/commit fix typo`). Only the `<command-message>`-led shape loads a
|
|
117
|
+
* skill; built-in commands lead with `<command-name>` and return "".
|
|
118
|
+
* Mirrors skillCommandInput in capture.js.
|
|
119
|
+
*/
|
|
120
|
+
export function skillCommandInput(text) {
|
|
121
|
+
if (typeof text !== "string" || !/^<command-message>/.test(text)) return "";
|
|
122
|
+
const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text);
|
|
123
|
+
if (!name) return "";
|
|
124
|
+
const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text);
|
|
125
|
+
return [name[1].trim(), args ? args[1].trim() : ""].filter(Boolean).join(" ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Is this a background-task or subagent notification? Mirrors capture.js. */
|
|
129
|
+
export function isTaskNotification(obj) {
|
|
130
|
+
if (obj?.type !== "user" || isToolResultMessage(obj)) return false;
|
|
131
|
+
if (obj.origin && obj.origin.kind === "task-notification") return true;
|
|
132
|
+
return /^\s*<task-notification>/.test(extractText(obj.message?.content));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Is this a subagent's hand-back report? Claude Code writes it as an
|
|
137
|
+
* `isMeta` entry tagged `origin: { kind: "peer", handback: true }`. Other
|
|
138
|
+
* peer messages are requests from another session. Mirrors capture.js.
|
|
139
|
+
*/
|
|
140
|
+
export function isSubagentHandback(obj) {
|
|
141
|
+
if (obj?.type !== "user" || isToolResultMessage(obj)) return false;
|
|
142
|
+
return obj.origin?.kind === "peer" && obj.origin.handback === true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handbackInput(obj) {
|
|
146
|
+
const name = obj.origin?.name || obj.origin?.from || "";
|
|
147
|
+
return name ? `Agent message: ${name}` : "Agent message";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Is this an `isMeta` entry Claude Code injected while loading a skill? The
|
|
152
|
+
* model-invoked shape carries `sourceToolUseID`; a typed `/skill` body starts
|
|
153
|
+
* with "Base directory for this skill:". Mirrors capture.js.
|
|
154
|
+
*/
|
|
155
|
+
export function isSkillContext(obj) {
|
|
156
|
+
if (obj?.type !== "user" || obj.isMeta !== true) return false;
|
|
157
|
+
if (isToolResultMessage(obj)) return false;
|
|
158
|
+
if (obj.sourceToolUseID) return true;
|
|
159
|
+
return SKILL_BODY_PATTERN.test(extractText(obj.message?.content));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function taskNotificationInput(text) {
|
|
163
|
+
const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text || "");
|
|
164
|
+
return `Task notification: ${summary ? summary[1].trim() : "background task update"}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function skillContextInput(text) {
|
|
168
|
+
const dir = SKILL_BODY_PATTERN.exec(text || "");
|
|
169
|
+
const name = dir ? path.basename(dir[1].replace(/\/+$/, "")) : "";
|
|
170
|
+
return name ? `Skill loaded: ${name}` : "Skill context loaded";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The rendered input for a turn. A typed skill command that got its body is
|
|
175
|
+
* unwrapped to `/name args`; a turn opened by injected context gets a one-line
|
|
176
|
+
* summary so that context is never sent as user input.
|
|
177
|
+
*/
|
|
178
|
+
function turnInput(turn) {
|
|
179
|
+
const text = extractText(turn.userMsg.message?.content);
|
|
180
|
+
if (isTaskNotification(turn.userMsg)) return taskNotificationInput(text);
|
|
181
|
+
if (isSubagentHandback(turn.userMsg)) return handbackInput(turn.userMsg);
|
|
182
|
+
if (isSkillContext(turn.userMsg)) return skillContextInput(text);
|
|
183
|
+
return turn.injectedEntries.some((e) => e.isMeta === true && !isSubagentHandback(e))
|
|
184
|
+
? skillCommandInput(text) || text
|
|
185
|
+
: text;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isCapturableInput(input) {
|
|
189
|
+
return !!input && !SKIP_INPUT_PATTERN.test(input);
|
|
190
|
+
}
|
|
191
|
+
|
|
107
192
|
/**
|
|
108
193
|
* Should this JSON entry be dropped before we start pairing turns?
|
|
109
194
|
* Mirrors the plan: skip sidechains, meta turns, and the non-message types.
|
|
@@ -177,6 +262,10 @@ export async function parseSessionStream(sessionPath) {
|
|
|
177
262
|
// Turn-assembly state.
|
|
178
263
|
const exchanges = [];
|
|
179
264
|
let current = null;
|
|
265
|
+
// The last entry kept for turn assembly, and whether consecutive injected
|
|
266
|
+
// isMeta entries are still arriving after a skill command or Skill call.
|
|
267
|
+
let prev = null;
|
|
268
|
+
let insideInjectedRun = false;
|
|
180
269
|
|
|
181
270
|
for await (const raw of rl) {
|
|
182
271
|
const line = raw.trim();
|
|
@@ -191,28 +280,63 @@ export async function parseSessionStream(sessionPath) {
|
|
|
191
280
|
if (!cwd && typeof obj.cwd === "string") cwd = obj.cwd;
|
|
192
281
|
if (!gitBranch && typeof obj.gitBranch === "string") gitBranch = obj.gitBranch;
|
|
193
282
|
if (!startedAt && typeof obj.timestamp === "string") startedAt = obj.timestamp;
|
|
194
|
-
if (!firstUserText && obj.type === "user" && !isSkippable(obj) && !isToolResultMessage(obj)) {
|
|
283
|
+
if (!firstUserText && obj.type === "user" && !isSkippable(obj) && !isToolResultMessage(obj) && !isTaskNotification(obj)) {
|
|
195
284
|
const txt = extractText(obj.message?.content);
|
|
196
285
|
if (txt && !SKIP_INPUT_PATTERN.test(txt)) firstUserText = txt.slice(0, 240);
|
|
197
286
|
}
|
|
198
287
|
|
|
199
|
-
|
|
288
|
+
// Skill context and hand-backs are isMeta but belong to their turn, so
|
|
289
|
+
// test them before isSkippable drops every other isMeta entry. A
|
|
290
|
+
// hand-back is never skill context, even if it carries sourceToolUseID.
|
|
291
|
+
const handback = isSubagentHandback(obj) && obj.isSidechain !== true;
|
|
292
|
+
const skillContext =
|
|
293
|
+
!handback && isSkillContext(obj) && obj.isSidechain !== true;
|
|
294
|
+
if (!skillContext && !handback && isSkippable(obj)) continue;
|
|
295
|
+
|
|
296
|
+
const isUserText = obj.type === "user" && !isToolResultMessage(obj);
|
|
297
|
+
let foldIntoCurrent = false;
|
|
298
|
+
if (isUserText && current) {
|
|
299
|
+
if (skillContext) {
|
|
300
|
+
const prevIsSkillCommand =
|
|
301
|
+
prev?.type === "user" &&
|
|
302
|
+
skillCommandInput(extractText(prev.message?.content)) !== "";
|
|
303
|
+
foldIntoCurrent =
|
|
304
|
+
!!obj.sourceToolUseID || insideInjectedRun || prevIsSkillCommand;
|
|
305
|
+
} else if (isTaskNotification(obj) || handback) {
|
|
306
|
+
// Folding into a turn that is never captured (a `/compact` or
|
|
307
|
+
// `<local-command-…>` turn) would drop the reply with it.
|
|
308
|
+
foldIntoCurrent = isCapturableInput(turnInput(current));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
insideInjectedRun = skillContext && (!!obj.sourceToolUseID || foldIntoCurrent);
|
|
200
312
|
|
|
201
|
-
if (
|
|
313
|
+
if (isUserText && !foldIntoCurrent) {
|
|
202
314
|
if (current) {
|
|
203
315
|
const built = buildExchange(current);
|
|
204
316
|
if (built) exchanges.push(built);
|
|
205
317
|
}
|
|
206
|
-
current = {
|
|
318
|
+
current = {
|
|
319
|
+
userMsg: obj,
|
|
320
|
+
assistantEntries: [],
|
|
321
|
+
toolResultEntries: [],
|
|
322
|
+
injectedEntries: [],
|
|
323
|
+
};
|
|
207
324
|
} else if (current) {
|
|
208
325
|
if (obj.type === "assistant") {
|
|
209
326
|
current.assistantEntries.push(obj);
|
|
210
327
|
} else if (obj.type === "user" && isToolResultMessage(obj)) {
|
|
211
328
|
current.toolResultEntries.push(obj);
|
|
329
|
+
} else if (isUserText) {
|
|
330
|
+
current.injectedEntries.push(obj);
|
|
212
331
|
}
|
|
213
332
|
}
|
|
333
|
+
prev = obj;
|
|
214
334
|
}
|
|
215
|
-
|
|
335
|
+
// A session still being written can get more entries for its trailing
|
|
336
|
+
// turn, such as a task notification and its reply. Hold that turn, like
|
|
337
|
+
// the Codex adapter does: if it were sent now, resume would skip it on the
|
|
338
|
+
// next run and the new entries would never be migrated.
|
|
339
|
+
if (current && !partial) {
|
|
216
340
|
const built = buildExchange(current);
|
|
217
341
|
if (built) exchanges.push(built);
|
|
218
342
|
}
|
|
@@ -236,8 +360,8 @@ function baseName(p) {
|
|
|
236
360
|
}
|
|
237
361
|
|
|
238
362
|
function buildExchange(ex) {
|
|
239
|
-
const input =
|
|
240
|
-
if (!
|
|
363
|
+
const input = turnInput(ex);
|
|
364
|
+
if (!isCapturableInput(input)) return null;
|
|
241
365
|
if (ex.assistantEntries.length === 0) return null;
|
|
242
366
|
|
|
243
367
|
const outputParts = [];
|
|
@@ -257,9 +381,29 @@ function buildExchange(ex) {
|
|
|
257
381
|
const trText = extractToolResultContent(tr);
|
|
258
382
|
if (trText) reasoningParts.push(trText);
|
|
259
383
|
}
|
|
384
|
+
const anchoredOnInjection =
|
|
385
|
+
isTaskNotification(ex.userMsg) || isSubagentHandback(ex.userMsg) || isSkillContext(ex.userMsg);
|
|
386
|
+
const injected = anchoredOnInjection
|
|
387
|
+
? [ex.userMsg, ...ex.injectedEntries]
|
|
388
|
+
: ex.injectedEntries;
|
|
389
|
+
for (const inj of injected) {
|
|
390
|
+
const text = extractText(inj.message?.content);
|
|
391
|
+
if (text) {
|
|
392
|
+
const label = isTaskNotification(inj)
|
|
393
|
+
? "TASK NOTIFICATION:\n"
|
|
394
|
+
: isSubagentHandback(inj)
|
|
395
|
+
? "AGENT MESSAGE:\n"
|
|
396
|
+
: "INJECTED CONTEXT:\n";
|
|
397
|
+
reasoningParts.push(label + truncate(text, MAX_TOOL_RESULT_LEN));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
260
400
|
|
|
261
401
|
return {
|
|
262
402
|
user_uuid: ex.userMsg.uuid || "unknown",
|
|
403
|
+
// Uuids of the injected entries folded into this turn. Older migrator
|
|
404
|
+
// versions sent a task notification as its own exchange, so a resume
|
|
405
|
+
// cursor saved by them can name one of these.
|
|
406
|
+
folded_uuids: ex.injectedEntries.map((e) => e.uuid).filter(Boolean),
|
|
263
407
|
input,
|
|
264
408
|
output: outputParts.join("\n\n"),
|
|
265
409
|
reasoning: reasoningParts.join("\n---\n"),
|
package/lib/migrate/id.mjs
CHANGED
|
@@ -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"]);
|
|
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) →
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
* @param {string} [opts.client] e.g. "claude_code", "cursor", "claude-desktop"
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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,14 +269,74 @@ 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",
|
|
284
|
+
repoRoot: cwd,
|
|
285
|
+
envOverride: process.env.MEKO_AGENT_ID,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {string} [cwd] Absolute project cwd of the Codex session (from the
|
|
291
|
+
* rollout's `session_meta.payload.cwd`). Omit for user-scope writes
|
|
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.
|
|
301
|
+
* @returns {string}
|
|
302
|
+
*/
|
|
303
|
+
export function agentIdForCodex(cwd, opts = {}) {
|
|
304
|
+
if (!cwd) return COMMON_BUCKET_AGENT_ID;
|
|
305
|
+
return deriveAgentId({
|
|
306
|
+
client: "codex",
|
|
307
|
+
cwd,
|
|
308
|
+
envOverride: process.env.MEKO_AGENT_ID,
|
|
309
|
+
...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
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 = {}) {
|
|
323
|
+
if (!cwd) return COMMON_BUCKET_AGENT_ID;
|
|
324
|
+
return deriveAgentId({
|
|
325
|
+
client: "kiro",
|
|
326
|
+
cwd,
|
|
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",
|
|
131
337
|
cwd,
|
|
132
338
|
envOverride: process.env.MEKO_AGENT_ID,
|
|
339
|
+
...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
|
|
133
340
|
});
|
|
134
341
|
}
|
|
135
342
|
|