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.
@@ -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 body = JSON.stringify(prepared);
127
- const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
128
- const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
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