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
+114
View File
@@ -0,0 +1,114 @@
// Updates models/ with GGUF files + manifest.json for the extension's managed mode.
//
// node scripts/make-model-release.mjs --from <dir with *-q8_0.gguf> [stage-c stage-b ...]
//
// --from defaults to ../../models (the training workspace next to this repo). Models are
// hard-linked (no second copy) and tracked with Git LFS. Then publish with:
// git add models && git commit -m "Models: …" && git push
// The extension reads <repo>/media/branch/main/models/manifest.json (Gitea's /media/ route
// serves LFS content); model URLs in the manifest are relative to it.
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { parseArgs } from 'node:util'
const repo = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
const { values, positionals } = parseArgs({ options: { from: { type: 'string', default: path.join(repo, '..', 'models') } }, allowPositionals: true })
const out = path.join(repo, 'models')
// Prompt layout each model was trained with (the extension only offers matching context modes).
const profiles = {
'stage-c': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'simple', description: 'Suffix-first FIM with repo/symbol-context headers (default)' },
'stage-b': { order: 'spm', modes: ['raw'], defaultMode: 'raw', description: 'Suffix-first FIM without context headers (previous stage)' },
}
const wanted = positionals.length ? positionals : ['stage-c', 'stage-b']
const sha256 = file => new Promise((resolve, reject) => {
const h = crypto.createHash('sha256')
fs.createReadStream(file).on('data', d => h.update(d)).on('end', () => resolve(h.digest('hex'))).on('error', reject)
})
const models = []
for (const name of wanted) {
if (!profiles[name]) throw new Error(`no profile for ${name}: add one to scripts/make-model-release.mjs`)
const file = `${name}-q8_0.gguf`
const src = path.join(values.from, file)
if (!fs.existsSync(src)) throw new Error(`${src} not found (use --from <dir>)`)
const dest = path.join(out, file)
if (path.resolve(src) !== path.resolve(dest)) {
fs.rmSync(dest, { force: true })
fs.linkSync(src, dest) // hard link: no second 500 MB copy
}
models.push({ name, file, url: file, sha256: await sha256(src), size: fs.statSync(src).size, ...profiles[name] })
console.log(`${file} ${(fs.statSync(src).size / 1e6).toFixed(0)} MB`)
}
const manifest = {
schema: 1,
default: wanted[0],
models,
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' },
},
},
}
fs.writeFileSync(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
fs.writeFileSync(path.join(out, 'NOTICE'), `These model files are modified versions of Qwen2.5-Coder-0.5B (https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B),
Copyright (c) Alibaba Cloud, licensed under the Apache License, Version 2.0 (see LICENSE).
Modifications: full-parameter fine-tuning for suffix-first fill-in-the-middle completion
(and, for stage-c, a repository/symbol-context header), followed by Q8_0 GGUF conversion.
Fine-tuning data: permissively licensed (MIT) open-source TypeScript/Vue repositories and
the publisher's own code. Secrets were scrubbed from the training data.
`)
const row = m => `| \`${m.name}\` | ${m.description} | ${m.order} | ${m.modes.join(', ')} | ${(m.size / 1e6).toFixed(0)} MB |`
fs.writeFileSync(path.join(out, 'README.md'), `# Models
Q8_0 GGUF fill-in-the-middle models for the [Local Autocomplete](../extensions/vscode) extension, fine-tuned from
[Qwen2.5-Coder-0.5B](https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B) for **TypeScript, Vue and Node.js**.
| model | description | prompt order | context modes | size |
|---|---|---|---|---|
${models.map(row).join('\n')}
The extension reads [\`manifest.json\`](manifest.json), downloads the selected model once, verifies its SHA-256 and
runs it locally with llama.cpp. The files are stored with Git LFS; download links use Gitea's \`/media/\` route:
https://git.harvmaster.com/Harvmaster/autocomplete-model/media/branch/main/models/<file>
## Evaluation (612 held-out samples from private repos the model never saw, suffix-first prompts)
| | exact match | edit similarity | still parses |
|---|---:|---:|---:|
| Qwen2.5-Coder-0.5B (base) | 0.270 | 0.506 | 0.783 |
| Qwen2.5-Coder-1.5B (base) | 0.366 | 0.623 | 0.882 |
| \`stage-b\` | 0.391 | 0.689 | 0.952 |
| \`stage-c\` (with symbol context) | 0.397 | 0.681 | 0.954 |
The base models were evaluated in their native prefix-first order (they can't do suffix-first). Suffix-first prompts
let the editor reuse the KV cache while typing: about 2 tokens re-processed per keystroke, ~90–100 ms end-to-end on a
laptop CPU.
## Using the files directly
Any llama.cpp-based runtime works, but the prompt must match the training layout. The
[daemon](../daemon) and [\`shared/prompt.ts\`](../shared/prompt.ts) are the reference.
Suffix-first (\`spm\`) order:
<|fim_prefix|><|fim_suffix|>{suffix}<|fim_middle|>{prefix} → model generates the middle, then <|endoftext|>
\`stage-c\` additionally expects a repo-level header: \`<|repo_name|>{repo}\\n<|file_sep|>{path}\\n\`, optionally with a
\`__symbols__.d.ts\` pseudo-file of available symbols before it.
## License
Apache-2.0 ([LICENSE](LICENSE), [NOTICE](NOTICE)), as a derivative of Qwen2.5-Coder.
`)
console.log(`updated ${path.relative(process.cwd(), out)}/: manifest.json, NOTICE, README.md`)
+65
View File
@@ -0,0 +1,65 @@
// Publishes the built VS Code extension as a Gitea release (tag vscode-v<version>) with the VSIX attached.
//
// npm run build # builds extensions/vscode/local-autocomplete-<version>.vsix
// node scripts/publish-extension-release.mjs [--dry-run]
//
// Needs a Gitea access token with write:repository scope, from GITEA_TOKEN or the file
// ~/.config/local-autocomplete/gitea-token (chmod 600). Create one at
// https://git.harvmaster.com/user/settings/applications.
// Release notes come from the matching "## vscode <version>" section of CHANGELOG.md.
// Re-running for the same version replaces the attached VSIX.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
const API = 'https://git.harvmaster.com/api/v1/repos/Harvmaster/autocomplete-model'
const repo = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
const dryRun = process.argv.includes('--dry-run')
const pkg = JSON.parse(fs.readFileSync(path.join(repo, 'extensions/vscode/package.json'), 'utf8'))
const version = pkg.version
const tag = `vscode-v${version}`
const vsixName = `${pkg.name}-${version}.vsix`
const vsix = path.join(repo, 'extensions/vscode', vsixName)
if (!fs.existsSync(vsix)) throw new Error(`${vsix} not found: run "npm run build" first`)
const changelog = fs.readFileSync(path.join(repo, 'CHANGELOG.md'), 'utf8')
const section = new RegExp(`^## vscode ${version.replace(/\./g, '\\.')}\\b[^\\n]*\\n([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, 'm').exec(changelog)
if (!section) throw new Error(`CHANGELOG.md has no "## vscode ${version}" section`)
const body = `${section[1].trim()}\n\n**Install:** download \`${vsixName}\` below, then \`code --install-extension ${vsixName}\` (or Extensions → … → Install from VSIX). See the [README](https://git.harvmaster.com/Harvmaster/autocomplete-model#quick-start).`
// The release should point at a commit that's actually on the server.
const git = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim()
const head = git('rev-parse', 'HEAD')
git('fetch', '-q', 'origin', 'main')
if (git('merge-base', '--is-ancestor', head, 'origin/main', '--') === '' && !git('branch', '-r', '--contains', head).includes('origin/main')) {
throw new Error('HEAD is not on origin/main yet: push first')
}
const tokenFile = path.join(os.homedir(), '.config/local-autocomplete/gitea-token')
const token = process.env.GITEA_TOKEN ?? (fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8').trim() : '')
console.log(`release ${tag} @ ${head.slice(0, 8)} with ${vsixName} (${(fs.statSync(vsix).size / 1e6).toFixed(1)} MB)`)
if (dryRun) { console.log('--- notes ---\n' + body); process.exit(0) }
if (!token) throw new Error(`no token: set GITEA_TOKEN or write it to ${tokenFile}`)
async function api(method, route, body, headers = {}) {
const res = await fetch(API + route, { method, body, headers: { authorization: `token ${token}`, ...headers } })
const text = await res.text()
if (!res.ok && res.status !== 404) throw new Error(`${method} ${route}: HTTP ${res.status} ${text.slice(0, 300)}`)
return res.status === 404 ? null : (text ? JSON.parse(text) : {})
}
let release = await api('GET', `/releases/tags/${tag}`)
if (!release) {
release = await api('POST', '/releases', JSON.stringify({ tag_name: tag, target_commitish: head, name: `VS Code extension ${version}`, body, draft: false, prerelease: version.startsWith('0.') }), { 'content-type': 'application/json' })
console.log(`created release ${release.html_url}`)
} else {
await api('PATCH', `/releases/${release.id}`, JSON.stringify({ body }), { 'content-type': 'application/json' })
for (const a of release.assets ?? []) if (a.name === vsixName) await api('DELETE', `/releases/${release.id}/assets/${a.id}`)
console.log(`updating existing release ${release.html_url}`)
}
const form = new FormData()
form.append('attachment', new Blob([fs.readFileSync(vsix)]), vsixName)
const asset = await api('POST', `/releases/${release.id}/assets?name=${encodeURIComponent(vsixName)}`, form)
console.log(`attached ${asset.name}: ${asset.browser_download_url}`)