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:
@@ -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<string, number>()
|
||||
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
|
||||
}
|
||||
@@ -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_START>{region, with <CURSOR> at the cursor}<REGION_END>{file after region}<OUTPUT_START>
|
||||
*
|
||||
* Target: the rewritten region, then <OUTPUT_END>; or <NO_EDIT><OUTPUT_END> 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<keyof typeof NEXT_EDIT_TOKENS | 'repoName' | 'fileSep', number>
|
||||
|
||||
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]
|
||||
}
|
||||
+25
-6
@@ -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_name>repo\n [<file_sep>dir/__symbols__.d.ts\n{context}\n] <file_sep>path\n
|
||||
* <repo_name>repo\n [<file_sep>dir/__symbols__.d.ts\n{context}\n] [<file_sep>dir/__errors__.txt\n{errors}\n] <file_sep>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]*`
|
||||
}
|
||||
|
||||
@@ -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<string, number>()
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user