@tyvm/knowhow 0.0.120 → 0.0.122
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/package.json +1 -1
- package/src/agents/base/base.ts +11 -1
- package/src/cli.ts +2 -0
- package/src/clients/fireworks.ts +54 -0
- package/src/clients/http.ts +94 -0
- package/src/clients/index.ts +87 -24
- package/src/clients/openai.ts +58 -0
- package/src/clients/pricing/fireworks.ts +3 -0
- package/src/clients/types.ts +18 -0
- package/src/commands/replay.ts +424 -0
- package/src/processors/Base64ImageDetector.ts +44 -22
- package/src/services/modules/index.ts +5 -4
- package/tests/clients/AIClient.test.ts +5 -3
- package/tests/processors/Base64ImageDetector.test.ts +135 -0
- package/tests/processors/CustomVariables.test.ts +17 -7
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.js +8 -1
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/cli.js +2 -0
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/clients/fireworks.d.ts +9 -0
- package/ts_build/src/clients/fireworks.js +29 -0
- package/ts_build/src/clients/fireworks.js.map +1 -1
- package/ts_build/src/clients/http.d.ts +3 -1
- package/ts_build/src/clients/http.js +76 -0
- package/ts_build/src/clients/http.js.map +1 -1
- package/ts_build/src/clients/index.d.ts +23 -9
- package/ts_build/src/clients/index.js +68 -20
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.d.ts +6 -0
- package/ts_build/src/clients/openai.js +45 -0
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/pricing/fireworks.js +3 -0
- package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
- package/ts_build/src/clients/types.d.ts +8 -0
- package/ts_build/src/commands/replay.d.ts +2 -0
- package/ts_build/src/commands/replay.js +324 -0
- package/ts_build/src/commands/replay.js.map +1 -0
- package/ts_build/src/processors/Base64ImageDetector.js +19 -12
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/services/modules/index.js.map +1 -1
- package/ts_build/tests/clients/AIClient.test.js +3 -3
- package/ts_build/tests/clients/AIClient.test.js.map +1 -1
- package/ts_build/tests/processors/Base64ImageDetector.test.js +88 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/ts_build/tests/processors/CustomVariables.test.js +8 -4
- package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
|
@@ -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
|
+
}
|
|
@@ -26,6 +26,14 @@ const IMAGE_PATH_REGEX =
|
|
|
26
26
|
|
|
27
27
|
const IMAGE_EXTENSIONS = ["png", "jpeg", "jpg", "gif", "webp", "bmp", "svg"];
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Sentinel prefix used to identify image-path hint blocks injected by this processor.
|
|
31
|
+
* Any existing hint block starting with this prefix is stripped before re-evaluation,
|
|
32
|
+
* ensuring the hint is idempotent and never duplicated across processor invocations.
|
|
33
|
+
*/
|
|
34
|
+
const TIP_SENTINEL = "[TIP: An image file path was detected:";
|
|
35
|
+
const TIP_SENTINEL_MULTI = "[TIP: Image file paths were detected.";
|
|
36
|
+
|
|
29
37
|
export class Base64ImageProcessor {
|
|
30
38
|
private imageDetail: "auto" | "low" | "high" = "auto";
|
|
31
39
|
private supportedFormats = ["png", "jpeg", "jpg", "gif", "webp"];
|
|
@@ -151,6 +159,13 @@ export class Base64ImageProcessor {
|
|
|
151
159
|
* The hint tells the model it can use loadImageAsBase64 to view the image.
|
|
152
160
|
*/
|
|
153
161
|
private addImagePathHint(text: string): string {
|
|
162
|
+
// If a hint was already injected into this text, leave it unchanged.
|
|
163
|
+
// Since we only process the last message per call, a hint is added once
|
|
164
|
+
// and then the message becomes historical — it is never processed again.
|
|
165
|
+
if (text.includes(TIP_SENTINEL) || text.includes(TIP_SENTINEL_MULTI)) {
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
const paths = this.findImageFilePaths(text);
|
|
155
170
|
if (paths.length === 0) return text;
|
|
156
171
|
|
|
@@ -262,31 +277,38 @@ export class Base64ImageProcessor {
|
|
|
262
277
|
|
|
263
278
|
createProcessor(): MessageProcessorFunction {
|
|
264
279
|
return (originalMessages: Message[], modifiedMessages: Message[]) => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
this.applyImagePathHintsToMessage(message);
|
|
279
|
-
}
|
|
280
|
+
// Only process the last (newest) message for hint injection.
|
|
281
|
+
// Processing all historical messages on every call would re-append hints
|
|
282
|
+
// to already-processed messages, causing the hint to multiply and busting
|
|
283
|
+
// Anthropic's prefix cache (which requires byte-identical prior messages).
|
|
284
|
+
const lastIndex = modifiedMessages.length - 1;
|
|
285
|
+
if (lastIndex < 0) return;
|
|
286
|
+
|
|
287
|
+
const lastMessage = modifiedMessages[lastIndex];
|
|
288
|
+
|
|
289
|
+
// Process user messages (images from user input)
|
|
290
|
+
if (lastMessage.role === "user") {
|
|
291
|
+
this.processMessageContent(lastMessage);
|
|
292
|
+
}
|
|
280
293
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
294
|
+
// Process tool messages (images from loadImageAsBase64 tool)
|
|
295
|
+
// Tool responses come back as JSON strings that need to be parsed
|
|
296
|
+
// and converted to proper image content before the agent sees them
|
|
297
|
+
if (lastMessage.role === "tool") {
|
|
298
|
+
this.processToolMessageContent(lastMessage);
|
|
299
|
+
// After processing tool content (which may not convert to image if it's plain text
|
|
300
|
+
// describing a screenshot path), add hints for any image file paths found in the text.
|
|
301
|
+
this.applyImagePathHintsToMessage(lastMessage);
|
|
302
|
+
}
|
|
286
303
|
|
|
287
|
-
|
|
288
|
-
|
|
304
|
+
// Also apply hints to assistant messages — e.g. when an assistant message
|
|
305
|
+
// contains the result of a screenshot tool that returned a file path.
|
|
306
|
+
if (lastMessage.role === "assistant") {
|
|
307
|
+
this.applyImagePathHintsToMessage(lastMessage);
|
|
289
308
|
}
|
|
309
|
+
|
|
310
|
+
// Process tool calls in any message
|
|
311
|
+
this.processToolCallArguments(lastMessage);
|
|
290
312
|
};
|
|
291
313
|
}
|
|
292
314
|
|
|
@@ -27,10 +27,11 @@ export class ModulesService {
|
|
|
27
27
|
|
|
28
28
|
const allModulePaths = config.modules;
|
|
29
29
|
|
|
30
|
-
// Search paths: .knowhow/node_modules first (where `knowhow modules install`
|
|
31
|
-
// puts packages), then
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// Search paths: local .knowhow/node_modules first (where `knowhow modules install`
|
|
31
|
+
// puts packages for this project), then global ~/.knowhow/node_modules, and finally
|
|
32
|
+
// cwd/node_modules as a last resort. Putting cwd/node_modules last avoids accidentally
|
|
33
|
+
// picking up workspace-symlinked dev versions of modules (e.g. when running knowhow
|
|
34
|
+
// from within the knowhow monorepo itself) instead of the properly-installed version.
|
|
34
35
|
const resolvePaths = [
|
|
35
36
|
path.join(process.cwd(), ".knowhow", "node_modules"),
|
|
36
37
|
path.join(os.homedir(), ".knowhow", "node_modules"),
|
|
@@ -116,9 +116,11 @@ describe("AIClient", () => {
|
|
|
116
116
|
aiClient.registerClient("fake", fakeClient);
|
|
117
117
|
aiClient.registerModels("fake", ["fake-model-1"]);
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// getClient now warns instead of throws for unregistered models,
|
|
120
|
+
// so the API itself can accept/reject the model (e.g. newly-released models)
|
|
121
|
+
const result = aiClient.getClient("fake", "non-existent-model");
|
|
122
|
+
expect(result.provider).toBe("fake");
|
|
123
|
+
expect(result.model).toBe("non-existent-model");
|
|
122
124
|
});
|
|
123
125
|
});
|
|
124
126
|
describe("detectProviderModel", () => {
|