@tyvm/knowhow 0.0.109-dev.05fe5a0 → 0.0.109-dev.86123ed
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/tools/list.ts +2 -2
- package/src/chat/CliChatService.ts +1 -1
- package/src/chat/modules/SystemModule.ts +2 -2
- package/src/clients/anthropic.ts +1 -1
- package/src/clients/index.ts +25 -6
- package/src/clients/openai.ts +8 -5
- package/src/clients/types.ts +29 -6
- package/src/clients/withRetry.ts +89 -0
- package/src/config.ts +1 -1
- package/src/fileSync.ts +20 -12
- package/src/hashes.ts +35 -13
- package/src/services/MediaProcessorService.ts +3 -2
- package/tests/unit/clients/AIClient.test.ts +446 -0
- package/tests/unit/clients/withRetry.test.ts +319 -0
- package/tests/unit/commands/github-credentials.test.ts +1 -2
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/tools/list.js +2 -2
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/chat/CliChatService.js +1 -1
- package/ts_build/src/chat/CliChatService.js.map +1 -1
- package/ts_build/src/chat/modules/SystemModule.js +2 -2
- package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
- package/ts_build/src/clients/anthropic.js +1 -1
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/index.js +7 -6
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.js +4 -4
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/types.d.ts +12 -6
- package/ts_build/src/clients/withRetry.d.ts +2 -0
- package/ts_build/src/clients/withRetry.js +60 -0
- package/ts_build/src/clients/withRetry.js.map +1 -0
- package/ts_build/src/config.js +1 -1
- package/ts_build/src/config.js.map +1 -1
- package/ts_build/src/fileSync.d.ts +2 -2
- package/ts_build/src/fileSync.js +13 -11
- package/ts_build/src/fileSync.js.map +1 -1
- package/ts_build/src/hashes.d.ts +2 -2
- package/ts_build/src/hashes.js +35 -9
- package/ts_build/src/hashes.js.map +1 -1
- package/ts_build/src/services/MediaProcessorService.d.ts +2 -1
- package/ts_build/src/services/MediaProcessorService.js +2 -1
- package/ts_build/src/services/MediaProcessorService.js.map +1 -1
- package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
- package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
- package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
- package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
- package/ts_build/tests/unit/commands/github-credentials.test.js +1 -2
- package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -1
package/package.json
CHANGED
package/src/agents/tools/list.ts
CHANGED
|
@@ -156,8 +156,8 @@ export const includedTools = [
|
|
|
156
156
|
},
|
|
157
157
|
model: {
|
|
158
158
|
type: "string",
|
|
159
|
-
description: "The model to use (default: 'gpt-
|
|
160
|
-
default: "gpt-
|
|
159
|
+
description: "The model to use (default: 'gpt-5.4-nano')",
|
|
160
|
+
default: "gpt-5.4-nano",
|
|
161
161
|
},
|
|
162
162
|
},
|
|
163
163
|
required: ["imageUrl", "question"],
|
|
@@ -45,7 +45,7 @@ export class SystemModule extends BaseChatModule {
|
|
|
45
45
|
const agent = context?.selectedAgent;
|
|
46
46
|
const Clients = agent.clientService;
|
|
47
47
|
const currentProvider = context?.currentProvider || "openai";
|
|
48
|
-
const currentModel = context?.currentModel || "gpt-
|
|
48
|
+
const currentModel = context?.currentModel || "gpt-5.4-nano";
|
|
49
49
|
|
|
50
50
|
const models = Clients.getRegisteredModels(currentProvider);
|
|
51
51
|
console.log(models);
|
|
@@ -86,7 +86,7 @@ export class SystemModule extends BaseChatModule {
|
|
|
86
86
|
const Clients = agent.clientService;
|
|
87
87
|
|
|
88
88
|
const currentProvider = context?.currentProvider || "openai";
|
|
89
|
-
const currentModel = context?.currentModel || "gpt-
|
|
89
|
+
const currentModel = context?.currentModel || "gpt-5.4-nano";
|
|
90
90
|
|
|
91
91
|
const providers = Object.keys(Clients.clients);
|
|
92
92
|
console.log(providers);
|
package/src/clients/anthropic.ts
CHANGED
|
@@ -376,7 +376,7 @@ export class GenericAnthropicClient implements GenericClient {
|
|
|
376
376
|
tool_choice: { type: "auto" },
|
|
377
377
|
tools,
|
|
378
378
|
}),
|
|
379
|
-
});
|
|
379
|
+
}, { signal: options.signal });
|
|
380
380
|
|
|
381
381
|
if (!response.content || !response.content.length) {
|
|
382
382
|
console.log("no content in Anthropic response", response);
|
package/src/clients/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { ContextLimits } from "./contextLimits";
|
|
|
33
33
|
import { OpenAiTextPricing } from "./pricing/openai";
|
|
34
34
|
import { AnthropicTextPricing } from "./pricing/anthropic";
|
|
35
35
|
import { GeminiPricing } from "./pricing/google";
|
|
36
|
+
import { withRetry } from "./withRetry";
|
|
36
37
|
import {
|
|
37
38
|
XaiTextPricing,
|
|
38
39
|
XaiImagePricing,
|
|
@@ -665,7 +666,10 @@ export class AIClient {
|
|
|
665
666
|
} model registered. Try using ${JSON.stringify(this.listAllModels())}`
|
|
666
667
|
);
|
|
667
668
|
}
|
|
668
|
-
return
|
|
669
|
+
return withRetry(
|
|
670
|
+
(signal) => client.createChatCompletion({ ...options, model, signal }),
|
|
671
|
+
options
|
|
672
|
+
);
|
|
669
673
|
}
|
|
670
674
|
|
|
671
675
|
async createEmbedding(
|
|
@@ -680,7 +684,10 @@ export class AIClient {
|
|
|
680
684
|
} model registered. Try using ${JSON.stringify(this.listAllModels())}`
|
|
681
685
|
);
|
|
682
686
|
}
|
|
683
|
-
return
|
|
687
|
+
return withRetry(
|
|
688
|
+
(signal) => client.createEmbedding({ ...options, model, signal }),
|
|
689
|
+
options
|
|
690
|
+
);
|
|
684
691
|
}
|
|
685
692
|
|
|
686
693
|
async createAudioTranscription(
|
|
@@ -693,7 +700,10 @@ export class AIClient {
|
|
|
693
700
|
`Provider ${provider} does not support audio transcription.`
|
|
694
701
|
);
|
|
695
702
|
}
|
|
696
|
-
return
|
|
703
|
+
return withRetry(
|
|
704
|
+
(signal) => client.createAudioTranscription({ ...options, signal }),
|
|
705
|
+
options
|
|
706
|
+
);
|
|
697
707
|
}
|
|
698
708
|
|
|
699
709
|
async createAudioGeneration(
|
|
@@ -711,7 +721,10 @@ export class AIClient {
|
|
|
711
721
|
`Model ${options.model} not registered for provider ${provider}.`
|
|
712
722
|
);
|
|
713
723
|
}
|
|
714
|
-
return
|
|
724
|
+
return withRetry(
|
|
725
|
+
(signal) => client.createAudioGeneration({ ...options, model, signal }),
|
|
726
|
+
options
|
|
727
|
+
);
|
|
715
728
|
}
|
|
716
729
|
|
|
717
730
|
async createImageGeneration(
|
|
@@ -729,7 +742,10 @@ export class AIClient {
|
|
|
729
742
|
`Model ${options.model} not registered for provider ${provider}.`
|
|
730
743
|
);
|
|
731
744
|
}
|
|
732
|
-
return
|
|
745
|
+
return withRetry(
|
|
746
|
+
(signal) => client.createImageGeneration({ ...options, model, signal }),
|
|
747
|
+
options
|
|
748
|
+
);
|
|
733
749
|
}
|
|
734
750
|
|
|
735
751
|
async createVideoGeneration(
|
|
@@ -747,7 +763,10 @@ export class AIClient {
|
|
|
747
763
|
`Model ${options.model} not registered for provider ${provider}.`
|
|
748
764
|
);
|
|
749
765
|
}
|
|
750
|
-
return
|
|
766
|
+
return withRetry(
|
|
767
|
+
(signal) => client.createVideoGeneration({ ...options, model, signal }),
|
|
768
|
+
options
|
|
769
|
+
);
|
|
751
770
|
}
|
|
752
771
|
|
|
753
772
|
async getVideoStatus(
|
package/src/clients/openai.ts
CHANGED
|
@@ -63,6 +63,10 @@ export class GenericOpenAiClient implements GenericClient {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Execute a function with timeout, retries, and exponential backoff.
|
|
68
|
+
* Retriable errors: 5xx, timeout, ECONNRESET, ETIMEDOUT, rate limits (429).
|
|
69
|
+
*/
|
|
66
70
|
reasoningEffort(
|
|
67
71
|
messages: CompletionOptions["messages"]
|
|
68
72
|
): "low" | "medium" | "high" {
|
|
@@ -155,12 +159,11 @@ export class GenericOpenAiClient implements GenericClient {
|
|
|
155
159
|
max_completion_tokens: Math.max(options.max_tokens ?? 0, 16_000),
|
|
156
160
|
reasoning_effort: this.resolveReasoningEffort(options),
|
|
157
161
|
}),
|
|
158
|
-
|
|
159
162
|
...(options.tools && {
|
|
160
163
|
tools: options.tools,
|
|
161
164
|
tool_choice: "auto",
|
|
162
165
|
}),
|
|
163
|
-
});
|
|
166
|
+
}, { signal: options.signal });
|
|
164
167
|
|
|
165
168
|
const usdCost = this.calculateCost(options.model, response.usage);
|
|
166
169
|
|
|
@@ -453,7 +456,7 @@ export class GenericOpenAiClient implements GenericClient {
|
|
|
453
456
|
prompt: options.prompt,
|
|
454
457
|
response_format: options.response_format || "verbose_json",
|
|
455
458
|
temperature: options.temperature,
|
|
456
|
-
});
|
|
459
|
+
}, { signal: options.signal });
|
|
457
460
|
|
|
458
461
|
// Calculate cost: $0.006 per minute for Whisper
|
|
459
462
|
const duration = typeof response === "object" && "duration" in response && typeof response.duration === "number"
|
|
@@ -489,7 +492,7 @@ export class GenericOpenAiClient implements GenericClient {
|
|
|
489
492
|
voice: options.voice as any,
|
|
490
493
|
response_format: options.response_format || "mp3",
|
|
491
494
|
speed: options.speed,
|
|
492
|
-
});
|
|
495
|
+
}, { signal: options.signal });
|
|
493
496
|
|
|
494
497
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
495
498
|
|
|
@@ -518,7 +521,7 @@ export class GenericOpenAiClient implements GenericClient {
|
|
|
518
521
|
style: options.style,
|
|
519
522
|
response_format: options.response_format,
|
|
520
523
|
user: options.user,
|
|
521
|
-
});
|
|
524
|
+
}, { signal: options.signal });
|
|
522
525
|
|
|
523
526
|
// Cost calculation varies by model and settings
|
|
524
527
|
// DALL-E 3: $0.040-$0.120 per image depending on quality/size
|
package/src/clients/types.ts
CHANGED
|
@@ -57,7 +57,30 @@ export interface ToolCall {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export interface
|
|
60
|
+
export interface RetryOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Request timeout in milliseconds per attempt. If the request does not complete
|
|
63
|
+
* within this time it is aborted and retried according to maxRetries.
|
|
64
|
+
*/
|
|
65
|
+
timeout?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Maximum number of retry attempts for retriable errors (5xx, timeout, ECONNRESET, 429).
|
|
68
|
+
* Default: 2. Set to 0 to disable retries.
|
|
69
|
+
*/
|
|
70
|
+
maxRetries?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Base backoff delay in milliseconds for exponential retry backoff.
|
|
73
|
+
* Default: 1000ms. Each retry waits backoffMs * 2^attempt ms.
|
|
74
|
+
*/
|
|
75
|
+
backoffMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Optional external AbortSignal. When the signal is aborted the current
|
|
78
|
+
* attempt is cancelled immediately and no further retries are made.
|
|
79
|
+
*/
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CompletionOptions extends RetryOptions {
|
|
61
84
|
model: string;
|
|
62
85
|
messages: Message[];
|
|
63
86
|
tools?: Tool[];
|
|
@@ -113,7 +136,7 @@ export interface CompletionResponse {
|
|
|
113
136
|
usd_cost?: number;
|
|
114
137
|
}
|
|
115
138
|
|
|
116
|
-
export interface EmbeddingOptions {
|
|
139
|
+
export interface EmbeddingOptions extends RetryOptions {
|
|
117
140
|
input: string;
|
|
118
141
|
model?: string;
|
|
119
142
|
}
|
|
@@ -132,7 +155,7 @@ export interface EmbeddingResponse {
|
|
|
132
155
|
usd_cost?: number;
|
|
133
156
|
}
|
|
134
157
|
|
|
135
|
-
export interface AudioTranscriptionOptions {
|
|
158
|
+
export interface AudioTranscriptionOptions extends RetryOptions {
|
|
136
159
|
file: Blob | File | any; // Support for Node.js ReadStream or web File/Blob
|
|
137
160
|
model?: string;
|
|
138
161
|
language?: string;
|
|
@@ -162,7 +185,7 @@ export interface AudioTranscriptionResponse {
|
|
|
162
185
|
usd_cost?: number;
|
|
163
186
|
}
|
|
164
187
|
|
|
165
|
-
export interface AudioGenerationOptions {
|
|
188
|
+
export interface AudioGenerationOptions extends RetryOptions {
|
|
166
189
|
model: string;
|
|
167
190
|
input: string;
|
|
168
191
|
voice: string; // e.g. "alloy", "echo", "fable", "onyx", "nova", "shimmer" for OpenAI; "Kore", "Puck" etc. for Gemini
|
|
@@ -176,7 +199,7 @@ export interface AudioGenerationResponse {
|
|
|
176
199
|
usd_cost?: number;
|
|
177
200
|
}
|
|
178
201
|
|
|
179
|
-
export interface ImageGenerationOptions {
|
|
202
|
+
export interface ImageGenerationOptions extends RetryOptions {
|
|
180
203
|
model: string;
|
|
181
204
|
prompt: string;
|
|
182
205
|
n?: number;
|
|
@@ -197,7 +220,7 @@ export interface ImageGenerationResponse {
|
|
|
197
220
|
usd_cost?: number;
|
|
198
221
|
}
|
|
199
222
|
|
|
200
|
-
export interface VideoGenerationOptions {
|
|
223
|
+
export interface VideoGenerationOptions extends RetryOptions {
|
|
201
224
|
model: string;
|
|
202
225
|
prompt: string;
|
|
203
226
|
duration?: number; // seconds
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared retry/timeout helper for all AI clients.
|
|
3
|
+
*
|
|
4
|
+
* Executes `fn` with exponential backoff for retriable errors:
|
|
5
|
+
* - Rate limits (429)
|
|
6
|
+
* - Timeouts (AbortError, ETIMEDOUT, ECONNRESET)
|
|
7
|
+
* - Server errors (5xx)
|
|
8
|
+
*
|
|
9
|
+
* @param fn Function to execute. Receives a combined AbortSignal
|
|
10
|
+
* that fires on per-attempt timeout OR external signal abort.
|
|
11
|
+
* @param opts Any object with optional RetryOptions fields (timeout, maxRetries,
|
|
12
|
+
* backoffMs, signal). Extra fields are ignored — so you can pass the
|
|
13
|
+
* full options object from any AI method directly.
|
|
14
|
+
* - timeout: Per-attempt timeout in ms. No timeout if omitted.
|
|
15
|
+
* - maxRetries: Max retry attempts after first failure. Default: 2.
|
|
16
|
+
* - backoffMs: Base backoff delay in ms. Default: 1000.
|
|
17
|
+
* - signal: Optional external AbortSignal. When aborted, the current
|
|
18
|
+
* attempt is cancelled and no further retries are made.
|
|
19
|
+
*/
|
|
20
|
+
import type { RetryOptions } from "./types";
|
|
21
|
+
|
|
22
|
+
export async function withRetry<T>(
|
|
23
|
+
fn: (signal?: AbortSignal) => Promise<T>,
|
|
24
|
+
opts: RetryOptions = {}
|
|
25
|
+
): Promise<T> {
|
|
26
|
+
const maxRetries = opts.maxRetries ?? 2;
|
|
27
|
+
const backoffMs = opts.backoffMs ?? 1000;
|
|
28
|
+
const timeout = opts.timeout;
|
|
29
|
+
const externalSignal = opts.signal;
|
|
30
|
+
|
|
31
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
32
|
+
// If the external signal is already aborted, bail out immediately.
|
|
33
|
+
if (externalSignal?.aborted) {
|
|
34
|
+
throw externalSignal.reason ?? new DOMException("Aborted", "AbortError");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
+
// Combine per-attempt timeout with the external signal into one controller.
|
|
39
|
+
const controller = timeout || externalSignal ? new AbortController() : undefined;
|
|
40
|
+
|
|
41
|
+
if (controller) {
|
|
42
|
+
if (timeout) {
|
|
43
|
+
timer = setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeout);
|
|
44
|
+
}
|
|
45
|
+
// Forward external signal abort into our combined controller.
|
|
46
|
+
if (externalSignal) {
|
|
47
|
+
const onExternalAbort = () => controller.abort(externalSignal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
48
|
+
if (externalSignal.aborted) {
|
|
49
|
+
controller.abort(externalSignal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
50
|
+
} else {
|
|
51
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
52
|
+
// Clean up the listener after the attempt resolves/rejects.
|
|
53
|
+
controller.signal.addEventListener("abort", () =>
|
|
54
|
+
externalSignal.removeEventListener("abort", onExternalAbort), { once: true }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const result = await fn(controller?.signal);
|
|
62
|
+
return result;
|
|
63
|
+
} catch (err: unknown) {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
// If the external signal was aborted, don't retry — propagate immediately.
|
|
66
|
+
if (externalSignal?.aborted) {
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
const errStr = String(err);
|
|
70
|
+
const isRetriable =
|
|
71
|
+
errStr.includes('429') ||
|
|
72
|
+
errStr.includes('timeout') ||
|
|
73
|
+
errStr.includes('TimeoutError') ||
|
|
74
|
+
errStr.includes('ECONNRESET') ||
|
|
75
|
+
errStr.includes('ETIMEDOUT') ||
|
|
76
|
+
errStr.includes('AbortError') ||
|
|
77
|
+
/5\d\d/.test(errStr);
|
|
78
|
+
if (isRetriable && attempt < maxRetries) {
|
|
79
|
+
const delay = backoffMs * Math.pow(2, attempt);
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new Error('withRetry: exhausted retries');
|
|
89
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -74,7 +74,7 @@ const defaultConfig = {
|
|
|
74
74
|
description:
|
|
75
75
|
"You can define agents in the config. They will have access to all tools.",
|
|
76
76
|
instructions: "Reply to the user saying 'Hello, world!'",
|
|
77
|
-
model: "gpt-
|
|
77
|
+
model: "gpt-5.4-nano",
|
|
78
78
|
provider: "openai",
|
|
79
79
|
},
|
|
80
80
|
],
|
package/src/fileSync.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { loadJwt } from "./login";
|
|
|
6
6
|
import { getConfig } from "./config";
|
|
7
7
|
import { services } from "./services";
|
|
8
8
|
import { S3Service } from "./services/S3";
|
|
9
|
-
import { getHashes, hasFileChangedSinceUpload, saveUploadHash, isLocalFileMatchingRemote, isLocalFileMatchingDownloadHash, saveDownloadHash } from "./hashes";
|
|
9
|
+
import { getHashes, saveHashes, hasFileChangedSinceUpload, saveUploadHash, isLocalFileMatchingRemote, isLocalFileMatchingDownloadHash, saveDownloadHash } from "./hashes";
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_BATCH_SIZE = 5;
|
|
12
12
|
|
|
@@ -165,7 +165,8 @@ export async function downloadFile(
|
|
|
165
165
|
s3Service: S3Service,
|
|
166
166
|
remotePath: string,
|
|
167
167
|
localPath: string,
|
|
168
|
-
dryRun: boolean
|
|
168
|
+
dryRun: boolean,
|
|
169
|
+
hashes?: any
|
|
169
170
|
): Promise<void> {
|
|
170
171
|
console.log(`⬇️ Downloading ${remotePath} → ${localPath}`);
|
|
171
172
|
|
|
@@ -176,8 +177,7 @@ export async function downloadFile(
|
|
|
176
177
|
|
|
177
178
|
try {
|
|
178
179
|
// Fast-path: check stored download hash before hitting the API
|
|
179
|
-
|
|
180
|
-
if (await isLocalFileMatchingDownloadHash(localPath, hashes)) {
|
|
180
|
+
if (hashes && await isLocalFileMatchingDownloadHash(localPath, hashes)) {
|
|
181
181
|
console.log(` ✓ Skipping ${localPath} (matches stored download hash)`);
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
@@ -189,7 +189,7 @@ export async function downloadFile(
|
|
|
189
189
|
if (isLocalFileMatchingRemote(localPath, checksumSHA256)) {
|
|
190
190
|
console.log(` ✓ Skipping ${localPath} (matches remote checksum)`);
|
|
191
191
|
// Store the hash so future syncs can skip without hitting the API
|
|
192
|
-
await saveDownloadHash(localPath);
|
|
192
|
+
await saveDownloadHash(localPath, hashes);
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -203,7 +203,7 @@ export async function downloadFile(
|
|
|
203
203
|
await s3Service.downloadFromPresignedUrl(downloadUrl, localPath);
|
|
204
204
|
|
|
205
205
|
// Save download hash so we can skip unchanged files next time
|
|
206
|
-
await saveDownloadHash(localPath);
|
|
206
|
+
await saveDownloadHash(localPath, hashes);
|
|
207
207
|
|
|
208
208
|
// Get file size for logging
|
|
209
209
|
const stats = fs.statSync(localPath);
|
|
@@ -221,7 +221,8 @@ export async function uploadFile(
|
|
|
221
221
|
s3Service: S3Service,
|
|
222
222
|
remotePath: string,
|
|
223
223
|
localPath: string,
|
|
224
|
-
dryRun: boolean
|
|
224
|
+
dryRun: boolean,
|
|
225
|
+
hashes?: any
|
|
225
226
|
): Promise<void> {
|
|
226
227
|
console.log(`⬆️ Uploading ${localPath} → ${remotePath}`);
|
|
227
228
|
|
|
@@ -237,8 +238,7 @@ export async function uploadFile(
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
// Skip upload if file hasn't changed since last upload
|
|
240
|
-
const
|
|
241
|
-
const changed = await hasFileChangedSinceUpload(localPath, hashes);
|
|
241
|
+
const changed = hashes ? await hasFileChangedSinceUpload(localPath, hashes) : true;
|
|
242
242
|
if (!changed) {
|
|
243
243
|
console.log(` ✓ Skipping ${localPath} (unchanged since last upload)`);
|
|
244
244
|
return;
|
|
@@ -254,7 +254,7 @@ export async function uploadFile(
|
|
|
254
254
|
await client.markOrgFileUploadComplete(remotePath);
|
|
255
255
|
|
|
256
256
|
// Save upload hash so we can skip unchanged files next time
|
|
257
|
-
await saveUploadHash(localPath);
|
|
257
|
+
await saveUploadHash(localPath, hashes);
|
|
258
258
|
|
|
259
259
|
const stats = fs.statSync(localPath);
|
|
260
260
|
console.log(` ✓ Uploaded ${stats.size} bytes`);
|
|
@@ -276,6 +276,8 @@ export async function uploadDirectory(
|
|
|
276
276
|
|
|
277
277
|
console.log(`⬆️ Uploading directory ${localDir} → ${remoteDir}`);
|
|
278
278
|
|
|
279
|
+
const hashes = await getHashes();
|
|
280
|
+
|
|
279
281
|
if (!fs.existsSync(localDir)) {
|
|
280
282
|
console.warn(` ⚠️ Local directory not found: ${localDir}`);
|
|
281
283
|
return 0;
|
|
@@ -295,7 +297,7 @@ export async function uploadDirectory(
|
|
|
295
297
|
const localFilePath = localDir + relFile;
|
|
296
298
|
const remoteFilePath = remoteDir + relFile;
|
|
297
299
|
try {
|
|
298
|
-
await uploadFile(client, s3Service, remoteFilePath, localFilePath, dryRun);
|
|
300
|
+
await uploadFile(client, s3Service, remoteFilePath, localFilePath, dryRun, hashes);
|
|
299
301
|
return 1;
|
|
300
302
|
} catch (error) {
|
|
301
303
|
console.error(
|
|
@@ -306,6 +308,8 @@ export async function uploadDirectory(
|
|
|
306
308
|
});
|
|
307
309
|
|
|
308
310
|
const counts = await batchRun(tasks);
|
|
311
|
+
await saveHashes(hashes);
|
|
312
|
+
|
|
309
313
|
return counts.reduce((sum, n) => sum + n, 0);
|
|
310
314
|
}
|
|
311
315
|
|
|
@@ -325,6 +329,8 @@ export async function downloadDirectory(
|
|
|
325
329
|
|
|
326
330
|
console.log(`⬇️ Downloading directory ${remoteDir} → ${localDir}`);
|
|
327
331
|
|
|
332
|
+
const hashes = await getHashes();
|
|
333
|
+
|
|
328
334
|
// List all org files and find those in the remote directory
|
|
329
335
|
const response = await client.listOrgFiles();
|
|
330
336
|
const allFiles = response.data;
|
|
@@ -352,10 +358,12 @@ export async function downloadDirectory(
|
|
|
352
358
|
// Strip the base remote dir prefix to get relative path
|
|
353
359
|
const relativePath = fullRemotePath.slice(remoteDir.length);
|
|
354
360
|
const localFilePath = localDir + relativePath;
|
|
355
|
-
await downloadFile(client, s3Service, fullRemotePath, localFilePath, dryRun);
|
|
361
|
+
await downloadFile(client, s3Service, fullRemotePath, localFilePath, dryRun, hashes);
|
|
356
362
|
return 1;
|
|
357
363
|
});
|
|
358
364
|
|
|
359
365
|
const counts = await batchRun(tasks);
|
|
366
|
+
await saveHashes(hashes);
|
|
367
|
+
|
|
360
368
|
return counts.reduce((sum, n) => sum + n, 0);
|
|
361
369
|
}
|
package/src/hashes.ts
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import * as crypto from "crypto";
|
|
3
3
|
import { Hashes } from "./types";
|
|
4
|
-
import { readFile
|
|
4
|
+
import { readFile } from "./utils";
|
|
5
5
|
import { convertToText } from "./conversion";
|
|
6
6
|
|
|
7
7
|
export async function getHashes() {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
try {
|
|
9
|
+
const hashes = JSON.parse(await readFile(".knowhow/.hashes.json", "utf8"));
|
|
10
|
+
return hashes as Hashes;
|
|
11
|
+
} catch (err: any) {
|
|
12
|
+
if (err.code === "ENOENT") {
|
|
13
|
+
return {} as Hashes;
|
|
14
|
+
}
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
10
17
|
}
|
|
11
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Atomically save hashes to disk — writes to a temp file then renames,
|
|
21
|
+
* preventing concurrent writes from producing corrupted/truncated JSON.
|
|
22
|
+
*/
|
|
12
23
|
export async function saveHashes(hashes: any) {
|
|
13
|
-
|
|
24
|
+
const target = ".knowhow/.hashes.json";
|
|
25
|
+
const tmp = `${target}.tmp.${process.pid}`;
|
|
26
|
+
try {
|
|
27
|
+
fs.writeFileSync(tmp, JSON.stringify(hashes, null, 2));
|
|
28
|
+
fs.renameSync(tmp, target);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
14
33
|
}
|
|
15
34
|
|
|
16
35
|
export async function md5Hash(str: string) {
|
|
@@ -90,17 +109,19 @@ export async function hasFileChangedSinceUpload(
|
|
|
90
109
|
}
|
|
91
110
|
|
|
92
111
|
/**
|
|
93
|
-
*
|
|
112
|
+
* Mutates the provided hashes object with the upload hash for localPath.
|
|
113
|
+
* If no hashes object is provided, loads, mutates, and saves independently.
|
|
94
114
|
*/
|
|
95
|
-
export async function saveUploadHash(localPath: string) {
|
|
96
|
-
const
|
|
115
|
+
export async function saveUploadHash(localPath: string, hashes?: any) {
|
|
116
|
+
const standalone = !hashes;
|
|
117
|
+
if (standalone) hashes = await getHashes();
|
|
97
118
|
const content = fs.readFileSync(localPath);
|
|
98
119
|
const currentHash = crypto.createHash("md5").update(content).digest("hex");
|
|
99
120
|
if (!hashes[localPath]) {
|
|
100
121
|
hashes[localPath] = { fileHash: currentHash, promptHash: "" };
|
|
101
122
|
}
|
|
102
123
|
hashes[localPath][UPLOAD_KEY] = currentHash;
|
|
103
|
-
await saveHashes(hashes);
|
|
124
|
+
if (standalone) await saveHashes(hashes);
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
/**
|
|
@@ -120,18 +141,19 @@ export async function isLocalFileMatchingDownloadHash(
|
|
|
120
141
|
}
|
|
121
142
|
|
|
122
143
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
144
|
+
* Mutates the provided hashes object with the download hash for localPath.
|
|
145
|
+
* If no hashes object is provided, loads, mutates, and saves independently.
|
|
125
146
|
*/
|
|
126
|
-
export async function saveDownloadHash(localPath: string) {
|
|
127
|
-
const
|
|
147
|
+
export async function saveDownloadHash(localPath: string, hashes?: any) {
|
|
148
|
+
const standalone = !hashes;
|
|
149
|
+
if (standalone) hashes = await getHashes();
|
|
128
150
|
const content = fs.readFileSync(localPath);
|
|
129
151
|
const currentHash = crypto.createHash("sha256").update(content).digest("base64");
|
|
130
152
|
if (!hashes[localPath]) {
|
|
131
153
|
hashes[localPath] = { fileHash: currentHash, promptHash: "" };
|
|
132
154
|
}
|
|
133
155
|
hashes[localPath][DOWNLOAD_KEY] = currentHash;
|
|
134
|
-
await saveHashes(hashes);
|
|
156
|
+
if (standalone) await saveHashes(hashes);
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
/**
|
|
@@ -4,6 +4,7 @@ import { exec } from "child_process";
|
|
|
4
4
|
import { promisify } from "util";
|
|
5
5
|
import { fileExists, readFile, mkdir } from "../utils";
|
|
6
6
|
import { AIClient } from "../clients";
|
|
7
|
+
import { Models } from "../types";
|
|
7
8
|
|
|
8
9
|
const execPromise = promisify(exec);
|
|
9
10
|
|
|
@@ -36,7 +37,7 @@ export interface KeyframeInfo {
|
|
|
36
37
|
* audio/video processing steps after downloading with ytdl.
|
|
37
38
|
*/
|
|
38
39
|
export class MediaProcessorService {
|
|
39
|
-
constructor(private clients:
|
|
40
|
+
constructor(private clients: AIClient) {}
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Split an audio/video file into fixed-length mp3 chunks using ffmpeg.
|
|
@@ -298,7 +299,7 @@ export class MediaProcessorService {
|
|
|
298
299
|
});
|
|
299
300
|
const image = `data:image/jpeg;base64,${base64}`;
|
|
300
301
|
return this.clients.createCompletion("openai", {
|
|
301
|
-
model:
|
|
302
|
+
model: Models.openai.GPT_54_Nano,
|
|
302
303
|
max_tokens: 2500,
|
|
303
304
|
messages: [
|
|
304
305
|
{
|