diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7520d70 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,30 @@ +# Runs on any Gitea Actions runner with the ubuntu-latest label (needs Node; no GPU, no models). +name: ci +on: + push: + branches: [main] + tags: ['vscode-v*'] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: false # the models aren't needed to build or test the extension + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm run setup + - run: npm test + - run: npm run build + - uses: actions/upload-artifact@v3 + with: + name: vsix + path: extensions/vscode/*.vsix + # On a vscode-v* tag, publish the release with the VSIX (repo secret RELEASE_TOKEN: write:repository). + - if: startsWith(github.ref, 'refs/tags/vscode-v') + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: node scripts/publish-extension-release.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 26df8f6..6a404eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Extension versions are released as `vscode-v` 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.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`. +- **Token healing:** completions that start mid-word (`this.connections.del▮`) now finish the word instead of skipping it. +- **Suggest on cursor move** (`triggerOnCursorMove`, off by default): with next-edit models it predicts what you came to change; otherwise it only fires on empty lines, line ends and lines with errors. +- **Pre-warm on focus** (`prewarmOnFocus`, off by default): fills the model cache when you switch files, so the first keystroke is fast. +- **Show Last Prompt** command: the exact prompt the model saw, its raw output and timings. +- **Update check** against the Releases page (daily; `checkForUpdates`), with one-click install. +- Edit recorder also records VS Code's diagnostics (TS, Vue, ESLint) for recorded files. +- Daemon: model profiles carry the prompt format they were trained with (`contextFormat`, `errors`, `nextEdit`), so newer models get TS errors and namespace-import symbols in their context. + ## vscode 0.5.0 - New repository layout (`models/`, `extensions/vscode/`, `daemon/`, `shared/`). The model manifest moved to `models/manifest.json`, and the default `localAutocomplete.modelManifestUrl` follows it. diff --git a/README.md b/README.md index 5c7e8a3..cb5b689 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Fast, private, inline code completion for **TypeScript, Vue and Node.js**, from - **Local:** a 0.5B model (fine-tuned Qwen2.5-Coder) runs through llama.cpp on your CPU or Apple GPU. No code leaves your machine. - **Fast:** ~90–100 ms per keystroke on a laptop CPU. Prompts are laid out suffix-first so the KV cache survives typing (~2 tokens re-processed per keystroke). -- **Context-aware:** an optional `symbols` mode feeds the model signatures from the TypeScript language service, and a missing `this.` is repaired automatically. +- **Context-aware:** an optional `symbols` mode feeds the model signatures from the TypeScript language service (and, for newer models, nearby TypeScript errors); a missing `this.` is repaired automatically, and half-typed words are completed properly (token healing). +- **Next-edit suggestions** (with models trained for it): edits beyond the cursor, like removing a leftover `)` or updating the uses of a renamed variable. **Tab** applies, **Esc** dismisses. - **Zero setup:** the VS Code extension downloads the model and llama.cpp on first use and manages the background daemon itself. | Model (held-out private code, never trained on) | exact match | edit similarity | @@ -39,7 +40,7 @@ Turn off other inline-completion providers (Copilot, …) so they don't compete. The menu only offers modes the active model was trained for. - **Model:** switch between models in the manifest (downloaded on first use). -- **On/off**, **Restart daemon**, **Open daemon log**. +- **On/off**, **Restart daemon**, **Open daemon log**, **Show last prompt** (exactly what the model saw, for debugging a suggestion), **Check for updates**. ### Settings @@ -50,7 +51,10 @@ Turn off other inline-completion providers (Copilot, …) so they don't compete. | `localAutocomplete.modelPath` | | use a local `.gguf` instead (set `modelProfile` to match its prompt layout) | | `localAutocomplete.llamaServerPath` | | use your own `llama-server` (e.g. Homebrew's) instead of the downloaded build | | `localAutocomplete.excludeGlobs` | `.env*`, keys, `secrets/`, … | never sent to the model | -| `localAutocomplete.recordEdits` | `false` | record your edits locally, as future training data for next-edit prediction | +| `localAutocomplete.nextEdit.enabled` | `true` | next-edit suggestions (only with models trained for it) | +| `localAutocomplete.triggerOnCursorMove` | `false` | also suggest when you move the cursor and pause | +| `localAutocomplete.prewarmOnFocus` | `false` | fill the model cache when you switch files (faster first keystroke) | +| `localAutocomplete.recordEdits` | `false` | record your edits (and diagnostics) locally, as future training data | | `localAutocomplete.daemon.mode` | `managed` | `external` = use a daemon you run yourself (`serverUrl`) | ## How it works diff --git a/daemon/src/config.ts b/daemon/src/config.ts index 6f29409..b4fd870 100644 --- a/daemon/src/config.ts +++ b/daemon/src/config.ts @@ -10,6 +10,14 @@ export interface ModelProfile { order: FimOrder modes: ContextMode[] defaultMode: ContextMode + /** Symbol-context format the model was trained on (shared/symbols.ts): 1 = Stage C, 2 = + namespace imports. */ + contextFormat?: 1 | 2 + /** Trained with the TS-errors header section (Stage D+). */ + errors?: boolean + /** Override symbols.members for this model (member lists after `expr.`). */ + members?: boolean + /** Trained for next-edit prediction (shared/nextedit.ts), enables /v1/next-edit. */ + nextEdit?: boolean } export interface DaemonConfig { @@ -43,6 +51,9 @@ export interface DaemonConfig { members: boolean } thisRepair: { enabled: boolean; waitMs: number } + /** Cut a half-typed word off the prompt and force the output to start with it (default on). */ + tokenHealing?: boolean + nextEditOptions?: { regionBefore: number; regionAfter: number; maxTokens: number; beforeTokens: number; afterTokens: 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 } diff --git a/daemon/src/edits.ts b/daemon/src/edits.ts new file mode 100644 index 0000000..7c1055c --- /dev/null +++ b/daemon/src/edits.ts @@ -0,0 +1,36 @@ +import { lineDiff } from '../../shared/nextedit.ts' + +/** + * Tracks each file's recent "edit burst" so next-edit prompts can include what the user just changed, + * without editors having to send diffs (works for any client that sends full text, Zed included). + * + * A burst starts when the cursor jumps more than `jumpLines` away or after `idleMs` without requests; its + * baseline is the text at that moment. The recent edit is the line diff from the baseline to now, the same + * compact format as training (shared/nextedit.ts lineDiff). + */ +export class EditTracker { + private files = new Map() + private jumpLines: number + private idleMs: number + + constructor(opts: { jumpLines?: number; idleMs?: number } = {}) { + this.jumpLines = opts.jumpLines ?? 20 + this.idleMs = opts.idleMs ?? 60_000 + } + + /** Records the file's current text; returns the burst's diff (empty if nothing changed). */ + observe(key: string, displayPath: string, text: string, cursorLine: number): string { + const now = Date.now() + const f = this.files.get(key) + if (!f) { + this.files.set(key, { baseline: text, last: text, line: cursorLine, time: now }) + if (this.files.size > 50) this.files.delete(this.files.keys().next().value!) + return '' + } + if (Math.abs(cursorLine - f.line) > this.jumpLines || now - f.time > this.idleMs) f.baseline = f.last + f.last = text + f.line = cursorLine + f.time = now + return lineDiff(displayPath, f.baseline, text) + } +} diff --git a/daemon/src/llama.ts b/daemon/src/llama.ts index db9601a..2c32483 100644 --- a/daemon/src/llama.ts +++ b/daemon/src/llama.ts @@ -70,7 +70,7 @@ export class LlamaServer { 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 { + async complete(prompt: number[], opts: { nPredict: number; stop: string[]; grammar?: string }, signal: AbortSignal): Promise { let text = '' let timings: Timings | null = null let stoppedBy: CompletionResult['stoppedBy'] = 'limit' @@ -79,7 +79,7 @@ export class LlamaServer { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - prompt, n_predict: opts.nPredict, stop: opts.stop, stream: true, + prompt, n_predict: opts.nPredict, stop: opts.stop, stream: true, ...(opts.grammar ? { grammar: opts.grammar } : {}), temperature: 0, top_k: 1, cache_prompt: true, id_slot: 0, }), signal, diff --git a/daemon/src/main.ts b/daemon/src/main.ts index ef089fe..b236df2 100644 --- a/daemon/src/main.ts +++ b/daemon/src/main.ts @@ -9,6 +9,9 @@ * 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/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 } + * GET /v1/debug/last?n=1 last requests with decoded prompts and raw output * POST /v1/shutdown stop llama-server and exit (used by the extension to replace an outdated daemon) * GET /health * @@ -25,6 +28,8 @@ import { Telemetry } from './telemetry.ts' import { trimSuffixOverlap } from '../../shared/prompt.ts' import { symbolLang } from '../../shared/symbol-lang.ts' import { SymbolService, mightNeedThis } from './symbols.ts' +import { EditTracker } from './edits.ts' +import { NEXT_EDIT_TOKENS } from '../../shared/nextedit.ts' const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' } } }) const config = loadConfig(args.config!) @@ -50,21 +55,14 @@ 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 }])), + 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 }])), 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' }) - } - +/** Everything needed to run a completion: the prompt (symbols, errors, healing, this.-repair applied) and its metadata. */ +async function prepareFim(body: FimRequest) { const profile = config.models[state.model] const requested = body.mode && profile.modes.includes(body.mode) ? body.mode : state.mode let mode: ContextMode = requested @@ -87,18 +85,47 @@ async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: } let symbolContext: string | undefined + let symbolErrors: string | undefined let symbolInfo: Record | 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 } + const s = await symbols.context({ + root, filePath, lang, prefix: body.prefix, suffix: body.suffix, budget: config.symbols.budget, + members: profile.members ?? config.symbols.members, waitMs: config.symbols.waitMs, format: profile.contextFormat ?? 1, errors: profile.errors ?? false, + }) + symbolContext = s.result?.context || undefined + symbolErrors = s.result?.errors + symbolInfo = { status: s.status, items: s.result?.items ?? 0, errors: symbolErrors ? symbolErrors.split('\n').length : 0 } } else { - mode = 'simple' // no workspace or not a TS-family file: header without symbols (Stage C saw this via context dropout) + mode = 'simple' // no workspace or not a TS-family file: header without symbols (seen in training via context dropout) } } - const prompt = builder.build({ ...body, prefix }, profile, mode, symbolContext) + const prompt = builder.build({ ...body, prefix }, profile, mode, symbolContext, symbolErrors, config.tokenHealing !== false) + return { prompt, mode, requested, repair, symbolInfo, filePath } +} + +/** Last requests with their decoded prompts, for "Show last prompt" (GET /v1/debug/last). */ +const debugLog: Record[] = [] +function remember(entry: Record) { + debugLog.push({ t: new Date().toISOString(), ...entry }) + if (debugLog.length > 20) debugLog.shift() +} + +const edits = new EditTracker() + +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' }) + } + // Keep the edit-burst tracker current (only when the client sent the whole file). + if (!body.prefixLineOffset) edits.observe(`${body.workspaceRoot ?? body.repo}/${body.path}`, body.path, body.prefix + body.suffix, body.prefix.split('\n').length - 1) + + const { prompt, mode, requested, repair, symbolInfo } = await prepareFim(body) inflight?.abort() const controller = new AbortController() @@ -109,6 +136,7 @@ async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: const result = await llama.complete(prompt.ids, { nPredict: prompt.singleLine ? p.nPredictSingleLine : p.nPredictMultiLine, stop: prompt.singleLine ? [...STOP_STRINGS, '\n'] : STOP_STRINGS, + grammar: prompt.grammar, }, controller.signal) if (inflight === controller) inflight = null if (result.stoppedBy === 'aborted') { @@ -116,7 +144,10 @@ async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: return } - let text = trimSuffixOverlap(result.text, body.suffix) + // Token healing: the output starts with the half-typed word that was cut off the prompt; strip it. + let raw = result.text + if (prompt.forced) raw = raw.startsWith(prompt.forced) ? raw.slice(prompt.forced.length) : '' + let text = trimSuffixOverlap(raw, body.suffix) if (prompt.singleLine) text = text.split('\n')[0] const maxLines = body.maxLines ?? p.maxLines const lines = text.split('\n') @@ -134,6 +165,7 @@ async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: mode, requestedMode: requested, singleLine: prompt.singleLine, + healed: prompt.forced ?? null, replaceBefore: repair?.replaceBefore ?? 0, filterText, symbols: symbolInfo, @@ -148,10 +180,69 @@ async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: totalMs: round(performance.now() - t0), }, } + remember({ kind: 'fim', requestId: body.requestId, path: body.path, model: state.model, mode, prompt: builder.decode(prompt.ids), raw: result.text, text, timings: response.timings }) telemetry.request(body, response) json(res, 200, response) } +/** 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) { + if (!state.enabled || loading || !llama.running) return json(res, 200, { warmed: false }) + if (inflight) return json(res, 200, { warmed: false, reason: 'busy' }) // never compete with a real completion + const { prompt } = await prepareFim(body) + const controller = new AbortController() + const r = await llama.complete(prompt.ids, { nPredict: 1, stop: STOP_STRINGS }, controller.signal) + json(res, 200, { warmed: true, promptTokens: prompt.ids.length, processedTokens: r.timings?.prompt_n ?? null, promptMs: round(r.timings?.prompt_ms) }) +} + +interface NextEditRequest { requestId?: string; repo: string; path: string; workspaceRoot?: string; languageId?: string; text: string; cursor: number } + +/** + * Next-edit prediction (models with profile.nextEdit): rewrite a small region around the cursor, given the + * current edit burst and nearby TS errors, or answer "no edit". + */ +async function handleNextEdit(body: NextEditRequest, res: http.ServerResponse, clientGone: AbortSignal) { + const t0 = performance.now() + const profile = config.models[state.model] + 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` }) + if (loading || !llama.running) return json(res, 503, { error: loadError ?? 'model loading' }) + 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 lines = body.text.split('\n') + const cursorLine = body.text.slice(0, body.cursor).split('\n').length - 1 + const startLine = Math.max(0, cursorLine - o.regionBefore) + 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 lang = symbolLang(body.languageId ?? '') + 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 }) + : undefined + 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 }) + + inflight?.abort() + const controller = new AbortController() + inflight = controller + clientGone.addEventListener('abort', () => controller.abort()) + const result = await llama.complete(ids, { nPredict: o.maxTokens, stop: [NEXT_EDIT_TOKENS.outputEnd, ...STOP_STRINGS] }, controller.signal) + if (inflight === controller) inflight = null + 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 raw = result.text + const newText = raw.startsWith(NEXT_EDIT_TOKENS.noEdit) ? null : raw + const noEdit = newText === null || newText === oldText || result.stoppedBy === 'limit' + const response = { + requestId: body.requestId, noEdit, startLine, endLine, oldText, newText: noEdit ? null : newText, + recentEdits, errors: errors ?? null, model: state.model, + timings: { promptTokens: ids.length, processedTokens: result.timings?.prompt_n ?? null, genTokens: result.timings?.predicted_n ?? null, 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 }) + 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) { @@ -204,6 +295,12 @@ const server = http.createServer(async (req, res) => { 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/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 === 'GET /v1/debug/last') { + const n = Math.min(20, Number(new URL(req.url!, 'http://x').searchParams.get('n') ?? 1)) + return json(res, 200, debugLog.slice(-n).reverse()) + } 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 ?? []) }) diff --git a/daemon/src/prompt-builder.ts b/daemon/src/prompt-builder.ts index adf57b7..d171aa8 100644 --- a/daemon/src/prompt-builder.ts +++ b/daemon/src/prompt-builder.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import { Tokenizer } from '@huggingface/tokenizers' -import { chunkedPrefixTokens, fimPrompt, headTokens, repoHeader, wantsSingleLine } from '../../shared/prompt.ts' +import { chunkedPrefixTokens, fimPrompt, forcedPrefixGrammar, headTokens, healPrefix, repoHeader, wantsSingleLine } from '../../shared/prompt.ts' +import { buildNextEditPrompt, nextEditTokenIds, type NextEditInput, type NextEditTokenIds } from '../../shared/nextedit.ts' import type { ContextMode, DaemonConfig, ModelProfile } from './config.ts' export interface FimRequest { @@ -28,12 +29,17 @@ export interface BuiltPrompt { singleLine: boolean prefixStartLine: number tokenizeMs: number + /** Token healing: text the output is forced to start with (strip it from the result), and its grammar. */ + forced?: string + grammar?: string } 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 decodeFn: (ids: number[]) => string + readonly nextEditIds: NextEditTokenIds private fim: { prefix: number; suffix: number; middle: number } private header: { repoName: number; fileSep: number } private config: DaemonConfig @@ -45,6 +51,8 @@ export class PromptBuilder { JSON.parse(fs.readFileSync(config.tokenizer.config, 'utf8')), ) this.encodeFn = s => tokenizer.encode(s, { add_special_tokens: false }).ids + this.decodeFn = ids => tokenizer.decode(ids, { skip_special_tokens: false }) + this.nextEditIds = nextEditTokenIds(t => tokenizer.token_to_id(t)) const id = (t: string) => { const v = tokenizer.token_to_id(t) if (v === undefined) throw new Error(`tokenizer is missing ${t}`) @@ -55,23 +63,30 @@ export class PromptBuilder { } encode(s: string) { return this.encodeFn(s) } + decode(ids: number[]) { return this.decodeFn(ids) } + + buildNextEdit(input: NextEditInput, opts: { beforeTokens: number; afterTokens: number }): number[] { + return buildNextEditPrompt(input, this.encodeFn, this.nextEditIds, opts) + } /** * 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 { + build(req: FimRequest, profile: ModelProfile, mode: ContextMode, symbols?: string, errors?: string, heal = false): 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 healed = heal ? healPrefix(req.prefix) : null + const { tokens: prefix, startLine } = chunkedPrefixTokens(healed ? healed.prefix : 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) + const header = mode === 'raw' ? [] : repoHeader(req.repo, req.path, mode === 'symbols' ? symbols : undefined, this.encodeFn, this.header, mode === 'symbols' && profile.errors ? errors : undefined) return { ids: [...header, ...fimPrompt(profile.order, prefix, suffix, this.fim)], mode, singleLine: req.singleLine ?? wantsSingleLine(req.suffix), prefixStartLine: startLine, tokenizeMs: performance.now() - t0, + ...(healed ? { forced: healed.forced, grammar: forcedPrefixGrammar(healed.forced) } : {}), } } } diff --git a/daemon/src/symbols-worker.ts b/daemon/src/symbols-worker.ts index d3189b9..956cfcf 100644 --- a/daemon/src/symbols-worker.ts +++ b/daemon/src/symbols-worker.ts @@ -7,10 +7,12 @@ 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' +import { formatDiagnostics, relevantDiagnostics } from '../../shared/diagnostics.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: 'context'; root: string; filePath: string; lang: SymbolLang; visible: string; cursor: number; budget: number; members: boolean; format: 1 | 2; errors: boolean } + | { id: number; kind: 'errors'; root: string; filePath: string; lang: SymbolLang; visible: string; cursor: number } | { 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 } @@ -31,9 +33,16 @@ parentPort!.on('message', (req: WorkerRequest) => { 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 }) + const errorsAt = (w: WorkspaceSymbols, filePath: string, lang: SymbolLang, visible: string, cursor: number) => { + const line = visible.slice(0, cursor).split('\n').length - 1 + return formatDiagnostics(relevantDiagnostics(w.diagnostics(filePath, lang, visible), line), visible) || undefined } + if (req.kind === 'context') { + const w = workspace(req.root) + const symbols = w.context({ filePath: req.filePath, lang: req.lang, visible: req.visible, cursor: req.cursor, budget: req.budget, countTokens, members: req.members, format: req.format }) + result = { ...(symbols ?? { context: '', items: 0, hasMembers: false }), errors: req.errors ? errorsAt(w, req.filePath, req.lang, req.visible, req.cursor) : undefined } + } + if (req.kind === 'errors') result = errorsAt(workspace(req.root), req.filePath, req.lang, req.visible, req.cursor) 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) { diff --git a/daemon/src/symbols.ts b/daemon/src/symbols.ts index 3b6684d..86586c2 100644 --- a/daemon/src/symbols.ts +++ b/daemon/src/symbols.ts @@ -1,6 +1,7 @@ import { Worker } from 'node:worker_threads' import crypto from 'node:crypto' -import type { SymbolsResult } from '../../shared/symbols.ts' +import type { SymbolsResult as BaseSymbolsResult } from '../../shared/symbols.ts' +export type SymbolsResult = BaseSymbolsResult & { errors?: string } import type { SymbolLang } from '../../shared/symbol-lang.ts' import type { WorkerRequest, WorkerResponse } from './symbols-worker.ts' @@ -49,9 +50,11 @@ export class SymbolService { void this.call('warm', { root }) } - async context(opts: { root: string; filePath: string; lang: SymbolLang; prefix: string; suffix: string; budget: number; members: boolean; waitMs: number }): + async context(opts: { root: string; filePath: string; lang: SymbolLang; prefix: string; suffix: string; budget: number; members: boolean; waitMs: number; format?: 1 | 2; errors?: boolean }): Promise<{ result: SymbolsResult | null; status: 'hit' | 'fresh' | 'stale' | 'none' }> { - const key = anchorKey(opts.filePath, opts.prefix, opts.members) + // Errors go stale as you type; refresh them at most every 20 s (a header change costs a cold prefill). + const errorsBucket = opts.errors ? `|e${Math.floor(Date.now() / 20_000)}` : '' + const key = `${anchorKey(opts.filePath, opts.prefix, opts.members)}|f${opts.format ?? 1}${errorsBucket}` if (this.cache.has(key)) { const result = this.cache.get(key)! this.lastForFile.set(opts.filePath, result) @@ -59,6 +62,7 @@ export class SymbolService { } 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, + format: opts.format ?? 1, errors: opts.errors ?? false, }) if (request) { this.inflight.add(key) @@ -83,6 +87,15 @@ export class SymbolService { if (this.cache.size > 500) this.cache.delete(this.cache.keys().next().value!) } + /** TS errors near the cursor (formatted), for next-edit prompts. */ + async errors(opts: { root: string; filePath: string; lang: SymbolLang; text: string; cursor: number; waitMs: number }): Promise { + const r = await Promise.race([ + this.call('errors', { root: opts.root, filePath: opts.filePath, lang: opts.lang, visible: opts.text, cursor: opts.cursor }), + new Promise(r => setTimeout(() => r(null), opts.waitMs)), + ]) + return (r?.result as string | undefined) ?? undefined + } + 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 }), diff --git a/daemon/test/smoke.ts b/daemon/test/smoke.ts index b873ccb..e0ebe91 100644 --- a/daemon/test/smoke.ts +++ b/daemon/test/smoke.ts @@ -105,6 +105,25 @@ if (envRoot && fs.existsSync(envRoot) && process.env.SMOKE_TS_FILE && process.en console.log('SKIP symbols / this.-repair checks (set SMOKE_TS_ROOT, SMOKE_TS_FILE, SMOKE_TS_ANCHOR)') } +// ---------- token healing, debug log, prewarm +const healedRes = (await fim('export class RelayService {\n private readonly connections = new Map()\n\n disconnect(relayUrl: string) {\n const connection = this.connections.get(relayUrl)\n connection?.close()\n this.connections.del', '\n }\n}\n')).body +check('token healing completes a half-typed word', healedRes.healed === 'del' && healedRes.text.startsWith('ete'), JSON.stringify({ healed: healedRes.healed, text: healedRes.text })) +const dbg = (await api('GET', '/v1/debug/last?n=2')).body as unknown as Record[] +check('debug log returns decoded prompts', Array.isArray(dbg) && dbg[0]?.prompt?.includes('<|fim_prefix|>'), `${dbg.length} entries`) +const warm = (await api('POST', '/v1/prewarm', { repo: 'smoke', path: 'src/warm.ts', prefix: 'export const answer = ', suffix: '\n' })).body +check('prewarm fills the cache', warm.warmed === true && warm.processedTokens > 0, JSON.stringify(warm)) +const ne400 = await api('POST', '/v1/next-edit', { repo: 'smoke', path: 'src/a.ts', text: 'const a = 1\n', cursor: 5 }) +check('next-edit refused for models not trained for it', ne400.status === 400) +if (cfg.models?.['nextedit-plumbing']) { + await api('PUT', '/v1/config', { model: 'nextedit-plumbing' }) + for (let i = 0; i < 120 && !(await api('GET', '/health')).body.ok; i++) await new Promise(r => setTimeout(r, 500)) + const text = 'export function add(a: number, b: number) {\n return a + b\n}\nconst x = add(1)\n' + const ne = (await api('POST', '/v1/next-edit', { repo: 'smoke', path: 'src/add.ts', text, cursor: text.indexOf('add(1)') + 5 })).body + check('next-edit endpoint returns a region decision (plumbing only)', typeof ne.noEdit === 'boolean' && typeof ne.startLine === 'number' && 'newText' in ne, JSON.stringify({ noEdit: ne.noEdit, lines: [ne.startLine, ne.endLine] })) + await api('PUT', '/v1/config', { model: 'stage-c', mode: 'simple' }) + for (let i = 0; i < 120 && !(await api('GET', '/health')).body.ok; i++) await new Promise(r => setTimeout(r, 500)) +} + // ---------- edit recording endpoint const recorded = await api('POST', '/v1/edits', { events: [{ t: Date.now(), repo: 'smoke', path: 'src/a.ts', version: 2, changes: [{ offset: 0, length: 0, text: 'x' }], snapshot: 'x' }] }) check('edit recordings are accepted', recorded.body.recorded === 1) diff --git a/docs/architecture.md b/docs/architecture.md index f9e8bcd..87594bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Editor integration design (VS Code + Zed) -Status (2026-10-03): the daemon, VS Code extension (incl. managed mode), symbols mode, `this.` repair and the edit recorder are implemented. See the [README](../README.md). Not yet built: token healing, the Zed shim, next-edit prediction. +Status (2026-10-03): the daemon, VS Code extension (incl. managed mode), symbols mode (format 1 and 2), TS errors context, `this.` repair, token healing, next-edit plumbing (`/v1/next-edit`, decorations UI, shared prompt format in `shared/nextedit.ts`), pre-warming, the debug log and the edit recorder are implemented. See the [README](../README.md). Not yet built: the Zed shim, parallel "as if accepted" completions, recent-edits-in-other-files context. ## Goals diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 00c0c55..0e69ba2 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -3,7 +3,7 @@ "displayName": "Local Autocomplete", "description": "Inline code completion from a local fine-tuned model via the acd daemon", "publisher": "harvmaster", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "engines": { "vscode": "^1.90.0" @@ -37,6 +37,26 @@ { "command": "localAutocomplete.showLog", "title": "Local Autocomplete: Show Log" + }, + { + "command": "localAutocomplete.applyNextEdit", + "title": "Local Autocomplete: Apply Suggested Edit" + }, + { + "command": "localAutocomplete.dismissNextEdit", + "title": "Local Autocomplete: Dismiss Suggested Edit" + }, + { + "command": "localAutocomplete.requestNextEdit", + "title": "Local Autocomplete: Suggest Next Edit Here" + }, + { + "command": "localAutocomplete.showLastPrompt", + "title": "Local Autocomplete: Show Last Prompt" + }, + { + "command": "localAutocomplete.checkForUpdates", + "title": "Local Autocomplete: Check for Updates" } ], "configuration": { @@ -187,9 +207,51 @@ "type": "string", "default": "", "description": "If set (e.g. ~/Projects/AutoComplete/daemon/recordings), edit recordings are written here directly and no daemon is needed. Otherwise they are sent to the daemon." + }, + "localAutocomplete.nextEdit.enabled": { + "type": "boolean", + "default": true, + "description": "Suggest edits beyond the cursor (e.g. remove a leftover \")\", update call sites) after you pause typing. Only with models trained for next-edit." + }, + "localAutocomplete.nextEdit.debounceMs": { + "type": "number", + "default": 700, + "description": "Pause after typing before asking for a next-edit suggestion." + }, + "localAutocomplete.triggerOnCursorMove": { + "type": "boolean", + "default": false, + "description": "Also suggest when you move the cursor (arrow keys, mouse) and pause. With next-edit models it predicts what you came to change; otherwise only on empty lines, line ends or lines with errors." + }, + "localAutocomplete.cursorMoveDelayMs": { + "type": "number", + "default": 400, + "description": "Pause after moving the cursor before triggerOnCursorMove fires." + }, + "localAutocomplete.prewarmOnFocus": { + "type": "boolean", + "default": false, + "description": "Prefill the model cache when you switch to a file, so the first keystroke is fast (uses some CPU/GPU when switching files)." + }, + "localAutocomplete.checkForUpdates": { + "type": "boolean", + "default": true, + "description": "Check the Gitea releases page for a newer version once a day." } } - } + }, + "keybindings": [ + { + "command": "localAutocomplete.applyNextEdit", + "key": "tab", + "when": "editorTextFocus && localAutocomplete.nextEditVisible && !inlineSuggestionVisible && !suggestWidgetVisible" + }, + { + "command": "localAutocomplete.dismissNextEdit", + "key": "escape", + "when": "editorTextFocus && localAutocomplete.nextEditVisible" + } + ] }, "scripts": { "compile": "tsc -p . && node build-daemon.mjs", diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index c3d6e83..140f129 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -4,7 +4,8 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as nodePath from 'node:path' import { randomUUID } from 'node:crypto' -import { MODE_DESCRIPTIONS, applySelectedSuggestion, repoAndPath, sliceDocument, statusText, type ContextMode } from './logic' +import { MODE_DESCRIPTIONS, applySelectedSuggestion, insertionMakesSense, newerVersion, repoAndPath, sliceDocument, statusText, type ContextMode } from './logic' +import { NextEditController } from './nextedit-ui' import * as managed from './managed-core' interface DaemonConfig { @@ -12,7 +13,7 @@ interface DaemonConfig { model: string mode: ContextMode effectiveMode: ContextMode - models: Record + models: Record llama: { running: boolean; loading: boolean; error: string | null } } @@ -190,7 +191,35 @@ class EditRecorder { this.timer ??= setTimeout(() => this.flush(), 2000) } - forget(doc: vscode.TextDocument) { this.counts.delete(doc.uri.toString()); this.lastSelection.delete(doc.uri.toString()) } + /** VS Code's own diagnostics (TS, Vue, ESLint…) for recorded files: real "error appeared → fixed" labels. */ + private lastDiagnostics = new Map() + onDiagnostics(e: vscode.DiagnosticChangeEvent) { + for (const uri of e.uris) { + const doc = vscode.workspace.textDocuments.find(d => d.uri.toString() === uri.toString()) + if (!doc || !this.eligible(doc)) continue + const diagnostics = vscode.languages.getDiagnostics(uri) + .filter(d => d.severity <= vscode.DiagnosticSeverity.Warning) + .map(d => ({ + line: d.range.start.line, char: d.range.start.character, endLine: d.range.end.line, endChar: d.range.end.character, + severity: d.severity === vscode.DiagnosticSeverity.Error ? 'error' : 'warning', + code: typeof d.code === 'object' ? d.code.value : d.code, source: d.source, message: d.message, + })) + const key = uri.toString() + const fingerprint = JSON.stringify(diagnostics) + if (this.lastDiagnostics.get(key) === fingerprint) continue + this.lastDiagnostics.set(key, fingerprint) + const folder = vscode.workspace.getWorkspaceFolder(uri) + const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(uri, false), doc.fileName) + this.queue.push({ kind: 'diagnostics', app: this.app, t: Date.now(), repo, path, version: doc.version, diagnostics }) + this.timer ??= setTimeout(() => this.flush(), 2000) + } + } + + forget(doc: vscode.TextDocument) { + this.lastDiagnostics.delete(doc.uri.toString()) + this.counts.delete(doc.uri.toString()) + this.lastSelection.delete(doc.uri.toString()) + } flush() { if (this.timer) { clearTimeout(this.timer); this.timer = null } @@ -211,6 +240,136 @@ class EditRecorder { } const recorder = new EditRecorder() +/** Fields every daemon request carries for a document. */ +function documentBase(document: vscode.TextDocument) { + const folder = vscode.workspace.getWorkspaceFolder(document.uri) + const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(document.uri, false), document.fileName) + return { repo, path, workspaceRoot: folder?.uri.scheme === 'file' ? folder.uri.fsPath : undefined, languageId: document.languageId } +} + +function documentAllowed(document: vscode.TextDocument) { + const cfg = settings() + return cfg.get('enableCompletions', true) && !(daemonConfig && !daemonConfig.enabled) + && cfg.get('languages', []).includes(document.languageId) + && !cfg.get('excludeGlobs', []).some(pattern => vscode.languages.match({ pattern }, document) > 0) +} + +const modelSupportsNextEdit = () => !!(daemonConfig && daemonConfig.models[daemonConfig.model]?.nextEdit) + +// ---------- next-edit prediction (models trained for it, e.g. Stage E) +const nextEdit = new NextEditController(s => { + api('POST', '/v1/feedback', { event: 'next-edit-accepted', lines: s.newText.split('\n').length }).catch(() => {}) +}) +let nextEditTimer: NodeJS.Timeout | null = null +let nextEditAbort: AbortController | null = null + +async function requestNextEdit(editor: vscode.TextEditor, reason: string) { + const doc = editor.document + if (!modelSupportsNextEdit() || !settings().get('nextEdit.enabled', true) || !documentAllowed(doc)) return + nextEditAbort?.abort() + const abort = new AbortController() + nextEditAbort = abort + const version = doc.version + try { + const res = await api<{ noEdit: boolean; startLine: number; endLine: number; oldText: string; newText: string | null }>('POST', '/v1/next-edit', { + ...documentBase(doc), text: doc.getText(), cursor: doc.offsetAt(editor.selection.active), reason, + }, abort.signal) + if (abort.signal.aborted || res.noEdit || !res.newText || editor.document.version !== version) return + nextEdit.show(editor, { uri: doc.uri, version, startLine: res.startLine, endLine: res.endLine, oldText: res.oldText, newText: res.newText }) + } catch (e) { + if (!abort.signal.aborted) log.appendLine(`next-edit failed: ${(e as Error).message}`) + } +} + +function scheduleNextEdit(editor: vscode.TextEditor, reason: string, delay: number) { + if (nextEditTimer) clearTimeout(nextEditTimer) + nextEditTimer = setTimeout(() => void requestNextEdit(editor, reason), delay) +} + +// ---------- cursor-move trigger ("why am I here?") +const lastVersions = new Map() +let moveTimer: NodeJS.Timeout | null = null +function onCursorMoved(e: vscode.TextEditorSelectionChangeEvent) { + nextEdit.onSelectionChanged(e) + const doc = e.textEditor.document + const key = doc.uri.toString() + const editedNow = lastVersions.get(key) !== doc.version + lastVersions.set(key, doc.version) + // Only plain navigation (keyboard/mouse), not the selection change that comes with typing. + if (editedNow || e.kind === vscode.TextEditorSelectionChangeKind.Command || !settings().get('triggerOnCursorMove', false)) return + if (!documentAllowed(doc)) return + if (moveTimer) clearTimeout(moveTimer) + moveTimer = setTimeout(() => { + if (vscode.window.activeTextEditor !== e.textEditor) return + if (modelSupportsNextEdit()) return void requestNextEdit(e.textEditor, 'cursor-move') + // Plain FIM can only insert at the cursor: only where an insertion makes sense. + const pos = e.textEditor.selection.active + const lineText = doc.lineAt(pos.line).text + const lineHasError = vscode.languages.getDiagnostics(doc.uri).some(d => d.severity === vscode.DiagnosticSeverity.Error && d.range.start.line <= pos.line && d.range.end.line >= pos.line) + if (insertionMakesSense(lineText, pos.character, lineHasError)) void vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') + }, settings().get('cursorMoveDelayMs', 400)) +} + +// ---------- prewarm on focus +function prewarm(editor: vscode.TextEditor | undefined) { + if (!editor || !settings().get('prewarmOnFocus', false) || !documentAllowed(editor.document)) return + const doc = editor.document + const cfg = settings() + const slice = sliceDocument(doc.getText(), doc.offsetAt(editor.selection.active), cfg.get('maxPrefixChars', 200000), cfg.get('maxSuffixChars', 20000)) + api('POST', '/v1/prewarm', { ...documentBase(doc), ...slice }).catch(() => {}) +} + +// ---------- show last prompt +async function showLastPrompt() { + const entries = await api[]>('GET', '/v1/debug/last?n=1').catch(e => { void vscode.window.showErrorMessage(`Local Autocomplete: ${(e as Error).message}`); return null }) + if (!entries) return + if (!entries.length) return void vscode.window.showInformationMessage('Local Autocomplete: no requests yet') + const e = entries[0] + const content = [ + `# Last ${e.kind} request (${e.t})`, '', `model \`${e.model}\`${e.mode ? `, mode \`${e.mode}\`` : ''}, file \`${e.path}\``, '', + '## Timings', '```json', JSON.stringify(e.timings, null, 2), '```', '', + '## Prompt (decoded, special tokens shown)', '```', String(e.prompt), '```', '', + '## Raw model output', '```', String(e.raw), '```', '', + ...(e.text !== undefined ? ['## Shown to you (after post-processing)', '```', String(e.text), '```'] : []), + ].join('\n') + const doc = await vscode.workspace.openTextDocument({ language: 'markdown', content }) + await vscode.window.showTextDocument(doc, { preview: true }) +} + +// ---------- update check (Gitea releases) +async function checkForUpdates(manual = false) { + const current = extensionCtx.extension.packageJSON.version as string + const last = extensionCtx.globalState.get('lastUpdateCheck', 0) + if (!manual && (!settings().get('checkForUpdates', true) || Date.now() - last < 24 * 3600_000)) return + await extensionCtx.globalState.update('lastUpdateCheck', Date.now()) + try { + const res = await fetch('https://git.harvmaster.com/api/v1/repos/Harvmaster/autocomplete-model/releases?limit=20', { signal: AbortSignal.timeout(10000) }) + const releases = await res.json() as { tag_name: string; html_url: string; assets: { name: string; browser_download_url: string }[] }[] + const latest = releases + .map(r => ({ r, v: /^vscode-v(\d+\.\d+\.\d+)$/.exec(r.tag_name)?.[1] })) + .filter((x): x is { r: typeof releases[number]; v: string } => !!x.v) + .sort((a, b) => (newerVersion(a.v, b.v) ? -1 : 1))[0] + if (!latest || !newerVersion(latest.v, current)) { + if (manual) void vscode.window.showInformationMessage(`Local Autocomplete ${current} is up to date`) + return + } + const asset = latest.r.assets.find(a => a.name.endsWith('.vsix')) + const pick = await vscode.window.showInformationMessage(`Local Autocomplete ${latest.v} is available (you have ${current}).`, ...(asset ? ['Install'] : []), 'Release notes') + if (pick === 'Release notes') void vscode.env.openExternal(vscode.Uri.parse(latest.r.html_url)) + if (pick === 'Install' && asset) { + const file = nodePath.join(extensionCtx.globalStorageUri.fsPath, asset.name) + fs.mkdirSync(nodePath.dirname(file), { recursive: true }) + fs.writeFileSync(file, Buffer.from(await (await fetch(asset.browser_download_url)).arrayBuffer())) + await vscode.commands.executeCommand('workbench.extensions.installExtension', vscode.Uri.file(file)) + const reload = await vscode.window.showInformationMessage(`Local Autocomplete ${latest.v} installed.`, 'Reload Window') + if (reload) void vscode.commands.executeCommand('workbench.action.reloadWindow') + } + } catch (e) { + log.appendLine(`update check failed: ${(e as Error).message}`) + if (manual) void vscode.window.showErrorMessage(`Local Autocomplete: update check failed (${(e as Error).message})`) + } +} + class Provider implements vscode.InlineCompletionItemProvider { async provideInlineCompletionItems(document: vscode.TextDocument, position: vscode.Position, context: vscode.InlineCompletionContext, token: vscode.CancellationToken) { const cfg = settings() @@ -235,14 +394,12 @@ class Provider implements vscode.InlineCompletionItemProvider { if (debounce > 0) await new Promise(r => setTimeout(r, debounce)) if (token.isCancellationRequested) return undefined - const folder = vscode.workspace.getWorkspaceFolder(document.uri) - const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(document.uri, false), document.fileName) + const { repo, path, workspaceRoot } = documentBase(document) const slice = sliceDocument(text, cursor, cfg.get('maxPrefixChars', 200000), cfg.get('maxSuffixChars', 20000)) const requestId = randomUUID() const abort = new AbortController() const sub = token.onCancellationRequested(() => abort.abort()) try { - const workspaceRoot = folder?.uri.scheme === 'file' ? folder.uri.fsPath : undefined const res = await api<{ text: string; cancelled?: boolean; replaceBefore?: number; filterText?: string; timings?: { totalMs: number } }>('POST', '/v1/fim', { requestId, repo, path, workspaceRoot, languageId: document.languageId, ...slice, }, abort.signal) @@ -290,12 +447,16 @@ async function showMenu() { { label: '$(file) Open daemon log', action: 'daemonLog' }, ] : []), { label: '$(output) Show log', action: 'log' }, + { label: '$(debug-console) Show last prompt', action: 'lastPrompt' }, + { label: '$(cloud-download) Check for updates', action: 'updates' }, ] const pick = await vscode.window.showQuickPick(items, { title: 'Local Autocomplete' }) if (pick?.action === 'mode') await selectMode() if (pick?.action === 'model') await selectModel() if (pick?.action === 'toggle') await toggleEnabled() if (pick?.action === 'log') log.show() + if (pick?.action === 'lastPrompt') await showLastPrompt() + if (pick?.action === 'updates') await checkForUpdates(true) if (pick?.action === 'restart') { await api('POST', '/v1/shutdown').catch(() => {}) await new Promise(r => setTimeout(r, 1000)) @@ -381,9 +542,24 @@ export async function activate(ctx: vscode.ExtensionContext) { if (isManaged() && managedKeys.some(k => e.affectsConfiguration(`localAutocomplete.${k}`))) void startManaged() else void refreshConfig() }), - vscode.workspace.onDidChangeTextDocument(e => recorder.onChange(e)), - vscode.window.onDidChangeTextEditorSelection(e => recorder.onSelection(e)), - vscode.window.onDidChangeActiveTextEditor(e => recorder.onActiveEditor(e)), + vscode.workspace.onDidChangeTextDocument(e => { + recorder.onChange(e) + nextEdit.onDocumentChanged(e) + // After a pause in typing, ask whether the edit implies a follow-up elsewhere in the region. + const editor = vscode.window.activeTextEditor + if (editor && editor.document === e.document && e.contentChanges.length && modelSupportsNextEdit()) { + scheduleNextEdit(editor, 'edit', settings().get('nextEdit.debounceMs', 700)) + } + }), + vscode.window.onDidChangeTextEditorSelection(e => { recorder.onSelection(e); onCursorMoved(e) }), + vscode.window.onDidChangeActiveTextEditor(e => { recorder.onActiveEditor(e); prewarm(e) }), + vscode.languages.onDidChangeDiagnostics(e => recorder.onDiagnostics(e)), + nextEdit, + vscode.commands.registerCommand('localAutocomplete.applyNextEdit', () => nextEdit.apply()), + vscode.commands.registerCommand('localAutocomplete.dismissNextEdit', () => nextEdit.clear()), + vscode.commands.registerCommand('localAutocomplete.requestNextEdit', () => { const ed = vscode.window.activeTextEditor; if (ed) void requestNextEdit(ed, 'manual') }), + vscode.commands.registerCommand('localAutocomplete.showLastPrompt', showLastPrompt), + vscode.commands.registerCommand('localAutocomplete.checkForUpdates', () => checkForUpdates(true)), vscode.workspace.onDidCloseTextDocument(doc => recorder.forget(doc)), { dispose: () => recorder.flush() }, vscode.window.onDidChangeWindowState(s => { if (s.focused) void refreshConfig() }), @@ -403,8 +579,10 @@ export async function activate(ctx: vscode.ExtensionContext) { status.text = cfg.get('recordEdits', false) ? '$(record) AC: recording only' : '$(circle-slash) AC: completions off' if (cfg.get('recordingDir', '')) return } + void checkForUpdates() await ensureDaemon() await refreshConfig() + prewarm(vscode.window.activeTextEditor) } export function deactivate() {} diff --git a/extensions/vscode/src/logic.ts b/extensions/vscode/src/logic.ts index 2577042..8aac4c9 100644 --- a/extensions/vscode/src/logic.ts +++ b/extensions/vscode/src/logic.ts @@ -63,3 +63,39 @@ export function statusText(cfg: { enabled: boolean; mode: ContextMode; effective const mode = cfg.effectiveMode && cfg.effectiveMode !== cfg.mode ? `${cfg.mode}→${cfg.effectiveMode}` : cfg.mode return `$(sparkle) AC: ${mode}${lastMs !== null ? ` · ${Math.round(lastMs)} ms` : ''}` } + +/** Line-block diff of a region: the first/last changed lines (old and new) after trimming common lines. */ +export function lineBlockDiff(oldText: string, newText: string): { oldStart: number; oldEnd: number; newLines: string[]; oldLines: string[] } | null { + const a = oldText.split('\n'), b = newText.split('\n') + let p = 0 + while (p < a.length && p < b.length && a[p] === b[p]) p++ + if (p === a.length && p === b.length) return null + let s = 0 + while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++ + return { oldStart: p, oldEnd: a.length - s, oldLines: a.slice(p, a.length - s), newLines: b.slice(p, b.length - s) } +} + +/** Character diff of one line: deleted [start, end) in the old line and the text inserted there. */ +export function charDiff(oldLine: string, newLine: string): { start: number; deleteEnd: number; insert: string } { + let p = 0 + while (p < oldLine.length && p < newLine.length && oldLine[p] === newLine[p]) p++ + let s = 0 + while (s < oldLine.length - p && s < newLine.length - p && oldLine[oldLine.length - 1 - s] === newLine[newLine.length - 1 - s]) s++ + return { start: p, deleteEnd: oldLine.length - s, insert: newLine.slice(p, newLine.length - s) } +} + +/** Where cursor-move triggers make sense for plain FIM: empty line, end of line, or on a line with an error. */ +export function insertionMakesSense(lineText: string, column: number, lineHasError: boolean): boolean { + if (!lineText.trim()) return true + if (!lineText.slice(column).trim()) return true + return lineHasError +} + +/** Semver-ish comparison of "a.b.c" strings. */ +export function newerVersion(candidate: string, current: string): boolean { + const pa = candidate.split('.').map(Number), pb = current.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0) + } + return false +} diff --git a/extensions/vscode/src/managed-core.ts b/extensions/vscode/src/managed-core.ts index 1d90543..48f03d1 100644 --- a/extensions/vscode/src/managed-core.ts +++ b/extensions/vscode/src/managed-core.ts @@ -26,8 +26,19 @@ export interface ModelEntry { modes: ('symbols' | 'simple' | 'raw')[] defaultMode: 'symbols' | 'simple' | 'raw' description?: string + /** Prompt-format flags the model was trained with (passed through to the daemon's model profile). */ + contextFormat?: 1 | 2 + errors?: boolean + members?: boolean + nextEdit?: boolean } +const profileOf = (m: ModelEntry) => ({ + order: m.order, modes: m.modes, defaultMode: m.defaultMode, + ...(m.contextFormat ? { contextFormat: m.contextFormat } : {}), ...(m.errors ? { errors: true } : {}), + ...(m.members !== undefined ? { members: m.members } : {}), ...(m.nextEdit ? { nextEdit: true } : {}), +}) + export interface Manifest { schema: 1 default: string @@ -197,7 +208,7 @@ export interface Prepared { configPath: string; instanceTag: string } /** Resolves model + llama-server (downloading as needed) and writes the daemon config. */ export async function prepare(storage: string, daemonDir: string, extensionVersion: string, s: ManagedSettings, progress: Progress, signal?: AbortSignal): Promise { - const models: Record> = {} + const models: Record> = {} let defaultModel: string let llamaCpp = DEFAULT_LLAMA_CPP @@ -218,11 +229,11 @@ 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(', ')})`) const file = await ensureFile(entry.url, path.join(storage, 'models', entry.file), entry.sha256, `model ${entry.name}`, progress, signal) defaultModel = entry.name - models[entry.name] = { path: file, order: entry.order, modes: entry.modes, defaultMode: entry.defaultMode } + models[entry.name] = { path: file, ...profileOf(entry) } // Other models already on disk stay switchable from the menu. for (const m of manifest.models) { const f = path.join(storage, 'models', m.file) - if (m.name !== entry.name && fs.existsSync(`${f}.verified`)) models[m.name] = { path: f, order: m.order, modes: m.modes, defaultMode: m.defaultMode } + if (m.name !== entry.name && fs.existsSync(`${f}.verified`)) models[m.name] = { path: f, ...profileOf(m) } } } diff --git a/extensions/vscode/src/nextedit-ui.ts b/extensions/vscode/src/nextedit-ui.ts new file mode 100644 index 0000000..e9e4c37 --- /dev/null +++ b/extensions/vscode/src/nextedit-ui.ts @@ -0,0 +1,121 @@ +import * as vscode from 'vscode' +import { charDiff, lineBlockDiff } from './logic' + +export interface NextEditSuggestion { + uri: vscode.Uri + version: number + startLine: number + endLine: number + oldText: string + newText: string +} + +/** + * Shows a predicted region rewrite as decorations (VS Code's stable inline-completion API can't show deletions): + * - one changed line: deleted characters struck through in place, inserted text as ghost text at its position + * - several lines: changed lines highlighted, "Tab to apply" hint, full diff on hover + * Tab applies (only while visible and no other inline suggestion / suggest widget is showing), Escape dismisses. + */ +export class NextEditController implements vscode.Disposable { + private current: NextEditSuggestion | null = null + private readonly deleted = vscode.window.createTextEditorDecorationType({ + textDecoration: 'line-through', + backgroundColor: new vscode.ThemeColor('diffEditor.removedTextBackground'), + }) + private readonly inserted = vscode.window.createTextEditorDecorationType({}) + private readonly block = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: new vscode.ThemeColor('diffEditor.removedLineBackground'), + }) + private readonly onAccepted: (s: NextEditSuggestion) => void + + constructor(onAccepted: (s: NextEditSuggestion) => void) { + this.onAccepted = onAccepted + } + + get visible() { return this.current !== null } + + show(editor: vscode.TextEditor, s: NextEditSuggestion) { + this.clear() + const doc = editor.document + // Only show if the region still holds exactly what the model saw. + const range = new vscode.Range(s.startLine, 0, Math.min(s.endLine, doc.lineCount), 0) + if (doc.version !== s.version || doc.getText(range) !== s.oldText) return + const diff = lineBlockDiff(s.oldText, s.newText) + if (!diff) return + this.current = s + const first = s.startLine + diff.oldStart + + if (diff.oldLines.length === 1 && diff.newLines.length === 1) { + const d = charDiff(diff.oldLines[0], diff.newLines[0]) + if (d.deleteEnd > d.start) editor.setDecorations(this.deleted, [new vscode.Range(first, d.start, first, d.deleteEnd)]) + if (d.insert) { + editor.setDecorations(this.inserted, [{ + range: new vscode.Range(first, d.deleteEnd, first, d.deleteEnd), + renderOptions: { before: { contentText: d.insert, color: new vscode.ThemeColor('editorGhostText.foreground'), fontStyle: 'italic', backgroundColor: new vscode.ThemeColor('diffEditor.insertedTextBackground') } }, + }]) + } + } else { + const hover = new vscode.MarkdownString(['**Suggested edit** (Tab to apply, Esc to dismiss)', '```diff', ...diff.oldLines.map(l => `-${l}`), ...diff.newLines.map(l => `+${l}`), '```'].join('\n')) + const lines = Math.max(1, diff.oldLines.length) + editor.setDecorations(this.block, Array.from({ length: lines }, (_, i) => ({ range: new vscode.Range(first + i, 0, first + i, 0), hoverMessage: hover }))) + editor.setDecorations(this.inserted, [{ + range: new vscode.Range(first, Number.MAX_SAFE_INTEGER, first, Number.MAX_SAFE_INTEGER), + hoverMessage: hover, + renderOptions: { after: { contentText: ` ⇥ Tab: suggested edit (−${diff.oldLines.length} +${diff.newLines.length} lines)`, color: new vscode.ThemeColor('editorGhostText.foreground'), fontStyle: 'italic' } }, + }]) + } + void vscode.commands.executeCommand('setContext', 'localAutocomplete.nextEditVisible', true) + } + + async apply(): Promise { + const s = this.current + const editor = vscode.window.activeTextEditor + this.clear() + if (!s || !editor || editor.document.uri.toString() !== s.uri.toString()) return false + const doc = editor.document + const range = new vscode.Range(s.startLine, 0, Math.min(s.endLine, doc.lineCount), 0) + if (doc.getText(range) !== s.oldText) return false + const ok = await editor.edit(b => b.replace(range, s.newText)) + if (ok) { + const diff = lineBlockDiff(s.oldText, s.newText) + if (diff) { + const line = s.startLine + diff.oldStart + Math.max(0, diff.newLines.length - 1) + const pos = new vscode.Position(line, doc.lineAt(Math.min(line, doc.lineCount - 1)).text.length) + editor.selection = new vscode.Selection(pos, pos) + } + this.onAccepted(s) + } + return ok + } + + /** Dismiss when the document changes or the cursor leaves the region. */ + onSelectionChanged(e: vscode.TextEditorSelectionChangeEvent) { + const s = this.current + if (!s || e.textEditor.document.uri.toString() !== s.uri.toString()) return + const line = e.selections[0].active.line + if (line < s.startLine - 2 || line > s.endLine + 2) this.clear() + } + + onDocumentChanged(e: vscode.TextDocumentChangeEvent) { + if (this.current && e.document.uri.toString() === this.current.uri.toString() && e.contentChanges.length) this.clear() + } + + clear() { + if (!this.current) return + this.current = null + for (const editor of vscode.window.visibleTextEditors) { + editor.setDecorations(this.deleted, []) + editor.setDecorations(this.inserted, []) + editor.setDecorations(this.block, []) + } + void vscode.commands.executeCommand('setContext', 'localAutocomplete.nextEditVisible', false) + } + + dispose() { + this.clear() + this.deleted.dispose() + this.inserted.dispose() + this.block.dispose() + } +} diff --git a/extensions/vscode/test/logic.test.mjs b/extensions/vscode/test/logic.test.mjs index 8b1b5a1..99bc99e 100644 --- a/extensions/vscode/test/logic.test.mjs +++ b/extensions/vscode/test/logic.test.mjs @@ -36,3 +36,28 @@ test('status text', () => { assert.match(statusText({ enabled: true, mode: 'symbols', effectiveMode: 'simple', model: 'stage-c' }, 41.6), /symbols→simple · 42 ms/) assert.match(statusText(null, null), /no daemon/) }) + +import { lineBlockDiff, charDiff, insertionMakesSense, newerVersion } from '../dist/logic.js' + +test('leftover paren is a one-character deletion', () => { + const d = charDiff('const v = fromString(someString))', 'const v = fromString(someString)') + assert.deepEqual(d, { start: 32, deleteEnd: 33, insert: '' }) +}) + +test('line block diff finds the changed lines', () => { + const d = lineBlockDiff('a\nb\nc\nd\n', 'a\nB\nB2\nd\n') + assert.deepEqual(d, { oldStart: 1, oldEnd: 3, oldLines: ['b', 'c'], newLines: ['B', 'B2'] }) + assert.equal(lineBlockDiff('x\n', 'x\n'), null) +}) + +test('cursor-move gate', () => { + assert.equal(insertionMakesSense(' ', 4, false), true) + assert.equal(insertionMakesSense(' foo()', 7, false), true) + assert.equal(insertionMakesSense(' foo()', 3, false), false) + assert.equal(insertionMakesSense(' foo()', 3, true), true) +}) + +test('version comparison', () => { + assert.equal(newerVersion('0.10.0', '0.9.3'), true) + assert.equal(newerVersion('0.5.0', '0.5.0'), false) +}) diff --git a/models/README.md b/models/README.md index cd26bf9..18024be 100644 --- a/models/README.md +++ b/models/README.md @@ -37,6 +37,38 @@ Suffix-first (`spm`) order: `stage-c` additionally expects a repo-level header: `<|repo_name|>{repo}\n<|file_sep|>{path}\n`, optionally with a `__symbols__.d.ts` pseudo-file of available symbols before it. +## Training data + +Fine-tuned on permissively licensed open-source repositories (below, pinned commits) and on the publisher's own +code (not published). Secrets were scrubbed from all training data; held-out evaluation repos were never trained on. + +| repository | commit | license | +|---|---|---| +| [github.com/WiseLibs/better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `f8e2d54120` | MIT | +| [github.com/CashScript/cashscript](https://github.com/CashScript/cashscript) | `71a243b656` | MIT | +| [gitlab.com/electrum-cash/network](https://gitlab.com/electrum-cash/network) | `c7511725a9` | MIT | +| [github.com/element-plus/element-plus](https://github.com/element-plus/element-plus) | `ad514f79ba` | MIT | +| [github.com/fastify/fastify](https://github.com/fastify/fastify) | `19d5be0daf` | MIT | +| [github.com/h3js/h3](https://github.com/h3js/h3) | `3afe46b170` | MIT | +| [github.com/kysely-org/kysely](https://github.com/kysely-org/kysely) | `4b7287e71b` | MIT | +| [github.com/bitauth/libauth](https://github.com/bitauth/libauth) | `60aec239cc` | MIT | +| [github.com/nuxt/ui](https://github.com/nuxt/ui) | `e8dd339dfd` | MIT | +| [github.com/unjs/ofetch](https://github.com/unjs/ofetch) | `1dbc37fd1c` | MIT | +| [github.com/vuejs/pinia](https://github.com/vuejs/pinia) | `98587ca465` | MIT | +| [github.com/quasarframework/quasar](https://github.com/quasarframework/quasar) | `0fd7ce30e7` | MIT | +| [github.com/unovue/reka-ui](https://github.com/unovue/reka-ui) | `e9230140ed` | MIT | +| [github.com/unovue/shadcn-vue](https://github.com/unovue/shadcn-vue) | `f12a6a098d` | MIT | +| [github.com/vitejs/vite](https://github.com/vitejs/vite) | `10033218d2` | MIT | +| [github.com/vuejs/vitepress](https://github.com/vuejs/vitepress) | `56fa6c9df1` | MIT | +| [github.com/vitest-dev/vitest](https://github.com/vitest-dev/vitest) | `964e1a4404` | MIT | +| [github.com/vuejs/core](https://github.com/vuejs/core) | `4ab865a848` | MIT | +| [github.com/intlify/vue-i18n](https://github.com/intlify/vue-i18n) | `6ef45366e1` | MIT | +| [github.com/vuejs/router](https://github.com/vuejs/router) | `07663eb6be` | MIT | +| [github.com/vbenjs/vue-vben-admin](https://github.com/vbenjs/vue-vben-admin) | `50f4ede309` | MIT | +| [github.com/vueuse/vueuse](https://github.com/vueuse/vueuse) | `efdd69a148` | MIT | +| [github.com/websockets/ws](https://github.com/websockets/ws) | `297202cdae` | MIT | +| [github.com/colinhacks/zod](https://github.com/colinhacks/zod) | `0b216ef674` | MIT | + ## License Apache-2.0 ([LICENSE](LICENSE), [NOTICE](NOTICE)), as a derivative of Qwen2.5-Coder. diff --git a/scripts/make-model-release.mjs b/scripts/make-model-release.mjs index 87972fc..589a302 100644 --- a/scripts/make-model-release.mjs +++ b/scripts/make-model-release.mjs @@ -13,11 +13,19 @@ import crypto from 'node:crypto' import { parseArgs } from 'node:util' const repo = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..') -const { values, positionals } = parseArgs({ options: { from: { type: 'string', default: path.join(repo, '..', 'models') } }, allowPositionals: true }) +const { values, positionals } = parseArgs({ options: { + from: { type: 'string', default: path.join(repo, '..', 'models') }, + // Lock file of the public fine-tuning corpus (training workspace), for the attribution table. + sources: { type: 'string', default: path.join(repo, '..', 'pipeline', 'config', 'public-sources.lock.json') }, +}, allowPositionals: true }) const out = path.join(repo, 'models') // Prompt layout each model was trained with (the extension only offers matching context modes). const profiles = { + // 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)' }, + // 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-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)' }, } @@ -68,6 +76,10 @@ Fine-tuning data: permissively licensed (MIT) open-source TypeScript/Vue reposit the publisher's own code. Secrets were scrubbed from the training data. `) +const sources = fs.existsSync(values.sources) ? JSON.parse(fs.readFileSync(values.sources, 'utf8')) : [] +const sourcesTable = sources.length + ? ['| repository | commit | license |', '|---|---|---|', ...sources.map(src => `| [${src.url.replace(/^https:\/\/|\.git$/g, '')}](${src.url.replace(/\.git$/, '')}) | \`${src.commit.slice(0, 10)}\` | ${src.license} |`)].join('\n') + : '(source list not available when this file was generated)' const row = m => `| \`${m.name}\` | ${m.description} | ${m.order} | ${m.modes.join(', ')} | ${(m.size / 1e6).toFixed(0)} MB |` fs.writeFileSync(path.join(out, 'README.md'), `# Models @@ -107,6 +119,13 @@ Suffix-first (\`spm\`) order: \`stage-c\` additionally expects a repo-level header: \`<|repo_name|>{repo}\\n<|file_sep|>{path}\\n\`, optionally with a \`__symbols__.d.ts\` pseudo-file of available symbols before it. +## Training data + +Fine-tuned on permissively licensed open-source repositories (below, pinned commits) and on the publisher's own +code (not published). Secrets were scrubbed from all training data; held-out evaluation repos were never trained on. + +${sourcesTable} + ## License Apache-2.0 ([LICENSE](LICENSE), [NOTICE](NOTICE)), as a derivative of Qwen2.5-Coder. diff --git a/shared/diagnostics.ts b/shared/diagnostics.ts new file mode 100644 index 0000000..0b77515 --- /dev/null +++ b/shared/diagnostics.ts @@ -0,0 +1,64 @@ +/** + * TypeScript errors near the cursor, as prompt context ("TS errors as context") and for verifying generated + * training data. Shared by the training pipeline and the daemon so the format is identical in both. + * + * Rendered as a pseudo-file in the repo header: + * <|file_sep|>{dir}/__errors__.txt + * TS2304 Cannot find name 'getU'. | const user = getU + */ + +export interface Diagnostic { + /** 0-based line in the visible file. */ + line: number + code: number + message: string + category: 'error' | 'warning' +} + +/** + * Errors that are noise for completion: environment problems (unresolved modules or types when dependencies + * aren't installed) rather than mistakes in the code being written. + */ +export const IGNORED_CODES = new Set([ + 2307, // Cannot find module + 7016, // Could not find a declaration file for module + 2792, // Cannot find module. Did you mean to set moduleResolution… + 7006, // Parameter implicitly has an 'any' type + 7031, // Binding element implicitly has an 'any' type + 2875, // JSX tag requires the module path … to exist + 6133, // declared but never read (unused), noise while writing + 6196, // declared but never used +]) + +export function relevantDiagnostics(diags: Diagnostic[], cursorLine: number, opts: { window?: number; max?: number } = {}): Diagnostic[] { + const window = opts.window ?? 20 + const max = opts.max ?? 8 + return diags + .filter(d => !IGNORED_CODES.has(d.code) && Math.abs(d.line - cursorLine) <= window) + .sort((a, b) => Math.abs(a.line - cursorLine) - Math.abs(b.line - cursorLine) || a.line - b.line) + .slice(0, max) +} + +/** One line per diagnostic: code, message (first line), and the trimmed source line it points at. */ +export function formatDiagnostics(diags: Diagnostic[], visible: string): string { + const lines = visible.split('\n') + return diags.map(d => { + const message = d.message.split('\n')[0].slice(0, 160) + const src = (lines[d.line] ?? '').trim().slice(0, 100) + return `TS${d.code} ${message} | ${src}` + }).join('\n') +} + +/** Diagnostics present in `after` but not in `before`, matched by code + message (positions shift with edits). */ +export function newDiagnostics(before: Diagnostic[], after: Diagnostic[]): Diagnostic[] { + const counts = new Map() + for (const d of before) counts.set(`${d.code}:${d.message}`, (counts.get(`${d.code}:${d.message}`) ?? 0) + 1) + const out: Diagnostic[] = [] + for (const d of after) { + const key = `${d.code}:${d.message}` + const n = counts.get(key) ?? 0 + if (n > 0) counts.set(key, n - 1) + else if (!IGNORED_CODES.has(d.code)) out.push(d) + } + return out +} diff --git a/shared/nextedit.ts b/shared/nextedit.ts new file mode 100644 index 0000000..8b11f21 --- /dev/null +++ b/shared/nextedit.ts @@ -0,0 +1,151 @@ +/** + * Next-edit prediction prompt format, shared by the training pipeline and the daemon. + * + * <|repo_name|>{repo}\n + * [<|file_sep|>{dir}/__errors__.txt\n{errors}\n] (shared/diagnostics.ts, optional) + * [<|file_sep|>__recent_edits__.diff\n{diff}\n] (most recent edit(s), optional) + * <|file_sep|>{path}\n + * {file before region}{region, with at the cursor}{file after region} + * + * Target: the rewritten region, then ; or when nothing should change. + * Markers reuse Qwen2.5's vision special tokens, which never occur in code. + */ +import type { Encode } from './prompt.ts' + +export const NEXT_EDIT_TOKENS = { + regionStart: '<|quad_start|>', + regionEnd: '<|quad_end|>', + cursor: '<|box_start|>', + outputStart: '<|object_ref_start|>', + outputEnd: '<|object_ref_end|>', + noEdit: '<|vision_pad|>', +} as const + +export type NextEditTokenIds = Record + +export interface NextEditInput { + repo: string + path: string + /** Current file text (after the user's recent edit). */ + file: string + /** Editable region: [startLine, endLine) in `file`, 0-based. */ + regionStartLine: number + regionEndLine: number + /** Cursor offset in `file` (must lie inside the region). */ + cursor: number + recentEdits?: string + errors?: string +} + +/** Character offsets of the start of each line, plus file length. */ +export function lineOffsets(text: string): number[] { + const out = [0] + for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) out.push(i + 1) + return out +} + +/** Region text for [startLine, endLine): whole lines, including the final newline when present. */ +export function regionText(text: string, startLine: number, endLine: number): string { + const offs = lineOffsets(text) + return text.slice(offs[startLine], endLine < offs.length ? offs[endLine] : text.length) +} + +/** + * Compact line diff of one edit for the "recent edits" context: + * @@ path:line + * -old + * +new + */ +export function lineDiff(path: string, before: string, after: string, maxLines = 12): string { + const a = before.split('\n'), b = after.split('\n') + let p = 0 + while (p < a.length && p < b.length && a[p] === b[p]) p++ + let s = 0 + while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++ + const removed = a.slice(p, a.length - s).slice(0, maxLines) + const added = b.slice(p, b.length - s).slice(0, maxLines) + if (!removed.length && !added.length) return '' + return [`@@ ${path}:${p + 1}`, ...removed.map(l => `-${l}`), ...added.map(l => `+${l}`)].join('\n') +} + +/** Changed line range of `after` relative to `before`, in `after`'s line numbers: [start, end). */ +export function changedLines(before: string, after: string): [number, number] | null { + const a = before.split('\n'), b = after.split('\n') + let p = 0 + while (p < a.length && p < b.length && a[p] === b[p]) p++ + if (p === a.length && p === b.length) return null + let s = 0 + while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++ + return [p, Math.max(p + 1, b.length - s)] +} + +/** + * Picks the editable region: covers the cursor line and every line that must change (both in `file` + * coordinates), plus a margin; null if that would exceed maxLines. + */ +export function chooseRegion(file: string, cursorLine: number, mustCover: [number, number][], opts: { margin?: number; maxLines?: number } = {}): [number, number] | null { + const margin = opts.margin ?? 3 + const maxLines = opts.maxLines ?? 40 + const nLines = file.split('\n').length + let start = cursorLine, end = cursorLine + 1 + for (const [a, b] of mustCover) { start = Math.min(start, a); end = Math.max(end, b) } + if (end - start > maxLines) return null + start = Math.max(0, start - margin) + end = Math.min(nLines, end + margin) + return [start, end] +} + +export function nextEditTokenIds(tokenToId: (t: string) => number | undefined): NextEditTokenIds { + const id = (t: string) => { + const v = tokenToId(t) + if (v === undefined) throw new Error(`tokenizer is missing ${t}`) + return v + } + return { + regionStart: id(NEXT_EDIT_TOKENS.regionStart), regionEnd: id(NEXT_EDIT_TOKENS.regionEnd), cursor: id(NEXT_EDIT_TOKENS.cursor), + outputStart: id(NEXT_EDIT_TOKENS.outputStart), outputEnd: id(NEXT_EDIT_TOKENS.outputEnd), noEdit: id(NEXT_EDIT_TOKENS.noEdit), + repoName: id('<|repo_name|>'), fileSep: id('<|file_sep|>'), + } +} + +/** Prompt tokens up to and including OUTPUT_START. Context before/after the region is truncated at line boundaries. */ +export function buildNextEditPrompt(input: NextEditInput, encode: Encode, ids: NextEditTokenIds, opts: { beforeTokens?: number; afterTokens?: number } = {}): number[] { + const beforeTokens = opts.beforeTokens ?? 1024 + const afterTokens = opts.afterTokens ?? 384 + const offs = lineOffsets(input.file) + const rStart = offs[input.regionStartLine] + const rEnd = input.regionEndLine < offs.length ? offs[input.regionEndLine] : input.file.length + if (input.cursor < rStart || input.cursor > rEnd) throw new Error('cursor outside region') + + const out: number[] = [ids.repoName, ...encode(`${input.repo}\n`)] + const dir = input.path.includes('/') ? input.path.slice(0, input.path.lastIndexOf('/')) : '.' + if (input.errors) out.push(ids.fileSep, ...encode(`${dir}/__errors__.txt\n${input.errors}\n`)) + if (input.recentEdits) out.push(ids.fileSep, ...encode(`__recent_edits__.diff\n${input.recentEdits}\n`)) + out.push(ids.fileSep, ...encode(`${input.path}\n`)) + + // Before the region: last lines that fit; after: first lines that fit. + let before = input.file.slice(0, rStart) + let beforeIds = encode(before) + while (beforeIds.length > beforeTokens && before.includes('\n')) { + before = before.slice(before.indexOf('\n') + 1) + beforeIds = beforeIds.length > beforeTokens * 2 ? encode(before.slice(-beforeTokens * 6)) : encode(before) + } + let after = input.file.slice(rEnd) + let afterIds = encode(after.slice(0, afterTokens * 8)) + if (afterIds.length > afterTokens) { + after = after.slice(0, afterTokens * 8) + while (afterIds.length > afterTokens && after.includes('\n')) { + after = after.slice(0, after.lastIndexOf('\n', after.length - 2) + 1) + afterIds = encode(after) + } + } + out.push(...beforeIds, ids.regionStart, + ...encode(input.file.slice(rStart, input.cursor)), ids.cursor, ...encode(input.file.slice(input.cursor, rEnd)), + ids.regionEnd, ...afterIds, ids.outputStart) + return out +} + +/** Target tokens: rewritten region (or NO_EDIT), then OUTPUT_END. The caller appends <|endoftext|>. */ +export function nextEditTarget(newRegion: string | null, encode: Encode, ids: NextEditTokenIds): number[] { + return newRegion === null ? [ids.noEdit, ids.outputEnd] : [...encode(newRegion), ids.outputEnd] +} diff --git a/shared/prompt.ts b/shared/prompt.ts index f1b633d..159a115 100644 --- a/shared/prompt.ts +++ b/shared/prompt.ts @@ -29,14 +29,14 @@ export function fimPrompt(order: FimOrder, prefix: number[], suffix: number[], i /** * Qwen2.5-Coder repo-level header placed before the FIM prompt: - * repo\n [dir/__symbols__.d.ts\n{context}\n] path\n + * repo\n [dir/__symbols__.d.ts\n{context}\n] [dir/__errors__.txt\n{errors}\n] path\n */ -export function repoHeader(repo: string, filePath: string, context: string | undefined, encode: Encode, ids: HeaderTokenIds): number[] { +export function repoHeader(repo: string, filePath: string, context: string | undefined, encode: Encode, ids: HeaderTokenIds, errors?: string): number[] { const out = [ids.repoName, ...encode(`${repo}\n`)] - if (context) { - const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '.' - out.push(ids.fileSep, ...encode(`${dir}/__symbols__.d.ts\n${context}\n`)) - } + const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '.' + if (context) out.push(ids.fileSep, ...encode(`${dir}/__symbols__.d.ts\n${context}\n`)) + // TS errors near the cursor (shared/diagnostics.ts), for models trained with them (Stage D+). + if (errors) out.push(ids.fileSep, ...encode(`${dir}/__errors__.txt\n${errors}\n`)) out.push(ids.fileSep, ...encode(`${filePath}\n`)) return out } @@ -115,3 +115,22 @@ export function wantsSingleLine(suffix: string): boolean { const restOfLine = suffix.split('\n')[0] return /[^\s)\]}'"`;,]/.test(restOfLine) } + +/** + * Token healing for completions that start mid-identifier (`this.connections.del▮`). + * + * Suffix-first prompts end exactly at the cursor, so a half-typed word leaves an unnatural final token and the + * model tends to treat the word as finished (`(relayUrl)` instead of `ete(relayUrl)`). Instead, cut the partial + * word off the prompt and force the output to start with it (llama.cpp grammar); the caller strips it again. + * Bonus: while typing `d`, `de`, `del` the healed prompt is identical, so the KV cache is fully reused. + */ +export function healPrefix(prefix: string): { prefix: string; forced: string } | null { + const m = /(^|[^\w$])([\w$]{1,32})$/.exec(prefix) + if (!m) return null + return { prefix: prefix.slice(0, prefix.length - m[2].length), forced: m[2] } +} + +/** GBNF grammar: output must start with `forced`, then anything. */ +export function forcedPrefixGrammar(forced: string): string { + return `root ::= "${forced.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" rest\nrest ::= [^\\x00]*` +} diff --git a/shared/symbols.ts b/shared/symbols.ts index c236c20..e84967d 100644 --- a/shared/symbols.ts +++ b/shared/symbols.ts @@ -16,6 +16,7 @@ import ts from 'typescript' import { parse as parseSfc } from '@vue/compiler-sfc' import type { SymbolLang } from './symbol-lang.ts' +import type { Diagnostic } from './diagnostics.ts' export { symbolLang, type SymbolLang } from './symbol-lang.ts' export interface SymbolsRequest { @@ -30,6 +31,11 @@ export interface SymbolsRequest { countTokens: (s: string) => number /** Include member completions when the cursor follows `expr.` (default true, as in training). */ members?: boolean + /** + * Context format version. 1 = Stage C's training format (default, byte-identical to before). + * 2 = also namespace imports (`import * as x`): x's members, the ones this file uses first, with signatures. + */ + format?: 1 | 2 } export interface SymbolsResult { context: string; items: number; hasMembers: boolean } @@ -181,6 +187,14 @@ export class WorkspaceSymbols { const names: ts.Identifier[] = [] if (stmt.importClause.name) names.push(stmt.importClause.name) const bindings = stmt.importClause.namedBindings + if ((req.format ?? 1) >= 2 && bindings && ts.isNamespaceImport(bindings)) { + const ns = this.namespaceMembers(fileName, tsText, bindings.name.text) + if (ns) { + const used = new RegExp(`\\b${bindings.name.text.replace(/\$/g, '\\$')}\\.`).test(near) + items.push({ priority: used ? 1 : 2, text: `// from '${module}' (namespace ${bindings.name.text})\n${ns}` }) + } + continue + } if (bindings && ts.isNamedImports(bindings)) for (const el of bindings.elements) names.push(el.name) for (const id of names) { const pos = id.getStart(sf) @@ -209,6 +223,66 @@ export class WorkspaceSymbols { return chosen.length ? { context: chosen.join('\n'), items: chosen.length, hasMembers: chosen[0].startsWith('// members of') } : null } + /** + * Members of a namespace import, as signature lines. Ranked by how often this file already uses each + * member (`ns.member`), then by TypeScript's own completion order; details for the top 12. + */ + private namespaceMembers(fileName: string, tsText: string, ns: string): string | null { + // Probe: ask for completions at `ns.` appended to the file, then restore. + const probe = `${tsText}\n;${ns}.` + this.overrides.set(fileName, { text: probe, version: ++this.version }) + try { + const pos = probe.length + const completions = this.service.getCompletionsAtPosition(fileName, pos, {}) + if (!completions?.entries.length) return null + const usage = new Map() + for (const m of tsText.matchAll(new RegExp(`\\b${ns.replace(/\$/g, '\\$')}\\.([A-Za-z_$][\\w$]*)`, 'g'))) usage.set(m[1], (usage.get(m[1]) ?? 0) + 1) + const entries = [...completions.entries].sort((a, b) => (usage.get(b.name) ?? 0) - (usage.get(a.name) ?? 0) || a.sortText.localeCompare(b.sortText) || a.name.localeCompare(b.name)).slice(0, 40) + const detailed = entries.slice(0, 12).map(e => { + const d = this.service.getCompletionEntryDetails(fileName, pos, e.name, undefined, undefined, undefined, undefined) + return clip(displayText(d?.displayParts).replace(/^\((method|property|function|const|alias|let|var)\) /, ''), 200) + }) + const rest = entries.slice(12).map(e => e.name) + return `${detailed.join('\n')}${rest.length ? `\n// also: ${rest.join(', ')}` : ''}` + } finally { + this.overrides.set(fileName, { text: tsText, version: ++this.version }) + } + } + + /** + * Syntactic + semantic diagnostics of the visible file (Vue: its script blocks), with lines mapped back to the + * visible file. Used for "TS errors as context" and to verify generated next-edit data. + */ + diagnostics(filePath: string, lang: SymbolLang, visible: string): Diagnostic[] { + const loaded = this.load(filePath, lang, visible, 0) + if (!loaded) return [] + // Map TS-text offsets back to visible-file lines (Vue script blocks are concatenated with '\n'). + const segments: { tsStart: number; visStart: number; length: number }[] = [] + if (lang === 'vue') { + let tsPos = 0 + for (const r of vueScriptRegions(visible)) { + segments.push({ tsStart: tsPos, visStart: r.start, length: r.end - r.start }) + tsPos += r.end - r.start + 1 + } + } else { + segments.push({ tsStart: 0, visStart: 0, length: visible.length }) + } + const toVisibleLine = (tsOffset: number) => { + const seg = segments.find(g => tsOffset >= g.tsStart && tsOffset <= g.tsStart + g.length) ?? segments[0] + const vis = seg.visStart + (tsOffset - seg.tsStart) + return visible.slice(0, vis).split('\n').length - 1 + } + const all = [...this.service.getSyntacticDiagnostics(loaded.fileName), ...this.service.getSemanticDiagnostics(loaded.fileName)] + return all + .filter(d => d.start !== undefined && (d.category === ts.DiagnosticCategory.Error || d.category === ts.DiagnosticCategory.Warning)) + .map(d => ({ + line: toVisibleLine(d.start!), + code: d.code, + message: ts.flattenDiagnosticMessageText(d.messageText, '\n'), + category: d.category === ts.DiagnosticCategory.Error ? 'error' as const : 'warning' as const, + })) + } + /** * If the identifier being typed at the cursor only resolves as a member of * the enclosing class (the user forgot `this.`), returns the member name.