scalix 1.5.1 → 1.6.0
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/.env.example +5 -3
- package/CHANGELOG.md +40 -0
- package/LICENSE +0 -1
- package/README.md +91 -3
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +372 -43
- package/dist/api-client.js +64 -5
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +397 -45
- package/dist/cloud.js +1 -1
- package/dist/command-sandbox.js +146 -0
- package/dist/commands.js +18 -6
- package/dist/config.js +44 -5
- package/dist/constants.js +90 -11
- package/dist/context-builder.js +9 -1
- package/dist/cost-tracker.js +1 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +43 -9
- package/dist/ipc-server.js +371 -110
- package/dist/keychain.js +34 -9
- package/dist/marketplace.js +400 -0
- package/dist/mcp.js +498 -29
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/providers/index.js +21 -4
- package/dist/sandbox.js +115 -27
- package/dist/secure-fetch.js +117 -0
- package/dist/sessions.js +12 -0
- package/dist/settings.js +193 -44
- package/dist/slash-commands.js +8 -0
- package/dist/sub-agent.js +6 -2
- package/dist/tools-browser.js +95 -18
- package/dist/tools-extra.js +101 -7
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +92 -28
- package/dist/tools.js +99 -23
- package/dist/workspace-trust.js +11 -1
- package/dist/worktree.js +24 -5
- package/package.json +5 -3
package/dist/api-client.js
CHANGED
|
@@ -12,6 +12,62 @@ export class ApiError extends Error {
|
|
|
12
12
|
this.retryAfterMs = retryAfterMs;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
// ─── Request body guards ───
|
|
16
|
+
// Two confirmed ways a request dies with the gateway's generic
|
|
17
|
+
// "invalid JSON body" (400) before the model ever sees it:
|
|
18
|
+
//
|
|
19
|
+
// 1. A body over the wall. The gateway and the proxies in front of it
|
|
20
|
+
// refuse bodies past ~200KB — measured 192KB passing and 256KB
|
|
21
|
+
// failing — and the refusal is "invalid JSON body", not 413, which
|
|
22
|
+
// reads like a truncated stream. A single tool result is allowed to
|
|
23
|
+
// reach 500K chars, so one fat command output is enough to cross it.
|
|
24
|
+
//
|
|
25
|
+
// 2. A lone surrogate escape. JSON.stringify serializes half of a cut
|
|
26
|
+
// surrogate pair as \ud83d. JavaScript's JSON.parse accepts that;
|
|
27
|
+
// strict parsers (serde_json, Go's encoding/json) reject it. undici
|
|
28
|
+
// would silently map it to U+FFFD on the wire anyway, so the model
|
|
29
|
+
// never saw the half a character either.
|
|
30
|
+
//
|
|
31
|
+
// Both are fixed here rather than at every truncation site: the body
|
|
32
|
+
// is sanitized, and tool results are shrunk until the request fits.
|
|
33
|
+
const MAX_BODY_BYTES = 180_000;
|
|
34
|
+
function utf8ByteLength(s) {
|
|
35
|
+
if (typeof Buffer !== 'undefined')
|
|
36
|
+
return Buffer.byteLength(s, 'utf8');
|
|
37
|
+
return new TextEncoder().encode(s).length;
|
|
38
|
+
}
|
|
39
|
+
function sanitizeLoneSurrogates(body) {
|
|
40
|
+
return body.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
|
|
41
|
+
}
|
|
42
|
+
/** Cut tool-result contents, largest first, until the serialized
|
|
43
|
+
* request fits. Tool results are the bulk of an oversized request,
|
|
44
|
+
* and trimming their text keeps tool_call/result pairing intact —
|
|
45
|
+
* dropping whole messages would break the pairing the wire requires.
|
|
46
|
+
* Rounds, not one pass: a single command output can be many times
|
|
47
|
+
* the wall, so the largest result is re-halved until the check
|
|
48
|
+
* converges. */
|
|
49
|
+
function shrinkToFit(req, maxBytes) {
|
|
50
|
+
let out = req;
|
|
51
|
+
for (;;) {
|
|
52
|
+
if (utf8ByteLength(JSON.stringify(out)) <= maxBytes)
|
|
53
|
+
return out;
|
|
54
|
+
const biggest = out.messages
|
|
55
|
+
.map((m, i) => ({ m, i }))
|
|
56
|
+
.filter(({ m }) => m.role === 'tool' && typeof m.content === 'string' && m.content.length > 400)
|
|
57
|
+
.sort((a, b) => b.m.content.length - a.m.content.length)[0];
|
|
58
|
+
// Nothing left worth cutting — the request is over for a reason
|
|
59
|
+
// this pass cannot fix, so send what remains.
|
|
60
|
+
if (!biggest)
|
|
61
|
+
return out;
|
|
62
|
+
const text = biggest.m.content;
|
|
63
|
+
const messages = [...out.messages];
|
|
64
|
+
messages[biggest.i] = {
|
|
65
|
+
...biggest.m,
|
|
66
|
+
content: text.slice(0, Math.max(200, Math.floor(text.length / 2))) + '\n...[trimmed so the request fits the endpoint]',
|
|
67
|
+
};
|
|
68
|
+
out = { ...out, messages };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
15
71
|
/**
|
|
16
72
|
* Parse a Retry-After header value into milliseconds. Accepts both forms from
|
|
17
73
|
* RFC 9110: delta-seconds ("120") and HTTP-date. Returns undefined for absent
|
|
@@ -31,7 +87,9 @@ export function parseRetryAfter(value) {
|
|
|
31
87
|
// Per-request abort. 300s matches the platform's own proxy walls; the old
|
|
32
88
|
// 120s default aborted the client before a slow first token could arrive on
|
|
33
89
|
// CPU-served models (the request was fine, we hung up on it). For reference,
|
|
34
|
-
// the OpenAI and Anthropic SDKs both default to 600s.
|
|
90
|
+
// the OpenAI and Anthropic SDKs both default to 600s. The default applies
|
|
91
|
+
// when SCALIX_REQUEST_TIMEOUT_MS is unset — 0 would mean "no timeout", which
|
|
92
|
+
// lets a wedged connection hang the turn forever.
|
|
35
93
|
const DEFAULT_TIMEOUT_MS = (() => {
|
|
36
94
|
const v = process.env.SCALIX_REQUEST_TIMEOUT_MS;
|
|
37
95
|
const n = v ? parseInt(v, 10) : NaN;
|
|
@@ -123,9 +181,10 @@ export class ScalixClient {
|
|
|
123
181
|
// as many times as there are narrowings left.
|
|
124
182
|
for (;;) {
|
|
125
183
|
const prepared = this.prepareRequest(req);
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
184
|
+
const wire = shrinkToFit(prepared, MAX_BODY_BYTES);
|
|
185
|
+
const body = sanitizeLoneSurrogates(JSON.stringify(wire));
|
|
186
|
+
const timeoutSignal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : undefined;
|
|
187
|
+
const combined = signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal || timeoutSignal);
|
|
129
188
|
const response = await fetch(url, {
|
|
130
189
|
method: 'POST',
|
|
131
190
|
headers: {
|
|
@@ -135,7 +194,7 @@ export class ScalixClient {
|
|
|
135
194
|
'x-client-type': 'cli',
|
|
136
195
|
},
|
|
137
196
|
body,
|
|
138
|
-
signal: combined,
|
|
197
|
+
signal: combined || undefined,
|
|
139
198
|
});
|
|
140
199
|
if (!response.ok) {
|
|
141
200
|
const text = await response.text().catch(() => '');
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew board context (scalix-org roadmap/SCALIX-CREW.md, T2.2/T2.3).
|
|
3
|
+
*
|
|
4
|
+
* BOARD.md is a READ-ONLY projection of a crew's notice board, written into
|
|
5
|
+
* the workspace by whoever runs the session (the orchestrator's CrewHydrator
|
|
6
|
+
* for cloud sessions; the cockpit for local ones) and refreshed before each
|
|
7
|
+
* turn. The engine never writes it — posting goes through the
|
|
8
|
+
* scalix_crew_board_post platform tool, the board's only write path.
|
|
9
|
+
*
|
|
10
|
+
* Durable lessons do not live here either: a member that learns something
|
|
11
|
+
* permanent from a board note promotes it into project memory (/remember),
|
|
12
|
+
* deliberately. Board = news; memory = knowledge.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
export const BOARD_FILE = 'BOARD.md';
|
|
17
|
+
/** A board is news, not a document dump — cap what lands in context so a
|
|
18
|
+
* runaway board cannot crowd the task out of the window. */
|
|
19
|
+
export const BOARD_CONTEXT_MAX_CHARS = 16 * 1024;
|
|
20
|
+
/** Read the workspace board projection. Null when absent or empty — those
|
|
21
|
+
* mean "this session has no crew board", which is different from a board
|
|
22
|
+
* with no notes (that renders its own explicit empty state). */
|
|
23
|
+
export async function readBoardFile(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(path.join(cwd, BOARD_FILE), 'utf-8');
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
return null;
|
|
29
|
+
return trimmed.length > BOARD_CONTEXT_MAX_CHARS
|
|
30
|
+
? trimmed.slice(0, BOARD_CONTEXT_MAX_CHARS) + '\n…(board truncated for context)'
|
|
31
|
+
: trimmed;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function boardToContext(board) {
|
|
38
|
+
if (!board)
|
|
39
|
+
return '';
|
|
40
|
+
return [
|
|
41
|
+
'## Crew Board (read-only projection)',
|
|
42
|
+
'Shared coordination state from your crew, current as of session start.',
|
|
43
|
+
'Post updates with the scalix_crew_board_post tool. Never edit BOARD.md —',
|
|
44
|
+
'it is overwritten on every refresh and your edit would be silently lost.',
|
|
45
|
+
'',
|
|
46
|
+
board,
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Turn-start freshness for a session whose system prompt is assembled once:
|
|
51
|
+
* tracks the last projection seen and, when the file moves, hands back a
|
|
52
|
+
* system note carrying the new copy for the conversation history. One file
|
|
53
|
+
* read per turn; a quiet board costs nothing else.
|
|
54
|
+
*/
|
|
55
|
+
export class BoardTurnRefresher {
|
|
56
|
+
cwd;
|
|
57
|
+
lastSeen = null;
|
|
58
|
+
constructor(cwd) {
|
|
59
|
+
this.cwd = cwd;
|
|
60
|
+
}
|
|
61
|
+
/** Baseline — call once near session start, after the system prompt has
|
|
62
|
+
* embedded the board, so the first turnNote() only fires on real change. */
|
|
63
|
+
async prime() {
|
|
64
|
+
this.lastSeen = await readBoardFile(this.cwd);
|
|
65
|
+
}
|
|
66
|
+
/** Null when nothing changed (or the file vanished — a removed projection
|
|
67
|
+
* is a session-plumbing event, not news to tell the model about). */
|
|
68
|
+
async turnNote() {
|
|
69
|
+
const current = await readBoardFile(this.cwd);
|
|
70
|
+
if (current === this.lastSeen)
|
|
71
|
+
return null;
|
|
72
|
+
this.lastSeen = current;
|
|
73
|
+
if (current === null)
|
|
74
|
+
return null;
|
|
75
|
+
return [
|
|
76
|
+
'## Crew Board (updated since your last turn — supersedes earlier copies)',
|
|
77
|
+
'',
|
|
78
|
+
current,
|
|
79
|
+
].join('\n');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
Binary file
|