@tyvm/knowhow 0.0.80 → 0.0.81

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.80",
3
+ "version": "0.0.81",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -480,7 +480,7 @@ export abstract class BaseAgent implements IAgent {
480
480
  } as Message);
481
481
  }
482
482
 
483
- async call(userInput: string | MessageContent[], _messages?: Message[]) {
483
+ async call(userInput: string | MessageContent[], _messages?: Message[], retryCount = 0) {
484
484
  if (this.status === this.eventTypes.notStarted) {
485
485
  this.status = this.eventTypes.inProgress;
486
486
  }
@@ -704,7 +704,32 @@ export abstract class BaseAgent implements IAgent {
704
704
  } catch (e) {
705
705
  if (e.toString().includes("429")) {
706
706
  this.setNotHealthy();
707
- return this.call(userInput, _messages);
707
+ return this.call(userInput, _messages, retryCount);
708
+ const errorStr = e.toString();
709
+ const isNonRetriable =
710
+ errorStr.includes("401") ||
711
+ errorStr.includes("403") ||
712
+ errorStr.includes("404");
713
+
714
+ const isRetriable =
715
+ !isNonRetriable &&
716
+ (errorStr.match(/5\d\d/) ||
717
+ errorStr.includes("Failed to get models") ||
718
+ errorStr.includes("timeout") ||
719
+ errorStr.includes("ECONNRESET") ||
720
+ errorStr.includes("ETIMEDOUT") ||
721
+ errorStr.includes("Invalid response format from MCP"));
722
+
723
+ if (isRetriable && retryCount < 3) {
724
+ const delay = 1000 * Math.pow(2, retryCount);
725
+ console.warn(
726
+ `Agent request failed (attempt ${retryCount + 1}/3), retrying in ${delay}ms...`,
727
+ e.message
728
+ );
729
+ await new Promise((resolve) => setTimeout(resolve, delay));
730
+ return this.call(userInput, _messages, retryCount + 1);
731
+ }
732
+
708
733
  }
709
734
 
710
735
  console.error("Agent failed", e);
@@ -6,6 +6,8 @@ import { spawn } from "child_process";
6
6
  interface StartAgentTaskParams {
7
7
  messageId?: string;
8
8
  syncFs?: boolean;
9
+ taskId?: string;
10
+ resume?: boolean;
9
11
  prompt: string;
10
12
  provider?: string;
11
13
  model?: string;
@@ -51,6 +53,8 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
51
53
  const {
52
54
  messageId,
53
55
  prompt,
56
+ taskId: providedTaskId,
57
+ resume,
54
58
  syncFs,
55
59
  provider,
56
60
  model,
@@ -62,8 +66,8 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
62
66
  throw new Error("prompt is required to create a chat task");
63
67
  }
64
68
 
65
- // Pre-generate taskId so we can return the agents dir path to the caller
66
- const taskId = generateTaskId(prompt);
69
+ // Use provided taskId if given, otherwise generate one from the prompt
70
+ const taskId = providedTaskId ?? generateTaskId(prompt);
67
71
  const agentTaskDir = path.join(AGENTS_DIR, taskId);
68
72
 
69
73
  // Build args array (no shell escaping needed - args are passed directly)
@@ -73,7 +77,10 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
73
77
  args.push("--message-id", messageId);
74
78
  } else if (syncFs) {
75
79
  args.push("--sync-fs");
76
- // Pass the pre-generated taskId so the agent dir path is predictable
80
+ }
81
+
82
+ if (syncFs || providedTaskId) {
83
+ // Pass --task-id whenever we have a known taskId (syncFs or explicit taskId)
77
84
  args.push("--task-id", taskId);
78
85
  }
79
86
 
@@ -96,6 +103,10 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
96
103
  if (maxSpendLimit !== undefined) {
97
104
  args.push("--max-spend-limit", String(maxSpendLimit));
98
105
  }
106
+ if (resume) {
107
+ // --resume is a boolean flag; task ID is already passed via --task-id above
108
+ args.push("--resume");
109
+ }
99
110
 
100
111
  const timeoutMs = maxTimeLimit ? maxTimeLimit * 60 * 1000 : 60 * 60 * 1000;
101
112
 
@@ -211,6 +222,16 @@ export const startAgentTaskDefinition: Tool = {
211
222
  type: "number",
212
223
  description: "Cost limit for agent execution in dollars. Default: 10",
213
224
  },
225
+ taskId: {
226
+ type: "string",
227
+ description:
228
+ "Pre-generated task ID to use for this agent run. When provided with syncFs, the agent directory will use this ID for a predictable path. Required when using resume.",
229
+ },
230
+ resume: {
231
+ type: "boolean",
232
+ description:
233
+ "Resume a previously started task from where it left off. Must be used together with taskId which identifies the task to resume.",
234
+ },
214
235
  },
215
236
  required: ["prompt"],
216
237
  },
@@ -732,6 +732,65 @@ Please continue from where you left off and complete the original request.
732
732
  }
733
733
  }
734
734
 
735
+ /**
736
+ * Resume an agent from a set of existing message threads
737
+ * Used by the CLI --resume flag to continue crashed/failed tasks
738
+ */
739
+ public async resumeFromMessages(options: {
740
+ agentName: string;
741
+ input: string;
742
+ threads: any[][];
743
+ messageId?: string;
744
+ taskId?: string;
745
+ }): Promise<{ taskCompleted: Promise<string> }> {
746
+ const { agentName, input, threads, messageId, taskId } = options;
747
+
748
+ // Try to extract the original request from the first user message in threads
749
+ let originalRequest = "";
750
+ if (threads && threads.length > 0) {
751
+ const firstThread = threads[0];
752
+ if (Array.isArray(firstThread)) {
753
+ const firstUserMsg = firstThread.find(
754
+ (m: any) => m.role === "user" && m.content
755
+ );
756
+ if (firstUserMsg) {
757
+ originalRequest =
758
+ typeof firstUserMsg.content === "string"
759
+ ? firstUserMsg.content
760
+ : JSON.stringify(firstUserMsg.content);
761
+ }
762
+ }
763
+ }
764
+
765
+ // Build the resume prompt
766
+ const resumePrompt = [
767
+ "You are resuming a previously started task.",
768
+ originalRequest
769
+ ? `ORIGINAL REQUEST: ${originalRequest}`
770
+ : "",
771
+ "Please continue from where you left off.",
772
+ input ? input : "",
773
+ ]
774
+ .filter(Boolean)
775
+ .join("\n");
776
+
777
+ // Flatten threads into a single messages array for the agent
778
+ const flattenedMessages = threads.flat();
779
+
780
+ const result = await this.setupAgent({
781
+ agentName,
782
+ input: resumePrompt,
783
+ messageId,
784
+ existingKnowhowTaskId: taskId,
785
+ run: false,
786
+ });
787
+
788
+ // Start agent with prior messages as context
789
+ result.agent.call(resumePrompt, flattenedMessages as any);
790
+
791
+ return { taskCompleted: result.taskCompleted };
792
+ }
793
+
735
794
  /**
736
795
  * Get list of active agent tasks
737
796
  */
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node --no-node-snapshot
2
2
  import "source-map-support/register";
3
3
  import * as fs from "fs";
4
+ import * as fsPromises from "fs/promises";
4
5
  import * as path from "path";
5
6
  import * as os from "os";
6
7
  import { Command } from "commander";
@@ -209,9 +210,61 @@ async function main() {
209
210
  .option("--task-id <taskId>", "Pre-generated task ID (used with --sync-fs for predictable agent directory path)")
210
211
  .option("--prompt-file <path>", "Custom prompt template file with {text}")
211
212
  .option("--input <text>", "Task input (fallback to stdin if not provided)")
213
+ .option("--resume", "Resume a previously started task using the --task-id (local FS or remote)")
212
214
  .action(async (options) => {
213
215
  try {
214
216
  await setupServices();
217
+
218
+ // Handle --resume flag: load threads from local FS or remote using --task-id
219
+ if (options.resume && options.taskId) {
220
+ const resumeTaskId: string = options.taskId;
221
+ const localMetadataPath = path.join(
222
+ ".knowhow",
223
+ "processes",
224
+ "agents",
225
+ resumeTaskId,
226
+ "metadata.json"
227
+ );
228
+
229
+ let threads: any[][] = [];
230
+
231
+ // Try local FS first
232
+ if (fs.existsSync(localMetadataPath)) {
233
+ try {
234
+ const raw = await fsPromises.readFile(localMetadataPath, "utf-8");
235
+ const metadata = JSON.parse(raw);
236
+ threads = metadata.threads || [];
237
+ console.log(`📁 Loaded threads from local FS: ${localMetadataPath}`);
238
+ } catch (e) {
239
+ console.warn(`⚠️ Failed to parse local metadata: ${e.message}`);
240
+ }
241
+ } else {
242
+ // Try remote via KnowhowSimpleClient
243
+ try {
244
+ const client = new KnowhowSimpleClient();
245
+ threads = await client.getTaskThreads(resumeTaskId);
246
+ console.log(`🌐 Loaded threads from remote for task: ${resumeTaskId}`);
247
+ } catch (e) {
248
+ console.warn(`⚠️ Could not load threads from remote: ${e.message}`);
249
+ }
250
+ }
251
+
252
+ const resumeInput =
253
+ options.input || "Please continue from where you left off.";
254
+
255
+ const agentModule = new AgentModule();
256
+ await agentModule.initialize(chatService);
257
+ const { taskCompleted } = await agentModule.resumeFromMessages({
258
+ agentName: options.agentName || "Patcher",
259
+ input: resumeInput,
260
+ threads,
261
+ messageId: options.messageId,
262
+ taskId: resumeTaskId,
263
+ });
264
+ await taskCompleted;
265
+ return;
266
+ }
267
+
215
268
  let input = options.input;
216
269
 
217
270
  // Only read from stdin if we don't have input and don't have a standalone prompt file
@@ -12,6 +12,41 @@ import path from "path";
12
12
  export class HttpClient implements GenericClient {
13
13
  constructor(private baseUrl: string, private headers = {}) {}
14
14
 
15
+ private async withRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
16
+ let lastError: any;
17
+ for (let attempt = 0; attempt <= retries; attempt++) {
18
+ try {
19
+ return await fn();
20
+ } catch (e: any) {
21
+ lastError = e;
22
+ const errorStr = e.toString();
23
+ const isNonRetriable =
24
+ errorStr.includes("401") ||
25
+ errorStr.includes("403") ||
26
+ errorStr.includes("404") ||
27
+ errorStr.includes("429");
28
+ const isRetriable =
29
+ !isNonRetriable &&
30
+ (errorStr.match(/5\d\d/) ||
31
+ errorStr.includes("timeout") ||
32
+ errorStr.includes("ECONNRESET") ||
33
+ errorStr.includes("ETIMEDOUT") ||
34
+ errorStr.includes("Invalid response format from MCP") ||
35
+ errorStr.includes("Failed to get models"));
36
+ if (!isRetriable || attempt >= retries) {
37
+ throw e;
38
+ }
39
+ const delay = 1000 * Math.pow(2, attempt);
40
+ console.warn(
41
+ `HTTP request failed (attempt ${attempt + 1}/${retries}), retrying in ${delay}ms...`,
42
+ e.message
43
+ );
44
+ await new Promise((resolve) => setTimeout(resolve, delay));
45
+ }
46
+ }
47
+ throw lastError;
48
+ }
49
+
15
50
  setJwt(jwt: string) {
16
51
  this.headers = {
17
52
  ...this.headers,
@@ -43,85 +78,91 @@ export class HttpClient implements GenericClient {
43
78
  async createChatCompletion(
44
79
  options: CompletionOptions
45
80
  ): Promise<CompletionResponse> {
46
- const body = {
47
- ...options,
48
- model: options.model,
49
- messages: options.messages,
50
- max_tokens: options.max_tokens || 3000,
51
-
52
- ...(options.tools && {
53
- tools: options.tools,
54
- tool_choice: "auto",
55
- }),
56
- };
57
-
58
- const response = await axios.post(
59
- `${this.baseUrl}/v1/chat/completions`,
60
- body,
61
- {
62
- headers: this.headers,
81
+ return this.withRetry(async () => {
82
+ const body = {
83
+ ...options,
84
+ model: options.model,
85
+ messages: options.messages,
86
+ max_tokens: options.max_tokens || 3000,
87
+
88
+ ...(options.tools && {
89
+ tools: options.tools,
90
+ tool_choice: "auto",
91
+ }),
92
+ };
93
+
94
+ const response = await axios.post(
95
+ `${this.baseUrl}/v1/chat/completions`,
96
+ body,
97
+ {
98
+ headers: this.headers,
99
+ }
100
+ );
101
+
102
+ const data = response.data;
103
+
104
+ // Since this uses a keepalive, we need to detect 200 with error in body
105
+ if (data.error) {
106
+ throw new Error(JSON.stringify(data.error, null, 2));
63
107
  }
64
- );
65
-
66
- const data = response.data;
67
-
68
- // Since this uses a keepalive, we need to detect 200 with error in body
69
- if (data.error) {
70
- throw new Error(JSON.stringify(data.error, null, 2));
71
- }
72
108
 
73
- return {
74
- choices: data.choices.map((choice: any) => ({
75
- message: {
76
- role: choice.message.role,
77
- content: choice.message.content,
78
- tool_calls: choice.message.tool_calls,
79
- },
80
- })),
81
- model: data.model,
82
- usage: data.usage,
83
- usd_cost: data.usd_cost,
84
- };
109
+ return {
110
+ choices: data.choices.map((choice: any) => ({
111
+ message: {
112
+ role: choice.message.role,
113
+ content: choice.message.content,
114
+ tool_calls: choice.message.tool_calls,
115
+ },
116
+ })),
117
+ model: data.model,
118
+ usage: data.usage,
119
+ usd_cost: data.usd_cost,
120
+ };
121
+ });
85
122
  }
86
123
 
87
124
  async createEmbedding(options: EmbeddingOptions): Promise<EmbeddingResponse> {
88
- const response = await axios.post(
89
- `${this.baseUrl}/v1/embeddings`,
90
- {
91
- model: options.model,
92
- input: options.input,
93
- },
94
- {
95
- headers: this.headers,
96
- }
97
- );
125
+ return this.withRetry(async () => {
126
+ const response = await axios.post(
127
+ `${this.baseUrl}/v1/embeddings`,
128
+ {
129
+ model: options.model,
130
+ input: options.input,
131
+ },
132
+ {
133
+ headers: this.headers,
134
+ }
135
+ );
98
136
 
99
- const data = response.data;
137
+ const data = response.data;
100
138
 
101
- // Since this uses a keepalive, we need to detect 200 with error in body
102
- if (data.error) {
103
- throw new Error(JSON.stringify(data.error, null, 2));
104
- }
139
+ // Since this uses a keepalive, we need to detect 200 with error in body
140
+ if (data.error) {
141
+ throw new Error(JSON.stringify(data.error, null, 2));
142
+ }
105
143
 
106
- return {
107
- data: data.data,
108
- model: options.model,
109
- usage: data.usage,
110
- usd_cost: data.usd_cost,
111
- };
144
+ return {
145
+ data: data.data,
146
+ model: options.model,
147
+ usage: data.usage,
148
+ usd_cost: data.usd_cost,
149
+ };
150
+ });
112
151
  }
113
152
 
114
153
  async getModels() {
115
- const response = await axios.get(`${this.baseUrl}/v1/models`, {
116
- headers: this.headers,
117
- });
154
+ return this.withRetry(async () => {
155
+ const response = await axios.get(`${this.baseUrl}/v1/models`, {
156
+ headers: this.headers,
157
+ });
118
158
 
119
- const data = response.data?.data;
159
+ const data = response.data?.data;
120
160
 
121
- return data.map((model: any) => ({
122
- id: model.id,
123
- object: model.object,
124
- owned_by: model.owned_by,
125
- }));
161
+ return data.map((model: any) => ({
162
+ id: model.id,
163
+ object: model.object,
164
+ owned_by: model.owned_by,
165
+ }));
166
+ });
126
167
  }
127
168
  }
package/src/index.ts CHANGED
@@ -135,6 +135,14 @@ export async function upload() {
135
135
  const url = await knowhowApiClient.getPresignedUploadUrl(source);
136
136
  console.log("Uploading to", url);
137
137
  await AwsS3.uploadToPresignedUrl(url, source.output);
138
+ // Sync config metadata back to the backend DB
139
+ await knowhowApiClient.updateEmbeddingMetadata(source.remoteId, {
140
+ inputGlob: source.input,
141
+ outputPath: source.output,
142
+ chunkSize: source.chunkSize,
143
+ remoteType: source.remoteType,
144
+ });
145
+ console.log("Synced metadata for", source.remoteId);
138
146
  } else {
139
147
  console.log(
140
148
  "Skipping upload to",
@@ -376,6 +384,9 @@ export async function download() {
376
384
  const preSignedUrl = await knowhowApiClient.getPresignedDownloadUrl(
377
385
  source
378
386
  );
387
+ // Ensure output directory exists
388
+ const outputDir = path.dirname(destinationPath);
389
+ if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
379
390
  await AwsS3.downloadFromPresignedUrl(preSignedUrl, destinationPath);
380
391
  } else {
381
392
  console.log("Unsupported remote type for", source.output);
package/src/login.ts CHANGED
@@ -111,9 +111,5 @@ export async function checkJwt(storedJwt: string) {
111
111
  return org.organizationId === orgId;
112
112
  });
113
113
 
114
- console.log(
115
- `Current user: ${user.email}, \nOrganization: ${currentOrg?.organization?.name} - ${orgId}`
116
- );
117
-
118
114
  return { user, currentOrg };
119
115
  }
@@ -53,7 +53,7 @@ export interface TaskDetailsResponse {
53
53
  inProgress: boolean;
54
54
  status: "running" | "paused" | "killed" | "completed";
55
55
  totalUsdCost: number;
56
- threads: any;
56
+ threads: any[][];
57
57
  createdAt: string;
58
58
  updatedAt: string;
59
59
  messageId?: string;
@@ -192,6 +192,25 @@ export class KnowhowSimpleClient {
192
192
  return presignedUrl;
193
193
  }
194
194
 
195
+ async updateEmbeddingMetadata(
196
+ id: string,
197
+ data: {
198
+ inputGlob?: string;
199
+ outputPath?: string;
200
+ chunkSize?: number;
201
+ remoteType?: string;
202
+ }
203
+ ) {
204
+ await this.checkJwt();
205
+ return axios.put(
206
+ `${this.baseUrl}/api/org-embeddings/${id}`,
207
+ data,
208
+ {
209
+ headers: this.headers,
210
+ }
211
+ );
212
+ }
213
+
195
214
  async createChatCompletion(options: CompletionOptions) {
196
215
  await this.checkJwt();
197
216
  return axios.post<CompletionResponse>(
@@ -431,6 +450,14 @@ export class KnowhowSimpleClient {
431
450
  );
432
451
  }
433
452
 
453
+ /**
454
+ * Get threads from a task by task ID
455
+ */
456
+ async getTaskThreads(taskId: string): Promise<any[][]> {
457
+ const response = await this.getTaskDetails(taskId);
458
+ return response.data.threads || [];
459
+ }
460
+
434
461
  /**
435
462
  * Kill/cancel a running or paused agent task
436
463
  */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.80",
3
+ "version": "0.0.81",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -115,7 +115,7 @@ export declare abstract class BaseAgent implements IAgent {
115
115
  unpause(): void;
116
116
  unpaused(): Promise<unknown>;
117
117
  kill(): Promise<void>;
118
- call(userInput: string | MessageContent[], _messages?: Message[]): any;
118
+ call(userInput: string | MessageContent[], _messages?: Message[], retryCount?: number): any;
119
119
  getStatusMessage(): string;
120
120
  logStatus(): void;
121
121
  addPendingMessage(message: Message): void;
@@ -345,7 +345,7 @@ class BaseAgent {
345
345
  content: `<Workflow>The user has requested the task to end, please call ${this.requiredToolNames} with a report of your ending state</Workflow>`,
346
346
  });
347
347
  }
348
- async call(userInput, _messages) {
348
+ async call(userInput, _messages, retryCount = 0) {
349
349
  if (this.status === this.eventTypes.notStarted) {
350
350
  this.status = this.eventTypes.inProgress;
351
351
  }
@@ -475,7 +475,24 @@ class BaseAgent {
475
475
  catch (e) {
476
476
  if (e.toString().includes("429")) {
477
477
  this.setNotHealthy();
478
- return this.call(userInput, _messages);
478
+ return this.call(userInput, _messages, retryCount);
479
+ const errorStr = e.toString();
480
+ const isNonRetriable = errorStr.includes("401") ||
481
+ errorStr.includes("403") ||
482
+ errorStr.includes("404");
483
+ const isRetriable = !isNonRetriable &&
484
+ (errorStr.match(/5\d\d/) ||
485
+ errorStr.includes("Failed to get models") ||
486
+ errorStr.includes("timeout") ||
487
+ errorStr.includes("ECONNRESET") ||
488
+ errorStr.includes("ETIMEDOUT") ||
489
+ errorStr.includes("Invalid response format from MCP"));
490
+ if (isRetriable && retryCount < 3) {
491
+ const delay = 1000 * Math.pow(2, retryCount);
492
+ console.warn(`Agent request failed (attempt ${retryCount + 1}/3), retrying in ${delay}ms...`, e.message);
493
+ await new Promise((resolve) => setTimeout(resolve, delay));
494
+ return this.call(userInput, _messages, retryCount + 1);
495
+ }
479
496
  }
480
497
  console.error("Agent failed", e);
481
498
  if ("response" in e && "data" in e.response) {