@giovannijecha/jecode 0.8.1 → 0.8.3

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 (48) hide show
  1. package/README.md +19 -278
  2. package/dist/accounts.js +17 -13
  3. package/dist/batch.js +54 -6
  4. package/dist/context/budget.js +28 -4
  5. package/dist/context/compactor.js +5 -4
  6. package/dist/context/estimate.js +43 -1
  7. package/dist/context/manual.js +8 -4
  8. package/dist/context/policy.js +68 -18
  9. package/dist/controller-request.js +23 -15
  10. package/dist/controller.js +26 -5
  11. package/dist/conversation.js +94 -33
  12. package/dist/credential-safety.js +56 -9
  13. package/dist/credentials.js +32 -4
  14. package/dist/input-boundary.js +80 -0
  15. package/dist/main.js +4 -1
  16. package/dist/openai-oauth-callback.js +1 -1
  17. package/dist/openai-oauth.js +59 -15
  18. package/dist/process-shutdown.js +52 -0
  19. package/dist/providers/anthropic-stream.js +24 -20
  20. package/dist/providers/anthropic-wire.js +7 -2
  21. package/dist/providers/ollama-wire.js +7 -15
  22. package/dist/providers/ollama.js +27 -10
  23. package/dist/providers/openai-wire.js +2 -16
  24. package/dist/providers/tool-input.js +17 -0
  25. package/dist/sessions/codec.js +2 -1
  26. package/dist/sessions/lease.js +7 -0
  27. package/dist/sessions/runtime.js +11 -3
  28. package/dist/sessions/store.js +90 -31
  29. package/dist/settings.js +10 -5
  30. package/dist/start.js +12 -2
  31. package/dist/text-boundary.js +2 -0
  32. package/dist/tui/app-input.js +40 -5
  33. package/dist/tui/app-state.js +1 -0
  34. package/dist/tui/app-workflows.js +1 -0
  35. package/dist/tui/app.js +11 -3
  36. package/dist/tui/blocks.js +1 -3
  37. package/dist/tui/components/messages.js +9 -13
  38. package/dist/tui/components/tool.js +2 -4
  39. package/dist/tui/editor.js +2 -0
  40. package/dist/tui/keys.js +64 -5
  41. package/dist/tui/overlay.js +12 -4
  42. package/dist/tui/picker.js +2 -0
  43. package/dist/tui/screen.js +5 -17
  44. package/dist/tui/transcript-grammar.js +1 -1
  45. package/dist/ui/theme.js +18 -18
  46. package/dist/user-store.js +54 -0
  47. package/package.json +6 -3
  48. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -1,8 +1,11 @@
1
1
  // The shell needs a useful process environment, not the application's secrets.
2
2
  import { credentialValues } from "./credentials.js";
3
3
  import { accountValues } from "./accounts.js";
4
+ import { USER_STORE_LIMITS } from "./user-store.js";
4
5
  const REDACTED = "[credential redacted]";
5
6
  const MIN_HEURISTIC_SECRET_CHARS = 8;
7
+ export const MAX_REDACTION_SECRETS = USER_STORE_LIMITS.credentialEntries;
8
+ const MAX_REDACTION_SECRET_CODE_UNITS = USER_STORE_LIMITS.accountToken;
6
9
  const EXPLICIT_CREDENTIAL_ENVIRONMENT_NAMES = new Set([
7
10
  "ANTHROPIC_API_KEY",
8
11
  "OLLAMA_API_KEY",
@@ -27,11 +30,24 @@ export function shellEnvironment(source = process.env) {
27
30
  }
28
31
  /** Remove values Jecode recognizes as credentials before tool output leaves the shell boundary. */
29
32
  export function redactCredentials(text, source = process.env) {
30
- return redact(text, secrets(source));
33
+ const known = secrets(source);
34
+ return known.saturated ? (text === "" ? "" : REDACTED) : redact(text, known.values);
31
35
  }
32
36
  /** Redact before bounded capture, retaining enough raw overlap for split values. */
33
37
  export function credentialRedactor(source = process.env) {
34
- const values = secrets(source);
38
+ const known = secrets(source);
39
+ if (known.saturated)
40
+ return closedRedactor();
41
+ const values = known.values;
42
+ const candidates = new Map();
43
+ for (const value of values) {
44
+ const first = value[0];
45
+ const bucket = candidates.get(first);
46
+ if (bucket === undefined)
47
+ candidates.set(first, [value]);
48
+ else
49
+ bucket.push(value);
50
+ }
35
51
  const longest = Math.max(0, ...values.map((value) => value.length));
36
52
  let pending = "";
37
53
  return {
@@ -40,16 +56,17 @@ export function credentialRedactor(source = process.env) {
40
56
  const ready = [];
41
57
  let at = 0;
42
58
  while (at < combined.length) {
43
- const rest = combined.slice(at);
59
+ const matching = candidates.get(combined[at]) ?? [];
60
+ const rest = matching.length === 0 ? "" : combined.slice(at);
44
61
  // A complete shorter credential can also be the prefix of a longer
45
62
  // one. Hold that ambiguous suffix until the next chunk proves which
46
63
  // value arrived, otherwise the longer credential leaks its tail.
47
64
  if (rest.length < longest &&
48
- values.some((value) => value.length > rest.length && value.startsWith(rest))) {
65
+ matching.some((value) => value.length > rest.length && value.startsWith(rest))) {
49
66
  pending = rest;
50
67
  return ready.join("");
51
68
  }
52
- const complete = values.find((value) => combined.startsWith(value, at));
69
+ const complete = matching.find((value) => combined.startsWith(value, at));
53
70
  if (complete !== undefined) {
54
71
  ready.push(REDACTED);
55
72
  at += complete.length;
@@ -68,6 +85,20 @@ export function credentialRedactor(source = process.env) {
68
85
  },
69
86
  };
70
87
  }
88
+ function closedRedactor() {
89
+ let emitted = false;
90
+ return {
91
+ write(chunk) {
92
+ if (chunk === "" || emitted)
93
+ return "";
94
+ emitted = true;
95
+ return REDACTED;
96
+ },
97
+ end() {
98
+ return "";
99
+ },
100
+ };
101
+ }
71
102
  function sensitiveEnvironmentName(name) {
72
103
  const normalized = name
73
104
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
@@ -79,16 +110,32 @@ function sensitiveEnvironmentName(name) {
79
110
  COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
80
111
  }
81
112
  function secrets(source) {
82
- const values = new Set([...credentialValues(), ...accountValues()]);
113
+ const values = new Set();
114
+ let saturated = false;
115
+ const add = (value) => {
116
+ if (value === "" || values.has(value) || saturated)
117
+ return;
118
+ if (value.length > MAX_REDACTION_SECRET_CODE_UNITS ||
119
+ values.size >= MAX_REDACTION_SECRETS) {
120
+ saturated = true;
121
+ return;
122
+ }
123
+ values.add(value);
124
+ };
125
+ for (const value of [...credentialValues(), ...accountValues()])
126
+ add(value);
83
127
  for (const [name, value] of Object.entries(source)) {
84
- if (value === undefined || value === "")
128
+ if (saturated || value === undefined || value === "")
85
129
  continue;
86
130
  const explicit = EXPLICIT_CREDENTIAL_ENVIRONMENT_NAMES.has(name.toUpperCase());
87
131
  if (explicit || (value.length >= MIN_HEURISTIC_SECRET_CHARS && sensitiveEnvironment(name, value))) {
88
- values.add(value);
132
+ add(value);
89
133
  }
90
134
  }
91
- return [...values].filter((value) => value !== "").sort((left, right) => right.length - left.length);
135
+ return {
136
+ values: [...values].sort((left, right) => right.length - left.length),
137
+ saturated,
138
+ };
92
139
  }
93
140
  function redact(text, values) {
94
141
  let redacted = text;
@@ -10,11 +10,11 @@
10
10
  // secret in the working tree is one `git add -A` from being published, which
11
11
  // is why "not in the repo" is a rule and not a preference.
12
12
  import { chmod, mkdir } from "node:fs/promises";
13
- import { readFileSync } from "node:fs";
14
13
  import * as path from "node:path";
15
14
  import { atomicWrite } from "./atomic.js";
16
15
  import { withStoreLock } from "./store-lock.js";
17
16
  import { legacyUserDataPath, userDataLabel, userDataPath } from "./user-data.js";
17
+ import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
18
18
  /** Keys this session was given but not asked to keep. Dies with the window. */
19
19
  const held = new Map();
20
20
  /** The saved file, read once. `undefined` until the first look at it. */
@@ -58,6 +58,10 @@ export function hasSaved(name) {
58
58
  }
59
59
  /** Take a key for this session only. Nothing is written anywhere. */
60
60
  export function hold(name, value) {
61
+ assertCredential(name, value);
62
+ if (!held.has(name) && held.size >= USER_STORE_LIMITS.credentialEntries) {
63
+ throw new Error("too many session credentials");
64
+ }
61
65
  held.set(name, value);
62
66
  }
63
67
  /** Remove only the value held by this process. Saved and environment values remain. */
@@ -73,10 +77,14 @@ export function forgetSession(name) {
73
77
  * Windows ignores the mode and relies on the profile directory's own ACL.
74
78
  */
75
79
  export async function keep(name, value) {
80
+ assertCredential(name, value);
76
81
  const file = storePath();
77
82
  await prepare(file);
78
83
  return withStoreLock(file, async () => {
79
84
  const all = { ...readSavedStore(), [name]: value };
85
+ if (Object.keys(all).length > USER_STORE_LIMITS.credentialEntries) {
86
+ throw new Error("too many saved credentials");
87
+ }
80
88
  await persist(file, all);
81
89
  saved = all;
82
90
  // A newly saved replacement must become active immediately. Otherwise an
@@ -131,9 +139,14 @@ function readSavedStore() {
131
139
  }
132
140
  function readStore(file) {
133
141
  try {
134
- const parsed = JSON.parse(readFileSync(file, "utf8"));
142
+ const parsed = readBoundedJsonSync(file, USER_STORE_LIMITS.credentialsBytes);
143
+ if (!record(parsed))
144
+ return {};
135
145
  // Anything that is not a string is not a key, whatever the file says.
136
- return Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string"));
146
+ const entries = Object.entries(parsed);
147
+ if (entries.length > USER_STORE_LIMITS.credentialEntries)
148
+ return {};
149
+ return Object.fromEntries(entries.filter((entry) => credential(entry[0], entry[1])));
137
150
  }
138
151
  catch (error) {
139
152
  // Only a missing canonical file falls through to the legacy location. A
@@ -148,7 +161,22 @@ async function prepare(file) {
148
161
  await chmod(directory, 0o700);
149
162
  }
150
163
  async function persist(file, values) {
151
- await atomicWrite(file, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
164
+ const text = `${JSON.stringify(values, null, 2)}\n`;
165
+ assertStoreText(text, USER_STORE_LIMITS.credentialsBytes);
166
+ await atomicWrite(file, text, { mode: 0o600 });
167
+ }
168
+ function assertCredential(name, value) {
169
+ if (!credential(name, value))
170
+ throw new Error("invalid credential name or value");
171
+ }
172
+ function credential(name, value) {
173
+ return name.length > 0 && name.length <= USER_STORE_LIMITS.credentialName &&
174
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
175
+ typeof value === "string" && value.length > 0 &&
176
+ value.length <= USER_STORE_LIMITS.credentialValue;
177
+ }
178
+ function record(value) {
179
+ return typeof value === "object" && value !== null && !Array.isArray(value);
152
180
  }
153
181
  /** An empty variable is an unset variable — an exported "" is not a key. */
154
182
  function use(value) {
@@ -0,0 +1,80 @@
1
+ // One bounded ingress for text that can become a user prompt.
2
+ import { Buffer } from "node:buffer";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { MAX_TEXT_CODE_UNITS } from "./text-boundary.js";
5
+ export const MAX_PROMPT_CODE_UNITS = MAX_TEXT_CODE_UNITS;
6
+ export const PROMPT_LIMIT_MESSAGE = `Prompt cannot exceed ${MAX_PROMPT_CODE_UNITS.toLocaleString("en-US")} UTF-16 code units`;
7
+ export class PromptLimitError extends Error {
8
+ constructor() {
9
+ super(PROMPT_LIMIT_MESSAGE);
10
+ this.name = "PromptLimitError";
11
+ }
12
+ }
13
+ export function assertPromptLength(length) {
14
+ if (length > MAX_PROMPT_CODE_UNITS)
15
+ throw new PromptLimitError();
16
+ }
17
+ export function assertPromptAppend(current, added) {
18
+ if (added > MAX_PROMPT_CODE_UNITS - current)
19
+ throw new PromptLimitError();
20
+ }
21
+ /**
22
+ * Split raw UTF-8 input without letting one unterminated line grow past the
23
+ * prompt boundary. Newline, CRLF, and a final line without a newline match the
24
+ * line semantics used by batch mode.
25
+ */
26
+ export async function* boundedInputLines(source) {
27
+ const decoder = new StringDecoder("utf8");
28
+ let line = "";
29
+ let pendingCr = false;
30
+ const append = (text, from, to) => {
31
+ const length = to - from;
32
+ assertPromptAppend(line.length, length);
33
+ if (length > 0)
34
+ line += text.slice(from, to);
35
+ };
36
+ const consume = function* (text) {
37
+ if (text === "")
38
+ return;
39
+ let from = 0;
40
+ if (pendingCr) {
41
+ if (text.startsWith("\n"))
42
+ from = 1;
43
+ pendingCr = false;
44
+ yield line;
45
+ line = "";
46
+ }
47
+ while (from < text.length) {
48
+ const cr = text.indexOf("\r", from);
49
+ const lf = text.indexOf("\n", from);
50
+ const newline = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
51
+ if (newline === -1) {
52
+ append(text, from, text.length);
53
+ return;
54
+ }
55
+ append(text, from, newline);
56
+ if (text[newline] === "\r" && newline + 1 === text.length) {
57
+ pendingCr = true;
58
+ return;
59
+ }
60
+ yield line;
61
+ line = "";
62
+ from = text[newline] === "\r" && text[newline + 1] === "\n"
63
+ ? newline + 2
64
+ : newline + 1;
65
+ }
66
+ };
67
+ for await (const chunk of source) {
68
+ const text = typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
69
+ yield* consume(text);
70
+ }
71
+ const tail = decoder.end();
72
+ if (tail !== "")
73
+ yield* consume(tail);
74
+ if (pendingCr) {
75
+ yield line;
76
+ line = "";
77
+ }
78
+ if (line !== "")
79
+ yield line;
80
+ }
package/dist/main.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // Process entry point. The testable bootstrap lives in start.ts.
2
2
  import { start } from "./start.js";
3
+ import { isProcessSignalError, withProcessShutdown } from "./process-shutdown.js";
3
4
  import { terminalText } from "./ui/terminal-text.js";
4
- start().catch((error) => {
5
+ withProcessShutdown((signal) => start(process.argv.slice(2), { signal })).catch((error) => {
6
+ if (isProcessSignalError(error))
7
+ return;
5
8
  process.stderr.write(`jecode: ${terminalText(error.message)}\n`);
6
9
  process.exitCode = 1;
7
10
  });
@@ -173,7 +173,7 @@ function resultPage(success) {
173
173
  let mascot;
174
174
  function mascotDataUri() {
175
175
  if (mascot === undefined) {
176
- const file = new URL("../docs/assets/brand/jeco-256.png", import.meta.url);
176
+ const file = new URL("../assets/jeco-256.png", import.meta.url);
177
177
  mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
178
178
  }
179
179
  return mascot;
@@ -17,6 +17,8 @@ const DEVICE_POLL = `${AUTHORITY}/api/accounts/deviceauth/token`;
17
17
  const DEVICE_VERIFY = `${AUTHORITY}/codex/device`;
18
18
  const DEVICE_REDIRECT = `${AUTHORITY}/deviceauth/callback`;
19
19
  const LOGIN_LIMIT_MS = 15 * 60_000;
20
+ /** RFC 8628 increases the polling interval by five seconds after `slow_down`. */
21
+ const SLOW_DOWN_INCREMENT_MS = 5_000;
20
22
  export async function beginBrowserLogin() {
21
23
  const verifier = randomBytes(64).toString("base64url");
22
24
  const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -121,23 +123,65 @@ async function exchange(code, signal) {
121
123
  return openAITokenReply(response.value);
122
124
  }
123
125
  async function pollDevice(deviceAuthId, userCode, interval, signal) {
124
- const started = Date.now();
125
- while (Date.now() - started < LOGIN_LIMIT_MS) {
126
- const response = await oauthRequest(DEVICE_POLL, {
127
- contentType: "application/json",
128
- value: { device_auth_id: deviceAuthId, user_code: userCode },
129
- }, signal, [200, 403, 404]);
130
- if (response.status === 200) {
131
- const value = record(response.value) ? response.value : {};
132
- return {
133
- authorizationCode: required(value["authorization_code"], "authorization code"),
134
- verifier: required(value["code_verifier"], "code verifier"),
135
- redirectUri: DEVICE_REDIRECT,
136
- };
126
+ const deadline = new AbortController();
127
+ const timer = setTimeout(() => {
128
+ deadline.abort(new Error("ChatGPT device sign-in timed out after 15 minutes"));
129
+ }, LOGIN_LIMIT_MS);
130
+ const combined = signal === undefined
131
+ ? deadline.signal
132
+ : AbortSignal.any([signal, deadline.signal]);
133
+ let intervalMs = interval * 1_000;
134
+ try {
135
+ while (true) {
136
+ const response = await oauthRequest(DEVICE_POLL, {
137
+ contentType: "application/json",
138
+ value: { device_auth_id: deviceAuthId, user_code: userCode },
139
+ }, combined, [200, 400, 403, 404, 429]);
140
+ if (combined.aborted)
141
+ throw abortReason(combined);
142
+ if (response.status === 200) {
143
+ const value = record(response.value) ? response.value : {};
144
+ return {
145
+ authorizationCode: required(value["authorization_code"], "authorization code"),
146
+ verifier: required(value["code_verifier"], "code verifier"),
147
+ redirectUri: DEVICE_REDIRECT,
148
+ };
149
+ }
150
+ const errorCode = deviceErrorCode(response.value);
151
+ if (errorCode === "access_denied") {
152
+ throw new Error("ChatGPT device sign-in was denied");
153
+ }
154
+ if (errorCode === "expired_token") {
155
+ throw new Error("ChatGPT device sign-in code expired");
156
+ }
157
+ const pending = errorCode === "authorization_pending" ||
158
+ errorCode === "deviceauth_authorization_pending" ||
159
+ ((response.status === 403 || response.status === 404) && errorCode === undefined);
160
+ const slowDown = errorCode === "slow_down" ||
161
+ (response.status === 429 && errorCode === undefined);
162
+ if (!pending && !slowDown) {
163
+ throw new Error(`ChatGPT device sign-in failed (${response.status})`);
164
+ }
165
+ if (slowDown)
166
+ intervalMs += SLOW_DOWN_INCREMENT_MS;
167
+ await sleep(intervalMs, combined);
137
168
  }
138
- await sleep(interval * 1_000, signal);
139
169
  }
140
- throw new Error("ChatGPT device sign-in timed out after 15 minutes");
170
+ finally {
171
+ clearTimeout(timer);
172
+ }
173
+ }
174
+ function deviceErrorCode(value) {
175
+ if (!record(value))
176
+ return undefined;
177
+ const error = value["error"];
178
+ const nested = typeof error === "string"
179
+ ? error
180
+ : record(error)
181
+ ? optional(error["code"])
182
+ : undefined;
183
+ const code = nested ?? optional(value["code"]);
184
+ return code?.trim().toLowerCase();
141
185
  }
142
186
  function intervalSeconds(value) {
143
187
  const parsed = typeof value === "string" ? Number(value.trim()) : value;
@@ -0,0 +1,52 @@
1
+ // One process-wide cancellation boundary for operating-system signals.
2
+ const SHUTDOWN_GRACE_MS = 2_000;
3
+ const SIGNALS = [
4
+ ["SIGINT", 2],
5
+ ["SIGHUP", 1],
6
+ ["SIGTERM", 15],
7
+ ];
8
+ export class ProcessSignalError extends Error {
9
+ signal;
10
+ exitCode;
11
+ constructor(signal, exitCode) {
12
+ super(`received ${signal}`);
13
+ this.name = "ProcessSignalError";
14
+ this.signal = signal;
15
+ this.exitCode = exitCode;
16
+ }
17
+ }
18
+ export function isProcessSignalError(error) {
19
+ return error instanceof ProcessSignalError;
20
+ }
21
+ /**
22
+ * Abort foreground work on the first fatal signal and reserve a bounded hard
23
+ * exit for work that ignores cancellation. A second signal exits immediately.
24
+ */
25
+ export async function withProcessShutdown(work) {
26
+ const control = new AbortController();
27
+ let exitCode;
28
+ let forceTimer;
29
+ const listeners = [];
30
+ for (const [name, number] of SIGNALS) {
31
+ const listener = () => {
32
+ if (control.signal.aborted) {
33
+ process.exit(exitCode ?? 128 + number);
34
+ }
35
+ exitCode = 128 + number;
36
+ process.exitCode = exitCode;
37
+ control.abort(new ProcessSignalError(name, exitCode));
38
+ forceTimer = setTimeout(() => process.exit(exitCode), SHUTDOWN_GRACE_MS);
39
+ };
40
+ listeners.push([name, listener]);
41
+ process.on(name, listener);
42
+ }
43
+ try {
44
+ return await work(control.signal);
45
+ }
46
+ finally {
47
+ if (forceTimer !== undefined)
48
+ clearTimeout(forceTimer);
49
+ for (const [name, listener] of listeners)
50
+ process.off(name, listener);
51
+ }
52
+ }
@@ -6,11 +6,13 @@
6
6
  // beyond display: thinking blocks carry a signature and must be echoed back
7
7
  // byte-for-byte on the next request.
8
8
  import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
9
+ import { toolInputFromJson } from "./tool-input.js";
9
10
  export async function assembleAnthropic(events, onStream) {
10
11
  const blocks = new Map();
11
12
  const partialJson = new Map();
12
13
  const announcedTools = new Set();
13
14
  const sizes = { toolArguments: 0 };
15
+ const toolInputErrors = {};
14
16
  let stopReason;
15
17
  let stopDetails;
16
18
  let usage;
@@ -46,7 +48,10 @@ export async function assembleAnthropic(events, onStream) {
46
48
  const pending = partialJson.get(event.index);
47
49
  const block = blocks.get(event.index);
48
50
  if (pending !== undefined && block !== undefined) {
49
- block.input = parseJsonObject(pending);
51
+ const parsed = toolInputFromJson(pending);
52
+ block.input = parsed.input;
53
+ if (parsed.inputError !== undefined)
54
+ toolInputErrors[event.index] = parsed.inputError;
50
55
  partialJson.delete(event.index);
51
56
  }
52
57
  break;
@@ -69,10 +74,24 @@ export async function assembleAnthropic(events, onStream) {
69
74
  }
70
75
  if (!complete)
71
76
  throw new Error("anthropic stream ended before message_stop");
72
- const content = [...blocks.entries()]
73
- .sort(([a], [b]) => a - b)
74
- .map(([, block]) => block);
75
- return { content, stop_reason: stopReason, stop_details: stopDetails, usage };
77
+ const ordered = [...blocks.entries()].sort(([a], [b]) => a - b);
78
+ const content = ordered.map(([, block]) => block);
79
+ const orderedInputErrors = {};
80
+ for (let index = 0; index < ordered.length; index++) {
81
+ const sourceIndex = ordered[index]?.[0];
82
+ if (sourceIndex === undefined || toolInputErrors[sourceIndex] === undefined)
83
+ continue;
84
+ orderedInputErrors[index] = toolInputErrors[sourceIndex];
85
+ }
86
+ return {
87
+ content,
88
+ stop_reason: stopReason,
89
+ stop_details: stopDetails,
90
+ usage,
91
+ ...(Object.keys(orderedInputErrors).length === 0
92
+ ? {}
93
+ : { toolInputErrors: orderedInputErrors }),
94
+ };
76
95
  }
77
96
  function mergeUsage(before, after) {
78
97
  return after === undefined ? before : { ...before, ...after };
@@ -108,18 +127,3 @@ function applyDelta(blocks, partialJson, sizes, index, delta, onStream) {
108
127
  return;
109
128
  }
110
129
  }
111
- // Tool arguments arrive as a stream of JSON fragments. An empty accumulation
112
- // is a call with no arguments, not a malformed one.
113
- function parseJsonObject(text) {
114
- if (text.trim() === "")
115
- return {};
116
- try {
117
- const parsed = JSON.parse(text);
118
- return typeof parsed === "object" && parsed !== null
119
- ? parsed
120
- : {};
121
- }
122
- catch {
123
- return {};
124
- }
125
- }
@@ -1,5 +1,6 @@
1
1
  // Translation between the normalized vocabulary and the Anthropic wire shape.
2
2
  // Pure functions, no I/O — which is what makes them testable without a key.
3
+ import { toolInputFromValue } from "./tool-input.js";
3
4
  import { wireTokenCount } from "./wire-usage.js";
4
5
  export function toWireTool(tool) {
5
6
  return { name: tool.name, description: tool.description, input_schema: tool.input };
@@ -45,7 +46,8 @@ export function fromWireResponse(data) {
45
46
  const raw = Array.isArray(data.content) ? data.content : [];
46
47
  const content = [];
47
48
  let suppressedToolCall = false;
48
- for (const item of raw) {
49
+ for (let index = 0; index < raw.length; index++) {
50
+ const item = raw[index];
49
51
  const block = item;
50
52
  if (block.type === "text" && typeof block.text === "string") {
51
53
  content.push({ kind: "text", text: block.text });
@@ -54,11 +56,14 @@ export function fromWireResponse(data) {
54
56
  if (data.stop_reason === "tool_use" &&
55
57
  typeof block.id === "string" &&
56
58
  typeof block.name === "string") {
59
+ const parsed = toolInputFromValue(block.input ?? {});
60
+ const inputError = data.toolInputErrors?.[index] ?? parsed.inputError;
57
61
  content.push({
58
62
  kind: "tool_call",
59
63
  id: block.id,
60
64
  name: block.name,
61
- input: (block.input ?? {}),
65
+ input: parsed.input,
66
+ ...(inputError === undefined ? {} : { inputError }),
62
67
  });
63
68
  }
64
69
  else {
@@ -6,6 +6,7 @@
6
6
  // Chat Completions differs on two points that matter here — a tool result is a
7
7
  // message of its own with role "tool", not a block inside a user turn, and tool
8
8
  // arguments travel as a JSON string rather than an object.
9
+ import { toolInputFromJson } from "./tool-input.js";
9
10
  import { wireTokenCount } from "./wire-usage.js";
10
11
  export function toWireTool(tool) {
11
12
  return {
@@ -63,7 +64,12 @@ export function fromWireReply(reply) {
63
64
  const acceptsToolCalls = reply.finishReason === "tool_calls";
64
65
  if (acceptsToolCalls) {
65
66
  for (const call of reply.toolCalls) {
66
- content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
67
+ content.push({
68
+ kind: "tool_call",
69
+ id: call.id,
70
+ name: call.name,
71
+ ...toolInputFromJson(call.args),
72
+ });
67
73
  }
68
74
  }
69
75
  const notice = stopNotice(reply);
@@ -104,17 +110,3 @@ export function stopNotice(reply) {
104
110
  ? "[truncated: hit the output limit — raise --max-tokens]"
105
111
  : undefined;
106
112
  }
107
- // A model that emits malformed JSON gets the empty object, which fails
108
- // validation in tools/args.ts with a message written for it to read. That is a
109
- // recoverable turn; throwing here would end the whole thing instead.
110
- function parseArgs(args) {
111
- if (args.trim() === "")
112
- return {};
113
- try {
114
- const parsed = JSON.parse(args);
115
- return typeof parsed === "object" && parsed !== null ? parsed : {};
116
- }
117
- catch {
118
- return {};
119
- }
120
- }
@@ -17,7 +17,8 @@ const KEY = "OLLAMA_API_KEY";
17
17
  const OLLAMA_EFFORTS = ["low", "medium", "high"];
18
18
  let configuredHost;
19
19
  const CONTEXT_CACHE_MS = 30_000;
20
- const contextByEndpoint = new Map();
20
+ const runtimeContextByEndpoint = new Map();
21
+ const modelContextByEndpoint = new Map();
21
22
  /** Set the endpoint selected for this process. Undefined restores key-aware inference. */
22
23
  export function configureOllama(host) {
23
24
  configuredHost = host === undefined ? undefined : parseOllamaEndpoint(host).baseUrl;
@@ -56,15 +57,16 @@ export const ollama = {
56
57
  async contextWindow(model, signal, onStatus) {
57
58
  const at = endpoint();
58
59
  const cacheKey = `${at.baseUrl}\u0000${model}`;
59
- const cached = contextByEndpoint.get(cacheKey);
60
- if (cached !== undefined && cached.expiresAt > Date.now())
61
- return cached.value;
62
- const observed = await nativeContextWindow(at, model, signal, onStatus);
60
+ const runtime = cachedContext(runtimeContextByEndpoint, cacheKey);
61
+ if (runtime !== undefined)
62
+ return runtime;
63
+ const modelCapacity = cachedContext(modelContextByEndpoint, cacheKey);
64
+ const observed = await nativeContextWindow(at, model, modelCapacity, signal, onStatus);
63
65
  if (observed?.runtime === true) {
64
- contextByEndpoint.set(cacheKey, {
65
- value: observed.value,
66
- expiresAt: Date.now() + CONTEXT_CACHE_MS,
67
- });
66
+ rememberContext(runtimeContextByEndpoint, cacheKey, observed.value);
67
+ }
68
+ else if (observed !== undefined && modelCapacity === undefined) {
69
+ rememberContext(modelContextByEndpoint, cacheKey, observed.value);
68
70
  }
69
71
  return observed?.value;
70
72
  },
@@ -88,6 +90,7 @@ export const ollama = {
88
90
  max_tokens: req.maxTokens,
89
91
  reasoning_effort: effort,
90
92
  stream: true,
93
+ stream_options: { include_usage: true },
91
94
  }, req.maxTokens, req.signal, req.onStatus);
92
95
  const reply = await assembleOllama(events, req.onStream);
93
96
  const notice = stopNotice(reply);
@@ -96,7 +99,7 @@ export const ollama = {
96
99
  return fromWireReply(reply);
97
100
  },
98
101
  };
99
- async function nativeContextWindow(at, model, signal, onStatus) {
102
+ async function nativeContextWindow(at, model, fallback, signal, onStatus) {
100
103
  try {
101
104
  const running = await getJson(`${at.baseUrl}/api/ps`, headers(at), signal, onStatus);
102
105
  const allocated = runningContext(running, model);
@@ -106,6 +109,8 @@ async function nativeContextWindow(at, model, signal, onStatus) {
106
109
  catch (error) {
107
110
  throwIfAborted(signal, error);
108
111
  }
112
+ if (fallback !== undefined)
113
+ return { value: fallback, runtime: false };
109
114
  try {
110
115
  const details = await postJson(`${at.baseUrl}/api/show`, headers(at), { model }, signal, onStatus);
111
116
  const capacity = modelCapacity(details);
@@ -118,6 +123,18 @@ async function nativeContextWindow(at, model, signal, onStatus) {
118
123
  return undefined;
119
124
  }
120
125
  }
126
+ function cachedContext(cache, key) {
127
+ const cached = cache.get(key);
128
+ if (cached === undefined)
129
+ return undefined;
130
+ if (cached.expiresAt > Date.now())
131
+ return cached.value;
132
+ cache.delete(key);
133
+ return undefined;
134
+ }
135
+ function rememberContext(cache, key, value) {
136
+ cache.set(key, { value, expiresAt: Date.now() + CONTEXT_CACHE_MS });
137
+ }
121
138
  function runningContext(value, model) {
122
139
  if (!record(value) || !Array.isArray(value["models"]))
123
140
  return undefined;