nebula-notebook 0.2.0 → 0.2.2
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 +35 -20
- package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
- package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
- package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
- 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 +35 -2
- package/node-server/dist/cluster/kernel-proxy.js +19 -3
- package/node-server/dist/cluster/server-registry.d.ts +9 -0
- package/node-server/dist/cluster/server-registry.js +26 -0
- 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 +31 -1
- package/node-server/dist/fs/fs-service.js +160 -28
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/index.js +71 -4
- package/node-server/dist/kernel/kernel-service.d.ts +17 -0
- package/node-server/dist/kernel/kernel-service.js +72 -4
- 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 +10 -0
- package/node-server/dist/notebook/headless-handler.js +55 -5
- package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
- package/node-server/dist/notebook/undoRedoManager.js +39 -16
- 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/fs.js +21 -4
- package/node-server/dist/routes/kernel.js +45 -2
- package/node-server/dist/routes/notebook.js +8 -2
- 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 +20 -0
- package/node-server/dist/scheduler/allocation-service.js +124 -8
- package/node-server/dist/scheduler/mock-scheduler.js +7 -3
- package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
- package/node-server/dist/scheduler/types.d.ts +3 -2
- 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 +130 -19
- package/node-server/dist/terminal/server.js +112 -21
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/package.json +1 -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 +17 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-7-YBurka.js +0 -716
- package/dist/assets/index-CFBUnxSZ.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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a Claude Code project-transcript directory name belong to a workspace
|
|
3
|
+
* carrying `token`? Claude munges the cwd into the dir name by replacing
|
|
4
|
+
* non-alphanumerics, so we compare alphanumeric-only forms — munge-algorithm
|
|
5
|
+
* independent, and a UUID token cannot collide with a real project path.
|
|
6
|
+
*/
|
|
7
|
+
export declare function transcriptDirMatchesToken(dirName: string, token: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Extract the tag-wrapped completion, whitespace preserved VERBATIM.
|
|
10
|
+
*
|
|
11
|
+
* Why tags: models structurally avoid beginning a message with whitespace, so
|
|
12
|
+
* a completion that must START with "\n" (cursor at the end of a finished
|
|
13
|
+
* comment like `# fibonacci`) loses its newline when asked for raw output —
|
|
14
|
+
* verified empirically; no prompt wording fixed it. Inside a tag pair the
|
|
15
|
+
* leading newline survives generation. Returns null when the reply isn't
|
|
16
|
+
* tag-wrapped (fall back to the fence-stripping pipeline).
|
|
17
|
+
*/
|
|
18
|
+
export declare function extractCompletionTag(text: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Streaming variant: wraps an onChunk callback so tag delimiters never leak
|
|
21
|
+
* into streamed ghost text, while the inner text (leading whitespace included)
|
|
22
|
+
* streams through as it arrives. If no opening tag shows up within the first
|
|
23
|
+
* 64 chars, assumes an untagged reply and passes everything through.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createTagStreamFilter(emit: (t: string) => void): (t: string) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Strip a wrapping markdown code fence, if present. Leading whitespace of an
|
|
28
|
+
* UNFENCED completion is preserved verbatim — it is often meaningful: with
|
|
29
|
+
* the cursor at the end of `# fibonacci` the completion must START with "\n"
|
|
30
|
+
* to put code on the next line, and indentation after `if x:` matters too.
|
|
31
|
+
* (A blanket .trim() here silently glued completions onto comments.)
|
|
32
|
+
*/
|
|
33
|
+
export declare function stripFences(text: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Streaming variant of trimPrefixOverlap: emits the completion as it streams,
|
|
36
|
+
* holding back ONLY while the text so far could still be the beginning of a
|
|
37
|
+
* longer prefix-echo. Without this the ghost text visibly "snaps" when the
|
|
38
|
+
* done event applies the trim that streaming skipped (echo shown, then
|
|
39
|
+
* removed). For non-echo completions the hold is typically zero characters.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createPrefixTrimStreamFilter(prefix: string, emit: (t: string) => void): (t: string) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Models sometimes echo the tail of the prefix (e.g. the current line) before
|
|
44
|
+
* continuing. Trim the longest suffix-of-prefix that the completion starts
|
|
45
|
+
* with, so accepting the suggestion never duplicates typed text.
|
|
46
|
+
*/
|
|
47
|
+
export declare function trimPrefixOverlap(prefix: string, completion: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Models also often close constructs that the suffix already closes (quotes,
|
|
50
|
+
* brackets, parens). Trim the longest head-of-suffix that the completion ends
|
|
51
|
+
* with, so accepted text composes with what follows the cursor.
|
|
52
|
+
*/
|
|
53
|
+
export declare function trimSuffixOverlap(suffix: string, completion: string): string;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a Claude Code project-transcript directory name belong to a workspace
|
|
3
|
+
* carrying `token`? Claude munges the cwd into the dir name by replacing
|
|
4
|
+
* non-alphanumerics, so we compare alphanumeric-only forms — munge-algorithm
|
|
5
|
+
* independent, and a UUID token cannot collide with a real project path.
|
|
6
|
+
*/
|
|
7
|
+
export function transcriptDirMatchesToken(dirName, token) {
|
|
8
|
+
const norm = (s) => s.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
9
|
+
const needle = norm(token);
|
|
10
|
+
return needle.length > 0 && norm(dirName).includes(needle);
|
|
11
|
+
}
|
|
12
|
+
const OPEN_TAG = "<completion>";
|
|
13
|
+
const CLOSE_TAG = "</completion>";
|
|
14
|
+
/**
|
|
15
|
+
* Extract the tag-wrapped completion, whitespace preserved VERBATIM.
|
|
16
|
+
*
|
|
17
|
+
* Why tags: models structurally avoid beginning a message with whitespace, so
|
|
18
|
+
* a completion that must START with "\n" (cursor at the end of a finished
|
|
19
|
+
* comment like `# fibonacci`) loses its newline when asked for raw output —
|
|
20
|
+
* verified empirically; no prompt wording fixed it. Inside a tag pair the
|
|
21
|
+
* leading newline survives generation. Returns null when the reply isn't
|
|
22
|
+
* tag-wrapped (fall back to the fence-stripping pipeline).
|
|
23
|
+
*/
|
|
24
|
+
export function extractCompletionTag(text) {
|
|
25
|
+
const start = text.indexOf(OPEN_TAG);
|
|
26
|
+
if (start === -1)
|
|
27
|
+
return null;
|
|
28
|
+
const inner = text.slice(start + OPEN_TAG.length);
|
|
29
|
+
const end = inner.indexOf(CLOSE_TAG);
|
|
30
|
+
return end === -1 ? inner : inner.slice(0, end);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Streaming variant: wraps an onChunk callback so tag delimiters never leak
|
|
34
|
+
* into streamed ghost text, while the inner text (leading whitespace included)
|
|
35
|
+
* streams through as it arrives. If no opening tag shows up within the first
|
|
36
|
+
* 64 chars, assumes an untagged reply and passes everything through.
|
|
37
|
+
*/
|
|
38
|
+
export function createTagStreamFilter(emit) {
|
|
39
|
+
let state = "seeking";
|
|
40
|
+
let buf = "";
|
|
41
|
+
return (chunk) => {
|
|
42
|
+
if (state === "done")
|
|
43
|
+
return;
|
|
44
|
+
if (state === "passthrough")
|
|
45
|
+
return emit(chunk);
|
|
46
|
+
buf += chunk;
|
|
47
|
+
if (state === "seeking") {
|
|
48
|
+
const at = buf.indexOf(OPEN_TAG);
|
|
49
|
+
if (at !== -1) {
|
|
50
|
+
state = "inside";
|
|
51
|
+
buf = buf.slice(at + OPEN_TAG.length);
|
|
52
|
+
}
|
|
53
|
+
else if (buf.length > 64 && !OPEN_TAG.startsWith(buf.slice(-OPEN_TAG.length))) {
|
|
54
|
+
state = "passthrough";
|
|
55
|
+
emit(buf);
|
|
56
|
+
buf = "";
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
return; // keep buffering — the tag may still be arriving
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// inside: emit everything except a possible partial close tag at the tail
|
|
64
|
+
const close = buf.indexOf(CLOSE_TAG);
|
|
65
|
+
if (close !== -1) {
|
|
66
|
+
if (close > 0)
|
|
67
|
+
emit(buf.slice(0, close));
|
|
68
|
+
state = "done";
|
|
69
|
+
buf = "";
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Hold back the longest suffix that could be the start of the close tag.
|
|
73
|
+
let hold = 0;
|
|
74
|
+
for (let n = Math.min(CLOSE_TAG.length - 1, buf.length); n > 0; n--) {
|
|
75
|
+
if (CLOSE_TAG.startsWith(buf.slice(-n))) {
|
|
76
|
+
hold = n;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const emittable = buf.slice(0, buf.length - hold);
|
|
81
|
+
if (emittable)
|
|
82
|
+
emit(emittable);
|
|
83
|
+
buf = buf.slice(buf.length - hold);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Strip a wrapping markdown code fence, if present. Leading whitespace of an
|
|
88
|
+
* UNFENCED completion is preserved verbatim — it is often meaningful: with
|
|
89
|
+
* the cursor at the end of `# fibonacci` the completion must START with "\n"
|
|
90
|
+
* to put code on the next line, and indentation after `if x:` matters too.
|
|
91
|
+
* (A blanket .trim() here silently glued completions onto comments.)
|
|
92
|
+
*/
|
|
93
|
+
export function stripFences(text) {
|
|
94
|
+
const t = text.trim();
|
|
95
|
+
const open = t.match(/^```[a-zA-Z0-9_-]*\n?/);
|
|
96
|
+
if (open) {
|
|
97
|
+
return t.slice(open[0].length).replace(/\n?```\s*$/, "");
|
|
98
|
+
}
|
|
99
|
+
// No fence: keep leading whitespace, drop only trailing whitespace.
|
|
100
|
+
return text.replace(/\s+$/, "");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Streaming variant of trimPrefixOverlap: emits the completion as it streams,
|
|
104
|
+
* holding back ONLY while the text so far could still be the beginning of a
|
|
105
|
+
* longer prefix-echo. Without this the ghost text visibly "snaps" when the
|
|
106
|
+
* done event applies the trim that streaming skipped (echo shown, then
|
|
107
|
+
* removed). For non-echo completions the hold is typically zero characters.
|
|
108
|
+
*/
|
|
109
|
+
export function createPrefixTrimStreamFilter(prefix, emit) {
|
|
110
|
+
const tail = prefix.slice(-200);
|
|
111
|
+
let buf = "";
|
|
112
|
+
let settled = false;
|
|
113
|
+
return (chunk) => {
|
|
114
|
+
if (settled)
|
|
115
|
+
return emit(chunk);
|
|
116
|
+
buf += chunk;
|
|
117
|
+
// Could buf still grow into a LONGER echo of the prefix tail?
|
|
118
|
+
let couldExtend = false;
|
|
119
|
+
for (let n = buf.length + 1; n <= tail.length; n++) {
|
|
120
|
+
if (tail.slice(-n).startsWith(buf)) {
|
|
121
|
+
couldExtend = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (couldExtend)
|
|
126
|
+
return; // hold — echo still possible
|
|
127
|
+
// Settle: strip the longest confirmed echo, stream the rest.
|
|
128
|
+
let confirmed = 0;
|
|
129
|
+
for (let n = Math.min(tail.length, buf.length); n > 0; n--) {
|
|
130
|
+
if (buf.startsWith(tail.slice(-n))) {
|
|
131
|
+
confirmed = n;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
settled = true;
|
|
136
|
+
const out = buf.slice(confirmed);
|
|
137
|
+
buf = "";
|
|
138
|
+
if (out)
|
|
139
|
+
emit(out);
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Models sometimes echo the tail of the prefix (e.g. the current line) before
|
|
144
|
+
* continuing. Trim the longest suffix-of-prefix that the completion starts
|
|
145
|
+
* with, so accepting the suggestion never duplicates typed text.
|
|
146
|
+
*/
|
|
147
|
+
export function trimPrefixOverlap(prefix, completion) {
|
|
148
|
+
const tail = prefix.slice(-200);
|
|
149
|
+
for (let n = Math.min(tail.length, completion.length); n > 0; n--) {
|
|
150
|
+
if (completion.startsWith(tail.slice(-n))) {
|
|
151
|
+
return completion.slice(n);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return completion;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Models also often close constructs that the suffix already closes (quotes,
|
|
158
|
+
* brackets, parens). Trim the longest head-of-suffix that the completion ends
|
|
159
|
+
* with, so accepted text composes with what follows the cursor.
|
|
160
|
+
*/
|
|
161
|
+
export function trimSuffixOverlap(suffix, completion) {
|
|
162
|
+
const head = suffix.slice(0, 200);
|
|
163
|
+
for (let n = Math.min(head.length, completion.length); n > 0; n--) {
|
|
164
|
+
if (completion.endsWith(head.slice(0, n))) {
|
|
165
|
+
return completion.slice(0, completion.length - n);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return completion;
|
|
169
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { AutocompleteEngine } from "./core/engine.js";
|
|
2
|
+
export { ClaudeBackend, type ClaudeBackendOptions } from "./backends/claude.js";
|
|
3
|
+
export { CodexBackend, type CodexBackendOptions } from "./backends/codex.js";
|
|
4
|
+
export { buildPrompt, cacheKey } from "./core/prompt.js";
|
|
5
|
+
export type { Transport, SshTransport } from "./transport.js";
|
|
6
|
+
export { stripFences, trimPrefixOverlap, trimSuffixOverlap, transcriptDirMatchesToken, } from "./core/text.js";
|
|
7
|
+
export type { CompletionBackend, CompletionRequest, CompletionResult, CompleteOptions, EngineOptions, NotebookCellContext, } from "./types.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AutocompleteEngine } from "./core/engine.js";
|
|
2
|
+
export { ClaudeBackend } from "./backends/claude.js";
|
|
3
|
+
export { CodexBackend } from "./backends/codex.js";
|
|
4
|
+
export { buildPrompt, cacheKey } from "./core/prompt.js";
|
|
5
|
+
export { stripFences, trimPrefixOverlap, trimSuffixOverlap, transcriptDirMatchesToken, } from "./core/text.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AutocompleteEngine } from "../core/engine.js";
|
|
2
|
+
import type { CompletionRequest } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal structural types so this plugin works with Fastify 4/5 without a
|
|
5
|
+
* hard dependency on the fastify package.
|
|
6
|
+
*/
|
|
7
|
+
interface FastifyLikeRequest {
|
|
8
|
+
body: unknown;
|
|
9
|
+
}
|
|
10
|
+
interface FastifyLikeReply {
|
|
11
|
+
raw: {
|
|
12
|
+
writeHead(status: number, headers: Record<string, string>): void;
|
|
13
|
+
write(chunk: string): void;
|
|
14
|
+
end(): void;
|
|
15
|
+
on(event: "close", cb: () => void): void;
|
|
16
|
+
writableEnded: boolean;
|
|
17
|
+
};
|
|
18
|
+
hijack?: () => void;
|
|
19
|
+
}
|
|
20
|
+
interface FastifyLikeInstance {
|
|
21
|
+
post(path: string, handler: (req: FastifyLikeRequest, reply: FastifyLikeReply) => Promise<void>): void;
|
|
22
|
+
}
|
|
23
|
+
export interface AutocompleteRouteOptions {
|
|
24
|
+
/** Route path. Default "/autocomplete". */
|
|
25
|
+
path?: string;
|
|
26
|
+
}
|
|
27
|
+
export type EngineResolver = (req: CompletionRequest) => AutocompleteEngine;
|
|
28
|
+
/**
|
|
29
|
+
* Register `POST <path>` streaming completions as SSE.
|
|
30
|
+
*
|
|
31
|
+
* Request body: CompletionRequest (JSON).
|
|
32
|
+
* Response events: {type:"chunk",text} then {type:"done",...CompletionResult},
|
|
33
|
+
* or {type:"error",message}.
|
|
34
|
+
*
|
|
35
|
+
* Usage in nebula-notebook's node-server:
|
|
36
|
+
* const engine = new AutocompleteEngine({ backend: new ClaudeBackend() });
|
|
37
|
+
* app.register(async (f) => registerAutocompleteRoute(f, engine), { prefix: "/api" });
|
|
38
|
+
*/
|
|
39
|
+
export declare function registerAutocompleteRoute(fastify: FastifyLikeInstance, engine: AutocompleteEngine | EngineResolver, options?: AutocompleteRouteOptions): void;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register `POST <path>` streaming completions as SSE.
|
|
3
|
+
*
|
|
4
|
+
* Request body: CompletionRequest (JSON).
|
|
5
|
+
* Response events: {type:"chunk",text} then {type:"done",...CompletionResult},
|
|
6
|
+
* or {type:"error",message}.
|
|
7
|
+
*
|
|
8
|
+
* Usage in nebula-notebook's node-server:
|
|
9
|
+
* const engine = new AutocompleteEngine({ backend: new ClaudeBackend() });
|
|
10
|
+
* app.register(async (f) => registerAutocompleteRoute(f, engine), { prefix: "/api" });
|
|
11
|
+
*/
|
|
12
|
+
export function registerAutocompleteRoute(fastify, engine, options = {}) {
|
|
13
|
+
const path = options.path ?? "/autocomplete";
|
|
14
|
+
const resolve = typeof engine === "function" ? engine : () => engine;
|
|
15
|
+
fastify.post(path, async (req, reply) => {
|
|
16
|
+
reply.hijack?.(); // take over the raw socket for SSE (Fastify 4/5)
|
|
17
|
+
const res = reply.raw;
|
|
18
|
+
res.writeHead(200, {
|
|
19
|
+
"content-type": "text/event-stream",
|
|
20
|
+
"cache-control": "no-cache",
|
|
21
|
+
connection: "keep-alive",
|
|
22
|
+
});
|
|
23
|
+
const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
24
|
+
const abort = new AbortController();
|
|
25
|
+
// Detect client disconnect via the RESPONSE stream closing before we
|
|
26
|
+
// ended it. (The request's "close" fires as soon as its body is fully
|
|
27
|
+
// consumed on modern Node — listening there aborts every request.)
|
|
28
|
+
res.on("close", () => {
|
|
29
|
+
if (!res.writableEnded)
|
|
30
|
+
abort.abort(new Error("client disconnected"));
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const body = req.body;
|
|
34
|
+
if (!body || typeof body.prefix !== "string") {
|
|
35
|
+
send({ type: "error", message: "prefix (string) is required" });
|
|
36
|
+
res.end();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const result = await resolve(body).complete(body, {
|
|
40
|
+
signal: abort.signal,
|
|
41
|
+
onChunk: (text) => send({ type: "chunk", text }),
|
|
42
|
+
});
|
|
43
|
+
send({ type: "done", ...result });
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (!abort.signal.aborted) {
|
|
47
|
+
send({ type: "error", message: e instanceof Error ? e.message : String(e) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
res.end();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process transport for backends: run a provider CLI either LOCALLY (same host
|
|
3
|
+
* as this process) or OVER SSH on a remote host.
|
|
4
|
+
*
|
|
5
|
+
* The over-ssh case powers "remote Nebula, local agent": the Nebula server runs
|
|
6
|
+
* on a cluster, but autocomplete should drive the user's OWN claude/codex on
|
|
7
|
+
* their laptop — reached over the reverse SSH tunnel the user already has. The
|
|
8
|
+
* CLI's stream-json stdio flows transparently over the ssh channel; a persistent
|
|
9
|
+
* warm worker keeps one ssh connection open (handshake paid once, not per turn),
|
|
10
|
+
* and ControlMaster makes any reconnect ~free. See the validated latency probe:
|
|
11
|
+
* per-turn overhead ≈ one network RTT (~50ms), negligible vs ~2s model latency.
|
|
12
|
+
*/
|
|
13
|
+
export interface SshTransport {
|
|
14
|
+
kind: 'ssh';
|
|
15
|
+
/** Host to ssh to — typically 'localhost' (the reverse-tunnel endpoint). */
|
|
16
|
+
host: string;
|
|
17
|
+
/** Reverse-tunnel port on this (server) host that forwards to the user's sshd. */
|
|
18
|
+
port: number;
|
|
19
|
+
/** Username on the user's machine. */
|
|
20
|
+
user: string;
|
|
21
|
+
/** Absolute path to the provider binary ON THE USER'S machine (PATH isn't set
|
|
22
|
+
* for a non-interactive ssh, so this must be absolute). */
|
|
23
|
+
remoteBin: string;
|
|
24
|
+
/** ControlMaster socket path, so warm workers + reconnects reuse one connection. */
|
|
25
|
+
controlPath?: string;
|
|
26
|
+
/** Working dir on the remote side (created if absent). Keep it empty of any
|
|
27
|
+
* CLAUDE.md so the agent harness stays minimal. */
|
|
28
|
+
remoteCwd?: string;
|
|
29
|
+
}
|
|
30
|
+
export type Transport = {
|
|
31
|
+
kind: 'local';
|
|
32
|
+
} | SshTransport;
|
|
33
|
+
export interface SpawnPlan {
|
|
34
|
+
command: string;
|
|
35
|
+
args: string[];
|
|
36
|
+
/** Options to pass to child_process.spawn (env/cwd). For ssh these are empty
|
|
37
|
+
* because env/cwd are embedded in the remote command line. */
|
|
38
|
+
options: {
|
|
39
|
+
env?: NodeJS.ProcessEnv;
|
|
40
|
+
cwd?: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** POSIX single-quote a string so it survives the remote shell verbatim. */
|
|
44
|
+
export declare function shellQuote(s: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Plan how to spawn `bin argv` with `env` in `cwd`, honoring the transport.
|
|
47
|
+
* - local: spawn the binary directly; env/cwd go to spawn().
|
|
48
|
+
* - ssh: spawn `ssh …host <remoteCmd>` where remoteCmd re-establishes cwd + env
|
|
49
|
+
* on the far side (via `cd` and `env K=V …`) and execs the binary. `cwd`/`env`
|
|
50
|
+
* passed here are interpreted on the REMOTE side.
|
|
51
|
+
*/
|
|
52
|
+
export declare function planSpawn(transport: Transport, bin: string, argv: string[], env: Record<string, string>, cwd: string): SpawnPlan;
|
|
53
|
+
/** Best-effort remote cleanup (e.g. wipe an ephemeral config dir) over the same
|
|
54
|
+
* ssh path. Returns the command+args for a fire-and-forget spawn. */
|
|
55
|
+
export declare function planRemoteCleanup(transport: SshTransport, remotePath: string): SpawnPlan;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process transport for backends: run a provider CLI either LOCALLY (same host
|
|
3
|
+
* as this process) or OVER SSH on a remote host.
|
|
4
|
+
*
|
|
5
|
+
* The over-ssh case powers "remote Nebula, local agent": the Nebula server runs
|
|
6
|
+
* on a cluster, but autocomplete should drive the user's OWN claude/codex on
|
|
7
|
+
* their laptop — reached over the reverse SSH tunnel the user already has. The
|
|
8
|
+
* CLI's stream-json stdio flows transparently over the ssh channel; a persistent
|
|
9
|
+
* warm worker keeps one ssh connection open (handshake paid once, not per turn),
|
|
10
|
+
* and ControlMaster makes any reconnect ~free. See the validated latency probe:
|
|
11
|
+
* per-turn overhead ≈ one network RTT (~50ms), negligible vs ~2s model latency.
|
|
12
|
+
*/
|
|
13
|
+
/** POSIX single-quote a string so it survives the remote shell verbatim. */
|
|
14
|
+
export function shellQuote(s) {
|
|
15
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
16
|
+
}
|
|
17
|
+
const SSH_BASE_OPTS = [
|
|
18
|
+
'-o', 'ProxyCommand=none',
|
|
19
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
20
|
+
'-o', 'BatchMode=yes',
|
|
21
|
+
'-o', 'ServerAliveInterval=30',
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Plan how to spawn `bin argv` with `env` in `cwd`, honoring the transport.
|
|
25
|
+
* - local: spawn the binary directly; env/cwd go to spawn().
|
|
26
|
+
* - ssh: spawn `ssh …host <remoteCmd>` where remoteCmd re-establishes cwd + env
|
|
27
|
+
* on the far side (via `cd` and `env K=V …`) and execs the binary. `cwd`/`env`
|
|
28
|
+
* passed here are interpreted on the REMOTE side.
|
|
29
|
+
*/
|
|
30
|
+
export function planSpawn(transport, bin, argv, env, cwd) {
|
|
31
|
+
if (transport.kind === 'local') {
|
|
32
|
+
return { command: bin, args: argv, options: { env: { ...process.env, ...env }, cwd } };
|
|
33
|
+
}
|
|
34
|
+
const sshOpts = [...SSH_BASE_OPTS, '-p', String(transport.port)];
|
|
35
|
+
if (transport.controlPath) {
|
|
36
|
+
// ControlPersist 1h (was 120s): the master handshake is the expensive part
|
|
37
|
+
// over the reverse tunnel, and a 2-minute persist meant any idle gap made
|
|
38
|
+
// the next worker spawn pay it again — part of the "autocomplete lags
|
|
39
|
+
// after I come back" cold start. An idle master on a localhost tunnel
|
|
40
|
+
// costs one file descriptor; keep it warm for the whole work session.
|
|
41
|
+
sshOpts.push('-o', 'ControlMaster=auto', '-o', `ControlPath=${transport.controlPath}`, '-o', 'ControlPersist=3600');
|
|
42
|
+
}
|
|
43
|
+
const remoteCwd = transport.remoteCwd ?? cwd;
|
|
44
|
+
const envPrefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(' ');
|
|
45
|
+
// mkdir+cd so the remote agent runs in a clean, CLAUDE.md-free dir; `exec env …`
|
|
46
|
+
// sets the child env on the far side (the local process.env doesn't reach it).
|
|
47
|
+
const remoteCmd = `mkdir -p ${shellQuote(remoteCwd)} 2>/dev/null; cd ${shellQuote(remoteCwd)} 2>/dev/null; ` +
|
|
48
|
+
`exec env ${envPrefix} ${shellQuote(transport.remoteBin)} ${argv.map(shellQuote).join(' ')}`;
|
|
49
|
+
return {
|
|
50
|
+
command: 'ssh',
|
|
51
|
+
args: [...sshOpts, `${transport.user}@${transport.host}`, remoteCmd],
|
|
52
|
+
options: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Best-effort remote cleanup (e.g. wipe an ephemeral config dir) over the same
|
|
56
|
+
* ssh path. Returns the command+args for a fire-and-forget spawn. */
|
|
57
|
+
export function planRemoteCleanup(transport, remotePath) {
|
|
58
|
+
const sshOpts = [...SSH_BASE_OPTS, '-p', String(transport.port)];
|
|
59
|
+
if (transport.controlPath)
|
|
60
|
+
sshOpts.push('-o', `ControlPath=${transport.controlPath}`);
|
|
61
|
+
return {
|
|
62
|
+
command: 'ssh',
|
|
63
|
+
args: [...sshOpts, `${transport.user}@${transport.host}`, `rm -rf ${shellQuote(remotePath)}`],
|
|
64
|
+
options: {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export interface NotebookCellContext {
|
|
2
|
+
type: "code" | "markdown";
|
|
3
|
+
content: string;
|
|
4
|
+
}
|
|
5
|
+
export interface CompletionRequest {
|
|
6
|
+
/** Text before the cursor in the active cell. Required. */
|
|
7
|
+
prefix: string;
|
|
8
|
+
/** Text after the cursor in the active cell. */
|
|
9
|
+
suffix?: string;
|
|
10
|
+
/** Explicit language, if known. Usually omitted — prefer the hints below and
|
|
11
|
+
* let the model infer the language from the code + kernel/filename. */
|
|
12
|
+
language?: string;
|
|
13
|
+
/** Active kernel name/spec (e.g. "python3", "ir", "julia-1.9"). A hint. */
|
|
14
|
+
kernelName?: string;
|
|
15
|
+
/** Notebook filename (e.g. "analysis.ipynb"). A hint. */
|
|
16
|
+
filename?: string;
|
|
17
|
+
/** All notebook cells, in order, for cross-cell context. */
|
|
18
|
+
cells?: NotebookCellContext[];
|
|
19
|
+
/** Index of the active cell within `cells`. */
|
|
20
|
+
activeCellIndex?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Requests sharing a sessionKey supersede each other: issuing a new one
|
|
23
|
+
* aborts the previous in-flight request. Use the cell id.
|
|
24
|
+
*/
|
|
25
|
+
sessionKey?: string;
|
|
26
|
+
/** Model override (e.g. "haiku" | "sonnet"). Engines are pooled per model. */
|
|
27
|
+
model?: string;
|
|
28
|
+
/** Per-request cross-cell context budget in chars (clamped server-side).
|
|
29
|
+
* More context grounds suggestions in the notebook (fewer hallucinated
|
|
30
|
+
* names) at the cost of a bigger prompt. */
|
|
31
|
+
contextBudget?: number;
|
|
32
|
+
/** Per-request cap on suggested lines (clamped server-side). */
|
|
33
|
+
maxLines?: number;
|
|
34
|
+
/** Backend selector, for servers hosting more than one engine. */
|
|
35
|
+
backend?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface CompletionResult {
|
|
38
|
+
text: string;
|
|
39
|
+
backend: string;
|
|
40
|
+
fromCache: boolean;
|
|
41
|
+
ttfbMs: number;
|
|
42
|
+
totalMs: number;
|
|
43
|
+
/** Per-request engine/worker diagnostics (see CompletionDiag). */
|
|
44
|
+
diag?: CompletionDiag;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Where a completion's latency went — filled by the backend, forwarded in the
|
|
48
|
+
* SSE done event so the browser console can show the breakdown.
|
|
49
|
+
*/
|
|
50
|
+
export interface CompletionDiag {
|
|
51
|
+
/** Transport the worker ran on. */
|
|
52
|
+
transport?: string;
|
|
53
|
+
/** ms spent queued behind an earlier in-flight turn for the same session
|
|
54
|
+
* (single-flight per cell — typing bursts wait for the previous turn
|
|
55
|
+
* instead of fanning out across workers; see engine.complete). */
|
|
56
|
+
queueWaitMs?: number;
|
|
57
|
+
/** ms from request start until the turn was dispatched to a warm worker
|
|
58
|
+
* (pool scan + possible spawn + warmup wait). The silent part of TTFB. */
|
|
59
|
+
workerWaitMs?: number;
|
|
60
|
+
/** A fresh worker process had to be spawned for this request. */
|
|
61
|
+
coldSpawn?: boolean;
|
|
62
|
+
/** The turn was retried on a fresh worker after the first one died. */
|
|
63
|
+
retried?: boolean;
|
|
64
|
+
/** How many turns this worker had already served (conversation history
|
|
65
|
+
* grows with each turn, inflating time-to-first-byte). */
|
|
66
|
+
workerTurn?: number;
|
|
67
|
+
/** Accumulated prompt chars in this worker's history before this turn —
|
|
68
|
+
* the actual TTFB-drag driver (recycled past maxHistoryCharsPerWorker). */
|
|
69
|
+
workerHistoryChars?: number;
|
|
70
|
+
/** Busy workers / pool size at request time (busy=size → cold spawn). */
|
|
71
|
+
poolBusy?: number;
|
|
72
|
+
poolSize?: number;
|
|
73
|
+
/** Characters in the assembled prompt. */
|
|
74
|
+
promptChars?: number;
|
|
75
|
+
/** Characters in the RAW model reply, before tag/fence/overlap trimming —
|
|
76
|
+
* rawChars>0 with empty final text means post-processing removed it. */
|
|
77
|
+
rawChars?: number;
|
|
78
|
+
}
|
|
79
|
+
export interface CompleteOptions {
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
/** Called with each streamed text chunk (claude backend streams; codex emits once). */
|
|
82
|
+
onChunk?: (text: string) => void;
|
|
83
|
+
}
|
|
84
|
+
/** A completion backend: turns a prompt into text. */
|
|
85
|
+
export interface CompletionBackend {
|
|
86
|
+
readonly name: string;
|
|
87
|
+
complete(prompt: string, opts: {
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
onChunk?: (text: string) => void;
|
|
90
|
+
diag?: CompletionDiag;
|
|
91
|
+
}): Promise<string>;
|
|
92
|
+
/** Release child processes. */
|
|
93
|
+
dispose(): void;
|
|
94
|
+
}
|
|
95
|
+
export interface EngineOptions {
|
|
96
|
+
backend: CompletionBackend;
|
|
97
|
+
/** Max completions cached (keyed on context). Default 128. 0 disables. */
|
|
98
|
+
cacheSize?: number;
|
|
99
|
+
/** Character budget for cross-cell context in the prompt. Default 6000. */
|
|
100
|
+
contextBudget?: number;
|
|
101
|
+
/** Max lines the model is asked to produce. Default 5. */
|
|
102
|
+
maxLines?: number;
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nebula-autocomplete",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LLM code autocomplete engine backed by Claude Code / Codex CLI subscriptions, with a Fastify plugin and CodeMirror 6 ghost-text extension.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"types": "./dist/server/fastify.d.ts",
|
|
16
|
+
"default": "./dist/server/fastify.js"
|
|
17
|
+
},
|
|
18
|
+
"./client": {
|
|
19
|
+
"types": "./dist/client/fetcher.d.ts",
|
|
20
|
+
"default": "./dist/client/fetcher.js"
|
|
21
|
+
},
|
|
22
|
+
"./codemirror": {
|
|
23
|
+
"types": "./dist/codemirror/ghostText.d.ts",
|
|
24
|
+
"default": "./dist/codemirror/ghostText.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"server.js",
|
|
30
|
+
"server.d.ts",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"prepare": "node scripts/prepare.cjs",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"smoke": "node scripts/smoke.mjs"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@codemirror/state": "^6.0.0",
|
|
41
|
+
"@codemirror/view": "^6.0.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@codemirror/state": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@codemirror/view": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@codemirror/state": "^6.5.0",
|
|
53
|
+
"@codemirror/view": "^6.36.0",
|
|
54
|
+
"@types/node": "^22.0.0",
|
|
55
|
+
"typescript": "^5.8.0",
|
|
56
|
+
"vitest": "^3.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|