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:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.vsix
|
||||||
|
*.local.json
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -1,202 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
Apache License
|
Copyright (c) 2026 Harvmaster
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
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,
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
the copyright owner that is granting the License.
|
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
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
SOFTWARE.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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
|
Fast, private, inline code completion for **TypeScript, Vue and Node.js**, from a small model that runs on your own machine.
|
||||||
`manifest.json`, downloads the selected model, verifies its SHA-256 and runs it locally via llama.cpp.
|
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **stage-b**: Previous stage, no context header (plain suffix-first FIM). Prompt order spm, context modes: raw. 531 MB.
|
- **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`.
|
| Model (held-out private code, never trained on) | exact match | edit similarity |
|
||||||
License: Apache-2.0 (see LICENSE and NOTICE).
|
|---|---:|---:|
|
||||||
|
| 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.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
acd.state.json
|
||||||
|
telemetry/
|
||||||
|
recordings/
|
||||||
|
*.local.json
|
||||||
|
test/test*.config.json
|
||||||
@@ -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`
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Generated
+57
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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>) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.vsix
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
src/**
|
||||||
|
test/**
|
||||||
|
**/*.map
|
||||||
|
tsconfig.json
|
||||||
|
node_modules/**
|
||||||
|
build-daemon.mjs
|
||||||
|
*.vsix
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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')
|
||||||
Generated
+2367
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
@@ -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` : ''}`
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"simple"
|
"simple"
|
||||||
],
|
],
|
||||||
"defaultMode": "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",
|
"name": "stage-b",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"raw"
|
"raw"
|
||||||
],
|
],
|
||||||
"defaultMode": "raw",
|
"defaultMode": "raw",
|
||||||
"description": "Previous stage, no context header (plain suffix-first FIM)"
|
"description": "Suffix-first FIM without context headers (previous stage)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"llamaCpp": {
|
"llamaCpp": {
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
@@ -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}`)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -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).
|
||||||
Generated
+223
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "autocomplete-shared",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-sfc": "3.5.43",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user