@tyvm/knowhow 0.0.119 → 0.0.121

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/agents/base/base.ts +11 -1
  3. package/src/cli.ts +2 -0
  4. package/src/clients/fireworks.ts +54 -0
  5. package/src/clients/index.ts +21 -10
  6. package/src/clients/pricing/fireworks.ts +3 -0
  7. package/src/commands/replay.ts +424 -0
  8. package/src/processors/Base64ImageDetector.ts +44 -22
  9. package/src/processors/CustomVariables.ts +45 -20
  10. package/src/services/modules/index.ts +5 -4
  11. package/tests/processors/Base64ImageDetector.test.ts +135 -0
  12. package/ts_build/package.json +1 -1
  13. package/ts_build/src/agents/base/base.js +8 -1
  14. package/ts_build/src/agents/base/base.js.map +1 -1
  15. package/ts_build/src/cli.js +2 -0
  16. package/ts_build/src/cli.js.map +1 -1
  17. package/ts_build/src/clients/fireworks.d.ts +9 -0
  18. package/ts_build/src/clients/fireworks.js +29 -0
  19. package/ts_build/src/clients/fireworks.js.map +1 -1
  20. package/ts_build/src/clients/index.js +12 -7
  21. package/ts_build/src/clients/index.js.map +1 -1
  22. package/ts_build/src/clients/pricing/fireworks.js +3 -0
  23. package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
  24. package/ts_build/src/commands/replay.d.ts +2 -0
  25. package/ts_build/src/commands/replay.js +324 -0
  26. package/ts_build/src/commands/replay.js.map +1 -0
  27. package/ts_build/src/processors/Base64ImageDetector.js +19 -12
  28. package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
  29. package/ts_build/src/processors/CustomVariables.js +14 -12
  30. package/ts_build/src/processors/CustomVariables.js.map +1 -1
  31. package/ts_build/src/services/modules/index.js.map +1 -1
  32. package/ts_build/tests/processors/Base64ImageDetector.test.js +88 -0
  33. package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.119",
3
+ "version": "0.0.121",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -668,11 +668,15 @@ export abstract class BaseAgent implements IAgent {
668
668
  promise: Promise<T>,
669
669
  interruptValue: T
670
670
  ): Promise<T> {
671
- return new Promise<T>((resolve) => {
671
+ return new Promise<T>((resolve, reject) => {
672
+ let interrupted = false;
673
+
672
674
  this._interruptResolve = (value: any) => {
675
+ interrupted = true;
673
676
  this._interruptResolve = null;
674
677
  resolve(value);
675
678
  };
679
+
676
680
  promise.then((result) => {
677
681
  if (this._interruptResolve) {
678
682
  this._interruptResolve = null;
@@ -681,7 +685,13 @@ export abstract class BaseAgent implements IAgent {
681
685
  }).catch((err) => {
682
686
  if (this._interruptResolve) {
683
687
  this._interruptResolve = null;
688
+ }
689
+ // Only swallow the error if interrupt() was explicitly called.
690
+ // Otherwise re-throw so callers see the real error.
691
+ if (interrupted) {
684
692
  resolve(interruptValue);
693
+ } else {
694
+ reject(err);
685
695
  }
686
696
  });
687
697
  });
package/src/cli.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  addGithubCredentialsCommand,
38
38
  } from "./commands/misc";
39
39
  import { addConvertCommand } from "./commands/convert";
40
+ import { addReplayCommand } from "./commands/replay";
40
41
 
41
42
  // Handle unhandled promise rejections gracefully — particularly from MCP SDK
42
43
  // which fires errors via event emitters that can bypass Promise.allSettled.
@@ -100,6 +101,7 @@ async function main() {
100
101
  addModulesCommand(program);
101
102
  addMcpCommands(program);
102
103
  addConvertCommand(program);
104
+ addReplayCommand(program);
103
105
 
104
106
  // Load global modules early (before parse) so they can register CLI subcommands.
105
107
  // We pass only the Program in context — no services are spun up at this stage.
@@ -1,5 +1,8 @@
1
1
  import { HttpClient } from "./http";
2
2
  import { FireworksTextPricing } from "./pricing/fireworks";
3
+ import { CompletionOptions, CompletionResponse } from "./types";
4
+
5
+ type ModelInfo = { id: string; object: string; owned_by: string };
3
6
 
4
7
  /**
5
8
  * Fireworks AI client — OpenAI-compatible API (fast serverless inference)
@@ -12,4 +15,55 @@ export class GenericFireworksClient extends HttpClient {
12
15
  if (apiKey) this.setJwt(apiKey);
13
16
  this.setPrices(FireworksTextPricing);
14
17
  }
18
+
19
+ /**
20
+ * Supplement the live /v1/models response with any models we have in the
21
+ * pricing table. The Fireworks API sometimes doesn't return newly-released
22
+ * models (e.g. minimax-m3, kimi-k2p7-code) even though they are available
23
+ * for inference — so we use the pricing map as the source of truth for
24
+ * "models we know exist on this provider".
25
+ */
26
+ async getModels(type = "all"): Promise<ModelInfo[]> {
27
+ let liveModels: ModelInfo[] = [];
28
+ try {
29
+ liveModels = await super.getModels(type);
30
+ } catch (_err) {
31
+ // Live API call failed — fall back to pricing map only
32
+ }
33
+
34
+ const liveIds = new Set(liveModels.map((m) => m.id));
35
+ const pricingModels: ModelInfo[] = Object.keys(FireworksTextPricing)
36
+ .filter((id) => !liveIds.has(id))
37
+ .map((id) => ({ id, object: "model", owned_by: "fireworks" }));
38
+
39
+ return [...liveModels, ...pricingModels];
40
+ }
41
+
42
+ /**
43
+ * Sanitize the request before sending to Fireworks.
44
+ * Some models (e.g. kimi-k2p7-code) reject extra fields like:
45
+ * - tools[N].function.returns (non-standard extension)
46
+ * - long_ttl_cache (Anthropic-specific cache flag)
47
+ */
48
+ async createChatCompletion(
49
+ options: CompletionOptions
50
+ ): Promise<CompletionResponse> {
51
+ const sanitized: CompletionOptions = {
52
+ ...options,
53
+ // Strip Anthropic-specific field not accepted by Fireworks
54
+ long_ttl_cache: undefined,
55
+ };
56
+
57
+ if (sanitized.tools) {
58
+ sanitized.tools = sanitized.tools.map((tool) => {
59
+ const { returns, ...fnRest } = tool.function as any;
60
+ return {
61
+ ...tool,
62
+ function: fnRest,
63
+ };
64
+ });
65
+ }
66
+
67
+ return super.createChatCompletion(sanitized);
68
+ }
15
69
  }
@@ -397,9 +397,10 @@ export class AIClient {
397
397
  const hasModel = this.providerHasModel(provider, model);
398
398
 
399
399
  if (!hasModel) {
400
- throw new Error(
401
- `Model ${model} not registered for provider ${provider}.`
402
- );
400
+ // Model not in local registry — pass it through anyway so the provider
401
+ // API can accept or reject it directly (e.g. newly-released models that
402
+ // haven't been fetched into our local model list yet).
403
+ console.warn(`⚠️ Model '${model}' not in local registry for provider '${provider}', attempting anyway.`);
403
404
  }
404
405
 
405
406
  return { client: this.clients[provider], provider, model };
@@ -609,6 +610,11 @@ export class AIClient {
609
610
  return { provider, model };
610
611
  }
611
612
 
613
+ // If an explicit provider was given, don't fall through to fuzzy cross-provider
614
+ // search — that would silently pick a completely different provider (e.g. nvidia
615
+ // instead of fireworks). Just pass through and let the API accept/reject the model.
616
+ const hasExplicitProvider = !!provider;
617
+
612
618
  if (model?.includes("/")) {
613
619
  const split = model.split("/");
614
620
 
@@ -620,16 +626,21 @@ export class AIClient {
620
626
  return { provider: inferredProvider, model: inferredModel };
621
627
  }
622
628
 
623
- // Starts with match
624
- const foundBySplit = this.findModel(inferredModel);
625
- if (foundBySplit) {
626
- return foundBySplit;
629
+ // Starts with match — only if no explicit provider was given
630
+ if (!hasExplicitProvider) {
631
+ const foundBySplit = this.findModel(inferredModel);
632
+ if (foundBySplit) {
633
+ return foundBySplit;
634
+ }
627
635
  }
628
636
  }
629
637
 
630
- const foundByModel = this.findModel(model);
631
- if (foundByModel) {
632
- return foundByModel;
638
+ // Fuzzy cross-provider search — only if no explicit provider was given
639
+ if (!hasExplicitProvider) {
640
+ const foundByModel = this.findModel(model);
641
+ if (foundByModel) {
642
+ return foundByModel;
643
+ }
633
644
  }
634
645
 
635
646
  const allModels = this.listAllModels();
@@ -4,10 +4,12 @@
4
4
  */
5
5
  export const FireworksTextPricing: Record<string, { input: number; output: number; cache_hit?: number }> = {
6
6
  // Moonshot AI
7
+ "accounts/fireworks/models/kimi-k2p7-code": { input: 0.95, cache_hit: 0.19, output: 4.0 },
7
8
  "accounts/fireworks/models/kimi-k2-6": { input: 0.95, cache_hit: 0.16, output: 4.0 },
8
9
  "accounts/fireworks/models/kimi-k2-5": { input: 0.60, cache_hit: 0.10, output: 3.0 },
9
10
 
10
11
  // MiniMax
12
+ "accounts/fireworks/models/minimax-m3": { input: 0.30, cache_hit: 0.06, output: 1.20 },
11
13
  "accounts/fireworks/models/minimax-m2-7": { input: 0.30, cache_hit: 0.06, output: 1.20 },
12
14
  "accounts/fireworks/models/minimax-m2-5": { input: 0.30, cache_hit: 0.03, output: 1.20 },
13
15
 
@@ -19,6 +21,7 @@ export const FireworksTextPricing: Record<string, { input: number; output: numbe
19
21
  "accounts/fireworks/models/qwen3-8b": { input: 0.20, cache_hit: 0.10, output: 0.20 },
20
22
 
21
23
  // Z.ai
24
+ "accounts/fireworks/models/glm-5-2": { input: 1.40, cache_hit: 0.26, output: 4.40 },
22
25
  "accounts/fireworks/models/glm-5-1": { input: 1.40, cache_hit: 0.26, output: 4.40 },
23
26
  "accounts/fireworks/models/glm-5": { input: 1.00, cache_hit: 0.20, output: 3.20 },
24
27
  "accounts/fireworks/models/glm-4-7": { input: 0.60, cache_hit: 0.30, output: 2.20 },
@@ -0,0 +1,424 @@
1
+ import { Command } from "commander";
2
+ import * as readline from "readline";
3
+ import { KnowhowSimpleClient, KNOWHOW_API_URL } from "../services/KnowhowClient";
4
+ import { ToolCall } from "../clients/types";
5
+
6
+ // ─── Types ────────────────────────────────────────────────────────────────────
7
+
8
+ interface ReplayResult {
9
+ toolName: string;
10
+ toolId: string;
11
+ status: "success" | "failed" | "skipped";
12
+ error?: string;
13
+ output?: string;
14
+ }
15
+
16
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
17
+
18
+ /** Flatten all tool_use blocks from all threads in order */
19
+ function extractToolCalls(threads: any[][]): { toolCall: ToolCall; threadIdx: number; msgIdx: number }[] {
20
+ const calls: { toolCall: ToolCall; threadIdx: number; msgIdx: number }[] = [];
21
+
22
+ for (let ti = 0; ti < threads.length; ti++) {
23
+ const thread = threads[ti];
24
+ for (let mi = 0; mi < thread.length; mi++) {
25
+ const msg = thread[mi];
26
+ if (msg.role !== "assistant") continue;
27
+
28
+ const content = msg.content;
29
+
30
+ // Handle Anthropic-style: content is an array of blocks with type "tool_use"
31
+ if (Array.isArray(content)) {
32
+ for (const block of content) {
33
+ if (block.type !== "tool_use") continue;
34
+ // Convert from Anthropic tool_use block to our ToolCall shape
35
+ const toolCall: ToolCall = {
36
+ id: block.id || `replay_${Date.now()}_${Math.random()}`,
37
+ type: "function",
38
+ function: {
39
+ name: block.name,
40
+ arguments:
41
+ typeof block.input === "string"
42
+ ? block.input
43
+ : JSON.stringify(block.input ?? {}),
44
+ },
45
+ };
46
+ calls.push({ toolCall, threadIdx: ti, msgIdx: mi });
47
+ }
48
+ }
49
+
50
+ // Handle OpenAI-style: tool_calls array on the message (content may be a string)
51
+ if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
52
+ for (const tc of msg.tool_calls) {
53
+ calls.push({ toolCall: tc, threadIdx: ti, msgIdx: mi });
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ return calls;
60
+ }
61
+
62
+ /** Fetch threads by taskId (direct task) */
63
+ async function fetchThreadsByTaskId(
64
+ client: KnowhowSimpleClient,
65
+ taskId: string
66
+ ): Promise<any[][]> {
67
+ return client.getTaskThreads(taskId);
68
+ }
69
+
70
+ /** Fetch threads from a session's messages and find the task embedded within */
71
+ async function fetchThreadsBySessionMessage(
72
+ client: KnowhowSimpleClient,
73
+ sessionId: string,
74
+ messageId: string
75
+ ): Promise<any[][]> {
76
+ // GET /api/chat/sessions/:sessionId/messages
77
+ const baseUrl = (client as any).baseUrl || KNOWHOW_API_URL;
78
+ const headers = (client as any).headers || {};
79
+
80
+ const resp = await fetch(`${baseUrl}/api/chat/sessions/${sessionId}/messages`, {
81
+ headers,
82
+ });
83
+
84
+ if (!resp.ok) {
85
+ throw new Error(`Failed to fetch session messages: ${resp.status} ${resp.statusText}`);
86
+ }
87
+
88
+ const messages: any[] = await resp.json();
89
+
90
+ // Find the message with our target messageId
91
+ let targetMsg = messages.find((m: any) => m.id === messageId);
92
+ if (!targetMsg) {
93
+ // messageId might be on a task inside an assistant message
94
+ for (const m of messages) {
95
+ if (m.tasks) {
96
+ for (const task of m.tasks) {
97
+ if (task.id === messageId) {
98
+ targetMsg = m;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ if (targetMsg) break;
104
+ }
105
+ }
106
+
107
+ if (!targetMsg) {
108
+ throw new Error(`Message ${messageId} not found in session ${sessionId}`);
109
+ }
110
+
111
+ // Extract threads - they may be directly on a task inside the message
112
+ if (targetMsg.tasks && targetMsg.tasks.length > 0) {
113
+ // Find task that contains our messageId, or use first task
114
+ let task = targetMsg.tasks.find((t: any) => t.id === messageId);
115
+ if (!task) task = targetMsg.tasks[0];
116
+
117
+ if (task.threads && Array.isArray(task.threads)) {
118
+ return task.threads;
119
+ }
120
+
121
+ // If task has an id but no inline threads, fetch via task API
122
+ if (task.id) {
123
+ return client.getTaskThreads(task.id);
124
+ }
125
+ }
126
+
127
+ // Fallback: if threads are directly on the message
128
+ if (targetMsg.threads) return targetMsg.threads;
129
+
130
+ throw new Error(`No threads found for message ${messageId}`);
131
+ }
132
+
133
+ /** Prompt user for approval (y/n/s for skip/q for quit) */
134
+ async function promptApproval(toolName: string, args: string): Promise<"run" | "skip" | "quit"> {
135
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
136
+ return new Promise((resolve) => {
137
+ const preview = args.length > 200 ? args.slice(0, 200) + "…" : args;
138
+ rl.question(
139
+ `\n🔧 Tool: ${toolName}\n Args: ${preview}\n Run this? [y/n/q] (y=run, n=skip, q=quit): `,
140
+ (answer) => {
141
+ rl.close();
142
+ const a = answer.trim().toLowerCase();
143
+ if (a === "q" || a === "quit") resolve("quit");
144
+ else if (a === "n" || a === "skip" || a === "s") resolve("skip");
145
+ else resolve("run");
146
+ }
147
+ );
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Parse --replace-path options like "/workspace/repo:./" into a list of [from, to] pairs.
153
+ * Multiple --replace-path flags are supported.
154
+ */
155
+ function parseReplacePaths(replacePaths: string[]): Array<[string, string]> {
156
+ return replacePaths.map((rp) => {
157
+ const colonIdx = rp.indexOf(":");
158
+ if (colonIdx === -1) {
159
+ throw new Error(`Invalid --replace-path value "${rp}": expected format "from:to" (e.g. /workspace/repo:./)`);
160
+ }
161
+ const from = rp.slice(0, colonIdx);
162
+ const to = rp.slice(colonIdx + 1);
163
+ return [from, to];
164
+ });
165
+ }
166
+
167
+ /** Apply all path replacements to a tool arguments string */
168
+ function applyPathReplacements(argsStr: string, replacements: Array<[string, string]>): string {
169
+ let result = argsStr;
170
+ for (const [from, to] of replacements) {
171
+ // Replace all occurrences globally
172
+ result = result.split(from).join(to);
173
+ }
174
+ return result;
175
+ }
176
+
177
+ // ─── Main replay logic ────────────────────────────────────────────────────────
178
+
179
+ async function runReplay(options: {
180
+ taskId?: string;
181
+ sessionId?: string;
182
+ messageId?: string;
183
+ approve: boolean;
184
+ fromStep?: number;
185
+ toStep?: number;
186
+ only?: string[];
187
+ ignore?: string[];
188
+ dryRun: boolean;
189
+ replacePaths: Array<[string, string]>;
190
+ }) {
191
+ // 1. Setup services + tools
192
+ console.log("🔧 Setting up services...");
193
+ const { setupServices } = await import("./services");
194
+ const { Tools } = await setupServices();
195
+
196
+ // 2. Fetch the threads from the remote API
197
+ const client = new KnowhowSimpleClient(KNOWHOW_API_URL);
198
+
199
+ let threads: any[][] = [];
200
+
201
+ if (options.taskId && !options.sessionId) {
202
+ console.log(`\n📡 Fetching threads for task: ${options.taskId}`);
203
+ threads = await fetchThreadsByTaskId(client, options.taskId);
204
+ } else if (options.sessionId && options.messageId) {
205
+ console.log(`\n📡 Fetching threads for session=${options.sessionId} message=${options.messageId}`);
206
+ threads = await fetchThreadsBySessionMessage(client, options.sessionId, options.messageId);
207
+ } else if (options.taskId) {
208
+ // taskId might actually be a task UUID from a message
209
+ console.log(`\n📡 Fetching threads for task: ${options.taskId}`);
210
+ threads = await fetchThreadsByTaskId(client, options.taskId);
211
+ } else {
212
+ throw new Error("Provide --task-id or --session-id + --message-id");
213
+ }
214
+
215
+ if (!threads || threads.length === 0) {
216
+ console.log("⚠️ No threads found. Nothing to replay.");
217
+ return;
218
+ }
219
+
220
+ console.log(`✅ Loaded ${threads.length} thread(s)`);
221
+
222
+ // 3. Extract all tool calls
223
+ const allToolCalls = extractToolCalls(threads);
224
+
225
+ if (allToolCalls.length === 0) {
226
+ console.log("⚠️ No tool calls found in threads. Nothing to replay.");
227
+ return;
228
+ }
229
+
230
+ console.log(`\n📋 Found ${allToolCalls.length} tool call(s) total`);
231
+
232
+ // 4. Apply filters
233
+ let filtered = allToolCalls;
234
+
235
+ if (options.fromStep != null) {
236
+ filtered = filtered.slice(options.fromStep - 1);
237
+ }
238
+ if (options.toStep != null) {
239
+ const end = options.toStep - (options.fromStep ?? 1) + 1;
240
+ filtered = filtered.slice(0, end);
241
+ }
242
+ if (options.only && options.only.length > 0) {
243
+ filtered = filtered.filter(({ toolCall }) =>
244
+ options.only!.some((n) => toolCall.function.name.includes(n))
245
+ );
246
+ console.log(` Filtered to tools matching: ${options.only.join(", ")} → ${filtered.length} call(s)`);
247
+ }
248
+ if (options.ignore && options.ignore.length > 0) {
249
+ filtered = filtered.filter(({ toolCall }) =>
250
+ !options.ignore!.some((n) => toolCall.function.name.includes(n))
251
+ );
252
+ console.log(` Ignoring tools matching: ${options.ignore.join(", ")} → ${filtered.length} call(s)`);
253
+ }
254
+
255
+ if (options.replacePaths.length > 0) {
256
+ const pairs = options.replacePaths.map(([f, t]) => `"${f}" → "${t}"`).join(", ");
257
+ console.log(` Path replacements: ${pairs}`);
258
+ }
259
+
260
+ if (filtered.length === 0) {
261
+ console.log("⚠️ No tool calls remain after filtering. Nothing to replay.");
262
+ return;
263
+ }
264
+
265
+ // 5. Print summary table
266
+ console.log("\n┌─────────────────────────────────────────────────────────────┐");
267
+ console.log(`│ Replay Plan: ${filtered.length} tool call(s)${" ".repeat(Math.max(0, 47 - String(filtered.length).length))}│`);
268
+ console.log("├──────┬──────────────────────────────────────────────────────┤");
269
+ console.log("│ # │ Tool Name │");
270
+ console.log("├──────┼──────────────────────────────────────────────────────┤");
271
+ for (let i = 0; i < filtered.length; i++) {
272
+ const name = filtered[i].toolCall.function.name.padEnd(52).slice(0, 52);
273
+ const num = String(i + 1).padStart(4);
274
+ console.log(`│ ${num} │ ${name}│`);
275
+ }
276
+ console.log("└──────┴──────────────────────────────────────────────────────┘\n");
277
+
278
+ if (options.dryRun) {
279
+ console.log("🏃 Dry-run mode: no tools will be executed.");
280
+ return;
281
+ }
282
+
283
+ // 6. Execute tool calls
284
+ const results: ReplayResult[] = [];
285
+ let stepNum = 0;
286
+
287
+ for (const { toolCall } of filtered) {
288
+ stepNum++;
289
+ const toolName = toolCall.function.name;
290
+ const argsStr =
291
+ typeof toolCall.function.arguments === "string"
292
+ ? toolCall.function.arguments
293
+ : JSON.stringify(toolCall.function.arguments);
294
+
295
+ // Apply path replacements to the arguments string before execution
296
+ const patchedArgsStr =
297
+ options.replacePaths.length > 0
298
+ ? applyPathReplacements(argsStr, options.replacePaths)
299
+ : argsStr;
300
+
301
+ const patchedToolCall: ToolCall = {
302
+ ...toolCall,
303
+ function: { ...toolCall.function, arguments: patchedArgsStr },
304
+ };
305
+
306
+ // Prompt for approval if requested
307
+ if (options.approve) {
308
+ const decision = await promptApproval(toolName, patchedArgsStr);
309
+ if (decision === "quit") {
310
+ console.log("\n⛔ Quit by user.");
311
+ break;
312
+ }
313
+ if (decision === "skip") {
314
+ console.log(` ⏭ Skipped: ${toolName}`);
315
+ results.push({ toolName, toolId: toolCall.id, status: "skipped" });
316
+ continue;
317
+ }
318
+ }
319
+
320
+ process.stdout.write(`[${stepNum}/${filtered.length}] Running ${toolName}... `);
321
+
322
+ try {
323
+ const { functionResp } = await Tools.callTool(patchedToolCall, Tools.getToolNames());
324
+ const output = typeof functionResp === "string" ? functionResp : JSON.stringify(functionResp);
325
+ const preview = output.length > 120 ? output.slice(0, 120) + "…" : output;
326
+ console.log(`✅`);
327
+ if (output) {
328
+ console.log(` → ${preview}`);
329
+ }
330
+ results.push({ toolName, toolId: toolCall.id, status: "success", output });
331
+ } catch (err: any) {
332
+ console.log(`❌`);
333
+ const errMsg = err?.message || String(err);
334
+ console.log(` ⚠ ${errMsg}`);
335
+ results.push({ toolName, toolId: toolCall.id, status: "failed", error: errMsg });
336
+ }
337
+ }
338
+
339
+ // 7. Print summary
340
+ const succeeded = results.filter((r) => r.status === "success").length;
341
+ const failed = results.filter((r) => r.status === "failed").length;
342
+ const skipped = results.filter((r) => r.status === "skipped").length;
343
+
344
+ console.log("\n═══════════════════════════════════════════════════════════════");
345
+ console.log(" Replay Complete");
346
+ console.log(` ✅ ${succeeded} succeeded ❌ ${failed} failed ⏭ ${skipped} skipped`);
347
+
348
+ if (failed > 0) {
349
+ console.log("\n Failed tool calls:");
350
+ results
351
+ .filter((r) => r.status === "failed")
352
+ .forEach((r) => {
353
+ console.log(` • ${r.toolName}: ${r.error}`);
354
+ });
355
+ }
356
+
357
+ console.log("═══════════════════════════════════════════════════════════════\n");
358
+ }
359
+
360
+ // ─── Command registration ─────────────────────────────────────────────────────
361
+
362
+ export function addReplayCommand(program: Command): void {
363
+ program
364
+ .command("replay")
365
+ .description(
366
+ "Replay tool calls from a remote session/task locally. " +
367
+ "Useful for recreating agent work from a cloud sandbox on your local machine."
368
+ )
369
+ .option("--task-id <taskId>", "Task UUID to replay tool calls from")
370
+ .option("--session-id <sessionId>", "Chat session UUID (use with --message-id)")
371
+ .option("--message-id <messageId>", "Message UUID within a session to replay")
372
+ .option(
373
+ "--approve",
374
+ "Approval mode: prompt before each tool call (y=run, n=skip, q=quit)"
375
+ )
376
+ .option("--dry-run", "List tool calls without executing them")
377
+ .option(
378
+ "--from-step <n>",
379
+ "Start replaying from step N (1-based)",
380
+ (v) => parseInt(v, 10)
381
+ )
382
+ .option(
383
+ "--to-step <n>",
384
+ "Stop replaying after step N (1-based)",
385
+ (v) => parseInt(v, 10)
386
+ )
387
+ .option(
388
+ "--only <tools>",
389
+ "Comma-separated list of tool name substrings to include (e.g. writeFile,patchFile)",
390
+ (v) => v.split(",").map((s) => s.trim()).filter(Boolean)
391
+ )
392
+ .option(
393
+ "--ignore <tools>",
394
+ "Comma-separated list of tool name substrings to skip (e.g. execCommand,readFile)",
395
+ (v) => v.split(",").map((s) => s.trim()).filter(Boolean)
396
+ )
397
+ .option(
398
+ "--replace-path <mapping>",
399
+ 'Replace a path prefix in all tool call arguments. Format: "from:to" (e.g. /workspace/repo:./). ' +
400
+ "Can be specified multiple times.",
401
+ (v: string, acc: string[]) => { acc.push(v); return acc; },
402
+ [] as string[]
403
+ )
404
+ .action(async (options) => {
405
+ try {
406
+ const replacePaths = parseReplacePaths(options.replacePath || []);
407
+ await runReplay({
408
+ taskId: options.taskId,
409
+ sessionId: options.sessionId,
410
+ messageId: options.messageId,
411
+ approve: options.approve || false,
412
+ fromStep: options.fromStep,
413
+ toStep: options.toStep,
414
+ only: options.only,
415
+ ignore: options.ignore,
416
+ dryRun: options.dryRun || false,
417
+ replacePaths,
418
+ });
419
+ } catch (err: any) {
420
+ console.error(`\n❌ Replay failed: ${err?.message || err}`);
421
+ process.exit(1);
422
+ }
423
+ });
424
+ }