Extension 0.6.0: next-edit, token healing, TS errors context, cursor-move, prewarm
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:
2026-10-03 17:21:38 +00:00
co-authored by Claude Opus 5.5
parent af302df26a
commit 87d3c3c3bf
24 changed files with 1090 additions and 53 deletions
+11
View File
@@ -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 }
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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 ?? []) })
+19 -4
View File
@@ -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) } : {}),
}
}
}
+12 -3
View File
@@ -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
View File
@@ -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 }),
+19
View File
@@ -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<string, WebSocket>()\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<string, string>[]
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)