7 Commits
Author SHA1 Message Date
HarvmasterandClaude Opus 5.5 9ccf108835 Extension: request the next edit right after accepting a suggestion (0.6.4)
ci / build (push) Canceled after 0s
Accepting a completion or a next-edit cancels the debounce timer and asks the daemon for
the follow-up immediately; next-edits chain. Setting localAutocomplete.nextEdit.afterAccept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-04 01:11:39 +00:00
HarvmasterandClaude Opus 5.5 3dc916834f Daemon: a changed default model overrides the saved state (0.6.3)
ci / build (push) Canceled after 0s
In managed mode the extension switches models by rewriting the daemon config's
defaultModel and restarting it, but loadState() preferred the persisted model, so the
daemon came back on the old one every time. State now records the default it was saved
under; a different default wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-04 01:06:31 +00:00
HarvmasterandClaude Opus 5.5 01e778daf6 Next-edit: n-gram speculative decoding on the companion server (0.6.2)
ci / build (push) Canceled after 0s
The rewritten region is mostly a copy of the current one, so prompt-lookup drafts are
accepted in long runs. Greedy decoding keeps the output identical. Laptop CPU, 160-token
region rewrite: generation 1.76 s -> 0.37 s. Timings now include errorsMs/promptMs/genMs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-04 00:54:49 +00:00
HarvmasterandClaude Opus 5.5 d246fb9349 Models stage-d (default) + stage-e (next-edit companion); extension 0.6.1
ci / build (push) Canceled after 0s
- stage-d: next-line / block-close / multi-line fixes, symbols format 2, TS errors in the header.
  Held-out: 0.426 exact / 0.700 edit sim (stage-c 0.405 / 0.689); block close 0.27 -> 0.79.
- stage-e: next-edit (80% exact region rewrites, abstains on 100% of no-edit samples).
- Manifest: nextEditModel (companion) and nextEditFormat (older extensions ignore it, so they
  never use next-edit with the empty-output deletion bug).
- Daemon: next-edit served by the companion model in a second llama-server, started after the
  main model loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-04 00:23:15 +00:00
HarvmasterandClaude Opus 5.5 5c99f011e8 Next-edit: treat empty model output as no-edit
ci / build (push) Canceled after 0s
llama-server leaves special tokens out of content, so the NO_EDIT token arrives as an
empty string. The daemon treated that as a rewrite of the region to nothing (Tab would
have deleted the region). shared/nextedit.ts parseNextEditOutput() is now used by the
daemon and the eval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-04 00:15:11 +00:00
HarvmasterandClaude Opus 5.5 f92341d1a2 shared/nextedit: trim before-context in a few encodes instead of one per line
ci / build (push) Canceled after 0s
Same output on 397/398 test prompts, ~6x faster prompt building (daemon and export).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-03 18:17:56 +00:00
HarvmasterandClaude Opus 5.5 4c86c2a703 Daemon: OpenAI-compatible /v1/completions for Zed
ci / build (push) Canceled after 0s
Parses Qwen-format FIM prompts (prefix- or suffix-first, optional repo/file headers) back into
prefix/suffix and rebuilds them in the trained layout; mode from the model string (ac:symbols /
ac:simple); files located via workspaceRoots by path or content. extensions/zed/README.md documents
the Zed settings; untested against real Zed traffic yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-03 17:25:27 +00:00
18 changed files with 362 additions and 39 deletions
+30
View File
@@ -2,6 +2,36 @@
Extension versions are released as `vscode-v<version>` on the [Releases](https://git.harvmaster.com/Harvmaster/autocomplete-model/releases) page. Models are versioned by their name in [`models/manifest.json`](models/manifest.json). Extension versions are released as `vscode-v<version>` on the [Releases](https://git.harvmaster.com/Harvmaster/autocomplete-model/releases) page. Models are versioned by their name in [`models/manifest.json`](models/manifest.json).
## vscode 0.6.4
- **Accepting a suggestion asks for the next edit immediately** (setting `nextEdit.afterAccept`, on by default). After
Tab on a completion or a next-edit, the follow-up is requested right away instead of after the typing pause, so
accepted next-edits chain (rename → next use → …).
## vscode 0.6.3
- **Fix: model choice didn't stick in managed mode.** The daemon's saved state (last model used) overrode both the
model picked in the menu and a new default from a release, so it kept restarting on the old model (e.g.
`stage-c`). A changed default now wins; switching through the daemon API still persists as before.
## vscode 0.6.2
- **Next-edit is ~3–5× faster:** the next-edit server uses n-gram speculative decoding (the rewritten region is mostly
a copy of the current one, so drafted tokens are verified in batches). Same output, since decoding is greedy.
Rewriting a 13-line region on a laptop CPU: ~2.1 s → ~0.7 s (~0.4 s when the prompt is cached).
- Next-edit timings in "Show Last Prompt" now split TS errors / prompt / generation time.
## vscode 0.6.1
- **New default model `stage-d`**, with **`stage-e` for next-edit**. Both download automatically (~530 MB each); the
daemon runs `stage-e` in a second llama-server, only for next-edit. `stage-d` closes blocks (`}`) and predicts the
next line far better (held-out exact match: block close 0.27 → 0.79, next line 0.01 → 0.18), and sees TypeScript
errors near the cursor. Results in [models/README.md](models/README.md).
- **Fix:** a "no edit" answer from a next-edit model could be applied as deleting the suggested region. llama-server
drops the model's no-edit token, so it arrived as empty text. Next-edit models are only enabled from this version on.
- Manifest: `nextEditModel` (companion model) and `nextEditFormat` (replaces `nextEdit`, which older versions ignore
from now on).
## vscode 0.6.0 ## vscode 0.6.0
- **Next-edit suggestions** (with models trained for it, e.g. the upcoming `stage-e`): after you pause typing, the model may suggest an edit around the cursor, such as removing a leftover `)` or updating a renamed variable's uses. Single-line changes show deleted text struck through and inserted text as ghost text; multi-line changes are highlighted with the diff on hover. **Tab** applies, **Esc** dismisses. Setting: `nextEdit.enabled`. - **Next-edit suggestions** (with models trained for it, e.g. the upcoming `stage-e`): after you pause typing, the model may suggest an edit around the cursor, such as removing a leftover `)` or updating a renamed variable's uses. Single-line changes show deleted text struck through and inserted text as ghost text; multi-line changes are highlighted with the diff on hover. **Tab** applies, **Esc** dismisses. Setting: `nextEdit.enabled`.
+2 -1
View File
@@ -76,6 +76,7 @@ VS Code extension ──HTTP──▶ acd daemon ──▶ llama-server (llama.c
|---|---| |---|---|
| [`models/`](models/) | GGUF models (Git LFS), `manifest.json`, model card, license | | [`models/`](models/) | GGUF models (Git LFS), `manifest.json`, model card, license |
| [`extensions/vscode/`](extensions/vscode/) | the VS Code extension | | [`extensions/vscode/`](extensions/vscode/) | the VS Code extension |
| [`extensions/zed/`](extensions/zed/) | Zed setup (settings for the daemon's OpenAI-compatible endpoint) |
| [`daemon/`](daemon/) | `acd`, the completion daemon (bundled into the extension; also runs standalone) | | [`daemon/`](daemon/) | `acd`, the completion daemon (bundled into the extension; also runs standalone) |
| [`shared/`](shared/) | prompt layout + symbol extraction, shared with the training pipeline so prompts match training exactly | | [`shared/`](shared/) | prompt layout + symbol extraction, shared with the training pipeline so prompts match training exactly |
| [`scripts/`](scripts/) | publish models and extension releases | | [`scripts/`](scripts/) | publish models and extension releases |
@@ -120,7 +121,7 @@ Running the daemon standalone (external mode) is covered in [daemon/README.md](d
- **TypeScript errors as context:** the model sees nearby compiler errors and is trained on error → fix. - **TypeScript errors as context:** the model sees nearby compiler errors and is trained on error → fix.
- **Suggestions on cursor move:** predict what you came to a location to do, not just what you're typing. - **Suggestions on cursor move:** predict what you came to a location to do, not just what you're typing.
- Token healing for completions that start mid-identifier. - Token healing for completions that start mid-identifier.
- Zed support via the daemon's OpenAI-compatible endpoint. - Zed: the daemon's OpenAI-compatible endpoint is in place ([extensions/zed](extensions/zed/)); needs testing against real Zed traffic.
## License ## License
+14 -3
View File
@@ -18,6 +18,8 @@ export interface ModelProfile {
members?: boolean members?: boolean
/** Trained for next-edit prediction (shared/nextedit.ts), enables /v1/next-edit. */ /** Trained for next-edit prediction (shared/nextedit.ts), enables /v1/next-edit. */
nextEdit?: boolean nextEdit?: boolean
/** Another profile (trained with nextEdit) that serves /v1/next-edit for this model, in a second llama-server. */
nextEditModel?: string
} }
export interface DaemonConfig { export interface DaemonConfig {
@@ -30,6 +32,9 @@ export interface DaemonConfig {
ctx: number ctx: number
/** Extra flags, e.g. ["-ngl", "99"] on Apple Silicon, ["-t", "16"] on the laptop CPU. */ /** Extra flags, e.g. ["-ngl", "99"] on Apple Silicon, ["-t", "16"] on the laptop CPU. */
args: string[] args: string[]
/** Extra flags for the next-edit companion server only. Default: n-gram speculative decoding (the output is
* mostly a copy of the region, so drafts from the prompt are accepted; exact with greedy sampling). */
nextEditArgs?: string[]
} }
models: Record<string, ModelProfile> models: Record<string, ModelProfile>
defaultModel: string defaultModel: string
@@ -58,6 +63,8 @@ export interface DaemonConfig {
/** Where opted-in edit recordings from editors are appended (the opt-in is the editor setting). */ /** Where opted-in edit recordings from editors are appended (the opt-in is the editor setting). */
recording: { dir: string } recording: { dir: string }
stateFile: string stateFile: string
/** Workspace roots for clients that can't send one (Zed via /v1/completions): files are looked up under these. */
workspaceRoots?: string[]
/** Exit after this many minutes without any request (0 = never). Editors poll every 15 s while open. */ /** Exit after this many minutes without any request (0 = never). Editors poll every 15 s while open. */
idleShutdownMinutes?: number idleShutdownMinutes?: number
/** Opaque tag reported by /health (the VS Code extension uses it to replace daemons left over from older versions). */ /** Opaque tag reported by /health (the VS Code extension uses it to replace daemons left over from older versions). */
@@ -69,6 +76,8 @@ export interface RuntimeState {
enabled: boolean enabled: boolean
model: string model: string
mode: ContextMode mode: ContextMode
/** config.defaultModel when this state was saved: a changed default (editor model setting, new release) wins. */
defaultModel?: string
} }
export function loadConfig(configPath: string): DaemonConfig { export function loadConfig(configPath: string): DaemonConfig {
@@ -84,17 +93,19 @@ export function loadConfig(configPath: string): DaemonConfig {
if (!raw.models[raw.defaultModel]) throw new Error(`defaultModel ${raw.defaultModel} is not in models`) if (!raw.models[raw.defaultModel]) throw new Error(`defaultModel ${raw.defaultModel} is not in models`)
for (const [name, m] of Object.entries(raw.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`) if (!m.modes.includes(m.defaultMode)) throw new Error(`model ${name}: defaultMode must be one of its modes`)
if (m.nextEditModel && !raw.models[m.nextEditModel]?.nextEdit) throw new Error(`model ${name}: nextEditModel ${m.nextEditModel} must be a profile with nextEdit`)
} }
return raw return raw
} }
export function loadState(config: DaemonConfig): RuntimeState { export function loadState(config: DaemonConfig): RuntimeState {
const fallback: RuntimeState = { enabled: true, model: config.defaultModel, mode: config.models[config.defaultModel].defaultMode } const fallback: RuntimeState = { enabled: true, model: config.defaultModel, mode: config.models[config.defaultModel].defaultMode, defaultModel: config.defaultModel }
try { try {
const s = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')) as Partial<RuntimeState> 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 keepModel = s.defaultModel === config.defaultModel && s.model && config.models[s.model]
const model = keepModel ? s.model! : fallback.model
const mode = s.mode && config.models[model].modes.includes(s.mode) ? s.mode : config.models[model].defaultMode const mode = s.mode && config.models[model].modes.includes(s.mode) ? s.mode : config.models[model].defaultMode
return { enabled: s.enabled ?? true, model, mode } return { enabled: s.enabled ?? true, model, mode, defaultModel: config.defaultModel }
} catch { } catch {
return fallback return fallback
} }
+79 -15
View File
@@ -9,6 +9,7 @@
* PUT /v1/config { enabled?, model?, mode? } (changing model restarts llama-server) * PUT /v1/config { enabled?, model?, mode? } (changing model restarts llama-server)
* POST /v1/feedback { requestId, event: shown|accepted|partial|rejected, acceptedText? } * POST /v1/feedback { requestId, event: shown|accepted|partial|rejected, acceptedText? }
* POST /v1/edits { events: [...] } opted-in edit recordings from editors -> recording.dir * POST /v1/edits { events: [...] } opted-in edit recordings from editors -> recording.dir
* POST /v1/completions OpenAI-compatible (Zed): Qwen FIM prompt in, completion text out (prompt rebuilt in our layout)
* POST /v1/prewarm same body as /v1/fim: prefill the prompt in the background (first keystroke warm) * POST /v1/prewarm same body as /v1/fim: prefill the prompt in the background (first keystroke warm)
* POST /v1/next-edit { repo, path, workspaceRoot?, languageId?, text, cursor } -> { noEdit, startLine, endLine, newText } * POST /v1/next-edit { repo, path, workspaceRoot?, languageId?, text, cursor } -> { noEdit, startLine, endLine, newText }
* GET /v1/debug/last?n=1 last requests with decoded prompts and raw output * GET /v1/debug/last?n=1 last requests with decoded prompts and raw output
@@ -29,7 +30,8 @@ import { trimSuffixOverlap } from '../../shared/prompt.ts'
import { symbolLang } from '../../shared/symbol-lang.ts' import { symbolLang } from '../../shared/symbol-lang.ts'
import { SymbolService, mightNeedThis } from './symbols.ts' import { SymbolService, mightNeedThis } from './symbols.ts'
import { EditTracker } from './edits.ts' import { EditTracker } from './edits.ts'
import { NEXT_EDIT_TOKENS } from '../../shared/nextedit.ts' import { locateFile, modeFromModel, parseFimPrompt } from './openai-shim.ts'
import { NEXT_EDIT_TOKENS, parseNextEditOutput } from '../../shared/nextedit.ts'
const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' } } }) const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' } } })
const config = loadConfig(args.config!) const config = loadConfig(args.config!)
@@ -45,17 +47,35 @@ function loadModel(name: string) {
loadError = null loadError = null
loading = llama.start(config.models[name].path) loading = llama.start(config.models[name].path)
.catch(e => { loadError = (e as Error).message; console.error(loadError) }) .catch(e => { loadError = (e as Error).message; console.error(loadError) })
.finally(() => { loading = null }) .finally(() => { loading = null; if (config.models[name].nextEditModel) void nextEditServer() })
return loading return loading
} }
// Next-edit companion model (profile.nextEditModel): a second llama-server, started on first use.
const NEXT_EDIT_SPECULATION = ['--spec-type', 'ngram-simple', '--spec-draft-n-max', '64', '--spec-ngram-simple-size-n', '3']
const neLlama = new LlamaServer({ ...config.llamaServer, port: config.llamaServer.port + 1, args: [...config.llamaServer.args, ...(config.llamaServer.nextEditArgs ?? NEXT_EDIT_SPECULATION)] })
let neLoading: Promise<void> | null = null
/** The server that answers next-edit for the active model, or an error message. */
async function nextEditServer(): Promise<LlamaServer | string> {
const profile = config.models[state.model]
if (profile.nextEdit) return loading || !llama.running ? (loadError ?? 'model loading') : llama
const companion = profile.nextEditModel
if (!companion) return `model ${state.model} is not trained for next-edit`
const wanted = config.models[companion].path
if (neLlama.model !== wanted && !neLoading) {
neLoading = neLlama.start(wanted).catch(e => console.error(`next-edit model ${companion}: ${(e as Error).message}`)).finally(() => { neLoading = null })
}
if (neLoading) await neLoading
return neLlama.running && neLlama.model === wanted ? neLlama : `next-edit model ${companion} failed to start`
}
let inflight: AbortController | null = null let inflight: AbortController | null = null
function describeConfig() { function describeConfig() {
return { return {
...state, ...state,
effectiveMode: state.mode, effectiveMode: state.mode,
models: Object.fromEntries(Object.entries(config.models).map(([n, m]) => [n, { order: m.order, modes: m.modes, defaultMode: m.defaultMode, nextEdit: m.nextEdit ?? false, errors: m.errors ?? false, contextFormat: m.contextFormat ?? 1 }])), models: Object.fromEntries(Object.entries(config.models).map(([n, m]) => [n, { order: m.order, modes: m.modes, defaultMode: m.defaultMode, nextEdit: !!(m.nextEdit || m.nextEditModel), nextEditModel: m.nextEditModel ?? null, errors: m.errors ?? false, contextFormat: m.contextFormat ?? 1 }])),
llama: { running: llama.running, loading: loading !== null, error: loadError }, llama: { running: llama.running, loading: loading !== null, error: loadError },
} }
} }
@@ -185,6 +205,44 @@ async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone:
json(res, 200, response) json(res, 200, response)
} }
/**
* OpenAI-compatible /v1/completions for editors with built-in providers (Zed: edit_predictions.provider =
* "open_ai_compatible_api", prompt_format "qwen", model "ac:symbols"). The client's prompt is parsed back into
* prefix/suffix and rebuilt in our trained layout; the answer comes back in OpenAI text_completion format.
*/
async function handleOpenAiCompletion(body: Record<string, unknown>, res: http.ServerResponse, clientGone: AbortSignal) {
const prompt = String(body.prompt ?? '')
const parsed = parseFimPrompt(prompt) ?? { prefix: prompt, suffix: '' }
const located = locateFile(config.workspaceRoots ?? [], parsed.path, parsed.prefix)
const fimBody: FimRequest = {
requestId: `oai-${Date.now()}`,
repo: located ? path.basename(located.root) : parsed.repo ?? 'workspace',
path: located?.path ?? parsed.path ?? 'untitled.ts',
workspaceRoot: located?.root,
languageId: /\.vue$/.test(located?.path ?? parsed.path ?? '') ? 'vue' : 'typescript',
prefix: parsed.prefix,
suffix: parsed.suffix,
mode: modeFromModel(body.model),
}
// Reuse the normal pipeline by capturing its JSON response.
let captured: Record<string, unknown> = {}
const fake = { writeHead: () => fake, end: (s: string) => { captured = JSON.parse(s) }, writableEnded: false, destroyed: false } as unknown as http.ServerResponse
await handleFim(fimBody, fake, clientGone)
const text = String(captured.text ?? '')
const out = {
id: `cmpl-${Date.now()}`, object: 'text_completion', created: Math.floor(Date.now() / 1000), model: String(body.model ?? state.model),
choices: [{ text, index: 0, logprobs: null, finish_reason: 'stop' }],
usage: { prompt_tokens: (captured.timings as Record<string, number> | undefined)?.promptTokens ?? 0, completion_tokens: (captured.timings as Record<string, number> | undefined)?.genTokens ?? 0, total_tokens: 0 },
}
if (body.stream) {
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
res.write(`data: ${JSON.stringify(out)}\n\n`)
res.end('data: [DONE]\n\n')
return
}
json(res, 200, out)
}
/** Prefill a file's prompt in the background (e.g. on editor focus) so the first keystroke is warm. */ /** Prefill a file's prompt in the background (e.g. on editor focus) so the first keystroke is warm. */
async function handlePrewarm(body: FimRequest, res: http.ServerResponse) { async function handlePrewarm(body: FimRequest, res: http.ServerResponse) {
if (!state.enabled || loading || !llama.running) return json(res, 200, { warmed: false }) if (!state.enabled || loading || !llama.running) return json(res, 200, { warmed: false })
@@ -205,8 +263,8 @@ async function handleNextEdit(body: NextEditRequest, res: http.ServerResponse, c
const t0 = performance.now() const t0 = performance.now()
const profile = config.models[state.model] const profile = config.models[state.model]
if (!state.enabled) return json(res, 200, { noEdit: true, disabled: true }) if (!state.enabled) return json(res, 200, { noEdit: true, disabled: true })
if (!profile.nextEdit) return json(res, 400, { error: `model ${state.model} is not trained for next-edit` }) const server = await nextEditServer()
if (loading || !llama.running) return json(res, 503, { error: loadError ?? 'model loading' }) if (typeof server === 'string') return json(res, server.includes('not trained') ? 400 : 503, { error: server })
if (typeof body.text !== 'string' || typeof body.cursor !== 'number') return json(res, 400, { error: 'text and cursor are required' }) if (typeof body.text !== 'string' || typeof body.cursor !== 'number') return json(res, 400, { error: 'text and cursor are required' })
const o = config.nextEditOptions ?? { regionBefore: 4, regionAfter: 8, maxTokens: 400, beforeTokens: 1024, afterTokens: 384 } const o = config.nextEditOptions ?? { regionBefore: 4, regionAfter: 8, maxTokens: 400, beforeTokens: 1024, afterTokens: 384 }
@@ -216,9 +274,11 @@ async function handleNextEdit(body: NextEditRequest, res: http.ServerResponse, c
const endLine = Math.min(lines.length, cursorLine + o.regionAfter + 1) const endLine = Math.min(lines.length, cursorLine + o.regionAfter + 1)
const recentEdits = edits.observe(`${body.workspaceRoot ?? body.repo}/${body.path}`, body.path, body.text, cursorLine) const recentEdits = edits.observe(`${body.workspaceRoot ?? body.repo}/${body.path}`, body.path, body.text, cursorLine)
const lang = symbolLang(body.languageId ?? '') const lang = symbolLang(body.languageId ?? '')
const tErrors = performance.now()
const errors = body.workspaceRoot && lang const errors = body.workspaceRoot && lang
? await symbols.errors({ root: body.workspaceRoot, filePath: path.join(body.workspaceRoot, body.path), lang, text: body.text, cursor: body.cursor, waitMs: 300 }) ? await symbols.errors({ root: body.workspaceRoot, filePath: path.join(body.workspaceRoot, body.path), lang, text: body.text, cursor: body.cursor, waitMs: 300 })
: undefined : undefined
const errorsMs = performance.now() - tErrors
const ids = builder.buildNextEdit({ repo: body.repo, path: body.path, file: body.text, regionStartLine: startLine, regionEndLine: endLine, cursor: body.cursor, recentEdits: recentEdits || undefined, errors }, const ids = builder.buildNextEdit({ repo: body.repo, path: body.path, file: body.text, regionStartLine: startLine, regionEndLine: endLine, cursor: body.cursor, recentEdits: recentEdits || undefined, errors },
{ beforeTokens: o.beforeTokens, afterTokens: o.afterTokens }) { beforeTokens: o.beforeTokens, afterTokens: o.afterTokens })
@@ -226,18 +286,18 @@ async function handleNextEdit(body: NextEditRequest, res: http.ServerResponse, c
const controller = new AbortController() const controller = new AbortController()
inflight = controller inflight = controller
clientGone.addEventListener('abort', () => controller.abort()) clientGone.addEventListener('abort', () => controller.abort())
const result = await llama.complete(ids, { nPredict: o.maxTokens, stop: [NEXT_EDIT_TOKENS.outputEnd, ...STOP_STRINGS] }, controller.signal) const result = await server.complete(ids, { nPredict: o.maxTokens, stop: [NEXT_EDIT_TOKENS.outputEnd, ...STOP_STRINGS] }, controller.signal)
if (inflight === controller) inflight = null if (inflight === controller) inflight = null
if (result.stoppedBy === 'aborted') return json(res, 200, { noEdit: true, cancelled: true }) if (result.stoppedBy === 'aborted') return json(res, 200, { noEdit: true, cancelled: true })
const oldText = lines.slice(startLine, endLine).join('\n') + (endLine < lines.length ? '\n' : '') const oldText = lines.slice(startLine, endLine).join('\n') + (endLine < lines.length ? '\n' : '')
const raw = result.text const raw = result.text
const newText = raw.startsWith(NEXT_EDIT_TOKENS.noEdit) ? null : raw const newText = parseNextEditOutput(raw, oldText)
const noEdit = newText === null || newText === oldText || result.stoppedBy === 'limit' const noEdit = newText === null || result.stoppedBy === 'limit'
const response = { const response = {
requestId: body.requestId, noEdit, startLine, endLine, oldText, newText: noEdit ? null : newText, requestId: body.requestId, noEdit, startLine, endLine, oldText, newText: noEdit ? null : newText,
recentEdits, errors: errors ?? null, model: state.model, recentEdits, errors: errors ?? null, model: profile.nextEditModel ?? state.model,
timings: { promptTokens: ids.length, processedTokens: result.timings?.prompt_n ?? null, genTokens: result.timings?.predicted_n ?? null, totalMs: round(performance.now() - t0) }, timings: { promptTokens: ids.length, processedTokens: result.timings?.prompt_n ?? null, genTokens: result.timings?.predicted_n ?? null, errorsMs: round(errorsMs), promptMs: round(result.timings?.prompt_ms ?? 0), genMs: round(result.timings?.predicted_ms ?? 0), totalMs: round(performance.now() - t0) },
} }
remember({ kind: 'next-edit', requestId: body.requestId, path: body.path, model: state.model, prompt: builder.decode(ids), raw, noEdit, timings: response.timings }) remember({ kind: 'next-edit', requestId: body.requestId, path: body.path, model: state.model, prompt: builder.decode(ids), raw, noEdit, timings: response.timings })
json(res, 200, response) json(res, 200, response)
@@ -263,7 +323,7 @@ if (config.idleShutdownMinutes) {
setInterval(() => { setInterval(() => {
if (Date.now() - lastActivity > config.idleShutdownMinutes! * 60_000) { if (Date.now() - lastActivity > config.idleShutdownMinutes! * 60_000) {
console.log(`idle for ${config.idleShutdownMinutes} min, shutting down`) console.log(`idle for ${config.idleShutdownMinutes} min, shutting down`)
void llama.stop().then(() => process.exit(0)) void Promise.all([llama.stop(), neLlama.stop()]).then(() => process.exit(0))
} }
}, 30_000).unref() }, 30_000).unref()
} }
@@ -278,7 +338,7 @@ const server = http.createServer(async (req, res) => {
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 === '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') { if (route === 'POST /v1/shutdown') {
json(res, 200, { ok: true }) json(res, 200, { ok: true })
void llama.stop().then(() => process.exit(0)) void Promise.all([llama.stop(), neLlama.stop()]).then(() => process.exit(0))
return return
} }
if (route === 'GET /v1/config') return json(res, 200, describeConfig()) if (route === 'GET /v1/config') return json(res, 200, describeConfig())
@@ -291,10 +351,14 @@ const server = http.createServer(async (req, res) => {
const modelChanged = model !== state.model const modelChanged = model !== state.model
Object.assign(state, { model, mode, enabled: body.enabled ?? state.enabled }) Object.assign(state, { model, mode, enabled: body.enabled ?? state.enabled })
saveState(config, state) saveState(config, state)
if (modelChanged) void loadModel(model) if (modelChanged) {
void loadModel(model)
if (neLlama.model && neLlama.model !== config.models[config.models[model].nextEditModel ?? '']?.path) void neLlama.stop()
}
return json(res, 200, describeConfig()) 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/fim') return await handleFim(await readBody(req) as FimRequest, res, gone.signal)
if (route === 'POST /v1/completions') return await handleOpenAiCompletion(await readBody(req) as Record<string, unknown>, res, gone.signal)
if (route === 'POST /v1/prewarm') return await handlePrewarm(await readBody(req) as FimRequest, res) if (route === 'POST /v1/prewarm') return await handlePrewarm(await readBody(req) as FimRequest, res)
if (route === 'POST /v1/next-edit') return await handleNextEdit(await readBody(req) as NextEditRequest, res, gone.signal) if (route === 'POST /v1/next-edit') return await handleNextEdit(await readBody(req) as NextEditRequest, res, gone.signal)
if (route === 'GET /v1/debug/last') { if (route === 'GET /v1/debug/last') {
@@ -316,8 +380,8 @@ const server = http.createServer(async (req, res) => {
} }
}) })
process.on('SIGINT', () => void llama.stop().then(() => process.exit(0))) process.on('SIGINT', () => void Promise.all([llama.stop(), neLlama.stop()]).then(() => process.exit(0)))
process.on('SIGTERM', () => void llama.stop().then(() => process.exit(0))) process.on('SIGTERM', () => void Promise.all([llama.stop(), neLlama.stop()]).then(() => process.exit(0)))
server.listen(config.port, config.host, () => { server.listen(config.port, config.host, () => {
console.log(`acd listening on http://${config.host}:${config.port} (model ${state.model}, mode ${state.mode})`) console.log(`acd listening on http://${config.host}:${config.port} (model ${state.model}, mode ${state.mode})`)
+69
View File
@@ -0,0 +1,69 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* Parses a FIM prompt built by another client (e.g. Zed's `open_ai_compatible_api` edit-prediction provider with
* prompt_format "qwen") back into prefix / suffix / file path, so the daemon can rebuild the prompt in the layout
* our models were trained on (suffix-first, repo header, symbols, healing).
*
* Accepts, in order of preference:
* [<|repo_name|>repo\n] [<|file_sep|>path\n …] <|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>
* <|fim_prefix|><|fim_suffix|>{suffix}<|fim_middle|>{prefix} (suffix-first)
* Returns null for anything else (e.g. a plain completion prompt): the caller then treats it as prefix-only.
*/
export function parseFimPrompt(prompt: string): { prefix: string; suffix: string; path?: string; repo?: string } | null {
const repo = /<\|repo_name\|>([^\n<]*)\n/.exec(prompt)?.[1]?.trim() || undefined
const seps = [...prompt.matchAll(/<\|file_sep\|>([^\n<]*)\n/g)]
const filePath = seps.length ? seps.at(-1)![1].trim() || undefined : undefined
const psm = /<\|fim_prefix\|>([\s\S]*?)<\|fim_suffix\|>([\s\S]*?)<\|fim_middle\|>\s*$/.exec(prompt)
if (psm && !psm[1].includes('<|fim_suffix|>')) {
if (psm[1] === '') {
// Possibly suffix-first: "<pre><suf>suffix<mid>prefix"
const spm = /<\|fim_prefix\|><\|fim_suffix\|>([\s\S]*?)<\|fim_middle\|>([\s\S]*)$/.exec(prompt)
if (spm) return { prefix: spm[2], suffix: spm[1], path: filePath, repo }
}
return { prefix: psm[1], suffix: psm[2], path: filePath, repo }
}
const spm = /<\|fim_prefix\|><\|fim_suffix\|>([\s\S]*?)<\|fim_middle\|>([\s\S]*)$/.exec(prompt)
if (spm) return { prefix: spm[2], suffix: spm[1], path: filePath, repo }
return null
}
/** "ac:symbols" / "ac:simple" / "ac:raw" select the context mode; anything else keeps the current toggle. */
export function modeFromModel(model: unknown): 'symbols' | 'simple' | 'raw' | undefined {
const m = /^ac:(symbols|simple|raw)$/.exec(String(model ?? ''))
return m ? (m[1] as 'symbols' | 'simple' | 'raw') : undefined
}
/**
* Finds the workspace for a request: the root containing `relPath` (if the client sent a path), else the root
* containing a file whose content starts with the prompt's prefix (Zed may not send a path).
*/
export function locateFile(roots: string[], relPath: string | undefined, prefix: string): { root: string; path: string } | null {
if (relPath) {
for (const root of roots) {
if (path.isAbsolute(relPath) && relPath.startsWith(root + path.sep)) return { root, path: path.relative(root, relPath).split(path.sep).join('/') }
if (fs.existsSync(path.join(root, relPath))) return { root, path: relPath }
}
}
const probe = prefix.slice(0, 400)
if (probe.length < 40) return null
for (const root of roots) {
const stack = [root]
let visited = 0
while (stack.length && visited < 5000) {
const dir = stack.pop()!
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist') continue
const p = path.join(dir, e.name)
if (e.isDirectory()) stack.push(p)
else if (/\.(ts|tsx|js|jsx|vue|mts|cts)$/.test(e.name) && ++visited) {
try {
if (fs.readFileSync(p, 'utf8').startsWith(probe)) return { root, path: path.relative(root, p).split(path.sep).join('/') }
} catch { /* unreadable */ }
}
}
}
}
return null
}
+5 -1
View File
@@ -124,6 +124,10 @@ if (cfg.models?.['nextedit-plumbing']) {
for (let i = 0; i < 120 && !(await api('GET', '/health')).body.ok; i++) await new Promise(r => setTimeout(r, 500)) for (let i = 0; i < 120 && !(await api('GET', '/health')).body.ok; i++) await new Promise(r => setTimeout(r, 500))
} }
// ---------- OpenAI-compatible endpoint (Zed)
const oai = (await api('POST', '/v1/completions', { model: 'ac:simple', prompt: "<|fim_prefix|>import { z } from 'zod'\n\nexport const configSchema = z.object({\n port: z.coerce.number().default(3000),\n})\n\nexport type Config = <|fim_suffix|>\n<|fim_middle|>", max_tokens: 64 })).body
check('OpenAI-compatible /v1/completions (Zed shim)', oai.object === 'text_completion' && String(oai.choices?.[0]?.text).includes('z.infer'), JSON.stringify(oai.choices?.[0]?.text))
// ---------- edit recording endpoint // ---------- 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' }] }) 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) check('edit recordings are accepted', recorded.body.recorded === 1)
@@ -132,7 +136,7 @@ check('edit recordings are accepted', recorded.body.recorded === 1)
// Optional typing replay over held-out FIM samples from the (private) training workspace. // Optional typing replay over held-out FIM samples from the (private) training workspace.
const dataDir = path.resolve(process.env.SMOKE_DATA_DIR ?? '../../data') const dataDir = path.resolve(process.env.SMOKE_DATA_DIR ?? '../../data')
const testFim = path.join(dataDir, 'fim/test-repo.jsonl') const testFim = path.join(dataDir, 'fim/test-repo.jsonl')
if (fs.existsSync(testFim)) { if (fs.existsSync(testFim) && Number(args['typing-samples']) > 0) {
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 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 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)) const samples = fs.readFileSync(testFim, 'utf8').trim().split('\n').map(l => JSON.parse(l))
+6 -1
View File
@@ -3,7 +3,7 @@
"displayName": "Local Autocomplete", "displayName": "Local Autocomplete",
"description": "Inline code completion from a local fine-tuned model via the acd daemon", "description": "Inline code completion from a local fine-tuned model via the acd daemon",
"publisher": "harvmaster", "publisher": "harvmaster",
"version": "0.6.0", "version": "0.6.4",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"vscode": "^1.90.0" "vscode": "^1.90.0"
@@ -218,6 +218,11 @@
"default": 700, "default": 700,
"description": "Pause after typing before asking for a next-edit suggestion." "description": "Pause after typing before asking for a next-edit suggestion."
}, },
"localAutocomplete.nextEdit.afterAccept": {
"type": "boolean",
"default": true,
"description": "After you accept a completion or a next-edit suggestion, immediately ask for the next edit (without waiting for the typing pause). Accepted next-edits chain: a rename suggests the next use, and so on."
},
"localAutocomplete.triggerOnCursorMove": { "localAutocomplete.triggerOnCursorMove": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
+11
View File
@@ -259,6 +259,7 @@ const modelSupportsNextEdit = () => !!(daemonConfig && daemonConfig.models[daemo
// ---------- next-edit prediction (models trained for it, e.g. Stage E) // ---------- next-edit prediction (models trained for it, e.g. Stage E)
const nextEdit = new NextEditController(s => { const nextEdit = new NextEditController(s => {
api('POST', '/v1/feedback', { event: 'next-edit-accepted', lines: s.newText.split('\n').length }).catch(() => {}) api('POST', '/v1/feedback', { event: 'next-edit-accepted', lines: s.newText.split('\n').length }).catch(() => {})
nextEditAfterAccept('accepted-next-edit')
}) })
let nextEditTimer: NodeJS.Timeout | null = null let nextEditTimer: NodeJS.Timeout | null = null
let nextEditAbort: AbortController | null = null let nextEditAbort: AbortController | null = null
@@ -281,6 +282,15 @@ async function requestNextEdit(editor: vscode.TextEditor, reason: string) {
} }
} }
/** Accepting a suggestion (next-edit or completion) asks for the follow-up right away instead of after the typing pause. */
function nextEditAfterAccept(reason: string) {
const editor = vscode.window.activeTextEditor
if (!editor || !modelSupportsNextEdit() || !settings().get<boolean>('nextEdit.afterAccept', true)) return
if (nextEditTimer) clearTimeout(nextEditTimer)
// Next tick: the accepted text and the cursor move have been applied by then.
nextEditTimer = setTimeout(() => void requestNextEdit(editor, reason), 0)
}
function scheduleNextEdit(editor: vscode.TextEditor, reason: string, delay: number) { function scheduleNextEdit(editor: vscode.TextEditor, reason: string, delay: number) {
if (nextEditTimer) clearTimeout(nextEditTimer) if (nextEditTimer) clearTimeout(nextEditTimer)
nextEditTimer = setTimeout(() => void requestNextEdit(editor, reason), delay) nextEditTimer = setTimeout(() => void requestNextEdit(editor, reason), delay)
@@ -535,6 +545,7 @@ export async function activate(ctx: vscode.ExtensionContext) {
vscode.commands.registerCommand('localAutocomplete.showLog', () => log.show()), vscode.commands.registerCommand('localAutocomplete.showLog', () => log.show()),
vscode.commands.registerCommand('localAutocomplete.accepted', (requestId: string, text: string) => { vscode.commands.registerCommand('localAutocomplete.accepted', (requestId: string, text: string) => {
api('POST', '/v1/feedback', { requestId, event: 'accepted', chars: text.length }).catch(() => {}) api('POST', '/v1/feedback', { requestId, event: 'accepted', chars: text.length }).catch(() => {})
nextEditAfterAccept('accepted-completion')
}), }),
vscode.workspace.onDidChangeConfiguration(e => { vscode.workspace.onDidChangeConfiguration(e => {
if (!e.affectsConfiguration('localAutocomplete')) return if (!e.affectsConfiguration('localAutocomplete')) return
+14 -8
View File
@@ -30,13 +30,19 @@ export interface ModelEntry {
contextFormat?: 1 | 2 contextFormat?: 1 | 2
errors?: boolean errors?: boolean
members?: boolean members?: boolean
/** Old (≤ 0.6.0) flag: ignored, because those daemons applied an empty "no edit" output as a deletion. */
nextEdit?: boolean nextEdit?: boolean
/** Trained for next-edit (shared/nextedit.ts format version); read since 0.6.1. */
nextEditFormat?: 1
/** Companion model (another manifest entry) that serves next-edit for this one; downloaded alongside it. */
nextEditModel?: string
} }
const profileOf = (m: ModelEntry) => ({ const profileOf = (m: ModelEntry, available: Set<string>) => ({
order: m.order, modes: m.modes, defaultMode: m.defaultMode, order: m.order, modes: m.modes, defaultMode: m.defaultMode,
...(m.contextFormat ? { contextFormat: m.contextFormat } : {}), ...(m.errors ? { errors: true } : {}), ...(m.contextFormat ? { contextFormat: m.contextFormat } : {}), ...(m.errors ? { errors: true } : {}),
...(m.members !== undefined ? { members: m.members } : {}), ...(m.nextEdit ? { nextEdit: true } : {}), ...(m.members !== undefined ? { members: m.members } : {}), ...(m.nextEditFormat ? { nextEdit: true } : {}),
...(m.nextEditModel && available.has(m.nextEditModel) ? { nextEditModel: m.nextEditModel } : {}),
}) })
export interface Manifest { export interface Manifest {
@@ -229,12 +235,12 @@ export async function prepare(storage: string, daemonDir: string, extensionVersi
if (!entry) throw new Error(`model "${s.model || manifest.default}" is not in the manifest (available: ${manifest.models.map(m => m.name).join(', ')})`) if (!entry) throw new Error(`model "${s.model || manifest.default}" is not in the manifest (available: ${manifest.models.map(m => m.name).join(', ')})`)
const file = await ensureFile(entry.url, path.join(storage, 'models', entry.file), entry.sha256, `model ${entry.name}`, progress, signal) const file = await ensureFile(entry.url, path.join(storage, 'models', entry.file), entry.sha256, `model ${entry.name}`, progress, signal)
defaultModel = entry.name defaultModel = entry.name
models[entry.name] = { path: file, ...profileOf(entry) } const companion = entry.nextEditModel ? manifest.models.find(m => m.name === entry.nextEditModel && m.nextEditFormat) : undefined
// Other models already on disk stay switchable from the menu. if (companion) await ensureFile(companion.url, path.join(storage, 'models', companion.file), companion.sha256, `next-edit model ${companion.name}`, progress, signal)
for (const m of manifest.models) { // The selected model, its companion and other models already on disk (switchable from the menu).
const f = path.join(storage, 'models', m.file) const onDisk = manifest.models.filter(m => m.name === entry.name || fs.existsSync(`${path.join(storage, 'models', m.file)}.verified`))
if (m.name !== entry.name && fs.existsSync(`${f}.verified`)) models[m.name] = { path: f, ...profileOf(m) } const available = new Set(onDisk.filter(m => m.nextEditFormat).map(m => m.name))
} for (const m of onDisk) models[m.name] = { path: path.join(storage, 'models', m.file), ...profileOf(m, available) }
} }
const binary = s.llamaServerPath ? s.llamaServerPath.replace(/^~(?=$|\/)/, os.homedir()) : await ensureLlamaServer(storage, llamaCpp, progress, signal) const binary = s.llamaServerPath ? s.llamaServerPath.replace(/^~(?=$|\/)/, os.homedir()) : await ensureLlamaServer(storage, llamaCpp, progress, signal)
+12 -3
View File
@@ -36,20 +36,29 @@ const step = (name, fn) => fn().then(r => { console.log(`PASS ${name}`); return
// 1. Simulate an interrupted model download: first 100 MB already on disk. // 1. Simulate an interrupted model download: first 100 MB already on disk.
fs.mkdirSync(path.join(storage, 'models'), { recursive: true }) fs.mkdirSync(path.join(storage, 'models'), { recursive: true })
const modelSrc = path.join(releaseDir, 'stage-c-q8_0.gguf') const modelSrc = path.join(releaseDir, 'stage-d-q8_0.gguf')
const fd = fs.openSync(modelSrc, 'r'); const buf = Buffer.alloc(100e6); fs.readSync(fd, buf, 0, buf.length, 0); fs.closeSync(fd) const fd = fs.openSync(modelSrc, 'r'); const buf = Buffer.alloc(100e6); fs.readSync(fd, buf, 0, buf.length, 0); fs.closeSync(fd)
fs.writeFileSync(path.join(storage, 'models', 'stage-c-q8_0.gguf.part'), buf) fs.writeFileSync(path.join(storage, 'models', 'stage-d-q8_0.gguf.part'), buf)
let t0 = Date.now() let t0 = Date.now()
const prepared = await step('first run: manifest, resumed + verified model download, llama.cpp download + unpack', () => core.prepare(storage, daemonDir, '0.4.0-test', settings, progress)) const prepared = await step('first run: manifest, resumed + verified model download, llama.cpp download + unpack', () => core.prepare(storage, daemonDir, '0.4.0-test', settings, progress))
console.log(` ${((Date.now() - t0) / 1000).toFixed(1)} s; range requests: ${rangeRequests}; model verified: ${fs.existsSync(path.join(storage, 'models', 'stage-c-q8_0.gguf.verified'))}`) console.log(` ${((Date.now() - t0) / 1000).toFixed(1)} s; range requests: ${rangeRequests}; model verified: ${fs.existsSync(path.join(storage, 'models', 'stage-d-q8_0.gguf.verified'))}`)
assert.ok(rangeRequests >= 1, 'download should have resumed with a Range request') assert.ok(rangeRequests >= 1, 'download should have resumed with a Range request')
const cfg = JSON.parse(fs.readFileSync(prepared.configPath, 'utf8')) const cfg = JSON.parse(fs.readFileSync(prepared.configPath, 'utf8'))
assert.ok(fs.existsSync(cfg.llamaServer.binary), 'llama-server unpacked') assert.ok(fs.existsSync(cfg.llamaServer.binary), 'llama-server unpacked')
await step('next-edit companion downloaded and wired (stage-d → stage-e)', async () => {
assert.equal(cfg.defaultModel, 'stage-d')
assert.equal(cfg.models['stage-d'].nextEditModel, 'stage-e')
assert.equal(cfg.models['stage-e'].nextEdit, true)
assert.ok(fs.existsSync(path.join(storage, 'models', 'stage-e-q8_0.gguf.verified')))
})
await step('daemon starts on the editor runtime', async () => assert.equal(await core.ensureRunning(storage, daemonDir, prepared, settings.port, progress), 'started')) await step('daemon starts on the editor runtime', async () => assert.equal(await core.ensureRunning(storage, daemonDir, prepared, settings.port, progress), 'started'))
const fim = await (await fetch('http://127.0.0.1:7111/v1/fim', { method: 'POST', body: JSON.stringify({ repo: 'e2e', path: 'src/a.ts', prefix: "import { z } from 'zod'\nexport const s = z.object({ a: z.string() })\nexport type S = ", suffix: '\n' }) })).json() const fim = await (await fetch('http://127.0.0.1:7111/v1/fim', { method: 'POST', body: JSON.stringify({ repo: 'e2e', path: 'src/a.ts', prefix: "import { z } from 'zod'\nexport const s = z.object({ a: z.string() })\nexport type S = ", suffix: '\n' }) })).json()
await step(`completion through the managed daemon (${JSON.stringify(fim.text)})`, async () => assert.match(fim.text, /z\.infer<typeof s>/)) await step(`completion through the managed daemon (${JSON.stringify(fim.text)})`, async () => assert.match(fim.text, /z\.infer<typeof s>/))
const neText = "export function f(s: string) {\n const t = s.trim()\n return parse(t))\n}\n"
const ne = await (await fetch('http://127.0.0.1:7111/v1/next-edit', { method: 'POST', body: JSON.stringify({ repo: 'e2e', path: 'src/f.ts', text: neText, cursor: neText.indexOf('(t)') + 1 }) })).json()
await step(`next-edit through the companion model (${JSON.stringify(ne.newText ?? ne.error ?? ne)})`, async () => assert.ok(ne.newText?.includes('return parse(t)\n')))
t0 = Date.now() t0 = Date.now()
const again = await step('second window: no re-download, daemon reused', async () => { const again = await step('second window: no re-download, daemon reused', async () => {
+32
View File
@@ -0,0 +1,32 @@
# Zed
Zed can't load third-party edit-prediction extensions, but its built-in `open_ai_compatible_api` provider can call
the daemon's OpenAI-compatible endpoint. The daemon parses Zed's Qwen-format FIM prompt back into prefix/suffix and
rebuilds it in the layout our models were trained on (suffix-first, repo header, symbols, token healing).
1. Run the daemon standalone (see [daemon/README.md](../../daemon/README.md)). Add your project folders to
`"workspaceRoots"` in its config, so symbols mode and the `this.` repair can find the files. Zed may not send
the file path; the daemon then locates the file by content.
2. In Zed's `settings.json`:
```jsonc
{
"edit_predictions": {
"provider": "open_ai_compatible_api",
"open_ai_compatible_api": {
"api_url": "http://127.0.0.1:7071/v1/completions",
"model": "ac:symbols", // the context-mode toggle: "ac:symbols" | "ac:simple"
"prompt_format": "qwen"
}
}
}
```
The mode can also be switched with `node daemon/src/acctl.ts mode simple`.
**Status: untested against real Zed traffic.** The parser accepts prefix-first and suffix-first Qwen prompts, with
or without `<|repo_name|>`/`<|file_sep|>` headers, and is covered by the daemon smoke test with a synthetic request.
Still to check with a logging proxy:
- whether Zed sends the file path
- its prefix/suffix truncation
- whether it streams and cancels superseded requests
+2 -1
View File
@@ -2,6 +2,7 @@ These model files are modified versions of Qwen2.5-Coder-0.5B (https://huggingfa
Copyright (c) Alibaba Cloud, licensed under the Apache License, Version 2.0 (see LICENSE). Copyright (c) Alibaba Cloud, licensed under the Apache License, Version 2.0 (see LICENSE).
Modifications: full-parameter fine-tuning for suffix-first fill-in-the-middle completion Modifications: full-parameter fine-tuning for suffix-first fill-in-the-middle completion
(and, for stage-c, a repository/symbol-context header), followed by Q8_0 GGUF conversion. (and, from stage-c on, a repository/symbol-context header; stage-d adds TypeScript errors; stage-e adds
next-edit prediction), followed by Q8_0 GGUF conversion.
Fine-tuning data: permissively licensed (MIT) open-source TypeScript/Vue repositories and Fine-tuning data: permissively licensed (MIT) open-source TypeScript/Vue repositories and
the publisher's own code. Secrets were scrubbed from the training data. the publisher's own code. Secrets were scrubbed from the training data.
+12
View File
@@ -5,6 +5,8 @@ Q8_0 GGUF fill-in-the-middle models for the [Local Autocomplete](../extensions/v
| model | description | prompt order | context modes | size | | model | description | prompt order | context modes | size |
|---|---|---|---|---| |---|---|---|---|---|
| `stage-d` | Suffix-first FIM with symbols (incl. namespace imports) + TS errors; better next-line, block closing and multi-line completions | spm | symbols, simple | 531 MB |
| `stage-e` | Next-edit prediction (rewrites a region around the cursor or answers "no edit"); also does FIM, slightly below stage-d | spm | symbols, simple | 531 MB |
| `stage-c` | Suffix-first FIM with repo/symbol-context headers (default) | spm | symbols, simple | 531 MB | | `stage-c` | Suffix-first FIM with repo/symbol-context headers (default) | spm | symbols, simple | 531 MB |
| `stage-b` | Suffix-first FIM without context headers (previous stage) | spm | raw | 531 MB | | `stage-b` | Suffix-first FIM without context headers (previous stage) | spm | raw | 531 MB |
@@ -21,6 +23,16 @@ runs it locally with llama.cpp. The files are stored with Git LFS; download link
| Qwen2.5-Coder-1.5B (base) | 0.366 | 0.623 | 0.882 | | Qwen2.5-Coder-1.5B (base) | 0.366 | 0.623 | 0.882 |
| `stage-b` | 0.391 | 0.689 | 0.952 | | `stage-b` | 0.391 | 0.689 | 0.952 |
| `stage-c` (with symbol context) | 0.397 | 0.681 | 0.954 | | `stage-c` (with symbol context) | 0.397 | 0.681 | 0.954 |
| `stage-c` + token healing (daemon default) | 0.405 | 0.689 | |
| `stage-d` (symbols + TS errors, healed) | **0.426** | **0.700** | 0.954 |
| `stage-e` (symbols + TS errors, healed) | 0.412 | 0.691 | 0.962 |
New behaviours (886 further held-out samples), exact match, stage-c → stage-d: closing a block 0.27 → **0.79**,
next line 0.01 → **0.18**, rest of block 0.08 → 0.11, member access 0.63 → 0.66.
**Next-edit** (`stage-e`, 445 held-out samples): 80% exact region rewrites (edit similarity 0.99) where an edit
was due, and "no edit" on 100% of samples where none was. `stage-d` uses `stage-e` as its next-edit companion
(`nextEditModel` in the manifest): the extension downloads both and the daemon runs it in a second llama-server.
The base models were evaluated in their native prefix-first order (they can't do suffix-first). Suffix-first prompts The base models were evaluated in their native prefix-first order (they can't do suffix-first). Suffix-first prompts
let the editor reuse the KV cache while typing: about 2 tokens re-processed per keystroke, ~90–100 ms end-to-end on a let the editor reuse the KV cache while typing: about 2 tokens re-processed per keystroke, ~90–100 ms end-to-end on a
+37 -1
View File
@@ -1,7 +1,43 @@
{ {
"schema": 1, "schema": 1,
"default": "stage-c", "default": "stage-d",
"models": [ "models": [
{
"name": "stage-d",
"file": "stage-d-q8_0.gguf",
"url": "stage-d-q8_0.gguf",
"sha256": "8edaef02ae9e794929af0267f0f47f600824454b08df7d386e386eec1123c839",
"size": 531067552,
"order": "spm",
"modes": [
"symbols",
"simple"
],
"defaultMode": "symbols",
"contextFormat": 2,
"errors": true,
"members": true,
"nextEditModel": "stage-e",
"description": "Suffix-first FIM with symbols (incl. namespace imports) + TS errors; better next-line, block closing and multi-line completions"
},
{
"name": "stage-e",
"file": "stage-e-q8_0.gguf",
"url": "stage-e-q8_0.gguf",
"sha256": "44d5fe87043f2fd3f8fba39c07a653ec7bdc228f83a2da4a5638be3ab77a962f",
"size": 531067552,
"order": "spm",
"modes": [
"symbols",
"simple"
],
"defaultMode": "symbols",
"contextFormat": 2,
"errors": true,
"members": true,
"nextEditFormat": 1,
"description": "Next-edit prediction (rewrites a region around the cursor or answers \"no edit\"); also does FIM, slightly below stage-d"
},
{ {
"name": "stage-c", "name": "stage-c",
"file": "stage-c-q8_0.gguf", "file": "stage-c-q8_0.gguf",
Binary file not shown.
Binary file not shown.
+15 -3
View File
@@ -23,9 +23,10 @@ const out = path.join(repo, 'models')
// Prompt layout each model was trained with (the extension only offers matching context modes). // Prompt layout each model was trained with (the extension only offers matching context modes).
const profiles = { const profiles = {
// Stage E: Stage D + next-edit prediction (shared/nextedit.ts) // Stage E: Stage D + next-edit prediction (shared/nextedit.ts)
'stage-e': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'symbols', contextFormat: 2, errors: true, members: true, nextEdit: true, description: 'Suffix-first FIM with symbols + TS errors, plus next-edit prediction (rewrites a region around the cursor)' }, // nextEditFormat (not the old nextEdit flag): extensions ≤ 0.6.0 must not use it (they applied "no edit" as a deletion).
'stage-e': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'symbols', contextFormat: 2, errors: true, members: true, nextEditFormat: 1, description: 'Next-edit prediction (rewrites a region around the cursor or answers "no edit"); also does FIM, slightly below stage-d' },
// Stage D: next-line / block-close / multi-line fixes, namespace-import symbols (format 2), TS errors in the header // Stage D: next-line / block-close / multi-line fixes, namespace-import symbols (format 2), TS errors in the header
'stage-d': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'symbols', contextFormat: 2, errors: true, members: true, description: 'Suffix-first FIM with symbols (incl. namespace imports) + TS errors; better next-line, block closing and multi-line completions' }, 'stage-d': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'symbols', contextFormat: 2, errors: true, members: true, nextEditModel: 'stage-e', description: 'Suffix-first FIM with symbols (incl. namespace imports) + TS errors; better next-line, block closing and multi-line completions' },
'stage-c': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'simple', description: 'Suffix-first FIM with repo/symbol-context headers (default)' }, 'stage-c': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'simple', description: 'Suffix-first FIM with repo/symbol-context headers (default)' },
'stage-b': { order: 'spm', modes: ['raw'], defaultMode: 'raw', description: 'Suffix-first FIM without context headers (previous stage)' }, 'stage-b': { order: 'spm', modes: ['raw'], defaultMode: 'raw', description: 'Suffix-first FIM without context headers (previous stage)' },
} }
@@ -71,7 +72,8 @@ fs.writeFileSync(path.join(out, 'NOTICE'), `These model files are modified versi
Copyright (c) Alibaba Cloud, licensed under the Apache License, Version 2.0 (see LICENSE). Copyright (c) Alibaba Cloud, licensed under the Apache License, Version 2.0 (see LICENSE).
Modifications: full-parameter fine-tuning for suffix-first fill-in-the-middle completion Modifications: full-parameter fine-tuning for suffix-first fill-in-the-middle completion
(and, for stage-c, a repository/symbol-context header), followed by Q8_0 GGUF conversion. (and, from stage-c on, a repository/symbol-context header; stage-d adds TypeScript errors; stage-e adds
next-edit prediction), followed by Q8_0 GGUF conversion.
Fine-tuning data: permissively licensed (MIT) open-source TypeScript/Vue repositories and Fine-tuning data: permissively licensed (MIT) open-source TypeScript/Vue repositories and
the publisher's own code. Secrets were scrubbed from the training data. the publisher's own code. Secrets were scrubbed from the training data.
`) `)
@@ -103,6 +105,16 @@ runs it locally with llama.cpp. The files are stored with Git LFS; download link
| Qwen2.5-Coder-1.5B (base) | 0.366 | 0.623 | 0.882 | | Qwen2.5-Coder-1.5B (base) | 0.366 | 0.623 | 0.882 |
| \`stage-b\` | 0.391 | 0.689 | 0.952 | | \`stage-b\` | 0.391 | 0.689 | 0.952 |
| \`stage-c\` (with symbol context) | 0.397 | 0.681 | 0.954 | | \`stage-c\` (with symbol context) | 0.397 | 0.681 | 0.954 |
| \`stage-c\` + token healing (daemon default) | 0.405 | 0.689 | |
| \`stage-d\` (symbols + TS errors, healed) | **0.426** | **0.700** | 0.954 |
| \`stage-e\` (symbols + TS errors, healed) | 0.412 | 0.691 | 0.962 |
New behaviours (886 further held-out samples), exact match, stage-c → stage-d: closing a block 0.27 → **0.79**,
next line 0.01 → **0.18**, rest of block 0.08 → 0.11, member access 0.63 → 0.66.
**Next-edit** (\`stage-e\`, 445 held-out samples): 80% exact region rewrites (edit similarity 0.99) where an edit
was due, and "no edit" on 100% of samples where none was. \`stage-d\` uses \`stage-e\` as its next-edit companion
(\`nextEditModel\` in the manifest): the extension downloads both and the daemon runs it in a second llama-server.
The base models were evaluated in their native prefix-first order (they can't do suffix-first). Suffix-first prompts The base models were evaluated in their native prefix-first order (they can't do suffix-first). Suffix-first prompts
let the editor reuse the KV cache while typing: about 2 tokens re-processed per keystroke, ~90–100 ms end-to-end on a let the editor reuse the KV cache while typing: about 2 tokens re-processed per keystroke, ~90–100 ms end-to-end on a
+16 -2
View File
@@ -124,11 +124,16 @@ export function buildNextEditPrompt(input: NextEditInput, encode: Encode, ids: N
out.push(ids.fileSep, ...encode(`${input.path}\n`)) out.push(ids.fileSep, ...encode(`${input.path}\n`))
// Before the region: last lines that fit; after: first lines that fit. // Before the region: last lines that fit; after: first lines that fit.
// (Window to ~6 chars/token first, then drop whole lines in proportion to the excess: a few encodes, not one per line.)
let before = input.file.slice(0, rStart) let before = input.file.slice(0, rStart)
if (before.length > beforeTokens * 6) before = before.slice(before.indexOf('\n', before.length - beforeTokens * 6) + 1)
let beforeIds = encode(before) let beforeIds = encode(before)
while (beforeIds.length > beforeTokens && before.includes('\n')) { while (beforeIds.length > beforeTokens && before.includes('\n')) {
before = before.slice(before.indexOf('\n') + 1) const excess = beforeIds.length - beforeTokens // undershoot (×0.8), then single lines: same cut as line-by-line
beforeIds = beforeIds.length > beforeTokens * 2 ? encode(before.slice(-beforeTokens * 6)) : encode(before) const drop = excess > 32 ? Math.floor(0.8 * before.length * excess / beforeIds.length) : 1
const nl = before.indexOf('\n', drop - 1)
before = nl < 0 ? '' : before.slice(nl + 1)
beforeIds = encode(before)
} }
let after = input.file.slice(rEnd) let after = input.file.slice(rEnd)
let afterIds = encode(after.slice(0, afterTokens * 8)) let afterIds = encode(after.slice(0, afterTokens * 8))
@@ -149,3 +154,12 @@ export function buildNextEditPrompt(input: NextEditInput, encode: Encode, ids: N
export function nextEditTarget(newRegion: string | null, encode: Encode, ids: NextEditTokenIds): number[] { export function nextEditTarget(newRegion: string | null, encode: Encode, ids: NextEditTokenIds): number[] {
return newRegion === null ? [ids.noEdit, ids.outputEnd] : [...encode(newRegion), ids.outputEnd] return newRegion === null ? [ids.noEdit, ids.outputEnd] : [...encode(newRegion), ids.outputEnd]
} }
/**
* Model output → rewritten region, or null for "no edit". llama-server leaves special tokens out of `content`, so the
* NO_EDIT token usually arrives as an empty string; never treat empty output as "replace the region with nothing".
*/
export function parseNextEditOutput(raw: string, oldText: string): string | null {
if (raw.startsWith(NEXT_EDIT_TOKENS.noEdit) || raw.trim() === '' || raw === oldText) return null
return raw
}