@tyvm/knowhow 0.0.154 → 0.0.155

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/agents/base/base.ts +44 -26
  3. package/src/agents/base/prompt.ts +3 -2
  4. package/src/auth/keyAuth.ts +35 -3
  5. package/src/chat/modules/AgentModule.ts +98 -42
  6. package/src/clients/index.ts +7 -4
  7. package/src/clients/openai.ts +37 -4
  8. package/src/services/KnowhowClient.ts +6 -5
  9. package/src/services/watchers/FsSyncer.ts +40 -2
  10. package/src/services/watchers/RemoteSyncer.ts +40 -2
  11. package/src/tunnel.ts +18 -12
  12. package/src/worker.ts +62 -12
  13. package/tests/clients/reasoningEffort.test.ts +57 -0
  14. package/tests/unit/auth/keyAuth.test.ts +31 -2
  15. package/tests/unit/services/KnowhowClient.test.ts +84 -0
  16. package/tests/unit/services/SyncedAgentWatcher.test.ts +89 -0
  17. package/ts_build/package.json +1 -1
  18. package/ts_build/src/agents/base/base.d.ts +1 -0
  19. package/ts_build/src/agents/base/base.js +27 -22
  20. package/ts_build/src/agents/base/base.js.map +1 -1
  21. package/ts_build/src/agents/base/prompt.d.ts +1 -1
  22. package/ts_build/src/agents/base/prompt.js +3 -2
  23. package/ts_build/src/agents/base/prompt.js.map +1 -1
  24. package/ts_build/src/auth/keyAuth.d.ts +1 -0
  25. package/ts_build/src/auth/keyAuth.js +19 -2
  26. package/ts_build/src/auth/keyAuth.js.map +1 -1
  27. package/ts_build/src/chat/modules/AgentModule.d.ts +3 -0
  28. package/ts_build/src/chat/modules/AgentModule.js +74 -34
  29. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  30. package/ts_build/src/clients/index.js +7 -4
  31. package/ts_build/src/clients/index.js.map +1 -1
  32. package/ts_build/src/clients/openai.js +29 -4
  33. package/ts_build/src/clients/openai.js.map +1 -1
  34. package/ts_build/src/services/KnowhowClient.js +5 -3
  35. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  36. package/ts_build/src/services/watchers/FsSyncer.d.ts +4 -0
  37. package/ts_build/src/services/watchers/FsSyncer.js +36 -2
  38. package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
  39. package/ts_build/src/services/watchers/RemoteSyncer.d.ts +4 -0
  40. package/ts_build/src/services/watchers/RemoteSyncer.js +36 -2
  41. package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
  42. package/ts_build/src/tunnel.js +11 -8
  43. package/ts_build/src/tunnel.js.map +1 -1
  44. package/ts_build/src/worker.js +47 -10
  45. package/ts_build/src/worker.js.map +1 -1
  46. package/ts_build/tests/clients/reasoningEffort.test.js +47 -0
  47. package/ts_build/tests/clients/reasoningEffort.test.js.map +1 -1
  48. package/ts_build/tests/unit/auth/keyAuth.test.js +22 -2
  49. package/ts_build/tests/unit/auth/keyAuth.test.js.map +1 -1
  50. package/ts_build/tests/unit/services/KnowhowClient.test.d.ts +1 -0
  51. package/ts_build/tests/unit/services/KnowhowClient.test.js +69 -0
  52. package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -0
  53. package/ts_build/tests/unit/services/SyncedAgentWatcher.test.d.ts +1 -0
  54. package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js +78 -0
  55. package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js.map +1 -0
@@ -16,7 +16,9 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
16
16
  public taskId: string = "";
17
17
  private client: KnowhowSimpleClient;
18
18
  private pollInterval: NodeJS.Timeout | null = null;
19
+ private lastThreadIndex: number = -1;
19
20
  private lastThreadLength: number = 0;
21
+ private lastMessageJson: string | null = null;
20
22
  public agentName: string = "remote-agent";
21
23
  private stopped: boolean = false;
22
24
  public agentEvents = new EventService();
@@ -41,8 +43,10 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
41
43
  const details = await this.client.getTaskDetails(taskId);
42
44
  const threads: any[][] = details?.data?.threads || [];
43
45
  const lastThread = threads[threads.length - 1] || [];
46
+ this.lastThreadIndex = threads.length - 1;
44
47
  this.agentName = "remote-agent";
45
48
  this.lastThreadLength = lastThread.length;
49
+ this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
46
50
  } catch (err: any) {
47
51
  console.warn(
48
52
  `⚠️ Could not load initial state for task ${taskId}: ${err.message}`
@@ -68,8 +72,26 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
68
72
  const details = await this.client.getTaskDetails(this.taskId);
69
73
  const threads: any[][] = details?.data?.threads || [];
70
74
  const lastThread = threads[threads.length - 1] || [];
75
+ const threadIndex = threads.length - 1;
76
+
77
+ let newMessages: any[];
78
+ if (
79
+ threadIndex === this.lastThreadIndex &&
80
+ lastThread.length >= this.lastThreadLength
81
+ ) {
82
+ newMessages = lastThread.slice(this.lastThreadLength);
83
+ } else {
84
+ const retainedIndex = this.findLastMessage(
85
+ lastThread,
86
+ this.lastMessageJson
87
+ );
88
+ newMessages = lastThread.slice(retainedIndex + 1);
89
+ }
90
+
91
+ this.lastThreadIndex = threadIndex;
92
+ this.lastThreadLength = lastThread.length;
93
+ this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
71
94
 
72
- const newMessages = lastThread.slice(this.lastThreadLength);
73
95
  if (newMessages.length > 0) {
74
96
  const renderEvents = messagesToRenderEvents(
75
97
  newMessages,
@@ -93,7 +115,6 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
93
115
  }
94
116
  }
95
117
  this.agentEvents.emit(this.eventTypes.threadUpdate, lastThread);
96
- this.lastThreadLength = lastThread.length;
97
118
  }
98
119
 
99
120
  // Stop polling and emit done if task is complete with a result
@@ -155,4 +176,21 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
155
176
  await this.client.sendMessageToAgent(this.taskId, `/poke ${message || ""}`.trim());
156
177
  console.log(`🫵 Interrupt sent to remote web agent: ${this.taskId}`);
157
178
  }
179
+
180
+ private messageJson(message: any): string | null {
181
+ if (message == null) return null;
182
+ try {
183
+ return JSON.stringify(message);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ private findLastMessage(messages: any[], messageJson: string | null): number {
190
+ if (messageJson == null) return -1;
191
+ for (let index = messages.length - 1; index >= 0; index--) {
192
+ if (this.messageJson(messages[index]) === messageJson) return index;
193
+ }
194
+ return -1;
195
+ }
158
196
  }
package/src/tunnel.ts CHANGED
@@ -10,10 +10,12 @@ import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
10
10
  import { WsMiddlewareStack } from "./workers/auth/WsMiddleware";
11
11
  import { makeAuthMiddleware } from "./workers/auth/authMiddleware";
12
12
 
13
+ const PROD_TUNNEL_WORKER_DOMAIN = "worker.tyvm-apps.com";
14
+
13
15
  /**
14
- * Extract the tunnel domain and protocol from the API URL.
15
- * e.g., "https://api.knowhow.tyvm.ai" -> { domain: "worker.knowhow.tyvm.ai", useHttps: true }
16
- * e.g., "http://localhost:4000" -> { domain: "worker.localhost:4000", useHttps: false }
16
+ * Resolve the tunnel domain and protocol from the environment and API URL.
17
+ * TUNNEL_WORKER_DOMAIN takes precedence. Otherwise dev API URLs use the dev
18
+ * tunnel domain and all other API URLs use the production tunnel domain.
17
19
  */
18
20
  export function extractTunnelDomain(apiUrl: string): {
19
21
  domain: string;
@@ -22,18 +24,22 @@ export function extractTunnelDomain(apiUrl: string): {
22
24
  try {
23
25
  const url = new URL(apiUrl);
24
26
  const useHttps = url.protocol === "https:";
27
+ const configuredDomain = process.env.TUNNEL_WORKER_DOMAIN?.trim();
25
28
 
26
- // For localhost, include port; for production, just use hostname
27
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
28
- return {
29
- domain: `worker.${url.hostname}:${url.port || "80"}`,
30
- useHttps,
31
- };
32
- }
33
- return { domain: `worker.${url.hostname}`, useHttps };
29
+ const isDevApi = url.hostname.split(".").includes("dev");
30
+ const domain =
31
+ configuredDomain ||
32
+ (isDevApi ? `worker.dev.tyvm-apps.com` : PROD_TUNNEL_WORKER_DOMAIN);
33
+
34
+ return { domain, useHttps };
34
35
  } catch (err) {
35
36
  console.error("Failed to parse API_URL for tunnel domain:", err);
36
- return { domain: "worker.localhost:4000", useHttps: false }; // fallback
37
+ return {
38
+ domain:
39
+ process.env.TUNNEL_WORKER_DOMAIN?.trim() ||
40
+ PROD_TUNNEL_WORKER_DOMAIN,
41
+ useHttps: false,
42
+ };
37
43
  }
38
44
  }
39
45
 
package/src/worker.ts CHANGED
@@ -6,6 +6,7 @@ import { loadJwt } from "./login";
6
6
  import { services } from "./services";
7
7
  import { PasskeySetupService } from "./workers/auth/PasskeySetup";
8
8
  import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
9
+ import { authenticateWithKey, hasKeyPair } from "./auth/keyAuth";
9
10
  import {
10
11
  makeUnlockTool,
11
12
  makeLockTool,
@@ -303,8 +304,8 @@ export async function worker(options?: {
303
304
  let connected = false;
304
305
  let tunnelHandler: TunnelHandler | null = null;
305
306
  let tunnelWs: WebSocket | null = null;
306
- let lastJwt: string | null = null;
307
307
  let unauthorizedJwt: string | null = null;
308
+ let refreshAttemptedJwt: string | null = null;
308
309
 
309
310
  // ---------------------------------------------------------------------------
310
311
  // Liveness watchdog state
@@ -353,7 +354,6 @@ export async function worker(options?: {
353
354
  async function connectWebSocket() {
354
355
  const jwt = await loadJwt();
355
356
  console.log(`Connecting to ${API_URL}`);
356
- lastJwt = jwt;
357
357
 
358
358
  // Reset the MCP server to avoid "Already connected to a transport" error on reconnects
359
359
  await mcpServer.reset();
@@ -476,11 +476,17 @@ export async function worker(options?: {
476
476
  },
477
477
  onClose: (code, _reason) => {
478
478
  if (code === 1008) {
479
- unauthorizedJwt = lastJwt;
479
+ unauthorizedJwt = jwt;
480
+ // The worker and tunnel sockets share a JWT and must be renewed as
481
+ // one connection cycle. Ensure a later worker "open" event cannot
482
+ // leave us connected with a permanently closed tunnel.
483
+ if (ws.readyState !== WebSocket.CLOSED) {
484
+ ws.terminate();
485
+ }
480
486
  console.error(
481
487
  "❌ Tunnel received Unauthorized (1008). The JWT may be expired."
482
488
  );
483
- console.error(" Pausing reconnection until JWT changes...");
489
+ console.error(" Attempting to refresh the JWT with the CLI identity...");
484
490
  } else {
485
491
  console.log(
486
492
  "Tunnel connection will reconnect on next connection cycle..."
@@ -532,14 +538,16 @@ export async function worker(options?: {
532
538
  // If we got an Unauthorized (1008) close, record the JWT that failed
533
539
  // so we don't keep hammering the server with the same expired token
534
540
  if (code === 1008) {
535
- unauthorizedJwt = lastJwt;
541
+ unauthorizedJwt = jwt;
542
+ if (tunnelConnection?.readyState !== WebSocket.CLOSED) {
543
+ tunnelConnection?.terminate();
544
+ }
536
545
  console.error(
537
546
  "❌ Worker received Unauthorized (1008). The JWT may be expired."
538
547
  );
539
548
  console.error(
540
- " Run 'knowhow login' to refresh your token, then restart the worker."
549
+ " Attempting to refresh the JWT with the CLI identity..."
541
550
  );
542
- console.error(" Pausing reconnection until JWT changes...");
543
551
  } else {
544
552
  console.log("Attempting to reconnect...");
545
553
  }
@@ -566,17 +574,59 @@ export async function worker(options?: {
566
574
 
567
575
  while (true) {
568
576
  if (!connected) {
569
- // If we got an Unauthorized error, check if the JWT has changed before retrying
577
+ // A registered CLI identity can renew an expired JWT without requiring an
578
+ // interactive login. Attempt this once per rejected token; if it fails,
579
+ // continue watching the JWT file so `knowhow login` in another shell works.
570
580
  if (unauthorizedJwt !== null) {
571
- const currentJwt = await loadJwt().catch(() => null);
581
+ let currentJwt = await loadJwt().catch(() => null);
572
582
  if (currentJwt === unauthorizedJwt) {
573
- // JWT hasn't changed - don't reconnect, just wait
574
- await wait(5000);
575
- continue;
583
+ let attemptedRefreshNow = false;
584
+ if (refreshAttemptedJwt !== currentJwt) {
585
+ refreshAttemptedJwt = currentJwt;
586
+ attemptedRefreshNow = true;
587
+ const freshConfig = await getConfig();
588
+ const identityPath = freshConfig.cliIdentityPath;
589
+ if (freshConfig.orgId && hasKeyPair(identityPath)) {
590
+ try {
591
+ const refreshed = await authenticateWithKey(
592
+ freshConfig.orgId,
593
+ API_URL,
594
+ identityPath
595
+ );
596
+ if (refreshed) {
597
+ currentJwt = await loadJwt();
598
+ console.log("✅ JWT refreshed automatically; reconnecting...");
599
+ } else {
600
+ console.error(
601
+ "❌ CLI identity was rejected and could not refresh the JWT."
602
+ );
603
+ }
604
+ } catch (error) {
605
+ const message =
606
+ error instanceof Error ? error.message : String(error);
607
+ console.error(`❌ Automatic JWT refresh failed: ${message}`);
608
+ }
609
+ } else {
610
+ console.error(
611
+ "❌ No registered CLI identity is available for automatic JWT refresh."
612
+ );
613
+ }
614
+ }
615
+
616
+ if (currentJwt === unauthorizedJwt) {
617
+ if (attemptedRefreshNow) {
618
+ console.error(
619
+ " Run 'knowhow login' in another shell; reconnection will resume when the JWT changes."
620
+ );
621
+ }
622
+ await wait(5000);
623
+ continue;
624
+ }
576
625
  }
577
626
  // JWT changed - clear the unauthorized state and reconnect
578
627
  console.log("🔄 JWT has changed, attempting to reconnect...");
579
628
  unauthorizedJwt = null;
629
+ refreshAttemptedJwt = null;
580
630
  }
581
631
 
582
632
  console.log("Attempting to connect...");
@@ -45,6 +45,63 @@ describe("reasoning effort values", () => {
45
45
  expect(client.resolveReasoningEffortForModel(options("unknown", "max"))).toBe("xhigh");
46
46
  });
47
47
 
48
+ it("explicitly disables reasoning in Responses API requests", async () => {
49
+ const client = new GenericOpenAiClient("fake-key");
50
+ const create = jest.fn().mockResolvedValue({
51
+ id: "resp_disabled",
52
+ status: "completed",
53
+ output: [{ type: "message", content: [{ type: "output_text", text: "summary" }] }],
54
+ usage: { input_tokens: 1, output_tokens: 1 },
55
+ });
56
+ (client.client as any).responses.create = create;
57
+
58
+ await client.createChatResponse({
59
+ model: Models.openai.GPT_56_Sol,
60
+ messages: [{ role: "user", content: "summarize" }],
61
+ reasoning_effort: "none",
62
+ });
63
+
64
+ expect(create).toHaveBeenCalledWith(
65
+ expect.objectContaining({ reasoning: { effort: "none" } })
66
+ );
67
+ });
68
+
69
+ it("diagnoses empty Responses output but not valid tool-only output", async () => {
70
+ const client = new GenericOpenAiClient("fake-key");
71
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
72
+ const create = jest.fn()
73
+ .mockResolvedValueOnce({
74
+ id: "resp_empty",
75
+ status: "incomplete",
76
+ incomplete_details: { reason: "max_output_tokens" },
77
+ output: [{ type: "reasoning", summary: [] }],
78
+ usage: { input_tokens: 2, output_tokens: 3 },
79
+ })
80
+ .mockResolvedValueOnce({
81
+ id: "resp_tool",
82
+ status: "completed",
83
+ output: [{
84
+ type: "function_call", call_id: "call_1", name: "readFile", arguments: "{}",
85
+ }],
86
+ usage: { input_tokens: 2, output_tokens: 3 },
87
+ });
88
+ (client.client as any).responses.create = create;
89
+ const request = {
90
+ model: Models.openai.GPT_56_Sol,
91
+ messages: [{ role: "user" as const, content: "continue" }],
92
+ };
93
+
94
+ await client.createChatResponse(request);
95
+ expect(warn).toHaveBeenCalledWith(
96
+ "[Responses API] Response contained no text or tool calls",
97
+ expect.objectContaining({ responseId: "resp_empty", status: "incomplete" })
98
+ );
99
+ warn.mockClear();
100
+ await client.createChatResponse(request);
101
+ expect(warn).not.toHaveBeenCalled();
102
+ warn.mockRestore();
103
+ });
104
+
48
105
  it("maps all values to valid Gemini thinking levels and budgets", () => {
49
106
  const client = new GenericGeminiClient("fake-key");
50
107
  const levelModel = GoogleThinkingLevelModels.find(
@@ -14,6 +14,7 @@ jest.mock("../../../src/utils/http", () => ({
14
14
  }));
15
15
 
16
16
  jest.mock("../../../src/auth/keyManager", () => ({
17
+ getDefaultPrivateKeyPath: () => "/keys/default",
17
18
  keyPairExists,
18
19
  loadKeyPair,
19
20
  signMessage,
@@ -25,6 +26,7 @@ jest.mock("../../../src/auth/browserLogin", () => ({
25
26
 
26
27
  import {
27
28
  authenticateWithKey,
29
+ exchangeAvailablePublicKeyJwt,
28
30
  exchangePublicKeyJwt,
29
31
  hasKeyPair,
30
32
  registerPublicKey,
@@ -69,14 +71,41 @@ describe("keyAuth", () => {
69
71
  ]);
70
72
  });
71
73
 
74
+ it("uses the registered default identity when a configured identity is rejected", async () => {
75
+ post
76
+ .mockRejectedValueOnce({ status: 401 })
77
+ .mockResolvedValueOnce({
78
+ data: {
79
+ challengeId: "challenge_2",
80
+ message: "default key challenge",
81
+ expiresAt: "2099-01-01T00:00:00.000Z",
82
+ },
83
+ status: 201,
84
+ })
85
+ .mockResolvedValueOnce({ data: { jwt: "default-key-jwt" }, status: 200 });
86
+
87
+ await expect(
88
+ exchangeAvailablePublicKeyJwt("org-1", "https://work.example", "/keys/stale")
89
+ ).resolves.toBe("default-key-jwt");
90
+
91
+ expect(loadKeyPair.mock.calls).toEqual([
92
+ ["/keys/stale"],
93
+ ["/keys/default"],
94
+ ]);
95
+ expect(signMessage).toHaveBeenCalledWith(
96
+ "default key challenge",
97
+ "/keys/default"
98
+ );
99
+ });
100
+
72
101
  it("falls back when the key is unavailable without signing a challenge", async () => {
73
- post.mockRejectedValueOnce({ status: 401 });
102
+ post.mockRejectedValue({ status: 401 });
74
103
 
75
104
  await expect(
76
105
  authenticateWithKey("org-1", "https://work.example", "/keys/work")
77
106
  ).resolves.toBe(false);
78
107
 
79
- expect(post).toHaveBeenCalledTimes(1);
108
+ expect(post).toHaveBeenCalledTimes(2);
80
109
  expect(signMessage).not.toHaveBeenCalled();
81
110
  });
82
111
 
@@ -0,0 +1,84 @@
1
+ import fs from "fs";
2
+
3
+ const mockGet = jest.fn();
4
+ const mockExchangePublicKeyJwt = jest.fn();
5
+ const mockStoreJwt = jest.fn();
6
+
7
+ jest.mock("../../../src/utils/http", () => {
8
+ const actual = jest.requireActual("../../../src/utils/http");
9
+ const mockedHttp = {
10
+ get: (...args: unknown[]) => mockGet(...args),
11
+ };
12
+ return {
13
+ __esModule: true,
14
+ ...actual,
15
+ http: mockedHttp,
16
+ default: mockedHttp,
17
+ };
18
+ });
19
+
20
+ jest.mock("../../../src/config", () => ({
21
+ getConfigSync: jest.fn().mockReturnValue({
22
+ orgId: "org-1",
23
+ cliIdentityPath: "/tmp/identity",
24
+ }),
25
+ }));
26
+
27
+ jest.mock("../../../src/auth/keyManager", () => ({
28
+ keyPairExists: jest.fn().mockReturnValue(true),
29
+ }));
30
+
31
+ jest.mock("../../../src/auth/keyAuth", () => ({
32
+ exchangeAvailablePublicKeyJwt: (...args: unknown[]) => mockExchangePublicKeyJwt(...args),
33
+ storeJwt: (...args: unknown[]) => mockStoreJwt(...args),
34
+ }));
35
+
36
+ import { KnowhowSimpleClient } from "../../../src/services/KnowhowClient";
37
+
38
+ describe("KnowhowSimpleClient identity renewal", () => {
39
+ afterEach(() => {
40
+ jest.restoreAllMocks();
41
+ jest.clearAllMocks();
42
+ });
43
+
44
+ it("obtains a JWT from the registered identity before loading models when no JWT exists", async () => {
45
+ jest.spyOn(fs, "existsSync").mockReturnValue(false);
46
+ mockExchangePublicKeyJwt.mockResolvedValue("fresh-jwt");
47
+ mockGet.mockResolvedValueOnce({ data: { data: [] } });
48
+
49
+ const client = new KnowhowSimpleClient("https://api.example.test");
50
+ await client.getModels();
51
+
52
+ expect(mockExchangePublicKeyJwt).toHaveBeenCalledWith(
53
+ "org-1",
54
+ "https://api.example.test",
55
+ "/tmp/identity"
56
+ );
57
+ expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
58
+ expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
59
+ });
60
+
61
+ it("replaces an expired JWT with one signed by the registered identity before loading models", async () => {
62
+ const expiredJwt = [
63
+ Buffer.from('{"alg":"none"}').toString("base64url"),
64
+ Buffer.from('{"exp":0}').toString("base64url"),
65
+ "old-signature",
66
+ ].join(".");
67
+ jest.spyOn(fs, "existsSync").mockReturnValue(true);
68
+ jest.spyOn(fs, "readFileSync").mockReturnValue(expiredJwt);
69
+ mockExchangePublicKeyJwt.mockResolvedValue("fresh-jwt");
70
+ mockGet.mockResolvedValueOnce({ data: { data: [] } });
71
+
72
+ const client = new KnowhowSimpleClient("https://api.example.test");
73
+ await client.getModels();
74
+
75
+ expect(mockExchangePublicKeyJwt).toHaveBeenCalledWith(
76
+ "org-1",
77
+ "https://api.example.test",
78
+ "/tmp/identity"
79
+ );
80
+ expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
81
+ expect(mockGet).toHaveBeenCalledTimes(1);
82
+ expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
83
+ });
84
+ });
@@ -0,0 +1,89 @@
1
+ import { FsSyncedAgentWatcher } from "../../../src/services/watchers/FsSyncer";
2
+ import { WebSyncedAgentWatcher } from "../../../src/services/watchers/RemoteSyncer";
3
+
4
+ const retained = { role: "assistant", content: "before compaction" };
5
+ const oldThread = [
6
+ { role: "system", content: "instructions" },
7
+ { role: "user", content: "task" },
8
+ retained,
9
+ ];
10
+ const compactedThread = [
11
+ { role: "system", content: "instructions" },
12
+ { role: "user", content: "compressed summary" },
13
+ retained,
14
+ { role: "assistant", content: "after compaction" },
15
+ ];
16
+
17
+ function seedCursor(watcher: any): void {
18
+ watcher.taskId = "task-1";
19
+ watcher.agentName = "Developer";
20
+ watcher.lastThreadIndex = 0;
21
+ watcher.lastThreadLength = oldThread.length;
22
+ watcher.lastMessageJson = JSON.stringify(retained);
23
+ }
24
+
25
+ describe("synced agent watcher thread rollover", () => {
26
+ it("continues rendering after filesystem agent compaction", async () => {
27
+ const watcher: any = new FsSyncedAgentWatcher();
28
+ seedCursor(watcher);
29
+ watcher.readMetadata = jest.fn().mockResolvedValue({
30
+ threads: [oldThread, compactedThread],
31
+ status: "running",
32
+ });
33
+
34
+ const messages: string[] = [];
35
+ watcher.agentEvents.on(watcher.eventTypes.agentSay, (data: any) => {
36
+ messages.push(data.message);
37
+ });
38
+
39
+ await watcher.onMetadataChanged();
40
+
41
+ expect(messages).toEqual(["after compaction"]);
42
+ expect(watcher.lastThreadIndex).toBe(1);
43
+ expect(watcher.lastThreadLength).toBe(compactedThread.length);
44
+ });
45
+
46
+ it("continues rendering after web agent compaction", async () => {
47
+ const client = {
48
+ getTaskDetails: jest.fn().mockResolvedValue({
49
+ data: {
50
+ threads: [oldThread, compactedThread],
51
+ status: "running",
52
+ },
53
+ }),
54
+ };
55
+ const watcher: any = new WebSyncedAgentWatcher(client as any);
56
+ seedCursor(watcher);
57
+
58
+ const messages: string[] = [];
59
+ watcher.agentEvents.on(watcher.eventTypes.agentSay, (data: any) => {
60
+ messages.push(data.message);
61
+ });
62
+
63
+ await watcher.onPoll();
64
+
65
+ expect(messages).toEqual(["after compaction"]);
66
+ expect(watcher.lastThreadIndex).toBe(1);
67
+ expect(watcher.lastThreadLength).toBe(compactedThread.length);
68
+ });
69
+
70
+ it("resets a cursor when a thread shrinks without changing index", async () => {
71
+ const watcher: any = new FsSyncedAgentWatcher();
72
+ seedCursor(watcher);
73
+ watcher.lastThreadLength = 10;
74
+ watcher.readMetadata = jest.fn().mockResolvedValue({
75
+ threads: [compactedThread],
76
+ status: "running",
77
+ });
78
+
79
+ const messages: string[] = [];
80
+ watcher.agentEvents.on(watcher.eventTypes.agentSay, (data: any) => {
81
+ messages.push(data.message);
82
+ });
83
+
84
+ await watcher.onMetadataChanged();
85
+
86
+ expect(messages).toEqual(["after compaction"]);
87
+ expect(watcher.lastThreadLength).toBe(compactedThread.length);
88
+ });
89
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.154",
3
+ "version": "0.0.155",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -191,6 +191,7 @@ export declare abstract class BaseAgent implements IAgent {
191
191
  role: string;
192
192
  content: string;
193
193
  } | null;
194
+ extractContentFromMessages(messages: Message[]): string;
194
195
  getTaskBreakdown(messages: Message[]): Promise<string>;
195
196
  compressMessages(messages: Message[]): Promise<Message[]>;
196
197
  }
@@ -1017,6 +1017,17 @@ let BaseAgent = class BaseAgent {
1017
1017
  }
1018
1018
  return null;
1019
1019
  }
1020
+ extractContentFromMessages(messages) {
1021
+ const content = messages
1022
+ .filter((m) => typeof m.content === "string" && m.content.trim())
1023
+ .map((m) => m.content);
1024
+ const finalAnswer = messages
1025
+ .filter((m) => m.tool_calls?.length)
1026
+ .flatMap((t) => t.tool_calls)
1027
+ .filter((t) => t.function.name === "finalAnswer")
1028
+ .map((t) => t.function.arguments);
1029
+ return [...content, ...finalAnswer].join("\n\n");
1030
+ }
1020
1031
  async getTaskBreakdown(messages) {
1021
1032
  if (this.taskBreakdown) {
1022
1033
  return this.taskBreakdown;
@@ -1039,22 +1050,12 @@ let BaseAgent = class BaseAgent {
1039
1050
  },
1040
1051
  ];
1041
1052
  const response = await this.createAgentCompletion({
1042
- model,
1043
1053
  messages: taskBreakdownMessages,
1044
1054
  tool_choice: "none",
1045
- long_ttl_cache: this.runTime() > 300_000,
1046
- ...(this.reasoningEffort !== undefined && {
1047
- reasoning_effort: this.reasoningEffort,
1048
- }),
1049
- ...(this.summarizeReasoning !== undefined && {
1050
- reasoning_summary: this.summarizeReasoning,
1051
- }),
1052
1055
  });
1053
- const breakdownContent = response.choices
1054
- .map((c) => c.message.content)
1055
- .filter((s) => typeof s === "string" && s.trim().length > 0)
1056
- .join("\n\n");
1056
+ const breakdownContent = this.extractContentFromMessages(response.choices?.map((c) => c.message));
1057
1057
  if (!breakdownContent) {
1058
+ console.log(JSON.stringify(response.choices, null, 2));
1058
1059
  throw new Error("Compaction task-breakdown request returned no text content");
1059
1060
  }
1060
1061
  this.taskBreakdown = breakdownContent;
@@ -1063,8 +1064,7 @@ let BaseAgent = class BaseAgent {
1063
1064
  }
1064
1065
  async compressMessages(messages) {
1065
1066
  let resumeIndex = messages.length - 1;
1066
- while (resumeIndex >= 0 &&
1067
- messages[resumeIndex].role !== "assistant") {
1067
+ while (resumeIndex >= 0 && messages[resumeIndex].role !== "assistant") {
1068
1068
  resumeIndex--;
1069
1069
  }
1070
1070
  const compressionEnd = resumeIndex === -1 ? messages.length : resumeIndex;
@@ -1080,7 +1080,9 @@ let BaseAgent = class BaseAgent {
1080
1080
  3. Next Steps - what we're about to do next to continue the user's original request.
1081
1081
  4. Tasks remaining - what tasks are left from the initial task breakdown.
1082
1082
 
1083
- ${this.taskBreakdown ? `Our initial task breakdown: ${this.taskBreakdown}` : ""}
1083
+ ${this.taskBreakdown
1084
+ ? `Our initial task breakdown: ${this.taskBreakdown}`
1085
+ : ""}
1084
1086
 
1085
1087
  This summary will replace the older history. The latest agent interaction will remain verbatim:
1086
1088
 
@@ -1096,21 +1098,24 @@ let BaseAgent = class BaseAgent {
1096
1098
  messages: compressMessagesPayload,
1097
1099
  tool_choice: "none",
1098
1100
  });
1099
- const summaries = response.choices
1100
- .map((c) => c.message.content)
1101
- .filter((s) => typeof s === "string" && s.trim().length > 0);
1102
- if (summaries.length === 0) {
1101
+ const summary = this.extractContentFromMessages(response.choices?.map((c) => c.message));
1102
+ if (summary.length === 0) {
1103
+ console.log(JSON.stringify(response.choices, null, 2));
1103
1104
  throw new Error("Compaction summary request returned no text content");
1104
1105
  }
1105
- this.summaries.push(...summaries);
1106
+ this.summaries.push(summary);
1106
1107
  const startMessages = [
1107
1108
  {
1108
1109
  role: "user",
1109
1110
  content: `
1110
- ${this.taskBreakdown ? `Initial task breakdown:\n ${this.taskBreakdown}` : "(No task breakdown available — summarize what you know from context above)"}
1111
+ ${this.taskBreakdown
1112
+ ? `Initial task breakdown:\n ${this.taskBreakdown}`
1113
+ : "(No task breakdown available — summarize what you know from context above)"}
1111
1114
 
1112
1115
  We have just compressed the conversation to save memory:
1113
- ${summaries.length > 0 ? JSON.stringify(summaries) : "(summary unavailable — please continue from the task breakdown above)"}
1116
+ ${summary.length > 0
1117
+ ? JSON.stringify(summary)
1118
+ : "(summary unavailable — please continue from the task breakdown above)"}
1114
1119
 
1115
1120
  Please continue the task from where we left off
1116
1121
  `,