@yugabytedb/meko-mcp 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +106 -38
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +131 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +95 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1237 -137
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +85 -18
  15. package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  40. package/bin/create-meko-setup.mjs +70 -7
  41. package/lib/capture-cleanup.mjs +63 -0
  42. package/lib/clients/claude-code.mjs +1 -0
  43. package/lib/clients/codex.mjs +80 -35
  44. package/lib/clients/common.mjs +7 -0
  45. package/lib/clients/index.mjs +2 -0
  46. package/lib/clients/kiro.mjs +5 -5
  47. package/lib/clients/opencode.mjs +532 -0
  48. package/lib/config.mjs +68 -1
  49. package/lib/installer.mjs +18 -2
  50. package/lib/migrate/adapters/claude-code.mjs +11 -2
  51. package/lib/migrate/adapters/codex.mjs +9 -2
  52. package/lib/migrate/adapters/memory-md.mjs +27 -8
  53. package/lib/migrate/adapters/opencode.mjs +148 -0
  54. package/lib/migrate/claude-parser.mjs +153 -9
  55. package/lib/migrate/id.mjs +195 -13
  56. package/lib/migrate/mcp-client.mjs +25 -1
  57. package/lib/migrate/opencode-normalizer.mjs +132 -0
  58. package/lib/migrate/orchestrator.mjs +293 -77
  59. package/lib/migrate/progress.mjs +239 -5
  60. package/lib/migrate/registry.mjs +72 -0
  61. package/lib/migrate.mjs +96 -8
  62. package/package.json +1 -1
  63. package/scripts/bundle-assets.mjs +4 -0
@@ -10,7 +10,7 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import os from "node:os";
13
- import { agentIdForClaude, slugifyCwd } from "../id.mjs";
13
+ import { agentIdForClaude, deriveLegacyAgentId, slugifyCwd } from "../id.mjs";
14
14
  import { parseSessionStream, peekSessionMetadata } from "../claude-parser.mjs";
15
15
 
16
16
  function homeDir() {
@@ -46,6 +46,7 @@ export function userClaudeMdPath(opts = {}) {
46
46
  * @property {string} slug
47
47
  * @property {string} cwd Derived from the first session in the slug dir.
48
48
  * @property {string} agentId
49
+ * @property {string} legacyAgentId Pre-MEKO-590 resume-state alias.
49
50
  * @property {number} size
50
51
  * @property {number} mtimeMs
51
52
  * @property {boolean} likelyInProgress
@@ -96,12 +97,20 @@ export async function listSessions(opts) {
96
97
  let stat;
97
98
  try { stat = fs.statSync(full); } catch { continue; }
98
99
  const sessionUuid = e.name.replace(/\.jsonl$/, "");
100
+ const legacyAgentId = deriveLegacyAgentId({
101
+ client: "claude_code",
102
+ cwd: slugCwd,
103
+ envOverride: process.env.MEKO_AGENT_ID,
104
+ });
99
105
  out.push({
100
106
  sessionUuid,
101
107
  path: full,
102
108
  slug,
103
109
  cwd: slugCwd,
104
- agentId: agentIdForClaude(slugCwd),
110
+ // Historical: `slugCwd` is a cwd recorded in an old transcript (or
111
+ // reverse-slugged from the project dir name) and may no longer exist.
112
+ agentId: agentIdForClaude(slugCwd, { historical: true }),
113
+ legacyAgentId,
105
114
  size: stat.size,
106
115
  mtimeMs: stat.mtimeMs,
107
116
  likelyInProgress: stat.mtimeMs > Date.now() - 60_000,
@@ -34,7 +34,7 @@
34
34
  import fs from "node:fs";
35
35
  import path from "node:path";
36
36
  import os from "node:os";
37
- import { agentIdForCodex } from "../id.mjs";
37
+ import { agentIdForCodex, deriveLegacyAgentId } from "../id.mjs";
38
38
 
39
39
  // Cap for the first-record read. Codex embeds `base_instructions` in the
40
40
  // session_meta line, which routinely exceeds 16 KiB; read generously so the
@@ -80,6 +80,7 @@ export function sessionsRoot(opts = {}) {
80
80
  * @property {string} path Absolute path to the rollout .jsonl.
81
81
  * @property {string} cwd Project root from session_meta.
82
82
  * @property {string} agentId `codex:<repo-basename>`.
83
+ * @property {string} legacyAgentId Pre-MEKO-590 resume-state alias.
83
84
  * @property {"codex"} source
84
85
  * @property {number} mtimeMs
85
86
  * @property {boolean} likelyInProgress
@@ -202,7 +203,13 @@ export function listSessions(opts) {
202
203
  sessionUuid: sessionUuidForRollout(full),
203
204
  path: full,
204
205
  cwd: meta.cwd,
205
- agentId: agentIdForCodex(meta.cwd),
206
+ // Historical: cwd recorded in the rollout, may be gone from disk.
207
+ agentId: agentIdForCodex(meta.cwd, { historical: true }),
208
+ legacyAgentId: deriveLegacyAgentId({
209
+ client: "codex",
210
+ cwd: meta.cwd,
211
+ envOverride: process.env.MEKO_AGENT_ID,
212
+ }),
206
213
  source: "codex",
207
214
  mtimeMs: stat.mtimeMs,
208
215
  likelyInProgress: stat.mtimeMs > Date.now() - 60_000,
@@ -14,6 +14,7 @@ import os from "node:os";
14
14
  import {
15
15
  CLAUDE_CODE_USER_AGENT_ID,
16
16
  agentIdForClaude,
17
+ deriveLegacyAgentId,
17
18
  memoryHash,
18
19
  slugifyCwd,
19
20
  } from "../id.mjs";
@@ -26,6 +27,7 @@ function homeDir() {
26
27
  /**
27
28
  * @typedef {Object} MemoryItem
28
29
  * @property {string} agentId
30
+ * @property {string} [legacyAgentId] Pre-MEKO-590 state alias.
29
31
  * @property {string} text
30
32
  * @property {string} hash
31
33
  * @property {{source: string, scope: "user"|"project", sourcePath: string, heading?: string, name?: string, description?: string, type?: string}} metadata
@@ -62,22 +64,35 @@ export async function enumerateMemoryItems(opts) {
62
64
  if (!stat.isDirectory()) continue;
63
65
  const cwd = await cwdForSlugDir(dir, slug);
64
66
  if (!cwd) continue;
65
- const agentId = agentIdForClaude(cwd);
67
+ // Historical: recorded/reverse-slugged cwd, may be gone from disk.
68
+ const agentId = agentIdForClaude(cwd, { historical: true });
69
+ const legacyAgentId = deriveLegacyAgentId({
70
+ client: "claude_code",
71
+ cwd,
72
+ envOverride: process.env.MEKO_AGENT_ID,
73
+ });
66
74
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
67
- items.push(...readClaudeMd(projMd, agentId, "project", cwd));
75
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd, legacyAgentId));
68
76
  const memDir = path.join(dir, "memory");
69
- items.push(...readMemoryDir(memDir, agentId, "project", cwd));
77
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd, legacyAgentId));
70
78
  }
71
79
  }
72
80
  } else {
73
81
  const cwd = opts.projectCwd;
74
82
  if (!cwd) return [];
75
- const agentId = agentIdForClaude(cwd);
83
+ // Keep project CLAUDE.md and indexed memories in the same bucket as the
84
+ // historical session adapter, including when the cwd is no longer a repo.
85
+ const agentId = agentIdForClaude(cwd, { historical: true });
86
+ const legacyAgentId = deriveLegacyAgentId({
87
+ client: "claude_code",
88
+ cwd,
89
+ envOverride: process.env.MEKO_AGENT_ID,
90
+ });
76
91
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
77
- items.push(...readClaudeMd(projMd, agentId, "project", cwd));
92
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd, legacyAgentId));
78
93
  const slug = slugifyCwd(cwd);
79
94
  const memDir = path.join(home, ".claude", "projects", slug, "memory");
80
- items.push(...readMemoryDir(memDir, agentId, "project", cwd));
95
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd, legacyAgentId));
81
96
  }
82
97
 
83
98
  return items;
@@ -94,9 +109,10 @@ export async function enumerateMemoryItems(opts) {
94
109
  * so downstream consumers can correlate even after the agent_id is
95
110
  * sanitized down to a basename. Null for user-scope items (which use the
96
111
  * `meko_agent` common bucket).
112
+ * @param {string|null} [legacyAgentId] Pre-MEKO-590 compatibility alias.
97
113
  * @returns {MemoryItem[]}
98
114
  */
99
- export function readClaudeMd(filePath, agentId, scope, cwd = null) {
115
+ export function readClaudeMd(filePath, agentId, scope, cwd = null, legacyAgentId = null) {
100
116
  let raw;
101
117
  try {
102
118
  raw = fs.readFileSync(filePath, "utf8");
@@ -112,6 +128,7 @@ export function readClaudeMd(filePath, agentId, scope, cwd = null) {
112
128
  const name = heading || path.basename(filePath);
113
129
  return {
114
130
  agentId,
131
+ ...(legacyAgentId && legacyAgentId !== agentId ? { legacyAgentId } : {}),
115
132
  text,
116
133
  hash: memoryHash(text, name),
117
134
  name,
@@ -166,9 +183,10 @@ export function splitTopLevelSections(md) {
166
183
  * @param {string} agentId
167
184
  * @param {"user"|"project"} scope
168
185
  * @param {string|null} [cwd] - Absolute project path; attached to metadata.
186
+ * @param {string|null} [legacyAgentId] Pre-MEKO-590 compatibility alias.
169
187
  * @returns {MemoryItem[]}
170
188
  */
171
- export function readMemoryDir(memDir, agentId, scope, cwd = null) {
189
+ export function readMemoryDir(memDir, agentId, scope, cwd = null, legacyAgentId = null) {
172
190
  let entries;
173
191
  try {
174
192
  entries = fs.readdirSync(memDir);
@@ -192,6 +210,7 @@ export function readMemoryDir(memDir, agentId, scope, cwd = null) {
192
210
  const name = frontmatter.name || entry.replace(/\.md$/, "");
193
211
  out.push({
194
212
  agentId,
213
+ ...(legacyAgentId && legacyAgentId !== agentId ? { legacyAgentId } : {}),
195
214
  text,
196
215
  hash: memoryHash(text, name),
197
216
  name,
@@ -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 / isMeta / attachment-type entries up front
9
- * (the hook-side version doesn't need to because it runs live)
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
- if (isSkippable(obj)) continue;
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 (obj.type === "user" && !isToolResultMessage(obj)) {
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 = { userMsg: obj, assistantEntries: [], toolResultEntries: [] };
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
- if (current) {
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 = extractText(ex.userMsg.message?.content);
240
- if (!input || SKIP_INPUT_PATTERN.test(input)) return null;
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"),