Restructure into a monorepo: models, VS Code extension, daemon, shared

- models/: GGUFs (Git LFS), manifest.json, model card, Apache-2.0 LICENSE + NOTICE
- extensions/vscode/: the Local Autocomplete extension (v0.5.0, publisher harvmaster)
- daemon/: acd completion daemon (bundled into the extension; also runs standalone)
- shared/: prompt layout + symbol extraction shared with the training pipeline
- scripts/: make-model-release.mjs, publish-extension-release.mjs (Gitea releases)
- README with quick start, settings, architecture, build and release instructions;
  MIT license for code; CHANGELOG

The default model manifest URL moves to models/manifest.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:37:19 +00:00
co-authored by Claude Opus 5.5
parent f8dcf1b7df
commit fa8f775779
52 changed files with 309630 additions and 207 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
acd.state.json
telemetry/
recordings/
*.local.json
test/test*.config.json
+54
View File
@@ -0,0 +1,54 @@
# acd: local completion daemon
Builds fill-in-the-middle prompts in exactly the format the models were trained on (it shares
[`../shared/`](../shared) with the training pipeline) and serves completions through a llama-server it manages.
Editors talk to it over localhost HTTP. Design notes: [../docs/architecture.md](../docs/architecture.md).
**You usually don't run this yourself:** the VS Code extension bundles it and runs it in *managed mode*. Run it
standalone for external mode, for other editors, or when developing.
## Running standalone
Requires Node ≥ 22.18 (runs TypeScript directly) and a `llama-server` binary (`brew install llama.cpp` on macOS,
or a [llama.cpp release](https://github.com/ggml-org/llama.cpp/releases) on Linux).
```sh
npm install
npm start # node src/main.ts --config acd.config.json
node src/acctl.ts status # or: mode simple|symbols|raw · model <name> · on · off
npm run smoke # end-to-end test (needs the models from ../models, via Git LFS)
```
`acd.config.json` uses `llama-server` from your PATH with `-ngl 99` (Apple GPU), and the models in `../models`.
- **Linux / CPU:** copy it to `acd.config.local.json` (git-ignored), set `"binary"` to your llama-server and `"args": ["-t", "<physical cores>"]`, then run `node src/main.ts --config acd.config.local.json`.
- **Serving another machine** (e.g. an M1 Max serving a laptop): set `"host": "0.0.0.0"` and point the editor's `serverUrl` at it, using `daemon.mode: "external"`. There's no auth, so only do this on a trusted network.
- **Start on login:** [`acd.service`](acd.service) is a systemd user unit.
## Context modes
| mode | prompt | models |
|---|---|---|
| `symbols` | repo header + language-service symbols (imports' signatures, repo-local types), from `../shared/symbols.ts`, the same code that built the training data. Requires the editor to send `workspaceRoot`; otherwise the request runs as `simple`. | stage-c |
| `simple` | repo name + file path header | stage-c |
| `raw` | plain suffix-first FIM | stage-b (spm), base-0.5b (psm) |
The mode and model are global to the daemon and persisted in `acd.state.json`. VS Code, Zed and `acctl` all see the same toggle.
**How symbols mode stays fast:**
- Context is built in a worker thread (one TypeScript language service per workspace) and cached per *anchor*: the file, the enclosing top-level declaration and the import block. While you type inside one function the header is identical and the KV cache keeps hitting; the smoke test shows the same ~2 re-processed tokens per keystroke as `simple`.
- When the anchor changes, the daemon waits up to `symbols.waitMs` for fresh context, else uses the file's previous context (stale-while-revalidate).
- Member completions after `.` are off (`symbols.members`): every `.` would change the header and force a cold prefill.
**`this.` repair** (`thisRepair.enabled`, any mode, needs `workspaceRoot`): when the identifier you're typing only resolves as a member of the enclosing class, the daemon completes as if `this.` were there. It returns `replaceBefore` + `filterText` so the editor replaces `getU` with `this.getUser(id)`.
**Edit recordings:** `POST /v1/edits` appends opted-in editor edit events to `recording.dir` (default `recordings/`, git-ignored). These are the future training data for next-edit prediction.
## API
- `POST /v1/fim`: `{ requestId?, repo, path, languageId?, prefix, suffix, prefixLineOffset?, mode?, singleLine?, maxLines? }` returns `{ text, mode, singleLine, timings, … }`. A newer request cancels the in-flight one; closing the connection cancels too.
- `GET|PUT /v1/config`: `{ enabled, model, mode }`. Changing the model restarts llama-server.
- `POST /v1/feedback`: editor events, written to the local log only when `telemetry.enabled`.
- `POST /v1/edits`: `{ events }` from editors that opted in to edit recording.
- The `/v1/fim` request may include `workspaceRoot` (absolute path on the daemon's machine). Responses may include `replaceBefore` / `filterText` (the `this.` repair) and `symbols: { status: hit|fresh|stale|none, items }`.
- `GET /health`
+62
View File
@@ -0,0 +1,62 @@
{
"host": "127.0.0.1",
"port": 7071,
"tokenizer": {
"json": "assets/qwen-tokenizer.json",
"config": "assets/qwen-tokenizer_config.json"
},
"llamaServer": {
"binary": "llama-server",
"port": 7072,
"ctx": 8192,
"args": [
"-ngl",
"99"
]
},
"models": {
"stage-c": {
"path": "../models/stage-c-q8_0.gguf",
"order": "spm",
"modes": [
"symbols",
"simple"
],
"defaultMode": "simple"
},
"stage-b": {
"path": "../models/stage-b-q8_0.gguf",
"order": "spm",
"modes": [
"raw"
],
"defaultMode": "raw"
}
},
"defaultModel": "stage-c",
"prompt": {
"prefixTokens": 1536,
"suffixTokens": 512,
"prefixChunkLines": 32,
"maxLines": 8,
"nPredictSingleLine": 48,
"nPredictMultiLine": 128
},
"symbols": {
"budget": 2048,
"waitMs": 150,
"members": false
},
"thisRepair": {
"enabled": true,
"waitMs": 100
},
"telemetry": {
"enabled": false,
"dir": "telemetry"
},
"recording": {
"dir": "recordings"
},
"stateFile": "acd.state.json"
}
+18
View File
@@ -0,0 +1,18 @@
# systemd user service for acd (only needed in external mode; the VS Code extension's
# managed mode runs the daemon itself). Edit the two paths below, then:
# mkdir -p ~/.config/systemd/user && cp acd.service ~/.config/systemd/user/
# systemctl --user daemon-reload && systemctl --user enable --now acd
# journalctl --user -u acd -f # logs
# To keep it running when you're not logged in: loginctl enable-linger $USER
[Unit]
Description=acd local completion daemon
[Service]
WorkingDirectory=%h/Projects/AutoComplete/local-autocomplete/daemon
# nvm-installed node isn't on PATH for services, so use the absolute path.
ExecStart=%h/.nvm/versions/node/v22.22.0/bin/node src/main.ts --config acd.config.json
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
{
"add_prefix_space": false,
"added_tokens_decoder": {
"151643": {
"content": "<|endoftext|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151644": {
"content": "<|im_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151645": {
"content": "<|im_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151646": {
"content": "<|object_ref_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151647": {
"content": "<|object_ref_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151648": {
"content": "<|box_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151649": {
"content": "<|box_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151650": {
"content": "<|quad_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151651": {
"content": "<|quad_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151652": {
"content": "<|vision_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151653": {
"content": "<|vision_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151654": {
"content": "<|vision_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151655": {
"content": "<|image_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151656": {
"content": "<|video_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151657": {
"content": "<tool_call>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151658": {
"content": "</tool_call>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151659": {
"content": "<|fim_prefix|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151660": {
"content": "<|fim_middle|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151661": {
"content": "<|fim_suffix|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151662": {
"content": "<|fim_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151663": {
"content": "<|repo_name|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151664": {
"content": "<|file_sep|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
}
},
"additional_special_tokens": [
"<|im_start|>",
"<|im_end|>",
"<|object_ref_start|>",
"<|object_ref_end|>",
"<|box_start|>",
"<|box_end|>",
"<|quad_start|>",
"<|quad_end|>",
"<|vision_start|>",
"<|vision_end|>",
"<|vision_pad|>",
"<|image_pad|>",
"<|video_pad|>"
],
"bos_token": null,
"chat_template": "{%- if tools %}\n {{- '<|im_start|>system\\n' }}\n {%- if messages[0]['role'] == 'system' %}\n {{- messages[0]['content'] }}\n {%- else %}\n {{- 'You are a helpful assistant.' }}\n {%- endif %}\n {{- \"\\n\\n# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n {%- if messages[0]['role'] == 'system' %}\n {{- '<|im_start|>system\\n' + messages[0]['content'] + '<|im_end|>\\n' }}\n {%- else %}\n {{- '<|im_start|>system\\nYou are a helpful assistant.<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- for message in messages %}\n {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) or (message.role == \"assistant\" and not message.tool_calls) %}\n {{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>' + '\\n' }}\n {%- elif message.role == \"assistant\" %}\n {{- '<|im_start|>' + message.role }}\n {%- if message.content %}\n {{- '\\n' + message.content }}\n {%- endif %}\n {%- for tool_call in message.tool_calls %}\n {%- if tool_call.function is defined %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {{- '\\n<tool_call>\\n{\"name\": \"' }}\n {{- tool_call.name }}\n {{- '\", \"arguments\": ' }}\n {{- tool_call.arguments | tojson }}\n {{- '}\\n</tool_call>' }}\n {%- endfor %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if (loop.index0 == 0) or (messages[loop.index0 - 1].role != \"tool\") %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {{- message.content }}\n {{- '\\n</tool_response>' }}\n {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n{%- endif %}\n",
"clean_up_tokenization_spaces": false,
"eos_token": "<|endoftext|>",
"errors": "replace",
"model_max_length": 32768,
"pad_token": "<|endoftext|>",
"split_special_tokens": false,
"tokenizer_class": "Qwen2Tokenizer",
"unk_token": null,
"add_bos_token": false
}
+57
View File
@@ -0,0 +1,57 @@
{
"name": "acd",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "acd",
"dependencies": {
"@huggingface/tokenizers": "^0.2.0"
},
"devDependencies": {
"@types/node": "^26.6.4",
"typescript": "5.9"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/@huggingface/tokenizers": {
"version": "0.2.0",
"resolved": "https://verdaccio.harvmaster.com/@huggingface/tokenizers/-/tokenizers-0.2.0.tgz",
"integrity": "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg==",
"license": "Apache-2.0"
},
"node_modules/@types/node": {
"version": "26.6.4",
"resolved": "https://verdaccio.harvmaster.com/@types/node/-/node-26.6.4.tgz",
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://verdaccio.harvmaster.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://verdaccio.harvmaster.com/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "acd",
"private": true,
"description": "Local completion daemon: builds training-identical FIM prompts and serves them via llama-server",
"type": "module",
"scripts": {
"start": "node src/main.ts --config acd.config.json",
"typecheck": "tsc",
"smoke": "node test/smoke.ts"
},
"engines": {
"node": ">=22.18"
},
"dependencies": {
"@huggingface/tokenizers": "^0.2.0"
},
"devDependencies": {
"@types/node": "^26.6.4",
"typescript": "5.9"
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* acctl: control a running acd from the terminal (the toggle for Zed, scripts, etc.)
*
* node src/acctl.ts status
* node src/acctl.ts mode symbols|simple|raw
* node src/acctl.ts model <name>
* node src/acctl.ts on|off
*
* ACD_URL overrides the daemon address (default http://127.0.0.1:7071).
*/
const url = (process.env.ACD_URL ?? 'http://127.0.0.1:7071').replace(/\/$/, '')
const [cmd, arg] = process.argv.slice(2)
async function call(method: string, route: string, body?: unknown) {
const res = await fetch(url + route, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
const json = await res.json() as Record<string, any>
if (!res.ok) { console.error(`error: ${json.error ?? res.status}`); process.exit(1) }
return json
}
function show(c: Record<string, any>) {
const modes = c.models[c.model].modes.join(', ')
console.log(`${c.enabled ? 'on' : 'off'} model=${c.model} mode=${c.mode}${c.effectiveMode !== c.mode ? ` (running as ${c.effectiveMode})` : ''} [modes: ${modes}]${c.llama.loading ? ' loading…' : ''}${c.llama.error ? ` error: ${c.llama.error}` : ''}`)
}
try {
if (!cmd || cmd === 'status') show(await call('GET', '/v1/config'))
else if (cmd === 'mode' && arg) show(await call('PUT', '/v1/config', { mode: arg }))
else if (cmd === 'model' && arg) show(await call('PUT', '/v1/config', { model: arg }))
else if (cmd === 'on' || cmd === 'off') show(await call('PUT', '/v1/config', { enabled: cmd === 'on' }))
else { console.error('usage: acctl status | mode <symbols|simple|raw> | model <name> | on | off'); process.exit(2) }
} catch (e) {
console.error(`cannot reach acd at ${url}: ${(e as Error).message}`)
process.exit(1)
}
+95
View File
@@ -0,0 +1,95 @@
import fs from 'node:fs'
import path from 'node:path'
import type { FimOrder } from '../../shared/prompt.ts'
export type ContextMode = 'symbols' | 'simple' | 'raw'
/** What a model was trained on; the toggle only offers these modes. */
export interface ModelProfile {
path: string
order: FimOrder
modes: ContextMode[]
defaultMode: ContextMode
}
export interface DaemonConfig {
host: string
port: number
tokenizer: { json: string; config: string }
llamaServer: {
binary: string
port: number
ctx: number
/** Extra flags, e.g. ["-ngl", "99"] on Apple Silicon, ["-t", "16"] on the laptop CPU. */
args: string[]
}
models: Record<string, ModelProfile>
defaultModel: string
prompt: {
prefixTokens: number
suffixTokens: number
/** Prefix window start moves in multiples of this many lines (KV-cache stability). */
prefixChunkLines: number
maxLines: number
nPredictSingleLine: number
nPredictMultiLine: number
}
symbols: {
/** Token budget for the __symbols__.d.ts context (Stage C was trained with up to 2048). */
budget: number
/** How long a request waits for fresh context when the anchor changes before using stale/none. */
waitMs: number
/** Member completions after `expr.`: in training, but every `.` changes the header (cold prefill). */
members: boolean
}
thisRepair: { enabled: boolean; waitMs: number }
telemetry: { enabled: boolean; dir: string }
/** Where opted-in edit recordings from editors are appended (the opt-in is the editor setting). */
recording: { dir: string }
stateFile: string
/** Exit after this many minutes without any request (0 = never). Editors poll every 15 s while open. */
idleShutdownMinutes?: number
/** Opaque tag reported by /health (the VS Code extension uses it to replace daemons left over from older versions). */
instanceTag?: string
}
/** Toggles that survive daemon restarts and are shared by every editor talking to this daemon. */
export interface RuntimeState {
enabled: boolean
model: string
mode: ContextMode
}
export function loadConfig(configPath: string): DaemonConfig {
const dir = path.dirname(path.resolve(configPath))
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as DaemonConfig
const abs = (p: string) => (path.isAbsolute(p) || !p.includes('/') ? p : path.resolve(dir, p)) // bare names (e.g. "llama-server") stay on PATH
raw.tokenizer = { json: abs(raw.tokenizer.json), config: abs(raw.tokenizer.config) }
raw.llamaServer.binary = abs(raw.llamaServer.binary)
for (const m of Object.values(raw.models)) m.path = abs(m.path)
raw.telemetry.dir = abs(raw.telemetry.dir)
raw.recording.dir = abs(raw.recording.dir)
raw.stateFile = abs(raw.stateFile)
if (!raw.models[raw.defaultModel]) throw new Error(`defaultModel ${raw.defaultModel} is not in models`)
for (const [name, m] of Object.entries(raw.models)) {
if (!m.modes.includes(m.defaultMode)) throw new Error(`model ${name}: defaultMode must be one of its modes`)
}
return raw
}
export function loadState(config: DaemonConfig): RuntimeState {
const fallback: RuntimeState = { enabled: true, model: config.defaultModel, mode: config.models[config.defaultModel].defaultMode }
try {
const s = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')) as Partial<RuntimeState>
const model = s.model && config.models[s.model] ? s.model : fallback.model
const mode = s.mode && config.models[model].modes.includes(s.mode) ? s.mode : config.models[model].defaultMode
return { enabled: s.enabled ?? true, model, mode }
} catch {
return fallback
}
}
export function saveState(config: DaemonConfig, state: RuntimeState): void {
fs.mkdirSync(path.dirname(config.stateFile), { recursive: true })
fs.writeFileSync(config.stateFile, JSON.stringify(state, null, 2) + '\n')
}
+111
View File
@@ -0,0 +1,111 @@
import { spawn, type ChildProcess } from 'node:child_process'
/**
* Owns one llama-server process. Completions stream internally so that an
* aborted request (the user typed again) closes the connection and llama-server
* stops generating immediately.
*/
export interface Timings {
prompt_n: number
prompt_ms: number
predicted_n: number
predicted_ms: number
cache_n?: number
}
export interface CompletionResult {
text: string
timings: Timings | null
stoppedBy: 'eos' | 'stop' | 'limit' | 'aborted'
}
export class LlamaServer {
private proc: ChildProcess | null = null
private log = ''
model: string | null = null
private binary: string
private port: number
private ctx: number
private args: string[]
constructor(opts: { binary: string; port: number; ctx: number; args: string[] }) {
this.binary = opts.binary
this.port = opts.port
this.ctx = opts.ctx
this.args = opts.args
}
get url() { return `http://127.0.0.1:${this.port}` }
async start(modelPath: string): Promise<void> {
await this.stop()
this.log = ''
const proc = spawn(this.binary, ['-m', modelPath, '--port', String(this.port), '--host', '127.0.0.1', '-c', String(this.ctx), '-np', '1', '--no-webui', ...this.args], {
stdio: ['ignore', 'ignore', 'pipe'],
})
proc.stderr!.on('data', d => { this.log = (this.log + d).slice(-8000) })
this.proc = proc
for (let i = 0; i < 240; i++) {
await new Promise(r => setTimeout(r, 250))
if (proc.exitCode !== null) throw new Error(`llama-server exited (code ${proc.exitCode}):\n${this.log}`)
try {
if ((await fetch(`${this.url}/health`)).ok) { this.model = modelPath; return }
} catch { /* not listening yet */ }
}
await this.stop()
throw new Error(`llama-server did not become healthy within 60 s:\n${this.log}`)
}
async stop(): Promise<void> {
const proc = this.proc
this.proc = null
this.model = null
if (!proc || proc.exitCode !== null) return
const exited = new Promise(r => proc.once('exit', r))
proc.kill('SIGTERM')
await Promise.race([exited, new Promise(r => setTimeout(r, 3000))])
if (proc.exitCode === null) proc.kill('SIGKILL')
}
get running() { return this.proc !== null && this.proc.exitCode === null && this.model !== null }
async complete(prompt: number[], opts: { nPredict: number; stop: string[] }, signal: AbortSignal): Promise<CompletionResult> {
let text = ''
let timings: Timings | null = null
let stoppedBy: CompletionResult['stoppedBy'] = 'limit'
try {
const res = await fetch(`${this.url}/completion`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt, n_predict: opts.nPredict, stop: opts.stop, stream: true,
temperature: 0, top_k: 1, cache_prompt: true, id_slot: 0,
}),
signal,
})
if (!res.ok || !res.body) throw new Error(`llama-server ${res.status}: ${await res.text()}`)
const decoder = new TextDecoder()
let buffer = ''
for await (const chunk of res.body) {
buffer += decoder.decode(chunk as Uint8Array, { stream: true })
let nl: number
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim()
buffer = buffer.slice(nl + 1)
if (!line.startsWith('data:')) continue
const event = JSON.parse(line.slice(5)) as { content?: string; stop?: boolean; timings?: Timings; stop_type?: string }
if (event.content) text += event.content
if (event.stop) {
timings = event.timings ?? null
stoppedBy = event.stop_type === 'eos' ? 'eos' : event.stop_type === 'word' ? 'stop' : 'limit'
}
}
}
} catch (e) {
if (signal.aborted) return { text, timings, stoppedBy: 'aborted' }
throw e
}
return { text, timings, stoppedBy }
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* acd: local completion daemon.
*
* node src/main.ts --config acd.config.json
*
* Endpoints (JSON over HTTP, localhost by default):
* POST /v1/fim { repo, path, prefix, suffix, mode?, singleLine?, ... } -> { text, mode, timings, ... }
* GET /v1/config current toggles + the modes each model supports
* PUT /v1/config { enabled?, model?, mode? } (changing model restarts llama-server)
* POST /v1/feedback { requestId, event: shown|accepted|partial|rejected, acceptedText? }
* POST /v1/edits { events: [...] } opted-in edit recordings from editors -> recording.dir
* POST /v1/shutdown stop llama-server and exit (used by the extension to replace an outdated daemon)
* GET /health
*
* A new /v1/fim request cancels any in-flight one (there is one llama-server
* slot, and the user has moved on anyway); a client disconnect cancels too.
*/
import http from 'node:http'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { loadConfig, loadState, saveState, type ContextMode, type RuntimeState } from './config.ts'
import { LlamaServer } from './llama.ts'
import { PromptBuilder, STOP_STRINGS, type FimRequest } from './prompt-builder.ts'
import { Telemetry } from './telemetry.ts'
import { trimSuffixOverlap } from '../../shared/prompt.ts'
import { symbolLang } from '../../shared/symbol-lang.ts'
import { SymbolService, mightNeedThis } from './symbols.ts'
const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' } } })
const config = loadConfig(args.config!)
const state: RuntimeState = loadState(config)
const builder = new PromptBuilder(config)
const llama = new LlamaServer(config.llamaServer)
const telemetry = new Telemetry(config.telemetry)
const symbols = new SymbolService(config.tokenizer)
let loading: Promise<void> | null = null
let loadError: string | null = null
function loadModel(name: string) {
loadError = null
loading = llama.start(config.models[name].path)
.catch(e => { loadError = (e as Error).message; console.error(loadError) })
.finally(() => { loading = null })
return loading
}
let inflight: AbortController | null = null
function describeConfig() {
return {
...state,
effectiveMode: state.mode,
models: Object.fromEntries(Object.entries(config.models).map(([n, m]) => [n, { order: m.order, modes: m.modes, defaultMode: m.defaultMode }])),
llama: { running: llama.running, loading: loading !== null, error: loadError },
}
}
async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: AbortSignal) {
const t0 = performance.now()
if (!state.enabled) return json(res, 200, { text: '', disabled: true })
if (loading) return json(res, 503, { error: 'model loading' })
if (!llama.running) return json(res, 503, { error: loadError ?? 'llama-server not running' })
if (typeof body.prefix !== 'string' || typeof body.suffix !== 'string' || !body.path || !body.repo) {
return json(res, 400, { error: 'repo, path, prefix and suffix are required' })
}
const profile = config.models[state.model]
const requested = body.mode && profile.modes.includes(body.mode) ? body.mode : state.mode
let mode: ContextMode = requested
const lang = symbolLang(body.languageId ?? '')
const root = body.workspaceRoot
const filePath = root ? path.join(root, body.path) : null
if (root && lang) symbols.warm(root)
// Missing `this.`: the identifier being typed only exists as a class member.
// Complete as if `this.` were there and tell the editor to replace the typed identifier.
let prefix = body.prefix
let repair: { replaceBefore: number; typed: string; replacement: string } | null = null
if (config.thisRepair.enabled && root && filePath && lang && mightNeedThis(prefix)) {
const member = await symbols.thisMember({ root, filePath, lang, prefix, suffix: body.suffix, waitMs: config.thisRepair.waitMs })
if (member) {
const typed = prefix.slice(-member.identLength)
repair = { replaceBefore: member.identLength, typed, replacement: `this.${typed}` }
prefix = prefix.slice(0, -member.identLength) + repair.replacement
}
}
let symbolContext: string | undefined
let symbolInfo: Record<string, unknown> | undefined
if (mode === 'symbols') {
if (root && filePath && lang) {
// Anchor on the original text so a repair doesn't churn the cached context.
const s = await symbols.context({ root, filePath, lang, prefix: body.prefix, suffix: body.suffix, budget: config.symbols.budget, members: config.symbols.members, waitMs: config.symbols.waitMs })
symbolContext = s.result?.context
symbolInfo = { status: s.status, items: s.result?.items ?? 0 }
} else {
mode = 'simple' // no workspace or not a TS-family file: header without symbols (Stage C saw this via context dropout)
}
}
const prompt = builder.build({ ...body, prefix }, profile, mode, symbolContext)
inflight?.abort()
const controller = new AbortController()
inflight = controller
clientGone.addEventListener('abort', () => controller.abort())
const p = config.prompt
const result = await llama.complete(prompt.ids, {
nPredict: prompt.singleLine ? p.nPredictSingleLine : p.nPredictMultiLine,
stop: prompt.singleLine ? [...STOP_STRINGS, '\n'] : STOP_STRINGS,
}, controller.signal)
if (inflight === controller) inflight = null
if (result.stoppedBy === 'aborted') {
if (!res.writableEnded && !res.destroyed) json(res, 200, { text: '', cancelled: true })
return
}
let text = trimSuffixOverlap(result.text, body.suffix)
if (prompt.singleLine) text = text.split('\n')[0]
const maxLines = body.maxLines ?? p.maxLines
const lines = text.split('\n')
if (lines.length > maxLines) text = lines.slice(0, maxLines).join('\n')
if (!text.trim()) text = ''
// With a repair, the edit replaces the typed identifier: "getU" -> "this.getUser(id)".
// filterText starts with what was typed, which is what VS Code requires to show it.
const filterText = repair ? repair.typed + text : undefined
if (repair) text = repair.replacement + text
const response = {
requestId: body.requestId,
text,
model: state.model,
mode,
requestedMode: requested,
singleLine: prompt.singleLine,
replaceBefore: repair?.replaceBefore ?? 0,
filterText,
symbols: symbolInfo,
stoppedBy: result.stoppedBy,
timings: {
tokenizeMs: round(prompt.tokenizeMs),
promptTokens: prompt.ids.length,
processedTokens: result.timings?.prompt_n ?? null,
promptMs: round(result.timings?.prompt_ms),
genTokens: result.timings?.predicted_n ?? null,
genMs: round(result.timings?.predicted_ms),
totalMs: round(performance.now() - t0),
},
}
telemetry.request(body, response)
json(res, 200, response)
}
const round = (x: number | undefined) => (x === undefined ? null : Math.round(x * 10) / 10)
function json(res: http.ServerResponse, status: number, body: unknown) {
if (res.writableEnded || res.destroyed) return
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
async function readBody(req: http.IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
for await (const c of req) chunks.push(c as Buffer)
const text = Buffer.concat(chunks).toString('utf8')
return text ? JSON.parse(text) : {}
}
let lastActivity = Date.now()
if (config.idleShutdownMinutes) {
setInterval(() => {
if (Date.now() - lastActivity > config.idleShutdownMinutes! * 60_000) {
console.log(`idle for ${config.idleShutdownMinutes} min, shutting down`)
void llama.stop().then(() => process.exit(0))
}
}, 30_000).unref()
}
const server = http.createServer(async (req, res) => {
lastActivity = Date.now()
// Cancel the completion if the editor gives up on this request.
const gone = new AbortController()
res.on('close', () => { if (!res.writableEnded) gone.abort() })
try {
const route = `${req.method} ${req.url?.split('?')[0]}`
if (route === 'GET /health') return json(res, 200, { ok: llama.running, loading: loading !== null, model: state.model, error: loadError, instanceTag: config.instanceTag ?? null, pid: process.pid })
if (route === 'POST /v1/shutdown') {
json(res, 200, { ok: true })
void llama.stop().then(() => process.exit(0))
return
}
if (route === 'GET /v1/config') return json(res, 200, describeConfig())
if (route === 'PUT /v1/config') {
const body = await readBody(req) as Partial<RuntimeState>
if (body.model !== undefined && !config.models[body.model]) return json(res, 400, { error: `unknown model ${body.model}` })
const model = body.model ?? state.model
const mode = body.mode ?? (body.model ? config.models[model].defaultMode : state.mode)
if (!config.models[model].modes.includes(mode)) return json(res, 400, { error: `model ${model} supports modes ${config.models[model].modes.join(', ')}` })
const modelChanged = model !== state.model
Object.assign(state, { model, mode, enabled: body.enabled ?? state.enabled })
saveState(config, state)
if (modelChanged) void loadModel(model)
return json(res, 200, describeConfig())
}
if (route === 'POST /v1/fim') return await handleFim(await readBody(req) as FimRequest, res, gone.signal)
if (route === 'POST /v1/edits') {
const body = await readBody(req) as { events?: unknown[] }
return json(res, 200, { recorded: Telemetry.recordEdits(config.recording.dir, body.events ?? []) })
}
if (route === 'POST /v1/feedback') {
telemetry.feedback(await readBody(req))
return json(res, 200, { ok: true })
}
json(res, 404, { error: 'not found' })
} catch (e) {
console.error(e)
json(res, 500, { error: (e as Error).message })
}
})
process.on('SIGINT', () => void llama.stop().then(() => process.exit(0)))
process.on('SIGTERM', () => void llama.stop().then(() => process.exit(0)))
server.listen(config.port, config.host, () => {
console.log(`acd listening on http://${config.host}:${config.port} (model ${state.model}, mode ${state.mode})`)
})
await loadModel(state.model)
if (llama.running) console.log(`llama-server ready: ${config.models[state.model].path}`)
+77
View File
@@ -0,0 +1,77 @@
import fs from 'node:fs'
import { Tokenizer } from '@huggingface/tokenizers'
import { chunkedPrefixTokens, fimPrompt, headTokens, repoHeader, wantsSingleLine } from '../../shared/prompt.ts'
import type { ContextMode, DaemonConfig, ModelProfile } from './config.ts'
export interface FimRequest {
requestId?: string
/** Workspace folder name; Stage C was trained with the repo directory name here. */
repo: string
/** Path relative to the workspace folder, '/'-separated. */
path: string
/** Absolute workspace folder on the daemon's machine; enables symbols mode and the this.-repair. */
workspaceRoot?: string
languageId?: string
prefix: string
suffix: string
/** Absolute line number of prefix's first line, if the client sent a truncated document. */
prefixLineOffset?: number
mode?: ContextMode
/** Override automatic single-line detection. */
singleLine?: boolean
maxLines?: number
}
export interface BuiltPrompt {
ids: number[]
mode: ContextMode
singleLine: boolean
prefixStartLine: number
tokenizeMs: number
}
export const STOP_STRINGS = ['<|endoftext|>', '<|fim_prefix|>', '<|fim_suffix|>', '<|fim_middle|>', '<|fim_pad|>', '<|file_sep|>', '<|repo_name|>', '<|im_start|>', '<|im_end|>']
export class PromptBuilder {
private encodeFn: (s: string) => number[]
private fim: { prefix: number; suffix: number; middle: number }
private header: { repoName: number; fileSep: number }
private config: DaemonConfig
constructor(config: DaemonConfig) {
this.config = config
const tokenizer = new Tokenizer(
JSON.parse(fs.readFileSync(config.tokenizer.json, 'utf8')),
JSON.parse(fs.readFileSync(config.tokenizer.config, 'utf8')),
)
this.encodeFn = s => tokenizer.encode(s, { add_special_tokens: false }).ids
const id = (t: string) => {
const v = tokenizer.token_to_id(t)
if (v === undefined) throw new Error(`tokenizer is missing ${t}`)
return v
}
this.fim = { prefix: id('<|fim_prefix|>'), suffix: id('<|fim_suffix|>'), middle: id('<|fim_middle|>') }
this.header = { repoName: id('<|repo_name|>'), fileSep: id('<|file_sep|>') }
}
encode(s: string) { return this.encodeFn(s) }
/**
* Builds the prompt in the model's trained layout. `symbols` context is passed
* in by the caller (empty until the symbol provider exists, which degrades to `simple`).
*/
build(req: FimRequest, profile: ModelProfile, mode: ContextMode, symbols?: string): BuiltPrompt {
const t0 = performance.now()
const p = this.config.prompt
const { tokens: prefix, startLine } = chunkedPrefixTokens(req.prefix, p.prefixTokens, this.encodeFn, p.prefixChunkLines, req.prefixLineOffset ?? 0)
const suffix = headTokens(req.suffix, p.suffixTokens, this.encodeFn)
const header = mode === 'raw' ? [] : repoHeader(req.repo, req.path, mode === 'symbols' ? symbols : undefined, this.encodeFn, this.header)
return {
ids: [...header, ...fimPrompt(profile.order, prefix, suffix, this.fim)],
mode,
singleLine: req.singleLine ?? wantsSingleLine(req.suffix),
prefixStartLine: startLine,
tokenizeMs: performance.now() - t0,
}
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Worker thread that owns the TypeScript language services (one per workspace
* root). Building a program for a large repo takes seconds, which must never
* block the request loop, so all symbol work happens here.
*/
import fs from 'node:fs'
import { parentPort, workerData } from 'node:worker_threads'
import { Tokenizer } from '@huggingface/tokenizers'
import { WorkspaceSymbols, type SymbolLang } from '../../shared/symbols.ts'
export type WorkerRequest =
| { id: number; kind: 'warm'; root: string }
| { id: number; kind: 'context'; root: string; filePath: string; lang: SymbolLang; visible: string; cursor: number; budget: number; members: boolean }
| { id: number; kind: 'thisMember'; root: string; filePath: string; lang: SymbolLang; visible: string; cursor: number }
export interface WorkerResponse { id: number; result?: unknown; error?: string; ms: number }
const { tokenizerJson, tokenizerConfig } = workerData as { tokenizerJson: string; tokenizerConfig: string }
const tokenizer = new Tokenizer(JSON.parse(fs.readFileSync(tokenizerJson, 'utf8')), JSON.parse(fs.readFileSync(tokenizerConfig, 'utf8')))
const countTokens = (s: string) => tokenizer.encode(s, { add_special_tokens: false }).ids.length
const workspaces = new Map<string, WorkspaceSymbols>()
const workspace = (root: string) => {
let ws = workspaces.get(root)
if (!ws) { ws = new WorkspaceSymbols(root); workspaces.set(root, ws) }
return ws
}
parentPort!.on('message', (req: WorkerRequest) => {
const t0 = performance.now()
try {
let result: unknown = null
if (req.kind === 'warm') workspace(req.root)
if (req.kind === 'context') {
result = workspace(req.root).context({ filePath: req.filePath, lang: req.lang, visible: req.visible, cursor: req.cursor, budget: req.budget, countTokens, members: req.members })
}
if (req.kind === 'thisMember') result = workspace(req.root).thisMember(req.filePath, req.lang, req.visible, req.cursor)
parentPort!.postMessage({ id: req.id, result, ms: performance.now() - t0 } satisfies WorkerResponse)
} catch (e) {
parentPort!.postMessage({ id: req.id, error: (e as Error).message, ms: performance.now() - t0 } satisfies WorkerResponse)
}
})
+110
View File
@@ -0,0 +1,110 @@
import { Worker } from 'node:worker_threads'
import crypto from 'node:crypto'
import type { SymbolsResult } from '../../shared/symbols.ts'
import type { SymbolLang } from '../../shared/symbol-lang.ts'
import type { WorkerRequest, WorkerResponse } from './symbols-worker.ts'
type Payload<K extends WorkerRequest['kind']> = Omit<Extract<WorkerRequest, { kind: K }>, 'id' | 'kind'>
/**
* Symbol context for `symbols` mode, with caching that keeps prompts stable.
*
* Context is cached per anchor: file + enclosing top-level declaration + the
* file's import block. While you type inside one function the header is
* identical, so llama-server's KV cache keeps hitting. When the anchor changes
* we wait briefly for fresh context; if the language service is slower (e.g.
* still building the program), the previous context for the file is used
* (stale-while-revalidate) and the fresh one lands in the cache for the next keystroke.
*/
export class SymbolService {
private worker: Worker
private nextId = 1
private pending = new Map<number, (r: WorkerResponse) => void>()
private cache = new Map<string, SymbolsResult | null>()
private lastForFile = new Map<string, SymbolsResult | null>()
private inflight = new Set<string>()
private warmed = new Set<string>()
constructor(tokenizer: { json: string; config: string }) {
// Source runs load the .ts worker (Node type stripping); the bundled extension build ships symbols-worker.mjs.
const workerFile = import.meta.url.endsWith('.ts') ? './symbols-worker.ts' : './symbols-worker.mjs'
this.worker = new Worker(new URL(workerFile, import.meta.url), { workerData: { tokenizerJson: tokenizer.json, tokenizerConfig: tokenizer.config } })
this.worker.on('message', (r: WorkerResponse) => { this.pending.get(r.id)?.(r); this.pending.delete(r.id) })
this.worker.on('error', e => console.error('symbols worker error:', e))
this.worker.unref()
}
private call<K extends WorkerRequest['kind']>(kind: K, payload: Payload<K>): Promise<WorkerResponse> {
const id = this.nextId++
return new Promise(resolve => {
this.pending.set(id, resolve)
this.worker.postMessage({ id, kind, ...payload })
})
}
/** Start building the language service for a workspace before it's needed. */
warm(root: string) {
if (this.warmed.has(root)) return
this.warmed.add(root)
void this.call('warm', { root })
}
async context(opts: { root: string; filePath: string; lang: SymbolLang; prefix: string; suffix: string; budget: number; members: boolean; waitMs: number }):
Promise<{ result: SymbolsResult | null; status: 'hit' | 'fresh' | 'stale' | 'none' }> {
const key = anchorKey(opts.filePath, opts.prefix, opts.members)
if (this.cache.has(key)) {
const result = this.cache.get(key)!
this.lastForFile.set(opts.filePath, result)
return { result, status: 'hit' }
}
const request = this.inflight.has(key) ? null : this.call('context', {
root: opts.root, filePath: opts.filePath, lang: opts.lang, visible: opts.prefix + opts.suffix, cursor: opts.prefix.length, budget: opts.budget, members: opts.members,
})
if (request) {
this.inflight.add(key)
void request.then(r => {
this.inflight.delete(key)
if (r.error) console.error('symbols:', r.error)
this.remember(key, (r.result as SymbolsResult | null) ?? null)
})
const fresh = await Promise.race([request, new Promise<null>(r => setTimeout(() => r(null), opts.waitMs))])
if (fresh && !fresh.error) {
const result = (fresh.result as SymbolsResult | null) ?? null
this.lastForFile.set(opts.filePath, result)
return { result, status: 'fresh' }
}
}
const stale = this.lastForFile.get(opts.filePath)
return stale !== undefined ? { result: stale, status: 'stale' } : { result: null, status: 'none' }
}
private remember(key: string, result: SymbolsResult | null) {
this.cache.set(key, result)
if (this.cache.size > 500) this.cache.delete(this.cache.keys().next().value!)
}
async thisMember(opts: { root: string; filePath: string; lang: SymbolLang; prefix: string; suffix: string; waitMs: number }) {
const r = await Promise.race([
this.call('thisMember', { root: opts.root, filePath: opts.filePath, lang: opts.lang, visible: opts.prefix + opts.suffix, cursor: opts.prefix.length }),
new Promise<null>(r => setTimeout(() => r(null), opts.waitMs)),
])
return (r?.result as { name: string; identLength: number } | null) ?? null
}
}
const TOP_LEVEL = /^(export\s|async\s|function[\s*]|class\s|const\s|let\s|var\s|interface\s|type\s|enum\s|abstract\s|declare\s|<script|<template|<style|@)/
/** file + enclosing top-level declaration (line index) + hash of the import lines (+ receiver when member context is on). */
export function anchorKey(filePath: string, prefix: string, members: boolean): string {
const lines = prefix.split('\n')
let anchor = 0
for (let i = lines.length - 1; i >= 0; i--) if (TOP_LEVEL.test(lines[i])) { anchor = i; break }
const imports = crypto.createHash('sha1').update(lines.filter(l => /^\s*import\s/.test(l)).join('\n')).digest('hex').slice(0, 12)
const receiver = members ? (/([\w$.()[\]'"]+)\.[\w$]*$/.exec(prefix.slice(-200))?.[1] ?? '') : ''
return `${filePath}|${anchor}|${imports}|${receiver}`
}
/** Cheap pre-check before asking the language service whether `this.` is missing. */
export function mightNeedThis(prefix: string): boolean {
return /(^|[^\w$.])[A-Za-z_$][\w$]+$/.test(prefix.slice(-100)) && !/\b(function|const|let|var|class|interface|type|enum|import|export|as|new)\s+[A-Za-z_$][\w$]*$/.test(prefix.slice(-100))
}
+54
View File
@@ -0,0 +1,54 @@
import fs from 'node:fs'
import path from 'node:path'
import type { FimRequest } from './prompt-builder.ts'
/**
* Local-only, opt-in telemetry (config.telemetry.enabled). One JSONL file per day.
*
* Besides explicit feedback events from the editor, acceptance is inferred:
* if the next request for the same file has a prefix that ends with the
* previous prefix + the suggested text, the suggestion was typed or accepted.
* That is the only acceptance signal Zed can give us, and a cross-check for VS Code.
*/
export class Telemetry {
private enabled: boolean
private dir: string
private last = new Map<string, { requestId?: string; prefixTail: string; text: string }>()
constructor(opts: { enabled: boolean; dir: string }) {
this.enabled = opts.enabled
this.dir = opts.dir
}
private write(record: Record<string, unknown>) {
if (!this.enabled) return
fs.mkdirSync(this.dir, { recursive: true })
const file = path.join(this.dir, `${new Date().toISOString().slice(0, 10)}.jsonl`)
fs.appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...record }) + '\n')
}
request(req: FimRequest, res: { requestId?: string; text: string; mode: string; model: string; timings: unknown }) {
if (!this.enabled) return
const key = `${req.repo}/${req.path}`
const prev = this.last.get(key)
if (prev?.text) {
const accepted = req.prefix.endsWith(prev.prefixTail + prev.text)
if (accepted) this.write({ event: 'inferred-accept', requestId: prev.requestId, path: key, chars: prev.text.length })
}
this.last.set(key, { requestId: req.requestId, prefixTail: req.prefix.slice(-200), text: res.text })
this.write({ event: 'completion', requestId: req.requestId, path: key, languageId: req.languageId, model: res.model, mode: res.mode, chars: res.text.length, lines: res.text ? res.text.split('\n').length : 0, timings: res.timings })
}
/** Editor edit events (opt-in in the editor). Always local, one JSONL file per day. */
static recordEdits(dir: string, events: unknown[]) {
if (!Array.isArray(events) || !events.length) return 0
fs.mkdirSync(dir, { recursive: true })
const file = path.join(dir, `edits-${new Date().toISOString().slice(0, 10)}.jsonl`)
fs.appendFileSync(file, events.map(e => JSON.stringify(e)).join('\n') + '\n')
return events.length
}
feedback(body: unknown) {
this.write({ event: 'feedback', ...(body as Record<string, unknown>) })
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* End-to-end smoke test: starts acd, then exercises it the way an editor would.
*
* node test/smoke.ts [--config acd.config.json] [--typing-samples 15]
*
* Symbols/this.-repair checks and the typing replay use the private training workspace's ../data
* (held-out samples, installed repos) when present, and are skipped otherwise.
*/
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { spawn } from 'node:child_process'
import { parseArgs } from 'node:util'
const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' }, 'typing-samples': { type: 'string', default: '15' }, entry: { type: 'string', default: 'src/main.ts' } } })
const config = JSON.parse(fs.readFileSync(args.config!, 'utf8')) as { port: number; stateFile: string }
const base = `http://127.0.0.1:${config.port}`
let failures = 0
const check = (name: string, ok: boolean, detail = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failures++
}
// --entry ../extensions/vscode/dist/daemon/acd.mjs tests the bundled build the extension ships.
const daemon = spawn(process.execPath, [args.entry!, '--config', args.config!], { stdio: ['ignore', 'pipe', 'pipe'] })
let daemonLog = ''
daemon.stdout.on('data', d => { daemonLog += d })
daemon.stderr.on('data', d => { daemonLog += d })
const cleanup = () => { daemon.kill('SIGTERM') }
process.on('exit', cleanup)
async function api(method: string, route: string, body?: unknown, signal?: AbortSignal) {
const res = await fetch(base + route, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal })
return { status: res.status, body: await res.json() as Record<string, any> }
}
for (let i = 0; ; i++) {
await new Promise(r => setTimeout(r, 500))
try { if ((await api('GET', '/health')).body.ok) break } catch { /* starting */ }
if (i > 120) { console.error(daemonLog); throw new Error('daemon did not become healthy') }
}
// ---------- config / toggle
await api('PUT', '/v1/config', { model: 'stage-c', mode: 'simple', enabled: true })
const cfg = (await api('GET', '/v1/config')).body
check('config lists model profiles', !!cfg.models?.['stage-c']?.modes?.includes('symbols'), JSON.stringify(cfg.models?.['stage-c']))
check('invalid mode for model is rejected', (await api('PUT', '/v1/config', { mode: 'raw' })).status === 400)
const sym = (await api('PUT', '/v1/config', { mode: 'symbols' })).body
check('symbols mode accepted', sym.mode === 'symbols' && sym.effectiveMode === 'symbols')
await api('PUT', '/v1/config', { mode: 'simple' })
check('state persisted to disk', JSON.parse(fs.readFileSync(path.resolve(path.dirname(args.config!), config.stateFile), 'utf8')).mode === 'simple')
// ---------- correctness on hand-written cases
const fim = (prefix: string, suffix: string, extra: Record<string, unknown> = {}, signal?: AbortSignal) =>
api('POST', '/v1/fim', { repo: 'smoke', path: 'src/example.ts', languageId: 'typescript', prefix, suffix, ...extra }, signal)
const zod = await fim("import { z } from 'zod'\n\nexport const configSchema = z.object({\n port: z.coerce.number().default(3000),\n})\n\nexport type Config = ", '\n')
check('zod type completion', zod.body.text.includes('z.infer<typeof configSchema>'), JSON.stringify(zod.body.text))
const obj = await fim('export interface Invoice {\n id: string\n memo: string\n paid: boolean\n}\n\nexport function createInvoice(memo = \'\'): Invoice {\n return {\n id: crypto.randomUUID(),\n ', '\n }\n}\n')
check('multi-line object completion stops before the suffix', obj.body.text.includes('paid') && !obj.body.text.includes('}\n}'), JSON.stringify(obj.body.text))
const single = await fim('const total = items.reduce((sum, item) => sum + item.', ', 0)\nconsole.log(total)\n')
check('single-line mode when code follows the cursor', single.body.singleLine === true && !single.body.text.includes('\n'), JSON.stringify(single.body.text))
// ---------- cancellation: a newer request cancels the in-flight one
const slow = fim('export async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {\n ', '\n}\n', { maxLines: 50 })
await new Promise(r => setTimeout(r, 30))
const fast = fim('const answer = ', '\n')
const [slowRes, fastRes] = await Promise.all([slow, fast])
check('newer request cancels in-flight one', slowRes.body.cancelled === true && fastRes.status === 200, `first=${JSON.stringify(slowRes.body).slice(0, 80)}`)
const aborter = new AbortController()
const aborted = fim('function fib(n: number): number {\n ', '\n}\n', {}, aborter.signal).catch(e => ({ status: -1, body: { error: (e as Error).name } }))
setTimeout(() => aborter.abort(), 20)
check('client abort is handled', (await aborted).body.error === 'AbortError')
check('daemon still healthy after aborts', (await api('GET', '/health')).body.ok === true)
// ---------- symbols mode + this.-repair
// Needs a TS project with node_modules installed: SMOKE_TS_ROOT (project dir), SMOKE_TS_FILE (a .ts file in it,
// relative) and SMOKE_TS_ANCHOR (text in that file to put the cursor before). Skipped when not set.
const envRoot = process.env.SMOKE_TS_ROOT ? path.resolve(process.env.SMOKE_TS_ROOT) : ''
if (envRoot && fs.existsSync(envRoot) && process.env.SMOKE_TS_FILE && process.env.SMOKE_TS_ANCHOR) {
const file = process.env.SMOKE_TS_FILE
const content = fs.readFileSync(path.join(envRoot, file), 'utf8')
const at = content.indexOf(process.env.SMOKE_TS_ANCHOR)
const req = { repo: path.basename(envRoot), path: file, workspaceRoot: envRoot, languageId: 'typescript', prefix: content.slice(0, at), suffix: content.slice(at), mode: 'symbols' }
let first = (await api('POST', '/v1/fim', req)).body
for (let i = 0; i < 20 && first.symbols?.status !== 'hit' && !(first.symbols?.items > 0); i++) {
await new Promise(r => setTimeout(r, 250)) // language service still building the program
first = (await api('POST', '/v1/fim', req)).body
}
check('symbols mode attaches language-service context', first.mode === 'symbols' && first.symbols?.items > 0, JSON.stringify(first.symbols))
const again = (await api('POST', '/v1/fim', { ...req, prefix: req.prefix + 'x' })).body
check('same anchor reuses cached context (KV-stable header)', again.symbols?.status === 'hit', JSON.stringify(again.symbols))
const outside = (await api('POST', '/v1/fim', { ...req, workspaceRoot: undefined })).body
check('symbols without a workspace falls back to simple', outside.mode === 'simple')
const cls = 'export class UserService {\n private users = new Map<string, string>()\n\n getUser(id: string) {\n return this.users.get(id)\n }\n\n printUser(id: string) {\n const user = getU'
const repaired = (await api('POST', '/v1/fim', { repo: req.repo, path: 'src/probe-user-service.ts', workspaceRoot: envRoot, languageId: 'typescript', prefix: cls, suffix: '\n }\n}\n' })).body
check('missing this. is repaired as a replacement', repaired.replaceBefore === 4 && repaired.text.startsWith('this.getU') && repaired.filterText?.startsWith('getU'), JSON.stringify({ text: repaired.text, replaceBefore: repaired.replaceBefore, filterText: repaired.filterText }))
const local = (await api('POST', '/v1/fim', { repo: req.repo, path: 'src/probe-user-service.ts', workspaceRoot: envRoot, languageId: 'typescript', prefix: cls.replace('const user = getU', 'const getUsername = () => id\n const user = getU'), suffix: '\n }\n}\n' })).body
check('no repair when a local matches', local.replaceBefore === 0, JSON.stringify(local.text))
} else {
console.log('SKIP symbols / this.-repair checks (set SMOKE_TS_ROOT, SMOKE_TS_FILE, SMOKE_TS_ANCHOR)')
}
// ---------- edit recording endpoint
const recorded = await api('POST', '/v1/edits', { events: [{ t: Date.now(), repo: 'smoke', path: 'src/a.ts', version: 2, changes: [{ offset: 0, length: 0, text: 'x' }], snapshot: 'x' }] })
check('edit recordings are accepted', recorded.body.recorded === 1)
// ---------- warm typing latency on held-out code
// Optional typing replay over held-out FIM samples from the (private) training workspace.
const dataDir = path.resolve(process.env.SMOKE_DATA_DIR ?? '../../data')
const testFim = path.join(dataDir, 'fim/test-repo.jsonl')
if (fs.existsSync(testFim)) {
const files = new Map(fs.readFileSync(path.join(dataDir, 'corpus/files.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)).map(r => [r.id, r]))
const rank = (id: string) => crypto.createHash('sha256').update(`smoke:${id}`).digest('hex')
const samples = fs.readFileSync(testFim, 'utf8').trim().split('\n').map(l => JSON.parse(l))
.filter(s => s.suffix_mode === 'full' && ['line-rest', 'statement', 'call-args'].includes(s.type) && s.middle.length >= 12 && !s.middle.includes('\n'))
.sort((a, b) => rank(a.id).localeCompare(rank(b.id)))
.slice(0, Number(args['typing-samples']))
for (const mode of ['simple', 'symbols']) {
await api('PUT', '/v1/config', { mode })
const steps: { total: number; processed: number }[] = []
let firstAtLocation: number[] = []
for (const s of samples) {
const content = files.get(s.file_id).content as string
for (let typed = 0; typed <= s.middle.length; typed += 3) {
const workspaceRoot = path.join(dataDir, 'ts-env', s.repo)
const r = await api('POST', '/v1/fim', { repo: s.repo, path: s.path, workspaceRoot: fs.existsSync(workspaceRoot) ? workspaceRoot : undefined, languageId: s.lang, prefix: content.slice(0, s.start) + s.middle.slice(0, typed), suffix: content.slice(s.end) })
if (typed === 0) { firstAtLocation.push(r.body.timings.totalMs); continue }
steps.push({ total: r.body.timings.totalMs, processed: r.body.timings.processedTokens })
}
}
const pct = (xs: number[], p: number) => [...xs].sort((a, b) => a - b)[Math.min(xs.length - 1, Math.floor(p * xs.length))]
console.log(`\nwarm typing, mode=${mode} (${samples.length} completions, ${steps.length} keystroke requests, end-to-end through acd):`)
console.log(` first request at a new location: p50 ${pct(firstAtLocation, 0.5)} ms, p90 ${pct(firstAtLocation, 0.9)} ms`)
console.log(` per keystroke: total p50 ${pct(steps.map(s => s.total), 0.5)} ms, p90 ${pct(steps.map(s => s.total), 0.9)} ms; tokens re-processed p50 ${pct(steps.map(s => s.processed), 0.5)}, p90 ${pct(steps.map(s => s.processed), 0.9)}`)
check(`${mode}: warm keystrokes reuse the KV cache (p50 re-processed tokens < 50)`, pct(steps.map(s => s.processed), 0.5) < 50)
}
await api('PUT', '/v1/config', { mode: 'simple' })
}
console.log(failures ? `\n${failures} FAILED` : '\nall passed')
cleanup()
process.exit(failures ? 1 : 0)
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test", "../shared"]
}