@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.
- package/README.md +19 -278
- package/dist/accounts.js +17 -13
- package/dist/batch.js +54 -6
- package/dist/context/budget.js +28 -4
- package/dist/context/compactor.js +5 -4
- package/dist/context/estimate.js +43 -1
- package/dist/context/manual.js +8 -4
- package/dist/context/policy.js +68 -18
- package/dist/controller-request.js +23 -15
- package/dist/controller.js +26 -5
- package/dist/conversation.js +94 -33
- package/dist/credential-safety.js +56 -9
- package/dist/credentials.js +32 -4
- package/dist/input-boundary.js +80 -0
- package/dist/main.js +4 -1
- package/dist/openai-oauth-callback.js +1 -1
- package/dist/openai-oauth.js +59 -15
- package/dist/process-shutdown.js +52 -0
- package/dist/providers/anthropic-stream.js +24 -20
- package/dist/providers/anthropic-wire.js +7 -2
- package/dist/providers/ollama-wire.js +7 -15
- package/dist/providers/ollama.js +27 -10
- package/dist/providers/openai-wire.js +2 -16
- package/dist/providers/tool-input.js +17 -0
- package/dist/sessions/codec.js +2 -1
- package/dist/sessions/lease.js +7 -0
- package/dist/sessions/runtime.js +11 -3
- package/dist/sessions/store.js +90 -31
- package/dist/settings.js +10 -5
- package/dist/start.js +12 -2
- package/dist/text-boundary.js +2 -0
- package/dist/tui/app-input.js +40 -5
- package/dist/tui/app-state.js +1 -0
- package/dist/tui/app-workflows.js +1 -0
- package/dist/tui/app.js +11 -3
- package/dist/tui/blocks.js +1 -3
- package/dist/tui/components/messages.js +9 -13
- package/dist/tui/components/tool.js +2 -4
- package/dist/tui/editor.js +2 -0
- package/dist/tui/keys.js +64 -5
- package/dist/tui/overlay.js +12 -4
- package/dist/tui/picker.js +2 -0
- package/dist/tui/screen.js +5 -17
- package/dist/tui/transcript-grammar.js +1 -1
- package/dist/ui/theme.js +18 -18
- package/dist/user-store.js +54 -0
- package/package.json +6 -3
- /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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
132
|
+
add(value);
|
|
89
133
|
}
|
|
90
134
|
}
|
|
91
|
-
return
|
|
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;
|
package/dist/credentials.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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("../
|
|
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;
|
package/dist/openai-oauth.js
CHANGED
|
@@ -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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 (
|
|
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:
|
|
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({
|
|
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
|
-
}
|
package/dist/providers/ollama.js
CHANGED
|
@@ -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
|
|
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
|
|
60
|
-
if (
|
|
61
|
-
return
|
|
62
|
-
const
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|