Files
autocomplete-model/daemon/src/config.ts
T
HarvmasterandClaude Opus 5.5 3dc916834f
ci / build (push) Canceled after 0s
Daemon: a changed default model overrides the saved state (0.6.3)
In managed mode the extension switches models by rewriting the daemon config's
defaultModel and restarting it, but loadState() preferred the persisted model, so the
daemon came back on the old one every time. State now records the default it was saved
under; a different default wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
2026-10-04 01:06:31 +00:00

118 lines
5.4 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import type { FimOrder } from '../../shared/prompt.ts'
export type ContextMode = 'symbols' | 'simple' | 'raw'
/** What a model was trained on; the toggle only offers these modes. */
export interface ModelProfile {
path: string
order: FimOrder
modes: ContextMode[]
defaultMode: ContextMode
/** 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
/** Another profile (trained with nextEdit) that serves /v1/next-edit for this model, in a second llama-server. */
nextEditModel?: string
}
export interface DaemonConfig {
host: string
port: number
tokenizer: { json: string; config: string }
llamaServer: {
binary: string
port: number
ctx: number
/** Extra flags, e.g. ["-ngl", "99"] on Apple Silicon, ["-t", "16"] on the laptop CPU. */
args: string[]
/** Extra flags for the next-edit companion server only. Default: n-gram speculative decoding (the output is
* mostly a copy of the region, so drafts from the prompt are accepted; exact with greedy sampling). */
nextEditArgs?: string[]
}
models: Record<string, ModelProfile>
defaultModel: string
prompt: {
prefixTokens: number
suffixTokens: number
/** Prefix window start moves in multiples of this many lines (KV-cache stability). */
prefixChunkLines: number
maxLines: number
nPredictSingleLine: number
nPredictMultiLine: number
}
symbols: {
/** Token budget for the __symbols__.d.ts context (Stage C was trained with up to 2048). */
budget: number
/** How long a request waits for fresh context when the anchor changes before using stale/none. */
waitMs: number
/** Member completions after `expr.`: in training, but every `.` changes the header (cold prefill). */
members: boolean
}
thisRepair: { enabled: boolean; waitMs: number }
/** 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 }
stateFile: string
/** Workspace roots for clients that can't send one (Zed via /v1/completions): files are looked up under these. */
workspaceRoots?: string[]
/** Exit after this many minutes without any request (0 = never). Editors poll every 15 s while open. */
idleShutdownMinutes?: number
/** Opaque tag reported by /health (the VS Code extension uses it to replace daemons left over from older versions). */
instanceTag?: string
}
/** Toggles that survive daemon restarts and are shared by every editor talking to this daemon. */
export interface RuntimeState {
enabled: boolean
model: string
mode: ContextMode
/** config.defaultModel when this state was saved: a changed default (editor model setting, new release) wins. */
defaultModel?: string
}
export function loadConfig(configPath: string): DaemonConfig {
const dir = path.dirname(path.resolve(configPath))
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as DaemonConfig
const abs = (p: string) => (path.isAbsolute(p) || !p.includes('/') ? p : path.resolve(dir, p)) // bare names (e.g. "llama-server") stay on PATH
raw.tokenizer = { json: abs(raw.tokenizer.json), config: abs(raw.tokenizer.config) }
raw.llamaServer.binary = abs(raw.llamaServer.binary)
for (const m of Object.values(raw.models)) m.path = abs(m.path)
raw.telemetry.dir = abs(raw.telemetry.dir)
raw.recording.dir = abs(raw.recording.dir)
raw.stateFile = abs(raw.stateFile)
if (!raw.models[raw.defaultModel]) throw new Error(`defaultModel ${raw.defaultModel} is not in models`)
for (const [name, m] of Object.entries(raw.models)) {
if (!m.modes.includes(m.defaultMode)) throw new Error(`model ${name}: defaultMode must be one of its modes`)
if (m.nextEditModel && !raw.models[m.nextEditModel]?.nextEdit) throw new Error(`model ${name}: nextEditModel ${m.nextEditModel} must be a profile with nextEdit`)
}
return raw
}
export function loadState(config: DaemonConfig): RuntimeState {
const fallback: RuntimeState = { enabled: true, model: config.defaultModel, mode: config.models[config.defaultModel].defaultMode, defaultModel: config.defaultModel }
try {
const s = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')) as Partial<RuntimeState>
const keepModel = s.defaultModel === config.defaultModel && s.model && config.models[s.model]
const model = keepModel ? s.model! : fallback.model
const mode = s.mode && config.models[model].modes.includes(s.mode) ? s.mode : config.models[model].defaultMode
return { enabled: s.enabled ?? true, model, mode, defaultModel: config.defaultModel }
} catch {
return fallback
}
}
export function saveState(config: DaemonConfig, state: RuntimeState): void {
fs.mkdirSync(path.dirname(config.stateFile), { recursive: true })
fs.writeFileSync(config.stateFile, JSON.stringify(state, null, 2) + '\n')
}