@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
package/README.md CHANGED
@@ -49,7 +49,23 @@ If you want to paste a token manually:
49
49
  knowhow login --jwt
50
50
  ```
51
51
 
52
- After login, Knowhow stores your JWT in `.knowhow/.jwt` and updates `knowhow.json` so models can route through the Knowhow proxy.
52
+ Authentication belongs to a named remote. `origin` is available by default and
53
+ points to the production API. To use another compatible backend:
54
+
55
+ ```bash
56
+ knowhow remote add dev https://api.dev.knowhow.tyvm.ai
57
+ knowhow remote use dev
58
+ knowhow login
59
+ ```
60
+
61
+ Use `knowhow remote list` to show remotes and `knowhow remote get-url` to show
62
+ the selected API. Each entry in `.knowhow/knowhow.json` stores its name, API URL,
63
+ organization ID, and JWT path. Tokens remain independent: production uses
64
+ `.knowhow/.jwt`, development uses `.knowhow/.jwt.dev`, localhost uses
65
+ `.knowhow/.jwt.local`, and arbitrary hosts receive a host-specific filename.
66
+
67
+ `KNOWHOW_API_URL` remains an explicit per-process override, which is useful when
68
+ you only want to select a remote for one terminal command.
53
69
 
54
70
  ### First steps after setup
55
71
  Start the interactive chat:
package/package.json CHANGED
@@ -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": {
@@ -49,6 +49,13 @@ type CachedChatCompletion = { messages: ChatCompletion["messages"] } & Partial<
49
49
  Omit<ChatCompletion, "messages">
50
50
  >;
51
51
 
52
+ interface CompletedCompaction {
53
+ compressionEnd: number;
54
+ originalPrefix: Message[];
55
+ replacementPrefix: Message[];
56
+ summary: string;
57
+ }
58
+
52
59
  /**
53
60
  * A source is anything that can (a) start producing data, (b) hand each datum to
54
61
  * a callback, and (c) be torn down. This is the unified contract behind
@@ -128,6 +135,12 @@ export abstract class BaseAgent implements IAgent {
128
135
  // regardless of whether the token threshold has been reached.
129
136
  protected _forceCompact = false;
130
137
 
138
+ // Compaction is intentionally detached from the main agent loop. A failed
139
+ // attempt is cleared so a later turn can retry without ending the task.
140
+ private _compactionPromise: Promise<void> | null = null;
141
+ private _completedCompaction: CompletedCompaction | null = null;
142
+ private _compactionGeneration = 0;
143
+
131
144
  // Interrupt support: resolves the currently awaited tool call or completion
132
145
  private _interruptResolve: (() => void) | null = null;
133
146
  // A monotonically-increasing token identifying the active interruptible window.
@@ -287,6 +300,10 @@ export abstract class BaseAgent implements IAgent {
287
300
  }
288
301
 
289
302
  newTask(taskId?: string) {
303
+ // Invalidate any detached compaction still finishing for the previous task.
304
+ this._compactionGeneration++;
305
+ this._compactionPromise = null;
306
+ this._completedCompaction = null;
290
307
  this.currentThread = 0;
291
308
  this.threads = [];
292
309
  this.taskBreakdown = "";
@@ -571,7 +588,11 @@ export abstract class BaseAgent implements IAgent {
571
588
  return this.totalCostUsd;
572
589
  }
573
590
 
574
- adjustTokenUsage(usage: any, messages?: Message[]) {
591
+ adjustTokenUsage(
592
+ usage: any,
593
+ messages?: Message[],
594
+ updateLastPromptTokens = true
595
+ ) {
575
596
  if (!usage) return;
576
597
  // Support both OpenAI-style (prompt_tokens/completion_tokens) and Anthropic-style (input_tokens/output_tokens)
577
598
  const inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0;
@@ -593,7 +614,7 @@ export abstract class BaseAgent implements IAgent {
593
614
  // (i.e. when there are input tokens) so a usage-less interrupt stub doesn't
594
615
  // reset it to 0.
595
616
  const promptTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
596
- if (promptTokens > 0) {
617
+ if (updateLastPromptTokens && promptTokens > 0) {
597
618
  this.lastPromptTokens = promptTokens;
598
619
  }
599
620
 
@@ -641,9 +662,10 @@ export abstract class BaseAgent implements IAgent {
641
662
  params: CachedChatCompletion,
642
663
  options: {
643
664
  interruptValue?: CompletionResponse;
665
+ updateLastPromptTokens?: boolean;
644
666
  } = {}
645
667
  ): Promise<CompletionResponse> {
646
- const { interruptValue } = options;
668
+ const { interruptValue, updateLastPromptTokens = true } = options;
647
669
 
648
670
  // If you change the tools, or any of this, you bust cache
649
671
  // only safe to change messages
@@ -668,7 +690,11 @@ export abstract class BaseAgent implements IAgent {
668
690
  : await callPromise;
669
691
 
670
692
  this.adjustTotalCostUsd(response?.usd_cost);
671
- this.adjustTokenUsage(response?.usage, params.messages);
693
+ this.adjustTokenUsage(
694
+ response?.usage,
695
+ params.messages,
696
+ updateLastPromptTokens
697
+ );
672
698
 
673
699
  return response;
674
700
  }
@@ -1276,28 +1302,59 @@ export abstract class BaseAgent implements IAgent {
1276
1302
  // prompt token count from the last completion response over the
1277
1303
  // whitespace-based estimate — so it compares real tokens against the
1278
1304
  // real (token-based) compress threshold.
1305
+ const completedCompaction = this._completedCompaction;
1306
+ this._completedCompaction = null;
1307
+ let appliedCompaction = false;
1308
+
1309
+ if (completedCompaction) {
1310
+ const currentPrefix = messages.slice(
1311
+ 0,
1312
+ completedCompaction.compressionEnd
1313
+ );
1314
+ const prefixIsUnchanged = currentPrefix.every(
1315
+ (message, index) =>
1316
+ message === completedCompaction.originalPrefix[index] ||
1317
+ JSON.stringify(message) ===
1318
+ JSON.stringify(completedCompaction.originalPrefix[index])
1319
+ );
1320
+
1321
+ if (
1322
+ currentPrefix.length === completedCompaction.compressionEnd &&
1323
+ prefixIsUnchanged
1324
+ ) {
1325
+ messages = [
1326
+ ...completedCompaction.replacementPrefix,
1327
+ ...messages.slice(completedCompaction.compressionEnd),
1328
+ ];
1329
+ this.summaries.push(completedCompaction.summary);
1330
+ this.startNewThread(messages);
1331
+ this.lastPromptTokens = 0;
1332
+ appliedCompaction = true;
1333
+ } else {
1334
+ this.log(
1335
+ "Discarding completed compaction because its source prefix changed; a later turn will retry",
1336
+ "warn"
1337
+ );
1338
+ }
1339
+ }
1340
+
1279
1341
  const contextTokens = this.getContextTokenCount(messages);
1280
1342
  const overThreshold =
1281
1343
  contextTokens > this.getCompressThreshold() &&
1282
1344
  messages.length > this.compressMinMessages;
1283
1345
 
1284
- if (overThreshold || this._forceCompact) {
1285
- const taskBreakdown = await this.getTaskBreakdown(messages);
1346
+ if (
1347
+ !appliedCompaction &&
1348
+ !this._compactionPromise &&
1349
+ (overThreshold || this._forceCompact)
1350
+ ) {
1286
1351
  this.log(
1287
1352
  this._forceCompact
1288
- ? `Compacting messages (manual /compact request): ${contextTokens} tokens`
1289
- : `Compressing messages: ${contextTokens} tokens exceeds ${this.getCompressThreshold()}`
1353
+ ? `Starting background compaction (manual /compact request): ${contextTokens} tokens`
1354
+ : `Starting background compaction: ${contextTokens} tokens exceeds ${this.getCompressThreshold()}`
1290
1355
  );
1291
1356
  this._forceCompact = false;
1292
- messages = await this.compressMessages(messages);
1293
- this.startNewThread(messages);
1294
- // Reset the real-token tracker. compressMessages() ran a completion over
1295
- // the older history, so lastPromptTokens reflects the compression input
1296
- // rather than the newly reconstructed thread. Zeroing it makes
1297
- // getContextTokenCount() estimate the new thread until the next regular
1298
- // completion reports fresh usage, preventing an immediate false
1299
- // re-compaction on the following iteration.
1300
- this.lastPromptTokens = 0;
1357
+ this.startBackgroundCompaction(messages);
1301
1358
  }
1302
1359
 
1303
1360
  if (["assistant", "tool"].includes(messages[messages.length - 1].role)) {
@@ -1683,7 +1740,61 @@ export abstract class BaseAgent implements IAgent {
1683
1740
  return [...content, ...finalAnswer].join("\n\n");
1684
1741
  }
1685
1742
 
1686
- async getTaskBreakdown(messages: Message[]) {
1743
+ private startBackgroundCompaction(messages: Message[]): void {
1744
+ const snapshot = messages.slice();
1745
+ let resumeIndex = snapshot.length - 1;
1746
+ while (resumeIndex >= 0 && snapshot[resumeIndex].role !== "assistant") {
1747
+ resumeIndex--;
1748
+ }
1749
+ const compressionEnd =
1750
+ resumeIndex === -1 ? snapshot.length : resumeIndex;
1751
+ const generation = this._compactionGeneration;
1752
+
1753
+ const promise = (async () => {
1754
+ const taskBreakdown = await this.getTaskBreakdown(snapshot, false);
1755
+ if (generation !== this._compactionGeneration) return;
1756
+ this.taskBreakdown = taskBreakdown;
1757
+ let summary = "";
1758
+ const compactedSnapshot = await this.compressMessages(
1759
+ snapshot,
1760
+ (generatedSummary) => {
1761
+ summary = generatedSummary;
1762
+ },
1763
+ taskBreakdown
1764
+ );
1765
+ if (generation !== this._compactionGeneration) return;
1766
+
1767
+ const preservedSnapshotLength = snapshot.length - compressionEnd;
1768
+ this._completedCompaction = {
1769
+ compressionEnd,
1770
+ originalPrefix: snapshot.slice(0, compressionEnd),
1771
+ replacementPrefix: compactedSnapshot.slice(
1772
+ 0,
1773
+ compactedSnapshot.length - preservedSnapshotLength
1774
+ ),
1775
+ summary,
1776
+ };
1777
+ this.log("Background compaction completed; it will be applied next turn");
1778
+ })();
1779
+
1780
+ this._compactionPromise = promise;
1781
+ promise
1782
+ .catch((error) => {
1783
+ if (generation === this._compactionGeneration) {
1784
+ this.log(
1785
+ `Background compaction failed; a later turn will retry: ${error}`,
1786
+ "warn"
1787
+ );
1788
+ }
1789
+ })
1790
+ .finally(() => {
1791
+ if (this._compactionPromise === promise) {
1792
+ this._compactionPromise = null;
1793
+ }
1794
+ });
1795
+ }
1796
+
1797
+ async getTaskBreakdown(messages: Message[], cacheResult = true) {
1687
1798
  if (this.taskBreakdown) {
1688
1799
  return this.taskBreakdown;
1689
1800
  }
@@ -1708,10 +1819,13 @@ export abstract class BaseAgent implements IAgent {
1708
1819
  },
1709
1820
  ] as Message[];
1710
1821
 
1711
- const response = await this.createAgentCompletion({
1712
- messages: taskBreakdownMessages,
1713
- tool_choice: "none",
1714
- });
1822
+ const response = await this.createAgentCompletion(
1823
+ {
1824
+ messages: taskBreakdownMessages,
1825
+ tool_choice: "none",
1826
+ },
1827
+ { updateLastPromptTokens: false }
1828
+ );
1715
1829
 
1716
1830
  const breakdownContent = this.extractContentFromMessages(
1717
1831
  response.choices?.map((c) => c.message)
@@ -1726,12 +1840,18 @@ export abstract class BaseAgent implements IAgent {
1726
1840
  );
1727
1841
  }
1728
1842
 
1729
- this.taskBreakdown = breakdownContent;
1843
+ if (cacheResult) {
1844
+ this.taskBreakdown = breakdownContent;
1845
+ }
1730
1846
  this.log(`task breakdown cost: ${response.usd_cost}`);
1731
- return this.taskBreakdown;
1847
+ return breakdownContent;
1732
1848
  }
1733
1849
 
1734
- async compressMessages(messages: Message[]) {
1850
+ async compressMessages(
1851
+ messages: Message[],
1852
+ onSummary?: (summary: string) => void,
1853
+ taskBreakdown = this.taskBreakdown
1854
+ ) {
1735
1855
  // Preserve the latest agent interaction exactly. Starting the resumed thread
1736
1856
  // at the final assistant message keeps its tool calls, every tool response,
1737
1857
  // and any subsequent user messages together as one protocol-valid unit.
@@ -1757,8 +1877,8 @@ export abstract class BaseAgent implements IAgent {
1757
1877
  4. Tasks remaining - what tasks are left from the initial task breakdown.
1758
1878
 
1759
1879
  ${
1760
- this.taskBreakdown
1761
- ? `Our initial task breakdown: ${this.taskBreakdown}`
1880
+ taskBreakdown
1881
+ ? `Our initial task breakdown: ${taskBreakdown}`
1762
1882
  : ""
1763
1883
  }
1764
1884
 
@@ -1774,10 +1894,13 @@ export abstract class BaseAgent implements IAgent {
1774
1894
  },
1775
1895
  ] as Message[];
1776
1896
 
1777
- const response = await this.createAgentCompletion({
1778
- messages: compressMessagesPayload,
1779
- tool_choice: "none",
1780
- });
1897
+ const response = await this.createAgentCompletion(
1898
+ {
1899
+ messages: compressMessagesPayload,
1900
+ tool_choice: "none",
1901
+ },
1902
+ { updateLastPromptTokens: false }
1903
+ );
1781
1904
 
1782
1905
  const summary = this.extractContentFromMessages(
1783
1906
  response.choices?.map((c) => c.message)
@@ -1788,15 +1911,19 @@ export abstract class BaseAgent implements IAgent {
1788
1911
  throw new Error("Compaction summary request returned no text content");
1789
1912
  }
1790
1913
 
1791
- this.summaries.push(summary);
1914
+ if (onSummary) {
1915
+ onSummary(summary);
1916
+ } else {
1917
+ this.summaries.push(summary);
1918
+ }
1792
1919
 
1793
1920
  const startMessages = [
1794
1921
  {
1795
1922
  role: "user",
1796
1923
  content: `
1797
1924
  ${
1798
- this.taskBreakdown
1799
- ? `Initial task breakdown:\n ${this.taskBreakdown}`
1925
+ taskBreakdown
1926
+ ? `Initial task breakdown:\n ${taskBreakdown}`
1800
1927
  : "(No task breakdown available — summarize what you know from context above)"
1801
1928
  }
1802
1929
 
@@ -8,6 +8,7 @@ import * as path from "path";
8
8
  import { KNOWHOW_API_URL } from "../services/KnowhowClient";
9
9
  import { Spinner } from "./spinner";
10
10
  import { BrowserLoginError } from "./errors";
11
+ import { storeJwtForApi } from "./jwtStore";
11
12
 
12
13
  // TypeScript interfaces for the CLI Login API
13
14
 
@@ -199,19 +200,7 @@ export class BrowserLoginService {
199
200
  * Securely stores the JWT token to the file system
200
201
  */
201
202
  private async storeJwt(jwt: string): Promise<void> {
202
- const configDir = `${process.cwd()}/.knowhow`;
203
- const jwtFile = `${configDir}/.jwt`;
204
-
205
- // Ensure directory exists
206
- if (!fs.existsSync(configDir)) {
207
- fs.mkdirSync(configDir, { recursive: true });
208
- }
209
-
210
- // Write JWT to file
211
- fs.writeFileSync(jwtFile, jwt, { mode: 0o600 });
212
-
213
- // Ensure file has correct permissions (readable only by owner)
214
- fs.chmodSync(jwtFile, 0o600);
203
+ storeJwtForApi(jwt, this.baseUrl);
215
204
  }
216
205
 
217
206
  /**
@@ -0,0 +1,51 @@
1
+ import { Config } from "../types";
2
+ import { loadJwtFromDisk } from "./jwtStore";
3
+ import { createRemote, findRemoteForApi, registerRemote } from "../remotes";
4
+
5
+ /** Stable config key for authentication state owned by one API deployment. */
6
+ export function getApiEnvironmentKey(apiUrl: string): string {
7
+ try {
8
+ const url = new URL(apiUrl);
9
+ return `${url.protocol}//${url.host}`.toLowerCase();
10
+ } catch {
11
+ return apiUrl.replace(/\/+$/, "").toLowerCase();
12
+ }
13
+ }
14
+
15
+ /** Read an organization claim without trusting the JWT for authorization. */
16
+ export function getOrgIdFromJwt(jwt: string): string | undefined {
17
+ try {
18
+ const payload = JSON.parse(
19
+ Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")
20
+ ) as Record<string, unknown>;
21
+ const orgId = payload.org ?? payload.orgId ?? payload.org_id ?? payload.organizationId;
22
+ return typeof orgId === "string" && orgId ? orgId : undefined;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ /** Resolve the org for the selected API without leaking another environment's org. */
29
+ export function getOrgIdForApi(config: Config, apiUrl: string): string | undefined {
30
+ const remote = findRemoteForApi(config, apiUrl);
31
+ if (remote?.orgId) return remote.orgId;
32
+
33
+ // Read old orgIds only until this API has been represented as a remote.
34
+ const environmentKey = getApiEnvironmentKey(apiUrl);
35
+ const configured = config.orgIds?.[environmentKey];
36
+ if (configured) return configured;
37
+
38
+ const tokenOrgId = getOrgIdFromJwt(loadJwtFromDisk(apiUrl));
39
+ if (tokenOrgId) return tokenOrgId;
40
+
41
+ return config.remotes || (config.orgIds && Object.keys(config.orgIds).length > 0)
42
+ ? undefined
43
+ : config.orgId;
44
+ }
45
+
46
+ /** Record authentication state on the remote that owns it. */
47
+ export function setOrgIdForApi(config: Config, apiUrl: string, orgId: string): void {
48
+ const remote = findRemoteForApi(config, apiUrl) ?? createRemote("origin", apiUrl);
49
+ registerRemote(config, { ...remote, orgId });
50
+ delete config.orgId;
51
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { findRemoteForApiSync, resolveJwtPath } from "../remotes";
4
+
5
+ const PRODUCTION_API_HOST = "api.knowhow.tyvm.ai";
6
+
7
+ /** Return a readable, environment-specific JWT filename for an API URL. */
8
+ export function getJwtFileName(apiUrl: string): string {
9
+ try {
10
+ const { hostname, port } = new URL(apiUrl);
11
+ const normalizedHost = hostname.toLowerCase();
12
+
13
+ if (normalizedHost === PRODUCTION_API_HOST) return ".jwt";
14
+ if (
15
+ normalizedHost === "localhost" ||
16
+ normalizedHost === "::1" ||
17
+ normalizedHost.startsWith("127.")
18
+ ) {
19
+ return ".jwt.local";
20
+ }
21
+ if (normalizedHost === "api.dev.knowhow.tyvm.ai" || normalizedHost.includes(".dev.")) {
22
+ return ".jwt.dev";
23
+ }
24
+
25
+ const environment = `${normalizedHost}${port ? `-${port}` : ""}`
26
+ .replace(/[^a-z0-9.-]+/g, "-")
27
+ .replace(/^-+|-+$/g, "");
28
+ return environment ? `.jwt.${environment}` : ".jwt";
29
+ } catch {
30
+ return ".jwt";
31
+ }
32
+ }
33
+
34
+ export function getJwtFilePath(
35
+ apiUrl: string,
36
+ configDir = path.join(process.cwd(), ".knowhow")
37
+ ): string {
38
+ return path.join(configDir, getJwtFileName(apiUrl));
39
+ }
40
+
41
+ /**
42
+ * Load the JWT selected for an environment. The optional legacy path provides
43
+ * the directory for configs that historically pointed at `.jwt`.
44
+ */
45
+ export function loadJwtFromDisk(apiUrl: string, legacyPath?: string): string {
46
+ const basePath = legacyPath ?? path.join(process.cwd(), ".knowhow", ".jwt");
47
+ const configuredPath = findRemoteForApiSync(apiUrl)?.jwtPath;
48
+ const jwtPath = configuredPath
49
+ ? resolveJwtPath(configuredPath)
50
+ : path.join(path.dirname(basePath), getJwtFileName(apiUrl));
51
+ return fs.existsSync(jwtPath)
52
+ ? fs.readFileSync(jwtPath, "utf8").trim()
53
+ : "";
54
+ }
55
+
56
+ /** Atomically store a JWT in the file selected for the API environment. */
57
+ export function storeJwtForApi(jwt: string, apiUrl: string, configuredPath?: string): string {
58
+ const jwtFilePath = configuredPath ? resolveJwtPath(configuredPath) : getJwtFilePath(apiUrl);
59
+ const directory = path.dirname(jwtFilePath);
60
+ const temporaryPath = `${jwtFilePath}.${process.pid}.tmp`;
61
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
62
+ try {
63
+ fs.writeFileSync(temporaryPath, jwt, { mode: 0o600, flag: "wx" });
64
+ fs.renameSync(temporaryPath, jwtFilePath);
65
+ fs.chmodSync(jwtFilePath, 0o600);
66
+ } finally {
67
+ fs.rmSync(temporaryPath, { force: true });
68
+ }
69
+ return jwtFilePath;
70
+ }
@@ -1,8 +1,7 @@
1
- import fs from "fs";
2
1
  import os from "os";
3
- import path from "path";
4
2
  import http from "../utils/http";
5
3
  import { getCliUserAgent } from "./browserLogin";
4
+ import { storeJwtForApi } from "./jwtStore";
6
5
  import {
7
6
  getDefaultPrivateKeyPath,
8
7
  keyPairExists,
@@ -101,7 +100,7 @@ export async function authenticateWithKey(
101
100
  if (!keyPairExists(privateKeyPath) && !keyPairExists()) return false;
102
101
 
103
102
  try {
104
- storeJwt(await exchangeAvailablePublicKeyJwt(orgId, apiUrl, privateKeyPath));
103
+ storeJwt(await exchangeAvailablePublicKeyJwt(orgId, apiUrl, privateKeyPath), apiUrl);
105
104
  return true;
106
105
  } catch (error: unknown) {
107
106
  // The identity is not registered for this environment/org (or was revoked).
@@ -138,16 +137,6 @@ export async function registerPublicKey(
138
137
  );
139
138
  }
140
139
 
141
- export function storeJwt(jwt: string): void {
142
- const jwtFilePath = path.join(process.cwd(), ".knowhow", ".jwt");
143
- const directory = path.dirname(jwtFilePath);
144
- const temporaryPath = `${jwtFilePath}.${process.pid}.tmp`;
145
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
146
- try {
147
- fs.writeFileSync(temporaryPath, jwt, { mode: 0o600, flag: "wx" });
148
- fs.renameSync(temporaryPath, jwtFilePath);
149
- fs.chmodSync(jwtFilePath, 0o600);
150
- } finally {
151
- fs.rmSync(temporaryPath, { force: true });
152
- }
140
+ export function storeJwt(jwt: string, apiUrl: string = DEFAULT_API_URL): void {
141
+ storeJwtForApi(jwt, apiUrl);
153
142
  }
@@ -1,8 +1,9 @@
1
- import fs from "fs";
2
1
  import path from "path";
3
2
  import { getOrCreatePublicKey } from "./keyManager";
4
3
  import { registerPublicKey, exchangePublicKeyJwt, storeJwt } from "./keyAuth";
5
4
  import { getConfig, updateConfig } from "../config";
5
+ import { getJwtFilePath, loadJwtFromDisk } from "./jwtStore";
6
+ import { setOrgIdForApi } from "./environmentAuth";
6
7
 
7
8
  export interface RegisterIdentityOptions {
8
9
  /** Optional Ed25519 private-key path. Defaults to ~/.knowhow/keys/id_ed25519. */
@@ -36,30 +37,29 @@ export function extractOrgIdFromJwt(jwt: string): string {
36
37
  }
37
38
 
38
39
  /**
39
- * Register a CLI identity using the JWT already stored in the current project's
40
- * .knowhow/.jwt, then replace that JWT with one obtained via key authentication.
40
+ * Register a CLI identity using the JWT stored for the selected API, then
41
+ * replace that JWT with one obtained via key authentication.
41
42
  * This is intentionally generic: authorization scope is derived by the backend
42
43
  * from the verified source JWT.
43
44
  */
44
45
  export async function registerIdentity(options: RegisterIdentityOptions = {}): Promise<void> {
45
46
  const apiUrl = options.apiUrl ?? process.env.KNOWHOW_API_URL ?? "https://api.knowhow.tyvm.ai";
46
- const jwtPath = path.join(process.cwd(), ".knowhow", ".jwt");
47
- if (!fs.existsSync(jwtPath)) throw new Error(`JWT file not found: ${jwtPath}`);
48
- const sourceJwt = fs.readFileSync(jwtPath, "utf8").trim();
47
+ const jwtPath = getJwtFilePath(apiUrl);
48
+ const sourceJwt = loadJwtFromDisk(apiUrl);
49
49
  if (!sourceJwt) throw new Error(`JWT file is empty: ${jwtPath}`);
50
50
 
51
51
  const orgId = extractOrgIdFromJwt(sourceJwt);
52
52
  const keyPair = getOrCreatePublicKey(options.identityPath);
53
53
  await registerPublicKey(sourceJwt, keyPair.publicKeyBase64, apiUrl);
54
54
  const identityJwt = await exchangePublicKeyJwt(orgId, apiUrl, options.identityPath);
55
- storeJwt(identityJwt);
55
+ storeJwt(identityJwt, apiUrl);
56
56
 
57
57
  const config = await getConfig();
58
58
  if (!config.modelProviders) config.modelProviders = [];
59
59
  if (!config.modelProviders.some((provider: { provider: string }) => provider.provider === "knowhow")) {
60
60
  config.modelProviders.push({ provider: "knowhow" });
61
61
  }
62
- config.orgId = orgId;
62
+ setOrgIdForApi(config, apiUrl, orgId);
63
63
  config.cliIdentityPath = path.resolve(options.identityPath ?? keyPair.privateKeyPath);
64
64
  await updateConfig(config);
65
65
 
package/src/cli.ts CHANGED
@@ -63,6 +63,7 @@ import { addAgentsCommand } from "./commands/agents";
63
63
  import { addHashCommand } from "./commands/hash";
64
64
  import { addSandboxCommand } from "./commands/sandbox";
65
65
  import { addProcessesCommand } from "./commands/processes";
66
+ import { addRemoteCommand } from "./commands/remote";
66
67
 
67
68
  // Handle unhandled promise rejections gracefully — particularly from MCP SDK
68
69
  // which fires errors via event emitters that can bypass Promise.allSettled.
@@ -106,6 +107,7 @@ async function main() {
106
107
  // Register all commands
107
108
  addInitCommand(program);
108
109
  addLoginCommand(program);
110
+ addRemoteCommand(program);
109
111
  addUpdateCommand(program);
110
112
  addGenerateCommand(program);
111
113
  addEmbedCommands(program);
@@ -31,6 +31,7 @@ import { ModelProvider } from "../types";
31
31
  import { getConfig, getConfigSync } from "../config";
32
32
  import { loadKnowhowJwt, KNOWHOW_API_URL } from "../services/KnowhowClient";
33
33
  import { keyPairExists } from "../auth/keyManager";
34
+ import { getOrgIdForApi } from "../auth/environmentAuth";
34
35
  import { ContextLimits } from "./contextLimits";
35
36
  import { OpenAiTextPricing } from "./pricing/openai";
36
37
  import { AnthropicTextPricing } from "./pricing/anthropic";
@@ -112,8 +113,9 @@ const BUILT_IN_PROVIDER_REGISTRY: Record<string, ProviderRegistryEntry> = {
112
113
  createClient: (entry: ModelProvider) => {
113
114
  if (!loadKnowhowJwt()) {
114
115
  const config = getConfigSync();
116
+ const orgId = getOrgIdForApi(config, KNOWHOW_API_URL);
115
117
  if (
116
- !config.orgId ||
118
+ !orgId ||
117
119
  (!keyPairExists(config.cliIdentityPath) && !keyPairExists())
118
120
  )
119
121
  return null;
@@ -1030,7 +1032,9 @@ export class AIClient {
1030
1032
  /**
1031
1033
  * Returns the context window limit (in tokens) for a given model.
1032
1034
  * Delegates to the registered client's getContextLimit() if available.
1033
- * Falls back to the global ContextLimits table.
1035
+ * Falls back to the global ContextLimits table, stripping any leading
1036
+ * provider prefix (e.g. "openai/gpt-5.6-sol" → "gpt-5.6-sol") so that
1037
+ * proxied providers like "knowhow" resolve correctly.
1034
1038
  */
1035
1039
  getContextLimit(
1036
1040
  provider: string,
@@ -1040,9 +1044,25 @@ export class AIClient {
1040
1044
  if (client?.getContextLimit) {
1041
1045
  return client.getContextLimit(model);
1042
1046
  }
1043
- const contextLimit = ContextLimits[model];
1044
- if (contextLimit === undefined) return undefined;
1045
- return { contextLimit, threshold: contextLimit };
1047
+ // Try the model as-is first, then strip leading "provider/" prefixes
1048
+ // (e.g. "openai/gpt-5.6-sol" used with the "knowhow" proxy provider).
1049
+ const candidates = [model];
1050
+ if (model.includes("/")) {
1051
+ const parts = model.split("/");
1052
+ for (let i = 1; i < parts.length; i++) {
1053
+ candidates.push(parts.slice(i).join("/"));
1054
+ }
1055
+ }
1056
+ for (const candidate of candidates) {
1057
+ const contextLimit = ContextLimits[candidate];
1058
+ if (contextLimit !== undefined) {
1059
+ const pricing = OpenAiTextPricing[candidate];
1060
+ const threshold =
1061
+ pricing && "input_gt_200k" in pricing ? 200_000 : contextLimit;
1062
+ return { contextLimit, threshold };
1063
+ }
1064
+ }
1065
+ return undefined;
1046
1066
  }
1047
1067
 
1048
1068
  /**