@tyvm/knowhow 0.0.109-dev.05fe5a0 → 0.0.109-dev.38b1faa

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 (58) 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/commands/modules.ts +365 -30
  11. package/src/config.ts +1 -1
  12. package/src/fileSync.ts +20 -12
  13. package/src/hashes.ts +35 -13
  14. package/src/services/MediaProcessorService.ts +4 -2
  15. package/src/services/modules/index.ts +24 -19
  16. package/tests/unit/clients/AIClient.test.ts +446 -0
  17. package/tests/unit/clients/withRetry.test.ts +319 -0
  18. package/tests/unit/commands/github-credentials.test.ts +1 -2
  19. package/ts_build/package.json +1 -1
  20. package/ts_build/src/agents/tools/list.js +2 -2
  21. package/ts_build/src/agents/tools/list.js.map +1 -1
  22. package/ts_build/src/chat/CliChatService.js +1 -1
  23. package/ts_build/src/chat/CliChatService.js.map +1 -1
  24. package/ts_build/src/chat/modules/SystemModule.js +2 -2
  25. package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
  26. package/ts_build/src/clients/anthropic.js +1 -1
  27. package/ts_build/src/clients/anthropic.js.map +1 -1
  28. package/ts_build/src/clients/index.js +7 -6
  29. package/ts_build/src/clients/index.js.map +1 -1
  30. package/ts_build/src/clients/openai.js +4 -4
  31. package/ts_build/src/clients/openai.js.map +1 -1
  32. package/ts_build/src/clients/types.d.ts +12 -6
  33. package/ts_build/src/clients/withRetry.d.ts +2 -0
  34. package/ts_build/src/clients/withRetry.js +60 -0
  35. package/ts_build/src/clients/withRetry.js.map +1 -0
  36. package/ts_build/src/commands/modules.js +297 -17
  37. package/ts_build/src/commands/modules.js.map +1 -1
  38. package/ts_build/src/config.js +1 -1
  39. package/ts_build/src/config.js.map +1 -1
  40. package/ts_build/src/fileSync.d.ts +2 -2
  41. package/ts_build/src/fileSync.js +13 -11
  42. package/ts_build/src/fileSync.js.map +1 -1
  43. package/ts_build/src/hashes.d.ts +2 -2
  44. package/ts_build/src/hashes.js +35 -9
  45. package/ts_build/src/hashes.js.map +1 -1
  46. package/ts_build/src/services/MediaProcessorService.d.ts +2 -1
  47. package/ts_build/src/services/MediaProcessorService.js +3 -1
  48. package/ts_build/src/services/MediaProcessorService.js.map +1 -1
  49. package/ts_build/src/services/modules/index.js +17 -13
  50. package/ts_build/src/services/modules/index.js.map +1 -1
  51. package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
  52. package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
  53. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
  54. package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
  55. package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
  56. package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
  57. package/ts_build/tests/unit/commands/github-credentials.test.js +1 -2
  58. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -1
@@ -4,6 +4,7 @@ import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import * as os from "os";
6
6
  import { getConfig, getGlobalConfig, updateConfig, updateGlobalConfig } from "../config";
7
+ import * as readline from "readline";
7
8
 
8
9
  // Default built-in modules that `knowhow modules setup` adds to the config.
9
10
  export const BUILTIN_MODULES = [
@@ -49,6 +50,234 @@ function npmInstallToKnowhow(mod: string, knowhowDir: string): void {
49
50
  });
50
51
  }
51
52
 
53
+ /**
54
+ * Returns true if a module package is already installed in the given knowhow dir.
55
+ */
56
+ function isModuleInstalled(mod: string, knowhowDir: string): boolean {
57
+ try {
58
+ require.resolve(mod, {
59
+ paths: [path.join(knowhowDir, "node_modules"), knowhowDir],
60
+ });
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ interface NpmRegistryInfo {
68
+ latestVersion: string;
69
+ publishedAt: string;
70
+ }
71
+
72
+ /**
73
+ * Fetch the latest version and publish time from the npm registry for a package.
74
+ * Returns null if the package info can't be fetched.
75
+ */
76
+ async function fetchNpmRegistryInfo(mod: string): Promise<NpmRegistryInfo | null> {
77
+ try {
78
+ // npm view <pkg> version time --json returns either a single value or array
79
+ const output = execSync(`npm view ${mod} version time --json`, {
80
+ stdio: ["ignore", "pipe", "pipe"],
81
+ encoding: "utf-8",
82
+ });
83
+ const parsed = JSON.parse(output.trim());
84
+ let latestVersion: string;
85
+ let timestamps: Record<string, string> = {};
86
+ if (Array.isArray(parsed)) {
87
+ latestVersion = parsed[0];
88
+ timestamps = parsed[1] || {};
89
+ } else if (parsed && typeof parsed === "object") {
90
+ latestVersion = parsed["version"] || "";
91
+ timestamps = parsed["time"] || {};
92
+ } else {
93
+ latestVersion = String(parsed);
94
+ }
95
+ const publishedAt = timestamps[latestVersion] || timestamps["modified"] || "";
96
+ return { latestVersion, publishedAt };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Simple engine range checker — handles the common cases:
104
+ * ">=22" ">=22.0.0" "^20" "20.x" "*" ""
105
+ * Returns true if the given nodeVersion satisfies the range.
106
+ * Falls back to true (permissive) for unsupported range syntax.
107
+ */
108
+ function nodeSatisfiesRange(nodeVersion: string, range: string): boolean {
109
+ if (!range || range === "*" || range === "") return true;
110
+
111
+ // Parse "major.minor.patch" from e.g. "v20.17.0"
112
+ const vMatch = nodeVersion.replace(/^v/, "").match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
113
+ if (!vMatch) return true;
114
+ const vMajor = parseInt(vMatch[1], 10);
115
+ const vMinor = parseInt(vMatch[2] ?? "0", 10);
116
+ const vPatch = parseInt(vMatch[3] ?? "0", 10);
117
+ const vNum = vMajor * 1_000_000 + vMinor * 1_000 + vPatch;
118
+
119
+ function parseVersion(s: string): number {
120
+ const m = s.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
121
+ if (!m) return 0;
122
+ return parseInt(m[1], 10) * 1_000_000 + parseInt(m[2] ?? "0", 10) * 1_000 + parseInt(m[3] ?? "0", 10);
123
+ }
124
+
125
+ // Handle " || " — any segment satisfying is fine
126
+ if (range.includes("||")) {
127
+ return range.split("||").some((r) => nodeSatisfiesRange(nodeVersion, r.trim()));
128
+ }
129
+
130
+ // Handle space-separated AND conditions e.g. ">=14 <18"
131
+ const parts = range.trim().split(/\s+/);
132
+ for (const part of parts) {
133
+ const gteMatch = part.match(/^>=(.+)/);
134
+ const gtMatch = part.match(/^>(?!=)(.+)/);
135
+ const lteMatch = part.match(/^<=(.+)/);
136
+ const ltMatch = part.match(/^<(?!=)(.+)/);
137
+ const caretMatch = part.match(/^\^(\d+)/);
138
+ const tildeMatch = part.match(/^~(\d+)(?:\.(\d+))?/);
139
+ const exactMatch = part.match(/^(\d+(?:\.\d+)*)/);
140
+
141
+ if (gteMatch) {
142
+ if (vNum < parseVersion(gteMatch[1])) return false;
143
+ } else if (gtMatch) {
144
+ if (vNum <= parseVersion(gtMatch[1])) return false;
145
+ } else if (lteMatch) {
146
+ if (vNum > parseVersion(lteMatch[1])) return false;
147
+ } else if (ltMatch) {
148
+ if (vNum >= parseVersion(ltMatch[1])) return false;
149
+ } else if (caretMatch) {
150
+ const base = parseVersion(caretMatch[1]);
151
+ const baseMajor = parseInt(caretMatch[1], 10);
152
+ if (vNum < base || vMajor !== baseMajor) return false;
153
+ } else if (tildeMatch) {
154
+ const baseMajor = parseInt(tildeMatch[1], 10);
155
+ const baseMinor = parseInt(tildeMatch[2] ?? "0", 10);
156
+ const base = parseVersion(`${baseMajor}.${baseMinor}`);
157
+ const nextMinor = parseVersion(`${baseMajor}.${baseMinor + 1}`);
158
+ if (vNum < base || vNum >= nextMinor) return false;
159
+ } else if (exactMatch && !part.startsWith("v")) {
160
+ // e.g. "20.x" or "20"
161
+ const xMatch = part.match(/^(\d+)(?:\.x)?$/);
162
+ if (xMatch) {
163
+ if (vMajor !== parseInt(xMatch[1], 10)) return false;
164
+ }
165
+ }
166
+ // Unknown operators — fall through (permissive)
167
+ }
168
+ return true;
169
+ }
170
+
171
+ /**
172
+ * Fetch the latest version of a package that is compatible with the current
173
+ * Node.js engine. Falls back to "@latest" if no engine info is available.
174
+ *
175
+ * Uses the npm registry API to get per-version engine requirements.
176
+ */
177
+ async function fetchLatestCompatibleVersion(mod: string): Promise<string> {
178
+ const currentNode = process.version; // e.g. "v20.17.0"
179
+ try {
180
+ // Encode scoped package names for URL (e.g. @tyvm/pkg -> @tyvm%2Fpkg)
181
+ const encodedMod = mod.replace(/^@/, "").replace("/", "%2F");
182
+ const registryUrl = mod.startsWith("@")
183
+ ? `https://registry.npmjs.org/@${encodedMod}`
184
+ : `https://registry.npmjs.org/${mod}`;
185
+
186
+ const response = await fetch(registryUrl);
187
+ if (!response.ok) throw new Error(`Registry returned ${response.status}`);
188
+ const pkgData = await response.json() as any;
189
+
190
+ // pkgData.versions is a map of version -> package metadata
191
+ const versionsMap: Record<string, any> = pkgData.versions ?? {};
192
+ const allVersions = Object.keys(versionsMap);
193
+
194
+ // Build per-version engine map from actual per-version metadata
195
+ const enginesByVersion: Record<string, string> = {};
196
+ for (const [v, meta] of Object.entries(versionsMap)) {
197
+ enginesByVersion[v] = (meta as any)?.engines?.node ?? "";
198
+ }
199
+
200
+ if (allVersions.length === 0) return `${mod}@latest`;
201
+
202
+ // Sort versions descending (simple semver numeric sort)
203
+ const sorted = [...allVersions].sort((a, b) => {
204
+ const pa = a.split(".").map(Number);
205
+ const pb = b.split(".").map(Number);
206
+ for (let i = 0; i < 3; i++) {
207
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
208
+ }
209
+ return 0;
210
+ });
211
+
212
+ for (const v of sorted) {
213
+ const engineRange = enginesByVersion[v] ?? "";
214
+ if (nodeSatisfiesRange(currentNode, engineRange)) {
215
+ return `${mod}@${v}`;
216
+ }
217
+ }
218
+
219
+ // No compatible version found — warn and fall back to latest
220
+ console.warn(`⚠️ No version of ${mod} found compatible with Node ${currentNode}. Installing latest anyway.`);
221
+ return `${mod}@latest`;
222
+ } catch {
223
+ // Can't fetch registry info — fall back to latest
224
+ return `${mod}@latest`;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Get the currently installed version of a package in .knowhow/node_modules.
230
+ */
231
+ function getInstalledVersion(mod: string, knowhowDir: string): string | null {
232
+ try {
233
+ const pkgJsonPath = path.join(knowhowDir, "node_modules", mod, "package.json");
234
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
235
+ return pkgJson.version ?? null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Format a date string as a human-readable relative time (e.g. "2 days ago").
243
+ */
244
+ function formatRelativeTime(isoDate: string): string {
245
+ if (!isoDate) return "unknown";
246
+ const then = new Date(isoDate).getTime();
247
+ if (isNaN(then)) return "unknown";
248
+ const diffMs = Date.now() - then;
249
+ const diffMins = Math.floor(diffMs / 60_000);
250
+ if (diffMins < 2) return "just now";
251
+ if (diffMins < 60) return `${diffMins} minutes ago`;
252
+ const diffHours = Math.floor(diffMins / 60);
253
+ if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
254
+ const diffDays = Math.floor(diffHours / 24);
255
+ if (diffDays < 30) return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
256
+ const diffMonths = Math.floor(diffDays / 30);
257
+ if (diffMonths < 12) return `${diffMonths} month${diffMonths !== 1 ? "s" : ""} ago`;
258
+ const diffYears = Math.floor(diffMonths / 12);
259
+ return `${diffYears} year${diffYears !== 1 ? "s" : ""} ago`;
260
+ }
261
+
262
+ /**
263
+ * Prompt the user for a yes/no confirmation.
264
+ */
265
+ function promptConfirm(question: string): Promise<boolean> {
266
+ return new Promise((resolve) => {
267
+ const rl = readline.createInterface({
268
+ input: process.stdin,
269
+ output: process.stdout,
270
+ });
271
+ rl.question(`${question} (y/N) `, (answer) => {
272
+ rl.close();
273
+ resolve(
274
+ answer.trim().toLowerCase() === "y" ||
275
+ answer.trim().toLowerCase() === "yes"
276
+ );
277
+ });
278
+ });
279
+ }
280
+
52
281
  export function addModulesCommand(program: Command): void {
53
282
  const modulesCmd = program
54
283
  .command("modules")
@@ -70,39 +299,39 @@ export function addModulesCommand(program: Command): void {
70
299
 
71
300
  if (!cfg.modules) cfg.modules = [];
72
301
 
73
- const toAdd = BUILTIN_MODULES.filter(
74
- (m) => !cfg.modules!.includes(m)
75
- );
76
-
77
- if (toAdd.length === 0) {
78
- console.log(
79
- `✅ All default modules are already in ${configLabel}. Nothing to do.`
80
- );
81
- return;
82
- }
83
-
84
302
  const knowhowDir = getKnowhowDir(isGlobal);
85
303
  ensureKnowhowPackageJson(knowhowDir);
86
304
 
87
- // Install packages that are not local file paths
88
- for (const mod of toAdd) {
305
+ // Even if modules are already in the config, they may not be installed.
306
+ // Check and install any that are missing from .knowhow/node_modules.
307
+ let anyChanges = false;
308
+ for (const mod of BUILTIN_MODULES) {
89
309
  if (!mod.startsWith(".") && !mod.startsWith("/")) {
90
- console.log(`📦 Installing ${mod}...`);
91
- npmInstallToKnowhow(mod, knowhowDir);
310
+ if (!isModuleInstalled(mod, knowhowDir)) {
311
+ const installTarget = await fetchLatestCompatibleVersion(mod);
312
+ console.log(`📦 Installing ${installTarget}...`);
313
+ npmInstallToKnowhow(installTarget, knowhowDir);
314
+ console.log(`✅ Installed ${mod}`);
315
+ anyChanges = true;
316
+ }
317
+ }
318
+ if (!cfg.modules.includes(mod)) {
319
+ cfg.modules.push(mod);
320
+ console.log(`✅ Added ${mod} to ${configLabel}`);
321
+ anyChanges = true;
92
322
  }
93
- cfg.modules!.push(mod);
94
- console.log(`✅ Added ${mod} to ${configLabel}`);
95
323
  }
96
324
 
97
- if (isGlobal) {
98
- await updateGlobalConfig(cfg);
325
+ if (!anyChanges) {
326
+ console.log(
327
+ `✅ All default modules are already in ${configLabel} and installed. Nothing to do.`
328
+ );
99
329
  } else {
100
- await updateConfig(cfg);
330
+ await (isGlobal ? updateGlobalConfig(cfg) : updateConfig(cfg));
331
+ console.log(
332
+ `\n🎉 Setup complete! Modules ready in ${knowhowDir}/node_modules.`
333
+ );
101
334
  }
102
-
103
- console.log(
104
- `\n🎉 Setup complete! ${toAdd.length} module(s) added to ${configLabel}`
105
- );
106
335
  } catch (error: any) {
107
336
  console.error("Error during modules setup:", error.message ?? error);
108
337
  process.exit(1);
@@ -116,6 +345,7 @@ export function addModulesCommand(program: Command): void {
116
345
  "If no module name is given, installs all modules already in the config."
117
346
  )
118
347
  .option("--global", "Use the global config (~/.knowhow/knowhow.json)")
348
+ .option("--latest", "Force install the latest version (bypasses package-lock)")
119
349
  .action(async (moduleName: string | undefined, opts) => {
120
350
  try {
121
351
  const isGlobal: boolean = opts.global ?? false;
@@ -135,9 +365,7 @@ export function addModulesCommand(program: Command): void {
135
365
  (m) => !m.startsWith(".") && !m.startsWith("/")
136
366
  );
137
367
  if (installable.length === 0) {
138
- console.log(
139
- `ℹ No installable modules found in ${configLabel}.`
140
- );
368
+ console.log(`ℹ No installable modules found in ${configLabel}.`);
141
369
  return;
142
370
  }
143
371
  console.log(
@@ -145,7 +373,8 @@ export function addModulesCommand(program: Command): void {
145
373
  );
146
374
  for (const mod of installable) {
147
375
  console.log(` 📦 Installing ${mod}...`);
148
- npmInstallToKnowhow(mod, knowhowDir);
376
+ const installTarget = opts.latest ? await fetchLatestCompatibleVersion(mod) : mod;
377
+ npmInstallToKnowhow(installTarget, knowhowDir);
149
378
  console.log(` ✅ Installed ${mod}`);
150
379
  }
151
380
  console.log(`\n🎉 All modules installed!`);
@@ -153,8 +382,9 @@ export function addModulesCommand(program: Command): void {
153
382
  }
154
383
 
155
384
  // Install the specified module
156
- console.log(`📦 Installing ${moduleName} into ${knowhowDir}/node_modules...`);
157
- npmInstallToKnowhow(moduleName, knowhowDir);
385
+ const installTarget = opts.latest ? await fetchLatestCompatibleVersion(moduleName) : moduleName;
386
+ console.log(`📦 Installing ${installTarget} into ${knowhowDir}/node_modules...`);
387
+ npmInstallToKnowhow(installTarget, knowhowDir);
158
388
  console.log(`✅ Installed ${moduleName}`);
159
389
 
160
390
  // Add to config if not already there
@@ -214,4 +444,109 @@ export function addModulesCommand(program: Command): void {
214
444
  process.exit(1);
215
445
  }
216
446
  });
447
+
448
+ modulesCmd
449
+ .command("update")
450
+ .description(
451
+ "Check for updates to all modules in your config and update them. " +
452
+ "Shows installed vs latest version with publish date before updating."
453
+ )
454
+ .option("--global", "Use the global config (~/.knowhow/knowhow.json)")
455
+ .option("-y, --yes", "Skip confirmation prompt and update all outdated modules automatically")
456
+ .action(async (opts) => {
457
+ try {
458
+ const isGlobal: boolean = opts.global ?? false;
459
+ const skipConfirm: boolean = opts.yes ?? false;
460
+ const cfg = isGlobal ? await getGlobalConfig() : await getConfig();
461
+ const configLabel = isGlobal
462
+ ? "~/.knowhow/knowhow.json"
463
+ : ".knowhow/knowhow.json";
464
+
465
+ if (!cfg.modules || cfg.modules.length === 0) {
466
+ console.log(`ℹ No modules found in ${configLabel}.`);
467
+ return;
468
+ }
469
+
470
+ const knowhowDir = getKnowhowDir(isGlobal);
471
+ ensureKnowhowPackageJson(knowhowDir);
472
+
473
+ // Only check npm packages (not local paths)
474
+ const installable = cfg.modules.filter(
475
+ (m) => !m.startsWith(".") && !m.startsWith("/")
476
+ );
477
+
478
+ if (installable.length === 0) {
479
+ console.log(`ℹ No npm modules found in ${configLabel}.`);
480
+ return;
481
+ }
482
+
483
+ console.log(`🔍 Checking for updates to ${installable.length} module(s)...\n`);
484
+
485
+ interface UpdateInfo {
486
+ mod: string;
487
+ installed: string | null;
488
+ latest: string;
489
+ compatibleVersion: string; // e.g. "@tyvm/knowhow-module-script@0.0.4"
490
+ publishedAt: string;
491
+ needsUpdate: boolean;
492
+ }
493
+
494
+ const updates: UpdateInfo[] = [];
495
+
496
+ for (const mod of installable) {
497
+ const registryInfo = await fetchNpmRegistryInfo(mod);
498
+ if (!registryInfo) {
499
+ console.log(` ⚠️ ${mod}: could not fetch registry info (skipping)`);
500
+ continue;
501
+ }
502
+ const installed = getInstalledVersion(mod, knowhowDir);
503
+ const { latestVersion, publishedAt } = registryInfo;
504
+ // Find the latest compatible version for the current Node.js engine
505
+ const compatibleInstallTarget = await fetchLatestCompatibleVersion(mod);
506
+ const compatibleVersion = compatibleInstallTarget.replace(/^[^@]+@/, ""); // strip "pkg@" prefix
507
+ const needsUpdate = installed !== compatibleVersion;
508
+ const timeAgo = formatRelativeTime(publishedAt);
509
+
510
+ if (needsUpdate) {
511
+ const installedStr = installed ?? "(not installed)";
512
+ const versionLabel = compatibleVersion !== latestVersion
513
+ ? `${compatibleVersion} (latest compatible with Node ${process.version}; absolute latest: ${latestVersion})`
514
+ : compatibleVersion;
515
+ console.log(` 📦 ${mod}`);
516
+ console.log(` installed: ${installedStr} → latest: ${versionLabel} (published ${timeAgo})`);
517
+ } else {
518
+ console.log(` ✅ ${mod} v${compatibleVersion} (up to date, published ${timeAgo})`);
519
+ }
520
+ updates.push({ mod, installed, latest: compatibleVersion, compatibleVersion: compatibleInstallTarget, publishedAt, needsUpdate });
521
+ }
522
+
523
+ const toUpdate = updates.filter((u) => u.needsUpdate);
524
+
525
+ if (toUpdate.length === 0) {
526
+ console.log(`\n✅ All modules are up to date!`);
527
+ return;
528
+ }
529
+
530
+ console.log(`\n${toUpdate.length} module(s) can be updated.`);
531
+
532
+ if (!skipConfirm) {
533
+ const confirmed = await promptConfirm(`Update ${toUpdate.length} module(s) now?`);
534
+ if (!confirmed) {
535
+ console.log("Cancelled.");
536
+ return;
537
+ }
538
+ }
539
+
540
+ console.log("");
541
+ for (const { mod, compatibleVersion } of toUpdate) {
542
+ console.log(` 📦 Updating ${compatibleVersion}...`);
543
+ npmInstallToKnowhow(compatibleVersion, knowhowDir);
544
+ console.log(` ✅ Updated ${mod}`);
545
+ }
546
+ console.log(`\n🎉 Update complete! ${toUpdate.length} module(s) updated.`);
547
+ } catch (error: any) {
548
+ console.error("Error during modules update:", error.message ?? error);
549
+ process.exit(1);
550
+ }
551
+ });
217
552
  }
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
  /**