nebula-notebook 0.2.0 → 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 +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,45 @@
|
|
|
1
|
+
import type { CompletionBackend } from "../types.js";
|
|
2
|
+
import { type Transport } from "../transport.js";
|
|
3
|
+
export interface CodexBackendOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Model. Default "gpt-5.6-luna" — the fast model available with
|
|
6
|
+
* ChatGPT-subscription auth (measured ~2× faster than the retired
|
|
7
|
+
* gpt-5.4-mini on warm autocomplete requests). The *-codex-mini
|
|
8
|
+
* variants are API-key-only.
|
|
9
|
+
*/
|
|
10
|
+
model?: string;
|
|
11
|
+
/** Reasoning effort. Default "low" ("minimal" is rejected by codex's built-in tools). */
|
|
12
|
+
reasoningEffort?: string;
|
|
13
|
+
/**
|
|
14
|
+
* CODEX_HOME containing only auth.json. Strongly recommended: skips the
|
|
15
|
+
* global config's MCP servers/plugins/hooks (measured ~2s/request saved).
|
|
16
|
+
* Prepare with: mkdir -p <dir> && cp ~/.codex/auth.json <dir>/
|
|
17
|
+
*/
|
|
18
|
+
codexHome?: string;
|
|
19
|
+
/** Per-request timeout. Default 60_000 ms. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Path to the codex binary. Default "codex". */
|
|
22
|
+
binary?: string;
|
|
23
|
+
/** Working directory. Default a temp dir. */
|
|
24
|
+
workspaceDir?: string;
|
|
25
|
+
/** Where codex runs: locally (default) or over ssh on the user's machine.
|
|
26
|
+
* Over ssh, codexHome is ignored (the user's own ~/.codex/auth.json is used;
|
|
27
|
+
* --ephemeral already keeps completions trace-free). */
|
|
28
|
+
transport?: Transport;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One-shot `codex exec --json` per completion (subscription auth). Codex has
|
|
32
|
+
* no incremental token stream in exec mode, so results arrive buffered.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CodexBackend implements CompletionBackend {
|
|
35
|
+
readonly name = "codex";
|
|
36
|
+
private opts;
|
|
37
|
+
constructor(options?: CodexBackendOptions);
|
|
38
|
+
complete(prompt: string, { signal, onChunk }?: {
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
onChunk?: (t: string) => void;
|
|
41
|
+
}): Promise<string>;
|
|
42
|
+
dispose(): void;
|
|
43
|
+
}
|
|
44
|
+
/** Find the agent message in codex --json JSONL output (shape varies by version). */
|
|
45
|
+
export declare function extractAgentMessage(jsonl: string): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { planSpawn } from "../transport.js";
|
|
6
|
+
/**
|
|
7
|
+
* One-shot `codex exec --json` per completion (subscription auth). Codex has
|
|
8
|
+
* no incremental token stream in exec mode, so results arrive buffered.
|
|
9
|
+
*/
|
|
10
|
+
export class CodexBackend {
|
|
11
|
+
name = "codex";
|
|
12
|
+
opts;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.opts = {
|
|
15
|
+
model: options.model ?? "gpt-5.6-luna",
|
|
16
|
+
reasoningEffort: options.reasoningEffort ?? "low",
|
|
17
|
+
codexHome: options.codexHome,
|
|
18
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
19
|
+
binary: options.binary ?? "codex",
|
|
20
|
+
workspaceDir: options.workspaceDir ?? join(tmpdir(), "nebula-autocomplete-ws"),
|
|
21
|
+
transport: options.transport ?? { kind: "local" },
|
|
22
|
+
};
|
|
23
|
+
if (this.opts.transport.kind === "local")
|
|
24
|
+
mkdirSync(this.opts.workspaceDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
complete(prompt, { signal, onChunk } = {}) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
if (signal?.aborted)
|
|
29
|
+
return reject(signal.reason ?? new Error("aborted"));
|
|
30
|
+
const argv = [
|
|
31
|
+
"exec", "--json",
|
|
32
|
+
"--skip-git-repo-check",
|
|
33
|
+
"--sandbox", "read-only",
|
|
34
|
+
"--color", "never",
|
|
35
|
+
// Completions are throwaway: don't persist rollout/session files or
|
|
36
|
+
// prompt history — at autocomplete frequency they pollute CODEX_HOME.
|
|
37
|
+
"--ephemeral",
|
|
38
|
+
"-c", 'history.persistence="none"',
|
|
39
|
+
// Also skip the curated-plugins sync, which otherwise clones tens of
|
|
40
|
+
// MB into CODEX_HOME/.tmp on every exec.
|
|
41
|
+
"-c", "features.plugins=false",
|
|
42
|
+
"-m", this.opts.model,
|
|
43
|
+
"-c", `model_reasoning_effort="${this.opts.reasoningEffort}"`,
|
|
44
|
+
prompt,
|
|
45
|
+
];
|
|
46
|
+
// Local: honor codexHome (skips global config's MCP/plugins). Over ssh,
|
|
47
|
+
// use the user's own ~/.codex on their machine (auth lives there).
|
|
48
|
+
const envOverrides = this.opts.transport.kind === "local" && this.opts.codexHome
|
|
49
|
+
? { CODEX_HOME: this.opts.codexHome }
|
|
50
|
+
: {};
|
|
51
|
+
const cwd = this.opts.transport.kind === "ssh" ? "/tmp/nebula-autocomplete-ws" : this.opts.workspaceDir;
|
|
52
|
+
const plan = planSpawn(this.opts.transport, this.opts.binary, argv, envOverrides, cwd);
|
|
53
|
+
const proc = spawn(plan.command, plan.args, { ...plan.options, stdio: ["ignore", "pipe", "pipe"] });
|
|
54
|
+
let out = "";
|
|
55
|
+
let err = "";
|
|
56
|
+
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
57
|
+
proc.stderr.on("data", (d) => (err += d.toString()));
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
proc.kill();
|
|
60
|
+
reject(new Error("codex timed out"));
|
|
61
|
+
}, this.opts.timeoutMs);
|
|
62
|
+
const onAbort = () => {
|
|
63
|
+
proc.kill();
|
|
64
|
+
reject(signal?.reason ?? new Error("aborted"));
|
|
65
|
+
};
|
|
66
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
proc.on("error", (e) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
reject(e);
|
|
70
|
+
});
|
|
71
|
+
proc.on("exit", (code) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
signal?.removeEventListener("abort", onAbort);
|
|
74
|
+
if (signal?.aborted)
|
|
75
|
+
return;
|
|
76
|
+
if (code !== 0) {
|
|
77
|
+
return reject(new Error(`codex rc=${code}: ${err.slice(-300)}`));
|
|
78
|
+
}
|
|
79
|
+
const text = extractAgentMessage(out);
|
|
80
|
+
if (text)
|
|
81
|
+
onChunk?.(text);
|
|
82
|
+
resolve(text);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
dispose() {
|
|
87
|
+
/* one-shot processes; nothing persistent to release */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Find the agent message in codex --json JSONL output (shape varies by version). */
|
|
91
|
+
export function extractAgentMessage(jsonl) {
|
|
92
|
+
let text = "";
|
|
93
|
+
for (const line of jsonl.split("\n")) {
|
|
94
|
+
let ev;
|
|
95
|
+
try {
|
|
96
|
+
ev = JSON.parse(line);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const item = ev.item ?? ev.msg ?? ev;
|
|
102
|
+
const kind = String(item.item_type ?? item.type ?? "");
|
|
103
|
+
if (kind.includes("agent_message") && typeof item.text === "string") {
|
|
104
|
+
text = item.text;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CompletionRequest, CompletionResult } from "../types.js";
|
|
2
|
+
export interface FetchCompletionOptions {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
onChunk?: (text: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export type CompletionFetcher = (req: CompletionRequest, opts?: FetchCompletionOptions) => Promise<CompletionResult>;
|
|
7
|
+
/**
|
|
8
|
+
* Browser-side client for the SSE endpoint registered by
|
|
9
|
+
* registerAutocompleteRoute. Streams chunks via onChunk and resolves with the
|
|
10
|
+
* final CompletionResult.
|
|
11
|
+
*/
|
|
12
|
+
export interface CompletionFetcherOptions {
|
|
13
|
+
/** Extra headers (e.g. auth) merged into every request. */
|
|
14
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
15
|
+
}
|
|
16
|
+
export declare function createCompletionFetcher(endpoint: string, options?: CompletionFetcherOptions): CompletionFetcher;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function createCompletionFetcher(endpoint, options = {}) {
|
|
2
|
+
return async (req, { signal, onChunk } = {}) => {
|
|
3
|
+
const extra = typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
|
|
4
|
+
const resp = await fetch(endpoint, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "content-type": "application/json", ...extra },
|
|
7
|
+
body: JSON.stringify(req),
|
|
8
|
+
signal,
|
|
9
|
+
});
|
|
10
|
+
if (!resp.ok || !resp.body) {
|
|
11
|
+
throw new Error(`autocomplete request failed: HTTP ${resp.status}`);
|
|
12
|
+
}
|
|
13
|
+
const reader = resp.body.getReader();
|
|
14
|
+
const decoder = new TextDecoder();
|
|
15
|
+
let buf = "";
|
|
16
|
+
let done = null;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { done: eof, value } = await reader.read();
|
|
19
|
+
if (eof)
|
|
20
|
+
break;
|
|
21
|
+
buf += decoder.decode(value, { stream: true });
|
|
22
|
+
let i;
|
|
23
|
+
while ((i = buf.indexOf("\n\n")) !== -1) {
|
|
24
|
+
const line = buf.slice(0, i).replace(/^data: /, "");
|
|
25
|
+
buf = buf.slice(i + 2);
|
|
26
|
+
if (!line.trim())
|
|
27
|
+
continue;
|
|
28
|
+
const ev = JSON.parse(line);
|
|
29
|
+
if (ev.type === "chunk")
|
|
30
|
+
onChunk?.(ev.text);
|
|
31
|
+
else if (ev.type === "done")
|
|
32
|
+
done = ev;
|
|
33
|
+
else if (ev.type === "error")
|
|
34
|
+
throw new Error(ev.message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!done)
|
|
38
|
+
throw new Error("stream ended without a done event");
|
|
39
|
+
return done;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -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
|