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:
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user