@giovannijecha/jecode 0.8.5 → 0.8.7

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 (104) hide show
  1. package/README.md +36 -9
  2. package/assets/tokenizers/LICENSE +21 -0
  3. package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
  4. package/dist/cli-info.js +8 -14
  5. package/dist/commands.js +7 -3
  6. package/dist/config.js +47 -53
  7. package/dist/context/automatic.js +16 -1
  8. package/dist/context/budget.js +5 -2
  9. package/dist/context/compactor.js +91 -37
  10. package/dist/context/diagnostics.js +108 -0
  11. package/dist/context/lifetime.js +18 -0
  12. package/dist/context/manager.js +67 -0
  13. package/dist/context/manual.js +11 -10
  14. package/dist/context/measurement.js +75 -0
  15. package/dist/context/policy.js +13 -10
  16. package/dist/context/request-observation.js +46 -0
  17. package/dist/context/request-projection.js +8 -34
  18. package/dist/context/request.js +22 -0
  19. package/dist/context/tokenizer/bpe.js +68 -0
  20. package/dist/context/tokenizer/o200k.js +54 -0
  21. package/dist/context/tokenizer/vocabulary.js +34 -0
  22. package/dist/controller-request.js +47 -45
  23. package/dist/controller.js +13 -2
  24. package/dist/credential-commands.js +3 -3
  25. package/dist/input-boundary.js +0 -62
  26. package/dist/launch.js +13 -9
  27. package/dist/model-command.js +7 -17
  28. package/dist/oauth-result-page.js +68 -0
  29. package/dist/openai-account-command.js +14 -12
  30. package/dist/openai-account.js +7 -5
  31. package/dist/openai-oauth-callback.js +15 -54
  32. package/dist/openai-oauth-tokens.js +9 -7
  33. package/dist/openai-oauth.js +8 -6
  34. package/dist/permission-command.js +9 -24
  35. package/dist/permissions.js +10 -8
  36. package/dist/provider-commands.js +1 -33
  37. package/dist/provider-errors.js +1 -10
  38. package/dist/provider-label.js +3 -10
  39. package/dist/providers/anthropic-wire.js +1 -1
  40. package/dist/providers/anthropic.js +3 -2
  41. package/dist/providers/index.js +1 -5
  42. package/dist/providers/input-measurement.js +85 -0
  43. package/dist/providers/ollama-context.js +42 -0
  44. package/dist/providers/ollama-endpoint.js +7 -34
  45. package/dist/providers/ollama-wire.js +1 -1
  46. package/dist/providers/ollama.js +15 -147
  47. package/dist/providers/openai-codex.js +7 -4
  48. package/dist/providers/openai-stream.js +20 -8
  49. package/dist/providers/openai-summary.js +37 -0
  50. package/dist/providers/openai-wire.js +1 -1
  51. package/dist/providers/openai.js +3 -1
  52. package/dist/sessions/bucket.js +55 -0
  53. package/dist/sessions/catalog-io.js +162 -0
  54. package/dist/sessions/catalog.js +3 -1
  55. package/dist/sessions/codec-messages.js +122 -0
  56. package/dist/sessions/codec-transcript.js +93 -0
  57. package/dist/sessions/codec-values.js +52 -0
  58. package/dist/sessions/codec.js +4 -257
  59. package/dist/sessions/files.js +158 -0
  60. package/dist/sessions/load.js +90 -0
  61. package/dist/sessions/snapshot.js +33 -0
  62. package/dist/sessions/store.js +42 -461
  63. package/dist/settings.js +17 -16
  64. package/dist/start.js +36 -53
  65. package/dist/timeline.js +2 -0
  66. package/dist/tools/file-read.js +192 -0
  67. package/dist/tools/file-summary.js +9 -0
  68. package/dist/tools/{fs.js → file-write.js} +5 -193
  69. package/dist/tools/glob.js +107 -0
  70. package/dist/tools/index.js +2 -1
  71. package/dist/tools/search.js +1 -105
  72. package/dist/tui/activity.js +1 -1
  73. package/dist/tui/app-input.js +34 -17
  74. package/dist/tui/app-workflows.js +13 -361
  75. package/dist/tui/app.js +55 -28
  76. package/dist/tui/approve.js +3 -2
  77. package/dist/tui/blocks.js +1 -2
  78. package/dist/tui/command-workflow.js +112 -0
  79. package/dist/tui/components/command-menu.js +7 -10
  80. package/dist/tui/components/menu.js +82 -43
  81. package/dist/tui/components/messages.js +16 -9
  82. package/dist/tui/components/status.js +1 -1
  83. package/dist/tui/components/tool-evidence.js +107 -0
  84. package/dist/tui/components/tool-motion.js +32 -0
  85. package/dist/tui/components/tool.js +48 -202
  86. package/dist/tui/frame.js +8 -3
  87. package/dist/tui/help.js +3 -2
  88. package/dist/tui/picker-layout.js +44 -0
  89. package/dist/tui/picker.js +7 -71
  90. package/dist/tui/screen.js +7 -0
  91. package/dist/tui/session-view.js +0 -1
  92. package/dist/tui/tool-details.js +135 -0
  93. package/dist/tui/transcript-grammar.js +1 -1
  94. package/dist/tui/transcript-view.js +30 -112
  95. package/dist/tui/turn-workflow.js +235 -0
  96. package/dist/tui/turn.js +7 -140
  97. package/dist/tui/view.js +4 -4
  98. package/dist/tui/workflow-types.js +2 -0
  99. package/dist/ui/render.js +0 -4
  100. package/package.json +16 -12
  101. package/dist/batch-view.js +0 -17
  102. package/dist/batch.js +0 -267
  103. package/dist/ollama-settings-command.js +0 -74
  104. package/dist/tui/motion.js +0 -32
@@ -1,8 +1,10 @@
1
1
  // Loopback callback used by the browser OAuth flow.
2
2
  import { timingSafeEqual } from "node:crypto";
3
- import { readFileSync } from "node:fs";
4
3
  import { createServer } from "node:http";
5
4
  import { leadingText } from "./text-boundary.js";
5
+ import { providerLabel } from "./provider-label.js";
6
+ import { oauthResultPage } from "./oauth-result-page.js";
7
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
6
8
  const CALLBACK_PORTS = [1455, 1457];
7
9
  export const OPENAI_CALLBACK_PATH = "/auth/callback";
8
10
  export async function openAICallback(state) {
@@ -26,7 +28,14 @@ export async function openAICallback(state) {
26
28
  outgoing.writeHead(400).end("Invalid sign-in callback.");
27
29
  return;
28
30
  }
29
- const incoming = new URL(request.url, "http://localhost");
31
+ let incoming;
32
+ try {
33
+ incoming = new URL(request.url, "http://localhost");
34
+ }
35
+ catch {
36
+ outgoing.writeHead(400).end("Invalid sign-in callback.");
37
+ return;
38
+ }
30
39
  if (incoming.pathname !== OPENAI_CALLBACK_PATH) {
31
40
  outgoing.writeHead(404).end("Not found.");
32
41
  return;
@@ -43,10 +52,10 @@ export async function openAICallback(state) {
43
52
  const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
44
53
  const authorizationCode = incoming.searchParams.get("code");
45
54
  if (authError !== null) {
46
- rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
55
+ rejectCode(new Error(`${ACCOUNT_LABEL} sign-in was rejected · ${leadingText(authError, 300)}`));
47
56
  }
48
57
  else if (authorizationCode === null || authorizationCode === "") {
49
- rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
58
+ rejectCode(new Error(`${ACCOUNT_LABEL} sign-in returned no authorization code`));
50
59
  }
51
60
  else {
52
61
  resolveCode(authorizationCode);
@@ -71,7 +80,7 @@ export async function openAICallback(state) {
71
80
  "referrer-policy": "no-referrer",
72
81
  "x-content-type-options": "nosniff",
73
82
  });
74
- response.end(resultPage(success));
83
+ response.end(oauthResultPage(success));
75
84
  await flushed;
76
85
  },
77
86
  close: () => closeServer(listening.server),
@@ -94,7 +103,7 @@ async function firstAvailableServer(handler) {
94
103
  throw error;
95
104
  }
96
105
  }
97
- throw new Error("ChatGPT sign-in could not open callback ports 1455 or 1457");
106
+ throw new Error(`${ACCOUNT_LABEL} sign-in could not open callback ports 1455 or 1457`);
98
107
  }
99
108
  function listen(server, port) {
100
109
  return new Promise((resolve, reject) => {
@@ -130,54 +139,6 @@ function closeServer(server) {
130
139
  server.closeIdleConnections();
131
140
  });
132
141
  }
133
- function resultPage(success) {
134
- const title = success ? "Signed in to Jecode" : "Jecode sign-in failed";
135
- const status = success ? "Authentication complete" : "Authentication stopped";
136
- const detail = success
137
- ? "Return to your terminal. Jecode will continue automatically."
138
- : "Return to your terminal to see what stopped the connection.";
139
- const state = success ? "success" : "failure";
140
- return `<!doctype html>
141
- <html lang="en">
142
- <head>
143
- <meta charset="utf-8">
144
- <meta name="viewport" content="width=device-width, initial-scale=1">
145
- <meta name="color-scheme" content="dark">
146
- <title>${title}</title>
147
- <style>
148
- :root{--night:#000;--steel:#669bd2;--steel-soft:#8db4dd;--bright:#ebeff4;--danger:#e87070}
149
- *{box-sizing:border-box}
150
- body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--night);color:var(--steel-soft);font-family:"Segoe UI",system-ui,sans-serif}
151
- main{width:min(34rem,calc(100vw - 3rem));padding:3rem 1.5rem;text-align:center}
152
- img{display:block;width:clamp(7.5rem,20vw,10rem);height:auto;margin:0 auto 1.75rem;filter:drop-shadow(0 1.25rem 2rem rgba(102,155,210,.16))}
153
- .rail{width:min(18rem,70vw);height:1px;margin:0 auto 1.5rem;background:linear-gradient(90deg,transparent,var(--steel),transparent)}
154
- .status{margin:0 0 .75rem;color:var(--steel);font:600 .72rem/1.2 ui-monospace,"Cascadia Mono",monospace;letter-spacing:.14em;text-transform:uppercase}
155
- h1{margin:0;color:var(--steel);font-size:clamp(2rem,6vw,3.25rem);font-weight:720;letter-spacing:-.04em;line-height:1.05}
156
- p:last-of-type{max-width:30rem;margin:1.25rem auto 0;color:var(--steel-soft);font-size:1.05rem;line-height:1.6}
157
- .failure h1,.failure .status{color:var(--danger)}
158
- @media (prefers-reduced-motion:no-preference){main{animation:arrive .45s ease-out both}@keyframes arrive{from{opacity:0;transform:translateY(.6rem)}to{opacity:1;transform:none}}}
159
- </style>
160
- </head>
161
- <body>
162
- <main class="${state}">
163
- <img src="${mascotDataUri()}" alt="Jeco, the Jecode gecko">
164
- <div class="rail" aria-hidden="true"></div>
165
- <p class="status">${status}</p>
166
- <h1>${title}</h1>
167
- <p>${detail}</p>
168
- </main>
169
- <script>history.replaceState(null,"","/auth/complete")</script>
170
- </body>
171
- </html>`;
172
- }
173
- let mascot;
174
- function mascotDataUri() {
175
- if (mascot === undefined) {
176
- const file = new URL("../assets/jeco-256.png", import.meta.url);
177
- mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
178
- }
179
- return mascot;
180
- }
181
142
  function sameState(expected, received) {
182
143
  if (received === null)
183
144
  return false;
@@ -1,15 +1,17 @@
1
1
  // Validate OAuth token responses and extract the ChatGPT account claims.
2
+ import { providerLabel } from "./provider-label.js";
3
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
2
4
  const CLAIMS = "https://api.openai.com/auth";
3
5
  export function openAITokenReply(value, previousRefresh) {
4
6
  if (!record(value))
5
- throw new Error("OpenAI sign-in returned an invalid token response");
7
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned an invalid token response`);
6
8
  const accessToken = required(value["access_token"], "access token");
7
9
  const refreshToken = optional(value["refresh_token"]) ?? previousRefresh;
8
10
  const expiresIn = value["expires_in"];
9
11
  if (refreshToken === undefined)
10
- throw new Error("OpenAI sign-in did not return a refresh token");
12
+ throw new Error(`${ACCOUNT_LABEL} sign-in did not return a refresh token`);
11
13
  if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
12
- throw new Error("OpenAI sign-in did not return a valid token lifetime");
14
+ throw new Error(`${ACCOUNT_LABEL} sign-in did not return a valid token lifetime`);
13
15
  }
14
16
  const idToken = optional(value["id_token"]);
15
17
  return {
@@ -25,7 +27,7 @@ export function openAIAccountFromTokens(token) {
25
27
  const auth = record(access[CLAIMS]) ? access[CLAIMS] : {};
26
28
  const accountId = optional(auth["chatgpt_account_id"]) ?? optional(identity?.["chatgpt_account_id"]);
27
29
  if (accountId === undefined)
28
- throw new Error("OpenAI sign-in did not identify a ChatGPT account");
30
+ throw new Error(`${ACCOUNT_LABEL} sign-in did not identify an account`);
29
31
  const email = optional(identity?.["email"]);
30
32
  const plan = optional(auth["chatgpt_plan_type"]) ?? optional(identity?.["chatgpt_plan_type"]);
31
33
  return {
@@ -40,7 +42,7 @@ export function openAIAccountFromTokens(token) {
40
42
  function jwt(token) {
41
43
  const part = token.split(".")[1];
42
44
  if (part === undefined)
43
- throw new Error("OpenAI sign-in returned an unreadable token");
45
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
44
46
  try {
45
47
  const value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
46
48
  if (!record(value))
@@ -48,13 +50,13 @@ function jwt(token) {
48
50
  return value;
49
51
  }
50
52
  catch {
51
- throw new Error("OpenAI sign-in returned an unreadable token");
53
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
52
54
  }
53
55
  }
54
56
  function required(value, label) {
55
57
  const found = optional(value);
56
58
  if (found === undefined)
57
- throw new Error(`OpenAI sign-in returned no ${label}`);
59
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
58
60
  return found;
59
61
  }
60
62
  function optional(value) {
@@ -5,8 +5,10 @@
5
5
  // an account on disk.
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { oauthRequest } from "./oauth-http.js";
8
+ import { providerLabel } from "./provider-label.js";
8
9
  import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
9
10
  import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
11
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
10
12
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
11
13
  const AUTHORITY = "https://auth.openai.com";
12
14
  const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
@@ -91,7 +93,7 @@ export async function refreshOpenAITokens(account, signal) {
91
93
  const token = openAITokenReply(response.value, account.refreshToken);
92
94
  const refreshed = openAIAccountFromTokens(token);
93
95
  if (refreshed.accountId !== account.accountId) {
94
- throw new Error("OpenAI refreshed a different ChatGPT account · sign in again");
96
+ throw new Error(`${ACCOUNT_LABEL} refresh returned a different account · sign in again`);
95
97
  }
96
98
  return {
97
99
  ...refreshed,
@@ -125,7 +127,7 @@ async function exchange(code, signal) {
125
127
  async function pollDevice(deviceAuthId, userCode, interval, signal) {
126
128
  const deadline = new AbortController();
127
129
  const timer = setTimeout(() => {
128
- deadline.abort(new Error("ChatGPT device sign-in timed out after 15 minutes"));
130
+ deadline.abort(new Error(`${ACCOUNT_LABEL} device sign-in timed out after 15 minutes`));
129
131
  }, LOGIN_LIMIT_MS);
130
132
  const combined = signal === undefined
131
133
  ? deadline.signal
@@ -149,10 +151,10 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
149
151
  }
150
152
  const errorCode = deviceErrorCode(response.value);
151
153
  if (errorCode === "access_denied") {
152
- throw new Error("ChatGPT device sign-in was denied");
154
+ throw new Error(`${ACCOUNT_LABEL} device sign-in was denied`);
153
155
  }
154
156
  if (errorCode === "expired_token") {
155
- throw new Error("ChatGPT device sign-in code expired");
157
+ throw new Error(`${ACCOUNT_LABEL} device sign-in code expired`);
156
158
  }
157
159
  const pending = errorCode === "authorization_pending" ||
158
160
  errorCode === "deviceauth_authorization_pending" ||
@@ -160,7 +162,7 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
160
162
  const slowDown = errorCode === "slow_down" ||
161
163
  (response.status === 429 && errorCode === undefined);
162
164
  if (!pending && !slowDown) {
163
- throw new Error(`ChatGPT device sign-in failed (${response.status})`);
165
+ throw new Error(`${ACCOUNT_LABEL} device sign-in failed (${response.status})`);
164
166
  }
165
167
  if (slowDown)
166
168
  intervalMs += SLOW_DOWN_INCREMENT_MS;
@@ -192,7 +194,7 @@ function intervalSeconds(value) {
192
194
  function required(value, label) {
193
195
  const found = optional(value);
194
196
  if (found === undefined)
195
- throw new Error(`OpenAI sign-in returned no ${label}`);
197
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
196
198
  return found;
197
199
  }
198
200
  function optional(value) {
@@ -9,17 +9,13 @@ export async function permissionsCommand(session, host) {
9
9
  }
10
10
  let selected = 0;
11
11
  while (true) {
12
- const index = await choose(permissionControlPicker(control, host, selected));
12
+ const index = await choose(permissionControlPicker(control, selected));
13
13
  if (index === undefined)
14
14
  return;
15
15
  const tool = control.listTools()[index];
16
16
  if (tool === undefined)
17
17
  return;
18
18
  selected = index;
19
- if (tool.locked) {
20
- lockedNotice(tool.name, host);
21
- continue;
22
- }
23
19
  if (tool.remembered > 0) {
24
20
  await reviewGrants(tool.name, control, choose, session.palette);
25
21
  }
@@ -29,12 +25,12 @@ export function permissionsPicker(tools, index = 0, adjust) {
29
25
  return {
30
26
  title: [],
31
27
  options: tools.map((tool) => {
32
- const description = toolDescription(tool);
28
+ const hint = toolHint(tool);
33
29
  return {
34
30
  label: tool.name,
35
- ...(description === undefined ? {} : { description }),
36
- value: tool.locked ? `${tool.mode} · locked` : tool.mode,
37
- adjustable: !tool.locked,
31
+ ...(hint === undefined ? {} : { hint }),
32
+ value: tool.mode,
33
+ adjustable: true,
38
34
  };
39
35
  }),
40
36
  visible: tools.length,
@@ -42,21 +38,17 @@ export function permissionsPicker(tools, index = 0, adjust) {
42
38
  index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
43
39
  };
44
40
  }
45
- function permissionControlPicker(control, host, selected) {
41
+ export function permissionControlPicker(control, selected) {
46
42
  return permissionsPicker(control.listTools(), selected, (index, step) => {
47
43
  const tool = control.listTools()[index];
48
44
  if (tool === undefined)
49
- return permissionControlPicker(control, host, selected);
50
- if (tool.locked) {
51
- lockedNotice(tool.name, host);
52
- return permissionControlPicker(control, host, index);
53
- }
45
+ return permissionControlPicker(control, selected);
54
46
  const modes = modesFor(tool);
55
47
  const at = Math.max(0, modes.indexOf(tool.mode));
56
48
  const next = modes[(at + step + modes.length) % modes.length];
57
49
  if (next !== undefined)
58
50
  control.set(tool.name, next);
59
- return permissionControlPicker(control, host, index);
51
+ return permissionControlPicker(control, index);
60
52
  });
61
53
  }
62
54
  async function reviewGrants(tool, control, choose, pal) {
@@ -90,17 +82,10 @@ async function reviewGrants(tool, control, choose, pal) {
90
82
  function modesFor(tool) {
91
83
  return tool.dangerous ? ["ask", "allow", "deny"] : ["allow", "deny"];
92
84
  }
93
- function toolDescription(tool) {
85
+ function toolHint(tool) {
94
86
  const parts = [
95
87
  tool.dangerous ? undefined : "read only",
96
88
  tool.remembered === 0 ? undefined : `${tool.remembered} remembered`,
97
89
  ].filter((part) => part !== undefined);
98
90
  return parts.length === 0 ? undefined : parts.join(" · ");
99
91
  }
100
- function lockedNotice(tool, host) {
101
- host.emit({
102
- kind: "notice",
103
- text: `restart without --auto-approve to change ${tool}`,
104
- tone: "warn",
105
- });
106
- }
@@ -1,12 +1,11 @@
1
1
  // Session-local tool policies and remembered approval scopes.
2
2
  /** One permission control plane for one interactive process. */
3
- export function sessionPermissions(tools, autoApprove) {
3
+ export function sessionPermissions(tools) {
4
4
  const catalogue = [...tools];
5
5
  const byName = new Map(catalogue.map((tool) => [tool.name, tool]));
6
6
  const modes = new Map();
7
7
  const grants = new Map();
8
8
  const configured = (tool) => modes.get(tool.name) ?? defaultMode(tool);
9
- const effective = (tool) => autoApprove && tool.dangerous ? "allow" : configured(tool);
10
9
  const revokeTool = (name) => {
11
10
  for (const [key, grant] of grants) {
12
11
  if (grant.tools.includes(name))
@@ -18,14 +17,13 @@ export function sessionPermissions(tools, autoApprove) {
18
17
  return catalogue.map((tool) => ({
19
18
  name: tool.name,
20
19
  dangerous: tool.dangerous,
21
- mode: effective(tool),
20
+ mode: configured(tool),
22
21
  remembered: [...grants.values()].filter((grant) => grant.tools.includes(tool.name)).length,
23
- locked: autoApprove && tool.dangerous,
24
22
  }));
25
23
  },
26
24
  set(name, mode) {
27
25
  const tool = byName.get(name);
28
- if (tool === undefined || (autoApprove && tool.dangerous))
26
+ if (tool === undefined)
29
27
  return false;
30
28
  if (!tool.dangerous && mode === "ask")
31
29
  return false;
@@ -50,13 +48,17 @@ export function sessionPermissions(tools, autoApprove) {
50
48
  grants.clear();
51
49
  },
52
50
  availableTools() {
53
- return catalogue.filter((tool) => effective(tool) !== "deny");
51
+ return catalogue.filter((tool) => configured(tool) !== "deny");
52
+ },
53
+ allowed(call) {
54
+ const tool = byName.get(call.name);
55
+ return tool !== undefined && configured(tool) !== "deny";
54
56
  },
55
57
  approved(call) {
56
58
  const tool = byName.get(call.name);
57
59
  if (tool === undefined)
58
60
  return false;
59
- const mode = effective(tool);
61
+ const mode = configured(tool);
60
62
  if (mode === "allow")
61
63
  return true;
62
64
  if (mode === "deny")
@@ -65,7 +67,7 @@ export function sessionPermissions(tools, autoApprove) {
65
67
  },
66
68
  remember(call) {
67
69
  const tool = byName.get(call.name);
68
- if (tool === undefined || effective(tool) !== "ask")
70
+ if (tool === undefined || configured(tool) !== "ask")
69
71
  return;
70
72
  const scope = scopeFor(call);
71
73
  grants.set(scope.key, { key: scope.key, tools: grantTools(call), label: scope.summary });
@@ -1,16 +1,12 @@
1
1
  // Provider access and connection management. Runtime selection lives in /models.
2
- import { saveCommandSettings } from "./command-settings.js";
3
2
  import { apiKeyCommand } from "./credential-commands.js";
4
3
  import { credentialSource } from "./credentials.js";
5
- import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
6
4
  import { openAIAccountHint } from "./openai-account.js";
7
5
  import { openAIAccountCommand } from "./openai-account-command.js";
8
6
  import { providerLabel, providerRouteLabel } from "./provider-label.js";
9
7
  import { PROVIDERS } from "./providers/index.js";
10
- import { ollamaConnection } from "./providers/ollama.js";
11
8
  import { heading } from "./tui/picker.js";
12
- const OLLAMA_KEY = "OLLAMA_API_KEY";
13
- /** The single control plane for API keys, OAuth accounts, and Ollama endpoints. */
9
+ /** The single control plane for API keys and OAuth accounts. */
14
10
  export async function providersCommand(session, host) {
15
11
  const choose = chooser(host);
16
12
  if (choose === undefined)
@@ -81,45 +77,17 @@ function providerGroupHint(group, activeId) {
81
77
  return active === undefined ? count : `${count} · current ${providerRouteLabel(active)}`;
82
78
  }
83
79
  export function providerAccessHint(provider) {
84
- if (provider.id === "ollama") {
85
- const connection = ollamaConnection();
86
- if (connection.loopback)
87
- return `${ollamaConnectionHint()} · no key needed`;
88
- return `${ollamaConnectionHint()} · API key ${credentialSource(OLLAMA_KEY) ?? "missing"}`;
89
- }
90
80
  if (provider.auth.kind === "oauth")
91
81
  return openAIAccountHint();
92
82
  return `API key · ${credentialSource(provider.auth.keyVar) ?? "missing"}`;
93
83
  }
94
84
  async function manageProvider(provider, session, host) {
95
- if (provider.id === "ollama") {
96
- await ollamaProviderCommand(session, host);
97
- return;
98
- }
99
85
  if (provider.auth.kind === "oauth") {
100
86
  await openAIAccountCommand(session, host);
101
87
  return;
102
88
  }
103
89
  await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host, provider.id);
104
90
  }
105
- async function ollamaProviderCommand(session, host) {
106
- if (host.choose === undefined)
107
- return;
108
- const index = await host.choose({
109
- title: heading("Ollama", "access and connection", session.palette),
110
- options: [
111
- { label: "connection", hint: ollamaConnectionHint() },
112
- { label: "API key", hint: credentialSource(OLLAMA_KEY) ?? "missing" },
113
- ],
114
- index: 0,
115
- });
116
- if (index === 0) {
117
- await ollamaConnectionSetting(session, host, (patch) => saveCommandSettings(host, patch));
118
- }
119
- else if (index === 1) {
120
- await apiKeyCommand(OLLAMA_KEY, "Ollama", session, host, "ollama");
121
- }
122
- }
123
91
  function chooser(host) {
124
92
  if (host.choose === undefined) {
125
93
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
@@ -4,21 +4,12 @@ import { providerLabel } from "./provider-label.js";
4
4
  import { leadingText } from "./text-boundary.js";
5
5
  import { terminalText } from "./ui/terminal-text.js";
6
6
  import { providerFailureDetails, } from "./providers/failure.js";
7
- const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
8
7
  const MAX_MESSAGE_CHARS = 1_000;
9
8
  const MAX_REASON_CHARS = 500;
10
9
  const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
11
10
  export function providerFailure(provider, error, labelProvider = false) {
12
11
  const details = providerFailureDetails(provider.id, error);
13
- let failure;
14
- if (provider.id === "ollama" && details.kind === "network" && CONNECTION_FAILURE.test(error.message)) {
15
- failure = provider.location?.() === "local"
16
- ? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
17
- : "Ollama is not reachable · check its connection in /providers";
18
- }
19
- else {
20
- failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
21
- }
12
+ let failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
22
13
  if (details.requestId !== undefined)
23
14
  failure += ` · request ${details.requestId}`;
24
15
  const label = providerLabel(provider.id);
@@ -1,23 +1,16 @@
1
1
  // Stable human-facing names for provider identifiers.
2
2
  export function providerLabel(id) {
3
3
  switch (id) {
4
- case "anthropic": return "Anthropic";
4
+ case "anthropic": return "Anthropic API";
5
5
  case "openai": return "OpenAI API";
6
- case "openai-codex": return "ChatGPT";
7
- case "ollama": return "Ollama";
6
+ case "openai-codex": return "OpenAI Account";
7
+ case "ollama": return "Ollama API";
8
8
  default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
9
9
  }
10
10
  }
11
11
  /** Name the actual connection a model request will use. */
12
12
  export function providerRouteLabel(provider) {
13
- if (provider.auth.kind === "oauth")
14
- return `${providerLabel(provider.id)} account`;
15
13
  if (provider.id === "openai")
16
14
  return "OpenAI API · billed usage";
17
- if (provider.id === "anthropic")
18
- return "Anthropic API";
19
- if (provider.id === "ollama") {
20
- return provider.location?.() === "local" ? "Ollama · local" : "Ollama API";
21
- }
22
15
  return providerLabel(provider.id);
23
16
  }
@@ -38,7 +38,7 @@ export function stopNotice(data) {
38
38
  return `[refused: ${category}] ${why}`;
39
39
  }
40
40
  if (data.stop_reason === "max_tokens") {
41
- return "[truncated: hit max_tokens — raise --max-tokens]";
41
+ return "[truncated: hit max_tokens — raise max output tokens in /settings]";
42
42
  }
43
43
  return undefined;
44
44
  }
@@ -13,6 +13,7 @@ import { EFFORTS, requireSupportedEffort } from "../effort.js";
13
13
  import { assembleAnthropic } from "./anthropic-stream.js";
14
14
  import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
15
15
  import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
16
+ import { measureAnthropicInput } from "./input-measurement.js";
16
17
  const ENDPOINT = "https://api.anthropic.com/v1/messages";
17
18
  const MODELS = "https://api.anthropic.com/v1/models?limit=100";
18
19
  const API_VERSION = "2023-06-01";
@@ -36,7 +37,7 @@ export function anthropicEfforts(model) {
36
37
  export const anthropic = {
37
38
  id: ID,
38
39
  // Sonnet is the default because it is the one that can be left running.
39
- // Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
40
+ // Other models are selected through the shared model catalogue.
40
41
  defaultModel: "claude-sonnet-5",
41
42
  auth: { kind: "api-key", keyVar: KEY },
42
43
  blocked() {
@@ -72,7 +73,7 @@ export const anthropic = {
72
73
  throwProviderError(ID, signal, error);
73
74
  }
74
75
  },
75
- location: () => "cloud",
76
+ measureInput: measureAnthropicInput,
76
77
  async send(req) {
77
78
  const key = requireKey();
78
79
  const body = {
@@ -1,7 +1,7 @@
1
1
  import { anthropic } from "./anthropic.js";
2
2
  import { openai } from "./openai.js";
3
3
  import { openaiCodex } from "./openai-codex.js";
4
- import { configureOllama, ollama } from "./ollama.js";
4
+ import { ollama } from "./ollama.js";
5
5
  export const PROVIDERS = [anthropic, openai, openaiCodex, ollama];
6
6
  export function providerNames() {
7
7
  return PROVIDERS.map((provider) => provider.id);
@@ -13,7 +13,3 @@ export function selectProvider(id) {
13
13
  }
14
14
  return found;
15
15
  }
16
- /** Apply provider-specific process state after startup precedence is resolved. */
17
- export function configureProviders(config) {
18
- configureOllama(config.ollamaHost);
19
- }
@@ -0,0 +1,85 @@
1
+ // Measure the content each adapter actually sends, without exposing wire
2
+ // formats to context policy or counting the normalized and raw copies twice.
3
+ import { estimateSerializedTokensResponsive } from "../context/estimate.js";
4
+ import { toWireItems, toWireTool as responsesTool } from "./openai-wire.js";
5
+ import { toWireMessage, toWireTool as anthropicTool } from "./anthropic-wire.js";
6
+ import { toWireMessages, toWireTool as ollamaTool } from "./ollama-wire.js";
7
+ import { countO200k } from "../context/tokenizer/o200k.js";
8
+ export function measureResponsesInput(request, providerId, signal) {
9
+ return measure({ instructions: request.system, tools: request.tools.map(responsesTool) }, request.messages.map((message) => {
10
+ const items = toWireItems(message, providerId);
11
+ const outputTokens = opaqueReserve(message, providerId);
12
+ if (outputTokens === undefined)
13
+ return { items,
14
+ unmeasuredOpaque: items.some((item) => hasOpaqueField(item, "reasoning", "encrypted_content")) };
15
+ return {
16
+ items: items.map((item) => withoutOpaqueField(item, "reasoning", "encrypted_content")),
17
+ outputTokens,
18
+ };
19
+ }), request.tools.length, signal, responsesTokenCounter(request.model, providerId));
20
+ }
21
+ export function measureAnthropicInput(request, signal) {
22
+ return measure({ system: request.system, tools: request.tools.map(anthropicTool) }, request.messages.map((message) => {
23
+ const wire = toWireMessage(message);
24
+ const outputTokens = opaqueReserve(message, "anthropic");
25
+ if (outputTokens === undefined || !Array.isArray(wire.content))
26
+ return { items: [wire] };
27
+ return {
28
+ items: [{
29
+ ...wire,
30
+ content: wire.content.map((item) => withoutOpaqueField(withoutOpaqueField(item, "thinking", "signature"), "redacted_thinking", "data")),
31
+ }],
32
+ outputTokens,
33
+ };
34
+ }), request.tools.length, signal);
35
+ }
36
+ export function measureOllamaInput(request, signal) {
37
+ return measure({ messages: toWireMessages(request.system, []), tools: request.tools.map(ollamaTool) }, request.messages.map((message) => ({ items: toWireMessages("", [message]) })), request.tools.length, signal);
38
+ }
39
+ async function measure(envelope, messages, toolCount, signal, count = estimateSerializedTokensResponsive) {
40
+ let tokens = 64 + toolCount * 16 + await count(envelope, signal);
41
+ for (const message of messages) {
42
+ if (message.items.length === 0)
43
+ continue;
44
+ const visible = await (message.unmeasuredOpaque ? estimateSerializedTokensResponsive : count)(message.items, signal);
45
+ // Reported output already includes hidden reasoning. Reserve it once for
46
+ // this assistant message, never once per opaque item or in addition to it.
47
+ tokens += Math.max(visible, message.outputTokens ?? 0) + message.items.length * 8;
48
+ }
49
+ return tokens;
50
+ }
51
+ /** Account aliases may precede tiktoken's model map: use a reference encoding,
52
+ * never label this a provider-exact count. Legacy/unknown API routes stay conservative. */
53
+ function responsesTokenCounter(model, providerId) {
54
+ if (responsesTokenization(model, providerId) === "heuristic")
55
+ return estimateSerializedTokensResponsive;
56
+ return async (value, signal) => Math.max(1, Math.ceil(await countO200k(JSON.stringify(value), signal) * 1.1));
57
+ }
58
+ export function responsesTokenization(model, providerId) {
59
+ const modern = /^(?:gpt-5|gpt-4\.[15](?:-|$)|gpt-4o(?:-|$)|chatgpt-4o-|o[13](?:-|$)|o4-mini(?:-|$)|ft:gpt-4o)/u.test(model);
60
+ return providerId === "openai-codex" || modern ? "o200k-reference" : "heuristic";
61
+ }
62
+ function opaqueReserve(message, providerId) {
63
+ const tokens = message.usage?.outputTokens;
64
+ return message.role === "assistant" && message.rawFrom === providerId &&
65
+ Array.isArray(message.raw) && typeof tokens === "number" &&
66
+ Number.isSafeInteger(tokens) && tokens > 0 && message.raw.some((item) => (providerId === "anthropic"
67
+ ? hasOpaqueField(item, "thinking", "signature") ||
68
+ hasOpaqueField(item, "redacted_thinking", "data")
69
+ : hasOpaqueField(item, "reasoning", "encrypted_content")))
70
+ ? tokens
71
+ : undefined;
72
+ }
73
+ function withoutOpaqueField(value, type, field) {
74
+ if (!hasOpaqueField(value, type, field))
75
+ return value;
76
+ const visible = { ...value };
77
+ delete visible[field];
78
+ return visible;
79
+ }
80
+ function hasOpaqueField(value, type, field) {
81
+ if (typeof value !== "object" || value === null || Array.isArray(value))
82
+ return false;
83
+ const item = value;
84
+ return item["type"] === type && typeof item[field] === "string";
85
+ }