@tyvm/knowhow 0.0.157 → 0.0.158

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 (94) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/agents/base/base.ts +161 -34
  4. package/src/auth/browserLogin.ts +2 -13
  5. package/src/auth/environmentAuth.ts +51 -0
  6. package/src/auth/jwtStore.ts +70 -0
  7. package/src/auth/keyAuth.ts +4 -15
  8. package/src/auth/registerIdentity.ts +8 -8
  9. package/src/cli.ts +2 -0
  10. package/src/clients/index.ts +25 -5
  11. package/src/clients/pricing/fireworks.ts +32 -2
  12. package/src/commands/mcp.ts +1 -0
  13. package/src/commands/misc.ts +1 -1
  14. package/src/commands/remote.ts +77 -0
  15. package/src/config.ts +9 -1
  16. package/src/login.ts +54 -42
  17. package/src/remotes.ts +110 -0
  18. package/src/services/KnowhowClient.ts +12 -16
  19. package/src/services/Mcp.ts +94 -7
  20. package/src/services/McpWebsocketTransport.ts +14 -11
  21. package/src/types.ts +12 -0
  22. package/tests/unit/McpWebsocketTransport.test.ts +56 -0
  23. package/tests/unit/auth/jwtStore.test.ts +55 -0
  24. package/tests/unit/auth/registerIdentity.test.ts +8 -2
  25. package/tests/unit/commands/remote.test.ts +79 -0
  26. package/tests/unit/login.test.ts +64 -3
  27. package/tests/unit/services/KnowhowClient.test.ts +2 -2
  28. package/tests/unit/services/Mcp.test.ts +2 -2
  29. package/ts_build/package.json +1 -1
  30. package/ts_build/src/agents/base/base.d.ts +8 -3
  31. package/ts_build/src/agents/base/base.js +98 -22
  32. package/ts_build/src/agents/base/base.js.map +1 -1
  33. package/ts_build/src/auth/browserLogin.js +2 -7
  34. package/ts_build/src/auth/browserLogin.js.map +1 -1
  35. package/ts_build/src/auth/environmentAuth.d.ts +5 -0
  36. package/ts_build/src/auth/environmentAuth.js +48 -0
  37. package/ts_build/src/auth/environmentAuth.js.map +1 -0
  38. package/ts_build/src/auth/jwtStore.d.ts +4 -0
  39. package/ts_build/src/auth/jwtStore.js +65 -0
  40. package/ts_build/src/auth/jwtStore.js.map +1 -0
  41. package/ts_build/src/auth/keyAuth.d.ts +1 -1
  42. package/ts_build/src/auth/keyAuth.js +4 -16
  43. package/ts_build/src/auth/keyAuth.js.map +1 -1
  44. package/ts_build/src/auth/registerIdentity.js +6 -7
  45. package/ts_build/src/auth/registerIdentity.js.map +1 -1
  46. package/ts_build/src/cli.js +2 -0
  47. package/ts_build/src/cli.js.map +1 -1
  48. package/ts_build/src/clients/index.js +19 -5
  49. package/ts_build/src/clients/index.js.map +1 -1
  50. package/ts_build/src/clients/pricing/fireworks.d.ts +4 -0
  51. package/ts_build/src/clients/pricing/fireworks.js +14 -2
  52. package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
  53. package/ts_build/src/commands/mcp.js.map +1 -1
  54. package/ts_build/src/commands/misc.js +1 -1
  55. package/ts_build/src/commands/misc.js.map +1 -1
  56. package/ts_build/src/commands/remote.d.ts +2 -0
  57. package/ts_build/src/commands/remote.js +70 -0
  58. package/ts_build/src/commands/remote.js.map +1 -0
  59. package/ts_build/src/config.js +8 -0
  60. package/ts_build/src/config.js.map +1 -1
  61. package/ts_build/src/login.d.ts +4 -3
  62. package/ts_build/src/login.js +34 -35
  63. package/ts_build/src/login.js.map +1 -1
  64. package/ts_build/src/remotes.d.ts +14 -0
  65. package/ts_build/src/remotes.js +117 -0
  66. package/ts_build/src/remotes.js.map +1 -0
  67. package/ts_build/src/services/KnowhowClient.d.ts +1 -1
  68. package/ts_build/src/services/KnowhowClient.js +12 -14
  69. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  70. package/ts_build/src/services/Mcp.d.ts +1 -1
  71. package/ts_build/src/services/Mcp.js +68 -7
  72. package/ts_build/src/services/Mcp.js.map +1 -1
  73. package/ts_build/src/services/McpWebsocketTransport.d.ts +1 -0
  74. package/ts_build/src/services/McpWebsocketTransport.js +11 -6
  75. package/ts_build/src/services/McpWebsocketTransport.js.map +1 -1
  76. package/ts_build/src/types.d.ts +13 -0
  77. package/ts_build/src/types.js.map +1 -1
  78. package/ts_build/tests/unit/McpWebsocketTransport.test.d.ts +1 -0
  79. package/ts_build/tests/unit/McpWebsocketTransport.test.js +47 -0
  80. package/ts_build/tests/unit/McpWebsocketTransport.test.js.map +1 -0
  81. package/ts_build/tests/unit/auth/jwtStore.test.d.ts +1 -0
  82. package/ts_build/tests/unit/auth/jwtStore.test.js +46 -0
  83. package/ts_build/tests/unit/auth/jwtStore.test.js.map +1 -0
  84. package/ts_build/tests/unit/auth/registerIdentity.test.js +8 -2
  85. package/ts_build/tests/unit/auth/registerIdentity.test.js.map +1 -1
  86. package/ts_build/tests/unit/commands/remote.test.d.ts +1 -0
  87. package/ts_build/tests/unit/commands/remote.test.js +70 -0
  88. package/ts_build/tests/unit/commands/remote.test.js.map +1 -0
  89. package/ts_build/tests/unit/login.test.js +44 -3
  90. package/ts_build/tests/unit/login.test.js.map +1 -1
  91. package/ts_build/tests/unit/services/KnowhowClient.test.js +2 -2
  92. package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -1
  93. package/ts_build/tests/unit/services/Mcp.test.js +2 -2
  94. package/ts_build/tests/unit/services/Mcp.test.js.map +1 -1
@@ -1,11 +1,14 @@
1
1
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import Anthropic from "@anthropic-ai/sdk";
4
+ import { spawn } from "child_process";
4
5
 
5
6
  import fs from "fs";
7
+ import path from "path";
6
8
  import { McpConfig } from "../types";
7
9
  import { Tool } from "../clients";
8
10
  import { getConfig } from "../config";
11
+ import { loadJwtFromDisk } from "../auth/jwtStore";
9
12
  import { TraceAll } from "../util/Trace";
10
13
  import { ToolsService } from "./Tools";
11
14
  import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -30,6 +33,84 @@ export const knowhowConfig = {
30
33
  export * from "./McpServer";
31
34
  export * from "./McpWebsocketTransport";
32
35
 
36
+ /*
37
+ * Returns true if the given URL string points to localhost / 127.x / ::1.
38
+ */
39
+ function isLocalhostUrl(url: string): boolean {
40
+ try {
41
+ const parsed = new URL(url);
42
+ const host = parsed.hostname;
43
+ return (
44
+ host === "localhost" ||
45
+ host === "127.0.0.1" ||
46
+ host === "::1" ||
47
+ /^127\./.test(host)
48
+ );
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Check if a URL is reachable by making a simple HTTP GET/HEAD request.
56
+ * Resolves true if we get any HTTP response (even 4xx), false on network error.
57
+ */
58
+ async function isUrlReachable(url: string, timeoutMs = 2000): Promise<boolean> {
59
+ return new Promise((resolve) => {
60
+ const parsed = new URL(url);
61
+ const lib = parsed.protocol === "https:" ? require("https") : require("http");
62
+ const req = lib.request(
63
+ { hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: parsed.pathname || "/", method: "GET", timeout: timeoutMs },
64
+ () => { resolve(true); req.destroy(); }
65
+ );
66
+ req.on("error", () => resolve(false));
67
+ req.on("timeout", () => { req.destroy(); resolve(false); });
68
+ req.end();
69
+ });
70
+ }
71
+
72
+ /**
73
+ * If an MCP config has both a localhost `url` and a `command`, this function
74
+ * ensures the server is running: checks reachability, and if not up, spawns
75
+ * the command and waits until the URL is reachable (or timeout expires).
76
+ */
77
+ async function ensureLocalServer(mcp: McpConfig): Promise<void> {
78
+ if (!mcp.url || !mcp.command || !isLocalhostUrl(mcp.url)) return;
79
+
80
+ const alreadyUp = await isUrlReachable(mcp.url);
81
+ if (alreadyUp) {
82
+ console.log(`MCP '${mcp.name}': server already running at ${mcp.url}`);
83
+ return;
84
+ }
85
+
86
+ console.log(`MCP '${mcp.name}': starting server with command: ${mcp.command} ${(mcp.args || []).join(" ")}`);
87
+
88
+ const child = spawn(mcp.command, mcp.args || [], {
89
+ detached: true,
90
+ stdio: "ignore",
91
+ env: mcp.env ? { ...process.env, ...mcp.env } : process.env,
92
+ });
93
+ child.unref(); // don't keep the knowhow process alive just for this child
94
+
95
+ // Wait up to 30s for the server to become reachable
96
+ const maxWait = 30_000;
97
+ const interval = 500;
98
+ const started = Date.now();
99
+
100
+ while (Date.now() - started < maxWait) {
101
+ await new Promise((r) => setTimeout(r, interval));
102
+ const up = await isUrlReachable(mcp.url!);
103
+ if (up) {
104
+ console.log(`MCP '${mcp.name}': server is up at ${mcp.url}`);
105
+ return;
106
+ }
107
+ }
108
+
109
+ throw new Error(
110
+ `MCP '${mcp.name}': timed out waiting for server to start at ${mcp.url} (command: ${mcp.command} ${(mcp.args || []).join(" ")})`
111
+ );
112
+ }
113
+
33
114
  /*
34
115
  *
35
116
  * McpService is a service that manages connections to multiple MCP servers.
@@ -53,7 +134,7 @@ export class McpService {
53
134
  }
54
135
 
55
136
  this.config = mcpServers;
56
- this.transports = mcpServers.map((mcp) => this.createTransport(mcp));
137
+ this.transports = await Promise.all(mcpServers.map((mcp) => this.createTransport(mcp)));
57
138
  this.clients = this.transports.map(() => new Client(knowhowMcpClient, knowhowConfig));
58
139
 
59
140
  return this.clients;
@@ -64,8 +145,9 @@ export class McpService {
64
145
  * Re-reads authorization_token_file every time so that token refreshes
65
146
  * are picked up without restarting the agent process.
66
147
  */
67
- createTransport(mcp: McpConfig): Transport {
68
- if (mcp.command) {
148
+ async createTransport(mcp: McpConfig): Promise<Transport> {
149
+ await ensureLocalServer(mcp);
150
+ if (mcp.command && !mcp.url) {
69
151
  const stdioParams: StdioServerParameters = {
70
152
  command: mcp.command,
71
153
  args: mcp.args,
@@ -79,8 +161,13 @@ export class McpService {
79
161
  if (mcp.url) {
80
162
  // Always re-read the token file so refreshed tokens are picked up
81
163
  if (mcp.authorization_token_file) {
82
- const token = fs.readFileSync(mcp.authorization_token_file, "utf-8");
83
- mcp.authorization_token = token.trim();
164
+ if (path.basename(mcp.authorization_token_file) === ".jwt") {
165
+ const apiUrl = process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai";
166
+ mcp.authorization_token = loadJwtFromDisk(apiUrl, mcp.authorization_token_file);
167
+ } else {
168
+ const token = fs.readFileSync(mcp.authorization_token_file, "utf-8");
169
+ mcp.authorization_token = token.trim();
170
+ }
84
171
  }
85
172
  const scheme = mcp.authorization_scheme === "basic" ? "Basic" : "Bearer";
86
173
  const transport = new StreamableHTTPClientTransport(new URL(mcp.url), {
@@ -216,7 +303,7 @@ export class McpService {
216
303
  // Always create a fresh transport + client before connecting.
217
304
  // This handles cases where a previous connection attempt failed and left
218
305
  // the transport in a started-but-not-connected state.
219
- this.transports[index] = this.createTransport(this.config[index]);
306
+ this.transports[index] = await this.createTransport(this.config[index]);
220
307
  this.clients[index] = new Client(knowhowMcpClient, knowhowConfig);
221
308
 
222
309
  const client = this.clients[index];
@@ -315,7 +402,7 @@ export class McpService {
315
402
  // Replace with a fresh transport + client so connectSingle() can reconnect
316
403
  // without hitting "StreamableHTTPClientTransport already started".
317
404
  // createTransport() re-reads authorization_token_file, picking up refreshed tokens.
318
- this.transports[index] = this.createTransport(this.config[index]);
405
+ this.transports[index] = await this.createTransport(this.config[index]);
319
406
  this.clients[index] = new Client(knowhowMcpClient, knowhowConfig);
320
407
 
321
408
  // Remove tools from cache
@@ -1,4 +1,3 @@
1
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
1
  import {
3
2
  JSONRPCMessage,
4
3
  JSONRPCMessageSchema,
@@ -9,6 +8,7 @@ import { WebSocket } from "ws";
9
8
  export class MCPWebSocketTransport implements Transport {
10
9
  protected _socket: WebSocket;
11
10
  onmessage?: (message: JSONRPCMessage) => void;
11
+ onrawmessage?: (message: unknown) => boolean | Promise<boolean>;
12
12
  onerror?: (error: Error) => void;
13
13
  onclose?: () => void;
14
14
 
@@ -27,15 +27,19 @@ export class MCPWebSocketTransport implements Transport {
27
27
  } else {
28
28
  parsed = JSON.parse(data.toString());
29
29
  }
30
- console.log("MCPW Message received", JSON.stringify(parsed));
31
- const message = JSONRPCMessageSchema.parse(parsed);
32
- // Process message asynchronously to avoid blocking the WebSocket
33
- // event loop while a long-running tool call is in progress.
34
- // Without this, all subsequent messages are queued until the
35
- // current tool call completes.
36
- setImmediate(() => {
37
- this.onmessage?.(message);
38
- });
30
+ Promise.resolve(this.onrawmessage?.(parsed) ?? false)
31
+ .then((consumed) => {
32
+ if (consumed) return;
33
+ const message = JSONRPCMessageSchema.parse(parsed);
34
+ // Process message asynchronously to avoid blocking the WebSocket
35
+ // event loop while a long-running tool call is in progress.
36
+ // Without this, all subsequent messages are queued until the
37
+ // current tool call completes.
38
+ setImmediate(() => {
39
+ this.onmessage?.(message);
40
+ });
41
+ })
42
+ .catch((error) => this.onerror?.(error as Error));
39
43
  } catch (error) {
40
44
  this.onerror?.(error as Error);
41
45
  }
@@ -53,7 +57,6 @@ export class MCPWebSocketTransport implements Transport {
53
57
  async send(message: JSONRPCMessage): Promise<void> {
54
58
  return new Promise((resolve, reject) => {
55
59
  const json = JSON.stringify(message);
56
- console.log("MCPWs sending", json);
57
60
 
58
61
  if (this._socket.readyState !== WebSocket.OPEN) {
59
62
  return reject(
package/src/types.ts CHANGED
@@ -70,11 +70,23 @@ export type EmbedSource = {
70
70
  minLength?: number;
71
71
  };
72
72
 
73
+ export type KnowhowRemote = {
74
+ name: string;
75
+ apiUrl: string;
76
+ jwtPath: string;
77
+ orgId?: string;
78
+ };
79
+
73
80
  export type Config = {
74
81
  openaiBaseUrl?: string;
75
82
  promptsDir: string;
76
83
  lintCommands?: { [fileExtension: string]: string };
84
+ /** Legacy organization field; migrated into the selected remote on login. */
77
85
  orgId?: string;
86
+ /** Legacy environment map retained for reading pre-remote configurations. */
87
+ orgIds?: Record<string, string>;
88
+ remotes?: Record<string, KnowhowRemote>;
89
+ activeRemote?: string;
78
90
  /** Private Ed25519 identity selected with `knowhow login --identity`. */
79
91
  cliIdentityPath?: string;
80
92
  syncRemote?: boolean;
@@ -0,0 +1,56 @@
1
+ import { EventEmitter } from "events";
2
+ import { WebSocket } from "ws";
3
+ import { MCPWebSocketTransport } from "../../src/services/McpWebsocketTransport";
4
+
5
+ class TestSocket extends EventEmitter {
6
+ readyState = WebSocket.OPEN;
7
+ // Simulate send() calling the callback immediately (required by MCPWebSocketTransport.send())
8
+ send = jest.fn((data: string, cb?: (err?: Error) => void) => { cb?.(); });
9
+ close = jest.fn();
10
+ }
11
+
12
+ const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
13
+
14
+ describe("MCPWebSocketTransport", () => {
15
+ it("delivers valid JSON-RPC frames to onmessage", async () => {
16
+ const socket = new TestSocket();
17
+ const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
18
+ const response = { jsonrpc: "2.0", id: 1, result: {} };
19
+ transport.onmessage = jest.fn();
20
+ transport.onerror = jest.fn();
21
+
22
+ await transport.start();
23
+ socket.emit("message", JSON.stringify(response));
24
+ await flush();
25
+ await flush();
26
+
27
+ expect(transport.onmessage).toHaveBeenCalledWith(response);
28
+ expect(transport.onerror).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("calls onerror for invalid JSON-RPC frames", async () => {
32
+ const socket = new TestSocket();
33
+ const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
34
+ const invalid = { notJsonRpc: true };
35
+ transport.onmessage = jest.fn();
36
+ transport.onerror = jest.fn();
37
+
38
+ await transport.start();
39
+ socket.emit("message", JSON.stringify(invalid));
40
+ await flush();
41
+
42
+ expect(transport.onmessage).not.toHaveBeenCalled();
43
+ expect(transport.onerror).toHaveBeenCalled();
44
+ });
45
+
46
+ it("sends serialized JSON over the socket", async () => {
47
+ const socket = new TestSocket();
48
+ const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
49
+ await transport.start();
50
+
51
+ const msg = { jsonrpc: "2.0" as const, method: "ping", id: 42 };
52
+ await transport.send(msg);
53
+
54
+ expect(socket.send).toHaveBeenCalledWith(JSON.stringify(msg), expect.any(Function));
55
+ });
56
+ });
@@ -0,0 +1,55 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import {
5
+ getJwtFileName,
6
+ loadJwtFromDisk,
7
+ storeJwtForApi,
8
+ } from "../../../src/auth/jwtStore";
9
+
10
+ describe("environment-specific JWT storage", () => {
11
+ const originalCwd = process.cwd();
12
+ let workDir: string;
13
+
14
+ beforeEach(() => {
15
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-jwt-store-"));
16
+ process.chdir(workDir);
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.chdir(originalCwd);
21
+ fs.rmSync(workDir, { recursive: true, force: true });
22
+ });
23
+
24
+ it("uses readable names for production, development, and local APIs", () => {
25
+ expect(getJwtFileName("https://api.knowhow.tyvm.ai")).toBe(".jwt");
26
+ expect(getJwtFileName("https://api.dev.knowhow.tyvm.ai")).toBe(".jwt.dev");
27
+ expect(getJwtFileName("http://localhost:4000")).toBe(".jwt.local");
28
+ });
29
+
30
+ it("keeps tokens for each API environment independent", () => {
31
+ storeJwtForApi("prod-token", "https://api.knowhow.tyvm.ai");
32
+ storeJwtForApi("dev-token", "https://api.dev.knowhow.tyvm.ai");
33
+ storeJwtForApi("local-token", "http://localhost:4000");
34
+
35
+ expect(loadJwtFromDisk("https://api.knowhow.tyvm.ai")).toBe("prod-token");
36
+ expect(loadJwtFromDisk("https://api.dev.knowhow.tyvm.ai")).toBe("dev-token");
37
+ expect(loadJwtFromDisk("http://localhost:4000")).toBe("local-token");
38
+ expect(fs.statSync(path.join(workDir, ".knowhow", ".jwt.dev")).mode & 0o777).toBe(0o600);
39
+ });
40
+
41
+ it("does not send the legacy production token to another environment", () => {
42
+ const configDir = path.join(workDir, ".knowhow");
43
+ fs.mkdirSync(configDir, { recursive: true });
44
+ fs.writeFileSync(path.join(configDir, ".jwt"), "production-token");
45
+
46
+ expect(loadJwtFromDisk("https://api.dev.knowhow.tyvm.ai")).toBe("");
47
+ expect(loadJwtFromDisk("https://api.knowhow.tyvm.ai")).toBe("production-token");
48
+ });
49
+
50
+ it("uses a host-specific file for additional Knowhow instances", () => {
51
+ expect(getJwtFileName("https://api.staging.example.com:8443")).toBe(
52
+ ".jwt.api.staging.example.com-8443"
53
+ );
54
+ });
55
+ });
@@ -31,6 +31,7 @@ describe("registerIdentity", () => {
31
31
  workDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-register-identity-"));
32
32
  fs.mkdirSync(path.join(workDir, ".knowhow"), { recursive: true });
33
33
  fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt"), sourceJwt);
34
+ fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt.api.example"), sourceJwt);
34
35
  process.chdir(workDir);
35
36
  getOrCreatePublicKey.mockReturnValue({
36
37
  publicKeyBase64,
@@ -53,11 +54,16 @@ describe("registerIdentity", () => {
53
54
  await registerIdentity({ apiUrl: "https://api.example" });
54
55
  expect(registerPublicKey).toHaveBeenCalledWith(sourceJwt, publicKeyBase64, "https://api.example");
55
56
  expect(exchangePublicKeyJwt).toHaveBeenCalledWith("org-test", "https://api.example", undefined);
56
- expect(storeJwt).toHaveBeenCalledWith("identity.jwt.token");
57
+ expect(storeJwt).toHaveBeenCalledWith("identity.jwt.token", "https://api.example");
57
58
  expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
58
- orgId: "org-test",
59
59
  modelProviders: [{ provider: "knowhow" }],
60
60
  cliIdentityPath: expect.stringContaining("id_ed25519"),
61
+ remotes: {
62
+ origin: expect.objectContaining({
63
+ apiUrl: "https://api.example",
64
+ orgId: "org-test",
65
+ }),
66
+ },
61
67
  }));
62
68
  });
63
69
 
@@ -0,0 +1,79 @@
1
+ import { Command } from "commander";
2
+
3
+ const getConfig = jest.fn();
4
+ const updateConfig = jest.fn();
5
+
6
+ jest.mock("../../../src/config", () => ({ getConfig, updateConfig }));
7
+
8
+ import { addRemoteCommand } from "../../../src/commands/remote";
9
+
10
+ describe("remote command", () => {
11
+ let program: Command;
12
+
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ jest.spyOn(console, "log").mockImplementation(() => undefined);
16
+ program = new Command();
17
+ program.exitOverride();
18
+ addRemoteCommand(program);
19
+ });
20
+
21
+ afterEach(() => jest.restoreAllMocks());
22
+
23
+ it("adds a named remote with an independent JWT path", async () => {
24
+ const config: any = {
25
+ orgIds: {
26
+ "http://localhost:4000": "org-local",
27
+ },
28
+ };
29
+ getConfig.mockResolvedValue(config);
30
+
31
+ await program.parseAsync([
32
+ "node",
33
+ "knowhow",
34
+ "remote",
35
+ "add",
36
+ "dev",
37
+ "https://api.dev.knowhow.tyvm.ai/",
38
+ ]);
39
+
40
+ expect(updateConfig).toHaveBeenCalledWith(config);
41
+ expect(config.remotes.dev).toEqual({
42
+ name: "dev",
43
+ apiUrl: "https://api.dev.knowhow.tyvm.ai",
44
+ jwtPath: ".knowhow/.jwt.dev",
45
+ });
46
+ expect(config.orgIds).toBeUndefined();
47
+ expect(config.remotes.local).toEqual({
48
+ name: "local",
49
+ apiUrl: "http://localhost:4000",
50
+ jwtPath: ".knowhow/.jwt.local",
51
+ orgId: "org-local",
52
+ });
53
+ });
54
+
55
+ it("selects a configured remote for subsequent commands", async () => {
56
+ const config: any = {
57
+ remotes: {
58
+ local: {
59
+ name: "local",
60
+ apiUrl: "http://localhost:4000",
61
+ jwtPath: ".knowhow/.jwt.local",
62
+ orgId: "org-local",
63
+ },
64
+ },
65
+ };
66
+ getConfig.mockResolvedValue(config);
67
+
68
+ await program.parseAsync([
69
+ "node",
70
+ "knowhow",
71
+ "remote",
72
+ "use",
73
+ "local",
74
+ ]);
75
+
76
+ expect(config.activeRemote).toBe("local");
77
+ expect(updateConfig).toHaveBeenCalledWith(config);
78
+ });
79
+ });
@@ -48,7 +48,7 @@ describe("login public-key bootstrap", () => {
48
48
  jest.spyOn(console, "warn").mockImplementation(() => undefined);
49
49
  workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-login-"));
50
50
  fs.mkdirSync(path.join(workingDirectory, ".knowhow"));
51
- fs.writeFileSync(path.join(workingDirectory, ".knowhow", ".jwt"), "raw-jwt");
51
+ fs.writeFileSync(path.join(workingDirectory, ".knowhow", ".jwt.api.example"), "raw-jwt");
52
52
  process.chdir(workingDirectory);
53
53
  hasKeyPair.mockReturnValue(false);
54
54
  browserLogin.mockResolvedValue(undefined);
@@ -87,7 +87,42 @@ describe("login public-key bootstrap", () => {
87
87
  "https://api.example"
88
88
  );
89
89
  expect(updateConfig).toHaveBeenCalledWith(
90
- expect.objectContaining({ orgId: "org-1", modelProviders: [{ provider: "knowhow" }] })
90
+ expect.objectContaining({
91
+ activeRemote: "api.example",
92
+ modelProviders: [{ provider: "knowhow" }],
93
+ remotes: expect.objectContaining({
94
+ "api.example": {
95
+ name: "api.example",
96
+ apiUrl: "https://api.example",
97
+ jwtPath: ".knowhow/.jwt.api.example",
98
+ orgId: "org-1",
99
+ },
100
+ }),
101
+ })
102
+ );
103
+ });
104
+
105
+ it("persists an explicit --api-url as the authenticated remote", async () => {
106
+ fs.writeFileSync(path.join(workingDirectory, ".knowhow", ".jwt.local"), "local-jwt");
107
+
108
+ await login(false, undefined, "http://localhost:4000/", "local");
109
+
110
+ expect(updateConfig).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ activeRemote: "local",
113
+ remotes: expect.objectContaining({
114
+ local: {
115
+ name: "local",
116
+ apiUrl: "http://localhost:4000",
117
+ jwtPath: ".knowhow/.jwt.local",
118
+ orgId: "org-1",
119
+ },
120
+ }),
121
+ })
122
+ );
123
+ expect(httpGet).toHaveBeenCalledWith(
124
+ "http://localhost:4000/api/users/me",
125
+ expect.any(Object)
91
126
  );
92
127
  });
93
128
 
@@ -119,9 +154,9 @@ describe("login public-key bootstrap", () => {
119
154
  expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/default");
120
155
  expect(updateConfig).toHaveBeenCalledWith(
121
156
  expect.objectContaining({
122
- orgId: "org-1",
123
157
  cliIdentityPath: "/keys/default",
124
158
  modelProviders: [{ provider: "knowhow" }],
159
+ remotes: expect.objectContaining({ "api.example": expect.objectContaining({ orgId: "org-1" }) }),
125
160
  })
126
161
  );
127
162
  });
@@ -143,6 +178,32 @@ describe("login public-key bootstrap", () => {
143
178
  expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/dev");
144
179
  });
145
180
 
181
+ it("uses the selected API's JWT org instead of an org saved by another environment", async () => {
182
+ const devJwt = `${Buffer.from("{}").toString("base64url")}.${Buffer.from(
183
+ JSON.stringify({ org: "org-dev" })
184
+ ).toString("base64url")}.signature`;
185
+ fs.writeFileSync(
186
+ path.join(workingDirectory, ".knowhow", ".jwt.api.example"),
187
+ devJwt
188
+ );
189
+ hasKeyPair.mockReturnValue(true);
190
+ getConfig.mockResolvedValue({
191
+ orgId: "org-local",
192
+ orgIds: { "http://localhost:4000": "org-local" },
193
+ modelProviders: [],
194
+ });
195
+ authenticateWithKey.mockResolvedValueOnce(true);
196
+
197
+ await expect(login(false, "/keys/default")).resolves.toBeUndefined();
198
+
199
+ expect(authenticateWithKey).toHaveBeenCalledWith(
200
+ "org-dev",
201
+ "https://api.example",
202
+ "/keys/default"
203
+ );
204
+ expect(browserLogin).not.toHaveBeenCalled();
205
+ });
206
+
146
207
  it("does not attempt key authentication without a configured organization", async () => {
147
208
  hasKeyPair.mockReturnValue(true);
148
209
  getConfig.mockResolvedValue({ modelProviders: [] });
@@ -54,7 +54,7 @@ describe("KnowhowSimpleClient identity renewal", () => {
54
54
  "https://api.example.test",
55
55
  "/tmp/identity"
56
56
  );
57
- expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
57
+ expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt", "https://api.example.test");
58
58
  expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
59
59
  });
60
60
 
@@ -77,7 +77,7 @@ describe("KnowhowSimpleClient identity renewal", () => {
77
77
  "https://api.example.test",
78
78
  "/tmp/identity"
79
79
  );
80
- expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
80
+ expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt", "https://api.example.test");
81
81
  expect(mockGet).toHaveBeenCalledTimes(1);
82
82
  expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
83
83
  });
@@ -33,11 +33,11 @@ describe("McpService JWT file transport", () => {
33
33
  fs.rmSync(temporaryDirectory, { recursive: true, force: true });
34
34
  });
35
35
 
36
- it("reads the refreshed JWT when the transport is created", () => {
36
+ it("reads the refreshed JWT when the transport is created", async () => {
37
37
  fs.writeFileSync(tokenFile, "expired-jwt");
38
38
  fs.writeFileSync(tokenFile, "fresh-jwt");
39
39
 
40
- new McpService().createTransport({
40
+ await new McpService().createTransport({
41
41
  name: "authenticated-remote",
42
42
  url: "https://api.example.test/mcp",
43
43
  authorization_token_file: tokenFile,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.157",
3
+ "version": "0.0.158",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -70,6 +70,9 @@ export declare abstract class BaseAgent implements IAgent {
70
70
  protected compressThreshold: number;
71
71
  protected compressMinMessages: number;
72
72
  protected _forceCompact: boolean;
73
+ private _compactionPromise;
74
+ private _completedCompaction;
75
+ private _compactionGeneration;
73
76
  private _interruptResolve;
74
77
  private _interruptToken;
75
78
  protected threads: Message[][];
@@ -147,7 +150,7 @@ export declare abstract class BaseAgent implements IAgent {
147
150
  getTurnCount(): number;
148
151
  adjustTotalCostUsd(cost: number): void;
149
152
  getTotalCostUsd(): number;
150
- adjustTokenUsage(usage: any, messages?: Message[]): void;
153
+ adjustTokenUsage(usage: any, messages?: Message[], updateLastPromptTokens?: boolean): void;
151
154
  getTokenUsage(): {
152
155
  totalInputTokens: number;
153
156
  totalOutputTokens: number;
@@ -156,6 +159,7 @@ export declare abstract class BaseAgent implements IAgent {
156
159
  };
157
160
  protected createAgentCompletion(params: CachedChatCompletion, options?: {
158
161
  interruptValue?: CompletionResponse;
162
+ updateLastPromptTokens?: boolean;
159
163
  }): Promise<CompletionResponse>;
160
164
  startNewThread(messages: Message[]): void;
161
165
  updateCurrentThread(messages: Message[]): void;
@@ -192,6 +196,7 @@ export declare abstract class BaseAgent implements IAgent {
192
196
  content: string;
193
197
  } | null;
194
198
  extractContentFromMessages(messages: Message[]): string;
195
- getTaskBreakdown(messages: Message[]): Promise<string>;
196
- compressMessages(messages: Message[]): Promise<Message[]>;
199
+ private startBackgroundCompaction;
200
+ getTaskBreakdown(messages: Message[], cacheResult?: boolean): Promise<string>;
201
+ compressMessages(messages: Message[], onSummary?: (summary: string) => void, taskBreakdown?: string): Promise<Message[]>;
197
202
  }