@tyvm/knowhow 0.0.71 → 0.0.72

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/agents/tools/startAgentTask.ts +3 -3
  3. package/src/ai.ts +12 -2
  4. package/src/chat/modules/AgentModule.ts +11 -7
  5. package/src/cli.ts +41 -14
  6. package/src/fileSync.ts +165 -0
  7. package/src/services/AgentSyncFs.ts +25 -6
  8. package/src/services/AgentSyncKnowhowWeb.ts +25 -6
  9. package/src/services/KnowhowClient.ts +176 -4
  10. package/src/services/SessionManager.ts +1 -1
  11. package/src/types.ts +6 -0
  12. package/ts_build/package.json +1 -1
  13. package/ts_build/src/agents/tools/startAgentTask.js +2 -2
  14. package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
  15. package/ts_build/src/ai.js +8 -2
  16. package/ts_build/src/ai.js.map +1 -1
  17. package/ts_build/src/chat/modules/AgentModule.js +6 -2
  18. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  19. package/ts_build/src/cli.js +27 -10
  20. package/ts_build/src/cli.js.map +1 -1
  21. package/ts_build/src/fileSync.d.ts +8 -0
  22. package/ts_build/src/fileSync.js +116 -0
  23. package/ts_build/src/fileSync.js.map +1 -0
  24. package/ts_build/src/services/AgentSyncFs.d.ts +3 -0
  25. package/ts_build/src/services/AgentSyncFs.js +21 -4
  26. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  27. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +3 -0
  28. package/ts_build/src/services/AgentSyncKnowhowWeb.js +21 -4
  29. package/ts_build/src/services/AgentSyncKnowhowWeb.js.map +1 -1
  30. package/ts_build/src/services/KnowhowClient.d.ts +27 -0
  31. package/ts_build/src/services/KnowhowClient.js +67 -1
  32. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  33. package/ts_build/src/services/SessionManager.js +0 -1
  34. package/ts_build/src/services/SessionManager.js.map +1 -1
  35. package/ts_build/src/types.d.ts +5 -0
  36. package/ts_build/src/types.js.map +1 -1
  37. package/ts_build/src/workerSync.d.ts +8 -0
  38. package/ts_build/src/workerSync.js +120 -0
  39. package/ts_build/src/workerSync.js.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.71",
3
+ "version": "0.0.72",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -139,12 +139,12 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
139
139
 
140
140
  const syncFsNote = syncFs
141
141
  ? `\nTask ID: ${taskId}\nAgent dir: ${agentTaskDir}\n` +
142
- `To send follow-up messages, write to: ${agentTaskDir}/input.txt\n` +
142
+ `To send agent messages, write to: ${agentTaskDir}/input.txt\n` +
143
143
  `To check status, read: ${agentTaskDir}/status.txt\n`
144
144
  : "";
145
145
 
146
- // Give the agent 30 seconds to finish before detaching
147
- const detachTime = 30 * 1000; // 30 seconds
146
+ // Give the agent 5 seconds to finish before detaching
147
+ const detachTime = 5 * 1000;
148
148
  const tid = setTimeout(() => {
149
149
  try { child.unref(); } catch {}
150
150
  done(
package/src/ai.ts CHANGED
@@ -25,9 +25,19 @@ export function readPromptFile(promptFile: string, input: string) {
25
25
  if (fs.existsSync(promptFile)) {
26
26
  const promptTemplate = fs.readFileSync(promptFile, "utf-8");
27
27
  if (promptTemplate.includes("{text}")) {
28
- return promptTemplate.replaceAll("{text}", input);
28
+ // Only replace if input is provided
29
+ if (input) {
30
+ return promptTemplate.replaceAll("{text}", input);
31
+ }
32
+ // If no input provided but template expects it, return template as-is
33
+ // This allows the calling code to handle the missing input
34
+ return promptTemplate;
29
35
  } else {
30
- return `${promptTemplate}\n\n${input}`;
36
+ // Template doesn't have {text}, so input is optional
37
+ if (input) {
38
+ return `${promptTemplate}\n\n${input}`;
39
+ }
40
+ return promptTemplate;
31
41
  }
32
42
  }
33
43
  }
@@ -528,6 +528,10 @@ Please continue from where you left off and complete the original request.
528
528
  // Save initial session
529
529
  this.saveSession(taskId, taskInfo, []);
530
530
 
531
+ // Reset sync services before setting up new task (removes old listeners)
532
+ this.webSync.reset();
533
+ this.fsSync.reset();
534
+
531
535
  // Create Knowhow chat task if messageId provided
532
536
  if (
533
537
  options.messageId &&
@@ -566,13 +570,11 @@ Please continue from where you left off and complete the original request.
566
570
  }
567
571
 
568
572
  // Set up session update listener
569
- agent.agentEvents.on(
570
- agent.eventTypes.threadUpdate,
571
- async (threadState) => {
572
- this.updateSession(taskId, threadState);
573
- taskInfo.totalCost = agent.getTotalCostUsd();
574
- }
575
- );
573
+ const threadUpdateHandler = async (threadState: any) => {
574
+ this.updateSession(taskId, threadState);
575
+ taskInfo.totalCost = agent.getTotalCostUsd();
576
+ };
577
+ agent.agentEvents.on(agent.eventTypes.threadUpdate, threadUpdateHandler);
576
578
 
577
579
  console.log(
578
580
  Marked.parse(`**Starting ${agent.name} with task ID: ${taskId}...**`)
@@ -665,6 +667,8 @@ Please continue from where you left off and complete the original request.
665
667
  console.log("🎯 [AgentModule] Task Completed");
666
668
  done = true;
667
669
  output = doneMsg || "No response from the AI";
670
+ // Remove threadUpdate listener to prevent cost sharing across tasks
671
+ agent.agentEvents.removeListener(agent.eventTypes.threadUpdate, threadUpdateHandler);
668
672
  // Update task info
669
673
  taskInfo = this.taskRegistry.get(taskId);
670
674
 
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import * as allTools from "./agents/tools";
15
15
  import { LazyToolsService, services } from "./services";
16
16
  import { login } from "./login";
17
17
  import { worker } from "./worker";
18
+ import { fileSync } from "./fileSync";
18
19
  import {
19
20
  startAllWorkers,
20
21
  listWorkerPaths,
@@ -211,18 +212,23 @@ async function main() {
211
212
  try {
212
213
  await setupServices();
213
214
  let input = options.input;
214
- if (!input) {
215
+
216
+ // Only read from stdin if we don't have input and don't have a standalone prompt file
217
+ if (!input && !options.promptFile) {
215
218
  input = await readStdin();
216
- if (!input) {
217
- console.error(
218
- "Error: No input provided. Use --input flag or pipe input via stdin."
219
- );
220
- process.exit(1);
221
- }
222
219
  }
223
220
 
221
+ // Read prompt file - it will handle cases where input is empty
224
222
  input = readPromptFile(options.promptFile, input);
225
223
 
224
+ // Only error if we have no prompt file and no input
225
+ if (!input) {
226
+ console.error(
227
+ "Error: No input provided. Use --input flag, pipe input via stdin, or provide --prompt-file."
228
+ );
229
+ process.exit(1);
230
+ }
231
+
226
232
  const agentModule = new AgentModule();
227
233
  await agentModule.initialize(chatService);
228
234
  const { taskCompleted } = await agentModule.setupAgent({
@@ -250,18 +256,23 @@ async function main() {
250
256
  try {
251
257
  await setupServices();
252
258
  let input = options.input;
253
- if (!input) {
259
+
260
+ // Only read from stdin if we don't have input and don't have a standalone prompt file
261
+ if (!input && !options.promptFile) {
254
262
  input = await readStdin();
255
- if (!input) {
256
- console.error(
257
- "Error: No question provided. Use --input flag or pipe input via stdin."
258
- );
259
- process.exit(1);
260
- }
261
263
  }
262
264
 
265
+ // Read prompt file - it will handle cases where input is empty
263
266
  input = readPromptFile(options.promptFile, input);
264
267
 
268
+ // Only error if we have no prompt file and no input
269
+ if (!input) {
270
+ console.error(
271
+ "Error: No question provided. Use --input flag, pipe input via stdin, or provide --prompt-file."
272
+ );
273
+ process.exit(1);
274
+ }
275
+
265
276
  const askModule = new AskModule();
266
277
  await askModule.initialize(chatService);
267
278
  await askModule.processAIQuery(input, {
@@ -358,6 +369,22 @@ async function main() {
358
369
  await worker(options);
359
370
  });
360
371
 
372
+ program
373
+ .command("files")
374
+ .description("Sync files between local filesystem and Knowhow FS (uses fileMounts config)")
375
+ .option("--upload", "Force upload direction for all mounts")
376
+ .option("--download", "Force download direction for all mounts")
377
+ .option("--config <path>", "Path to knowhow.json", "./knowhow.json")
378
+ .option("--dry-run", "Print what would be synced without doing it")
379
+ .action(async (options) => {
380
+ try {
381
+ await fileSync(options);
382
+ } catch (error) {
383
+ console.error("Error syncing files:", error);
384
+ process.exit(1);
385
+ }
386
+ });
387
+
361
388
  program
362
389
  .command("workers")
363
390
  .description("Manage and start all registered workers")
@@ -0,0 +1,165 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { KnowhowSimpleClient, KNOWHOW_API_URL } from "./services/KnowhowClient";
4
+ import { loadJwt } from "./login";
5
+ import { getConfig } from "./config";
6
+ import { services } from "./services";
7
+ import { S3Service } from "./services/S3";
8
+
9
+ export interface FileSyncOptions {
10
+ upload?: boolean;
11
+ download?: boolean;
12
+ apiUrl?: string;
13
+ configPath?: string;
14
+ dryRun?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Sync files between local filesystem and Knowhow FS
19
+ */
20
+ export async function fileSync(options: FileSyncOptions = {}) {
21
+ const {
22
+ upload = false,
23
+ download = false,
24
+ apiUrl = KNOWHOW_API_URL,
25
+ configPath = "./knowhow.json",
26
+ dryRun = false,
27
+ } = options;
28
+
29
+ // Load configuration
30
+ const config = await getConfig();
31
+
32
+ // Check if files is configured
33
+ if (!config.files || config.files.length === 0) {
34
+ console.log("✓ No files configured, skipping sync");
35
+ return;
36
+ }
37
+
38
+ // Load JWT token
39
+ const jwt = await loadJwt();
40
+ if (!jwt) {
41
+ console.error("❌ No JWT token found. Please run 'knowhow login' first.");
42
+ process.exit(1);
43
+ }
44
+
45
+ // Create API client
46
+ const client = new KnowhowSimpleClient(apiUrl, jwt);
47
+
48
+ // Get S3 service for presigned URL operations
49
+ const { AwsS3 } = services();
50
+
51
+ console.log(`🔄 Starting file sync (${config.files.length} mounts)...`);
52
+
53
+ let successCount = 0;
54
+ let failCount = 0;
55
+
56
+ // Process each file mount
57
+ for (const mount of config.files) {
58
+ const { remotePath, localPath, direction = "download" } = mount;
59
+
60
+ // Determine actual direction based on flags and config
61
+ let actualDirection = direction;
62
+ if (upload) {
63
+ actualDirection = "upload";
64
+ } else if (download) {
65
+ actualDirection = "download";
66
+ }
67
+
68
+ try {
69
+ if (actualDirection === "download") {
70
+ await downloadFile(client, AwsS3, remotePath, localPath, dryRun);
71
+ successCount++;
72
+ } else if (actualDirection === "upload") {
73
+ await uploadFile(client, AwsS3, remotePath, localPath, dryRun);
74
+ successCount++;
75
+ }
76
+ } catch (error) {
77
+ console.error(`❌ Failed to sync ${remotePath}: ${error.message}`);
78
+ failCount++;
79
+ }
80
+ }
81
+
82
+ console.log(
83
+ `\n✓ Sync complete: ${successCount} succeeded, ${failCount} failed`
84
+ );
85
+
86
+ if (failCount > 0) {
87
+ process.exit(1);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Download a file from Knowhow FS to local filesystem
93
+ */
94
+ async function downloadFile(
95
+ client: KnowhowSimpleClient,
96
+ s3Service: S3Service,
97
+ remotePath: string,
98
+ localPath: string,
99
+ dryRun: boolean
100
+ ): Promise<void> {
101
+ console.log(`⬇️ Downloading ${remotePath} → ${localPath}`);
102
+
103
+ if (dryRun) {
104
+ console.log(` [DRY RUN] Would download to ${localPath}`);
105
+ return;
106
+ }
107
+
108
+ try {
109
+ // Get presigned download URL
110
+ const presignedUrl = await client.getOrgFilePresignedDownloadUrl(
111
+ remotePath
112
+ );
113
+
114
+ // Ensure parent directory exists
115
+ const dir = path.dirname(localPath);
116
+ if (!fs.existsSync(dir)) {
117
+ fs.mkdirSync(dir, { recursive: true });
118
+ }
119
+
120
+ // Download file using presigned URL
121
+ await s3Service.downloadFromPresignedUrl(presignedUrl, localPath);
122
+
123
+ // Get file size for logging
124
+ const stats = fs.statSync(localPath);
125
+ console.log(` ✓ Downloaded ${stats.size} bytes`);
126
+ } catch (error) {
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Upload a file from local filesystem to Knowhow FS
133
+ */
134
+ async function uploadFile(
135
+ client: KnowhowSimpleClient,
136
+ s3Service: S3Service,
137
+ remotePath: string,
138
+ localPath: string,
139
+ dryRun: boolean
140
+ ): Promise<void> {
141
+ console.log(`⬆️ Uploading ${localPath} → ${remotePath}`);
142
+
143
+ if (dryRun) {
144
+ console.log(` [DRY RUN] Would upload from ${localPath}`);
145
+ return;
146
+ }
147
+
148
+ // Check if local file exists
149
+ if (!fs.existsSync(localPath)) {
150
+ console.warn(` ⚠️ Local file not found: ${localPath}`);
151
+ return;
152
+ }
153
+
154
+ // Get presigned upload URL
155
+ const presignedUrl = await client.getOrgFilePresignedUploadUrl(remotePath);
156
+
157
+ // Upload file using presigned URL
158
+ await s3Service.uploadToPresignedUrl(presignedUrl, localPath);
159
+
160
+ // Notify backend that upload is complete to update the updatedAt timestamp
161
+ await client.markOrgFileUploadComplete(remotePath);
162
+
163
+ const stats = fs.statSync(localPath);
164
+ console.log(` ✓ Uploaded ${stats.size} bytes`);
165
+ }
@@ -25,6 +25,9 @@ export class AgentSyncFs {
25
25
  private lastInputContent: string = "";
26
26
  private cleanupInterval: NodeJS.Timeout | null = null;
27
27
  private finalizationPromise: Promise<void> | null = null;
28
+ private agent: BaseAgent | undefined;
29
+ private threadUpdateHandler: ((...args: any[]) => void) | undefined;
30
+ private doneHandler: ((...args: any[]) => void) | undefined;
28
31
 
29
32
  constructor() {
30
33
  // Start cleanup process when created
@@ -281,8 +284,10 @@ export class AgentSyncFs {
281
284
  * Set up event handlers for automatic synchronization
282
285
  */
283
286
  private setupEventHandlers(agent: BaseAgent): void {
284
- // Listen to thread updates to sync state
285
- agent.agentEvents.on(agent.eventTypes.threadUpdate, async () => {
287
+ this.agent = agent;
288
+
289
+ // Listen to thread updates to sync state (store reference for cleanup)
290
+ this.threadUpdateHandler = async () => {
286
291
  if (!this.taskId) return;
287
292
 
288
293
  try {
@@ -291,10 +296,11 @@ export class AgentSyncFs {
291
296
  } catch (error) {
292
297
  console.error(`❌ Error during threadUpdate sync:`, error);
293
298
  }
294
- });
299
+ };
300
+ agent.agentEvents.on(agent.eventTypes.threadUpdate, this.threadUpdateHandler);
295
301
 
296
- // Listen to completion event to finalize task
297
- agent.agentEvents.on(agent.eventTypes.done, (result: string) => {
302
+ // Listen to completion event to finalize task (store reference for cleanup)
303
+ this.doneHandler = (result: string) => {
298
304
  if (!this.taskId) {
299
305
  console.warn(`⚠️ [AgentSyncFs] Cannot finalize: taskId=${this.taskId}`);
300
306
  return;
@@ -313,7 +319,8 @@ export class AgentSyncFs {
313
319
  throw error;
314
320
  }
315
321
  })();
316
- });
322
+ };
323
+ agent.agentEvents.on(agent.eventTypes.done, this.doneHandler);
317
324
  }
318
325
 
319
326
  /**
@@ -407,6 +414,18 @@ export class AgentSyncFs {
407
414
  * Reset synchronization state
408
415
  */
409
416
  reset(): void {
417
+ // Remove old event listeners from the agent before resetting
418
+ if (this.agent) {
419
+ if (this.threadUpdateHandler) {
420
+ this.agent.agentEvents.removeListener(this.agent.eventTypes.threadUpdate, this.threadUpdateHandler);
421
+ this.threadUpdateHandler = undefined;
422
+ }
423
+ if (this.doneHandler) {
424
+ this.agent.agentEvents.removeListener(this.agent.eventTypes.done, this.doneHandler);
425
+ this.doneHandler = undefined;
426
+ }
427
+ this.agent = undefined;
428
+ }
410
429
  this.cleanup();
411
430
  this.taskId = undefined;
412
431
  this.taskPath = undefined;
@@ -33,6 +33,9 @@ export class AgentSyncKnowhowWeb {
33
33
  private knowhowTaskId: string | undefined;
34
34
  private eventHandlersSetup: boolean = false;
35
35
  private finalizationPromise: Promise<void> | null = null;
36
+ private agent: BaseAgent | undefined;
37
+ private threadUpdateHandler: ((...args: any[]) => void) | undefined;
38
+ private doneHandler: ((...args: any[]) => void) | undefined;
36
39
 
37
40
  constructor(baseUrl: string = KNOWHOW_API_URL) {
38
41
  this.baseUrl = baseUrl;
@@ -228,8 +231,10 @@ export class AgentSyncKnowhowWeb {
228
231
  * Set up event handlers for automatic synchronization
229
232
  */
230
233
  private setupEventHandlers(agent: BaseAgent): void {
231
- // Listen to thread updates to sync state and check for pending messages
232
- agent.agentEvents.on(agent.eventTypes.threadUpdate, async () => {
234
+ this.agent = agent;
235
+
236
+ // Listen to thread updates to sync state and check for pending messages (store reference for cleanup)
237
+ this.threadUpdateHandler = async () => {
233
238
  if (!this.knowhowTaskId || !this.baseUrl) {
234
239
  return;
235
240
  }
@@ -244,10 +249,11 @@ export class AgentSyncKnowhowWeb {
244
249
  console.error(`❌ Error during threadUpdate sync:`, error);
245
250
  // Continue execution even if synchronization fails
246
251
  }
247
- });
252
+ };
253
+ agent.agentEvents.on(agent.eventTypes.threadUpdate, this.threadUpdateHandler);
248
254
 
249
- // Listen to completion event to finalize task
250
- agent.agentEvents.on(agent.eventTypes.done, async (result: string) => {
255
+ // Listen to completion event to finalize task (store reference for cleanup)
256
+ this.doneHandler = async (result: string) => {
251
257
  if (!this.knowhowTaskId || !this.baseUrl) {
252
258
  console.warn(`⚠️ [AgentSync] Cannot finalize: knowhowTaskId=${this.knowhowTaskId}, baseUrl=${this.baseUrl}`);
253
259
  return;
@@ -268,7 +274,8 @@ export class AgentSyncKnowhowWeb {
268
274
  throw error; // Re-throw so CLI can handle it
269
275
  }
270
276
  })();
271
- });
277
+ };
278
+ agent.agentEvents.on(agent.eventTypes.done, this.doneHandler);
272
279
  }
273
280
 
274
281
  /**
@@ -284,6 +291,18 @@ export class AgentSyncKnowhowWeb {
284
291
  * Reset synchronization state (useful for reusing the service)
285
292
  */
286
293
  reset(): void {
294
+ // Remove old event listeners from the agent before resetting
295
+ if (this.agent) {
296
+ if (this.threadUpdateHandler) {
297
+ this.agent.agentEvents.removeListener(this.agent.eventTypes.threadUpdate, this.threadUpdateHandler);
298
+ this.threadUpdateHandler = undefined;
299
+ }
300
+ if (this.doneHandler) {
301
+ this.agent.agentEvents.removeListener(this.agent.eventTypes.done, this.doneHandler);
302
+ this.doneHandler = undefined;
303
+ }
304
+ this.agent = undefined;
305
+ }
287
306
  this.knowhowTaskId = undefined;
288
307
  this.eventHandlersSetup = false;
289
308
  this.finalizationPromise = null;
@@ -221,15 +221,21 @@ export class KnowhowSimpleClient {
221
221
  const formData = new FormData();
222
222
  // options.file can be a Buffer, ReadStream, Blob, or File
223
223
  if (Buffer.isBuffer(options.file)) {
224
- formData.append("file", new Blob([options.file]), options["fileName"] || "audio.mp3");
224
+ formData.append(
225
+ "file",
226
+ new Blob([options.file]),
227
+ options.fileName || "audio.mp3"
228
+ );
225
229
  } else {
226
230
  formData.append("file", options.file);
227
231
  }
228
232
  if (options.model) formData.append("model", options.model);
229
233
  if (options.language) formData.append("language", options.language);
230
234
  if (options.prompt) formData.append("prompt", options.prompt);
231
- if (options.response_format) formData.append("response_format", options.response_format);
232
- if (options.temperature != null) formData.append("temperature", String(options.temperature));
235
+ if (options.response_format)
236
+ formData.append("response_format", options.response_format);
237
+ if (options.temperature != null)
238
+ formData.append("temperature", String(options.temperature));
233
239
 
234
240
  return axios.post<AudioTranscriptionResponse>(
235
241
  `${this.baseUrl}/api/proxy/v1/audio/transcriptions`,
@@ -377,7 +383,11 @@ export class KnowhowSimpleClient {
377
383
  /**
378
384
  * Send a message to a running agent task
379
385
  */
380
- async sendMessageToAgent(taskId: string, message: string, role: "user" | "system" = "user") {
386
+ async sendMessageToAgent(
387
+ taskId: string,
388
+ message: string,
389
+ role: "user" | "system" = "user"
390
+ ) {
381
391
  await this.checkJwt();
382
392
  return axios.post<SendMessageResponse>(
383
393
  `${this.baseUrl}/api/org-agent-tasks/${taskId}/messages`,
@@ -429,4 +439,166 @@ export class KnowhowSimpleClient {
429
439
  }
430
440
  );
431
441
  }
442
+
443
+ // ============================================
444
+ // File Sync Methods
445
+ // Uses existing org-files endpoints: list, text GET/PUT, and create
446
+ // ============================================
447
+
448
+ /**
449
+ * List all org files for the current user's org
450
+ */
451
+ async listOrgFiles() {
452
+ await this.checkJwt();
453
+ return axios.get<
454
+ { id: string; fileName: string; folderPath: string; name: string }[]
455
+ >(`${this.baseUrl}/api/org-files`, { headers: this.headers });
456
+ }
457
+
458
+ /**
459
+ * Create a new org file record
460
+ */
461
+ async createOrgFile(fileName: string, folderPath: string) {
462
+ await this.checkJwt();
463
+ return axios.post<{
464
+ id: string;
465
+ fileName: string;
466
+ folderPath: string;
467
+ name: string;
468
+ }>(
469
+ `${this.baseUrl}/api/org-files`,
470
+ { fileName, folderPath, name: fileName },
471
+ { headers: this.headers }
472
+ );
473
+ }
474
+
475
+ /**
476
+ * Get text content of an org file by id (returns streaming JSON array of strings)
477
+ */
478
+ async getOrgFileText(fileId: string) {
479
+ await this.checkJwt();
480
+ return axios.get<string>(`${this.baseUrl}/api/org-files/${fileId}/text`, {
481
+ headers: this.headers,
482
+ params: { reading: "true" },
483
+ });
484
+ }
485
+
486
+ /**
487
+ * Update text content of an org file by id
488
+ */
489
+ async updateOrgFileText(fileId: string, text: string) {
490
+ await this.checkJwt();
491
+ return axios.put(
492
+ `${this.baseUrl}/api/org-files/${fileId}/text`,
493
+ { text },
494
+ { headers: this.headers }
495
+ );
496
+ }
497
+
498
+ /**
499
+ * Find an org file by its full remote path (e.g. /test.md or /docs/readme.md)
500
+ * Returns null if not found
501
+ */
502
+ async findOrgFileByPath(
503
+ remotePath: string
504
+ ): Promise<{ id: string; fileName: string; folderPath: string } | null> {
505
+ const lastSlash = remotePath.lastIndexOf("/");
506
+ const rawFolder =
507
+ lastSlash >= 0 ? remotePath.substring(0, lastSlash + 1) : "/";
508
+ // Normalize: "/" and "" both mean root
509
+ const folderPath = rawFolder === "/" ? rawFolder : rawFolder;
510
+ const fileName =
511
+ lastSlash >= 0 ? remotePath.substring(lastSlash + 1) : remotePath;
512
+
513
+ const response = await this.listOrgFiles();
514
+ const files = response.data;
515
+ // DB may store root as "" or "/" - match both
516
+ const isRoot = folderPath === "/" || folderPath === "";
517
+ return (
518
+ files.find(
519
+ (f) =>
520
+ f.fileName === fileName &&
521
+ (f.folderPath === folderPath ||
522
+ (isRoot && (f.folderPath === "/" || f.folderPath === "")))
523
+ ) || null
524
+ );
525
+ }
526
+
527
+ /**
528
+ * Find or create an org file by path, returns the file record
529
+ */
530
+ async findOrCreateOrgFileByPath(
531
+ remotePath: string
532
+ ): Promise<{ id: string; fileName: string; folderPath: string }> {
533
+ const existing = await this.findOrgFileByPath(remotePath);
534
+ if (existing) return existing;
535
+
536
+ const lastSlash = remotePath.lastIndexOf("/");
537
+ const folderPath =
538
+ lastSlash >= 0 ? remotePath.substring(0, lastSlash + 1) || "/" : "/";
539
+ const fileName =
540
+ lastSlash >= 0 ? remotePath.substring(lastSlash + 1) : remotePath;
541
+
542
+ const response = await this.createOrgFile(fileName, folderPath);
543
+ return response.data;
544
+ }
545
+
546
+ /**
547
+ * Get presigned S3 URL for downloading a file from Knowhow FS.
548
+ * First finds or creates the file by path, then gets its download URL.
549
+ */
550
+ async getOrgFilePresignedDownloadUrl(filePath: string): Promise<string> {
551
+ await this.checkJwt();
552
+
553
+ // Find the file by path
554
+ const file = await this.findOrgFileByPath(filePath);
555
+ if (!file) {
556
+ throw new Error(`File not found: ${filePath}`);
557
+ }
558
+
559
+ // Get download URL using the file ID
560
+ const response = await axios.post<{ downloadUrl: string }>(
561
+ `${this.baseUrl}/api/org-files/download/${file.id}`,
562
+ {},
563
+ { headers: this.headers }
564
+ );
565
+ return response.data.downloadUrl;
566
+ }
567
+
568
+ /**
569
+ * Notify the backend that a file upload is complete, updating its updatedAt timestamp.
570
+ */
571
+ async markOrgFileUploadComplete(filePath: string): Promise<void> {
572
+ await this.checkJwt();
573
+
574
+ const file = await this.findOrgFileByPath(filePath);
575
+ if (!file) {
576
+ throw new Error(`File not found: ${filePath}`);
577
+ }
578
+
579
+ await axios.post(`${this.baseUrl}/api/org-files/upload/${file.id}/complete`, {}, { headers: this.headers });
580
+ }
581
+
582
+ /**
583
+ * Get presigned S3 URL for uploading a file to Knowhow FS.
584
+ * First finds or creates the file by path, then gets its upload URL.
585
+ */
586
+ async getOrgFilePresignedUploadUrl(filePath: string): Promise<string> {
587
+ await this.checkJwt();
588
+
589
+ // Find or create the file by path
590
+ const file = await this.findOrCreateOrgFileByPath(filePath);
591
+
592
+ // Extract just the filename from the path
593
+ const lastSlash = filePath.lastIndexOf("/");
594
+ const fileName = lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath;
595
+
596
+ // Get upload URL using the file ID
597
+ const response = await axios.post<{ uploadUrl: string }>(
598
+ `${this.baseUrl}/api/org-files/upload/${file.id}`,
599
+ { fileName },
600
+ { headers: this.headers }
601
+ );
602
+ return response.data.uploadUrl;
603
+ }
432
604
  }
@@ -151,7 +151,7 @@ export class SessionManager {
151
151
  ).toLocaleString()})`
152
152
  );
153
153
  session.status = "failed";
154
- session.lastUpdated = Date.now();
154
+ // Preserve original lastUpdated so sessions don't all get the same timestamp
155
155
  // Update the session file with failed status
156
156
  fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
157
157
  }