nebula-notebook 0.1.1 → 0.2.1
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 +110 -16
- package/dist/assets/errorwidget-Wy5ajPiM.js +5 -0
- package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
- package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
- package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
- package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
- package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
- package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
- package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
- package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
- package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
- package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
- package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
- package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
- package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
- package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
- package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
- package/dist/assets/index-B_Bs5koo.js +1 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/index-DeQN03nW.js +81 -0
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/index-g6MjT-VG.js +1 -0
- package/dist/assets/services-shim-BirHVsih.js +33 -0
- package/dist/assets/viewlist-uomDf7I7.js +1 -0
- package/dist/assets/widgets-X7J3NxEn.css +1 -0
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.js +22 -4
- package/node-server/dist/auth/auth-service.js +37 -7
- package/node-server/dist/cluster/client-registration.d.ts +12 -1
- package/node-server/dist/cluster/client-registration.js +38 -2
- package/node-server/dist/cluster/kernel-proxy.js +43 -12
- package/node-server/dist/cluster/server-registry.d.ts +17 -0
- package/node-server/dist/cluster/server-registry.js +57 -7
- package/node-server/dist/discovery/conda-locations.d.ts +73 -0
- package/node-server/dist/discovery/conda-locations.js +427 -0
- package/node-server/dist/discovery/discovery-service.d.ts +73 -4
- package/node-server/dist/discovery/discovery-service.js +211 -127
- package/node-server/dist/discovery/types.d.ts +0 -1
- package/node-server/dist/discovery/types.js +1 -2
- package/node-server/dist/environment.d.ts +12 -0
- package/node-server/dist/environment.js +104 -0
- package/node-server/dist/fs/fs-service.d.ts +86 -8
- package/node-server/dist/fs/fs-service.js +640 -99
- package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
- package/node-server/dist/fs/notebook-formats/percent.js +286 -0
- package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
- package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
- package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
- package/node-server/dist/fs/notebook-formats/registry.js +77 -0
- package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
- package/node-server/dist/fs/notebook-formats/types.js +13 -0
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +198 -8
- package/node-server/dist/kernel/kernel-service.d.ts +130 -2
- package/node-server/dist/kernel/kernel-service.js +834 -64
- package/node-server/dist/kernel/kernelspec.d.ts +27 -0
- package/node-server/dist/kernel/kernelspec.js +75 -4
- package/node-server/dist/notebook/headless-handler.d.ts +12 -0
- package/node-server/dist/notebook/headless-handler.js +125 -29
- package/node-server/dist/notebook/operation-router.js +7 -2
- package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
- package/node-server/dist/notebook/undoRedoManager.js +49 -18
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/private-tmp.d.ts +14 -0
- package/node-server/dist/private-tmp.js +70 -0
- package/node-server/dist/routes/autocomplete.d.ts +19 -0
- package/node-server/dist/routes/autocomplete.js +294 -0
- package/node-server/dist/routes/cluster.js +2 -2
- package/node-server/dist/routes/compute.d.ts +8 -0
- package/node-server/dist/routes/compute.js +136 -0
- package/node-server/dist/routes/fs.js +23 -6
- package/node-server/dist/routes/kernel.js +156 -3
- package/node-server/dist/routes/notebook.js +37 -3
- package/node-server/dist/routes/python.d.ts +1 -2
- package/node-server/dist/routes/python.js +139 -3
- package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
- package/node-server/dist/scheduler/allocation-service.js +285 -0
- package/node-server/dist/scheduler/job-template.d.ts +30 -0
- package/node-server/dist/scheduler/job-template.js +85 -0
- package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
- package/node-server/dist/scheduler/mock-scheduler.js +125 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
- package/node-server/dist/scheduler/types.d.ts +118 -0
- package/node-server/dist/scheduler/types.js +8 -0
- package/node-server/dist/scheduler/util.d.ts +7 -0
- package/node-server/dist/scheduler/util.js +20 -0
- package/node-server/dist/terminal/agent-registry.d.ts +87 -0
- package/node-server/dist/terminal/agent-registry.js +246 -0
- package/node-server/dist/terminal/binding-store.d.ts +57 -0
- package/node-server/dist/terminal/binding-store.js +0 -0
- package/node-server/dist/terminal/pty-manager.d.ts +23 -6
- package/node-server/dist/terminal/pty-manager.js +138 -19
- package/node-server/dist/terminal/server.js +155 -23
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +2 -0
- package/node_modules/nebula-autocomplete/README.md +145 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
- package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
- package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
- package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/index.js +5 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
- package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
- package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
- package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
- package/node_modules/nebula-autocomplete/dist/types.js +1 -0
- package/node_modules/nebula-autocomplete/package.json +58 -0
- package/node_modules/nebula-autocomplete/server.d.ts +3 -0
- package/node_modules/nebula-autocomplete/server.js +3 -0
- package/package.json +18 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-BvrHu37J.js +0 -648
- package/dist/assets/index-Dfj_xsKU.css +0 -32
- package/node-server/dist/llm/index.d.ts +0 -5
- package/node-server/dist/llm/index.js +0 -21
- package/node-server/dist/llm/llm-service.d.ts +0 -77
- package/node-server/dist/llm/llm-service.js +0 -454
- package/node-server/dist/llm/types.d.ts +0 -40
- package/node-server/dist/llm/types.js +0 -15
- package/node-server/dist/routes/llm.d.ts +0 -8
- package/node-server/dist/routes/llm.js +0 -105
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { transcriptDirMatchesToken } from "../core/text.js";
|
|
7
|
+
import { planSpawn, planRemoteCleanup } from "../transport.js";
|
|
8
|
+
function isWorkerDeath(err) {
|
|
9
|
+
return err instanceof Error && err.message === "claude worker exited";
|
|
10
|
+
}
|
|
11
|
+
class ClaudeWorker {
|
|
12
|
+
opts;
|
|
13
|
+
busy = false;
|
|
14
|
+
dead = false;
|
|
15
|
+
turns = 0;
|
|
16
|
+
/** Total prompt chars sent — the real recycle driver: stream-json history
|
|
17
|
+
* grows with every prompt, and TTFB grows with HISTORY SIZE, not turn
|
|
18
|
+
* count (measured: 6.4KB prompts drag +~0.2s/turn; 1.5KB prompts don't). */
|
|
19
|
+
sentChars = 0;
|
|
20
|
+
bornAt = Date.now();
|
|
21
|
+
/** True once the warmup turn has settled — pool selection prefers warmed
|
|
22
|
+
* workers so a request never waits ~5s on a still-booting one while a
|
|
23
|
+
* warm sibling sits idle (observed: workerWait 4506ms, pool 0/2 busy). */
|
|
24
|
+
warmedDone = false;
|
|
25
|
+
/** Invoked once when the process exits/errors — lets the pool respawn in the background. */
|
|
26
|
+
onDeath;
|
|
27
|
+
warmed;
|
|
28
|
+
// Each worker runs in its OWN cwd subdir so Claude's per-cwd transcript
|
|
29
|
+
// directory is unique per worker. Pruning a recycled worker's transcripts
|
|
30
|
+
// then can't touch a concurrently-active worker's dir. cwdToken is the
|
|
31
|
+
// subdir name — it appears in the munged transcript-dir name.
|
|
32
|
+
cwd;
|
|
33
|
+
cwdToken = randomUUID();
|
|
34
|
+
pending = null;
|
|
35
|
+
buf = "";
|
|
36
|
+
proc;
|
|
37
|
+
// For the ssh transport: an ephemeral config dir on the REMOTE side so claude's
|
|
38
|
+
// transcripts don't accumulate on the user's machine — wiped on kill().
|
|
39
|
+
remoteConfigDir;
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
this.opts = opts;
|
|
42
|
+
const argv = [
|
|
43
|
+
"-p",
|
|
44
|
+
"--model", opts.model,
|
|
45
|
+
// Strip tools and MCP servers from the harness prompt — the largest
|
|
46
|
+
// per-turn latency saver (measured ~2.3s -> ~1.4s TTFB).
|
|
47
|
+
"--tools", "",
|
|
48
|
+
"--mcp-config", '{"mcpServers":{}}',
|
|
49
|
+
"--strict-mcp-config",
|
|
50
|
+
"--input-format", "stream-json",
|
|
51
|
+
"--output-format", "stream-json",
|
|
52
|
+
"--include-partial-messages",
|
|
53
|
+
"--verbose",
|
|
54
|
+
];
|
|
55
|
+
const env = {
|
|
56
|
+
// Generous cap: hitting it surfaces as an ERROR that discards the turn,
|
|
57
|
+
// so a tight cap converts long completions into nothing (observed).
|
|
58
|
+
// Runaways are bounded by the turn timeout and by supersession
|
|
59
|
+
// interrupts instead; long ghost text streams visibly and the user can
|
|
60
|
+
// ignore or Escape it.
|
|
61
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "4096",
|
|
62
|
+
// Default 0: thinking tokens never stream as ghost text — observed as
|
|
63
|
+
// a 17s turn burning the whole budget with NOTHING shown. Users can
|
|
64
|
+
// opt into a budget (Advanced) for higher-quality completions.
|
|
65
|
+
MAX_THINKING_TOKENS: String(opts.maxThinkingTokens ?? 0),
|
|
66
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
67
|
+
};
|
|
68
|
+
if (opts.transport.kind === "ssh") {
|
|
69
|
+
// Remote: run in a fresh remote tmp dir; keep transcripts in an ephemeral
|
|
70
|
+
// remote config dir (auth still comes from the user's real login via their
|
|
71
|
+
// ~/.zshenv token). No local workspace dir.
|
|
72
|
+
this.cwd = `/tmp/nebula-autocomplete-ws-${this.cwdToken}`;
|
|
73
|
+
this.remoteConfigDir = `/tmp/nebula-autocomplete-cfg-${this.cwdToken}`;
|
|
74
|
+
env.CLAUDE_CONFIG_DIR = this.remoteConfigDir;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
this.cwd = join(opts.workspaceDir, this.cwdToken);
|
|
78
|
+
this.remoteConfigDir = "";
|
|
79
|
+
mkdirSync(this.cwd, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
const plan = planSpawn(opts.transport, opts.binary, argv, env, this.cwd);
|
|
82
|
+
this.proc = spawn(plan.command, plan.args, {
|
|
83
|
+
...plan.options,
|
|
84
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
this.proc.stdout.on("data", (d) => this.onData(d));
|
|
87
|
+
this.proc.on("exit", () => {
|
|
88
|
+
this.dead = true;
|
|
89
|
+
this.pending?.reject(new Error("claude worker exited"));
|
|
90
|
+
this.pending = null;
|
|
91
|
+
const cb = this.onDeath;
|
|
92
|
+
this.onDeath = undefined; // fire once
|
|
93
|
+
cb?.();
|
|
94
|
+
});
|
|
95
|
+
this.proc.on("error", (err) => {
|
|
96
|
+
this.dead = true;
|
|
97
|
+
this.pending?.reject(err instanceof Error ? err : new Error(String(err)));
|
|
98
|
+
this.pending = null;
|
|
99
|
+
const cb = this.onDeath;
|
|
100
|
+
this.onDeath = undefined;
|
|
101
|
+
cb?.();
|
|
102
|
+
});
|
|
103
|
+
this.warmed = this.runTurn("Reply with exactly: ok").then(() => { this.warmedDone = true; }, () => { this.warmedDone = true; });
|
|
104
|
+
}
|
|
105
|
+
onData(data) {
|
|
106
|
+
this.buf += data.toString();
|
|
107
|
+
let idx;
|
|
108
|
+
while ((idx = this.buf.indexOf("\n")) !== -1) {
|
|
109
|
+
const line = this.buf.slice(0, idx);
|
|
110
|
+
this.buf = this.buf.slice(idx + 1);
|
|
111
|
+
if (!line.trim() || !this.pending)
|
|
112
|
+
continue;
|
|
113
|
+
let ev;
|
|
114
|
+
try {
|
|
115
|
+
ev = JSON.parse(line);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (ev.type === "stream_event") {
|
|
121
|
+
const event = ev.event;
|
|
122
|
+
if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
123
|
+
this.pending.streamed = (this.pending.streamed ?? "") + event.delta.text;
|
|
124
|
+
this.pending.onChunk?.(event.delta.text);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else if (ev.type === "result") {
|
|
128
|
+
const p = this.pending;
|
|
129
|
+
this.pending = null;
|
|
130
|
+
clearTimeout(p.timer);
|
|
131
|
+
// CLI failures (not logged in, out of credits, …) arrive as a result
|
|
132
|
+
// event with is_error / non-success subtype. Reject those turns —
|
|
133
|
+
// resolving would cache and render the error text as a suggestion.
|
|
134
|
+
if (ev.is_error === true || (typeof ev.subtype === "string" && ev.subtype !== "success")) {
|
|
135
|
+
const msg = typeof ev.result === "string" && ev.result.trim()
|
|
136
|
+
? ev.result.trim()
|
|
137
|
+
: `claude turn failed (${ev.subtype ?? "error"})`;
|
|
138
|
+
// Hitting the output-token cap arrives as an ERROR, discarding a
|
|
139
|
+
// completion we already streamed in full (observed: 35s turn, 512
|
|
140
|
+
// tokens, then thrown away). The streamed text IS the completion —
|
|
141
|
+
// truncated, but the tag/trim pipeline handles a missing close tag.
|
|
142
|
+
if (/output token maximum/i.test(msg) && p.streamed) {
|
|
143
|
+
p.resolve(p.streamed);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const note = /output token maximum/i.test(msg)
|
|
147
|
+
? ` (streamed ${p.streamed?.length ?? 0}ch before the cap)`
|
|
148
|
+
: "";
|
|
149
|
+
p.reject(new Error(msg + note));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
p.resolve(typeof ev.result === "string" ? ev.result : "");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
runTurn(text, onChunk, signal) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
if (this.dead)
|
|
161
|
+
return reject(new Error("claude worker is dead"));
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
this.pending = null;
|
|
164
|
+
this.kill();
|
|
165
|
+
reject(new Error("claude turn timed out"));
|
|
166
|
+
}, this.opts.turnTimeoutMs);
|
|
167
|
+
const pending = { onChunk, resolve, reject, timer };
|
|
168
|
+
// Supersession: a dispatched turn CAN be cancelled — the stream-json
|
|
169
|
+
// protocol accepts {type:"control_request",request:{subtype:"interrupt"}}
|
|
170
|
+
// and ends the running turn in ~10ms (measured), leaving the worker
|
|
171
|
+
// reusable. Without this, a superseded completion held its worker for
|
|
172
|
+
// the full turn and the successor's "queued" time was a whole turn.
|
|
173
|
+
// Guard on `this.pending === pending` so a late abort can never kill a
|
|
174
|
+
// successor's turn that reused this worker.
|
|
175
|
+
const onAbort = () => {
|
|
176
|
+
if (this.pending === pending && !this.dead) {
|
|
177
|
+
try {
|
|
178
|
+
this.proc.stdin.write(JSON.stringify({
|
|
179
|
+
type: "control_request",
|
|
180
|
+
request_id: `int-${Date.now()}`,
|
|
181
|
+
request: { subtype: "interrupt" },
|
|
182
|
+
}) + "\n");
|
|
183
|
+
}
|
|
184
|
+
catch { /* worker dying anyway */ }
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
188
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
189
|
+
pending.resolve = (v) => { cleanup(); resolve(v); };
|
|
190
|
+
pending.reject = (e) => { cleanup(); reject(e); };
|
|
191
|
+
this.pending = pending;
|
|
192
|
+
if (signal?.aborted)
|
|
193
|
+
onAbort();
|
|
194
|
+
const msg = {
|
|
195
|
+
type: "user",
|
|
196
|
+
message: { role: "user", content: [{ type: "text", text }] },
|
|
197
|
+
};
|
|
198
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
kill() {
|
|
202
|
+
this.dead = true;
|
|
203
|
+
try {
|
|
204
|
+
this.proc.kill();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* already gone */
|
|
208
|
+
}
|
|
209
|
+
// Remote no-trace: wipe this worker's ephemeral config + workspace dirs on
|
|
210
|
+
// the user's machine. Fire-and-forget over the (still-warm) control channel.
|
|
211
|
+
if (this.opts.transport.kind === "ssh") {
|
|
212
|
+
for (const dir of [this.remoteConfigDir, this.cwd]) {
|
|
213
|
+
if (!dir)
|
|
214
|
+
continue;
|
|
215
|
+
try {
|
|
216
|
+
const c = planRemoteCleanup(this.opts.transport, dir);
|
|
217
|
+
spawn(c.command, c.args, { stdio: "ignore" }).unref();
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
/* best-effort */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Persistent warm pool of `claude` CLI processes (subscription auth) in
|
|
228
|
+
* stream-json mode. Workers are recycled after maxTurnsPerWorker completions
|
|
229
|
+
* so conversation history never bloats the context.
|
|
230
|
+
*/
|
|
231
|
+
export class ClaudeBackend {
|
|
232
|
+
name = "claude";
|
|
233
|
+
pool = [];
|
|
234
|
+
opts;
|
|
235
|
+
disposed = false;
|
|
236
|
+
// Unique token embedded in the workspace path. Claude Code keys its session
|
|
237
|
+
// transcript directory (~/.claude/projects/<munged-cwd>) off the process cwd,
|
|
238
|
+
// so this token survives path munging and lets us find and delete only our
|
|
239
|
+
// own transcript dirs — never a real session. See pruneTranscripts().
|
|
240
|
+
wsToken = randomUUID();
|
|
241
|
+
constructor(options = {}) {
|
|
242
|
+
// For ssh transport, give all workers a shared ControlMaster socket so the
|
|
243
|
+
// handshake is paid once and reconnects are ~free.
|
|
244
|
+
let transport = options.transport ?? { kind: "local" };
|
|
245
|
+
if (transport.kind === "ssh" && !transport.controlPath) {
|
|
246
|
+
// Keep the ControlMaster socket path SHORT: unix-socket paths max ~104
|
|
247
|
+
// chars, and macOS tmpdir() is a long /var/folders/... path that overflows
|
|
248
|
+
// it (→ ssh aborts). /tmp + a short token stays well under the limit.
|
|
249
|
+
transport = { ...transport, controlPath: `/tmp/nac-${this.wsToken.slice(0, 8)}` };
|
|
250
|
+
}
|
|
251
|
+
this.opts = {
|
|
252
|
+
model: options.model ?? "haiku",
|
|
253
|
+
poolSize: options.poolSize ?? 2,
|
|
254
|
+
// MEASURED (10 sequential real turns, one worker): per-turn latency does
|
|
255
|
+
// NOT grow with turn count — turns 7-9 averaged FASTER than 0-2, so the
|
|
256
|
+
// CLI/API handles its own history efficiently (prompt caching). Keep 8:
|
|
257
|
+
// recycling more often would just burn a warmup API call per recycle
|
|
258
|
+
// for zero latency benefit.
|
|
259
|
+
maxTurnsPerWorker: options.maxTurnsPerWorker ?? 8,
|
|
260
|
+
// MEASURED: TTFB grows with accumulated HISTORY, not turns — 6.4KB
|
|
261
|
+
// real-notebook prompts dragged ttfb 3.2s@turn5 -> 4.0s@turn7, while a
|
|
262
|
+
// 10-turn run of 1.5KB prompts showed no drag at all. Cap history size;
|
|
263
|
+
// the turn cap above is just a backstop. Recycling is background-warmed
|
|
264
|
+
// (spawnWorker/ensurePool), so tighter recycling costs the user nothing.
|
|
265
|
+
maxHistoryCharsPerWorker: options.maxHistoryCharsPerWorker ?? 18_000,
|
|
266
|
+
turnTimeoutMs: options.turnTimeoutMs ?? 45_000,
|
|
267
|
+
// Default to a per-instance dir so the transcript-dir name is unique to
|
|
268
|
+
// this backend. A caller-supplied dir is used verbatim (its transcripts
|
|
269
|
+
// are then only pruned if its basename happens to contain wsToken — so
|
|
270
|
+
// prefer letting the default apply for full hygiene).
|
|
271
|
+
workspaceDir: options.workspaceDir ?? join(tmpdir(), `nebula-autocomplete-ws-${this.wsToken}`),
|
|
272
|
+
binary: options.binary ?? "claude",
|
|
273
|
+
transport,
|
|
274
|
+
maxThinkingTokens: options.maxThinkingTokens ?? 0,
|
|
275
|
+
};
|
|
276
|
+
// Local transport keeps its worker workspaces under workspaceDir; ssh workers
|
|
277
|
+
// use remote dirs, so no local dir is needed.
|
|
278
|
+
if (transport.kind === "local")
|
|
279
|
+
mkdirSync(this.opts.workspaceDir, { recursive: true });
|
|
280
|
+
this.ensurePool();
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Delete any Claude Code session-transcript directories that belong to this
|
|
284
|
+
* backend. Claude writes `~/.claude/projects/<munged-cwd>/*.jsonl` on some
|
|
285
|
+
* versions/platforms; the munged name preserves alphanumerics, so our
|
|
286
|
+
* wsToken (a UUID) always survives. We match by comparing alphanumeric-only
|
|
287
|
+
* forms, which is munge-algorithm-independent and cannot collide with a real
|
|
288
|
+
* project path. Best-effort: never throws into the caller.
|
|
289
|
+
*/
|
|
290
|
+
pruneTranscripts(token = this.wsToken) {
|
|
291
|
+
// ssh transport: transcripts live on the remote machine in a per-worker
|
|
292
|
+
// ephemeral CLAUDE_CONFIG_DIR, wiped by ClaudeWorker.kill(). Nothing local.
|
|
293
|
+
if (this.opts.transport.kind === "ssh")
|
|
294
|
+
return;
|
|
295
|
+
try {
|
|
296
|
+
const projectsDir = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects");
|
|
297
|
+
for (const entry of readdirSync(projectsDir)) {
|
|
298
|
+
if (transcriptDirMatchesToken(entry, token)) {
|
|
299
|
+
rmSync(join(projectsDir, entry), { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
/* projects dir absent (nothing written) or unreadable — nothing to prune */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
ensurePool() {
|
|
308
|
+
if (this.disposed)
|
|
309
|
+
return;
|
|
310
|
+
this.pool = this.pool.filter((w) => !w.dead);
|
|
311
|
+
while (this.pool.length < this.opts.poolSize) {
|
|
312
|
+
this.pool.push(this.spawnWorker());
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Spawn a worker wired for background self-healing: the claude CLI exits on
|
|
317
|
+
* its own after long idle, and replacing it lazily (at the next request)
|
|
318
|
+
* put the whole cold start — process boot + ssh reconnect + warmup turn —
|
|
319
|
+
* on the user's first post-idle completion (multi-second "autocomplete is
|
|
320
|
+
* dead after I come back" lag). Respawning the moment a worker dies keeps
|
|
321
|
+
* the pool warm through idle, so the first keystroke after a break pays the
|
|
322
|
+
* same ~1.5s as any other. Crash-loop guard: only self-respawn for workers
|
|
323
|
+
* that were healthy (survived 60s or completed a turn) — a broken binary
|
|
324
|
+
* dies instantly and would otherwise spawn-loop forever.
|
|
325
|
+
*/
|
|
326
|
+
spawnWorker() {
|
|
327
|
+
const w = new ClaudeWorker(this.opts);
|
|
328
|
+
w.onDeath = () => {
|
|
329
|
+
if (this.disposed)
|
|
330
|
+
return;
|
|
331
|
+
const wasHealthy = w.turns > 0 || Date.now() - w.bornAt > 60_000;
|
|
332
|
+
if (!wasHealthy)
|
|
333
|
+
return;
|
|
334
|
+
setTimeout(() => this.ensurePool(), 250);
|
|
335
|
+
};
|
|
336
|
+
return w;
|
|
337
|
+
}
|
|
338
|
+
async complete(prompt, { signal, onChunk, diag } = {}) {
|
|
339
|
+
if (this.disposed)
|
|
340
|
+
throw new Error("backend disposed");
|
|
341
|
+
signal?.throwIfAborted();
|
|
342
|
+
// A pooled worker can die between turns (the CLI process exits on its own
|
|
343
|
+
// in some versions). Retry once on a guaranteed-fresh worker before giving
|
|
344
|
+
// up — but never after the caller aborted or emitted partial ghost text.
|
|
345
|
+
let emitted = false;
|
|
346
|
+
const guard = (t) => {
|
|
347
|
+
if (!signal?.aborted) {
|
|
348
|
+
emitted = true;
|
|
349
|
+
onChunk?.(t);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
try {
|
|
353
|
+
return await this.attempt(prompt, signal, guard, false, diag);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
if (this.disposed || signal?.aborted || emitted || !isWorkerDeath(err))
|
|
357
|
+
throw err;
|
|
358
|
+
if (diag)
|
|
359
|
+
diag.retried = true;
|
|
360
|
+
return await this.attempt(prompt, signal, guard, true, diag);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async attempt(prompt, signal, guard, forceFresh, diag) {
|
|
364
|
+
const tAcquire = performance.now();
|
|
365
|
+
this.ensurePool();
|
|
366
|
+
if (diag) {
|
|
367
|
+
diag.transport = this.opts.transport.kind;
|
|
368
|
+
diag.poolSize = this.pool.length;
|
|
369
|
+
diag.poolBusy = this.pool.filter((w) => w.busy && !w.dead).length;
|
|
370
|
+
}
|
|
371
|
+
// Prefer a WARMED idle worker; only fall back to a still-warming one
|
|
372
|
+
// (and only then to a cold spawn). Landing on a booting worker costs the
|
|
373
|
+
// whole warmup (~5s over ssh) even when a warm sibling is idle.
|
|
374
|
+
let worker = forceFresh
|
|
375
|
+
? undefined
|
|
376
|
+
: this.pool.find((w) => !w.busy && !w.dead && w.warmedDone) ??
|
|
377
|
+
this.pool.find((w) => !w.busy && !w.dead);
|
|
378
|
+
if (!worker) {
|
|
379
|
+
worker = this.spawnWorker();
|
|
380
|
+
this.pool.push(worker);
|
|
381
|
+
if (diag)
|
|
382
|
+
diag.coldSpawn = true;
|
|
383
|
+
console.log(`[autocomplete] cold worker spawn (${this.opts.transport.kind}): pool ${this.pool.length - 1} all busy/dead`);
|
|
384
|
+
}
|
|
385
|
+
worker.busy = true;
|
|
386
|
+
if (diag) {
|
|
387
|
+
diag.workerTurn = worker.turns;
|
|
388
|
+
diag.workerHistoryChars = worker.sentChars;
|
|
389
|
+
}
|
|
390
|
+
// Always wait for the warmup turn: dispatching while it is in flight
|
|
391
|
+
// would steal its pending handler and resolve with the warmup's output.
|
|
392
|
+
await worker.warmed;
|
|
393
|
+
if (diag)
|
|
394
|
+
diag.workerWaitMs = Math.round(performance.now() - tAcquire);
|
|
395
|
+
try {
|
|
396
|
+
const full = await worker.runTurn(prompt, guard, signal);
|
|
397
|
+
signal?.throwIfAborted();
|
|
398
|
+
return full;
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
worker.turns += 1;
|
|
402
|
+
worker.sentChars += prompt.length;
|
|
403
|
+
worker.busy = false;
|
|
404
|
+
if (worker.turns >= this.opts.maxTurnsPerWorker ||
|
|
405
|
+
worker.sentChars >= this.opts.maxHistoryCharsPerWorker ||
|
|
406
|
+
worker.dead) {
|
|
407
|
+
const recycled = worker;
|
|
408
|
+
recycled.kill();
|
|
409
|
+
// Prune only THIS worker's transcript dir (matched by its own cwd
|
|
410
|
+
// token) so a concurrently-active worker's dir is never disturbed.
|
|
411
|
+
// Bounds growth over a long-lived server, not just at shutdown.
|
|
412
|
+
this.pruneTranscripts(recycled.cwdToken);
|
|
413
|
+
if (this.opts.transport.kind === "local") {
|
|
414
|
+
try {
|
|
415
|
+
rmSync(recycled.cwd, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
/* best-effort */
|
|
419
|
+
}
|
|
420
|
+
} // ssh worker dirs are remote — cleaned by recycled.kill()
|
|
421
|
+
this.ensurePool(); // warm replacement spawns in the background
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
dispose() {
|
|
426
|
+
this.disposed = true;
|
|
427
|
+
for (const w of this.pool)
|
|
428
|
+
w.kill();
|
|
429
|
+
this.pool = [];
|
|
430
|
+
this.pruneTranscripts();
|
|
431
|
+
if (this.opts.transport.kind === "ssh") {
|
|
432
|
+
// Tear down the shared ControlMaster connection.
|
|
433
|
+
if (this.opts.transport.controlPath) {
|
|
434
|
+
try {
|
|
435
|
+
spawn("ssh", ["-O", "exit", "-o", `ControlPath=${this.opts.transport.controlPath}`,
|
|
436
|
+
`${this.opts.transport.user}@${this.opts.transport.host}`], { stdio: "ignore" }).unref();
|
|
437
|
+
}
|
|
438
|
+
catch { /* best-effort */ }
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
rmSync(this.opts.workspaceDir, { recursive: true, force: true });
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
/* best-effort */
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CompletionBackend } from "../types.js";
|
|
2
|
+
import { type Transport } from "../transport.js";
|
|
3
|
+
export interface CodexBackendOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Model. Default "gpt-5.6-luna" — the fast model available with
|
|
6
|
+
* ChatGPT-subscription auth (measured ~2× faster than the retired
|
|
7
|
+
* gpt-5.4-mini on warm autocomplete requests). The *-codex-mini
|
|
8
|
+
* variants are API-key-only.
|
|
9
|
+
*/
|
|
10
|
+
model?: string;
|
|
11
|
+
/** Reasoning effort. Default "low" ("minimal" is rejected by codex's built-in tools). */
|
|
12
|
+
reasoningEffort?: string;
|
|
13
|
+
/**
|
|
14
|
+
* CODEX_HOME containing only auth.json. Strongly recommended: skips the
|
|
15
|
+
* global config's MCP servers/plugins/hooks (measured ~2s/request saved).
|
|
16
|
+
* Prepare with: mkdir -p <dir> && cp ~/.codex/auth.json <dir>/
|
|
17
|
+
*/
|
|
18
|
+
codexHome?: string;
|
|
19
|
+
/** Per-request timeout. Default 60_000 ms. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Path to the codex binary. Default "codex". */
|
|
22
|
+
binary?: string;
|
|
23
|
+
/** Working directory. Default a temp dir. */
|
|
24
|
+
workspaceDir?: string;
|
|
25
|
+
/** Where codex runs: locally (default) or over ssh on the user's machine.
|
|
26
|
+
* Over ssh, codexHome is ignored (the user's own ~/.codex/auth.json is used;
|
|
27
|
+
* --ephemeral already keeps completions trace-free). */
|
|
28
|
+
transport?: Transport;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One-shot `codex exec --json` per completion (subscription auth). Codex has
|
|
32
|
+
* no incremental token stream in exec mode, so results arrive buffered.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CodexBackend implements CompletionBackend {
|
|
35
|
+
readonly name = "codex";
|
|
36
|
+
private opts;
|
|
37
|
+
constructor(options?: CodexBackendOptions);
|
|
38
|
+
complete(prompt: string, { signal, onChunk }?: {
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
onChunk?: (t: string) => void;
|
|
41
|
+
}): Promise<string>;
|
|
42
|
+
dispose(): void;
|
|
43
|
+
}
|
|
44
|
+
/** Find the agent message in codex --json JSONL output (shape varies by version). */
|
|
45
|
+
export declare function extractAgentMessage(jsonl: string): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { planSpawn } from "../transport.js";
|
|
6
|
+
/**
|
|
7
|
+
* One-shot `codex exec --json` per completion (subscription auth). Codex has
|
|
8
|
+
* no incremental token stream in exec mode, so results arrive buffered.
|
|
9
|
+
*/
|
|
10
|
+
export class CodexBackend {
|
|
11
|
+
name = "codex";
|
|
12
|
+
opts;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.opts = {
|
|
15
|
+
model: options.model ?? "gpt-5.6-luna",
|
|
16
|
+
reasoningEffort: options.reasoningEffort ?? "low",
|
|
17
|
+
codexHome: options.codexHome,
|
|
18
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
19
|
+
binary: options.binary ?? "codex",
|
|
20
|
+
workspaceDir: options.workspaceDir ?? join(tmpdir(), "nebula-autocomplete-ws"),
|
|
21
|
+
transport: options.transport ?? { kind: "local" },
|
|
22
|
+
};
|
|
23
|
+
if (this.opts.transport.kind === "local")
|
|
24
|
+
mkdirSync(this.opts.workspaceDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
complete(prompt, { signal, onChunk } = {}) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
if (signal?.aborted)
|
|
29
|
+
return reject(signal.reason ?? new Error("aborted"));
|
|
30
|
+
const argv = [
|
|
31
|
+
"exec", "--json",
|
|
32
|
+
"--skip-git-repo-check",
|
|
33
|
+
"--sandbox", "read-only",
|
|
34
|
+
"--color", "never",
|
|
35
|
+
// Completions are throwaway: don't persist rollout/session files or
|
|
36
|
+
// prompt history — at autocomplete frequency they pollute CODEX_HOME.
|
|
37
|
+
"--ephemeral",
|
|
38
|
+
"-c", 'history.persistence="none"',
|
|
39
|
+
// Also skip the curated-plugins sync, which otherwise clones tens of
|
|
40
|
+
// MB into CODEX_HOME/.tmp on every exec.
|
|
41
|
+
"-c", "features.plugins=false",
|
|
42
|
+
"-m", this.opts.model,
|
|
43
|
+
"-c", `model_reasoning_effort="${this.opts.reasoningEffort}"`,
|
|
44
|
+
prompt,
|
|
45
|
+
];
|
|
46
|
+
// Local: honor codexHome (skips global config's MCP/plugins). Over ssh,
|
|
47
|
+
// use the user's own ~/.codex on their machine (auth lives there).
|
|
48
|
+
const envOverrides = this.opts.transport.kind === "local" && this.opts.codexHome
|
|
49
|
+
? { CODEX_HOME: this.opts.codexHome }
|
|
50
|
+
: {};
|
|
51
|
+
const cwd = this.opts.transport.kind === "ssh" ? "/tmp/nebula-autocomplete-ws" : this.opts.workspaceDir;
|
|
52
|
+
const plan = planSpawn(this.opts.transport, this.opts.binary, argv, envOverrides, cwd);
|
|
53
|
+
const proc = spawn(plan.command, plan.args, { ...plan.options, stdio: ["ignore", "pipe", "pipe"] });
|
|
54
|
+
let out = "";
|
|
55
|
+
let err = "";
|
|
56
|
+
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
57
|
+
proc.stderr.on("data", (d) => (err += d.toString()));
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
proc.kill();
|
|
60
|
+
reject(new Error("codex timed out"));
|
|
61
|
+
}, this.opts.timeoutMs);
|
|
62
|
+
const onAbort = () => {
|
|
63
|
+
proc.kill();
|
|
64
|
+
reject(signal?.reason ?? new Error("aborted"));
|
|
65
|
+
};
|
|
66
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
proc.on("error", (e) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
reject(e);
|
|
70
|
+
});
|
|
71
|
+
proc.on("exit", (code) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
signal?.removeEventListener("abort", onAbort);
|
|
74
|
+
if (signal?.aborted)
|
|
75
|
+
return;
|
|
76
|
+
if (code !== 0) {
|
|
77
|
+
return reject(new Error(`codex rc=${code}: ${err.slice(-300)}`));
|
|
78
|
+
}
|
|
79
|
+
const text = extractAgentMessage(out);
|
|
80
|
+
if (text)
|
|
81
|
+
onChunk?.(text);
|
|
82
|
+
resolve(text);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
dispose() {
|
|
87
|
+
/* one-shot processes; nothing persistent to release */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Find the agent message in codex --json JSONL output (shape varies by version). */
|
|
91
|
+
export function extractAgentMessage(jsonl) {
|
|
92
|
+
let text = "";
|
|
93
|
+
for (const line of jsonl.split("\n")) {
|
|
94
|
+
let ev;
|
|
95
|
+
try {
|
|
96
|
+
ev = JSON.parse(line);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const item = ev.item ?? ev.msg ?? ev;
|
|
102
|
+
const kind = String(item.item_type ?? item.type ?? "");
|
|
103
|
+
if (kind.includes("agent_message") && typeof item.text === "string") {
|
|
104
|
+
text = item.text;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CompletionRequest, CompletionResult } from "../types.js";
|
|
2
|
+
export interface FetchCompletionOptions {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
onChunk?: (text: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export type CompletionFetcher = (req: CompletionRequest, opts?: FetchCompletionOptions) => Promise<CompletionResult>;
|
|
7
|
+
/**
|
|
8
|
+
* Browser-side client for the SSE endpoint registered by
|
|
9
|
+
* registerAutocompleteRoute. Streams chunks via onChunk and resolves with the
|
|
10
|
+
* final CompletionResult.
|
|
11
|
+
*/
|
|
12
|
+
export interface CompletionFetcherOptions {
|
|
13
|
+
/** Extra headers (e.g. auth) merged into every request. */
|
|
14
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
15
|
+
}
|
|
16
|
+
export declare function createCompletionFetcher(endpoint: string, options?: CompletionFetcherOptions): CompletionFetcher;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function createCompletionFetcher(endpoint, options = {}) {
|
|
2
|
+
return async (req, { signal, onChunk } = {}) => {
|
|
3
|
+
const extra = typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
|
|
4
|
+
const resp = await fetch(endpoint, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "content-type": "application/json", ...extra },
|
|
7
|
+
body: JSON.stringify(req),
|
|
8
|
+
signal,
|
|
9
|
+
});
|
|
10
|
+
if (!resp.ok || !resp.body) {
|
|
11
|
+
throw new Error(`autocomplete request failed: HTTP ${resp.status}`);
|
|
12
|
+
}
|
|
13
|
+
const reader = resp.body.getReader();
|
|
14
|
+
const decoder = new TextDecoder();
|
|
15
|
+
let buf = "";
|
|
16
|
+
let done = null;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { done: eof, value } = await reader.read();
|
|
19
|
+
if (eof)
|
|
20
|
+
break;
|
|
21
|
+
buf += decoder.decode(value, { stream: true });
|
|
22
|
+
let i;
|
|
23
|
+
while ((i = buf.indexOf("\n\n")) !== -1) {
|
|
24
|
+
const line = buf.slice(0, i).replace(/^data: /, "");
|
|
25
|
+
buf = buf.slice(i + 2);
|
|
26
|
+
if (!line.trim())
|
|
27
|
+
continue;
|
|
28
|
+
const ev = JSON.parse(line);
|
|
29
|
+
if (ev.type === "chunk")
|
|
30
|
+
onChunk?.(ev.text);
|
|
31
|
+
else if (ev.type === "done")
|
|
32
|
+
done = ev;
|
|
33
|
+
else if (ev.type === "error")
|
|
34
|
+
throw new Error(ev.message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!done)
|
|
38
|
+
throw new Error("stream ended without a done event");
|
|
39
|
+
return done;
|
|
40
|
+
};
|
|
41
|
+
}
|