@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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/agents/tools/list.ts +2 -2
  3. package/src/chat/CliChatService.ts +1 -1
  4. package/src/chat/modules/SystemModule.ts +2 -2
  5. package/src/clients/anthropic.ts +1 -1
  6. package/src/clients/index.ts +25 -6
  7. package/src/clients/openai.ts +8 -5
  8. package/src/clients/types.ts +29 -6
  9. package/src/clients/withRetry.ts +89 -0
  10. package/src/config.ts +1 -1
  11. package/src/fileSync.ts +20 -12
  12. package/src/hashes.ts +35 -13
  13. package/src/services/MediaProcessorService.ts +3 -2
  14. package/tests/unit/clients/AIClient.test.ts +446 -0
  15. package/tests/unit/clients/withRetry.test.ts +319 -0
  16. package/tests/unit/commands/github-credentials.test.ts +1 -2
  17. package/ts_build/package.json +1 -1
  18. package/ts_build/src/agents/tools/list.js +2 -2
  19. package/ts_build/src/agents/tools/list.js.map +1 -1
  20. package/ts_build/src/chat/CliChatService.js +1 -1
  21. package/ts_build/src/chat/CliChatService.js.map +1 -1
  22. package/ts_build/src/chat/modules/SystemModule.js +2 -2
  23. package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
  24. package/ts_build/src/clients/anthropic.js +1 -1
  25. package/ts_build/src/clients/anthropic.js.map +1 -1
  26. package/ts_build/src/clients/index.js +7 -6
  27. package/ts_build/src/clients/index.js.map +1 -1
  28. package/ts_build/src/clients/openai.js +4 -4
  29. package/ts_build/src/clients/openai.js.map +1 -1
  30. package/ts_build/src/clients/types.d.ts +12 -6
  31. package/ts_build/src/clients/withRetry.d.ts +2 -0
  32. package/ts_build/src/clients/withRetry.js +60 -0
  33. package/ts_build/src/clients/withRetry.js.map +1 -0
  34. package/ts_build/src/config.js +1 -1
  35. package/ts_build/src/config.js.map +1 -1
  36. package/ts_build/src/fileSync.d.ts +2 -2
  37. package/ts_build/src/fileSync.js +13 -11
  38. package/ts_build/src/fileSync.js.map +1 -1
  39. package/ts_build/src/hashes.d.ts +2 -2
  40. package/ts_build/src/hashes.js +35 -9
  41. package/ts_build/src/hashes.js.map +1 -1
  42. package/ts_build/src/services/MediaProcessorService.d.ts +2 -1
  43. package/ts_build/src/services/MediaProcessorService.js +2 -1
  44. package/ts_build/src/services/MediaProcessorService.js.map +1 -1
  45. package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
  46. package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
  47. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
  48. package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
  49. package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
  50. package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
  51. package/ts_build/tests/unit/commands/github-credentials.test.js +1 -2
  52. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.109-dev.05fe5a0",
3
+ "version": "0.0.109-dev.86123ed",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -156,8 +156,8 @@ export const includedTools = [
156
156
  },
157
157
  model: {
158
158
  type: "string",
159
- description: "The model to use (default: 'gpt-4o')",
160
- default: "gpt-4o",
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"],
@@ -38,7 +38,7 @@ export class CliChatService implements ChatService {
38
38
  searchMode: false,
39
39
  voiceMode: false,
40
40
  multilineMode: false,
41
- currentModel: "gpt-4o",
41
+ currentModel: "gpt-5.4-nano",
42
42
  currentProvider: "openai",
43
43
  chatHistory: this.chatHistory,
44
44
  plugins,
@@ -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-4o";
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-4o";
89
+ const currentModel = context?.currentModel || "gpt-5.4-nano";
90
90
 
91
91
  const providers = Object.keys(Clients.clients);
92
92
  console.log(providers);
@@ -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);
@@ -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 client.createChatCompletion({ ...options, model });
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 client.createEmbedding({ ...options, model });
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 client.createAudioTranscription(options);
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 client.createAudioGeneration({ ...options, model });
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 client.createImageGeneration({ ...options, model });
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 client.createVideoGeneration({ ...options, model });
766
+ return withRetry(
767
+ (signal) => client.createVideoGeneration({ ...options, model, signal }),
768
+ options
769
+ );
751
770
  }
752
771
 
753
772
  async getVideoStatus(
@@ -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
@@ -57,7 +57,30 @@ export interface ToolCall {
57
57
  };
58
58
  }
59
59
 
60
- export interface CompletionOptions {
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-4o-2024-08-06",
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
- const hashes = await getHashes();
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 hashes = await getHashes();
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, writeFile } from "./utils";
4
+ import { readFile } from "./utils";
5
5
  import { convertToText } from "./conversion";
6
6
 
7
7
  export async function getHashes() {
8
- const hashes = JSON.parse(await readFile(".knowhow/.hashes.json", "utf8"));
9
- return hashes as Hashes;
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
- await writeFile(".knowhow/.hashes.json", JSON.stringify(hashes, null, 2));
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
- * Saves the hash of the file at the time of a successful upload
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 hashes = await getHashes();
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
- * Saves the SHA-256 hash of the file after a successful download so we can
124
- * skip unchanged files on the next sync.
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 hashes = await getHashes();
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: any) {}
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: "gpt-4o",
302
+ model: Models.openai.GPT_54_Nano,
302
303
  max_tokens: 2500,
303
304
  messages: [
304
305
  {