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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.vsix
*.local.json
.DS_Store
+30
View File
@@ -0,0 +1,30 @@
# Changelog
Extension versions are released as `vscode-v<version>` on the [Releases](https://git.harvmaster.com/Harvmaster/autocomplete-model/releases) page. Models are versioned by their name in [`models/manifest.json`](models/manifest.json).
## vscode 0.5.0
- New repository layout (`models/`, `extensions/vscode/`, `daemon/`, `shared/`). The model manifest moved to `models/manifest.json`, and the default `localAutocomplete.modelManifestUrl` follows it.
- Publisher is now `harvmaster` (extension id `harvmaster.local-autocomplete`). **Uninstall the old `local.local-autocomplete` first.** The model is downloaded once more, into the new extension's storage.
- MIT license, repository links, versioned VSIX file names.
## vscode 0.4.0
- **Managed mode (default):** downloads the model (from `models/manifest.json`) and the pinned llama.cpp build on first use, SHA-256 verified and resumable, then runs the bundled daemon on VS Code's own Node runtime. Shared by all windows; exits when idle; replaced automatically after updates.
- Custom models via `localAutocomplete.modelPath` + `modelProfile`; custom llama-server via `llamaServerPath`.
- External mode (`daemon.mode: "external"`) keeps the previous behaviour.
## vscode 0.3.0
- Recording-only mode (`enableCompletions: false` + `recordingDir`), cursor-move and file-switch events, events tagged with the editor app (Cursor notice).
## vscode 0.2.0
- `symbols` context mode (TypeScript language service, same code as training data generation).
- Missing-`this.` repair: `getU` → `this.getUser(id)` when the name only exists on the class.
- Opt-in edit recorder (`recordEdits`).
- Fix: a function's closing `}` was being trimmed when the next line also closed a block.
## vscode 0.1.0
- First version: inline completions via the acd daemon, context-mode toggle in the status bar, cancellation, exclusion globs.
+17 -198
View File
@@ -1,202 +1,21 @@
MIT License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2026 Harvmaster
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
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:
1. Definitions.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Alibaba Cloud
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
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.
+118 -7
View File
@@ -1,10 +1,121 @@
# Local autocomplete models
# Local Autocomplete
Q8_0 GGUF models for the Local Autocomplete VS Code extension (managed mode). The extension reads
`manifest.json`, downloads the selected model, verifies its SHA-256 and runs it locally via llama.cpp.
Fast, private, inline code completion for **TypeScript, Vue and Node.js**, from a small model that runs on your own machine.
- **stage-c**: Qwen2.5-Coder-0.5B fine-tuned for suffix-first FIM on TS/Vue + personal code, with symbol-context headers (default). Prompt order spm, context modes: symbols, simple. 531 MB.
- **stage-b**: Previous stage, no context header (plain suffix-first FIM). Prompt order spm, context modes: raw. 531 MB.
- **Local:** a 0.5B model (fine-tuned Qwen2.5-Coder) runs through llama.cpp on your CPU or Apple GPU. No code leaves your machine.
- **Fast:** ~90–100 ms per keystroke on a laptop CPU. Prompts are laid out suffix-first so the KV cache survives typing (~2 tokens re-processed per keystroke).
- **Context-aware:** an optional `symbols` mode feeds the model signatures from the TypeScript language service, and a missing `this.` is repaired automatically.
- **Zero setup:** the VS Code extension downloads the model and llama.cpp on first use and manages the background daemon itself.
Set `localAutocomplete.modelManifestUrl` to the URL of this folder's `manifest.json`.
License: Apache-2.0 (see LICENSE and NOTICE).
| Model (held-out private code, never trained on) | exact match | edit similarity |
|---|---:|---:|
| Qwen2.5-Coder-0.5B base | 0.270 | 0.506 |
| Qwen2.5-Coder-1.5B base | 0.366 | 0.623 |
| **`stage-c` (this repo)** | **0.397** | **0.681** |
See [models/](models/) for details.
## Quick start
1. Download the latest `local-autocomplete-<version>.vsix` from **[Releases](https://git.harvmaster.com/Harvmaster/autocomplete-model/releases)**.
2. Install it: `code --install-extension local-autocomplete-<version>.vsix`, or in VS Code: Extensions → `⋯` → *Install from VSIX…*
3. Open a TypeScript or Vue file. On first use the extension downloads the model (~530 MB) and llama.cpp (~15 MB), then starts. A progress notification shows this.
4. Start typing: ghost text appears. **Tab** accepts, **Ctrl/Cmd+→** accepts word by word.
Turn off other inline-completion providers (Copilot, …) so they don't compete.
**Remote-SSH:** install the extension in the remote window. Everything (model, daemon, symbol lookups) then runs on the remote machine, next to your code.
**Cursor:** the VSIX installs in Cursor too.
### The status bar
`AC: simple · 42 ms`. Click it for the menu:
- **Context mode:**
- `symbols`: available symbols from the TypeScript language service (best for TS/Vue projects)
- `simple`: repo + file path only
- `raw`: no header, for older models
The menu only offers modes the active model was trained for.
- **Model:** switch between models in the manifest (downloaded on first use).
- **On/off**, **Restart daemon**, **Open daemon log**.
### Settings
| setting | default | |
|---|---|---|
| `localAutocomplete.modelManifestUrl` | this repo's `models/manifest.json` | where models are downloaded from |
| `localAutocomplete.model` | manifest default | which model to use |
| `localAutocomplete.modelPath` | | use a local `.gguf` instead (set `modelProfile` to match its prompt layout) |
| `localAutocomplete.llamaServerPath` | | use your own `llama-server` (e.g. Homebrew's) instead of the downloaded build |
| `localAutocomplete.excludeGlobs` | `.env*`, keys, `secrets/`, … | never sent to the model |
| `localAutocomplete.recordEdits` | `false` | record your edits locally, as future training data for next-edit prediction |
| `localAutocomplete.daemon.mode` | `managed` | `external` = use a daemon you run yourself (`serverUrl`) |
## How it works
```
VS Code extension ──HTTP──▶ acd daemon ──▶ llama-server (llama.cpp, CPU / Metal)
ghost text, toggle, builds the exact prompt layout the model was trained on,
edit recorder symbol context (TypeScript language service, worker thread),
KV-cache-friendly truncation, cancellation, this.-repair
```
- In **managed mode** (the default) the extension bundles the daemon and runs it on VS Code's own Node runtime. One daemon is shared by all windows, and it exits when idle.
- The daemon can also run standalone ([daemon/](daemon/)); that's how other editors (Zed is planned) and the `acctl` CLI use it.
- Design details: [docs/architecture.md](docs/architecture.md).
## Repository layout
| path | |
|---|---|
| [`models/`](models/) | GGUF models (Git LFS), `manifest.json`, model card, license |
| [`extensions/vscode/`](extensions/vscode/) | the VS Code extension |
| [`daemon/`](daemon/) | `acd`, the completion daemon (bundled into the extension; also runs standalone) |
| [`shared/`](shared/) | prompt layout + symbol extraction, shared with the training pipeline so prompts match training exactly |
| [`scripts/`](scripts/) | publish models and extension releases |
| [`docs/`](docs/) | architecture |
## Building from source
Requires **Node ≥ 22.18** (the daemon and shared code run as TypeScript directly) and **Git LFS** for the models.
```sh
git clone https://git.harvmaster.com/Harvmaster/autocomplete-model.git local-autocomplete
# code only, without the ~1 GB of models: GIT_LFS_SKIP_SMUDGE=1 git clone …
cd local-autocomplete
npm run setup # installs shared/, daemon/ and extensions/vscode/ dependencies
npm run build # → extensions/vscode/local-autocomplete-<version>.vsix
npm test
```
Running the daemon standalone (external mode) is covered in [daemon/README.md](daemon/README.md).
## Releasing
**Extension:**
1. Bump `version` in `extensions/vscode/package.json` and add a `## vscode <version>` section to [CHANGELOG.md](CHANGELOG.md).
2. Run `npm run build`, then commit and push.
3. Run `npm run release:extension`. This creates the `vscode-v<version>` release with the VSIX attached, using a Gitea token (see the script header).
**Models:**
1. Run `node scripts/make-model-release.mjs --from <dir> stage-d stage-c`. It updates `models/` (hard links, manifest, model card).
2. Commit and push. Installed extensions pick up the new manifest on their next start.
## Privacy
- **Nothing leaves your machine.** Completions run locally, and the only network access is the one-time model and llama.cpp download.
- **Excluded files are never read:** anything matching `excludeGlobs` (`.env*`, keys, …) is never sent to the model or recorded.
- **Telemetry and edit recording are off by default.** If you enable them, they're written to local files only.
## Roadmap
- Better multi-line completions: continuing onto the next line, closing blocks, whole methods.
- **Next-edit prediction:** e.g. removing a leftover `)` after unwrapping a call, or updating call sites after adding a parameter.
- Token healing for completions that start mid-identifier.
- Zed support via the daemon's OpenAI-compatible endpoint.
## License
Code: [MIT](LICENSE). Models: Apache-2.0 ([models/LICENSE](models/LICENSE), [models/NOTICE](models/NOTICE)), as derivatives of Qwen2.5-Coder.
+6
View File
@@ -0,0 +1,6 @@
node_modules/
acd.state.json
telemetry/
recordings/
*.local.json
test/test*.config.json
+54
View File
@@ -0,0 +1,54 @@
# acd: local completion daemon
Builds fill-in-the-middle prompts in exactly the format the models were trained on (it shares
[`../shared/`](../shared) with the training pipeline) and serves completions through a llama-server it manages.
Editors talk to it over localhost HTTP. Design notes: [../docs/architecture.md](../docs/architecture.md).
**You usually don't run this yourself:** the VS Code extension bundles it and runs it in *managed mode*. Run it
standalone for external mode, for other editors, or when developing.
## Running standalone
Requires Node ≥ 22.18 (runs TypeScript directly) and a `llama-server` binary (`brew install llama.cpp` on macOS,
or a [llama.cpp release](https://github.com/ggml-org/llama.cpp/releases) on Linux).
```sh
npm install
npm start # node src/main.ts --config acd.config.json
node src/acctl.ts status # or: mode simple|symbols|raw · model <name> · on · off
npm run smoke # end-to-end test (needs the models from ../models, via Git LFS)
```
`acd.config.json` uses `llama-server` from your PATH with `-ngl 99` (Apple GPU), and the models in `../models`.
- **Linux / CPU:** copy it to `acd.config.local.json` (git-ignored), set `"binary"` to your llama-server and `"args": ["-t", "<physical cores>"]`, then run `node src/main.ts --config acd.config.local.json`.
- **Serving another machine** (e.g. an M1 Max serving a laptop): set `"host": "0.0.0.0"` and point the editor's `serverUrl` at it, using `daemon.mode: "external"`. There's no auth, so only do this on a trusted network.
- **Start on login:** [`acd.service`](acd.service) is a systemd user unit.
## Context modes
| mode | prompt | models |
|---|---|---|
| `symbols` | repo header + language-service symbols (imports' signatures, repo-local types), from `../shared/symbols.ts`, the same code that built the training data. Requires the editor to send `workspaceRoot`; otherwise the request runs as `simple`. | stage-c |
| `simple` | repo name + file path header | stage-c |
| `raw` | plain suffix-first FIM | stage-b (spm), base-0.5b (psm) |
The mode and model are global to the daemon and persisted in `acd.state.json`. VS Code, Zed and `acctl` all see the same toggle.
**How symbols mode stays fast:**
- Context is built in a worker thread (one TypeScript language service per workspace) and cached per *anchor*: the file, the enclosing top-level declaration and the import block. While you type inside one function the header is identical and the KV cache keeps hitting; the smoke test shows the same ~2 re-processed tokens per keystroke as `simple`.
- When the anchor changes, the daemon waits up to `symbols.waitMs` for fresh context, else uses the file's previous context (stale-while-revalidate).
- Member completions after `.` are off (`symbols.members`): every `.` would change the header and force a cold prefill.
**`this.` repair** (`thisRepair.enabled`, any mode, needs `workspaceRoot`): when the identifier you're typing only resolves as a member of the enclosing class, the daemon completes as if `this.` were there. It returns `replaceBefore` + `filterText` so the editor replaces `getU` with `this.getUser(id)`.
**Edit recordings:** `POST /v1/edits` appends opted-in editor edit events to `recording.dir` (default `recordings/`, git-ignored). These are the future training data for next-edit prediction.
## API
- `POST /v1/fim`: `{ requestId?, repo, path, languageId?, prefix, suffix, prefixLineOffset?, mode?, singleLine?, maxLines? }` returns `{ text, mode, singleLine, timings, … }`. A newer request cancels the in-flight one; closing the connection cancels too.
- `GET|PUT /v1/config`: `{ enabled, model, mode }`. Changing the model restarts llama-server.
- `POST /v1/feedback`: editor events, written to the local log only when `telemetry.enabled`.
- `POST /v1/edits`: `{ events }` from editors that opted in to edit recording.
- The `/v1/fim` request may include `workspaceRoot` (absolute path on the daemon's machine). Responses may include `replaceBefore` / `filterText` (the `this.` repair) and `symbols: { status: hit|fresh|stale|none, items }`.
- `GET /health`
+62
View File
@@ -0,0 +1,62 @@
{
"host": "127.0.0.1",
"port": 7071,
"tokenizer": {
"json": "assets/qwen-tokenizer.json",
"config": "assets/qwen-tokenizer_config.json"
},
"llamaServer": {
"binary": "llama-server",
"port": 7072,
"ctx": 8192,
"args": [
"-ngl",
"99"
]
},
"models": {
"stage-c": {
"path": "../models/stage-c-q8_0.gguf",
"order": "spm",
"modes": [
"symbols",
"simple"
],
"defaultMode": "simple"
},
"stage-b": {
"path": "../models/stage-b-q8_0.gguf",
"order": "spm",
"modes": [
"raw"
],
"defaultMode": "raw"
}
},
"defaultModel": "stage-c",
"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": "telemetry"
},
"recording": {
"dir": "recordings"
},
"stateFile": "acd.state.json"
}
+18
View File
@@ -0,0 +1,18 @@
# systemd user service for acd (only needed in external mode; the VS Code extension's
# managed mode runs the daemon itself). Edit the two paths below, then:
# mkdir -p ~/.config/systemd/user && cp acd.service ~/.config/systemd/user/
# systemctl --user daemon-reload && systemctl --user enable --now acd
# journalctl --user -u acd -f # logs
# To keep it running when you're not logged in: loginctl enable-linger $USER
[Unit]
Description=acd local completion daemon
[Service]
WorkingDirectory=%h/Projects/AutoComplete/local-autocomplete/daemon
# nvm-installed node isn't on PATH for services, so use the absolute path.
ExecStart=%h/.nvm/versions/node/v22.22.0/bin/node src/main.ts --config acd.config.json
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
{
"add_prefix_space": false,
"added_tokens_decoder": {
"151643": {
"content": "<|endoftext|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151644": {
"content": "<|im_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151645": {
"content": "<|im_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151646": {
"content": "<|object_ref_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151647": {
"content": "<|object_ref_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151648": {
"content": "<|box_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151649": {
"content": "<|box_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151650": {
"content": "<|quad_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151651": {
"content": "<|quad_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151652": {
"content": "<|vision_start|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151653": {
"content": "<|vision_end|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151654": {
"content": "<|vision_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151655": {
"content": "<|image_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151656": {
"content": "<|video_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"151657": {
"content": "<tool_call>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151658": {
"content": "</tool_call>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151659": {
"content": "<|fim_prefix|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151660": {
"content": "<|fim_middle|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151661": {
"content": "<|fim_suffix|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151662": {
"content": "<|fim_pad|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151663": {
"content": "<|repo_name|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
},
"151664": {
"content": "<|file_sep|>",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": false
}
},
"additional_special_tokens": [
"<|im_start|>",
"<|im_end|>",
"<|object_ref_start|>",
"<|object_ref_end|>",
"<|box_start|>",
"<|box_end|>",
"<|quad_start|>",
"<|quad_end|>",
"<|vision_start|>",
"<|vision_end|>",
"<|vision_pad|>",
"<|image_pad|>",
"<|video_pad|>"
],
"bos_token": null,
"chat_template": "{%- if tools %}\n {{- '<|im_start|>system\\n' }}\n {%- if messages[0]['role'] == 'system' %}\n {{- messages[0]['content'] }}\n {%- else %}\n {{- 'You are a helpful assistant.' }}\n {%- endif %}\n {{- \"\\n\\n# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n {%- if messages[0]['role'] == 'system' %}\n {{- '<|im_start|>system\\n' + messages[0]['content'] + '<|im_end|>\\n' }}\n {%- else %}\n {{- '<|im_start|>system\\nYou are a helpful assistant.<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- for message in messages %}\n {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) or (message.role == \"assistant\" and not message.tool_calls) %}\n {{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>' + '\\n' }}\n {%- elif message.role == \"assistant\" %}\n {{- '<|im_start|>' + message.role }}\n {%- if message.content %}\n {{- '\\n' + message.content }}\n {%- endif %}\n {%- for tool_call in message.tool_calls %}\n {%- if tool_call.function is defined %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {{- '\\n<tool_call>\\n{\"name\": \"' }}\n {{- tool_call.name }}\n {{- '\", \"arguments\": ' }}\n {{- tool_call.arguments | tojson }}\n {{- '}\\n</tool_call>' }}\n {%- endfor %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if (loop.index0 == 0) or (messages[loop.index0 - 1].role != \"tool\") %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {{- message.content }}\n {{- '\\n</tool_response>' }}\n {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n{%- endif %}\n",
"clean_up_tokenization_spaces": false,
"eos_token": "<|endoftext|>",
"errors": "replace",
"model_max_length": 32768,
"pad_token": "<|endoftext|>",
"split_special_tokens": false,
"tokenizer_class": "Qwen2Tokenizer",
"unk_token": null,
"add_bos_token": false
}
+57
View File
@@ -0,0 +1,57 @@
{
"name": "acd",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "acd",
"dependencies": {
"@huggingface/tokenizers": "^0.2.0"
},
"devDependencies": {
"@types/node": "^26.6.4",
"typescript": "5.9"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/@huggingface/tokenizers": {
"version": "0.2.0",
"resolved": "https://verdaccio.harvmaster.com/@huggingface/tokenizers/-/tokenizers-0.2.0.tgz",
"integrity": "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg==",
"license": "Apache-2.0"
},
"node_modules/@types/node": {
"version": "26.6.4",
"resolved": "https://verdaccio.harvmaster.com/@types/node/-/node-26.6.4.tgz",
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://verdaccio.harvmaster.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://verdaccio.harvmaster.com/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "acd",
"private": true,
"description": "Local completion daemon: builds training-identical FIM prompts and serves them via llama-server",
"type": "module",
"scripts": {
"start": "node src/main.ts --config acd.config.json",
"typecheck": "tsc",
"smoke": "node test/smoke.ts"
},
"engines": {
"node": ">=22.18"
},
"dependencies": {
"@huggingface/tokenizers": "^0.2.0"
},
"devDependencies": {
"@types/node": "^26.6.4",
"typescript": "5.9"
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* acctl: control a running acd from the terminal (the toggle for Zed, scripts, etc.)
*
* node src/acctl.ts status
* node src/acctl.ts mode symbols|simple|raw
* node src/acctl.ts model <name>
* node src/acctl.ts on|off
*
* ACD_URL overrides the daemon address (default http://127.0.0.1:7071).
*/
const url = (process.env.ACD_URL ?? 'http://127.0.0.1:7071').replace(/\/$/, '')
const [cmd, arg] = process.argv.slice(2)
async function call(method: string, route: string, body?: unknown) {
const res = await fetch(url + route, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
const json = await res.json() as Record<string, any>
if (!res.ok) { console.error(`error: ${json.error ?? res.status}`); process.exit(1) }
return json
}
function show(c: Record<string, any>) {
const modes = c.models[c.model].modes.join(', ')
console.log(`${c.enabled ? 'on' : 'off'} model=${c.model} mode=${c.mode}${c.effectiveMode !== c.mode ? ` (running as ${c.effectiveMode})` : ''} [modes: ${modes}]${c.llama.loading ? ' loading…' : ''}${c.llama.error ? ` error: ${c.llama.error}` : ''}`)
}
try {
if (!cmd || cmd === 'status') show(await call('GET', '/v1/config'))
else if (cmd === 'mode' && arg) show(await call('PUT', '/v1/config', { mode: arg }))
else if (cmd === 'model' && arg) show(await call('PUT', '/v1/config', { model: arg }))
else if (cmd === 'on' || cmd === 'off') show(await call('PUT', '/v1/config', { enabled: cmd === 'on' }))
else { console.error('usage: acctl status | mode <symbols|simple|raw> | model <name> | on | off'); process.exit(2) }
} catch (e) {
console.error(`cannot reach acd at ${url}: ${(e as Error).message}`)
process.exit(1)
}
+95
View File
@@ -0,0 +1,95 @@
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
}
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[]
}
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 }
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
/** 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
}
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`)
}
return raw
}
export function loadState(config: DaemonConfig): RuntimeState {
const fallback: RuntimeState = { enabled: true, model: config.defaultModel, mode: config.models[config.defaultModel].defaultMode }
try {
const s = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')) as Partial<RuntimeState>
const model = s.model && config.models[s.model] ? 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 }
} 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')
}
+111
View File
@@ -0,0 +1,111 @@
import { spawn, type ChildProcess } from 'node:child_process'
/**
* Owns one llama-server process. Completions stream internally so that an
* aborted request (the user typed again) closes the connection and llama-server
* stops generating immediately.
*/
export interface Timings {
prompt_n: number
prompt_ms: number
predicted_n: number
predicted_ms: number
cache_n?: number
}
export interface CompletionResult {
text: string
timings: Timings | null
stoppedBy: 'eos' | 'stop' | 'limit' | 'aborted'
}
export class LlamaServer {
private proc: ChildProcess | null = null
private log = ''
model: string | null = null
private binary: string
private port: number
private ctx: number
private args: string[]
constructor(opts: { binary: string; port: number; ctx: number; args: string[] }) {
this.binary = opts.binary
this.port = opts.port
this.ctx = opts.ctx
this.args = opts.args
}
get url() { return `http://127.0.0.1:${this.port}` }
async start(modelPath: string): Promise<void> {
await this.stop()
this.log = ''
const proc = spawn(this.binary, ['-m', modelPath, '--port', String(this.port), '--host', '127.0.0.1', '-c', String(this.ctx), '-np', '1', '--no-webui', ...this.args], {
stdio: ['ignore', 'ignore', 'pipe'],
})
proc.stderr!.on('data', d => { this.log = (this.log + d).slice(-8000) })
this.proc = proc
for (let i = 0; i < 240; i++) {
await new Promise(r => setTimeout(r, 250))
if (proc.exitCode !== null) throw new Error(`llama-server exited (code ${proc.exitCode}):\n${this.log}`)
try {
if ((await fetch(`${this.url}/health`)).ok) { this.model = modelPath; return }
} catch { /* not listening yet */ }
}
await this.stop()
throw new Error(`llama-server did not become healthy within 60 s:\n${this.log}`)
}
async stop(): Promise<void> {
const proc = this.proc
this.proc = null
this.model = null
if (!proc || proc.exitCode !== null) return
const exited = new Promise(r => proc.once('exit', r))
proc.kill('SIGTERM')
await Promise.race([exited, new Promise(r => setTimeout(r, 3000))])
if (proc.exitCode === null) proc.kill('SIGKILL')
}
get running() { return this.proc !== null && this.proc.exitCode === null && this.model !== null }
async complete(prompt: number[], opts: { nPredict: number; stop: string[] }, signal: AbortSignal): Promise<CompletionResult> {
let text = ''
let timings: Timings | null = null
let stoppedBy: CompletionResult['stoppedBy'] = 'limit'
try {
const res = await fetch(`${this.url}/completion`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt, n_predict: opts.nPredict, stop: opts.stop, stream: true,
temperature: 0, top_k: 1, cache_prompt: true, id_slot: 0,
}),
signal,
})
if (!res.ok || !res.body) throw new Error(`llama-server ${res.status}: ${await res.text()}`)
const decoder = new TextDecoder()
let buffer = ''
for await (const chunk of res.body) {
buffer += decoder.decode(chunk as Uint8Array, { stream: true })
let nl: number
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim()
buffer = buffer.slice(nl + 1)
if (!line.startsWith('data:')) continue
const event = JSON.parse(line.slice(5)) as { content?: string; stop?: boolean; timings?: Timings; stop_type?: string }
if (event.content) text += event.content
if (event.stop) {
timings = event.timings ?? null
stoppedBy = event.stop_type === 'eos' ? 'eos' : event.stop_type === 'word' ? 'stop' : 'limit'
}
}
}
} catch (e) {
if (signal.aborted) return { text, timings, stoppedBy: 'aborted' }
throw e
}
return { text, timings, stoppedBy }
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* acd: local completion daemon.
*
* node src/main.ts --config acd.config.json
*
* Endpoints (JSON over HTTP, localhost by default):
* POST /v1/fim { repo, path, prefix, suffix, mode?, singleLine?, ... } -> { text, mode, timings, ... }
* GET /v1/config current toggles + the modes each model supports
* PUT /v1/config { enabled?, model?, mode? } (changing model restarts llama-server)
* POST /v1/feedback { requestId, event: shown|accepted|partial|rejected, acceptedText? }
* POST /v1/edits { events: [...] } opted-in edit recordings from editors -> recording.dir
* POST /v1/shutdown stop llama-server and exit (used by the extension to replace an outdated daemon)
* GET /health
*
* A new /v1/fim request cancels any in-flight one (there is one llama-server
* slot, and the user has moved on anyway); a client disconnect cancels too.
*/
import http from 'node:http'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { loadConfig, loadState, saveState, type ContextMode, type RuntimeState } from './config.ts'
import { LlamaServer } from './llama.ts'
import { PromptBuilder, STOP_STRINGS, type FimRequest } from './prompt-builder.ts'
import { Telemetry } from './telemetry.ts'
import { trimSuffixOverlap } from '../../shared/prompt.ts'
import { symbolLang } from '../../shared/symbol-lang.ts'
import { SymbolService, mightNeedThis } from './symbols.ts'
const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' } } })
const config = loadConfig(args.config!)
const state: RuntimeState = loadState(config)
const builder = new PromptBuilder(config)
const llama = new LlamaServer(config.llamaServer)
const telemetry = new Telemetry(config.telemetry)
const symbols = new SymbolService(config.tokenizer)
let loading: Promise<void> | null = null
let loadError: string | null = null
function loadModel(name: string) {
loadError = null
loading = llama.start(config.models[name].path)
.catch(e => { loadError = (e as Error).message; console.error(loadError) })
.finally(() => { loading = null })
return loading
}
let inflight: AbortController | null = null
function describeConfig() {
return {
...state,
effectiveMode: state.mode,
models: Object.fromEntries(Object.entries(config.models).map(([n, m]) => [n, { order: m.order, modes: m.modes, defaultMode: m.defaultMode }])),
llama: { running: llama.running, loading: loading !== null, error: loadError },
}
}
async function handleFim(body: FimRequest, res: http.ServerResponse, clientGone: AbortSignal) {
const t0 = performance.now()
if (!state.enabled) return json(res, 200, { text: '', disabled: true })
if (loading) return json(res, 503, { error: 'model loading' })
if (!llama.running) return json(res, 503, { error: loadError ?? 'llama-server not running' })
if (typeof body.prefix !== 'string' || typeof body.suffix !== 'string' || !body.path || !body.repo) {
return json(res, 400, { error: 'repo, path, prefix and suffix are required' })
}
const profile = config.models[state.model]
const requested = body.mode && profile.modes.includes(body.mode) ? body.mode : state.mode
let mode: ContextMode = requested
const lang = symbolLang(body.languageId ?? '')
const root = body.workspaceRoot
const filePath = root ? path.join(root, body.path) : null
if (root && lang) symbols.warm(root)
// Missing `this.`: the identifier being typed only exists as a class member.
// Complete as if `this.` were there and tell the editor to replace the typed identifier.
let prefix = body.prefix
let repair: { replaceBefore: number; typed: string; replacement: string } | null = null
if (config.thisRepair.enabled && root && filePath && lang && mightNeedThis(prefix)) {
const member = await symbols.thisMember({ root, filePath, lang, prefix, suffix: body.suffix, waitMs: config.thisRepair.waitMs })
if (member) {
const typed = prefix.slice(-member.identLength)
repair = { replaceBefore: member.identLength, typed, replacement: `this.${typed}` }
prefix = prefix.slice(0, -member.identLength) + repair.replacement
}
}
let symbolContext: string | undefined
let symbolInfo: Record<string, unknown> | undefined
if (mode === 'symbols') {
if (root && filePath && lang) {
// Anchor on the original text so a repair doesn't churn the cached context.
const s = await symbols.context({ root, filePath, lang, prefix: body.prefix, suffix: body.suffix, budget: config.symbols.budget, members: config.symbols.members, waitMs: config.symbols.waitMs })
symbolContext = s.result?.context
symbolInfo = { status: s.status, items: s.result?.items ?? 0 }
} else {
mode = 'simple' // no workspace or not a TS-family file: header without symbols (Stage C saw this via context dropout)
}
}
const prompt = builder.build({ ...body, prefix }, profile, mode, symbolContext)
inflight?.abort()
const controller = new AbortController()
inflight = controller
clientGone.addEventListener('abort', () => controller.abort())
const p = config.prompt
const result = await llama.complete(prompt.ids, {
nPredict: prompt.singleLine ? p.nPredictSingleLine : p.nPredictMultiLine,
stop: prompt.singleLine ? [...STOP_STRINGS, '\n'] : STOP_STRINGS,
}, controller.signal)
if (inflight === controller) inflight = null
if (result.stoppedBy === 'aborted') {
if (!res.writableEnded && !res.destroyed) json(res, 200, { text: '', cancelled: true })
return
}
let text = trimSuffixOverlap(result.text, body.suffix)
if (prompt.singleLine) text = text.split('\n')[0]
const maxLines = body.maxLines ?? p.maxLines
const lines = text.split('\n')
if (lines.length > maxLines) text = lines.slice(0, maxLines).join('\n')
if (!text.trim()) text = ''
// With a repair, the edit replaces the typed identifier: "getU" -> "this.getUser(id)".
// filterText starts with what was typed, which is what VS Code requires to show it.
const filterText = repair ? repair.typed + text : undefined
if (repair) text = repair.replacement + text
const response = {
requestId: body.requestId,
text,
model: state.model,
mode,
requestedMode: requested,
singleLine: prompt.singleLine,
replaceBefore: repair?.replaceBefore ?? 0,
filterText,
symbols: symbolInfo,
stoppedBy: result.stoppedBy,
timings: {
tokenizeMs: round(prompt.tokenizeMs),
promptTokens: prompt.ids.length,
processedTokens: result.timings?.prompt_n ?? null,
promptMs: round(result.timings?.prompt_ms),
genTokens: result.timings?.predicted_n ?? null,
genMs: round(result.timings?.predicted_ms),
totalMs: round(performance.now() - t0),
},
}
telemetry.request(body, response)
json(res, 200, response)
}
const round = (x: number | undefined) => (x === undefined ? null : Math.round(x * 10) / 10)
function json(res: http.ServerResponse, status: number, body: unknown) {
if (res.writableEnded || res.destroyed) return
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
async function readBody(req: http.IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
for await (const c of req) chunks.push(c as Buffer)
const text = Buffer.concat(chunks).toString('utf8')
return text ? JSON.parse(text) : {}
}
let lastActivity = Date.now()
if (config.idleShutdownMinutes) {
setInterval(() => {
if (Date.now() - lastActivity > config.idleShutdownMinutes! * 60_000) {
console.log(`idle for ${config.idleShutdownMinutes} min, shutting down`)
void llama.stop().then(() => process.exit(0))
}
}, 30_000).unref()
}
const server = http.createServer(async (req, res) => {
lastActivity = Date.now()
// Cancel the completion if the editor gives up on this request.
const gone = new AbortController()
res.on('close', () => { if (!res.writableEnded) gone.abort() })
try {
const route = `${req.method} ${req.url?.split('?')[0]}`
if (route === 'GET /health') return json(res, 200, { ok: llama.running, loading: loading !== null, model: state.model, error: loadError, instanceTag: config.instanceTag ?? null, pid: process.pid })
if (route === 'POST /v1/shutdown') {
json(res, 200, { ok: true })
void llama.stop().then(() => process.exit(0))
return
}
if (route === 'GET /v1/config') return json(res, 200, describeConfig())
if (route === 'PUT /v1/config') {
const body = await readBody(req) as Partial<RuntimeState>
if (body.model !== undefined && !config.models[body.model]) return json(res, 400, { error: `unknown model ${body.model}` })
const model = body.model ?? state.model
const mode = body.mode ?? (body.model ? config.models[model].defaultMode : state.mode)
if (!config.models[model].modes.includes(mode)) return json(res, 400, { error: `model ${model} supports modes ${config.models[model].modes.join(', ')}` })
const modelChanged = model !== state.model
Object.assign(state, { model, mode, enabled: body.enabled ?? state.enabled })
saveState(config, state)
if (modelChanged) void loadModel(model)
return json(res, 200, describeConfig())
}
if (route === 'POST /v1/fim') return await handleFim(await readBody(req) as FimRequest, res, gone.signal)
if (route === 'POST /v1/edits') {
const body = await readBody(req) as { events?: unknown[] }
return json(res, 200, { recorded: Telemetry.recordEdits(config.recording.dir, body.events ?? []) })
}
if (route === 'POST /v1/feedback') {
telemetry.feedback(await readBody(req))
return json(res, 200, { ok: true })
}
json(res, 404, { error: 'not found' })
} catch (e) {
console.error(e)
json(res, 500, { error: (e as Error).message })
}
})
process.on('SIGINT', () => void llama.stop().then(() => process.exit(0)))
process.on('SIGTERM', () => void llama.stop().then(() => process.exit(0)))
server.listen(config.port, config.host, () => {
console.log(`acd listening on http://${config.host}:${config.port} (model ${state.model}, mode ${state.mode})`)
})
await loadModel(state.model)
if (llama.running) console.log(`llama-server ready: ${config.models[state.model].path}`)
+77
View File
@@ -0,0 +1,77 @@
import fs from 'node:fs'
import { Tokenizer } from '@huggingface/tokenizers'
import { chunkedPrefixTokens, fimPrompt, headTokens, repoHeader, wantsSingleLine } from '../../shared/prompt.ts'
import type { ContextMode, DaemonConfig, ModelProfile } from './config.ts'
export interface FimRequest {
requestId?: string
/** Workspace folder name; Stage C was trained with the repo directory name here. */
repo: string
/** Path relative to the workspace folder, '/'-separated. */
path: string
/** Absolute workspace folder on the daemon's machine; enables symbols mode and the this.-repair. */
workspaceRoot?: string
languageId?: string
prefix: string
suffix: string
/** Absolute line number of prefix's first line, if the client sent a truncated document. */
prefixLineOffset?: number
mode?: ContextMode
/** Override automatic single-line detection. */
singleLine?: boolean
maxLines?: number
}
export interface BuiltPrompt {
ids: number[]
mode: ContextMode
singleLine: boolean
prefixStartLine: number
tokenizeMs: number
}
export const STOP_STRINGS = ['<|endoftext|>', '<|fim_prefix|>', '<|fim_suffix|>', '<|fim_middle|>', '<|fim_pad|>', '<|file_sep|>', '<|repo_name|>', '<|im_start|>', '<|im_end|>']
export class PromptBuilder {
private encodeFn: (s: string) => number[]
private fim: { prefix: number; suffix: number; middle: number }
private header: { repoName: number; fileSep: number }
private config: DaemonConfig
constructor(config: DaemonConfig) {
this.config = config
const tokenizer = new Tokenizer(
JSON.parse(fs.readFileSync(config.tokenizer.json, 'utf8')),
JSON.parse(fs.readFileSync(config.tokenizer.config, 'utf8')),
)
this.encodeFn = s => tokenizer.encode(s, { add_special_tokens: false }).ids
const id = (t: string) => {
const v = tokenizer.token_to_id(t)
if (v === undefined) throw new Error(`tokenizer is missing ${t}`)
return v
}
this.fim = { prefix: id('<|fim_prefix|>'), suffix: id('<|fim_suffix|>'), middle: id('<|fim_middle|>') }
this.header = { repoName: id('<|repo_name|>'), fileSep: id('<|file_sep|>') }
}
encode(s: string) { return this.encodeFn(s) }
/**
* Builds the prompt in the model's trained layout. `symbols` context is passed
* in by the caller (empty until the symbol provider exists, which degrades to `simple`).
*/
build(req: FimRequest, profile: ModelProfile, mode: ContextMode, symbols?: string): BuiltPrompt {
const t0 = performance.now()
const p = this.config.prompt
const { tokens: prefix, startLine } = chunkedPrefixTokens(req.prefix, p.prefixTokens, this.encodeFn, p.prefixChunkLines, req.prefixLineOffset ?? 0)
const suffix = headTokens(req.suffix, p.suffixTokens, this.encodeFn)
const header = mode === 'raw' ? [] : repoHeader(req.repo, req.path, mode === 'symbols' ? symbols : undefined, this.encodeFn, this.header)
return {
ids: [...header, ...fimPrompt(profile.order, prefix, suffix, this.fim)],
mode,
singleLine: req.singleLine ?? wantsSingleLine(req.suffix),
prefixStartLine: startLine,
tokenizeMs: performance.now() - t0,
}
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Worker thread that owns the TypeScript language services (one per workspace
* root). Building a program for a large repo takes seconds, which must never
* block the request loop, so all symbol work happens here.
*/
import fs from 'node:fs'
import { parentPort, workerData } from 'node:worker_threads'
import { Tokenizer } from '@huggingface/tokenizers'
import { WorkspaceSymbols, type SymbolLang } from '../../shared/symbols.ts'
export type WorkerRequest =
| { id: number; kind: 'warm'; root: string }
| { id: number; kind: 'context'; root: string; filePath: string; lang: SymbolLang; visible: string; cursor: number; budget: number; members: boolean }
| { id: number; kind: 'thisMember'; root: string; filePath: string; lang: SymbolLang; visible: string; cursor: number }
export interface WorkerResponse { id: number; result?: unknown; error?: string; ms: number }
const { tokenizerJson, tokenizerConfig } = workerData as { tokenizerJson: string; tokenizerConfig: string }
const tokenizer = new Tokenizer(JSON.parse(fs.readFileSync(tokenizerJson, 'utf8')), JSON.parse(fs.readFileSync(tokenizerConfig, 'utf8')))
const countTokens = (s: string) => tokenizer.encode(s, { add_special_tokens: false }).ids.length
const workspaces = new Map<string, WorkspaceSymbols>()
const workspace = (root: string) => {
let ws = workspaces.get(root)
if (!ws) { ws = new WorkspaceSymbols(root); workspaces.set(root, ws) }
return ws
}
parentPort!.on('message', (req: WorkerRequest) => {
const t0 = performance.now()
try {
let result: unknown = null
if (req.kind === 'warm') workspace(req.root)
if (req.kind === 'context') {
result = workspace(req.root).context({ filePath: req.filePath, lang: req.lang, visible: req.visible, cursor: req.cursor, budget: req.budget, countTokens, members: req.members })
}
if (req.kind === 'thisMember') result = workspace(req.root).thisMember(req.filePath, req.lang, req.visible, req.cursor)
parentPort!.postMessage({ id: req.id, result, ms: performance.now() - t0 } satisfies WorkerResponse)
} catch (e) {
parentPort!.postMessage({ id: req.id, error: (e as Error).message, ms: performance.now() - t0 } satisfies WorkerResponse)
}
})
+110
View File
@@ -0,0 +1,110 @@
import { Worker } from 'node:worker_threads'
import crypto from 'node:crypto'
import type { SymbolsResult } from '../../shared/symbols.ts'
import type { SymbolLang } from '../../shared/symbol-lang.ts'
import type { WorkerRequest, WorkerResponse } from './symbols-worker.ts'
type Payload<K extends WorkerRequest['kind']> = Omit<Extract<WorkerRequest, { kind: K }>, 'id' | 'kind'>
/**
* Symbol context for `symbols` mode, with caching that keeps prompts stable.
*
* Context is cached per anchor: file + enclosing top-level declaration + the
* file's import block. While you type inside one function the header is
* identical, so llama-server's KV cache keeps hitting. When the anchor changes
* we wait briefly for fresh context; if the language service is slower (e.g.
* still building the program), the previous context for the file is used
* (stale-while-revalidate) and the fresh one lands in the cache for the next keystroke.
*/
export class SymbolService {
private worker: Worker
private nextId = 1
private pending = new Map<number, (r: WorkerResponse) => void>()
private cache = new Map<string, SymbolsResult | null>()
private lastForFile = new Map<string, SymbolsResult | null>()
private inflight = new Set<string>()
private warmed = new Set<string>()
constructor(tokenizer: { json: string; config: string }) {
// Source runs load the .ts worker (Node type stripping); the bundled extension build ships symbols-worker.mjs.
const workerFile = import.meta.url.endsWith('.ts') ? './symbols-worker.ts' : './symbols-worker.mjs'
this.worker = new Worker(new URL(workerFile, import.meta.url), { workerData: { tokenizerJson: tokenizer.json, tokenizerConfig: tokenizer.config } })
this.worker.on('message', (r: WorkerResponse) => { this.pending.get(r.id)?.(r); this.pending.delete(r.id) })
this.worker.on('error', e => console.error('symbols worker error:', e))
this.worker.unref()
}
private call<K extends WorkerRequest['kind']>(kind: K, payload: Payload<K>): Promise<WorkerResponse> {
const id = this.nextId++
return new Promise(resolve => {
this.pending.set(id, resolve)
this.worker.postMessage({ id, kind, ...payload })
})
}
/** Start building the language service for a workspace before it's needed. */
warm(root: string) {
if (this.warmed.has(root)) return
this.warmed.add(root)
void this.call('warm', { root })
}
async context(opts: { root: string; filePath: string; lang: SymbolLang; prefix: string; suffix: string; budget: number; members: boolean; waitMs: number }):
Promise<{ result: SymbolsResult | null; status: 'hit' | 'fresh' | 'stale' | 'none' }> {
const key = anchorKey(opts.filePath, opts.prefix, opts.members)
if (this.cache.has(key)) {
const result = this.cache.get(key)!
this.lastForFile.set(opts.filePath, result)
return { result, status: 'hit' }
}
const request = this.inflight.has(key) ? null : this.call('context', {
root: opts.root, filePath: opts.filePath, lang: opts.lang, visible: opts.prefix + opts.suffix, cursor: opts.prefix.length, budget: opts.budget, members: opts.members,
})
if (request) {
this.inflight.add(key)
void request.then(r => {
this.inflight.delete(key)
if (r.error) console.error('symbols:', r.error)
this.remember(key, (r.result as SymbolsResult | null) ?? null)
})
const fresh = await Promise.race([request, new Promise<null>(r => setTimeout(() => r(null), opts.waitMs))])
if (fresh && !fresh.error) {
const result = (fresh.result as SymbolsResult | null) ?? null
this.lastForFile.set(opts.filePath, result)
return { result, status: 'fresh' }
}
}
const stale = this.lastForFile.get(opts.filePath)
return stale !== undefined ? { result: stale, status: 'stale' } : { result: null, status: 'none' }
}
private remember(key: string, result: SymbolsResult | null) {
this.cache.set(key, result)
if (this.cache.size > 500) this.cache.delete(this.cache.keys().next().value!)
}
async thisMember(opts: { root: string; filePath: string; lang: SymbolLang; prefix: string; suffix: string; waitMs: number }) {
const r = await Promise.race([
this.call('thisMember', { root: opts.root, filePath: opts.filePath, lang: opts.lang, visible: opts.prefix + opts.suffix, cursor: opts.prefix.length }),
new Promise<null>(r => setTimeout(() => r(null), opts.waitMs)),
])
return (r?.result as { name: string; identLength: number } | null) ?? null
}
}
const TOP_LEVEL = /^(export\s|async\s|function[\s*]|class\s|const\s|let\s|var\s|interface\s|type\s|enum\s|abstract\s|declare\s|<script|<template|<style|@)/
/** file + enclosing top-level declaration (line index) + hash of the import lines (+ receiver when member context is on). */
export function anchorKey(filePath: string, prefix: string, members: boolean): string {
const lines = prefix.split('\n')
let anchor = 0
for (let i = lines.length - 1; i >= 0; i--) if (TOP_LEVEL.test(lines[i])) { anchor = i; break }
const imports = crypto.createHash('sha1').update(lines.filter(l => /^\s*import\s/.test(l)).join('\n')).digest('hex').slice(0, 12)
const receiver = members ? (/([\w$.()[\]'"]+)\.[\w$]*$/.exec(prefix.slice(-200))?.[1] ?? '') : ''
return `${filePath}|${anchor}|${imports}|${receiver}`
}
/** Cheap pre-check before asking the language service whether `this.` is missing. */
export function mightNeedThis(prefix: string): boolean {
return /(^|[^\w$.])[A-Za-z_$][\w$]+$/.test(prefix.slice(-100)) && !/\b(function|const|let|var|class|interface|type|enum|import|export|as|new)\s+[A-Za-z_$][\w$]*$/.test(prefix.slice(-100))
}
+54
View File
@@ -0,0 +1,54 @@
import fs from 'node:fs'
import path from 'node:path'
import type { FimRequest } from './prompt-builder.ts'
/**
* Local-only, opt-in telemetry (config.telemetry.enabled). One JSONL file per day.
*
* Besides explicit feedback events from the editor, acceptance is inferred:
* if the next request for the same file has a prefix that ends with the
* previous prefix + the suggested text, the suggestion was typed or accepted.
* That is the only acceptance signal Zed can give us, and a cross-check for VS Code.
*/
export class Telemetry {
private enabled: boolean
private dir: string
private last = new Map<string, { requestId?: string; prefixTail: string; text: string }>()
constructor(opts: { enabled: boolean; dir: string }) {
this.enabled = opts.enabled
this.dir = opts.dir
}
private write(record: Record<string, unknown>) {
if (!this.enabled) return
fs.mkdirSync(this.dir, { recursive: true })
const file = path.join(this.dir, `${new Date().toISOString().slice(0, 10)}.jsonl`)
fs.appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...record }) + '\n')
}
request(req: FimRequest, res: { requestId?: string; text: string; mode: string; model: string; timings: unknown }) {
if (!this.enabled) return
const key = `${req.repo}/${req.path}`
const prev = this.last.get(key)
if (prev?.text) {
const accepted = req.prefix.endsWith(prev.prefixTail + prev.text)
if (accepted) this.write({ event: 'inferred-accept', requestId: prev.requestId, path: key, chars: prev.text.length })
}
this.last.set(key, { requestId: req.requestId, prefixTail: req.prefix.slice(-200), text: res.text })
this.write({ event: 'completion', requestId: req.requestId, path: key, languageId: req.languageId, model: res.model, mode: res.mode, chars: res.text.length, lines: res.text ? res.text.split('\n').length : 0, timings: res.timings })
}
/** Editor edit events (opt-in in the editor). Always local, one JSONL file per day. */
static recordEdits(dir: string, events: unknown[]) {
if (!Array.isArray(events) || !events.length) return 0
fs.mkdirSync(dir, { recursive: true })
const file = path.join(dir, `edits-${new Date().toISOString().slice(0, 10)}.jsonl`)
fs.appendFileSync(file, events.map(e => JSON.stringify(e)).join('\n') + '\n')
return events.length
}
feedback(body: unknown) {
this.write({ event: 'feedback', ...(body as Record<string, unknown>) })
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* End-to-end smoke test: starts acd, then exercises it the way an editor would.
*
* node test/smoke.ts [--config acd.config.json] [--typing-samples 15]
*
* Symbols/this.-repair checks and the typing replay use the private training workspace's ../data
* (held-out samples, installed repos) when present, and are skipped otherwise.
*/
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { spawn } from 'node:child_process'
import { parseArgs } from 'node:util'
const { values: args } = parseArgs({ options: { config: { type: 'string', default: 'acd.config.json' }, 'typing-samples': { type: 'string', default: '15' }, entry: { type: 'string', default: 'src/main.ts' } } })
const config = JSON.parse(fs.readFileSync(args.config!, 'utf8')) as { port: number; stateFile: string }
const base = `http://127.0.0.1:${config.port}`
let failures = 0
const check = (name: string, ok: boolean, detail = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failures++
}
// --entry ../extensions/vscode/dist/daemon/acd.mjs tests the bundled build the extension ships.
const daemon = spawn(process.execPath, [args.entry!, '--config', args.config!], { stdio: ['ignore', 'pipe', 'pipe'] })
let daemonLog = ''
daemon.stdout.on('data', d => { daemonLog += d })
daemon.stderr.on('data', d => { daemonLog += d })
const cleanup = () => { daemon.kill('SIGTERM') }
process.on('exit', cleanup)
async function api(method: string, route: string, body?: unknown, signal?: AbortSignal) {
const res = await fetch(base + route, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal })
return { status: res.status, body: await res.json() as Record<string, any> }
}
for (let i = 0; ; i++) {
await new Promise(r => setTimeout(r, 500))
try { if ((await api('GET', '/health')).body.ok) break } catch { /* starting */ }
if (i > 120) { console.error(daemonLog); throw new Error('daemon did not become healthy') }
}
// ---------- config / toggle
await api('PUT', '/v1/config', { model: 'stage-c', mode: 'simple', enabled: true })
const cfg = (await api('GET', '/v1/config')).body
check('config lists model profiles', !!cfg.models?.['stage-c']?.modes?.includes('symbols'), JSON.stringify(cfg.models?.['stage-c']))
check('invalid mode for model is rejected', (await api('PUT', '/v1/config', { mode: 'raw' })).status === 400)
const sym = (await api('PUT', '/v1/config', { mode: 'symbols' })).body
check('symbols mode accepted', sym.mode === 'symbols' && sym.effectiveMode === 'symbols')
await api('PUT', '/v1/config', { mode: 'simple' })
check('state persisted to disk', JSON.parse(fs.readFileSync(path.resolve(path.dirname(args.config!), config.stateFile), 'utf8')).mode === 'simple')
// ---------- correctness on hand-written cases
const fim = (prefix: string, suffix: string, extra: Record<string, unknown> = {}, signal?: AbortSignal) =>
api('POST', '/v1/fim', { repo: 'smoke', path: 'src/example.ts', languageId: 'typescript', prefix, suffix, ...extra }, signal)
const zod = await fim("import { z } from 'zod'\n\nexport const configSchema = z.object({\n port: z.coerce.number().default(3000),\n})\n\nexport type Config = ", '\n')
check('zod type completion', zod.body.text.includes('z.infer<typeof configSchema>'), JSON.stringify(zod.body.text))
const obj = await fim('export interface Invoice {\n id: string\n memo: string\n paid: boolean\n}\n\nexport function createInvoice(memo = \'\'): Invoice {\n return {\n id: crypto.randomUUID(),\n ', '\n }\n}\n')
check('multi-line object completion stops before the suffix', obj.body.text.includes('paid') && !obj.body.text.includes('}\n}'), JSON.stringify(obj.body.text))
const single = await fim('const total = items.reduce((sum, item) => sum + item.', ', 0)\nconsole.log(total)\n')
check('single-line mode when code follows the cursor', single.body.singleLine === true && !single.body.text.includes('\n'), JSON.stringify(single.body.text))
// ---------- cancellation: a newer request cancels the in-flight one
const slow = fim('export async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {\n ', '\n}\n', { maxLines: 50 })
await new Promise(r => setTimeout(r, 30))
const fast = fim('const answer = ', '\n')
const [slowRes, fastRes] = await Promise.all([slow, fast])
check('newer request cancels in-flight one', slowRes.body.cancelled === true && fastRes.status === 200, `first=${JSON.stringify(slowRes.body).slice(0, 80)}`)
const aborter = new AbortController()
const aborted = fim('function fib(n: number): number {\n ', '\n}\n', {}, aborter.signal).catch(e => ({ status: -1, body: { error: (e as Error).name } }))
setTimeout(() => aborter.abort(), 20)
check('client abort is handled', (await aborted).body.error === 'AbortError')
check('daemon still healthy after aborts', (await api('GET', '/health')).body.ok === true)
// ---------- symbols mode + this.-repair
// Needs a TS project with node_modules installed: SMOKE_TS_ROOT (project dir), SMOKE_TS_FILE (a .ts file in it,
// relative) and SMOKE_TS_ANCHOR (text in that file to put the cursor before). Skipped when not set.
const envRoot = process.env.SMOKE_TS_ROOT ? path.resolve(process.env.SMOKE_TS_ROOT) : ''
if (envRoot && fs.existsSync(envRoot) && process.env.SMOKE_TS_FILE && process.env.SMOKE_TS_ANCHOR) {
const file = process.env.SMOKE_TS_FILE
const content = fs.readFileSync(path.join(envRoot, file), 'utf8')
const at = content.indexOf(process.env.SMOKE_TS_ANCHOR)
const req = { repo: path.basename(envRoot), path: file, workspaceRoot: envRoot, languageId: 'typescript', prefix: content.slice(0, at), suffix: content.slice(at), mode: 'symbols' }
let first = (await api('POST', '/v1/fim', req)).body
for (let i = 0; i < 20 && first.symbols?.status !== 'hit' && !(first.symbols?.items > 0); i++) {
await new Promise(r => setTimeout(r, 250)) // language service still building the program
first = (await api('POST', '/v1/fim', req)).body
}
check('symbols mode attaches language-service context', first.mode === 'symbols' && first.symbols?.items > 0, JSON.stringify(first.symbols))
const again = (await api('POST', '/v1/fim', { ...req, prefix: req.prefix + 'x' })).body
check('same anchor reuses cached context (KV-stable header)', again.symbols?.status === 'hit', JSON.stringify(again.symbols))
const outside = (await api('POST', '/v1/fim', { ...req, workspaceRoot: undefined })).body
check('symbols without a workspace falls back to simple', outside.mode === 'simple')
const cls = 'export class UserService {\n private users = new Map<string, string>()\n\n getUser(id: string) {\n return this.users.get(id)\n }\n\n printUser(id: string) {\n const user = getU'
const repaired = (await api('POST', '/v1/fim', { repo: req.repo, path: 'src/probe-user-service.ts', workspaceRoot: envRoot, languageId: 'typescript', prefix: cls, suffix: '\n }\n}\n' })).body
check('missing this. is repaired as a replacement', repaired.replaceBefore === 4 && repaired.text.startsWith('this.getU') && repaired.filterText?.startsWith('getU'), JSON.stringify({ text: repaired.text, replaceBefore: repaired.replaceBefore, filterText: repaired.filterText }))
const local = (await api('POST', '/v1/fim', { repo: req.repo, path: 'src/probe-user-service.ts', workspaceRoot: envRoot, languageId: 'typescript', prefix: cls.replace('const user = getU', 'const getUsername = () => id\n const user = getU'), suffix: '\n }\n}\n' })).body
check('no repair when a local matches', local.replaceBefore === 0, JSON.stringify(local.text))
} else {
console.log('SKIP symbols / this.-repair checks (set SMOKE_TS_ROOT, SMOKE_TS_FILE, SMOKE_TS_ANCHOR)')
}
// ---------- edit recording endpoint
const recorded = await api('POST', '/v1/edits', { events: [{ t: Date.now(), repo: 'smoke', path: 'src/a.ts', version: 2, changes: [{ offset: 0, length: 0, text: 'x' }], snapshot: 'x' }] })
check('edit recordings are accepted', recorded.body.recorded === 1)
// ---------- warm typing latency on held-out code
// Optional typing replay over held-out FIM samples from the (private) training workspace.
const dataDir = path.resolve(process.env.SMOKE_DATA_DIR ?? '../../data')
const testFim = path.join(dataDir, 'fim/test-repo.jsonl')
if (fs.existsSync(testFim)) {
const files = new Map(fs.readFileSync(path.join(dataDir, 'corpus/files.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)).map(r => [r.id, r]))
const rank = (id: string) => crypto.createHash('sha256').update(`smoke:${id}`).digest('hex')
const samples = fs.readFileSync(testFim, 'utf8').trim().split('\n').map(l => JSON.parse(l))
.filter(s => s.suffix_mode === 'full' && ['line-rest', 'statement', 'call-args'].includes(s.type) && s.middle.length >= 12 && !s.middle.includes('\n'))
.sort((a, b) => rank(a.id).localeCompare(rank(b.id)))
.slice(0, Number(args['typing-samples']))
for (const mode of ['simple', 'symbols']) {
await api('PUT', '/v1/config', { mode })
const steps: { total: number; processed: number }[] = []
let firstAtLocation: number[] = []
for (const s of samples) {
const content = files.get(s.file_id).content as string
for (let typed = 0; typed <= s.middle.length; typed += 3) {
const workspaceRoot = path.join(dataDir, 'ts-env', s.repo)
const r = await api('POST', '/v1/fim', { repo: s.repo, path: s.path, workspaceRoot: fs.existsSync(workspaceRoot) ? workspaceRoot : undefined, languageId: s.lang, prefix: content.slice(0, s.start) + s.middle.slice(0, typed), suffix: content.slice(s.end) })
if (typed === 0) { firstAtLocation.push(r.body.timings.totalMs); continue }
steps.push({ total: r.body.timings.totalMs, processed: r.body.timings.processedTokens })
}
}
const pct = (xs: number[], p: number) => [...xs].sort((a, b) => a - b)[Math.min(xs.length - 1, Math.floor(p * xs.length))]
console.log(`\nwarm typing, mode=${mode} (${samples.length} completions, ${steps.length} keystroke requests, end-to-end through acd):`)
console.log(` first request at a new location: p50 ${pct(firstAtLocation, 0.5)} ms, p90 ${pct(firstAtLocation, 0.9)} ms`)
console.log(` per keystroke: total p50 ${pct(steps.map(s => s.total), 0.5)} ms, p90 ${pct(steps.map(s => s.total), 0.9)} ms; tokens re-processed p50 ${pct(steps.map(s => s.processed), 0.5)}, p90 ${pct(steps.map(s => s.processed), 0.9)}`)
check(`${mode}: warm keystrokes reuse the KV cache (p50 re-processed tokens < 50)`, pct(steps.map(s => s.processed), 0.5) < 50)
}
await api('PUT', '/v1/config', { mode: 'simple' })
}
console.log(failures ? `\n${failures} FAILED` : '\nall passed')
cleanup()
process.exit(failures ? 1 : 0)
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test", "../shared"]
}
+181
View File
@@ -0,0 +1,181 @@
# Editor integration design (VS Code + Zed)
Status (2026-10-03): the daemon, VS Code extension (incl. managed mode), symbols mode, `this.` repair and the edit recorder are implemented. See the [README](../README.md). Not yet built: token healing, the Zed shim, next-edit prediction.
## Goals
1. **Low latency while typing.** Ghost text within ~100 ms of a keystroke on the slowest machine (Ryzen laptop CPU), once warm. A slow cold start, when moving to a new location or loading new context, is acceptable.
2. **The same prompts as training.** The model only performs as measured if inference prompts are byte-identical in structure to training: suffix-first (SPM) order, repo-level header, the same symbol-context format.
3. **A context-mode toggle**: `symbols` (language-service context) vs `simple` (repo + path header only) vs `raw` (no header, for older models).
4. **One implementation for both editors.** All three machines run the same stack: laptop CPU, M3 Air and M1 Max.
5. **Private by default.** Everything runs locally. Telemetry is written to local files only, opt-in, with path exclusions.
## Architecture
```
┌──────────────┐ native API (HTTP, streaming) ┌──────────────────────────────────────┐
│ VS Code ext. │ ───────────────────────────────────▶ │ acd: local completion daemon (Node) │
└──────────────┘ + buffer sync, feedback events │ • prompt builder (pipeline/src/fim) │
│ • context modes + symbol provider │
┌──────────────┐ OpenAI-compatible /v1/completions │ (TS LanguageService, as training) │──▶ llama-server
│ Zed (built-in│ ───────────────────────────────────▶ │ • KV-cache-aware prompt layout │ (Metal / CPU)
│ provider) │ (prompt rewritten by acd) │ • cancellation, post-processing │
└──────────────┘ │ • local telemetry log │
└──────────────────────────────────────┘
```
**Why a daemon instead of logic in each extension:**
- **Training parity.** It imports `pipeline/src/fim.ts` (`fimPrompt`, `repoHeader`) and the extraction code behind `symbol-context.ts` directly, so production prompts can't drift from training prompts.
- **Same context everywhere.** Symbol context comes from the same TypeScript LanguageService code used to build the training data. VS Code's own hover/completion providers return differently formatted markdown, which the model has never seen.
- **Zed can't run our logic.** It has no extension hook for edit predictions; its built-in `open_ai_compatible_api` provider only calls an HTTP endpoint. A daemon serves both editors.
- **One server, one cache.** A single llama-server and KV cache per machine. The daemon can also run on the M1 Max and serve the laptop over the LAN.
## Prompt layout and KV cache
Training format (Stage C), in token order:
```
<|repo_name|>{repo}\n
[<|file_sep|>{dir}/__symbols__.d.ts\n{symbols}\n] ← only in `symbols` mode
<|file_sep|>{path}\n
<|fim_prefix|><|fim_suffix|>{suffix}<|fim_middle|>{prefix} → model generates middle + <|endoftext|>
```
Everything before the prefix is stable while typing, so each keystroke only appends to the end of the prompt. Measured on the laptop CPU: 3 tokens processed per keystroke, ~15 ms. To keep the prompt stable:
| Part | Changes when | Policy |
|---|---|---|
| Header + symbols | cursor moves to another line/scope, or a member-access receiver changes | Recompute off the hot path. Keep using the previous context until the new prompt is prefilled (stale-while-revalidate), so typing never waits on a context switch. |
| Suffix | text after the cursor changes (rare while typing) | First ≤ 512 tokens, cut at a line boundary. |
| Prefix | every keystroke | **Chunked truncation:** drop from the start in 256-token blocks aligned to line starts, so the window start only moves at block boundaries. Truncating to "the last N tokens" on every keystroke would shift the start and invalidate the whole cache. Training used variable prefix lengths, so any line-aligned start is in distribution. |
llama-server settings: `cache_prompt: true`, two slots (one for live requests, one for background warming of a new context), pinned per editor window with `id_slot`.
## Context modes (the toggle)
| Mode | Prompt | Cost | Use |
|---|---|---|---|
| `symbols` | header + `__symbols__.d.ts` (budget configurable, default 2,048 tokens) + path | context extraction ~40 ms (measured), computed off the hot path; prefill of the larger header on context changes | default for TS/JS/Vue once Stage C is validated |
| `simple` | header with repo + path only | none | fallback; non-TS languages; language service still loading. Stage C trained 20% of samples this way (context dropout), so it is in distribution |
| `raw` | no header | none | Stage A/B models, and A/B comparisons |
**Model profiles** (`acd.config.json`) declare which modes and orders a model supports, e.g. `stage-c: { order: spm, modes: [symbols, simple] }`, `stage-b: { order: spm, modes: [raw] }`, `base-0.5b: { order: psm, modes: [raw] }`. The toggle only offers valid modes.
**Symbol provider (in acd):**
- One TS LanguageService per workspace root, with tsconfig discovery.
- Vue SFCs go through the same `regionsOf` script extraction as training.
- Unsaved buffers come from the editor (VS Code: `/v1/documents/sync`; Zed: the request's own prefix + suffix merged over the on-disk file).
- Output format and ranking are identical to `symbol-context.ts`: member completions at `expr.`, then imported symbols used near the cursor, then other imports. Signatures only; framework modules (`vue`, `pinia`, `node:*`) skipped.
- Cached per anchor (file, line/scope, receiver).
## Daemon API (acd)
| Endpoint | Purpose |
|---|---|
| `POST /v1/fim` | Native completion request: `{ requestId, workspaceRoot, path, languageId, documentVersion, prefix, suffix, contextMode?, maxLines?, singleLine? }`. Streams SSE `{ text, done, timings, meanLogprob }`. Closing the connection cancels the llama request. |
| `POST /v1/completions` | OpenAI-compatible shim for Zed (below). |
| `POST /v1/documents/sync` | Unsaved buffer contents for the language service (debounced). |
| `POST /v1/feedback` | `{ requestId, event: shown | accepted | partial | rejected, acceptedText?, survivedAfter30s? }`, appended to the local telemetry log. |
| `GET/PUT /v1/config` | Current model and context mode; lets the toggle be changed from either editor or a CLI (`acctl mode symbols`). |
| `GET /health` | Readiness: model loaded, language-service status per root. |
**Token healing (when the cursor is mid-identifier):** the prefix ends partway through a word (`this.connections.del▮`), so its last token is an unnatural split. In suffix-first order the model then tends to treat the word as finished: it emits `(relayUrl)` instead of `ete(relayUrl)`, consistently across Stage B and C in the scenario test, and about 3 points worse than prefix-first on the 99 mid-identifier eval samples. Fix:
1. Drop the prefix's last token.
2. Constrain generation (llama-server `grammar`, or logit bias) to start with that token's text.
3. Strip it again before returning.
Measure with the eval harness's mid-identifier subset.
**Post-processing**, shared with `eval.ts` so evaluation measures what users see:
- Trim text that repeats the existing suffix.
- Enforce single-line mode when the cursor has non-whitespace text after it on the same line.
- Cap multi-line completions (default 8 lines).
- Fix indentation to the current line.
- Drop empty or whitespace-only results.
- Optionally suppress completions below a mean-logprob threshold, calibrated later from telemetry.
## VS Code extension
- `vscode.languages.registerInlineCompletionItemProvider` for `typescript`, `javascript`, `typescriptreact`, `vue`, `json`, `jsonc`, `yaml`, `dockercompose`, `dockerfile`.
- **Request flow:** in `provideInlineCompletionItems`:
1. Apply a ~25 ms debounce.
2. Send prefix, suffix, path and version to `/v1/fim`.
3. Tie `token.onCancellationRequested` to aborting the fetch.
4. Return one `InlineCompletionItem`, with a `command` that reports `accepted`.
- **Suggest widget interplay:** when `context.selectedCompletionInfo` is set (the IntelliSense list is open), send the selected item's text as part of the prefix so the ghost text continues it. Otherwise VS Code discards the suggestion.
- **Partial accept** (word/line) uses VS Code's built-in commands. Reporting partial accepts and "shown" events needs the proposed `inlineCompletionsAdditions` API (`enabledApiProposals`). That's fine for a privately installed VSIX, but such an extension can't be published to the Marketplace. The fallback is acceptance heuristics in the daemon, see Telemetry.
- **Status bar:** `AC: symbols · 42 ms`. Clicking it opens a quick pick for the context mode (and model, if several are configured). Command: `autocomplete.toggleContextMode`. Setting: `autocomplete.contextMode` (`symbols | simple | raw`), plus `autocomplete.server` (URL; default spawns a local acd).
- **Privacy controls:** `autocomplete.excludeGlobs` (never send these files, defaulting to `.env*`, `*.pem`, …), `autocomplete.telemetry` (off by default), `autocomplete.recordEdits` (off by default; the next-edit data recorder).
## Zed
Zed (2026) has built-in edit-prediction providers, including `open_ai_compatible_api`. Third-party extensions can't provide edit predictions ([discussion #30855](https://github.com/zed-industries/zed/discussions/30855)), so **Zed needs no extension, only settings that point at acd**:
```jsonc
{
"edit_predictions": {
"provider": "open_ai_compatible_api",
"open_ai_compatible_api": {
"api_url": "http://127.0.0.1:7071/v1/completions",
"model": "ac:symbols", // the toggle: "ac:symbols" | "ac:simple"
"prompt_format": "qwen"
}
}
}
```
**How acd handles a Zed request:**
1. Zed builds its own Qwen-style FIM prompt and sends it to `/v1/completions`.
2. acd parses `prefix` and `suffix` out of Zed's FIM tokens.
3. It resolves the file. If the prompt contains a `<|file_sep|>path`, it uses that; otherwise it locates the buffer by matching the prefix against files under configured workspace roots.
4. It **rebuilds the prompt in our training layout** (SPM + header + symbols) and streams the result back in OpenAI completions format.
5. The context mode comes from the `model` string, so the toggle is a one-word settings edit. `acctl mode` works too.
**Spike to do first (unverified):** capture real Zed requests with a logging proxy, to confirm:
- whether the file path is included
- the prefix/suffix truncation sizes
- streaming, `max_tokens` and stop sequences
- whether Zed aborts superseded requests, so acd must cancel them
- that the `model` string is passed through verbatim
If Zed's prefix/suffix are heavily truncated, acd merges them over the on-disk file to recover full context.
**Telemetry in Zed:** Zed doesn't report accept/reject to providers. acd infers acceptance: if the next request's prefix ends with the previously predicted text, it was accepted (the same heuristic serves as the fallback for VS Code).
## Runtimes per machine
| Machine | llama-server backend | Notes |
|---|---|---|
| Ryzen 7945HX laptop | CPU (AVX-512), 16 threads | Measured: 1.7k tok/s prefill, ~95 tok/s decode for 0.5B Q8_0. Try the Vulkan iGPU build as an experiment. |
| M3 Air | Metal | Fanless: measure sustained throughput, not just bursts. |
| M1 Max | Metal | Fastest. Can also serve the other machines over the LAN (`autocomplete.server`). |
Model: `stage-c-q8_0.gguf` (531 MB) once validated; `stage-b` as the `raw`-mode fallback. Try Q6_K / Q4_K_M only if the M3 Air's latency needs it, and re-run the eval if so.
## Telemetry (local only, opt-in)
JSONL under the daemon's data directory, one record per request:
- timings: debounce, context, prefill, first token, total
- mode, model, prediction (hashed or plaintext per setting)
- outcome: shown, accepted, partial, rejected
- **survival**: does the accepted text still exist 30 s later?
That's what's needed to calibrate suppression and to compare `symbols` vs `simple` in real use. The **edit recorder** (separate opt-in) logs document diffs with timestamps, for future next-edit training data.
## Build order
1. **acd core:** `/v1/fim` with `simple`/`raw`, chunked prefix truncation, llama-server management, cancellation, shared post-processing. Validate with `bench-typing.ts` pointed at acd: warm keystroke latency on all three machines.
2. **VS Code extension:** inline provider, status-bar toggle, settings, exclusions.
3. **`symbols` mode:** move the extractor from `symbol-context.ts` into a shared module used by acd and the training pipeline, add workspace TS services, buffer sync and stale-while-revalidate warming.
4. **Zed:** the logging-proxy spike, then the `/v1/completions` shim.
5. **Telemetry + suppression threshold**, then the edit recorder.
## Proposed repo layout
```
shared/ prompt builder, post-processing, symbol extraction (also used by the training pipeline)
daemon/ acd (Node/TS): HTTP API, llama-server manager, workspace LanguageServices, telemetry
extensions/vscode/ VS Code extension (bundles the daemon; managed mode downloads model + llama.cpp)
extensions/zed/ (planned) settings snippet + README
models/ GGUF models (Git LFS) + manifest.json
```
+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"]
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Alibaba Cloud
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
View File
+42
View File
@@ -0,0 +1,42 @@
# 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 |
|---|---|---|---|---|
| `stage-c` | Suffix-first FIM with repo/symbol-context headers (default) | spm | symbols, simple | 531 MB |
| `stage-b` | Suffix-first FIM without context headers (previous stage) | spm | raw | 531 MB |
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.
+2 -2
View File
@@ -14,7 +14,7 @@
"simple"
],
"defaultMode": "simple",
"description": "Qwen2.5-Coder-0.5B fine-tuned for suffix-first FIM on TS/Vue + personal code, with symbol-context headers (default)"
"description": "Suffix-first FIM with repo/symbol-context headers (default)"
},
{
"name": "stage-b",
@@ -27,7 +27,7 @@
"raw"
],
"defaultMode": "raw",
"description": "Previous stage, no context header (plain suffix-first FIM)"
"description": "Suffix-first FIM without context headers (previous stage)"
}
],
"llamaCpp": {
+14
View File
@@ -0,0 +1,14 @@
{
"name": "local-autocomplete-monorepo",
"private": true,
"description": "Local, low-latency code completion: models, daemon and editor extensions",
"license": "MIT",
"scripts": {
"setup": "npm ci --prefix shared && npm ci --prefix daemon && npm ci --prefix extensions/vscode",
"build": "npm run compile --prefix extensions/vscode && npm run package --prefix extensions/vscode",
"test": "npm test --prefix extensions/vscode && npm run typecheck --prefix daemon",
"release:models": "node scripts/make-model-release.mjs",
"release:extension": "node scripts/publish-extension-release.mjs"
},
"engines": { "node": ">=22.18" }
}
+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}`)
+1
View File
@@ -0,0 +1 @@
node_modules/
+12
View File
@@ -0,0 +1,12 @@
# shared
Code used both by the training pipeline (to build training/eval data) and by the [daemon](../daemon) at inference
time. Keeping one copy is what guarantees the model sees exactly the prompt format it was trained on.
- [`prompt.ts`](prompt.ts): FIM prompt layout (`spm`/`psm`), the repo-level header, KV-cache-friendly prefix
truncation, and post-processing (suffix-overlap trimming, single-line detection). No dependencies.
- [`symbols.ts`](symbols.ts): "symbols available at the cursor" context from the TypeScript language service
(imported signatures, repo-local types, optional member completions) and the missing-`this.` detection.
Pinned to `typescript@5.9.3` and `@vue/compiler-sfc@3.5.43`: the output format is part of the model's training
distribution, so upgrade only together with re-checking parity.
- [`symbol-lang.ts`](symbol-lang.ts): language-id mapping (dependency-free, so the daemon's main bundle stays small).
+223
View File
@@ -0,0 +1,223 @@
{
"name": "autocomplete-shared",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "autocomplete-shared",
"dependencies": {
"@vue/compiler-sfc": "^3.5.43",
"typescript": "^5.9.3"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://verdaccio.harvmaster.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://verdaccio.harvmaster.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.9",
"resolved": "https://verdaccio.harvmaster.com/@babel/parser/-/parser-7.29.9.tgz",
"integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://verdaccio.harvmaster.com/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://verdaccio.harvmaster.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.43",
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
"integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.43",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.43",
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
"integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.43",
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.43",
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
"integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.43",
"@vue/compiler-dom": "3.5.43",
"@vue/compiler-ssr": "3.5.43",
"@vue/shared": "3.5.43",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.28",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.43",
"resolved": "https://verdaccio.harvmaster.com/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
"integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.43",
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/shared": {
"version": "3.5.43",
"resolved": "https://verdaccio.harvmaster.com/@vue/shared/-/shared-3.5.43.tgz",
"integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://verdaccio.harvmaster.com/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://verdaccio.harvmaster.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://verdaccio.harvmaster.com/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://verdaccio.harvmaster.com/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://verdaccio.harvmaster.com/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://verdaccio.harvmaster.com/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://verdaccio.harvmaster.com/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://verdaccio.harvmaster.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "autocomplete-shared",
"private": true,
"type": "module",
"dependencies": {
"@vue/compiler-sfc": "3.5.43",
"typescript": "5.9.3"
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Prompt construction and output post-processing shared by training export,
* evaluation and the completion daemon, so all three build identical prompts.
*
* No dependencies: callers pass in their tokenizer's encode function and the
* special token ids.
*/
export type FimOrder = 'psm' | 'spm'
export type Encode = (text: string) => number[]
export interface FimTokenIds { prefix: number; suffix: number; middle: number }
export interface HeaderTokenIds { repoName: number; fileSep: number }
/**
* Prompt token layout (everything before the middle).
*
* psm: <pre> prefix <suf> suffix <mid> Qwen2.5-Coder's native format
* spm: <pre> <suf> suffix <mid> prefix Bavarian et al. 2022 SPM variant
*
* In spm the prefix runs straight into the middle and ends the prompt, so as
* the user types, the prompt only grows at the end and the KV cache stays valid.
*/
export function fimPrompt(order: FimOrder, prefix: number[], suffix: number[], ids: FimTokenIds): number[] {
return order === 'spm'
? [ids.prefix, ids.suffix, ...suffix, ids.middle, ...prefix]
: [ids.prefix, ...prefix, ids.suffix, ...suffix, ids.middle]
}
/**
* 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
*/
export function repoHeader(repo: string, filePath: string, context: string | undefined, encode: Encode, ids: HeaderTokenIds): 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`))
}
out.push(ids.fileSep, ...encode(`${filePath}\n`))
return out
}
/** Last ≤ maxTokens tokens of text, tokenising only a window that starts at a line boundary (training-time truncation). */
export function tailTokens(text: string, maxTokens: number, encode: Encode): number[] {
let window = text.slice(-maxTokens * 8)
if (window.length < text.length) window = window.slice(window.indexOf('\n') + 1)
return encode(window).slice(-maxTokens)
}
/** First ≤ maxTokens tokens of text, window cut at a line boundary. Deterministic, so stable while the suffix is unchanged. */
export function headTokens(text: string, maxTokens: number, encode: Encode): number[] {
let window = text.slice(0, maxTokens * 8)
if (window.length < text.length) window = window.slice(0, window.lastIndexOf('\n') + 1)
return encode(window).slice(0, maxTokens)
}
/**
* Cache-friendly prefix truncation for live completion.
*
* Instead of "the last N tokens" (whose start moves on every keystroke and
* invalidates the whole KV cache), the prefix starts at a line whose index is
* a multiple of `chunkLines`, chosen as the earliest such line that fits. The
* start therefore only moves when the prefix outgrows the budget, about once
* every `chunkLines` lines of typing.
*/
export function chunkedPrefixTokens(prefix: string, maxTokens: number, encode: Encode, chunkLines = 32, lineOffset = 0): { tokens: number[]; startLine: number } {
// lineOffset: absolute line number of prefix's first line, when the client sent a truncated document,
// so chunk boundaries stay on absolute multiples of chunkLines.
const lineStarts = [0]
for (let i = prefix.indexOf('\n'); i !== -1; i = prefix.indexOf('\n', i + 1)) lineStarts.push(i + 1)
const firstChunkLine = (chunkLines - (lineOffset % chunkLines)) % chunkLines
const candidates: number[] = []
for (let l = firstChunkLine; l < lineStarts.length; l += chunkLines) candidates.push(l)
// Estimate first (≈3.2 chars/token for code) to skip hopeless candidates, then verify with the real tokenizer.
let c = 0
while (c < candidates.length - 1 && (prefix.length - lineStarts[candidates[c]]) / 3.2 > maxTokens * 0.95) c++
for (; c < candidates.length; c++) {
const tokens = encode(prefix.slice(lineStarts[candidates[c]]))
if (tokens.length <= maxTokens) return { tokens, startLine: lineOffset + candidates[c] }
}
// Even the last chunk is over budget (e.g. minified code): fall back to a plain tail.
return { tokens: tailTokens(prefix, maxTokens, encode), startLine: lineOffset + lineStarts.length - 1 }
}
/**
* Cleans a raw completion the way an editor client would: drop what the model
* re-generates of the existing suffix. Never looks at the gold middle.
*
* Lines are compared with their indentation: a method's closing ` }` must not
* be mistaken for the class's `}` that follows in the suffix (that bug removed
* legitimately predicted closing braces).
*/
export function trimSuffixOverlap(raw: string, suffix: string): string {
let out = raw
const sufLine = suffix.split('\n')[0]
const sufFirstNonBlank = suffix.split('\n').find(l => l.trim())?.trimEnd()
const lines = out.split('\n')
if (sufFirstNonBlank) {
const i = lines.findIndex((l, idx) => idx > 0 && l.trimEnd() === sufFirstNonBlank)
if (i > 0) out = lines.slice(0, i).join('\n').replace(/\n[ \t]*$/, '')
}
if (!out.includes('\n') && sufLine.trim() && out.trimEnd().endsWith(sufLine.trimEnd())) {
out = out.trimEnd().slice(0, -sufLine.trimEnd().length)
}
return out
}
/**
* Single-line mode when there is real code after the cursor on the same line
* (a completion there must not add new lines). Closing brackets, quotes and
* punctuation alone don't count: `foo(▮)` may still complete multi-line args.
*/
export function wantsSingleLine(suffix: string): boolean {
const restOfLine = suffix.split('\n')[0]
return /[^\s)\]}'"`;,]/.test(restOfLine)
}
+10
View File
@@ -0,0 +1,10 @@
/** Languages the symbol provider handles (kept dependency-free so the daemon's main bundle stays small). */
export type SymbolLang = 'typescript' | 'javascript' | 'tsx' | 'jsx' | 'vue'
/** Maps VS Code language ids (and pipeline langs) onto what the symbol provider handles. */
export function symbolLang(languageId: string): SymbolLang | null {
const map: Record<string, SymbolLang> = {
typescript: 'typescript', javascript: 'javascript', typescriptreact: 'tsx', javascriptreact: 'jsx', tsx: 'tsx', jsx: 'jsx', vue: 'vue',
}
return map[languageId] ?? null
}
+234
View File
@@ -0,0 +1,234 @@
/**
* "Symbols available at the cursor" context, from the TypeScript language
* service, used both to build training data (pipeline/src/symbol-context.ts)
* and at inference time (daemon). Keep the output format stable: Stage C was
* trained on it.
*
* Context, in priority order:
* 1. member completions when the cursor follows `expr.` / `expr.partial` (optional)
* 2. hover signatures of imported symbols (ones used near the cursor first);
* repo-local interfaces/types/classes include their declaration source
* Trimmed to a token budget.
*/
import fs from 'node:fs'
import path from 'node:path'
import ts from 'typescript'
import { parse as parseSfc } from '@vue/compiler-sfc'
import type { SymbolLang } from './symbol-lang.ts'
export { symbolLang, type SymbolLang } from './symbol-lang.ts'
export interface SymbolsRequest {
/** Absolute path of the file being edited (it may not exist on disk yet). */
filePath: string
lang: SymbolLang
/** The file as it is at completion time (prefix + suffix). */
visible: string
/** Cursor offset in `visible`. */
cursor: number
budget: number
countTokens: (s: string) => number
/** Include member completions when the cursor follows `expr.` (default true, as in training). */
members?: boolean
}
export interface SymbolsResult { context: string; items: number; hasMembers: boolean }
/** <script> regions of a Vue SFC, sorted by position (same as pipeline/src/parse.ts regionsOf). */
function vueScriptRegions(content: string): { start: number; end: number }[] {
const { descriptor } = parseSfc(content, { sourceMap: false, ignoreEmpty: false })
return [descriptor.script, descriptor.scriptSetup]
.filter(b => b !== null)
.map(b => ({ start: b!.loc.start.offset, end: b!.loc.end.offset }))
.sort((a, b) => a.start - b.start)
}
function compilerOptionsFor(rootDir: string): ts.CompilerOptions {
const fallback: ts.CompilerOptions = {
target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler,
strict: true, allowJs: true, skipLibCheck: true, jsx: ts.JsxEmit.Preserve, allowImportingTsExtensions: true, noEmit: true,
}
const configPath = path.join(rootDir, 'tsconfig.json')
let options = fallback
if (fs.existsSync(configPath)) {
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} })
if (parsed) options = { ...parsed.options, noEmit: true, skipLibCheck: true, allowJs: true }
}
// Common aliases in Quasar / Vite projects, if the tsconfig didn't resolve them.
options.baseUrl ??= rootDir
options.paths ??= { 'src/*': ['src/*'], '@/*': ['src/*'], 'components/*': ['src/components/*'], 'boot/*': ['src/boot/*'], 'stores/*': ['src/stores/*'] }
return options
}
const displayText = (parts?: readonly ts.SymbolDisplayPart[]) => (parts ?? []).map(p => p.text).join('')
const clip = (s: string, max: number) => (s.length > max ? s.slice(0, max) + ' …' : s)
/** One TypeScript language service over a workspace root, with an overridable "current file". */
export class WorkspaceSymbols {
readonly rootDir: string
private service: ts.LanguageService
private overrides = new Map<string, { text: string; version: number }>()
private version = 0
constructor(rootDir: string) {
this.rootDir = rootDir
const options = compilerOptionsFor(rootDir)
const host: ts.LanguageServiceHost = {
getCompilationSettings: () => options,
getScriptFileNames: () => [...this.overrides.keys()],
getScriptVersion: f => String(this.overrides.get(f)?.version ?? 0),
getScriptSnapshot: f => {
const o = this.overrides.get(f)
if (o) return ts.ScriptSnapshot.fromString(o.text)
return fs.existsSync(f) ? ts.ScriptSnapshot.fromString(fs.readFileSync(f, 'utf8')) : undefined
},
getCurrentDirectory: () => rootDir,
getDefaultLibFileName: o => ts.getDefaultLibFilePath(o),
fileExists: f => this.overrides.has(f) || ts.sys.fileExists(f),
readFile: f => this.overrides.get(f)?.text ?? ts.sys.readFile(f),
readDirectory: ts.sys.readDirectory,
directoryExists: ts.sys.directoryExists,
getDirectories: ts.sys.getDirectories,
}
this.service = ts.createLanguageService(host, ts.createDocumentRegistry())
}
/** Makes `text` the content of the file being edited; returns the TS-visible file name, text and cursor. */
private load(filePath: string, lang: SymbolLang, visible: string, cursor: number) {
let tsText = visible
let tsCursor: number | null = cursor
let fileName = filePath
if (lang === 'vue') {
const scripts = vueScriptRegions(visible)
if (!scripts.length) return null
tsText = scripts.map(r => visible.slice(r.start, r.end)).join('\n')
const inScript = scripts.find(r => r.start <= cursor && cursor <= r.end)
tsCursor = inScript ? cursor - inScript.start + scripts.slice(0, scripts.indexOf(inScript)).reduce((n, r) => n + r.end - r.start + 1, 0) : null
fileName += '.ts'
}
this.overrides.clear()
this.overrides.set(fileName, { text: tsText, version: ++this.version })
return { fileName, tsText, tsCursor }
}
/** Source text of the declaration at a definition location, for repo-local types (not node_modules). */
private declarationSource(fileName: string, pos: number): string | null {
const def = this.service.getDefinitionAtPosition(fileName, pos)?.[0]
if (!def || def.fileName.includes('node_modules') || def.fileName.endsWith('.d.ts')) return null
if (!['interface', 'type', 'class', 'enum'].includes(def.kind)) return null
const sf = this.service.getProgram()?.getSourceFile(def.fileName)
if (!sf) return null
let node: ts.Node | undefined
const find = (n: ts.Node) => {
if (n.getStart(sf) <= def.textSpan.start && def.textSpan.start + def.textSpan.length <= n.end) {
if (ts.isInterfaceDeclaration(n) || ts.isTypeAliasDeclaration(n) || ts.isClassDeclaration(n) || ts.isEnumDeclaration(n)) node = n
ts.forEachChild(n, find)
}
}
find(sf)
if (!node) return null
let text = (node as ts.Node).getText(sf)
if (ts.isClassDeclaration(node)) {
// Signatures only: header + each member up to (not including) its body.
const cls = node
const header = sf.text.slice(cls.getStart(sf), cls.members.pos).trimEnd()
const members = cls.members.map(m => {
const body = (m as { body?: ts.Node }).body
const sig = sf.text.slice(m.getStart(sf), body ? body.getStart(sf) : m.end).trim().replace(/\s*=\s*[\s\S]{60,}$/, '')
return ' ' + sig.replace(/\s+/g, ' ').replace(/[;{]?$/, ';')
})
text = [header, ...members, '}'].join('\n')
}
const lines = text.split('\n')
return lines.length > 25 ? [...lines.slice(0, 24), ' // …', lines.at(-1)!].join('\n') : text
}
context(req: SymbolsRequest): SymbolsResult | null {
const loaded = this.load(req.filePath, req.lang, req.visible, req.cursor)
if (!loaded) return null
const { fileName, tsText, tsCursor } = loaded
const items: { priority: number; text: string }[] = []
// 1. Members at the cursor
if (tsCursor !== null && req.members !== false) {
const m = /\.([A-Za-z_$][\w$]*)?$/.exec(tsText.slice(Math.max(0, tsCursor - 200), tsCursor))
if (m) {
const pos = tsCursor - (m[1]?.length ?? 0)
const completions = this.service.getCompletionsAtPosition(fileName, pos, { includeCompletionsWithInsertText: true })
if (completions?.isMemberCompletion) {
const entries = [...completions.entries].sort((a, b) => 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|getter|setter)\) /, ''), 200)
})
const receiver = /([\w$.()[\]'"]+)\.[\w$]*$/.exec(tsText.slice(Math.max(0, tsCursor - 200), tsCursor))?.[1] ?? 'expression'
const rest = entries.slice(12).map(e => e.name)
items.push({ priority: 0, text: `// members of ${receiver}\n${detailed.join('\n')}${rest.length ? `\n// also: ${rest.join(', ')}` : ''}` })
}
}
}
// 2. Imported symbols
const sf = ts.createSourceFile(fileName, tsText, ts.ScriptTarget.Latest, true)
const near = tsCursor !== null
? tsText.slice(Math.max(0, tsCursor - 2000), tsCursor + 500)
: req.visible.slice(Math.max(0, req.cursor - 2000), req.cursor + 500)
for (const stmt of sf.statements) {
if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue
const module = (stmt.moduleSpecifier as ts.StringLiteral).text
// Frameworks the model already knows well: their signatures would only spend budget.
if (/^(vue|vue-router|pinia|node:.*|fs|path|events|crypto)$/.test(module)) continue
const names: ts.Identifier[] = []
if (stmt.importClause.name) names.push(stmt.importClause.name)
const bindings = stmt.importClause.namedBindings
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)
const info = this.service.getQuickInfoAtPosition(fileName, pos)
let text = displayText(info?.displayParts).replace(/^\(alias\) /, '').split(/\nimport /)[0]
if (!text || / any$/.test(text) || text.endsWith(': any')) continue
const decl = this.declarationSource(fileName, pos)
if (decl) text = decl
// Skip hovers that carry no signature (e.g. "import z", "namespace debug", "class Mutex").
else if (!/[:(]/.test(text.split('\n')[0]) || /^(import|namespace|module) /.test(text)) continue
const used = new RegExp(`\\b${id.text.replace(/\$/g, '\\$')}\\b`).test(near)
items.push({ priority: used ? 1 : 2, text: `// from '${module}'\n${clip(text, 900)}` })
}
}
if (!items.length) return null
items.sort((a, b) => a.priority - b.priority)
const chosen: string[] = []
let used = 0
for (const item of items) {
const t = req.countTokens(item.text) + 1
if (used + t > req.budget) continue
chosen.push(item.text)
used += t
}
return chosen.length ? { context: chosen.join('\n'), items: chosen.length, hasMembers: chosen[0].startsWith('// members of') } : null
}
/**
* 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.
* Uses the language service's own completions: TS offers class members with
* insertText `this.x` at a lower priority than locals and parameters.
*/
thisMember(filePath: string, lang: SymbolLang, visible: string, cursor: number): { name: string; identLength: number } | null {
const before = visible.slice(Math.max(0, cursor - 200), cursor)
const m = /(^|[^\w$.])([A-Za-z_$][\w$]*)$/.exec(before)
if (!m || m[2].length < 2) return null
// Not when declaring something with that name.
if (/\b(function|const|let|var|class|interface|type|enum|import|export|as|new)\s+[A-Za-z_$][\w$]*$/.test(before)) return null
const ident = m[2]
const loaded = this.load(filePath, lang, visible, cursor)
if (!loaded || loaded.tsCursor === null) return null
const completions = this.service.getCompletionsAtPosition(loaded.fileName, loaded.tsCursor, { includeCompletionsWithInsertText: true })
const matching = (completions?.entries ?? []).filter(e => e.name.startsWith(ident) && e.kind !== 'keyword')
if (!matching.length) return null
matching.sort((a, b) => a.sortText.localeCompare(b.sortText))
const best = matching[0]
return best.insertText?.startsWith('this.') ? { name: best.name, identLength: ident.length } : null
}
}