@tyvm/knowhow 0.0.111 → 0.0.113
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/scripts/test-repetition-hint.ts +234 -0
- package/src/chat/CliChatService.ts +14 -6
- package/src/chat/modules/AgentModule.ts +4 -1
- package/src/chat/modules/ClipboardImageModule.ts +136 -0
- package/src/chat/modules/InternalChatModule.ts +3 -0
- package/src/chat/modules/RendererModule.ts +30 -2
- package/src/clients/xai.ts +42 -20
- package/src/processors/CustomVariables.ts +175 -0
- package/src/services/EventService.ts +5 -33
- package/src/utils/index.ts +1 -0
- package/tests/fixtures/fake-secret.txt +1 -0
- package/tests/manual/modalities/xai.modalities.test.ts +42 -55
- package/tests/processors/CustomVariables.test.ts +416 -1
- package/ts_build/package.json +1 -1
- package/ts_build/src/chat/CliChatService.js +9 -4
- package/ts_build/src/chat/CliChatService.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +3 -1
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/ClipboardImageModule.d.ts +15 -0
- package/ts_build/src/chat/modules/ClipboardImageModule.js +157 -0
- package/ts_build/src/chat/modules/ClipboardImageModule.js.map +1 -0
- package/ts_build/src/chat/modules/InternalChatModule.d.ts +1 -0
- package/ts_build/src/chat/modules/InternalChatModule.js +3 -0
- package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
- package/ts_build/src/chat/modules/RendererModule.js +30 -1
- package/ts_build/src/chat/modules/RendererModule.js.map +1 -1
- package/ts_build/src/clients/xai.js +36 -19
- package/ts_build/src/clients/xai.js.map +1 -1
- package/ts_build/src/processors/CustomVariables.d.ts +10 -0
- package/ts_build/src/processors/CustomVariables.js +127 -0
- package/ts_build/src/processors/CustomVariables.js.map +1 -1
- package/ts_build/src/services/EventService.d.ts +0 -4
- package/ts_build/src/services/EventService.js +4 -15
- package/ts_build/src/services/EventService.js.map +1 -1
- package/ts_build/src/utils/index.js.map +1 -1
- package/ts_build/tests/manual/modalities/xai.modalities.test.js +29 -46
- package/ts_build/tests/manual/modalities/xai.modalities.test.js.map +1 -1
- package/ts_build/tests/processors/CustomVariables.test.js +347 -0
- package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
|
@@ -2,6 +2,7 @@ import { Message } from "../clients/types";
|
|
|
2
2
|
import { MessageProcessorFunction } from "../services/MessageProcessor";
|
|
3
3
|
import { ToolsService } from "../services";
|
|
4
4
|
import { Tool } from "../clients";
|
|
5
|
+
import { ToolCall } from "../clients/types";
|
|
5
6
|
|
|
6
7
|
interface VariableStorage {
|
|
7
8
|
[name: string]: any;
|
|
@@ -238,6 +239,180 @@ export class CustomVariables {
|
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Extracts all string values from a JSON-parsed object (recursively)
|
|
244
|
+
*/
|
|
245
|
+
private extractStringValues(obj: any, results: string[] = []): string[] {
|
|
246
|
+
if (typeof obj === "string") {
|
|
247
|
+
results.push(obj);
|
|
248
|
+
} else if (Array.isArray(obj)) {
|
|
249
|
+
for (const item of obj) {
|
|
250
|
+
this.extractStringValues(item, results);
|
|
251
|
+
}
|
|
252
|
+
} else if (obj && typeof obj === "object") {
|
|
253
|
+
for (const val of Object.values(obj)) {
|
|
254
|
+
this.extractStringValues(val, results);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Collects all large string values from tool calls, keyed by tool name.
|
|
262
|
+
* Returns an array of {value, toolName} pairs.
|
|
263
|
+
*/
|
|
264
|
+
private collectToolCallStrings(
|
|
265
|
+
messages: Message[],
|
|
266
|
+
minLength: number
|
|
267
|
+
): Array<{ value: string; toolName: string }> {
|
|
268
|
+
const collected: Array<{ value: string; toolName: string }> = [];
|
|
269
|
+
for (const message of messages) {
|
|
270
|
+
if (!message.tool_calls) continue;
|
|
271
|
+
for (const toolCall of message.tool_calls) {
|
|
272
|
+
const strings = this.getToolCallStrings(toolCall);
|
|
273
|
+
for (const str of strings) {
|
|
274
|
+
if (str.length >= minLength) {
|
|
275
|
+
collected.push({ value: str, toolName: toolCall.function.name });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return collected;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Finds the longest common substring between two strings that is >= minLength.
|
|
285
|
+
* Returns the substring or null if none found.
|
|
286
|
+
*/
|
|
287
|
+
private longestCommonSubstring(a: string, b: string, minLength: number): string | null {
|
|
288
|
+
let best = "";
|
|
289
|
+
for (let i = 0; i < a.length - minLength + 1; i++) {
|
|
290
|
+
for (let j = a.length; j > i + minLength - 1; j--) {
|
|
291
|
+
const sub = a.slice(i, j);
|
|
292
|
+
if (sub.length <= best.length) break; // already found longer
|
|
293
|
+
if (b.includes(sub)) {
|
|
294
|
+
best = sub;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return best.length >= minLength ? best : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Extracts all string values from a tool call's arguments
|
|
304
|
+
*/
|
|
305
|
+
private getToolCallStrings(toolCall: ToolCall): string[] {
|
|
306
|
+
try {
|
|
307
|
+
const parsed = JSON.parse(toolCall.function.arguments);
|
|
308
|
+
return this.extractStringValues(parsed);
|
|
309
|
+
} catch {
|
|
310
|
+
// If not JSON, treat the whole arguments string as a single value
|
|
311
|
+
return [toolCall.function.arguments];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Creates a processor that scans messages for repeated large string values
|
|
317
|
+
* in tool call arguments, and appends a hint suggesting variable storage.
|
|
318
|
+
*
|
|
319
|
+
* This helps the LLM discover that it can avoid re-outputting long strings
|
|
320
|
+
* (e.g. JWTs, file contents) by storing them once with setVariable or
|
|
321
|
+
* storeToolCallToVariable and then referencing them via {{varName}}.
|
|
322
|
+
*/
|
|
323
|
+
createRepetitionHintProcessor(options: {
|
|
324
|
+
minLength?: number; // Minimum string length to consider (default: 50)
|
|
325
|
+
minRepetitions?: number; // Minimum occurrences to trigger hint (default: 2)
|
|
326
|
+
minSubstringLength?: number; // Minimum repeated substring length (default: 50)
|
|
327
|
+
recentMessagesWindow?: number; // Only scan the last N messages (default: 10)
|
|
328
|
+
} = {}): MessageProcessorFunction {
|
|
329
|
+
const minLength = options.minLength ?? 50;
|
|
330
|
+
const minRepetitions = options.minRepetitions ?? 2;
|
|
331
|
+
const minSubstringLength = options.minSubstringLength ?? 50;
|
|
332
|
+
const recentMessagesWindow = options.recentMessagesWindow ?? 10;
|
|
333
|
+
|
|
334
|
+
return async (originalMessages: Message[], modifiedMessages: Message[]) => {
|
|
335
|
+
// Count occurrences of each string value across all tool call arguments
|
|
336
|
+
const stringCounts = new Map<string, { count: number; toolNames: Set<string> }>();
|
|
337
|
+
|
|
338
|
+
// Only scan the most recent N messages to keep cost bounded
|
|
339
|
+
const recentMessages = modifiedMessages.slice(-recentMessagesWindow);
|
|
340
|
+
|
|
341
|
+
// Step 1: exact full-string matches
|
|
342
|
+
const toolStrings = this.collectToolCallStrings(recentMessages, minLength);
|
|
343
|
+
|
|
344
|
+
for (const { value, toolName } of toolStrings) {
|
|
345
|
+
const existing = stringCounts.get(value);
|
|
346
|
+
if (existing) {
|
|
347
|
+
existing.count++;
|
|
348
|
+
existing.toolNames.add(toolName);
|
|
349
|
+
} else {
|
|
350
|
+
stringCounts.set(value, { count: 1, toolNames: new Set([toolName]) });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Step 2: detect repeated substrings across different full strings
|
|
355
|
+
// e.g. the same JWT embedded in many different commands
|
|
356
|
+
const substringCounts = new Map<string, { count: number; toolNames: Set<string> }>();
|
|
357
|
+
|
|
358
|
+
for (let i = 0; i < toolStrings.length; i++) {
|
|
359
|
+
for (let j = i + 1; j < toolStrings.length; j++) {
|
|
360
|
+
const a = toolStrings[i];
|
|
361
|
+
const b = toolStrings[j];
|
|
362
|
+
// Skip if the full strings are identical (already counted above)
|
|
363
|
+
if (a.value === b.value) continue;
|
|
364
|
+
|
|
365
|
+
const common = this.longestCommonSubstring(a.value, b.value, minSubstringLength);
|
|
366
|
+
if (common) {
|
|
367
|
+
const existing = substringCounts.get(common);
|
|
368
|
+
if (existing) {
|
|
369
|
+
existing.count++;
|
|
370
|
+
existing.toolNames.add(a.toolName);
|
|
371
|
+
existing.toolNames.add(b.toolName);
|
|
372
|
+
} else {
|
|
373
|
+
substringCounts.set(common, {
|
|
374
|
+
count: 1,
|
|
375
|
+
toolNames: new Set([a.toolName, b.toolName]),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Merge substring counts: count = number of unique pairs, so count+1 = occurrences
|
|
383
|
+
for (const [sub, info] of substringCounts.entries()) {
|
|
384
|
+
if (info.count + 1 >= minRepetitions) {
|
|
385
|
+
const existing = stringCounts.get(sub);
|
|
386
|
+
if (!existing) {
|
|
387
|
+
stringCounts.set(sub, { count: info.count + 1, toolNames: info.toolNames });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Find entries that exceed the repetition threshold
|
|
393
|
+
const repeatedTools: string[] = [];
|
|
394
|
+
for (const [str, info] of stringCounts.entries()) {
|
|
395
|
+
if (info.count >= minRepetitions) {
|
|
396
|
+
for (const toolName of info.toolNames) {
|
|
397
|
+
if (!repeatedTools.includes(toolName)) {
|
|
398
|
+
repeatedTools.push(toolName);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (repeatedTools.length > 0) {
|
|
405
|
+
modifiedMessages.push({
|
|
406
|
+
role: "user",
|
|
407
|
+
content:
|
|
408
|
+
`⚠️ Tool inputs have large repetitions detected in: ${repeatedTools.join(", ")}. ` +
|
|
409
|
+
`Consider storing repeated values with \`setVariable\` or \`storeToolCallToVariable\`, ` +
|
|
410
|
+
`then reference them via {{variableName}} in future tool calls to avoid re-outputting large strings.`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
241
416
|
/**
|
|
242
417
|
* Registers all custom variable tools with the ToolsService
|
|
243
418
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
2
|
import { IAgent } from "../agents/interface";
|
|
3
|
+
import { logger } from "../logger";
|
|
3
4
|
|
|
4
5
|
export type LogLevel = "info" | "warn" | "error";
|
|
5
6
|
|
|
@@ -36,9 +37,9 @@ type ManagedListenerRecord = {
|
|
|
36
37
|
/**
|
|
37
38
|
* Default console handler for plugin:log events.
|
|
38
39
|
* Active when no renderer has taken over (e.g. worker mode, CLI before chat starts).
|
|
39
|
-
*
|
|
40
|
+
* Respects logger.silence() — if the logger is silenced, this handler is a no-op.
|
|
40
41
|
*
|
|
41
|
-
*
|
|
42
|
+
* Uses process.stdout/stderr directly to avoid infinite recursion
|
|
42
43
|
* with logger.installConsoleOverload() which overrides console.log/warn.
|
|
43
44
|
*/
|
|
44
45
|
function defaultConsoleLogHandler(event: {
|
|
@@ -46,6 +47,7 @@ function defaultConsoleLogHandler(event: {
|
|
|
46
47
|
message: string;
|
|
47
48
|
level: LogLevel;
|
|
48
49
|
}): void {
|
|
50
|
+
if (logger.isSilenced()) return;
|
|
49
51
|
const prefix = event.source ? `[${event.source}] ` : "";
|
|
50
52
|
const line = `${prefix}${event.message}\n`;
|
|
51
53
|
switch (event.level) {
|
|
@@ -63,8 +65,6 @@ function defaultConsoleLogHandler(event: {
|
|
|
63
65
|
export class EventService extends EventEmitter {
|
|
64
66
|
private blockingHandlers: Map<string, EventHandler[]> = new Map();
|
|
65
67
|
private managedListeners: Map<string, ManagedListenerRecord> = new Map();
|
|
66
|
-
private defaultLoggerActive = true;
|
|
67
|
-
private boundDefaultLogHandler = defaultConsoleLogHandler;
|
|
68
68
|
|
|
69
69
|
eventTypes = {
|
|
70
70
|
agentMsg: "agent:msg",
|
|
@@ -76,35 +76,7 @@ export class EventService extends EventEmitter {
|
|
|
76
76
|
constructor() {
|
|
77
77
|
super();
|
|
78
78
|
this.setMaxListeners(100);
|
|
79
|
-
|
|
80
|
-
// even before a renderer is attached (worker mode, module loading, etc.)
|
|
81
|
-
this.on(this.eventTypes.pluginLog, this.boundDefaultLogHandler);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Suppress the default console logger.
|
|
86
|
-
* Call this when a renderer has taken over and will handle plugin:log events.
|
|
87
|
-
* This prevents double-printing when both the renderer and the default handler fire.
|
|
88
|
-
*/
|
|
89
|
-
suppressDefaultLogger(): void {
|
|
90
|
-
if (this.defaultLoggerActive) {
|
|
91
|
-
this.removeListener(
|
|
92
|
-
this.eventTypes.pluginLog,
|
|
93
|
-
this.boundDefaultLogHandler
|
|
94
|
-
);
|
|
95
|
-
this.defaultLoggerActive = false;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Restore the default console logger.
|
|
101
|
-
* Call this when the renderer is torn down.
|
|
102
|
-
*/
|
|
103
|
-
restoreDefaultLogger(): void {
|
|
104
|
-
if (!this.defaultLoggerActive) {
|
|
105
|
-
this.on(this.eventTypes.pluginLog, this.boundDefaultLogHandler);
|
|
106
|
-
this.defaultLoggerActive = true;
|
|
107
|
-
}
|
|
79
|
+
this.on(this.eventTypes.pluginLog, defaultConsoleLogHandler);
|
|
108
80
|
}
|
|
109
81
|
|
|
110
82
|
/**
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmYWtlLXVzZXItaWQiLCJuYW1lIjoiRmFrZSBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.FAKE_SECRET_DO_NOT_USE
|
|
@@ -98,7 +98,7 @@ describe("XAI (Grok) Modalities", () => {
|
|
|
98
98
|
const dataUrl = `data:image/png;base64,${base64Image}`;
|
|
99
99
|
|
|
100
100
|
const response = await client.createCompletion("xai", {
|
|
101
|
-
model: Models.xai.
|
|
101
|
+
model: Models.xai.Grok_4_20_NonReasoning,
|
|
102
102
|
messages: [
|
|
103
103
|
{
|
|
104
104
|
role: "user",
|
|
@@ -199,42 +199,42 @@ describe("XAI (Grok) Modalities", () => {
|
|
|
199
199
|
return;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
const apiKey = process.env.XAI_API_KEY!;
|
|
203
202
|
const prompt =
|
|
204
203
|
"A cyberpunk cityscape at night with flying cars and neon lights, " +
|
|
205
204
|
"cinematic camera slowly panning upward to reveal the skyline";
|
|
206
205
|
|
|
207
|
-
// Helper: poll a known
|
|
208
|
-
async function pollAndSave(
|
|
206
|
+
// Helper: poll a known job ID via client until done, then save results
|
|
207
|
+
async function pollAndSave(jobId: string) {
|
|
209
208
|
const maxPollingTime = 20 * 60 * 1000; // 20 minutes
|
|
210
209
|
const pollingInterval = 5000; // 5 seconds
|
|
211
210
|
const startTime = Date.now();
|
|
212
211
|
|
|
213
|
-
console.log(`⏳ Polling
|
|
212
|
+
console.log(`⏳ Polling job ID: ${jobId}`);
|
|
214
213
|
|
|
215
214
|
while (Date.now() - startTime < maxPollingTime) {
|
|
216
215
|
await new Promise((resolve) => setTimeout(resolve, pollingInterval));
|
|
217
216
|
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
217
|
+
const status = await client.getVideoStatus("xai", { jobId });
|
|
218
|
+
console.log(
|
|
219
|
+
` Status: ${status.status}, has data: ${!!status.data?.length}`
|
|
221
220
|
);
|
|
222
221
|
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
222
|
+
if (status.status === "failed") {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`XAI video generation failed: ${status.error || "unknown error"}`
|
|
225
|
+
);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
if (status.status === "expired") {
|
|
229
|
+
throw new Error("XAI video generation request expired");
|
|
230
|
+
}
|
|
230
231
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const videoUrl = pollData.video.url;
|
|
232
|
+
if (status.status === "completed" && status.data?.[0]?.url) {
|
|
233
|
+
const videoUrl = status.data[0].url;
|
|
234
234
|
const outputContent = [
|
|
235
235
|
`Generated at: ${new Date().toISOString()}`,
|
|
236
236
|
`Prompt: ${prompt}`,
|
|
237
|
-
`
|
|
237
|
+
`Job ID: ${jobId}`,
|
|
238
238
|
`Video URL: ${videoUrl}`,
|
|
239
239
|
].join("\n");
|
|
240
240
|
fs.writeFileSync(outputPath, outputContent);
|
|
@@ -243,61 +243,48 @@ describe("XAI (Grok) Modalities", () => {
|
|
|
243
243
|
|
|
244
244
|
// Clean up job ID file
|
|
245
245
|
if (fs.existsSync(jobIdPath)) fs.unlinkSync(jobIdPath);
|
|
246
|
-
return
|
|
247
|
-
} else if (pollData.status === "expired") {
|
|
248
|
-
throw new Error("XAI video generation request expired");
|
|
249
|
-
} else if (pollData.status === "failed") {
|
|
250
|
-
throw new Error(`XAI video generation failed: ${JSON.stringify(pollData)}`);
|
|
246
|
+
return;
|
|
251
247
|
}
|
|
252
|
-
//
|
|
248
|
+
// queued / in_progress – keep polling
|
|
253
249
|
}
|
|
254
250
|
|
|
255
|
-
throw new Error(
|
|
251
|
+
throw new Error(
|
|
252
|
+
"XAI video generation timed out after 20 minutes of polling"
|
|
253
|
+
);
|
|
256
254
|
}
|
|
257
255
|
|
|
258
|
-
// If we already have a
|
|
256
|
+
// If we already have a job ID from a previous (timed-out) run, resume
|
|
259
257
|
if (fs.existsSync(jobIdPath)) {
|
|
260
|
-
const
|
|
261
|
-
console.log(`🔄 Resuming poll for existing
|
|
262
|
-
await pollAndSave(
|
|
258
|
+
const jobId = fs.readFileSync(jobIdPath, "utf8").trim();
|
|
259
|
+
console.log(`🔄 Resuming poll for existing job ID: ${jobId}`);
|
|
260
|
+
await pollAndSave(jobId);
|
|
263
261
|
expect(fs.existsSync(outputPath)).toBe(true);
|
|
264
262
|
return;
|
|
265
263
|
}
|
|
266
264
|
|
|
267
|
-
// Otherwise start a new job
|
|
265
|
+
// Otherwise start a new job via the client
|
|
268
266
|
console.log("⏳ Submitting XAI video generation job...");
|
|
269
267
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
},
|
|
276
|
-
body: JSON.stringify({
|
|
277
|
-
model: "grok-imagine-video",
|
|
278
|
-
prompt,
|
|
279
|
-
duration: 5,
|
|
280
|
-
aspect_ratio: "16:9",
|
|
281
|
-
}),
|
|
268
|
+
const response = await client.createVideoGeneration("xai", {
|
|
269
|
+
model: "grok-imagine-video",
|
|
270
|
+
prompt,
|
|
271
|
+
duration: 5,
|
|
272
|
+
aspect_ratio: "16:9",
|
|
282
273
|
});
|
|
283
274
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
throw new Error(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const startData = await startResponse.json();
|
|
290
|
-
const requestId = startData.request_id;
|
|
291
|
-
|
|
292
|
-
if (!requestId) {
|
|
293
|
-
throw new Error(`No request_id in response: ${JSON.stringify(startData)}`);
|
|
275
|
+
const jobId = response.jobId;
|
|
276
|
+
if (!jobId) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`No jobId returned from video generation: ${JSON.stringify(response)}`
|
|
279
|
+
);
|
|
294
280
|
}
|
|
295
281
|
|
|
296
|
-
// Persist the
|
|
297
|
-
fs.writeFileSync(jobIdPath,
|
|
298
|
-
console.log(`📝
|
|
282
|
+
// Persist the job ID so subsequent runs can resume if this run times out
|
|
283
|
+
fs.writeFileSync(jobIdPath, jobId);
|
|
284
|
+
console.log(`📝 Job ID saved to: ${jobIdPath} (ID: ${jobId})`);
|
|
285
|
+
console.log(` Estimated cost: $${response.usd_cost?.toFixed(6)}`);
|
|
299
286
|
|
|
300
|
-
await pollAndSave(
|
|
287
|
+
await pollAndSave(jobId);
|
|
301
288
|
|
|
302
289
|
expect(fs.existsSync(outputPath)).toBe(true);
|
|
303
290
|
},
|