Restructure into a monorepo: models, VS Code extension, daemon, shared

- models/: GGUFs (Git LFS), manifest.json, model card, Apache-2.0 LICENSE + NOTICE
- extensions/vscode/: the Local Autocomplete extension (v0.5.0, publisher harvmaster)
- daemon/: acd completion daemon (bundled into the extension; also runs standalone)
- shared/: prompt layout + symbol extraction shared with the training pipeline
- scripts/: make-model-release.mjs, publish-extension-release.mjs (Gitea releases)
- README with quick start, settings, architecture, build and release instructions;
  MIT license for code; CHANGELOG

The default model manifest URL moves to models/manifest.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:37:19 +00:00
co-authored by Claude Opus 5.5
parent f8dcf1b7df
commit fa8f775779
52 changed files with 309630 additions and 207 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.vsix
+7
View File
@@ -0,0 +1,7 @@
src/**
test/**
**/*.map
tsconfig.json
node_modules/**
build-daemon.mjs
*.vsix
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Harvmaster
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
# Local Autocomplete for VS Code
Inline code completion from a small fine-tuned model running **on your machine**. It's tuned for TypeScript, Vue and Node.js.
## Getting started
1. Install the VSIX from the [Releases](https://git.harvmaster.com/Harvmaster/autocomplete-model/releases) page: `code --install-extension local-autocomplete-<version>.vsix`.
2. Open a TypeScript or Vue file. The first time, the model (~530 MB) and llama.cpp are downloaded and verified (a progress notification shows this), and a background daemon starts.
3. Type. **Tab** accepts the ghost text; **Ctrl/Cmd+→** accepts it word by word.
Turn off other inline-completion providers (e.g. Copilot) so they don't compete.
With **Remote-SSH**, install the extension in the remote window: the model and daemon then run on the remote machine, next to your code.
## The status bar
`AC: simple · 38 ms`. Click it to:
- **Switch context mode:** `symbols` (available symbols from the TypeScript language service), `simple` (repo + path only), or `raw`. Only modes the current model was trained for are offered.
- **Switch model:** downloaded on first use.
- **Turn completions on/off**, **restart the daemon**, or **open its log**.
## How the daemon runs (managed mode)
- **One daemon for every window.** The extension bundles the daemon and runs it on VS Code's own Node runtime, so there's nothing to install. It exits after `daemon.idleShutdownMinutes` (default 30) once no window is using it.
- **Downloads are verified.** They're SHA-256 checked, resume if interrupted, and are stored in the extension's global storage.
- **Updates are automatic.** After an extension update, or a change to model/daemon settings, the daemon is replaced.
- **Your own daemon is left alone.** If something you started yourself already listens on `daemon.port`, it's used as-is and never stopped.
Custom setups:
- `modelPath` (+ `modelProfile`): use a local `.gguf`.
- `llamaServerPath`: use your own `llama-server`, e.g. Homebrew's.
- `daemon.mode: "external"` + `serverUrl`: use a daemon you run yourself, possibly on another machine. See [daemon/](../../daemon).
## Edit recording (opt-in)
`localAutocomplete.recordEdits: true` records your edits, cursor moves and file switches to local JSONL files, as training data for future next-edit prediction. It never records excluded files, and nothing leaves your machine. Every event is tagged with the editor app.
- **Recording only:** `enableCompletions: false` + `recordingDir: "~/…"` records without showing completions and without a daemon.
- **In Cursor:** Cursor's terms (§1.5) prohibit using its Tab suggestions to train a competing model, and accepted suggestions end up in the recording. Turn Cursor Tab off while recording.
## Privacy
Files matching `excludeGlobs` (`.env*`, keys, `secrets/`, … by default) are never read or sent. Completions run locally. Telemetry and recording are off unless you enable them.
## Development
From the repo root, `npm run setup && npm run build` produces `local-autocomplete-<version>.vsix`.
- `npm test` runs the logic unit tests.
- `npm run test:managed` tests the download/daemon lifecycle against a local server.
- `src/logic.ts` is the testable core, `src/managed-core.ts` handles downloads and the daemon lifecycle, and `src/extension.ts` is the VS Code glue.
- `build-daemon.mjs` bundles `../../daemon` + `../../shared` into `dist/daemon`.
+43
View File
@@ -0,0 +1,43 @@
// Bundles ../../daemon (and ../../shared) into dist/daemon so the extension can run
// the daemon on VS Code's own Node runtime, with no separate install.
import { build } from 'esbuild'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
// @vue/compiler-sfc lazily require()s ~40 optional template engines (pug, twig, …) that we never
// use (we only call parse()). Leave anything unresolvable from inside it as a lazy external require.
const optionalVueDeps = {
name: 'optional-vue-deps',
setup(b) {
b.onResolve({ filter: /^[^./]/ }, args => {
if (!args.importer.includes('@vue/compiler-sfc') && !args.importer.includes('consolidate')) return undefined
try { createRequire(args.importer).resolve(args.path); return undefined } catch { return { path: args.path, external: true } }
})
},
}
const out = 'dist/daemon'
fs.rmSync(out, { recursive: true, force: true })
const result = await build({
entryPoints: { acd: '../../daemon/src/main.ts', 'symbols-worker': '../../daemon/src/symbols-worker.ts' },
outdir: out,
outExtension: { '.js': '.mjs' },
bundle: true,
platform: 'node',
format: 'esm',
target: 'node18',
minify: true,
legalComments: 'external',
logLevel: 'warning',
metafile: true,
plugins: [optionalVueDeps],
// Bundled CJS deps (typescript) use require/__filename/__dirname; give the ESM output real ones.
banner: { js: "import { createRequire as __cr } from 'node:module'; import { fileURLToPath as __fu } from 'node:url'; import { dirname as __dn } from 'node:path'; const require = __cr(import.meta.url); const __filename = __fu(import.meta.url); const __dirname = __dn(__filename);" },
})
fs.mkdirSync(`${out}/assets`, { recursive: true })
for (const f of ['qwen-tokenizer.json', 'qwen-tokenizer_config.json']) fs.copyFileSync(`../../daemon/assets/${f}`, `${out}/assets/${f}`)
// TypeScript looks for its lib.*.d.ts next to the executing file (the bundled worker).
const tsLib = path.dirname(createRequire(import.meta.url).resolve('../../shared/node_modules/typescript/lib/typescript.js'))
for (const f of fs.readdirSync(tsLib)) if (/^lib\..*\.d\.ts$/.test(f)) fs.copyFileSync(path.join(tsLib, f), `${out}/${f}`)
for (const [file, info] of Object.entries(result.metafile.outputs)) if (file.endsWith('.mjs')) console.log(file, (info.bytes / 1e6).toFixed(1) + ' MB')
+2367
View File
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
{
"name": "local-autocomplete",
"displayName": "Local Autocomplete",
"description": "Inline code completion from a local fine-tuned model via the acd daemon",
"publisher": "harvmaster",
"version": "0.5.0",
"license": "MIT",
"engines": {
"vscode": "^1.90.0"
},
"categories": [
"Programming Languages",
"Machine Learning"
],
"main": "./dist/extension.js",
"activationEvents": [
"onStartupFinished"
],
"contributes": {
"commands": [
{
"command": "localAutocomplete.showMenu",
"title": "Local Autocomplete: Menu (mode, model, on/off)"
},
{
"command": "localAutocomplete.selectMode",
"title": "Local Autocomplete: Select Context Mode"
},
{
"command": "localAutocomplete.selectModel",
"title": "Local Autocomplete: Select Model"
},
{
"command": "localAutocomplete.toggleEnabled",
"title": "Local Autocomplete: Toggle On/Off"
},
{
"command": "localAutocomplete.showLog",
"title": "Local Autocomplete: Show Log"
}
],
"configuration": {
"title": "Local Autocomplete",
"properties": {
"localAutocomplete.daemon.mode": {
"type": "string",
"enum": [
"managed",
"external"
],
"default": "managed",
"enumDescriptions": [
"The extension downloads the model + llama.cpp and runs the bundled daemon itself (shared by all windows, stops when idle).",
"Use a daemon you run yourself at serverUrl (optionally started with daemon.command)."
],
"description": "How the completion daemon is run."
},
"localAutocomplete.modelManifestUrl": {
"type": "string",
"default": "https://git.harvmaster.com/Harvmaster/autocomplete-model/media/branch/main/models/manifest.json",
"description": "URL of manifest.json on the model server (managed mode). Defaults to the published models on git.harvmaster.com. Lists the models and the llama.cpp build; files are downloaded once and SHA-256 verified."
},
"localAutocomplete.model": {
"type": "string",
"default": "",
"description": "Model name from the manifest (empty = the manifest's default). Managed mode."
},
"localAutocomplete.modelPath": {
"type": "string",
"default": "",
"description": "Use this local .gguf instead of downloading one (managed mode). Set modelProfile to match how it was trained."
},
"localAutocomplete.modelProfile": {
"type": "object",
"default": {
"order": "spm",
"modes": [
"symbols",
"simple"
],
"defaultMode": "simple"
},
"description": "Prompt layout of a custom modelPath: order (spm|psm), supported context modes and the default mode."
},
"localAutocomplete.llamaServerPath": {
"type": "string",
"default": "",
"description": "Use this llama-server binary instead of downloading the pinned llama.cpp build (e.g. from Homebrew)."
},
"localAutocomplete.llamaServerArgs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Extra llama-server flags. Empty = automatic (-ngl 99 on macOS, -t <physical cores> on Linux)."
},
"localAutocomplete.daemon.port": {
"type": "number",
"default": 7071,
"description": "Port of the managed daemon (llama-server uses port + 1)."
},
"localAutocomplete.daemon.idleShutdownMinutes": {
"type": "number",
"default": 30,
"description": "The managed daemon exits after this long with no editor connected (0 = never)."
},
"localAutocomplete.serverUrl": {
"type": "string",
"default": "http://127.0.0.1:7071",
"description": "Daemon URL in external mode (can be another machine, e.g. the M1 Max)."
},
"localAutocomplete.daemon.autoStart": {
"type": "boolean",
"default": true,
"description": "External mode: start the daemon with daemon.command if it isn't running."
},
"localAutocomplete.daemon.command": {
"type": "string",
"default": "",
"description": "External mode: shell command that starts acd."
},
"localAutocomplete.debounceMs": {
"type": "number",
"default": 25,
"description": "Wait this long after a keystroke before requesting (requests are also cancelled when you keep typing)."
},
"localAutocomplete.languages": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"typescript",
"javascript",
"typescriptreact",
"javascriptreact",
"vue",
"json",
"jsonc",
"yaml",
"dockercompose",
"dockerfile",
"css",
"scss",
"html",
"shellscript"
],
"description": "Language ids to complete in."
},
"localAutocomplete.excludeGlobs": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"**/.env",
"**/.env.*",
"**/*.pem",
"**/*.key",
"**/*.p12",
"**/secrets/**",
"**/.npmrc"
],
"description": "Files matching these globs are never sent to the daemon."
},
"localAutocomplete.maxPrefixChars": {
"type": "number",
"default": 200000,
"description": "Larger prefixes are truncated at a stable line boundary before sending."
},
"localAutocomplete.maxSuffixChars": {
"type": "number",
"default": 20000
},
"localAutocomplete.recordEdits": {
"type": "boolean",
"default": false,
"description": "Record your document edits, cursor moves and file switches (diffs + periodic snapshots) locally, as training data for next-edit prediction. Respects excludeGlobs. Off by default. Events are tagged with the editor app."
},
"localAutocomplete.enableCompletions": {
"type": "boolean",
"default": true,
"description": "Show inline completions. Turn off to use the extension only as an edit recorder (e.g. alongside another completion provider)."
},
"localAutocomplete.recordingDir": {
"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."
}
}
}
},
"scripts": {
"compile": "tsc -p . && node build-daemon.mjs",
"test": "node --test test/*.test.mjs",
"package": "vsce package --no-dependencies --baseContentUrl https://git.harvmaster.com/Harvmaster/autocomplete-model/src/branch/main/extensions/vscode --baseImagesUrl https://git.harvmaster.com/Harvmaster/autocomplete-model/raw/branch/main/extensions/vscode",
"test:managed": "node test/managed.e2e.mjs ../../models /tmp/local-autocomplete-managed-test"
},
"devDependencies": {
"@types/node": "^22.20.5",
"@types/vscode": "^1.90.0",
"@vscode/vsce": "^4.0.0",
"esbuild": "^0.28.2",
"typescript": "5.9"
},
"extensionKind": [
"workspace"
],
"repository": {
"type": "git",
"url": "https://git.harvmaster.com/Harvmaster/autocomplete-model.git",
"directory": "extensions/vscode"
},
"homepage": "https://git.harvmaster.com/Harvmaster/autocomplete-model",
"bugs": {
"url": "https://git.harvmaster.com/Harvmaster/autocomplete-model/issues"
}
}
+410
View File
@@ -0,0 +1,410 @@
import * as vscode from 'vscode'
import { spawn } from 'node:child_process'
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 * as managed from './managed-core'
interface DaemonConfig {
enabled: boolean
model: string
mode: ContextMode
effectiveMode: ContextMode
models: Record<string, { order: string; modes: ContextMode[]; defaultMode: ContextMode }>
llama: { running: boolean; loading: boolean; error: string | null }
}
let log: vscode.OutputChannel
let status: vscode.StatusBarItem
let daemonConfig: DaemonConfig | null = null
let lastLatency: number | null = null
const settings = () => vscode.workspace.getConfiguration('localAutocomplete')
const isManaged = () => settings().get<string>('daemon.mode', 'managed') === 'managed'
const serverUrl = () => isManaged()
? `http://127.0.0.1:${settings().get<number>('daemon.port', 7071)}`
: settings().get<string>('serverUrl', 'http://127.0.0.1:7071').replace(/\/$/, '')
let extensionCtx: vscode.ExtensionContext
let managedStarting: Promise<void> | null = null
function managedSettings(): managed.ManagedSettings {
const c = settings()
return {
port: c.get<number>('daemon.port', 7071),
manifestUrl: c.get<string>('modelManifestUrl', ''),
model: c.get<string>('model', ''),
modelPath: c.get<string>('modelPath', ''),
modelProfile: c.get('modelProfile', { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'simple' }) as managed.ManagedSettings['modelProfile'],
llamaServerPath: c.get<string>('llamaServerPath', ''),
llamaServerArgs: c.get<string[]>('llamaServerArgs', []),
recordingDir: c.get<string>('recordingDir', ''),
idleShutdownMinutes: c.get<number>('daemon.idleShutdownMinutes', 30),
}
}
/**
* Managed mode: download (first run) and start the bundled daemon with the model
* from the manifest, or reuse the one another window already started.
*/
function startManaged(): Promise<void> {
managedStarting ??= (async () => {
const storage = extensionCtx.globalStorageUri.fsPath
const daemonDir = vscode.Uri.joinPath(extensionCtx.extensionUri, 'dist', 'daemon').fsPath
const version = extensionCtx.extension.packageJSON.version as string
try {
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Local Autocomplete', cancellable: true }, async (progress, token) => {
const abort = new AbortController()
token.onCancellationRequested(() => abort.abort())
let last = 0
const report: managed.Progress = (message, fraction) => {
const increment = fraction === undefined ? undefined : Math.max(0, fraction * 100 - last)
if (fraction !== undefined) last = fraction * 100
progress.report({ message, increment })
log.appendLine(message)
}
const prepared = await managed.prepare(storage, daemonDir, version, managedSettings(), report, abort.signal)
const how = await managed.ensureRunning(storage, daemonDir, prepared, managedSettings().port, report)
log.appendLine(how === 'external'
? `port ${managedSettings().port} is served by a daemon you started yourself; using it`
: `managed daemon ${how} (${prepared.instanceTag}); log: ${storage}/acd.log`)
})
} catch (e) {
const msg = (e as Error).message
log.appendLine(`managed daemon: ${msg}`)
const pick = await vscode.window.showErrorMessage(`Local Autocomplete: ${msg}`, 'Open Settings', 'Show Log')
if (pick === 'Open Settings') void vscode.commands.executeCommand('workbench.action.openSettings', 'localAutocomplete')
if (pick === 'Show Log') log.show()
} finally {
managedStarting = null
await refreshConfig()
}
})()
return managedStarting
}
async function api<T>(method: string, route: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(serverUrl() + route, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal })
const json = await res.json() as T & { error?: string }
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`)
return json
}
async function refreshConfig() {
if (!settings().get<boolean>('enableCompletions', true)) {
status.text = settings().get<boolean>('recordEdits', false) ? '$(record) AC: recording only' : '$(circle-slash) AC: completions off'
status.tooltip = 'Local Autocomplete: completions disabled (localAutocomplete.enableCompletions)'
return
}
try { daemonConfig = await api<DaemonConfig>('GET', '/v1/config') } catch { daemonConfig = null }
status.text = statusText(daemonConfig, lastLatency)
status.tooltip = daemonConfig
? `Local Autocomplete\nmodel: ${daemonConfig.model}\nmode: ${daemonConfig.mode}${daemonConfig.effectiveMode !== daemonConfig.mode ? ` (running as ${daemonConfig.effectiveMode})` : ''}${daemonConfig.llama.error ? `\nerror: ${daemonConfig.llama.error}` : ''}\nclick for options`
: `Local Autocomplete: daemon not reachable at ${serverUrl()}`
}
async function ensureDaemon() {
if (isManaged()) return startManaged()
try { await api('GET', '/health'); return } catch { /* not running */ }
const command = settings().get<string>('daemon.command', '')
if (!settings().get<boolean>('daemon.autoStart', true) || !command) {
log.appendLine(`daemon not reachable at ${serverUrl()} and no daemon.command configured`)
return
}
log.appendLine(`starting daemon: ${command}`)
const child = spawn(command, { shell: true, detached: true, stdio: 'ignore' })
child.unref()
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 500))
try { await api('GET', '/health'); log.appendLine('daemon is up'); return } catch { /* still starting */ }
}
log.appendLine('daemon did not come up within 60 s')
}
/**
* Opt-in edit recorder (localAutocomplete.recordEdits): records document
* changes, cursor moves and file switches as local JSONL, either directly to
* `recordingDir` or via the daemon. Real editing histories are the training
* data for next-edit prediction. The first event per document (and every
* 200th) carries a full snapshot so histories can be replayed. Every event is
* tagged with the editor app, so e.g. sessions recorded in Cursor with its
* own Tab model active can be excluded from training (Cursor ToS §1.5).
*/
class EditRecorder {
private queue: unknown[] = []
private counts = new Map<string, number>()
private timer: NodeJS.Timeout | null = null
private lastSelection = new Map<string, { offset: number; t: number }>()
private readonly app = vscode.env.appName
private eligible(doc: vscode.TextDocument) {
const cfg = settings()
return cfg.get<boolean>('recordEdits', false) && doc.uri.scheme === 'file'
&& cfg.get<string[]>('languages', []).includes(doc.languageId)
&& !cfg.get<string[]>('excludeGlobs', []).some(pattern => vscode.languages.match({ pattern }, doc) > 0)
}
onChange(e: vscode.TextDocumentChangeEvent) {
if (!e.contentChanges.length || !this.eligible(e.document)) return
const key = e.document.uri.toString()
const n = this.counts.get(key) ?? 0
this.counts.set(key, n + 1)
const folder = vscode.workspace.getWorkspaceFolder(e.document.uri)
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(e.document.uri, false), e.document.fileName)
const editor = vscode.window.activeTextEditor
this.queue.push({
kind: 'edit', app: this.app, t: Date.now(), repo, path, languageId: e.document.languageId, version: e.document.version,
reason: e.reason === vscode.TextDocumentChangeReason.Undo ? 'undo' : e.reason === vscode.TextDocumentChangeReason.Redo ? 'redo' : undefined,
changes: e.contentChanges.map(c => ({ offset: c.rangeOffset, length: c.rangeLength, text: c.text })),
cursor: editor?.document === e.document ? e.document.offsetAt(editor.selection.active) : undefined,
// Snapshot of the document *after* this change.
snapshot: n % 200 === 0 ? e.document.getText() : undefined,
})
this.timer ??= setTimeout(() => this.flush(), 2000)
}
/** Cursor moves without edits (throttled): where the user goes next is half of next-edit prediction. */
onSelection(e: vscode.TextEditorSelectionChangeEvent) {
const doc = e.textEditor.document
if (!this.eligible(doc) || e.kind === vscode.TextEditorSelectionChangeKind.Command) return
const key = doc.uri.toString()
const offset = doc.offsetAt(e.selections[0].active)
const prev = this.lastSelection.get(key)
const now = Date.now()
if (prev && (prev.offset === offset || now - prev.t < 300)) return
this.lastSelection.set(key, { offset, t: now })
const folder = vscode.workspace.getWorkspaceFolder(doc.uri)
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(doc.uri, false), doc.fileName)
this.queue.push({ kind: 'cursor', app: this.app, t: now, repo, path, version: doc.version, cursor: offset, via: e.kind === vscode.TextEditorSelectionChangeKind.Mouse ? 'mouse' : 'keyboard' })
this.timer ??= setTimeout(() => this.flush(), 2000)
}
onActiveEditor(editor: vscode.TextEditor | undefined) {
if (!editor || !this.eligible(editor.document)) return
const doc = editor.document
const folder = vscode.workspace.getWorkspaceFolder(doc.uri)
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(doc.uri, false), doc.fileName)
this.queue.push({ kind: 'focus', app: this.app, t: Date.now(), repo, path, version: doc.version, cursor: doc.offsetAt(editor.selection.active) })
this.timer ??= setTimeout(() => this.flush(), 2000)
}
forget(doc: vscode.TextDocument) { this.counts.delete(doc.uri.toString()); this.lastSelection.delete(doc.uri.toString()) }
flush() {
if (this.timer) { clearTimeout(this.timer); this.timer = null }
if (!this.queue.length) return
const events = this.queue
this.queue = []
const dir = settings().get<string>('recordingDir', '').replace(/^~(?=$|\/)/, os.homedir())
if (dir) {
// Direct to disk: no daemon needed (recording-only setups, e.g. inside Cursor).
try {
fs.mkdirSync(dir, { recursive: true })
fs.appendFileSync(nodePath.join(dir, `edits-${new Date().toISOString().slice(0, 10)}.jsonl`), events.map(e => JSON.stringify(e)).join('\n') + '\n')
} catch (e) { log.appendLine(`edit recorder: ${(e as Error).message}`) }
return
}
api('POST', '/v1/edits', { events }).catch(e => log.appendLine(`edit recorder: ${(e as Error).message}`))
}
}
const recorder = new EditRecorder()
class Provider implements vscode.InlineCompletionItemProvider {
async provideInlineCompletionItems(document: vscode.TextDocument, position: vscode.Position, context: vscode.InlineCompletionContext, token: vscode.CancellationToken) {
const cfg = settings()
if (!cfg.get<boolean>('enableCompletions', true)) return undefined
if (daemonConfig && !daemonConfig.enabled) return undefined
if (!cfg.get<string[]>('languages', []).includes(document.languageId)) return undefined
if (cfg.get<string[]>('excludeGlobs', []).some(pattern => vscode.languages.match({ pattern }, document) > 0)) return undefined
let text = document.getText()
let cursor = document.offsetAt(position)
let replaceStart = position
const selected = context.selectedCompletionInfo
if (selected) {
// Extend the highlighted IntelliSense item instead of contradicting it.
const applied = applySelectedSuggestion(text, document.offsetAt(selected.range.start), cursor, selected.text)
text = applied.text
cursor = applied.cursorOffset
replaceStart = selected.range.start
}
const debounce = cfg.get<number>('debounceMs', 25)
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 slice = sliceDocument(text, cursor, cfg.get<number>('maxPrefixChars', 200000), cfg.get<number>('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)
if (res.timings) {
lastLatency = res.timings.totalMs
status.text = statusText(daemonConfig, lastLatency)
}
if (!res.text || res.cancelled || token.isCancellationRequested) return undefined
let item: vscode.InlineCompletionItem
if (res.replaceBefore) {
// The daemon rewrote text before the cursor (e.g. "getU" -> "this.getUser(id)").
if (selected) return undefined
const start = position.translate(0, -res.replaceBefore)
item = new vscode.InlineCompletionItem(res.text, new vscode.Range(start, position))
item.filterText = res.filterText
} else {
const insertText = selected ? selected.text + res.text : res.text
item = new vscode.InlineCompletionItem(insertText, new vscode.Range(replaceStart, position))
}
item.command = { command: 'localAutocomplete.accepted', title: 'accepted', arguments: [requestId, res.text] }
return [item]
} catch (e) {
if (!abort.signal.aborted) log.appendLine(`completion failed: ${(e as Error).message}`)
return undefined
} finally {
sub.dispose()
}
}
}
async function showMenu() {
await refreshConfig()
if (!daemonConfig) {
const pick = await vscode.window.showQuickPick(['Start daemon', 'Show log'], { title: `Daemon not reachable at ${serverUrl()}` })
if (pick === 'Start daemon') { await ensureDaemon(); await refreshConfig() }
if (pick === 'Show log') log.show()
return
}
const items = [
{ label: '$(symbol-namespace) Context mode…', detail: `current: ${daemonConfig.mode}`, action: 'mode' },
{ label: '$(server-process) Model…', detail: `current: ${daemonConfig.model}`, action: 'model' },
{ label: daemonConfig.enabled ? '$(circle-slash) Turn off' : '$(check) Turn on', action: 'toggle' },
...(isManaged() ? [
{ label: '$(debug-restart) Restart daemon', action: 'restart' },
{ label: '$(file) Open daemon log', action: 'daemonLog' },
] : []),
{ label: '$(output) Show log', action: 'log' },
]
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 === 'restart') {
await api('POST', '/v1/shutdown').catch(() => {})
await new Promise(r => setTimeout(r, 1000))
await startManaged()
}
if (pick?.action === 'daemonLog') void vscode.window.showTextDocument(vscode.Uri.joinPath(extensionCtx.globalStorageUri, 'acd.log'))
}
async function selectMode() {
await refreshConfig()
if (!daemonConfig) return
const modes = daemonConfig.models[daemonConfig.model].modes
const pick = await vscode.window.showQuickPick(
modes.map(m => ({ label: m, description: m === daemonConfig!.mode ? '(current)' : '', detail: MODE_DESCRIPTIONS[m] })),
{ title: `Context mode for ${daemonConfig.model}` },
)
if (!pick) return
await api('PUT', '/v1/config', { mode: pick.label })
await refreshConfig()
}
async function selectModel() {
await refreshConfig()
// Managed mode: offer every model in the manifest (downloading on demand), not just the ones on disk.
const cached = vscode.Uri.joinPath(extensionCtx.globalStorageUri, 'manifest.json').fsPath
if (isManaged() && !settings().get<string>('modelPath', '') && fs.existsSync(cached)) {
const manifest = JSON.parse(fs.readFileSync(cached, 'utf8')) as managed.Manifest
const current = daemonConfig?.model
const pick = await vscode.window.showQuickPick(
manifest.models.map(m => ({ label: m.name, description: m.name === current ? '(current)' : `${(m.size / 1e6).toFixed(0)} MB`, detail: m.description })),
{ title: 'Model (downloads on first use)' },
)
if (!pick || pick.label === current) return
await settings().update('model', pick.label, vscode.ConfigurationTarget.Global) // triggers re-prepare via onDidChangeConfiguration
return
}
if (!daemonConfig) return
const pick = await vscode.window.showQuickPick(
Object.entries(daemonConfig.models).map(([name, m]) => ({ label: name, description: name === daemonConfig!.model ? '(current)' : '', detail: `${m.order}, modes: ${m.modes.join(', ')}` })),
{ title: 'Model (switching reloads llama-server)' },
)
if (!pick || pick.label === daemonConfig.model) return
await api('PUT', '/v1/config', { model: pick.label })
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Loading ${pick.label}…` }, async () => {
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 500))
await refreshConfig()
if (daemonConfig && !daemonConfig.llama.loading) break
}
})
if (daemonConfig?.llama.error) vscode.window.showErrorMessage(`Local Autocomplete: ${daemonConfig.llama.error}`)
}
async function toggleEnabled() {
await refreshConfig()
if (!daemonConfig) return
await api('PUT', '/v1/config', { enabled: !daemonConfig.enabled })
await refreshConfig()
}
export async function activate(ctx: vscode.ExtensionContext) {
extensionCtx = ctx
log = vscode.window.createOutputChannel('Local Autocomplete')
status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100)
status.command = 'localAutocomplete.showMenu'
status.text = '$(loading~spin) AC'
status.show()
ctx.subscriptions.push(
log, status,
vscode.languages.registerInlineCompletionItemProvider({ pattern: '**' }, new Provider()),
vscode.commands.registerCommand('localAutocomplete.showMenu', showMenu),
vscode.commands.registerCommand('localAutocomplete.selectMode', selectMode),
vscode.commands.registerCommand('localAutocomplete.selectModel', selectModel),
vscode.commands.registerCommand('localAutocomplete.toggleEnabled', toggleEnabled),
vscode.commands.registerCommand('localAutocomplete.showLog', () => log.show()),
vscode.commands.registerCommand('localAutocomplete.accepted', (requestId: string, text: string) => {
api('POST', '/v1/feedback', { requestId, event: 'accepted', chars: text.length }).catch(() => {})
}),
vscode.workspace.onDidChangeConfiguration(e => {
if (!e.affectsConfiguration('localAutocomplete')) return
const managedKeys = ['daemon.mode', 'daemon.port', 'daemon.idleShutdownMinutes', 'modelManifestUrl', 'model', 'modelPath', 'modelProfile', 'llamaServerPath', 'llamaServerArgs', 'recordingDir']
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.onDidCloseTextDocument(doc => recorder.forget(doc)),
{ dispose: () => recorder.flush() },
vscode.window.onDidChangeWindowState(s => { if (s.focused) void refreshConfig() }),
)
// The toggle is shared with other editors and the acctl CLI, so pick up changes made elsewhere.
const timer = setInterval(() => void refreshConfig(), 15000)
ctx.subscriptions.push({ dispose: () => clearInterval(timer) })
const cfg = settings()
if (cfg.get<boolean>('recordEdits', false) && /cursor/i.test(vscode.env.appName) && !ctx.globalState.get('cursorRecordingNoticeShown')) {
void ctx.globalState.update('cursorRecordingNoticeShown', true)
void vscode.window.showInformationMessage(
'Local Autocomplete is recording edits inside Cursor. Cursor\'s terms (§1.5) prohibit using its Tab suggestions to train a competing model, so turn Cursor Tab off while recording if you want these recordings to be usable for training. Events are tagged with app="Cursor".')
}
if (!cfg.get<boolean>('enableCompletions', true)) {
// Recording-only: no daemon needed when recordings go straight to disk.
status.text = cfg.get<boolean>('recordEdits', false) ? '$(record) AC: recording only' : '$(circle-slash) AC: completions off'
if (cfg.get<string>('recordingDir', '')) return
}
await ensureDaemon()
await refreshConfig()
}
export function deactivate() {}
+65
View File
@@ -0,0 +1,65 @@
/**
* Pure request-building logic (no vscode import), unit-tested in test/.
*/
export interface DocumentSlice {
prefix: string
suffix: string
/** Absolute line number where `prefix` starts (non-zero only when truncated). */
prefixLineOffset: number
}
/**
* Splits the document at the cursor. Very large prefixes are cut at a line
* whose number is a multiple of `lineChunk`, so the cut point (and the daemon's
* KV cache) stays stable while typing.
*/
export function sliceDocument(text: string, offset: number, maxPrefixChars: number, maxSuffixChars: number, lineChunk = 256): DocumentSlice {
let prefix = text.slice(0, offset)
let prefixLineOffset = 0
if (prefix.length > maxPrefixChars) {
const lineStarts = [0]
for (let i = prefix.indexOf('\n'); i !== -1; i = prefix.indexOf('\n', i + 1)) lineStarts.push(i + 1)
let line = 0
while (line + lineChunk < lineStarts.length && prefix.length - lineStarts[line] > maxPrefixChars) line += lineChunk
prefix = prefix.slice(lineStarts[line])
prefixLineOffset = line
}
let suffix = text.slice(offset, offset + maxSuffixChars)
if (offset + maxSuffixChars < text.length) suffix = suffix.slice(0, suffix.lastIndexOf('\n') + 1)
return { prefix, suffix, prefixLineOffset }
}
/**
* When VS Code's suggest widget is open, an inline completion must extend the
* selected suggestion. We pretend the selected text was already typed: the
* prefix ends at the replace range's start plus the selected text.
*/
export function applySelectedSuggestion(text: string, rangeStartOffset: number, cursorOffset: number, selectedText: string): { text: string; cursorOffset: number } {
return {
text: text.slice(0, rangeStartOffset) + selectedText + text.slice(cursorOffset),
cursorOffset: rangeStartOffset + selectedText.length,
}
}
/** Repo name and '/'-separated relative path, as the model saw them in training. */
export function repoAndPath(workspaceFolderName: string | undefined, relativePath: string, fileName: string): { repo: string; path: string } {
if (!workspaceFolderName) return { repo: 'untitled', path: fileName.split(/[\\/]/).pop() || 'untitled' }
return { repo: workspaceFolderName, path: relativePath.replace(/\\/g, '/') }
}
export type ContextMode = 'symbols' | 'simple' | 'raw'
export const MODE_DESCRIPTIONS: Record<ContextMode, string> = {
symbols: 'repo header + available symbols from the TypeScript language service',
simple: 'repo name + file path header only (fast, no language service)',
raw: 'no header: plain suffix-first FIM (for models trained without it)',
}
export function statusText(cfg: { enabled: boolean; mode: ContextMode; effectiveMode?: ContextMode; model: string; llama?: { loading: boolean; running: boolean } } | null, lastMs: number | null): string {
if (!cfg) return '$(debug-disconnect) AC: no daemon'
if (!cfg.enabled) return '$(circle-slash) AC: off'
if (cfg.llama?.loading) return '$(loading~spin) AC: loading'
const mode = cfg.effectiveMode && cfg.effectiveMode !== cfg.mode ? `${cfg.mode}→${cfg.effectiveMode}` : cfg.mode
return `$(sparkle) AC: ${mode}${lastMs !== null ? ` · ${Math.round(lastMs)} ms` : ''}`
}
+296
View File
@@ -0,0 +1,296 @@
/**
* Managed daemon: everything needed to run acd without a separate install,
* kept free of the vscode API so it can be tested with plain Node.
*
* - manifest.json (self-hosted) lists models and the pinned llama.cpp build;
* relative URLs resolve against the manifest URL, so all files can sit in one folder
* - downloads resume (.part + Range), are SHA-256 verified, and are guarded by
* a lock file so several windows don't download the same file at once
* - the bundled daemon runs on the editor's own Node (ELECTRON_RUN_AS_NODE)
*/
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import * as crypto from 'node:crypto'
import * as http from 'node:http'
import * as https from 'node:https'
import { spawn, execFileSync } from 'node:child_process'
export interface ModelEntry {
name: string
file: string
url: string
sha256: string
size: number
order: 'spm' | 'psm'
modes: ('symbols' | 'simple' | 'raw')[]
defaultMode: 'symbols' | 'simple' | 'raw'
description?: string
}
export interface Manifest {
schema: 1
default: string
models: ModelEntry[]
llamaCpp: { build: string; assets: Record<string, { url: string; sha256: string }> }
}
/** Fallback when the manifest has no llama.cpp section (or can't be fetched): pinned upstream release. */
export const DEFAULT_LLAMA_CPP: Manifest['llamaCpp'] = {
build: 'b11368',
assets: {
'linux-x64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-ubuntu-x64.tar.gz', sha256: '1c267259a8db2a28f2b212aef4df67b197aa184377a222369135f6fafdbfe235' },
'linux-arm64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-ubuntu-arm64.tar.gz', sha256: '98f78c5d1f6ec04f0d64bc028bd9345d5665c29fd18c1a7c8c3ed0ba54ceb94e' },
'darwin-arm64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-macos-arm64.tar.gz', sha256: '2e98cd5d0afb2cc00e62db33f1bd9048d4942ceef9bab9affb01832d6ad3735c' },
'darwin-x64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-macos-x64.tar.gz', sha256: 'b46659767fbf39f8a8ff3cb78f7e8722e6b5bc92f3eb448bbe322463d83675b7' },
},
}
export type Progress = (message: string, fraction?: number) => void
export const platformKey = () => `${process.platform}-${process.arch}`
// ---------- HTTP
function get(url: string, headers: Record<string, string> = {}, redirects = 5): Promise<http.IncomingMessage> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http
const req = lib.get(url, { headers: { 'user-agent': 'local-autocomplete', ...headers } }, res => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume()
if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`))
return resolve(get(new URL(res.headers.location, url).toString(), headers, redirects - 1))
}
resolve(res)
})
req.on('error', reject)
req.setTimeout(60_000, () => req.destroy(new Error(`timeout fetching ${url}`)))
})
}
export async function fetchManifest(manifestUrl: string, cachePath: string): Promise<Manifest> {
try {
const res = await get(manifestUrl)
if (res.statusCode !== 200) { res.resume(); throw new Error(`HTTP ${res.statusCode}`) }
const chunks: Buffer[] = []
for await (const c of res) chunks.push(c as Buffer)
const manifest = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Manifest
// Resolve relative URLs against the manifest location.
for (const m of manifest.models) m.url = new URL(m.url, manifestUrl).toString()
for (const a of Object.values(manifest.llamaCpp?.assets ?? {})) a.url = new URL(a.url, manifestUrl).toString()
fs.mkdirSync(path.dirname(cachePath), { recursive: true })
fs.writeFileSync(cachePath, JSON.stringify(manifest, null, 2))
return manifest
} catch (e) {
// Offline startup: use the last manifest we saw.
if (fs.existsSync(cachePath)) return JSON.parse(fs.readFileSync(cachePath, 'utf8')) as Manifest
throw new Error(`cannot fetch model manifest ${manifestUrl}: ${(e as Error).message}`)
}
}
async function sha256File(file: string): Promise<string> {
const hash = crypto.createHash('sha256')
for await (const chunk of fs.createReadStream(file)) hash.update(chunk as Buffer)
return hash.digest('hex')
}
/** Cross-window lock: O_EXCL lock file, considered stale after 10 minutes without progress. */
async function withLock<T>(lockPath: string, fn: () => Promise<T>, progress: Progress): Promise<T> {
for (;;) {
try {
fs.mkdirSync(path.dirname(lockPath), { recursive: true })
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' })
break
} catch {
const age = Date.now() - (fs.statSync(lockPath, { throwIfNoEntry: false })?.mtimeMs ?? 0)
if (age > 10 * 60_000) { fs.rmSync(lockPath, { force: true }); continue }
progress('waiting for another window to finish downloading…')
await new Promise(r => setTimeout(r, 1000))
}
}
try { return await fn() } finally { fs.rmSync(lockPath, { force: true }) }
}
/**
* Downloads url to dest (resuming a previous .part), verifies SHA-256, and
* records the verified hash so later startups don't re-hash 500 MB.
*/
export async function ensureFile(url: string, dest: string, sha256: string, label: string, progress: Progress, signal?: AbortSignal): Promise<string> {
const marker = `${dest}.verified`
if (fs.existsSync(dest) && fs.existsSync(marker) && fs.readFileSync(marker, 'utf8').trim() === sha256) return dest
return withLock(`${dest}.lock`, async () => {
if (fs.existsSync(dest) && (await sha256File(dest)) === sha256) { fs.writeFileSync(marker, sha256); return dest }
const part = `${dest}.part`
let have = fs.statSync(part, { throwIfNoEntry: false })?.size ?? 0
const res = await get(url, have ? { range: `bytes=${have}-` } : {})
if (res.statusCode === 200) have = 0 // server ignored the range: start over
else if (res.statusCode !== 206) { res.resume(); throw new Error(`downloading ${label}: HTTP ${res.statusCode} from ${url}`) }
const total = have + Number(res.headers['content-length'] ?? 0)
const out = fs.createWriteStream(part, { flags: have ? 'a' : 'w' })
let received = have
let lastReport = 0
await new Promise<void>((resolve, reject) => {
signal?.addEventListener('abort', () => { res.destroy(new Error('cancelled')) })
res.on('data', (chunk: Buffer) => {
received += chunk.length
if (Date.now() - lastReport > 250) {
lastReport = Date.now()
progress(`downloading ${label}: ${(received / 1e6).toFixed(0)} / ${total ? (total / 1e6).toFixed(0) : '?'} MB`, total ? received / total : undefined)
}
})
res.on('error', reject)
out.on('error', reject)
out.on('finish', () => resolve())
res.pipe(out)
})
progress(`verifying ${label}…`)
const actual = await sha256File(part)
if (actual !== sha256) {
fs.rmSync(part, { force: true })
throw new Error(`${label}: checksum mismatch (expected ${sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…); the partial download was deleted`)
}
fs.renameSync(part, dest)
fs.writeFileSync(marker, sha256)
return dest
}, progress)
}
/** Downloads and unpacks the pinned llama.cpp build for this platform; returns the llama-server path. */
export async function ensureLlamaServer(storage: string, llama: Manifest['llamaCpp'], progress: Progress, signal?: AbortSignal): Promise<string> {
const asset = llama.assets[platformKey()]
if (!asset) throw new Error(`no llama.cpp build for ${platformKey()}; set localAutocomplete.llamaServerPath`)
const dir = path.join(storage, 'llama.cpp', llama.build)
const find = (d: string): string | null => {
if (!fs.existsSync(d)) return null
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name)
if (e.isFile() && e.name === 'llama-server') return p
if (e.isDirectory()) { const f = find(p); if (f) return f }
}
return null
}
const existing = find(dir)
if (existing) return existing
const archive = await ensureFile(asset.url, path.join(storage, 'llama.cpp', path.basename(new URL(asset.url).pathname)), asset.sha256, `llama.cpp ${llama.build}`, progress, signal)
progress('unpacking llama.cpp…')
fs.mkdirSync(dir, { recursive: true })
execFileSync('tar', ['-xzf', archive, '-C', dir])
const server = find(dir)
if (!server) throw new Error(`llama-server not found in ${archive}`)
fs.chmodSync(server, 0o755)
return server
}
export interface ManagedSettings {
port: number
manifestUrl: string
model: string
modelPath: string
modelProfile: Pick<ModelEntry, 'order' | 'modes' | 'defaultMode'>
llamaServerPath: string
llamaServerArgs: string[]
recordingDir: string
idleShutdownMinutes: number
}
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<Prepared> {
const models: Record<string, { path: string } & Pick<ModelEntry, 'order' | 'modes' | 'defaultMode'>> = {}
let defaultModel: string
let llamaCpp = DEFAULT_LLAMA_CPP
let manifest: Manifest | null = null
if (s.manifestUrl) {
progress('fetching model manifest…')
manifest = await fetchManifest(s.manifestUrl, path.join(storage, 'manifest.json'))
if (manifest.llamaCpp?.assets) llamaCpp = manifest.llamaCpp
}
if (s.modelPath) {
const p = s.modelPath.replace(/^~(?=$|\/)/, os.homedir())
if (!fs.existsSync(p)) throw new Error(`localAutocomplete.modelPath does not exist: ${p}`)
defaultModel = 'custom'
models.custom = { path: p, ...s.modelProfile }
} else {
if (!manifest) throw new Error('set localAutocomplete.modelManifestUrl (your model server) or localAutocomplete.modelPath (a local .gguf)')
const entry = manifest.models.find(m => m.name === (s.model || manifest!.default))
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 }
// 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 }
}
}
const binary = s.llamaServerPath ? s.llamaServerPath.replace(/^~(?=$|\/)/, os.homedir()) : await ensureLlamaServer(storage, llamaCpp, progress, signal)
const gpuArgs = process.platform === 'darwin' ? ['-ngl', '99'] : ['-t', String(Math.max(1, Math.floor(os.cpus().length / 2)))]
const config = {
host: '127.0.0.1',
port: s.port,
tokenizer: { json: path.join(daemonDir, 'assets/qwen-tokenizer.json'), config: path.join(daemonDir, 'assets/qwen-tokenizer_config.json') },
llamaServer: { binary, port: s.port + 1, ctx: 8192, args: s.llamaServerArgs.length ? s.llamaServerArgs : gpuArgs },
models,
defaultModel,
prompt: { prefixTokens: 1536, suffixTokens: 512, prefixChunkLines: 32, maxLines: 8, nPredictSingleLine: 48, nPredictMultiLine: 128 },
symbols: { budget: 2048, waitMs: 150, members: false },
thisRepair: { enabled: true, waitMs: 100 },
telemetry: { enabled: false, dir: path.join(storage, 'telemetry') },
recording: { dir: s.recordingDir ? s.recordingDir.replace(/^~(?=$|\/)/, os.homedir()) : path.join(storage, 'recordings') },
stateFile: path.join(storage, 'acd.state.json'),
idleShutdownMinutes: s.idleShutdownMinutes,
instanceTag: '',
}
// The tag changes whenever the extension or the effective config changes, so stale daemons get replaced.
const { instanceTag: _, ...hashed } = config
config.instanceTag = `managed:${extensionVersion}:${crypto.createHash('sha1').update(JSON.stringify(hashed)).digest('hex').slice(0, 10)}`
const configPath = path.join(storage, 'acd.config.json')
fs.mkdirSync(storage, { recursive: true })
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
return { configPath, instanceTag: config.instanceTag }
}
async function health(port: number): Promise<{ ok: boolean; instanceTag: string | null } | null> {
try {
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) })
return await res.json() as { ok: boolean; instanceTag: string | null }
} catch { return null }
}
/**
* Makes sure a daemon with our instance tag is serving on `port`.
* - ours (same tag): reuse it (shared by every window)
* - managed but outdated (tag starts with "managed:"): ask it to shut down, then start ours
* - not managed (you started it yourself): leave it alone and use it
*/
export async function ensureRunning(storage: string, daemonDir: string, prepared: Prepared, port: number, progress: Progress): Promise<'reused' | 'started' | 'external'> {
const current = await health(port)
if (current?.instanceTag === prepared.instanceTag) return 'reused'
if (current && !current.instanceTag?.startsWith('managed:')) return 'external'
if (current) {
progress('restarting the completion daemon (updated)…')
await fetch(`http://127.0.0.1:${port}/v1/shutdown`, { method: 'POST' }).catch(() => {})
for (let i = 0; i < 40 && (await health(port)); i++) await new Promise(r => setTimeout(r, 250))
}
progress('starting the completion daemon…')
const logPath = path.join(storage, 'acd.log')
const log = fs.openSync(logPath, 'a')
const child = spawn(process.execPath, [path.join(daemonDir, 'acd.mjs'), '--config', prepared.configPath], {
detached: true,
stdio: ['ignore', log, log],
// In desktop VS Code process.execPath is the Electron app; this makes it behave as plain Node.
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
})
child.unref()
for (let i = 0; i < 240; i++) {
await new Promise(r => setTimeout(r, 250))
const h = await health(port)
if (h?.ok && h.instanceTag === prepared.instanceTag) return 'started'
if (h && h.instanceTag !== prepared.instanceTag && !h.instanceTag?.startsWith('managed:')) return 'external' // someone else won the port
if (child.exitCode !== null && !h) throw new Error(`daemon exited (code ${child.exitCode}); see ${logPath}`)
}
throw new Error(`daemon did not become ready within 60 s; see ${logPath}`)
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { sliceDocument, applySelectedSuggestion, repoAndPath, statusText } from '../dist/logic.js'
test('slices at the cursor', () => {
const s = sliceDocument('abc\ndef', 5, 1000, 1000)
assert.deepEqual(s, { prefix: 'abc\nd', suffix: 'ef', prefixLineOffset: 0 })
})
test('large prefixes are cut at a stable line chunk', () => {
const doc = Array.from({ length: 2000 }, (_, i) => `line ${i}`).join('\n')
const a = sliceDocument(doc, doc.length - 5, 5000, 100, 256)
const b = sliceDocument(doc, doc.length - 2, 5000, 100, 256) // typed 3 more chars
assert.equal(a.prefixLineOffset % 256, 0)
assert.equal(a.prefixLineOffset, b.prefixLineOffset)
assert.ok(a.prefix.startsWith(`line ${a.prefixLineOffset}\n`))
assert.ok(a.prefix.length <= 5000 + 20 * 256)
})
test('suffix is cut at a line boundary', () => {
const s = sliceDocument('x' + '\nabcdef'.repeat(10), 1, 1000, 15)
assert.ok(s.suffix.endsWith('\n'))
})
test('selected IntelliSense item is treated as typed', () => {
const r = applySelectedSuggestion('this.conn', 5, 9, 'connections')
assert.deepEqual(r, { text: 'this.connections', cursorOffset: 16 })
})
test('repo/path normalisation', () => {
assert.deepEqual(repoAndPath('wrapped.cash', 'src\\a\\b.ts', '/x/src/a/b.ts'), { repo: 'wrapped.cash', path: 'src/a/b.ts' })
assert.deepEqual(repoAndPath(undefined, '', '/tmp/scratch.ts'), { repo: 'untitled', path: 'scratch.ts' })
})
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/)
})
+76
View File
@@ -0,0 +1,76 @@
// End-to-end test of managed mode without VS Code: local "git server" (with Range support),
// resumable + verified downloads, llama.cpp fetch/unpack, daemon start/reuse/replace.
// node test/managed.e2e.mjs <release-out-dir> <scratch-dir>
import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
const core = createRequire(import.meta.url)('../dist/managed-core.js')
const [releaseDir, scratch] = process.argv.slice(2)
const storage = path.join(scratch, 'storage')
const daemonDir = path.resolve('dist/daemon')
fs.rmSync(storage, { recursive: true, force: true })
let rangeRequests = 0
const server = http.createServer((req, res) => {
const file = path.join(releaseDir, decodeURIComponent(new URL(req.url, 'http://x').pathname))
if (!fs.existsSync(file)) { res.writeHead(404); return res.end() }
const size = fs.statSync(file).size
const m = /bytes=(\d+)-/.exec(req.headers.range ?? '')
if (m) {
rangeRequests++
const start = Number(m[1])
res.writeHead(206, { 'content-length': size - start, 'content-range': `bytes ${start}-${size - 1}/${size}` })
return fs.createReadStream(file, { start }).pipe(res)
}
res.writeHead(200, { 'content-length': size })
fs.createReadStream(file).pipe(res)
}).listen(7120)
const manifestUrl = 'http://127.0.0.1:7120/manifest.json'
const settings = { port: 7111, manifestUrl, model: '', modelPath: '', modelProfile: { order: 'spm', modes: ['simple'], defaultMode: 'simple' }, llamaServerPath: '', llamaServerArgs: [], recordingDir: '', idleShutdownMinutes: 30 }
const log = []
const progress = msg => { if (!log.includes(msg.replace(/\d+ \/ /, 'N / '))) log.push(msg.replace(/\d+ \/ /, 'N / ')) }
const step = (name, fn) => fn().then(r => { console.log(`PASS ${name}`); return r }, e => { console.log(`FAIL ${name}: ${e.message}`); process.exitCode = 1; throw e })
// 1. Simulate an interrupted model download: first 100 MB already on disk.
fs.mkdirSync(path.join(storage, 'models'), { recursive: true })
const modelSrc = path.join(releaseDir, 'stage-c-q8_0.gguf')
const fd = fs.openSync(modelSrc, 'r'); const buf = Buffer.alloc(100e6); fs.readSync(fd, buf, 0, buf.length, 0); fs.closeSync(fd)
fs.writeFileSync(path.join(storage, 'models', 'stage-c-q8_0.gguf.part'), buf)
let t0 = Date.now()
const prepared = await step('first run: manifest, resumed + verified model download, llama.cpp download + unpack', () => core.prepare(storage, daemonDir, '0.4.0-test', settings, progress))
console.log(` ${((Date.now() - t0) / 1000).toFixed(1)} s; range requests: ${rangeRequests}; model verified: ${fs.existsSync(path.join(storage, 'models', 'stage-c-q8_0.gguf.verified'))}`)
assert.ok(rangeRequests >= 1, 'download should have resumed with a Range request')
const cfg = JSON.parse(fs.readFileSync(prepared.configPath, 'utf8'))
assert.ok(fs.existsSync(cfg.llamaServer.binary), 'llama-server unpacked')
await step('daemon starts on the editor runtime', async () => assert.equal(await core.ensureRunning(storage, daemonDir, prepared, settings.port, progress), 'started'))
const fim = await (await fetch('http://127.0.0.1:7111/v1/fim', { method: 'POST', body: JSON.stringify({ repo: 'e2e', path: 'src/a.ts', prefix: "import { z } from 'zod'\nexport const s = z.object({ a: z.string() })\nexport type S = ", suffix: '\n' }) })).json()
await step(`completion through the managed daemon (${JSON.stringify(fim.text)})`, async () => assert.match(fim.text, /z\.infer<typeof s>/))
t0 = Date.now()
const again = await step('second window: no re-download, daemon reused', async () => {
const p = await core.prepare(storage, daemonDir, '0.4.0-test', settings, () => {})
assert.equal(await core.ensureRunning(storage, daemonDir, p, settings.port, () => {}), 'reused')
return p
})
console.log(` ${((Date.now() - t0) / 1000).toFixed(2)} s`)
await step('changed settings replace the managed daemon', async () => {
const p = await core.prepare(storage, daemonDir, '0.4.0-test', { ...settings, idleShutdownMinutes: 31 }, () => {})
assert.notEqual(p.instanceTag, again.instanceTag)
assert.equal(await core.ensureRunning(storage, daemonDir, p, settings.port, () => {}), 'started')
})
await step('checksum mismatch is rejected and the partial file removed', async () => {
const bad = path.join(storage, 'bad.bin')
await assert.rejects(core.ensureFile('http://127.0.0.1:7120/NOTICE', bad, '0'.repeat(64), 'NOTICE', () => {}), /checksum mismatch/)
assert.ok(!fs.existsSync(bad) && !fs.existsSync(`${bad}.part`))
})
console.log('progress messages:', log.filter(l => !l.startsWith('downloading')).join(' | '))
await fetch('http://127.0.0.1:7111/v1/shutdown', { method: 'POST' }).catch(() => {})
server.close()
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"lib": ["es2022", "dom"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"sourceMap": true,
"skipLibCheck": true,
"types": ["node", "vscode"]
},
"include": ["src"]
}