Extension 0.6.0: next-edit, token healing, TS errors context, cursor-move, prewarm
ci / build (push) Canceled after 0s
ci / build (push) Canceled after 0s
- shared/nextedit.ts: next-edit prompt format (region rewrite or NO_EDIT, Qwen spare special tokens) - shared/diagnostics.ts: TS errors near the cursor as context; symbols format 2 adds namespace imports - shared/prompt.ts: token healing (cut the half-typed word, force it via grammar), errors header section - daemon: model profiles carry contextFormat/errors/members/nextEdit; /v1/next-edit with edit-burst tracking; /v1/prewarm; /v1/debug/last; token healing on by default - extension: next-edit decorations (Tab apply / Esc dismiss), suggest on cursor move, prewarm on focus, Show Last Prompt, update check against releases, diagnostics in the edit recorder - models/README: training-data attribution; release script profiles for stage-d/stage-e - .gitea/workflows/ci.yml: build + test, publish release on vscode-v* tags (needs a runner + RELEASE_TOKEN) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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<string, { baseline: string; last: string; line: number; time: number }>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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<CompletionResult> {
|
||||
async complete(prompt: number[], opts: { nPredict: number; stop: string[]; grammar?: string }, signal: AbortSignal): Promise<CompletionResult> {
|
||||
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,
|
||||
|
||||
+113
-16
@@ -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<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 }
|
||||
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<string, unknown>[] = []
|
||||
function remember(entry: Record<string, unknown>) {
|
||||
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 ?? []) })
|
||||
|
||||
@@ -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) } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+16
-3
@@ -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<string | undefined> {
|
||||
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<null>(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 }),
|
||||
|
||||
Reference in New Issue
Block a user