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
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,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). */
|
||||||
|
|||||||
@@ -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,6 +30,7 @@ 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 { locateFile, modeFromModel, parseFimPrompt } from './openai-shim.ts'
|
||||||
import { NEXT_EDIT_TOKENS } from '../../shared/nextedit.ts'
|
import { NEXT_EDIT_TOKENS } 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' } } })
|
||||||
@@ -185,6 +187,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 })
|
||||||
@@ -295,6 +335,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
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') {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user