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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeMirror 6 inline ghost-text completion.
|
|
3
|
+
*
|
|
4
|
+
* Renders the pending LLM suggestion as dimmed inline text at the cursor;
|
|
5
|
+
* Tab accepts, Escape dismisses, any edit or cursor move invalidates. Fetching
|
|
6
|
+
* is debounced and superseded requests are aborted.
|
|
7
|
+
*
|
|
8
|
+
* This intentionally does NOT use @codemirror/autocomplete's dropdown — the
|
|
9
|
+
* kernel/static completion sources keep the dropdown; multi-line LLM
|
|
10
|
+
* suggestions render as ghost text alongside it.
|
|
11
|
+
*/
|
|
12
|
+
import { EditorState, type Extension } from "@codemirror/state";
|
|
13
|
+
import { EditorView } from "@codemirror/view";
|
|
14
|
+
export interface GhostTextContext {
|
|
15
|
+
prefix: string;
|
|
16
|
+
suffix: string;
|
|
17
|
+
state: EditorState;
|
|
18
|
+
}
|
|
19
|
+
export type GhostTextFetcher = (ctx: GhostTextContext, opts: {
|
|
20
|
+
signal: AbortSignal;
|
|
21
|
+
onChunk: (text: string) => void;
|
|
22
|
+
}) => Promise<string>;
|
|
23
|
+
export interface GhostTextOptions {
|
|
24
|
+
/** Idle time after the last edit before a fetch fires. Default 400 ms. */
|
|
25
|
+
debounceMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Skip fetching when the prefix (trimmed) is shorter than this. Default 0:
|
|
28
|
+
* with notebook-level context (other cells) available to the fetcher, even
|
|
29
|
+
* an empty cell is completable — gate in the fetcher, which can see that
|
|
30
|
+
* context, not here. Set >0 only for context-free single-document use.
|
|
31
|
+
*/
|
|
32
|
+
minPrefixLength?: number;
|
|
33
|
+
/**
|
|
34
|
+
* When this returns true at fetch time, the fetch is skipped — e.g. while a
|
|
35
|
+
* completion DROPDOWN is open, so the two UIs don't stack and ghost turns
|
|
36
|
+
* aren't burned while the user browses the list. Injected as a predicate so
|
|
37
|
+
* this package needs no dependency on @codemirror/autocomplete.
|
|
38
|
+
*/
|
|
39
|
+
holdWhen?: (state: unknown) => boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function acceptGhostText(view: EditorView): boolean;
|
|
42
|
+
export declare function dismissGhostText(view: EditorView): boolean;
|
|
43
|
+
export declare function ghostText(fetcher: GhostTextFetcher, options?: GhostTextOptions): Extension;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeMirror 6 inline ghost-text completion.
|
|
3
|
+
*
|
|
4
|
+
* Renders the pending LLM suggestion as dimmed inline text at the cursor;
|
|
5
|
+
* Tab accepts, Escape dismisses, any edit or cursor move invalidates. Fetching
|
|
6
|
+
* is debounced and superseded requests are aborted.
|
|
7
|
+
*
|
|
8
|
+
* This intentionally does NOT use @codemirror/autocomplete's dropdown — the
|
|
9
|
+
* kernel/static completion sources keep the dropdown; multi-line LLM
|
|
10
|
+
* suggestions render as ghost text alongside it.
|
|
11
|
+
*/
|
|
12
|
+
import { Prec, StateEffect, StateField, } from "@codemirror/state";
|
|
13
|
+
import { Decoration, EditorView, keymap, ViewPlugin, WidgetType, } from "@codemirror/view";
|
|
14
|
+
const setGhost = StateEffect.define();
|
|
15
|
+
class GhostWidget extends WidgetType {
|
|
16
|
+
text;
|
|
17
|
+
constructor(text) {
|
|
18
|
+
super();
|
|
19
|
+
this.text = text;
|
|
20
|
+
}
|
|
21
|
+
eq(other) {
|
|
22
|
+
return other.text === this.text;
|
|
23
|
+
}
|
|
24
|
+
toDOM() {
|
|
25
|
+
const span = document.createElement("span");
|
|
26
|
+
span.className = "cm-ghost-text";
|
|
27
|
+
span.style.opacity = "0.45";
|
|
28
|
+
span.style.whiteSpace = "pre-wrap";
|
|
29
|
+
span.setAttribute("aria-hidden", "true");
|
|
30
|
+
span.textContent = this.text;
|
|
31
|
+
return span;
|
|
32
|
+
}
|
|
33
|
+
get lineBreaks() {
|
|
34
|
+
return (this.text.match(/\n/g) ?? []).length;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const ghostField = StateField.define({
|
|
38
|
+
create: () => null,
|
|
39
|
+
update(value, tr) {
|
|
40
|
+
for (const e of tr.effects)
|
|
41
|
+
if (e.is(setGhost))
|
|
42
|
+
return e.value;
|
|
43
|
+
// Any document change or cursor move invalidates the suggestion.
|
|
44
|
+
if (tr.docChanged || tr.selection)
|
|
45
|
+
return null;
|
|
46
|
+
return value;
|
|
47
|
+
},
|
|
48
|
+
provide: (field) => EditorView.decorations.from(field, (value) => {
|
|
49
|
+
if (!value || !value.text)
|
|
50
|
+
return Decoration.none;
|
|
51
|
+
return Decoration.set([
|
|
52
|
+
Decoration.widget({
|
|
53
|
+
widget: new GhostWidget(value.text),
|
|
54
|
+
side: 1,
|
|
55
|
+
}).range(value.pos),
|
|
56
|
+
]);
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
export function acceptGhostText(view) {
|
|
60
|
+
const ghost = view.state.field(ghostField, false);
|
|
61
|
+
if (!ghost || !ghost.text)
|
|
62
|
+
return false;
|
|
63
|
+
view.dispatch({
|
|
64
|
+
changes: { from: ghost.pos, insert: ghost.text },
|
|
65
|
+
selection: { anchor: ghost.pos + ghost.text.length },
|
|
66
|
+
effects: setGhost.of(null),
|
|
67
|
+
userEvent: "input.complete",
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
export function dismissGhostText(view) {
|
|
72
|
+
const ghost = view.state.field(ghostField, false);
|
|
73
|
+
if (!ghost)
|
|
74
|
+
return false;
|
|
75
|
+
view.dispatch({ effects: setGhost.of(null) });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
export function ghostText(fetcher, options = {}) {
|
|
79
|
+
const debounceMs = options.debounceMs ?? 400;
|
|
80
|
+
const minPrefixLength = options.minPrefixLength ?? 0;
|
|
81
|
+
const plugin = ViewPlugin.fromClass(class {
|
|
82
|
+
view;
|
|
83
|
+
timer = null;
|
|
84
|
+
controller = null;
|
|
85
|
+
// In-flight fetches (supersession can briefly overlap two). While > 0 the
|
|
86
|
+
// editor root carries .cm-ai-pending so themes can signal "computing"
|
|
87
|
+
// (e.g. tint the cursor) instead of leaving the wait invisible.
|
|
88
|
+
inflight = 0;
|
|
89
|
+
constructor(view) {
|
|
90
|
+
this.view = view;
|
|
91
|
+
}
|
|
92
|
+
trackFetch(delta) {
|
|
93
|
+
this.inflight += delta;
|
|
94
|
+
this.view.dom.classList.toggle("cm-ai-pending", this.inflight > 0);
|
|
95
|
+
}
|
|
96
|
+
update(update) {
|
|
97
|
+
if (!update.docChanged)
|
|
98
|
+
return;
|
|
99
|
+
// Any user-driven edit triggers a fetch — typing, paste, delete,
|
|
100
|
+
// undo/redo — EXCEPT accepting a suggestion (that would immediately
|
|
101
|
+
// re-fetch on its own output). The kind of edit doesn't matter: the
|
|
102
|
+
// model can complete from whatever state the edit produced.
|
|
103
|
+
const isUserEdit = update.transactions.some((tr) => (tr.isUserEvent("input") && !tr.isUserEvent("input.complete")) ||
|
|
104
|
+
tr.isUserEvent("delete") ||
|
|
105
|
+
tr.isUserEvent("undo") ||
|
|
106
|
+
tr.isUserEvent("redo") ||
|
|
107
|
+
tr.isUserEvent("move"));
|
|
108
|
+
if (!isUserEdit)
|
|
109
|
+
return;
|
|
110
|
+
this.schedule();
|
|
111
|
+
}
|
|
112
|
+
schedule() {
|
|
113
|
+
if (this.timer)
|
|
114
|
+
clearTimeout(this.timer);
|
|
115
|
+
this.controller?.abort();
|
|
116
|
+
this.timer = setTimeout(() => this.fetch(), debounceMs);
|
|
117
|
+
}
|
|
118
|
+
async fetch() {
|
|
119
|
+
const { state } = this.view;
|
|
120
|
+
if (!this.view.hasFocus)
|
|
121
|
+
return;
|
|
122
|
+
if (options.holdWhen?.(state))
|
|
123
|
+
return; // e.g. kernel dropdown open
|
|
124
|
+
const pos = state.selection.main.head;
|
|
125
|
+
if (!state.selection.main.empty)
|
|
126
|
+
return;
|
|
127
|
+
const prefix = state.sliceDoc(0, pos);
|
|
128
|
+
const suffix = state.sliceDoc(pos);
|
|
129
|
+
if (prefix.trim().length < minPrefixLength)
|
|
130
|
+
return;
|
|
131
|
+
this.controller = new AbortController();
|
|
132
|
+
const { signal } = this.controller;
|
|
133
|
+
const startDoc = state.doc;
|
|
134
|
+
const stale = () => signal.aborted || this.view.state.doc !== startDoc;
|
|
135
|
+
let streamed = "";
|
|
136
|
+
this.trackFetch(1);
|
|
137
|
+
try {
|
|
138
|
+
const full = await fetcher({ prefix, suffix, state }, {
|
|
139
|
+
signal,
|
|
140
|
+
onChunk: (text) => {
|
|
141
|
+
if (stale())
|
|
142
|
+
return;
|
|
143
|
+
streamed += text;
|
|
144
|
+
this.view.dispatch({ effects: setGhost.of({ pos, text: streamed }) });
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
if (stale())
|
|
148
|
+
return;
|
|
149
|
+
if (full)
|
|
150
|
+
this.view.dispatch({ effects: setGhost.of({ pos, text: full }) });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* aborted or backend error — ghost text simply doesn't appear */
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
this.trackFetch(-1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
destroy() {
|
|
160
|
+
if (this.timer)
|
|
161
|
+
clearTimeout(this.timer);
|
|
162
|
+
this.controller?.abort();
|
|
163
|
+
this.inflight = 0;
|
|
164
|
+
this.view.dom.classList.remove("cm-ai-pending");
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return [
|
|
168
|
+
ghostField,
|
|
169
|
+
plugin,
|
|
170
|
+
Prec.highest(keymap.of([
|
|
171
|
+
{ key: "Tab", run: acceptGhostText },
|
|
172
|
+
{ key: "Escape", run: dismissGhostText },
|
|
173
|
+
])),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CompleteOptions, CompletionRequest, CompletionResult, EngineOptions } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Backend-agnostic autocomplete engine: prompt assembly, LRU result cache,
|
|
4
|
+
* and per-session supersession (a new request aborts the previous one with
|
|
5
|
+
* the same sessionKey).
|
|
6
|
+
*/
|
|
7
|
+
export declare class AutocompleteEngine {
|
|
8
|
+
private opts;
|
|
9
|
+
private cache;
|
|
10
|
+
private inflight;
|
|
11
|
+
private turnChain;
|
|
12
|
+
private contextBudget;
|
|
13
|
+
private maxLines;
|
|
14
|
+
constructor(opts: EngineOptions);
|
|
15
|
+
complete(req: CompletionRequest, { signal, onChunk }?: CompleteOptions): Promise<CompletionResult>;
|
|
16
|
+
dispose(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { LruCache } from "./lru.js";
|
|
2
|
+
import { buildPrompt, cacheKey } from "./prompt.js";
|
|
3
|
+
import { createPrefixTrimStreamFilter, createTagStreamFilter, extractCompletionTag, stripFences, trimPrefixOverlap, trimSuffixOverlap, } from "./text.js";
|
|
4
|
+
/**
|
|
5
|
+
* Backend-agnostic autocomplete engine: prompt assembly, LRU result cache,
|
|
6
|
+
* and per-session supersession (a new request aborts the previous one with
|
|
7
|
+
* the same sessionKey).
|
|
8
|
+
*/
|
|
9
|
+
export class AutocompleteEngine {
|
|
10
|
+
opts;
|
|
11
|
+
cache;
|
|
12
|
+
inflight = new Map();
|
|
13
|
+
// Per-session dispatch chain (single-flight): a backend turn cannot be
|
|
14
|
+
// cancelled once dispatched — an aborted request's worker still runs its
|
|
15
|
+
// turn to completion. Without this, a typing burst fanned superseded
|
|
16
|
+
// requests out across workers: the pool exhausted, each keystroke cold-
|
|
17
|
+
// spawned another process, and contending spawns snowballed into ~30s
|
|
18
|
+
// waits. Chaining per session keeps at most ONE turn per cell in flight;
|
|
19
|
+
// superseded requests abort while queued and never touch the backend.
|
|
20
|
+
turnChain = new Map();
|
|
21
|
+
contextBudget;
|
|
22
|
+
maxLines;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.opts = opts;
|
|
25
|
+
this.cache = new LruCache(opts.cacheSize ?? 128);
|
|
26
|
+
this.contextBudget = opts.contextBudget ?? 6000;
|
|
27
|
+
this.maxLines = opts.maxLines ?? 10; // field-tested: longer caps don't hurt latency; short ones truncate wanted completions
|
|
28
|
+
}
|
|
29
|
+
async complete(req, { signal, onChunk } = {}) {
|
|
30
|
+
const t0 = performance.now();
|
|
31
|
+
const key = cacheKey(req);
|
|
32
|
+
const cached = this.cache.get(key);
|
|
33
|
+
if (cached !== undefined) {
|
|
34
|
+
onChunk?.(cached);
|
|
35
|
+
return {
|
|
36
|
+
text: cached,
|
|
37
|
+
backend: this.opts.backend.name,
|
|
38
|
+
fromCache: true,
|
|
39
|
+
ttfbMs: 0,
|
|
40
|
+
totalMs: Math.round(performance.now() - t0),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Supersede any in-flight request for the same session (cell).
|
|
44
|
+
const session = req.sessionKey;
|
|
45
|
+
let controller;
|
|
46
|
+
if (session !== undefined) {
|
|
47
|
+
this.inflight.get(session)?.abort(new Error("superseded"));
|
|
48
|
+
controller = new AbortController();
|
|
49
|
+
this.inflight.set(session, controller);
|
|
50
|
+
if (signal) {
|
|
51
|
+
const upstream = signal;
|
|
52
|
+
upstream.addEventListener("abort", () => controller.abort(upstream.reason), {
|
|
53
|
+
once: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const effectiveSignal = controller?.signal ?? signal;
|
|
58
|
+
let ttfb = null;
|
|
59
|
+
const rawChunkGuard = (t) => {
|
|
60
|
+
if (ttfb === null)
|
|
61
|
+
ttfb = performance.now() - t0;
|
|
62
|
+
onChunk?.(t);
|
|
63
|
+
};
|
|
64
|
+
// Streamed ghost text goes through the same shaping as the final text so
|
|
65
|
+
// the done event never visibly "snaps" it: strip <completion> tags, then
|
|
66
|
+
// hold back a possible prefix-echo until it either confirms (stripped) or
|
|
67
|
+
// is ruled out (streamed through).
|
|
68
|
+
const chunkGuard = createTagStreamFilter(createPrefixTrimStreamFilter(req.prefix, rawChunkGuard));
|
|
69
|
+
try {
|
|
70
|
+
// Per-request tuning (Advanced settings), clamped to sane bounds.
|
|
71
|
+
const clamp = (v, lo, hi, dflt) => typeof v === "number" && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : dflt;
|
|
72
|
+
const prompt = buildPrompt(req, {
|
|
73
|
+
contextBudget: clamp(req.contextBudget, 0, 200_000, this.contextBudget),
|
|
74
|
+
maxLines: clamp(req.maxLines, 1, 40, this.maxLines),
|
|
75
|
+
});
|
|
76
|
+
const diag = { promptChars: prompt.length };
|
|
77
|
+
// Single-flight per session: wait for the previous turn on this cell to
|
|
78
|
+
// settle before dispatching. If we're superseded while queued (the user
|
|
79
|
+
// kept typing), bail here — no backend turn is ever wasted on us.
|
|
80
|
+
const prev = session !== undefined ? this.turnChain.get(session) : undefined;
|
|
81
|
+
const run = (async () => {
|
|
82
|
+
if (prev) {
|
|
83
|
+
const tQueue = performance.now();
|
|
84
|
+
await prev; // never rejects (stored pre-caught)
|
|
85
|
+
diag.queueWaitMs = Math.round(performance.now() - tQueue);
|
|
86
|
+
if (effectiveSignal?.aborted)
|
|
87
|
+
throw new Error("superseded");
|
|
88
|
+
}
|
|
89
|
+
return this.opts.backend.complete(prompt, {
|
|
90
|
+
signal: effectiveSignal,
|
|
91
|
+
onChunk: chunkGuard,
|
|
92
|
+
diag,
|
|
93
|
+
});
|
|
94
|
+
})();
|
|
95
|
+
if (session !== undefined) {
|
|
96
|
+
const link = run.then(() => undefined, () => undefined);
|
|
97
|
+
this.turnChain.set(session, link);
|
|
98
|
+
void link.then(() => {
|
|
99
|
+
if (this.turnChain.get(session) === link)
|
|
100
|
+
this.turnChain.delete(session);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const raw = await run;
|
|
104
|
+
diag.rawChars = raw.length;
|
|
105
|
+
// Tag-wrapped replies carry whitespace verbatim; untagged replies fall
|
|
106
|
+
// back to the fence-stripping pipeline.
|
|
107
|
+
const tagged = extractCompletionTag(raw);
|
|
108
|
+
const text = trimSuffixOverlap(req.suffix ?? "", trimPrefixOverlap(req.prefix, tagged !== null ? tagged : stripFences(raw)));
|
|
109
|
+
// Cache only non-empty results: an empty completion is a transient
|
|
110
|
+
// model shrug, and caching it pins "no suggestion" onto that exact
|
|
111
|
+
// prefix for the cache's lifetime (observed as instant empty repeats).
|
|
112
|
+
if (text)
|
|
113
|
+
this.cache.set(key, text);
|
|
114
|
+
return {
|
|
115
|
+
text,
|
|
116
|
+
backend: this.opts.backend.name,
|
|
117
|
+
fromCache: false,
|
|
118
|
+
ttfbMs: Math.round(ttfb ?? performance.now() - t0),
|
|
119
|
+
totalMs: Math.round(performance.now() - t0),
|
|
120
|
+
diag,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (session !== undefined && this.inflight.get(session) === controller) {
|
|
125
|
+
this.inflight.delete(session);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
dispose() {
|
|
130
|
+
for (const c of this.inflight.values())
|
|
131
|
+
c.abort(new Error("engine disposed"));
|
|
132
|
+
this.inflight.clear();
|
|
133
|
+
this.opts.backend.dispose();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class LruCache {
|
|
2
|
+
capacity;
|
|
3
|
+
map = new Map();
|
|
4
|
+
constructor(capacity) {
|
|
5
|
+
this.capacity = capacity;
|
|
6
|
+
}
|
|
7
|
+
get(key) {
|
|
8
|
+
const v = this.map.get(key);
|
|
9
|
+
if (v !== undefined) {
|
|
10
|
+
this.map.delete(key);
|
|
11
|
+
this.map.set(key, v);
|
|
12
|
+
}
|
|
13
|
+
return v;
|
|
14
|
+
}
|
|
15
|
+
set(key, value) {
|
|
16
|
+
if (this.capacity <= 0)
|
|
17
|
+
return;
|
|
18
|
+
if (this.map.has(key))
|
|
19
|
+
this.map.delete(key);
|
|
20
|
+
this.map.set(key, value);
|
|
21
|
+
if (this.map.size > this.capacity) {
|
|
22
|
+
const oldest = this.map.keys().next().value;
|
|
23
|
+
this.map.delete(oldest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CompletionRequest } from "../types.js";
|
|
2
|
+
export interface PromptOptions {
|
|
3
|
+
contextBudget: number;
|
|
4
|
+
maxLines: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Build the completion prompt: instruction, cross-cell context (nearest cells
|
|
8
|
+
* kept when over budget), then the active cell with a <CURSOR> marker.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildPrompt(req: CompletionRequest, opts: PromptOptions): string;
|
|
11
|
+
/** Cache key: exact near-cursor text + a cheap digest of the wider context. */
|
|
12
|
+
export declare function cacheKey(req: CompletionRequest): string;
|
|
Binary file
|
|
@@ -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";
|