// Updates models/ with GGUF files + manifest.json for the extension's managed mode.
//
// node scripts/make-model-release.mjs --from
[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 /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') },
// Lock file of the public fine-tuning corpus (training workspace), for the attribution table.
sources: { type: 'string', default: path.join(repo, '..', 'pipeline', 'config', 'public-sources.lock.json') },
}, 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 E: Stage D + next-edit prediction (shared/nextedit.ts)
'stage-e': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'symbols', contextFormat: 2, errors: true, members: true, nextEdit: true, description: 'Suffix-first FIM with symbols + TS errors, plus next-edit prediction (rewrites a region around the cursor)' },
// Stage D: next-line / block-close / multi-line fixes, namespace-import symbols (format 2), TS errors in the header
'stage-d': { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'symbols', contextFormat: 2, errors: true, members: true, description: 'Suffix-first FIM with symbols (incl. namespace imports) + TS errors; better next-line, block closing and multi-line completions' },
'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 )`)
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 sources = fs.existsSync(values.sources) ? JSON.parse(fs.readFileSync(values.sources, 'utf8')) : []
const sourcesTable = sources.length
? ['| repository | commit | license |', '|---|---|---|', ...sources.map(src => `| [${src.url.replace(/^https:\/\/|\.git$/g, '')}](${src.url.replace(/\.git$/, '')}) | \`${src.commit.slice(0, 10)}\` | ${src.license} |`)].join('\n')
: '(source list not available when this file was generated)'
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/
## 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.
## Training data
Fine-tuned on permissively licensed open-source repositories (below, pinned commits) and on the publisher's own
code (not published). Secrets were scrubbed from all training data; held-out evaluation repos were never trained on.
${sourcesTable}
## 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`)