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.
Files changed (100) hide show
  1. package/README.md +35 -20
  2. package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
  3. package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
  4. package/dist/assets/index-DK4nS74r.js +829 -0
  5. package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
  6. package/dist/assets/index-DzjpmUak.css +32 -0
  7. package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
  8. package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/node-server/dist/auth/auth-middleware.js +22 -4
  11. package/node-server/dist/auth/auth-service.js +37 -7
  12. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  13. package/node-server/dist/cluster/client-registration.js +35 -2
  14. package/node-server/dist/cluster/kernel-proxy.js +19 -3
  15. package/node-server/dist/cluster/server-registry.d.ts +9 -0
  16. package/node-server/dist/cluster/server-registry.js +26 -0
  17. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  18. package/node-server/dist/discovery/conda-locations.js +427 -0
  19. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  20. package/node-server/dist/discovery/discovery-service.js +211 -127
  21. package/node-server/dist/discovery/types.d.ts +0 -1
  22. package/node-server/dist/discovery/types.js +1 -2
  23. package/node-server/dist/environment.d.ts +12 -0
  24. package/node-server/dist/environment.js +104 -0
  25. package/node-server/dist/fs/fs-service.d.ts +31 -1
  26. package/node-server/dist/fs/fs-service.js +160 -28
  27. package/node-server/dist/fs/types.d.ts +9 -0
  28. package/node-server/dist/fs/types.js +8 -0
  29. package/node-server/dist/index.js +71 -4
  30. package/node-server/dist/kernel/kernel-service.d.ts +17 -0
  31. package/node-server/dist/kernel/kernel-service.js +72 -4
  32. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  33. package/node-server/dist/kernel/kernelspec.js +75 -4
  34. package/node-server/dist/notebook/headless-handler.d.ts +10 -0
  35. package/node-server/dist/notebook/headless-handler.js +55 -5
  36. package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
  37. package/node-server/dist/notebook/undoRedoManager.js +39 -16
  38. package/node-server/dist/private-tmp.d.ts +14 -0
  39. package/node-server/dist/private-tmp.js +70 -0
  40. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  41. package/node-server/dist/routes/autocomplete.js +294 -0
  42. package/node-server/dist/routes/fs.js +21 -4
  43. package/node-server/dist/routes/kernel.js +45 -2
  44. package/node-server/dist/routes/notebook.js +8 -2
  45. package/node-server/dist/routes/python.d.ts +1 -2
  46. package/node-server/dist/routes/python.js +139 -3
  47. package/node-server/dist/scheduler/allocation-service.d.ts +20 -0
  48. package/node-server/dist/scheduler/allocation-service.js +124 -8
  49. package/node-server/dist/scheduler/mock-scheduler.js +7 -3
  50. package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
  51. package/node-server/dist/scheduler/types.d.ts +3 -2
  52. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  53. package/node-server/dist/terminal/agent-registry.js +246 -0
  54. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  55. package/node-server/dist/terminal/binding-store.js +0 -0
  56. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  57. package/node-server/dist/terminal/pty-manager.js +130 -19
  58. package/node-server/dist/terminal/server.js +112 -21
  59. package/node-server/dist/terminal/types.d.ts +2 -0
  60. package/node-server/package.json +1 -0
  61. package/node_modules/nebula-autocomplete/README.md +145 -0
  62. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  63. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  64. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  65. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  66. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  67. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  68. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  69. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  70. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  71. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  72. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  73. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  74. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  75. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  76. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  77. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  78. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  79. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  80. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  81. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  82. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  83. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  84. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  85. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  86. package/node_modules/nebula-autocomplete/package.json +58 -0
  87. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  88. package/node_modules/nebula-autocomplete/server.js +3 -0
  89. package/package.json +17 -5
  90. package/scripts/postinstall.cjs +25 -5
  91. package/dist/assets/index-7-YBurka.js +0 -716
  92. package/dist/assets/index-CFBUnxSZ.css +0 -32
  93. package/node-server/dist/llm/index.d.ts +0 -5
  94. package/node-server/dist/llm/index.js +0 -21
  95. package/node-server/dist/llm/llm-service.d.ts +0 -77
  96. package/node-server/dist/llm/llm-service.js +0 -454
  97. package/node-server/dist/llm/types.d.ts +0 -40
  98. package/node-server/dist/llm/types.js +0 -15
  99. package/node-server/dist/routes/llm.d.ts +0 -8
  100. 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
+ }
@@ -0,0 +1,3 @@
1
+ // Type shim for consumers on classic (node10) module resolution, which cannot
2
+ // read the "exports" map. Runtime resolution still goes through "exports".
3
+ export * from "./dist/server/fastify.js";
@@ -0,0 +1,3 @@
1
+ // Runtime shim mirroring server.d.ts (only used by resolvers that bypass the
2
+ // "exports" map).
3
+ export * from "./dist/server/fastify.js";