@giovannijecha/jecode 0.7.2 → 0.7.4
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 +87 -23
- package/dist/atomic.js +14 -0
- package/dist/batch.js +26 -19
- package/dist/cli-info.js +1 -1
- package/dist/config.js +3 -2
- package/dist/context/budget.js +37 -0
- package/dist/context/compactor.js +9 -2
- package/dist/context/estimate.js +31 -0
- package/dist/context/policy.js +24 -14
- package/dist/controller-request.js +27 -4
- package/dist/controller.js +6 -4
- package/dist/conversation.js +48 -31
- package/dist/oauth-http.js +2 -1
- package/dist/openai-oauth-callback.js +2 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/http.js +8 -5
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +1 -3
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +117 -40
- package/dist/providers/stream-limits.js +14 -2
- package/dist/sessions/store.js +2 -1
- package/dist/text-boundary.js +47 -0
- package/dist/timeline.js +2 -1
- package/dist/tools/fs.js +85 -38
- package/dist/tools/search.js +106 -44
- package/dist/tools/shell.js +14 -6
- package/dist/tools/text-boundary.js +7 -25
- package/dist/tui/app-state.js +0 -1
- package/dist/tui/app-workflows.js +40 -30
- package/dist/tui/app.js +12 -14
- package/dist/tui/blocks.js +0 -2
- package/dist/tui/components/messages.js +2 -5
- package/dist/tui/components/tool.js +11 -10
- package/dist/tui/session-view.js +2 -1
- package/dist/tui/transcript-view.js +178 -106
- package/dist/tui/turn.js +29 -8
- package/dist/tui/view.js +8 -3
- package/dist/ui/diff.js +51 -16
- package/dist/ui/render.js +16 -24
- package/dist/ui/width.js +31 -22
- package/dist/usage.js +5 -1
- package/package.json +2 -1
package/dist/conversation.js
CHANGED
|
@@ -25,32 +25,25 @@ export class ConversationTree {
|
|
|
25
25
|
return new ConversationTree([], 0);
|
|
26
26
|
}
|
|
27
27
|
static restore(nodes, activeNodeId) {
|
|
28
|
-
|
|
28
|
+
if (nodes.length > CONVERSATION_LIMITS.nodes) {
|
|
29
|
+
throw new Error("conversation reached its session limit — start /new");
|
|
30
|
+
}
|
|
31
|
+
const restored = [];
|
|
29
32
|
for (let index = 0; index < nodes.length; index++) {
|
|
30
33
|
const node = nodes[index];
|
|
31
34
|
if (node === undefined || node.id !== index + 1) {
|
|
32
35
|
throw new Error("session contains a non-sequential conversation node");
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
messages: node.messages,
|
|
39
|
-
blocks: node.blocks,
|
|
40
|
-
...(node.context === undefined ? {} : { context: node.context }),
|
|
41
|
-
...(node.failure === undefined ? {} : { failure: node.failure }),
|
|
42
|
-
}, node.settlement);
|
|
43
|
-
const restored = tree.activeNode;
|
|
44
|
-
if (restored === undefined || restored.id !== node.id) {
|
|
45
|
-
throw new Error("session conversation could not be restored");
|
|
46
|
-
}
|
|
47
|
-
if (node.revision > 1) {
|
|
48
|
-
const copy = [...tree.#nodes];
|
|
49
|
-
copy[node.id - 1] = ownedNode({ ...restored, revision: node.revision });
|
|
50
|
-
tree = new ConversationTree(copy, node.id);
|
|
51
|
-
}
|
|
37
|
+
const owned = ownedNode({ ...node, blocks: settledBlocks(node.blocks) });
|
|
38
|
+
assertTurn(owned);
|
|
39
|
+
assertPersistableNode(owned);
|
|
40
|
+
restored.push(owned);
|
|
52
41
|
}
|
|
53
|
-
|
|
42
|
+
assertBounds(restored);
|
|
43
|
+
if (!validNodeId(activeNodeId) ||
|
|
44
|
+
(activeNodeId !== 0 && restored[activeNodeId - 1]?.id !== activeNodeId))
|
|
45
|
+
throw new Error("conversation node does not exist");
|
|
46
|
+
return new ConversationTree(restored, activeNodeId);
|
|
54
47
|
}
|
|
55
48
|
/** Commit or extend the one prospective leaf turn. */
|
|
56
49
|
commit(draft, settlement) {
|
|
@@ -236,12 +229,13 @@ function assertBounds(nodes) {
|
|
|
236
229
|
let messageCodeUnits = 0;
|
|
237
230
|
let transcriptCodeUnits = 0;
|
|
238
231
|
let contextCodeUnits = 0;
|
|
232
|
+
const contextOwners = [];
|
|
239
233
|
for (const node of nodes) {
|
|
240
234
|
messageCodeUnits += JSON.stringify(node.messages).length;
|
|
241
235
|
transcriptCodeUnits += JSON.stringify(node.blocks).length;
|
|
242
236
|
contextCodeUnits += node.context?.summary.length ?? 0;
|
|
243
237
|
if (node.context !== undefined)
|
|
244
|
-
|
|
238
|
+
contextOwners.push(node);
|
|
245
239
|
}
|
|
246
240
|
if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
|
|
247
241
|
throw new Error("conversation model history reached its session limit — start /new");
|
|
@@ -252,18 +246,41 @@ function assertBounds(nodes) {
|
|
|
252
246
|
if (contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
|
|
253
247
|
throw new Error("conversation context summaries reached their session limit — start /new");
|
|
254
248
|
}
|
|
249
|
+
assertContextPaths(nodes, contextOwners);
|
|
255
250
|
}
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
251
|
+
function assertContextPaths(nodes, owners) {
|
|
252
|
+
if (owners.length === 0)
|
|
253
|
+
return;
|
|
254
|
+
const children = Array.from({ length: nodes.length + 1 }, () => []);
|
|
255
|
+
for (const node of nodes)
|
|
256
|
+
children[node.parentId]?.push(node.id);
|
|
257
|
+
const entered = new Uint32Array(nodes.length + 1);
|
|
258
|
+
const exited = new Uint32Array(nodes.length + 1);
|
|
259
|
+
const stack = [{ id: 0, exit: false }];
|
|
260
|
+
let clock = 0;
|
|
261
|
+
while (stack.length > 0) {
|
|
262
|
+
const current = stack.pop();
|
|
263
|
+
if (current.exit) {
|
|
264
|
+
exited[current.id] = clock++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
entered[current.id] = clock++;
|
|
268
|
+
stack.push({ id: current.id, exit: true });
|
|
269
|
+
const descendants = children[current.id];
|
|
270
|
+
for (let index = descendants.length - 1; index >= 0; index--) {
|
|
271
|
+
stack.push({ id: descendants[index], exit: false });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const owner of owners) {
|
|
275
|
+
const context = owner.context;
|
|
276
|
+
const boundary = nodes[context.throughNodeId - 1];
|
|
277
|
+
if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
|
|
278
|
+
throw new Error("turn context checkpoint is invalid");
|
|
279
|
+
}
|
|
280
|
+
if (entered[boundary.id] > entered[owner.id] ||
|
|
281
|
+
exited[owner.id] > exited[boundary.id])
|
|
282
|
+
throw new Error("turn context checkpoint is outside its branch");
|
|
261
283
|
}
|
|
262
|
-
let id = owner.id;
|
|
263
|
-
while (id !== 0 && id !== boundary.id)
|
|
264
|
-
id = nodes[id - 1]?.parentId ?? 0;
|
|
265
|
-
if (id !== boundary.id)
|
|
266
|
-
throw new Error("turn context checkpoint is outside its branch");
|
|
267
284
|
}
|
|
268
285
|
function validNodeId(value) {
|
|
269
286
|
return Number.isSafeInteger(value) && value >= 0;
|
package/dist/oauth-http.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// These requests never redirect, never retry, and never retain an unbounded
|
|
4
4
|
// response. Authorization codes and refresh tokens must not leak into errors.
|
|
5
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
6
|
const AUTH_ORIGIN = "https://auth.openai.com";
|
|
6
7
|
const TIMEOUT_MS = 15_000;
|
|
7
8
|
const MAX_BODY_CHARS = 64_000;
|
|
@@ -96,7 +97,7 @@ function errorDetail(value, secrets) {
|
|
|
96
97
|
let safe = detail.replace(/[\r\n]+/g, " ");
|
|
97
98
|
for (const secret of secrets)
|
|
98
99
|
safe = safe.replaceAll(secret, "[credential redacted]");
|
|
99
|
-
return ` · ${safe
|
|
100
|
+
return ` · ${leadingText(safe, 300)}`;
|
|
100
101
|
}
|
|
101
102
|
function bodySecrets(body) {
|
|
102
103
|
const values = body.contentType === "application/x-www-form-urlencoded"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
6
|
const CALLBACK_PORTS = [1455, 1457];
|
|
6
7
|
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
7
8
|
export async function openAICallback(state) {
|
|
@@ -42,7 +43,7 @@ export async function openAICallback(state) {
|
|
|
42
43
|
const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
|
|
43
44
|
const authorizationCode = incoming.searchParams.get("code");
|
|
44
45
|
if (authError !== null) {
|
|
45
|
-
rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError
|
|
46
|
+
rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
|
|
46
47
|
}
|
|
47
48
|
else if (authorizationCode === null || authorizationCode === "") {
|
|
48
49
|
rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
|
|
@@ -80,7 +80,7 @@ export const anthropic = {
|
|
|
80
80
|
effort: requireSupportedEffort(req.model, req.effort, efforts),
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
-
const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
|
|
83
|
+
const events = await postSse(ENDPOINT, headers(key), body, req.maxTokens, req.signal, req.onStatus);
|
|
84
84
|
const data = await assembleAnthropic(events, req.onStream);
|
|
85
85
|
// A refusal or a truncation never arrives as streamed text, so it has to
|
|
86
86
|
// be announced separately or the user watches the turn end in silence.
|
package/dist/providers/http.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// The entire HTTP layer: one bounded request, then either a JSON body or an
|
|
2
2
|
// event stream. Only idempotent reads retry. Once a POST starts or response
|
|
3
3
|
// bytes flow, a failure is surfaced rather than silently replayed.
|
|
4
|
+
import { leadingText } from "../text-boundary.js";
|
|
4
5
|
import { readSseJson } from "./sse.js";
|
|
6
|
+
import { sseStreamCharacterLimit } from "./stream-limits.js";
|
|
5
7
|
const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
|
|
6
8
|
const MAX_JSON_CHARS = 5_000_000;
|
|
7
9
|
const MAX_ERROR_CHARS = 2_000;
|
|
@@ -30,14 +32,15 @@ async function asJson(url, res) {
|
|
|
30
32
|
return JSON.parse(text);
|
|
31
33
|
}
|
|
32
34
|
catch {
|
|
33
|
-
throw httpError(`${url} returned non-JSON`, res.status, text
|
|
35
|
+
throw httpError(`${url} returned non-JSON`, res.status, leadingText(text, 500));
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
|
-
export async function postSse(url, headers, body, signal, onStatus) {
|
|
38
|
+
export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
|
|
39
|
+
const maximumChars = sseStreamCharacterLimit(maxOutputTokens);
|
|
37
40
|
const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
|
|
38
41
|
if (res.body === null)
|
|
39
42
|
throw httpError(`${url} returned no body`, res.status);
|
|
40
|
-
return readSseJson(withIdleTimeout(url, res.body));
|
|
43
|
+
return readSseJson(withIdleTimeout(url, res.body), maximumChars);
|
|
41
44
|
}
|
|
42
45
|
async function request(url, headers, body, signal, onStatus) {
|
|
43
46
|
const maxRetries = body === undefined ? GET_RETRIES : 0;
|
|
@@ -105,13 +108,13 @@ async function boundedText(url, res, max) {
|
|
|
105
108
|
if (done) {
|
|
106
109
|
text += decoder.decode();
|
|
107
110
|
return text.length > max
|
|
108
|
-
? { text: text
|
|
111
|
+
? { text: leadingText(text, max), truncated: true }
|
|
109
112
|
: { text, truncated: false };
|
|
110
113
|
}
|
|
111
114
|
text += decoder.decode(value, { stream: true });
|
|
112
115
|
if (text.length > max) {
|
|
113
116
|
await reader.cancel().catch(() => undefined);
|
|
114
|
-
return { text: text
|
|
117
|
+
return { text: leadingText(text, max), truncated: true };
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
}
|
package/dist/providers/ollama.js
CHANGED
|
@@ -88,7 +88,7 @@ export const ollama = {
|
|
|
88
88
|
max_tokens: req.maxTokens,
|
|
89
89
|
reasoning_effort: effort,
|
|
90
90
|
stream: true,
|
|
91
|
-
}, req.signal, req.onStatus);
|
|
91
|
+
}, req.maxTokens, req.signal, req.onStatus);
|
|
92
92
|
const reply = await assembleOllama(events, req.onStream);
|
|
93
93
|
const notice = stopNotice(reply);
|
|
94
94
|
if (notice !== undefined)
|
|
@@ -71,7 +71,7 @@ export const openaiCodex = {
|
|
|
71
71
|
text: { verbosity: "low" },
|
|
72
72
|
include: ["reasoning.encrypted_content"],
|
|
73
73
|
prompt_cache_key: SESSION_ID,
|
|
74
|
-
}, req.signal, req.onStatus);
|
|
74
|
+
}, req.maxTokens, req.signal, req.onStatus);
|
|
75
75
|
const data = await assembleOpenAI(events, req.onStream);
|
|
76
76
|
const notice = stopNotice(data);
|
|
77
77
|
if (notice !== undefined)
|
|
@@ -152,8 +152,6 @@ function modelContextWindow(entry) {
|
|
|
152
152
|
return undefined;
|
|
153
153
|
const percent = percentage(entry["effective_context_window_percent"]) ?? 95;
|
|
154
154
|
const tokens = Math.floor(resolved * percent / 100);
|
|
155
|
-
if (!validTokenCount(tokens))
|
|
156
|
-
return undefined;
|
|
157
155
|
const automatic = Math.floor(resolved * 9 / 10);
|
|
158
156
|
const advertised = tokenCount(entry["auto_compact_token_limit"]);
|
|
159
157
|
return Object.freeze({
|
package/dist/providers/openai.js
CHANGED
|
@@ -86,7 +86,7 @@ export const openai = {
|
|
|
86
86
|
store: false,
|
|
87
87
|
include: ["reasoning.encrypted_content"],
|
|
88
88
|
stream: true,
|
|
89
|
-
}, req.signal, req.onStatus);
|
|
89
|
+
}, req.maxTokens, req.signal, req.onStatus);
|
|
90
90
|
const data = await assembleOpenAI(events, req.onStream);
|
|
91
91
|
const notice = stopNotice(data);
|
|
92
92
|
if (notice !== undefined)
|
package/dist/providers/sse.js
CHANGED
|
@@ -3,41 +3,30 @@
|
|
|
3
3
|
// The format is small: `field: value` lines, a blank line ends an event. Only
|
|
4
4
|
// `data` matters here — both providers put the event discriminator inside the
|
|
5
5
|
// JSON payload, so the `event:` line is redundant and skipped.
|
|
6
|
-
import { addBounded, MAX_SSE_EVENT_CHARS,
|
|
7
|
-
export async function* readSseJson(body) {
|
|
6
|
+
import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
|
|
7
|
+
export async function* readSseJson(body, maximumChars) {
|
|
8
8
|
const reader = body.getReader();
|
|
9
9
|
const decoder = new TextDecoder();
|
|
10
|
-
|
|
10
|
+
const parser = new SseEventParser();
|
|
11
11
|
let finished = false;
|
|
12
12
|
let total = 0;
|
|
13
|
-
const append = (text) => {
|
|
14
|
-
total = addBounded(total, text.length, MAX_SSE_STREAM_CHARS, "SSE stream");
|
|
15
|
-
buffer += text;
|
|
16
|
-
};
|
|
17
13
|
try {
|
|
18
14
|
for (;;) {
|
|
19
15
|
const { done, value } = await reader.read();
|
|
20
16
|
if (done)
|
|
21
17
|
break;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
break;
|
|
27
|
-
assertEventSize(boundary.start);
|
|
28
|
-
const chunk = buffer.slice(0, boundary.start);
|
|
29
|
-
buffer = buffer.slice(boundary.end);
|
|
30
|
-
const payload = parseData(chunk);
|
|
31
|
-
if (payload !== undefined)
|
|
32
|
-
yield payload;
|
|
33
|
-
}
|
|
34
|
-
assertEventSize(buffer.length);
|
|
18
|
+
const text = decoder.decode(value, { stream: true });
|
|
19
|
+
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
20
|
+
for (const payload of parser.push(text))
|
|
21
|
+
yield payload;
|
|
35
22
|
}
|
|
36
23
|
// A stream that ends without a trailing blank line still owes us its last
|
|
37
24
|
// event.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const payload
|
|
25
|
+
const text = decoder.decode();
|
|
26
|
+
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
27
|
+
for (const payload of parser.push(text))
|
|
28
|
+
yield payload;
|
|
29
|
+
const payload = parser.finish();
|
|
41
30
|
if (payload !== undefined)
|
|
42
31
|
yield payload;
|
|
43
32
|
finished = true;
|
|
@@ -48,28 +37,116 @@ export async function* readSseJson(body) {
|
|
|
48
37
|
reader.releaseLock();
|
|
49
38
|
}
|
|
50
39
|
}
|
|
40
|
+
// Keep fragments in bounded groups. A provider may split one SSE line into
|
|
41
|
+
// hundreds of thousands of tiny chunks; repeatedly flattening the growing line
|
|
42
|
+
// would make parsing quadratic even if boundary scanning itself were linear.
|
|
43
|
+
class TextParts {
|
|
44
|
+
#groups = [];
|
|
45
|
+
#pieces = [];
|
|
46
|
+
#lastCodeUnit = "";
|
|
47
|
+
length = 0;
|
|
48
|
+
append(text) {
|
|
49
|
+
if (text === "")
|
|
50
|
+
return;
|
|
51
|
+
this.#pieces.push(text);
|
|
52
|
+
this.#lastCodeUnit = text.at(-1);
|
|
53
|
+
this.length += text.length;
|
|
54
|
+
if (this.#pieces.length >= 256)
|
|
55
|
+
this.#flush();
|
|
56
|
+
}
|
|
57
|
+
take() {
|
|
58
|
+
this.#flush();
|
|
59
|
+
const text = this.#groups.length === 1 ? this.#groups[0] : this.#groups.join("");
|
|
60
|
+
this.#groups.length = 0;
|
|
61
|
+
this.#lastCodeUnit = "";
|
|
62
|
+
this.length = 0;
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
endsWithCarriageReturn() {
|
|
66
|
+
return this.#lastCodeUnit === "\r";
|
|
67
|
+
}
|
|
68
|
+
#flush() {
|
|
69
|
+
if (this.#pieces.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
this.#groups.push(this.#pieces.length === 1 ? this.#pieces[0] : this.#pieces.join(""));
|
|
72
|
+
this.#pieces = [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export class SseEventParser {
|
|
76
|
+
#line = new TextParts();
|
|
77
|
+
#data = [];
|
|
78
|
+
#eventLength = 0;
|
|
79
|
+
#hasLine = false;
|
|
80
|
+
#pendingTerminatorLength = 0;
|
|
81
|
+
*push(text) {
|
|
82
|
+
let start = 0;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const newline = text.indexOf("\n", start);
|
|
85
|
+
if (newline === -1) {
|
|
86
|
+
this.#line.append(text.slice(start));
|
|
87
|
+
this.#assertPendingLineSize();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.#line.append(text.slice(start, newline));
|
|
91
|
+
const rawLine = this.#line.take();
|
|
92
|
+
const crlf = rawLine.endsWith("\r");
|
|
93
|
+
const line = crlf ? rawLine.slice(0, -1) : rawLine;
|
|
94
|
+
if (line === "") {
|
|
95
|
+
const payload = this.#finishEvent();
|
|
96
|
+
if (payload !== undefined)
|
|
97
|
+
yield payload;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.#appendLine(line, crlf ? 2 : 1);
|
|
101
|
+
}
|
|
102
|
+
start = newline + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
finish() {
|
|
106
|
+
const line = this.#line.take();
|
|
107
|
+
if (line !== "") {
|
|
108
|
+
this.#appendLine(line, 0);
|
|
109
|
+
}
|
|
110
|
+
else if (this.#hasLine) {
|
|
111
|
+
// A single trailing line terminator is part of an unterminated event.
|
|
112
|
+
assertEventSize(this.#eventLength + this.#pendingTerminatorLength);
|
|
113
|
+
}
|
|
114
|
+
return this.#finishEvent();
|
|
115
|
+
}
|
|
116
|
+
#appendLine(line, terminatorLength) {
|
|
117
|
+
const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
|
|
118
|
+
assertEventSize(this.#eventLength + separatorLength + line.length);
|
|
119
|
+
this.#eventLength += separatorLength + line.length;
|
|
120
|
+
this.#hasLine = true;
|
|
121
|
+
this.#pendingTerminatorLength = terminatorLength;
|
|
122
|
+
if (line.startsWith("data:")) {
|
|
123
|
+
this.#data.push(line.slice("data:".length).trimStart());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
#assertPendingLineSize() {
|
|
127
|
+
const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
|
|
128
|
+
const remaining = MAX_SSE_EVENT_CHARS - this.#eventLength - separatorLength;
|
|
129
|
+
// One trailing CR may turn out to be part of a split CRLF terminator.
|
|
130
|
+
const splitCrlf = this.#line.length === remaining + 1 && this.#line.endsWithCarriageReturn();
|
|
131
|
+
if (this.#line.length > remaining && !splitCrlf) {
|
|
132
|
+
assertEventSize(MAX_SSE_EVENT_CHARS + 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
#finishEvent() {
|
|
136
|
+
const data = this.#data.join("\n");
|
|
137
|
+
this.#data = [];
|
|
138
|
+
this.#eventLength = 0;
|
|
139
|
+
this.#hasLine = false;
|
|
140
|
+
this.#pendingTerminatorLength = 0;
|
|
141
|
+
return parseData(data);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
51
144
|
function assertEventSize(length) {
|
|
52
145
|
if (length > MAX_SSE_EVENT_CHARS) {
|
|
53
146
|
throw new Error(`SSE event exceeded ${MAX_SSE_EVENT_CHARS} characters`);
|
|
54
147
|
}
|
|
55
148
|
}
|
|
56
|
-
|
|
57
|
-
// normalising pass would have to cope with a \r\n split across two chunks.
|
|
58
|
-
function findBoundary(buffer) {
|
|
59
|
-
const lf = buffer.indexOf("\n\n");
|
|
60
|
-
const crlf = buffer.indexOf("\r\n\r\n");
|
|
61
|
-
if (lf === -1 && crlf === -1)
|
|
62
|
-
return undefined;
|
|
63
|
-
if (crlf !== -1 && (lf === -1 || crlf < lf))
|
|
64
|
-
return { start: crlf, end: crlf + 4 };
|
|
65
|
-
return { start: lf, end: lf + 2 };
|
|
66
|
-
}
|
|
67
|
-
function parseData(chunk) {
|
|
68
|
-
const data = chunk
|
|
69
|
-
.split(/\r?\n/)
|
|
70
|
-
.filter((line) => line.startsWith("data:"))
|
|
71
|
-
.map((line) => line.slice("data:".length).trimStart())
|
|
72
|
-
.join("\n");
|
|
149
|
+
function parseData(data) {
|
|
73
150
|
if (data === "" || data === "[DONE]")
|
|
74
151
|
return undefined;
|
|
75
152
|
try {
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
// Response streams are remote input. Bound the pieces that otherwise grow
|
|
2
|
-
// independently
|
|
2
|
+
// independently, while letting a larger requested output carry its necessarily
|
|
3
|
+
// larger framing, terminal envelope, and opaque reasoning payloads.
|
|
3
4
|
export const MAX_SSE_EVENT_CHARS = 1_000_000;
|
|
4
|
-
export const MAX_SSE_STREAM_CHARS =
|
|
5
|
+
export const MAX_SSE_STREAM_CHARS = 256_000_000;
|
|
5
6
|
export const MAX_TOOL_ARGUMENT_CHARS = 1_000_000;
|
|
7
|
+
const MIN_SSE_STREAM_CHARS = 4_000_000;
|
|
8
|
+
const SSE_CHARS_PER_OUTPUT_TOKEN = 512;
|
|
9
|
+
export function sseStreamCharacterLimit(maxOutputTokens) {
|
|
10
|
+
if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
|
|
11
|
+
throw new Error("max output tokens must be a positive safe integer");
|
|
12
|
+
}
|
|
13
|
+
const scaled = maxOutputTokens > Math.floor(MAX_SSE_STREAM_CHARS / SSE_CHARS_PER_OUTPUT_TOKEN)
|
|
14
|
+
? MAX_SSE_STREAM_CHARS
|
|
15
|
+
: maxOutputTokens * SSE_CHARS_PER_OUTPUT_TOKEN;
|
|
16
|
+
return Math.max(MIN_SSE_STREAM_CHARS, scaled);
|
|
17
|
+
}
|
|
6
18
|
export function addBounded(total, added, maximum, label) {
|
|
7
19
|
if (added > maximum - total) {
|
|
8
20
|
throw new Error(`${label} exceeded ${maximum} characters`);
|
package/dist/sessions/store.js
CHANGED
|
@@ -8,6 +8,7 @@ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { atomicWrite } from "../atomic.js";
|
|
10
10
|
import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
|
|
11
|
+
import { leadingText } from "../text-boundary.js";
|
|
11
12
|
import { userDataPath } from "../user-data.js";
|
|
12
13
|
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
|
|
13
14
|
import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
|
|
@@ -367,7 +368,7 @@ function firstUserText(conversation) {
|
|
|
367
368
|
const text = message.content.find((block) => block.kind === "text")?.text
|
|
368
369
|
.replace(/\s+/gu, " ").trim();
|
|
369
370
|
if (text !== undefined && text !== "")
|
|
370
|
-
return text
|
|
371
|
+
return leadingText(text, 160);
|
|
371
372
|
}
|
|
372
373
|
return "Untitled session";
|
|
373
374
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Shared grapheme boundaries for every projection of user-visible text.
|
|
2
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
3
|
+
export function segmentGraphemes(text) {
|
|
4
|
+
return SEGMENTER.segment(text);
|
|
5
|
+
}
|
|
6
|
+
export function graphemes(text) {
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const { segment } of segmentGraphemes(text))
|
|
9
|
+
out.push(segment);
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
/** Largest complete grapheme boundary no greater than a UTF-16 offset. */
|
|
13
|
+
export function graphemeFloor(text, offset) {
|
|
14
|
+
const target = Math.max(0, Math.min(text.length, offset));
|
|
15
|
+
if (target === 0 || target === text.length)
|
|
16
|
+
return target;
|
|
17
|
+
const containing = segmentGraphemes(text).containing(target);
|
|
18
|
+
if (containing === undefined || containing.index === target)
|
|
19
|
+
return target;
|
|
20
|
+
return containing.index;
|
|
21
|
+
}
|
|
22
|
+
/** Smallest complete grapheme boundary no less than a UTF-16 offset. */
|
|
23
|
+
export function graphemeCeiling(text, offset) {
|
|
24
|
+
const target = Math.max(0, Math.min(text.length, offset));
|
|
25
|
+
if (target === 0 || target === text.length)
|
|
26
|
+
return target;
|
|
27
|
+
const containing = segmentGraphemes(text).containing(target);
|
|
28
|
+
if (containing === undefined || containing.index === target)
|
|
29
|
+
return target;
|
|
30
|
+
return containing.index + containing.segment.length;
|
|
31
|
+
}
|
|
32
|
+
/** Keep a bounded prefix without returning part of a user-perceived character. */
|
|
33
|
+
export function leadingText(text, maxCodeUnits) {
|
|
34
|
+
if (maxCodeUnits <= 0)
|
|
35
|
+
return "";
|
|
36
|
+
if (text.length <= maxCodeUnits)
|
|
37
|
+
return text;
|
|
38
|
+
return text.slice(0, graphemeFloor(text, maxCodeUnits));
|
|
39
|
+
}
|
|
40
|
+
/** Keep a bounded suffix without returning part of a user-perceived character. */
|
|
41
|
+
export function trailingText(text, maxCodeUnits) {
|
|
42
|
+
if (maxCodeUnits <= 0)
|
|
43
|
+
return "";
|
|
44
|
+
if (text.length <= maxCodeUnits)
|
|
45
|
+
return text;
|
|
46
|
+
return text.slice(graphemeCeiling(text, text.length - maxCodeUnits));
|
|
47
|
+
}
|
package/dist/timeline.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Selecting a node changes only the in-memory path. The next real user turn
|
|
4
4
|
// is what persists a branch, so opening or cancelling this control plane can
|
|
5
5
|
// never create empty history.
|
|
6
|
+
import { leadingText } from "./text-boundary.js";
|
|
6
7
|
import { usageFromHistory } from "./usage.js";
|
|
7
8
|
import { heading } from "./tui/picker.js";
|
|
8
9
|
export function timelinePicker(conversation, palette) {
|
|
@@ -83,7 +84,7 @@ function preview(node) {
|
|
|
83
84
|
const text = message.content.find((block) => block.kind === "text")?.text
|
|
84
85
|
.replace(/\s+/gu, " ").trim();
|
|
85
86
|
if (text !== undefined && text !== "")
|
|
86
|
-
return text
|
|
87
|
+
return leadingText(text, 160);
|
|
87
88
|
}
|
|
88
89
|
return "Untitled turn";
|
|
89
90
|
}
|