@tyvm/knowhow 0.0.111 → 0.0.112

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 (40) hide show
  1. package/package.json +1 -1
  2. package/scripts/test-repetition-hint.ts +234 -0
  3. package/src/chat/CliChatService.ts +14 -6
  4. package/src/chat/modules/AgentModule.ts +4 -1
  5. package/src/chat/modules/ClipboardImageModule.ts +136 -0
  6. package/src/chat/modules/InternalChatModule.ts +3 -0
  7. package/src/chat/modules/RendererModule.ts +30 -2
  8. package/src/clients/xai.ts +20 -3
  9. package/src/processors/CustomVariables.ts +175 -0
  10. package/src/services/EventService.ts +5 -33
  11. package/src/utils/index.ts +1 -0
  12. package/tests/fixtures/fake-secret.txt +1 -0
  13. package/tests/manual/modalities/xai.modalities.test.ts +1 -1
  14. package/tests/processors/CustomVariables.test.ts +416 -1
  15. package/ts_build/package.json +1 -1
  16. package/ts_build/src/chat/CliChatService.js +9 -4
  17. package/ts_build/src/chat/CliChatService.js.map +1 -1
  18. package/ts_build/src/chat/modules/AgentModule.js +3 -1
  19. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  20. package/ts_build/src/chat/modules/ClipboardImageModule.d.ts +15 -0
  21. package/ts_build/src/chat/modules/ClipboardImageModule.js +157 -0
  22. package/ts_build/src/chat/modules/ClipboardImageModule.js.map +1 -0
  23. package/ts_build/src/chat/modules/InternalChatModule.d.ts +1 -0
  24. package/ts_build/src/chat/modules/InternalChatModule.js +3 -0
  25. package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
  26. package/ts_build/src/chat/modules/RendererModule.js +30 -1
  27. package/ts_build/src/chat/modules/RendererModule.js.map +1 -1
  28. package/ts_build/src/clients/xai.js +14 -2
  29. package/ts_build/src/clients/xai.js.map +1 -1
  30. package/ts_build/src/processors/CustomVariables.d.ts +10 -0
  31. package/ts_build/src/processors/CustomVariables.js +127 -0
  32. package/ts_build/src/processors/CustomVariables.js.map +1 -1
  33. package/ts_build/src/services/EventService.d.ts +0 -4
  34. package/ts_build/src/services/EventService.js +4 -15
  35. package/ts_build/src/services/EventService.js.map +1 -1
  36. package/ts_build/src/utils/index.js.map +1 -1
  37. package/ts_build/tests/manual/modalities/xai.modalities.test.js +1 -1
  38. package/ts_build/tests/manual/modalities/xai.modalities.test.js.map +1 -1
  39. package/ts_build/tests/processors/CustomVariables.test.js +347 -0
  40. package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.111",
3
+ "version": "0.0.112",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env ts-node
2
+ /**
3
+ * Test script: runs the repetition hint processor logic against a real agent metadata file
4
+ * and prints whether the hint would fire and why/why not.
5
+ *
6
+ * Usage:
7
+ * npx ts-node scripts/test-repetition-hint.ts [path-to-metadata.json]
8
+ */
9
+
10
+ import * as fs from "fs";
11
+
12
+ const metadataPath =
13
+ process.argv[2] ||
14
+ "/Users/micah/dev/knowhow-web/.knowhow/processes/agents/1779684572-can-you-try-setting-mysql-postgres-this-sandbox-with/metadata.json";
15
+
16
+ interface ToolCall {
17
+ id: string;
18
+ type: string;
19
+ function: {
20
+ name: string;
21
+ arguments: string | Record<string, any>;
22
+ };
23
+ }
24
+
25
+ interface Message {
26
+ role: string;
27
+ content?: string;
28
+ tool_calls?: ToolCall[];
29
+ }
30
+
31
+ // ---- Replica of processor logic (mirrors CustomVariables.ts) ----
32
+
33
+ function extractStringValues(obj: any, results: string[] = []): string[] {
34
+ if (typeof obj === "string") {
35
+ results.push(obj);
36
+ } else if (Array.isArray(obj)) {
37
+ for (const item of obj) extractStringValues(item, results);
38
+ } else if (obj && typeof obj === "object") {
39
+ for (const val of Object.values(obj)) extractStringValues(val, results);
40
+ }
41
+ return results;
42
+ }
43
+
44
+ function getToolCallStrings(toolCall: ToolCall): string[] {
45
+ try {
46
+ const args = toolCall.function.arguments;
47
+ const parsed = typeof args === "string" ? JSON.parse(args) : args;
48
+ return extractStringValues(parsed);
49
+ } catch {
50
+ const args = toolCall.function.arguments;
51
+ return [typeof args === "string" ? args : JSON.stringify(args)];
52
+ }
53
+ }
54
+
55
+ function collectToolCallStrings(
56
+ messages: Message[],
57
+ minLength: number
58
+ ): Array<{ value: string; toolName: string }> {
59
+ const collected: Array<{ value: string; toolName: string }> = [];
60
+ for (const message of messages) {
61
+ if (!message.tool_calls) continue;
62
+ for (const toolCall of message.tool_calls) {
63
+ const strings = getToolCallStrings(toolCall);
64
+ for (const str of strings) {
65
+ if (str.length >= minLength) {
66
+ collected.push({ value: str, toolName: toolCall.function.name });
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return collected;
72
+ }
73
+
74
+ function longestCommonSubstring(a: string, b: string, minLength: number): string | null {
75
+ let best = "";
76
+ for (let i = 0; i < a.length - minLength + 1; i++) {
77
+ for (let j = a.length; j > i + minLength - 1; j--) {
78
+ const sub = a.slice(i, j);
79
+ if (sub.length <= best.length) break; // already found longer, skip shorter
80
+ if (b.includes(sub)) {
81
+ best = sub;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ return best.length >= minLength ? best : null;
87
+ }
88
+
89
+ function runProcessor(
90
+ messages: Message[],
91
+ minLength = 50,
92
+ minRepetitions = 2,
93
+ minSubstringLength = 50
94
+ ): { wouldHint: boolean; repeatedTools: string[]; details: Map<string, { count: number; tools: Set<string> }> } {
95
+ const stringCounts = new Map<string, { count: number; tools: Set<string> }>();
96
+ const toolStrings = collectToolCallStrings(messages, minLength);
97
+
98
+ // Step 1: exact full-string matches
99
+ for (const { value, toolName } of toolStrings) {
100
+ const existing = stringCounts.get(value);
101
+ if (existing) {
102
+ existing.count++;
103
+ existing.tools.add(toolName);
104
+ } else {
105
+ stringCounts.set(value, { count: 1, tools: new Set([toolName]) });
106
+ }
107
+ }
108
+
109
+ // Step 2: repeated substrings across different full strings
110
+ // e.g. the same JWT embedded in many different commands
111
+ const substringCounts = new Map<string, { count: number; tools: Set<string> }>();
112
+ for (let i = 0; i < toolStrings.length; i++) {
113
+ for (let j = i + 1; j < toolStrings.length; j++) {
114
+ const a = toolStrings[i];
115
+ const b = toolStrings[j];
116
+ if (a.value === b.value) continue; // already handled by exact match
117
+ const common = longestCommonSubstring(a.value, b.value, minSubstringLength);
118
+ if (common) {
119
+ const existing = substringCounts.get(common);
120
+ if (existing) {
121
+ existing.count++;
122
+ existing.tools.add(a.toolName);
123
+ existing.tools.add(b.toolName);
124
+ } else {
125
+ substringCounts.set(common, { count: 1, tools: new Set([a.toolName, b.toolName]) });
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // Merge substring counts: count = number of unique pairs, count+1 = number of occurrences
132
+ for (const [sub, info] of substringCounts.entries()) {
133
+ if (info.count + 1 >= minRepetitions && !stringCounts.has(sub)) {
134
+ stringCounts.set(sub, { count: info.count + 1, tools: info.tools });
135
+ }
136
+ }
137
+
138
+ // Find entries that exceed the repetition threshold
139
+ const repeatedTools: string[] = [];
140
+ for (const [, info] of stringCounts.entries()) {
141
+ if (info.count >= minRepetitions) {
142
+ for (const toolName of info.tools) {
143
+ if (!repeatedTools.includes(toolName)) repeatedTools.push(toolName);
144
+ }
145
+ }
146
+ }
147
+
148
+ return { wouldHint: repeatedTools.length > 0, repeatedTools, details: stringCounts };
149
+ }
150
+
151
+ // ---- Main ----
152
+
153
+ const raw = fs.readFileSync(metadataPath, "utf-8");
154
+ const metadata = JSON.parse(raw);
155
+ const threads: Message[][] = metadata.threads || [];
156
+
157
+ console.log(`\n=== Repetition Hint Processor Test ===`);
158
+ console.log(`File: ${metadataPath}`);
159
+ console.log(`Threads: ${threads.length}`);
160
+
161
+ for (let ti = 0; ti < threads.length; ti++) {
162
+ const thread = threads[ti];
163
+ const toolCallMsgs = thread.filter((m) => m.tool_calls && m.tool_calls.length > 0);
164
+ console.log(`\n--- Thread ${ti}: ${thread.length} messages, ${toolCallMsgs.length} with tool calls ---`);
165
+
166
+ // Run with OLD logic (exact matches only)
167
+ console.log(`\n[OLD Processor] exact full-string matches only, minLength=50, minRepetitions=2`);
168
+ const oldResult = runProcessor(thread, 50, 2, Infinity);
169
+ if (oldResult.wouldHint) {
170
+ console.log(`✅ Would hint! Repeated tools: ${oldResult.repeatedTools.join(", ")}`);
171
+ } else {
172
+ console.log(`❌ Would NOT hint (bug - missed embedded repetitions).`);
173
+ }
174
+
175
+ // Run with NEW logic (exact + substring)
176
+ console.log(`\n[NEW Processor] exact + substring matching, minLength=50, minRepetitions=2, minSubstringLength=50`);
177
+ const newResult = runProcessor(thread, 50, 2, 50);
178
+ if (newResult.wouldHint) {
179
+ console.log(`✅ Would hint! Repeated tools: ${newResult.repeatedTools.join(", ")}`);
180
+ // Show top repeated substrings
181
+ const repeated = Array.from(newResult.details.entries())
182
+ .filter(([, info]) => info.count >= 2)
183
+ .sort((a, b) => b[1].count - a[1].count)
184
+ .slice(0, 5);
185
+ console.log(`\n Top repeated values (count, tools, preview):`);
186
+ for (const [str, info] of repeated) {
187
+ console.log(` count=${info.count}, tools=${[...info.tools].join(",")}`);
188
+ console.log(` value=${JSON.stringify(str.slice(0, 120))}`);
189
+ }
190
+ } else {
191
+ console.log(`❌ Would NOT hint.`);
192
+ // Show top large strings for diagnosis
193
+ const toolStrings = collectToolCallStrings(thread, 50);
194
+ console.log(`\n Total large strings in tool calls: ${toolStrings.length}`);
195
+ const top = toolStrings.slice(0, 3);
196
+ for (const { value, toolName } of top) {
197
+ console.log(` tool=${toolName}, len=${value.length}, preview=${JSON.stringify(value.slice(0, 100))}`);
198
+ }
199
+ }
200
+
201
+ // Check for Bearer tokens specifically
202
+ console.log(`\n[Bearer Token Check]`);
203
+ const jwtMap = new Map<string, { count: number; tools: Set<string> }>();
204
+ const jwtPattern = /Bearer ([\w\-\.]+)/;
205
+ for (const msg of thread) {
206
+ if (!msg.tool_calls) continue;
207
+ for (const tc of msg.tool_calls) {
208
+ const args = typeof tc.function.arguments === "string"
209
+ ? tc.function.arguments
210
+ : JSON.stringify(tc.function.arguments);
211
+ const match = jwtPattern.exec(args);
212
+ if (match) {
213
+ const jwt = match[1];
214
+ const existing = jwtMap.get(jwt);
215
+ if (existing) {
216
+ existing.count++;
217
+ existing.tools.add(tc.function.name);
218
+ } else {
219
+ jwtMap.set(jwt, { count: 1, tools: new Set([tc.function.name]) });
220
+ }
221
+ }
222
+ }
223
+ }
224
+ if (jwtMap.size > 0) {
225
+ for (const [jwt, info] of jwtMap.entries()) {
226
+ console.log(` ⚠️ Bearer token appears ${info.count} times in: ${[...info.tools].join(", ")}`);
227
+ console.log(` ${jwt.slice(0, 80)}...`);
228
+ }
229
+ } else {
230
+ console.log(` No Bearer tokens found in tool calls.`);
231
+ }
232
+ }
233
+
234
+ console.log("\n=== Done ===\n");
@@ -216,12 +216,20 @@ export class CliChatService implements ChatService {
216
216
  } else {
217
217
  // Input starts with "/" but no matching command found - warn the user
218
218
  const availableCommands = this.getCommandsForActiveModes();
219
- console.log(
220
- `Unknown command "/${commandName}". Available commands: ${availableCommands
221
- .map((cmd) => `/${cmd.name}`)
222
- .join(", ")}`
223
- );
224
- return true;
219
+ // If the input looks like a filepath (contains path separators or file extensions),
220
+ // don't treat it as a failed command - let it fall through to modules
221
+ const looksLikeFilepath =
222
+ commandName.includes("/") ||
223
+ commandName.includes(".") ||
224
+ commandName.includes("\\");
225
+ if (!looksLikeFilepath) {
226
+ console.log(
227
+ `Unknown command "/${commandName}". Available commands: ${availableCommands
228
+ .map((cmd) => `/${cmd.name}`)
229
+ .join(", ")}`
230
+ );
231
+ return true;
232
+ }
225
233
  }
226
234
  }
227
235
 
@@ -756,10 +756,12 @@ export class AgentModule extends BaseChatModule {
756
756
  ),
757
757
  ];
758
758
 
759
+ const customVariables = new CustomVariables(agent.tools);
760
+
759
761
  agent.messageProcessor.setProcessors("pre_call", [
760
762
  new Base64ImageProcessor(agent.tools).createProcessor(),
761
763
  ...caching,
762
- new CustomVariables(agent.tools).createProcessor(),
764
+ customVariables.createProcessor(),
763
765
  ]);
764
766
 
765
767
  agent.messageProcessor.setProcessors("post_call", [
@@ -770,6 +772,7 @@ export class AgentModule extends BaseChatModule {
770
772
  agent.messageProcessor.setProcessors("post_tools", [
771
773
  new Base64ImageProcessor(agent.tools).createProcessor(),
772
774
  ...caching,
775
+ customVariables.createRepetitionHintProcessor(),
773
776
  ]);
774
777
 
775
778
  // Set up event listeners
@@ -0,0 +1,136 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ import { ChatModule, ChatCommand, ChatMode, ChatContext } from "../types";
7
+ import { CliChatService } from "../CliChatService";
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ /**
12
+ * Attempts to capture an image from the system clipboard and save it to a temp file.
13
+ * Returns the filepath if successful, or null if the clipboard has no image.
14
+ */
15
+ export async function captureClipboardImage(): Promise<string | null> {
16
+ const tmpFile = path.join(os.tmpdir(), `knowhow-clipboard-${Date.now()}.png`);
17
+
18
+ const platform = process.platform;
19
+
20
+ try {
21
+ if (platform === "darwin") {
22
+ // macOS: use pngpaste if available, fall back to osascript
23
+ try {
24
+ await execAsync(`pngpaste "${tmpFile}"`);
25
+ if (fs.existsSync(tmpFile)) return tmpFile;
26
+ } catch {
27
+ // pngpaste not available or no image in clipboard, try osascript
28
+ const script = `
29
+ tell application "System Events"
30
+ set theImage to the clipboard as «class PNGf»
31
+ set fileRef to open for access POSIX file "${tmpFile}" with write permission
32
+ write theImage to fileRef
33
+ close access fileRef
34
+ end tell
35
+ `;
36
+ try {
37
+ await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`);
38
+ if (fs.existsSync(tmpFile)) return tmpFile;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ } else if (platform === "linux") {
44
+ // Linux: try xclip first, then xsel
45
+ try {
46
+ await execAsync(`xclip -selection clipboard -t image/png -o > "${tmpFile}"`);
47
+ if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
48
+ } catch {
49
+ try {
50
+ await execAsync(`xsel --clipboard --output > "${tmpFile}"`);
51
+ if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ } else if (platform === "win32") {
57
+ // Windows: use PowerShell
58
+ const ps = `
59
+ Add-Type -AssemblyName System.Windows.Forms
60
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
61
+ if ($img -ne $null) {
62
+ $img.Save('${tmpFile.replace(/\\/g, "\\\\")}')
63
+ Write-Output 'saved'
64
+ } else {
65
+ Write-Output 'no image'
66
+ }
67
+ `;
68
+ const { stdout } = await execAsync(`powershell -Command "${ps.replace(/"/g, '\\"')}"`);
69
+ if (stdout.trim() === "saved" && fs.existsSync(tmpFile)) return tmpFile;
70
+ }
71
+ } catch {
72
+ // Silently fail - clipboard doesn't contain an image
73
+ }
74
+
75
+ // Clean up empty file if created
76
+ try {
77
+ if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size === 0) {
78
+ fs.unlinkSync(tmpFile);
79
+ }
80
+ } catch { /* ignore */ }
81
+
82
+ return null;
83
+ }
84
+
85
+ export class ClipboardImageModule implements ChatModule {
86
+ name = "clipboard-image";
87
+ description = "Handles clipboard image paste detection and capture";
88
+ commands: ChatCommand[] = [];
89
+ modes: ChatMode[] = [];
90
+
91
+ getCommands(): ChatCommand[] {
92
+ return [
93
+ {
94
+ name: "paste",
95
+ description: "Capture image from clipboard and send to agent",
96
+ handler: this.handlePasteCommand.bind(this),
97
+ },
98
+ ];
99
+ }
100
+
101
+ getModes(): ChatMode[] {
102
+ return [];
103
+ }
104
+
105
+ async initialize(chatService: CliChatService): Promise<void> {
106
+ // Register /paste command
107
+ chatService.registerCommand({
108
+ name: "paste",
109
+ description: "Capture image from clipboard and send to agent",
110
+ handler: this.handlePasteCommand.bind(this),
111
+ });
112
+
113
+ }
114
+
115
+ private async handlePasteCommand(args: string[]): Promise<{ handled: boolean; contents?: string }> {
116
+ console.log("🔍 Checking clipboard for image...");
117
+ const filepath = await captureClipboardImage();
118
+
119
+ if (!filepath) {
120
+ console.log("No image found in clipboard. Copy an image first, then use /paste.");
121
+ return { handled: true };
122
+ }
123
+
124
+ console.log(`📋 Image captured: ${filepath}`);
125
+
126
+ // Return as not-handled so the filepath flows through to modules (AgentModule)
127
+ return { handled: false, contents: filepath };
128
+ }
129
+
130
+ async handleInput(input: string, context: ChatContext): Promise<boolean> {
131
+ return false; // This module only handles commands
132
+ }
133
+
134
+ async cleanup(): Promise<void> {
135
+ }
136
+ }
@@ -12,8 +12,10 @@ import { ShellCommandModule } from "./ShellCommandModule";
12
12
  import { RendererModule } from "./RendererModule";
13
13
  import { SessionsModule } from "./SessionsModule";
14
14
  import { RemoteSyncModule } from "./RemoteSyncModule";
15
+ import { ClipboardImageModule } from "./ClipboardImageModule";
15
16
 
16
17
  export class InternalChatModule implements ChatModule {
18
+ private clipboardImageModule = new ClipboardImageModule();
17
19
  private chatService?: CliChatService;
18
20
  name = "internal";
19
21
  description = "Internal chat module aggregating all functionality";
@@ -57,6 +59,7 @@ export class InternalChatModule implements ChatModule {
57
59
  await this.customCommandsModule.initialize(chatService);
58
60
  await this.remoteSyncModule.initialize(chatService);
59
61
  await this.shellCommandModule.initialize(chatService);
62
+ await this.clipboardImageModule.initialize(chatService);
60
63
 
61
64
  // Register our own commands (exit and multi) - not duplicated by BaseChatModule
62
65
  chatService.registerCommand({
@@ -6,6 +6,7 @@ import { ChatCommand, ChatMode, ChatContext, ChatService } from "../types";
6
6
  import { loadRenderer } from "../renderer/loadRenderer";
7
7
  import { ConsoleRenderer } from "../renderer";
8
8
  import { AgentModule } from "./AgentModule";
9
+ import { getConfig, updateConfig } from "../../config";
9
10
 
10
11
  const BUILTIN_RENDERERS = ["basic", "compact", "fancy"];
11
12
 
@@ -27,10 +28,25 @@ export class RendererModule extends BaseChatModule {
27
28
  async initialize(service: ChatService): Promise<void> {
28
29
  await super.initialize(service);
29
30
 
30
- // Initialize context.renderer with a default ConsoleRenderer if not already set
31
+ // Initialize context.renderer from config or default to ConsoleRenderer
31
32
  const context = service.getContext();
32
33
  if (!context.renderer) {
33
- service.setContext({ renderer: new ConsoleRenderer() });
34
+ try {
35
+ const config = await getConfig();
36
+ const savedName = config.chat?.renderer;
37
+ if (savedName && savedName !== "basic") {
38
+ const savedRenderer = await loadRenderer(savedName);
39
+ this.currentRendererName = savedName;
40
+ service.setContext({ renderer: savedRenderer });
41
+ } else {
42
+ service.setContext({ renderer: new ConsoleRenderer() });
43
+ if (savedName === "basic" || !savedName) {
44
+ this.currentRendererName = "basic";
45
+ }
46
+ }
47
+ } catch {
48
+ service.setContext({ renderer: new ConsoleRenderer() });
49
+ }
34
50
  }
35
51
  }
36
52
 
@@ -85,6 +101,18 @@ export class RendererModule extends BaseChatModule {
85
101
  this.chatService?.setContext({ renderer: newRenderer });
86
102
  this.currentRendererName = specifier;
87
103
 
104
+ // Persist renderer preference to config
105
+ try {
106
+ const config = await getConfig();
107
+ config.chat = {
108
+ ...config.chat,
109
+ renderer: specifier,
110
+ };
111
+ await updateConfig(config);
112
+ } catch (saveErr: any) {
113
+ console.warn(`⚠️ Could not save renderer preference: ${saveErr.message}`);
114
+ }
115
+
88
116
  // Rewire agent rendering event listeners to the new renderer so live
89
117
  // events are forwarded correctly even mid-session.
90
118
  // This works because wireAgentRendering() always reads `this.renderer`
@@ -425,7 +425,7 @@ export class GenericXAIClient implements GenericClient {
425
425
  options: VideoStatusOptions
426
426
  ): Promise<VideoStatusResponse> {
427
427
  const statusResponse = await fetch(
428
- `https://api.x.ai/v1/videos/${options.jobId}`,
428
+ `https://api.x.ai/v1/videos/generations/${options.jobId}`,
429
429
  {
430
430
  method: "GET",
431
431
  headers: {
@@ -485,8 +485,25 @@ export class GenericXAIClient implements GenericClient {
485
485
  async downloadVideo(
486
486
  options: FileDownloadOptions
487
487
  ): Promise<FileDownloadResponse> {
488
- // XAI returns a URL for the video, not raw bytes from their API
489
- const url = options.uri || options.fileId;
488
+ // XAI returns a presigned URL from the status endpoint, not raw bytes.
489
+ // options.fileId is the request_id (jobId) — we need to fetch the status
490
+ // to get the actual video URL, then download from there.
491
+ let url = options.uri;
492
+ if (!url) {
493
+ const statusResponse = await fetch(
494
+ `https://api.x.ai/v1/videos/generations/${options.fileId}`,
495
+ { headers: { Authorization: `Bearer ${this.apiKey}` } }
496
+ );
497
+ if (!statusResponse.ok) {
498
+ const errorText = await statusResponse.text();
499
+ throw new Error(`XAI video status fetch failed: ${statusResponse.status} ${errorText}`);
500
+ }
501
+ const statusData = await statusResponse.json();
502
+ url = statusData.video?.url;
503
+ if (!url) {
504
+ throw new Error(`XAI video not ready yet or no URL available (status: ${statusData.status})`);
505
+ }
506
+ }
490
507
 
491
508
  const response = await fetch(url);
492
509
  if (!response.ok) {