@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.
Files changed (47) 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/http.ts +94 -0
  6. package/src/clients/index.ts +87 -24
  7. package/src/clients/openai.ts +58 -0
  8. package/src/clients/pricing/fireworks.ts +3 -0
  9. package/src/clients/types.ts +18 -0
  10. package/src/commands/replay.ts +424 -0
  11. package/src/processors/Base64ImageDetector.ts +44 -22
  12. package/src/services/modules/index.ts +5 -4
  13. package/tests/clients/AIClient.test.ts +5 -3
  14. package/tests/processors/Base64ImageDetector.test.ts +135 -0
  15. package/tests/processors/CustomVariables.test.ts +17 -7
  16. package/ts_build/package.json +1 -1
  17. package/ts_build/src/agents/base/base.js +8 -1
  18. package/ts_build/src/agents/base/base.js.map +1 -1
  19. package/ts_build/src/cli.js +2 -0
  20. package/ts_build/src/cli.js.map +1 -1
  21. package/ts_build/src/clients/fireworks.d.ts +9 -0
  22. package/ts_build/src/clients/fireworks.js +29 -0
  23. package/ts_build/src/clients/fireworks.js.map +1 -1
  24. package/ts_build/src/clients/http.d.ts +3 -1
  25. package/ts_build/src/clients/http.js +76 -0
  26. package/ts_build/src/clients/http.js.map +1 -1
  27. package/ts_build/src/clients/index.d.ts +23 -9
  28. package/ts_build/src/clients/index.js +68 -20
  29. package/ts_build/src/clients/index.js.map +1 -1
  30. package/ts_build/src/clients/openai.d.ts +6 -0
  31. package/ts_build/src/clients/openai.js +45 -0
  32. package/ts_build/src/clients/openai.js.map +1 -1
  33. package/ts_build/src/clients/pricing/fireworks.js +3 -0
  34. package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
  35. package/ts_build/src/clients/types.d.ts +8 -0
  36. package/ts_build/src/commands/replay.d.ts +2 -0
  37. package/ts_build/src/commands/replay.js +324 -0
  38. package/ts_build/src/commands/replay.js.map +1 -0
  39. package/ts_build/src/processors/Base64ImageDetector.js +19 -12
  40. package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
  41. package/ts_build/src/services/modules/index.js.map +1 -1
  42. package/ts_build/tests/clients/AIClient.test.js +3 -3
  43. package/ts_build/tests/clients/AIClient.test.js.map +1 -1
  44. package/ts_build/tests/processors/Base64ImageDetector.test.js +88 -0
  45. package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
  46. package/ts_build/tests/processors/CustomVariables.test.js +8 -4
  47. 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
- for (const message of modifiedMessages) {
266
- // Process user messages (images from user input)
267
- if (message.role === "user") {
268
- this.processMessageContent(message);
269
- }
270
-
271
- // Process tool messages (images from loadImageAsBase64 tool)
272
- // Tool responses come back as JSON strings that need to be parsed
273
- // and converted to proper image content before the agent sees them
274
- if (message.role === "tool") {
275
- this.processToolMessageContent(message);
276
- // After processing tool content (which may not convert to image if it's plain text
277
- // describing a screenshot path), add hints for any image file paths found in the text.
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
- // Also apply hints to assistant messages e.g. when an assistant message
282
- // contains the result of a screenshot tool that returned a file path.
283
- if (message.role === "assistant") {
284
- this.applyImagePathHintsToMessage(message);
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
- // Process tool calls in any message
288
- this.processToolCallArguments(message);
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 cwd node_modules, then global node_modules.
32
- // This allows modules installed via `knowhow modules install` to be found
33
- // even when knowhow itself is installed globally.
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
- expect(() => {
120
- aiClient.getClient("fake", "non-existent-model");
121
- }).toThrow("Model non-existent-model not registered for provider fake.");
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", () => {