delphi-code 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {delphi_code-0.1.1 → delphi_code-0.2.0}/PKG-INFO +78 -6
- {delphi_code-0.1.1 → delphi_code-0.2.0}/README.md +76 -5
- delphi_code-0.2.0/delphi_code/__init__.py +3 -0
- delphi_code-0.2.0/delphi_code/cli.py +188 -0
- delphi_code-0.2.0/delphi_code/doctor.py +38 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/download.py +3 -1
- delphi_code-0.2.0/delphi_code/errors.py +27 -0
- delphi_code-0.2.0/delphi_code/files.py +92 -0
- delphi_code-0.2.0/delphi_code/hosts.py +344 -0
- delphi_code-0.2.0/delphi_code/indexing.py +109 -0
- delphi_code-0.2.0/delphi_code/keys.py +83 -0
- delphi_code-0.2.0/delphi_code/manifest.py +122 -0
- delphi_code-0.2.0/delphi_code/model.py +115 -0
- delphi_code-0.2.0/delphi_code/offline.py +31 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/paths.py +3 -0
- delphi_code-0.2.0/delphi_code/picker.py +135 -0
- delphi_code-0.2.0/delphi_code/projects.py +307 -0
- delphi_code-0.2.0/delphi_code/registry.py +119 -0
- delphi_code-0.2.0/delphi_code/selection.py +17 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/setup.py +50 -32
- delphi_code-0.2.0/delphi_code/sources.py +196 -0
- delphi_code-0.2.0/delphi_code/store.py +236 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/PKG-INFO +78 -6
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/SOURCES.txt +16 -1
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/requires.txt +1 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/pyproject.toml +26 -1
- {delphi_code-0.1.1 → delphi_code-0.2.0}/tests/test_cross_project.py +25 -13
- delphi_code-0.2.0/tests/test_index_directory.py +92 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/tests/test_model_defaults.py +12 -6
- delphi_code-0.2.0/tests/test_picker.py +370 -0
- delphi_code-0.2.0/tests/test_project_names.py +65 -0
- delphi_code-0.2.0/tests/test_registry.py +167 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/tests/test_setup.py +29 -15
- delphi_code-0.2.0/tests/test_sources.py +258 -0
- delphi_code-0.1.1/delphi_code/__init__.py +0 -22
- delphi_code-0.1.1/delphi_code/cli.py +0 -282
- delphi_code-0.1.1/delphi_code/files.py +0 -64
- delphi_code-0.1.1/delphi_code/indexing.py +0 -78
- delphi_code-0.1.1/delphi_code/model.py +0 -70
- delphi_code-0.1.1/tests/test_index_directory.py +0 -36
- delphi_code-0.1.1/tests/test_project_names.py +0 -54
- {delphi_code-0.1.1 → delphi_code-0.2.0}/LICENSE +0 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/THIRD_PARTY_NOTICES.md +0 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/__main__.py +0 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/model_provenance.json +0 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/dependency_links.txt +0 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/entry_points.txt +0 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/top_level.txt +0 -0
- {delphi_code-0.1.1 → delphi_code-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: delphi-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Offline local code search for agents
|
|
5
5
|
Author: Mark Dorofeev
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -24,6 +24,7 @@ Requires-Dist: sqlite-vec==0.1.9
|
|
|
24
24
|
Requires-Dist: sentence-transformers==6.1.0
|
|
25
25
|
Requires-Dist: numpy==2.5.3
|
|
26
26
|
Requires-Dist: pathspec==0.12.1
|
|
27
|
+
Requires-Dist: questionary==2.1.1
|
|
27
28
|
Requires-Dist: certifi
|
|
28
29
|
Dynamic: license-file
|
|
29
30
|
|
|
@@ -107,18 +108,82 @@ delphi-code status -p /path/to/project
|
|
|
107
108
|
- **`status`** reports stored index state. It needs no model and does not rescan source files to detect staleness.
|
|
108
109
|
- **`doctor`** checks the model, SQLite extensions, and native storage, then runs a real local embedding and vector distance calculation.
|
|
109
110
|
- **`setup`** provisions the model (see above).
|
|
111
|
+
- **`add`**, **`sync`**, **`list`**, and **`remove`** manage tracked projects (see below).
|
|
110
112
|
|
|
111
113
|
`python -m delphi_code` works as an alternative to the `delphi-code` executable.
|
|
112
114
|
|
|
113
115
|
### Selecting a project
|
|
114
116
|
|
|
115
117
|
- `--project` / `-p` accepts a path, resolved relative to the current working directory. The project root is explicit; it is not inferred from Git.
|
|
116
|
-
- After a project has been indexed once, its folder name works too, for example `delphi-code search -p
|
|
118
|
+
- After a project has been indexed once, its key or folder name works too, for example `delphi-code search -p acme/api 'parse config'` or `-p myapp`. A key matches in full or by a trailing part (`bitbucket.org/acme/api`, `acme/api`, or `api`). A name that matches several indexes produces an error listing their keys. Use `./name` or an absolute path to select a directory explicitly.
|
|
117
119
|
- Without `--project`, `search` searches all stored indexes; other commands use the current directory.
|
|
118
120
|
|
|
121
|
+
### Tracking projects
|
|
122
|
+
|
|
123
|
+
Instead of indexing projects one by one, keep a list of them and update them all together:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
delphi-code add ~/src/api ~/src/web --path 'src/*'
|
|
127
|
+
delphi-code sync
|
|
128
|
+
delphi-code list
|
|
129
|
+
delphi-code remove api
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- **`add`** records projects and indexes them; `--no-sync` only records them. Filter options are stored with each project, and adding a project again replaces its options.
|
|
133
|
+
- **`sync`** indexes every tracked project. One failing project does not stop the rest: the command then exits with `sync_failed` (code 5) and still reports each project's result under `data`.
|
|
134
|
+
- **`list`** shows tracked projects and every stored index, including ones created with plain `index`. It needs no model.
|
|
135
|
+
- **`remove`** stops tracking a project and deletes its index; `--keep-index` keeps the index.
|
|
136
|
+
|
|
137
|
+
The list lives in `repos.toml` in the storage directory (`DELPHI_CODE_REGISTRY` overrides the location). You can edit it by hand; `add` and `remove` rewrite it without comments.
|
|
138
|
+
|
|
139
|
+
```toml
|
|
140
|
+
[[repo]]
|
|
141
|
+
source = "/Users/me/src/api"
|
|
142
|
+
key = "bitbucket.org/acme/api"
|
|
143
|
+
paths = ["src/*"]
|
|
144
|
+
|
|
145
|
+
[[repo]]
|
|
146
|
+
source = "bitbucket.org/acme/billing"
|
|
147
|
+
ref = "release"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Bitbucket Cloud and GitHub repositories
|
|
151
|
+
|
|
152
|
+
Repositories you have not cloned can be tracked too:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
delphi-code add bitbucket.org/acme/billing
|
|
156
|
+
delphi-code add git@bitbucket.org:acme/billing.git --ref release
|
|
157
|
+
delphi-code add github.com/octo/tools
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- `sync` asks the hosting service for the latest commit of the branch or tag, which is the default branch unless `--ref` names another. It skips the repository when the index already holds that commit with the same filters and model. Otherwise it makes a shallow clone into a temporary directory, indexes it, and deletes the clone. Unchanged files are not embedded again.
|
|
161
|
+
- Search results from these repositories carry a `url`: a Bitbucket or GitHub link to the indexed commit and line range. The response also includes `ref` and `commit`, and `project` is `null`.
|
|
162
|
+
- Use `sync`, not `index`, to update them.
|
|
163
|
+
- Only `add` and `sync` touch the network. `index`, `search`, `status`, and `list` stay offline.
|
|
164
|
+
|
|
165
|
+
**Picking repositories.** Run `add` without sources in a terminal to choose from the repositories your accounts can see:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
delphi-code add
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
172
|
+
|
|
173
|
+
**Credentials.** Git never prompts. Credentials from the environment reach git through `GIT_ASKPASS`, never in URLs or arguments, and take precedence over configured git credentials. Listing repositories for the picker uses the services' REST APIs. The API requests run in a separate process, like the model download in `setup`, so the main process keeps its network guard.
|
|
174
|
+
|
|
175
|
+
| | Bitbucket Cloud | GitHub |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| Cloning | `BITBUCKET_USERNAME` and `BITBUCKET_APP_PASSWORD`, otherwise git's own credentials | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise git's own credentials (`gh auth setup-git` configures them) |
|
|
178
|
+
| Listing for the picker | The same variables (with `BITBUCKET_EMAIL` in place of the username when set), otherwise the credentials git has stored for bitbucket.org | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise `gh auth token`, otherwise the token git has stored for github.com |
|
|
179
|
+
|
|
180
|
+
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`.
|
|
181
|
+
|
|
182
|
+
`DELPHI_CODE_BITBUCKET_GIT_BASE` and `DELPHI_CODE_GITHUB_GIT_BASE` send clones to another base URL, such as a mirror, and `DELPHI_CODE_BITBUCKET_API_BASE` and `DELPHI_CODE_GITHUB_API_BASE` do the same for the APIs. Result links still point to bitbucket.org and github.com.
|
|
183
|
+
|
|
119
184
|
### Searching across projects
|
|
120
185
|
|
|
121
|
-
Cross-project search returns a single globally ranked list, with the full `project` path on each result. The query is embedded once, `--limit` applies to the combined list, and path/language filters apply within every index. All indexes must be readable, complete, and built with the selected model: a busy, broken, or incompatible index produces an error rather than silently partial results. Use `-p` to narrow the search when needed.
|
|
186
|
+
Cross-project search returns a single globally ranked list, with the `key` and full `project` path on each result (`null` for Bitbucket and GitHub repositories, whose results carry a `url` instead). The query is embedded once, `--limit` applies to the combined list, and path/language filters apply within every index. All indexes must be readable, complete, and built with the selected model: a busy, broken, or incompatible index produces an error rather than silently partial results. Use `-p` to narrow the search when needed.
|
|
122
187
|
|
|
123
188
|
## Output
|
|
124
189
|
|
|
@@ -205,11 +270,18 @@ Models and indexes live outside the installation, so reinstalling or upgrading l
|
|
|
205
270
|
|
|
206
271
|
The default model is under `models/all-MiniLM-L6-v2/` and indexes are under `indexes/`. `DELPHI_CODE_INDEX_ROOT` overrides the index location.
|
|
207
272
|
|
|
208
|
-
Each project has one index
|
|
273
|
+
Each project has one index in `indexes/<id>/`, where the id is random. The index holds CocoIndex's incremental state, the SQLite vector table, and a manifest recording the project's key and its last indexed path. Command output includes this `index_directory` and the `key`.
|
|
274
|
+
|
|
275
|
+
A project's key decides which index it uses:
|
|
276
|
+
|
|
277
|
+
- The root of a Git repository with an `origin` remote is keyed by that remote, normalized to `host/owner/repo`. `git@bitbucket.org:acme/api.git` and `https://bitbucket.org/acme/api` both become `bitbucket.org/acme/api`. Moving or re-cloning the repository keeps its index, and unchanged files are not embedded again.
|
|
278
|
+
- Any other directory, including a subdirectory of a repository, is keyed by its resolved path: `local:/path/to/project`.
|
|
279
|
+
- Indexes created by earlier versions are given a key when they are first seen, without being rebuilt. If another index already has that remote key, the older index keeps a path key.
|
|
209
280
|
|
|
210
281
|
- A file lock prevents reads during updates and concurrent writers.
|
|
211
282
|
- An interrupted or failed update leaves the index marked `ready: false`, and search refuses it until indexing succeeds. There is no fallback to a last good snapshot.
|
|
212
|
-
-
|
|
283
|
+
- Two checkouts of the same repository share one index, which holds whichever of them was indexed last. Select a subdirectory to keep them apart.
|
|
284
|
+
- Moving a project that has no Git remote changes its key, and indexing it again builds a new index.
|
|
213
285
|
- To switch model assets or rebuild incompatible state, move that `index_directory` aside and run `index` again.
|
|
214
286
|
|
|
215
287
|
## Offline guarantees
|
|
@@ -223,7 +295,7 @@ A Python audit hook also rejects Internet socket operations and DNS lookups. Thi
|
|
|
223
295
|
delphi-code index -p /path/to/project
|
|
224
296
|
```
|
|
225
297
|
|
|
226
|
-
Delphi Code needs read access to source and model files, write access to its user index directory, threads, SQLite extensions, and CocoIndex's LMDB memory maps. It needs no network access. Some stricter sandboxes deny CocoIndex's native storage initialization with `EPERM`; `doctor` probes for this and reports `sandbox_storage_denied`. The application never escalates its own permissions. `doctor` reports the Python guard only; it does not claim that an external OS sandbox is active.
|
|
298
|
+
Delphi Code needs read access to source and model files, write access to its user index directory, threads, SQLite extensions, and CocoIndex's LMDB memory maps. It needs no network access, except for `setup` and for `add` and `sync` of Bitbucket or GitHub repositories; local projects never need it. Some stricter sandboxes deny CocoIndex's native storage initialization with `EPERM`; `doctor` probes for this and reports `sandbox_storage_denied`. The application never escalates its own permissions. `doctor` reports the Python guard only; it does not claim that an external OS sandbox is active.
|
|
227
299
|
|
|
228
300
|
## Development
|
|
229
301
|
|
|
@@ -78,18 +78,82 @@ delphi-code status -p /path/to/project
|
|
|
78
78
|
- **`status`** reports stored index state. It needs no model and does not rescan source files to detect staleness.
|
|
79
79
|
- **`doctor`** checks the model, SQLite extensions, and native storage, then runs a real local embedding and vector distance calculation.
|
|
80
80
|
- **`setup`** provisions the model (see above).
|
|
81
|
+
- **`add`**, **`sync`**, **`list`**, and **`remove`** manage tracked projects (see below).
|
|
81
82
|
|
|
82
83
|
`python -m delphi_code` works as an alternative to the `delphi-code` executable.
|
|
83
84
|
|
|
84
85
|
### Selecting a project
|
|
85
86
|
|
|
86
87
|
- `--project` / `-p` accepts a path, resolved relative to the current working directory. The project root is explicit; it is not inferred from Git.
|
|
87
|
-
- After a project has been indexed once, its folder name works too, for example `delphi-code search -p
|
|
88
|
+
- After a project has been indexed once, its key or folder name works too, for example `delphi-code search -p acme/api 'parse config'` or `-p myapp`. A key matches in full or by a trailing part (`bitbucket.org/acme/api`, `acme/api`, or `api`). A name that matches several indexes produces an error listing their keys. Use `./name` or an absolute path to select a directory explicitly.
|
|
88
89
|
- Without `--project`, `search` searches all stored indexes; other commands use the current directory.
|
|
89
90
|
|
|
91
|
+
### Tracking projects
|
|
92
|
+
|
|
93
|
+
Instead of indexing projects one by one, keep a list of them and update them all together:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
delphi-code add ~/src/api ~/src/web --path 'src/*'
|
|
97
|
+
delphi-code sync
|
|
98
|
+
delphi-code list
|
|
99
|
+
delphi-code remove api
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- **`add`** records projects and indexes them; `--no-sync` only records them. Filter options are stored with each project, and adding a project again replaces its options.
|
|
103
|
+
- **`sync`** indexes every tracked project. One failing project does not stop the rest: the command then exits with `sync_failed` (code 5) and still reports each project's result under `data`.
|
|
104
|
+
- **`list`** shows tracked projects and every stored index, including ones created with plain `index`. It needs no model.
|
|
105
|
+
- **`remove`** stops tracking a project and deletes its index; `--keep-index` keeps the index.
|
|
106
|
+
|
|
107
|
+
The list lives in `repos.toml` in the storage directory (`DELPHI_CODE_REGISTRY` overrides the location). You can edit it by hand; `add` and `remove` rewrite it without comments.
|
|
108
|
+
|
|
109
|
+
```toml
|
|
110
|
+
[[repo]]
|
|
111
|
+
source = "/Users/me/src/api"
|
|
112
|
+
key = "bitbucket.org/acme/api"
|
|
113
|
+
paths = ["src/*"]
|
|
114
|
+
|
|
115
|
+
[[repo]]
|
|
116
|
+
source = "bitbucket.org/acme/billing"
|
|
117
|
+
ref = "release"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Bitbucket Cloud and GitHub repositories
|
|
121
|
+
|
|
122
|
+
Repositories you have not cloned can be tracked too:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
delphi-code add bitbucket.org/acme/billing
|
|
126
|
+
delphi-code add git@bitbucket.org:acme/billing.git --ref release
|
|
127
|
+
delphi-code add github.com/octo/tools
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- `sync` asks the hosting service for the latest commit of the branch or tag, which is the default branch unless `--ref` names another. It skips the repository when the index already holds that commit with the same filters and model. Otherwise it makes a shallow clone into a temporary directory, indexes it, and deletes the clone. Unchanged files are not embedded again.
|
|
131
|
+
- Search results from these repositories carry a `url`: a Bitbucket or GitHub link to the indexed commit and line range. The response also includes `ref` and `commit`, and `project` is `null`.
|
|
132
|
+
- Use `sync`, not `index`, to update them.
|
|
133
|
+
- Only `add` and `sync` touch the network. `index`, `search`, `status`, and `list` stay offline.
|
|
134
|
+
|
|
135
|
+
**Picking repositories.** Run `add` without sources in a terminal to choose from the repositories your accounts can see:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
delphi-code add
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
142
|
+
|
|
143
|
+
**Credentials.** Git never prompts. Credentials from the environment reach git through `GIT_ASKPASS`, never in URLs or arguments, and take precedence over configured git credentials. Listing repositories for the picker uses the services' REST APIs. The API requests run in a separate process, like the model download in `setup`, so the main process keeps its network guard.
|
|
144
|
+
|
|
145
|
+
| | Bitbucket Cloud | GitHub |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| Cloning | `BITBUCKET_USERNAME` and `BITBUCKET_APP_PASSWORD`, otherwise git's own credentials | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise git's own credentials (`gh auth setup-git` configures them) |
|
|
148
|
+
| Listing for the picker | The same variables (with `BITBUCKET_EMAIL` in place of the username when set), otherwise the credentials git has stored for bitbucket.org | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise `gh auth token`, otherwise the token git has stored for github.com |
|
|
149
|
+
|
|
150
|
+
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`.
|
|
151
|
+
|
|
152
|
+
`DELPHI_CODE_BITBUCKET_GIT_BASE` and `DELPHI_CODE_GITHUB_GIT_BASE` send clones to another base URL, such as a mirror, and `DELPHI_CODE_BITBUCKET_API_BASE` and `DELPHI_CODE_GITHUB_API_BASE` do the same for the APIs. Result links still point to bitbucket.org and github.com.
|
|
153
|
+
|
|
90
154
|
### Searching across projects
|
|
91
155
|
|
|
92
|
-
Cross-project search returns a single globally ranked list, with the full `project` path on each result. The query is embedded once, `--limit` applies to the combined list, and path/language filters apply within every index. All indexes must be readable, complete, and built with the selected model: a busy, broken, or incompatible index produces an error rather than silently partial results. Use `-p` to narrow the search when needed.
|
|
156
|
+
Cross-project search returns a single globally ranked list, with the `key` and full `project` path on each result (`null` for Bitbucket and GitHub repositories, whose results carry a `url` instead). The query is embedded once, `--limit` applies to the combined list, and path/language filters apply within every index. All indexes must be readable, complete, and built with the selected model: a busy, broken, or incompatible index produces an error rather than silently partial results. Use `-p` to narrow the search when needed.
|
|
93
157
|
|
|
94
158
|
## Output
|
|
95
159
|
|
|
@@ -176,11 +240,18 @@ Models and indexes live outside the installation, so reinstalling or upgrading l
|
|
|
176
240
|
|
|
177
241
|
The default model is under `models/all-MiniLM-L6-v2/` and indexes are under `indexes/`. `DELPHI_CODE_INDEX_ROOT` overrides the index location.
|
|
178
242
|
|
|
179
|
-
Each project has one index
|
|
243
|
+
Each project has one index in `indexes/<id>/`, where the id is random. The index holds CocoIndex's incremental state, the SQLite vector table, and a manifest recording the project's key and its last indexed path. Command output includes this `index_directory` and the `key`.
|
|
244
|
+
|
|
245
|
+
A project's key decides which index it uses:
|
|
246
|
+
|
|
247
|
+
- The root of a Git repository with an `origin` remote is keyed by that remote, normalized to `host/owner/repo`. `git@bitbucket.org:acme/api.git` and `https://bitbucket.org/acme/api` both become `bitbucket.org/acme/api`. Moving or re-cloning the repository keeps its index, and unchanged files are not embedded again.
|
|
248
|
+
- Any other directory, including a subdirectory of a repository, is keyed by its resolved path: `local:/path/to/project`.
|
|
249
|
+
- Indexes created by earlier versions are given a key when they are first seen, without being rebuilt. If another index already has that remote key, the older index keeps a path key.
|
|
180
250
|
|
|
181
251
|
- A file lock prevents reads during updates and concurrent writers.
|
|
182
252
|
- An interrupted or failed update leaves the index marked `ready: false`, and search refuses it until indexing succeeds. There is no fallback to a last good snapshot.
|
|
183
|
-
-
|
|
253
|
+
- Two checkouts of the same repository share one index, which holds whichever of them was indexed last. Select a subdirectory to keep them apart.
|
|
254
|
+
- Moving a project that has no Git remote changes its key, and indexing it again builds a new index.
|
|
184
255
|
- To switch model assets or rebuild incompatible state, move that `index_directory` aside and run `index` again.
|
|
185
256
|
|
|
186
257
|
## Offline guarantees
|
|
@@ -194,7 +265,7 @@ A Python audit hook also rejects Internet socket operations and DNS lookups. Thi
|
|
|
194
265
|
delphi-code index -p /path/to/project
|
|
195
266
|
```
|
|
196
267
|
|
|
197
|
-
Delphi Code needs read access to source and model files, write access to its user index directory, threads, SQLite extensions, and CocoIndex's LMDB memory maps. It needs no network access. Some stricter sandboxes deny CocoIndex's native storage initialization with `EPERM`; `doctor` probes for this and reports `sandbox_storage_denied`. The application never escalates its own permissions. `doctor` reports the Python guard only; it does not claim that an external OS sandbox is active.
|
|
268
|
+
Delphi Code needs read access to source and model files, write access to its user index directory, threads, SQLite extensions, and CocoIndex's LMDB memory maps. It needs no network access, except for `setup` and for `add` and `sync` of Bitbucket or GitHub repositories; local projects never need it. Some stricter sandboxes deny CocoIndex's native storage initialization with `EPERM`; `doctor` probes for this and reports `sandbox_storage_denied`. The application never escalates its own permissions. `doctor` reports the Python guard only; it does not claim that an external OS sandbox is active.
|
|
198
269
|
|
|
199
270
|
## Development
|
|
200
271
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from contextlib import redirect_stdout
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import projects
|
|
10
|
+
from .errors import ExitCode, Failure
|
|
11
|
+
from .paths import model_directory
|
|
12
|
+
from .registry import Registry
|
|
13
|
+
from .selection import DEFAULT_MAX_BYTES, FileSelection
|
|
14
|
+
from .store import Store
|
|
15
|
+
|
|
16
|
+
OUTPUT_SCHEMA_VERSION = 1
|
|
17
|
+
MAX_SEARCH_LIMIT = 1000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Parser(argparse.ArgumentParser):
|
|
21
|
+
def error(self, message):
|
|
22
|
+
raise Failure("usage", message, ExitCode.USAGE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def arguments() -> argparse.Namespace:
|
|
26
|
+
parser = Parser(prog="delphi-code", description="Offline local code search")
|
|
27
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
for name in ("index", "search", "status", "doctor"):
|
|
29
|
+
command = commands.add_parser(name)
|
|
30
|
+
command.add_argument(
|
|
31
|
+
"--project",
|
|
32
|
+
"-p",
|
|
33
|
+
default=None if name == "search" else str(Path.cwd()),
|
|
34
|
+
help="Project path, key, or indexed name; search defaults to all indexes",
|
|
35
|
+
)
|
|
36
|
+
if name != "status":
|
|
37
|
+
_add_model_option(command)
|
|
38
|
+
if name in {"index", "search"}:
|
|
39
|
+
_add_selection_options(command, indexing=name == "index")
|
|
40
|
+
if name == "search":
|
|
41
|
+
command.add_argument("query")
|
|
42
|
+
command.add_argument("--limit", type=_search_limit, default=10)
|
|
43
|
+
add = commands.add_parser("add", help="Track projects in the registry and index them")
|
|
44
|
+
add.add_argument(
|
|
45
|
+
"sources",
|
|
46
|
+
nargs="*",
|
|
47
|
+
metavar="SOURCE",
|
|
48
|
+
help="Project directory, bitbucket.org/workspace/repository, or github.com/owner/repository; "
|
|
49
|
+
"omit to pick repositories interactively",
|
|
50
|
+
)
|
|
51
|
+
add.add_argument("--ref", help="Branch or tag of remote repositories; defaults to the default branch")
|
|
52
|
+
_add_selection_options(add, indexing=True)
|
|
53
|
+
_add_model_option(add)
|
|
54
|
+
add.add_argument("--no-sync", action="store_true", help="Only update the registry")
|
|
55
|
+
sync = commands.add_parser("sync", help="Index every tracked project")
|
|
56
|
+
_add_model_option(sync)
|
|
57
|
+
commands.add_parser("list", help="List tracked projects and stored indexes")
|
|
58
|
+
remove = commands.add_parser("remove", help="Stop tracking a project and delete its index")
|
|
59
|
+
remove.add_argument("name", help="Project path, key, or indexed name")
|
|
60
|
+
remove.add_argument("--keep-index", action="store_true", help="Only untrack the project")
|
|
61
|
+
setup = commands.add_parser("setup", help="Download or import the pinned model and check the installation")
|
|
62
|
+
setup.add_argument("--from", dest="source", help="Import a prepared MiniLM model without network access")
|
|
63
|
+
_add_model_option(setup, help="Model destination")
|
|
64
|
+
return parser.parse_args()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def execute(args: argparse.Namespace) -> dict:
|
|
68
|
+
return COMMANDS[args.command](args)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main():
|
|
72
|
+
command = None
|
|
73
|
+
exit_code = 0
|
|
74
|
+
try:
|
|
75
|
+
args = arguments()
|
|
76
|
+
command = args.command
|
|
77
|
+
with redirect_stdout(sys.stderr):
|
|
78
|
+
payload = {"ok": True, "command": command, "data": execute(args)}
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
if os.environ.get("DELPHI_CODE_DEBUG") == "1":
|
|
81
|
+
import traceback
|
|
82
|
+
|
|
83
|
+
traceback.print_exc(file=sys.stderr)
|
|
84
|
+
failure = Failure.from_exception(exc)
|
|
85
|
+
exit_code = failure.exit_code
|
|
86
|
+
print(f"delphi-code: {failure}", file=sys.stderr)
|
|
87
|
+
payload = {"ok": False, "command": command, "error": failure.to_json()}
|
|
88
|
+
if failure.data is not None:
|
|
89
|
+
payload["data"] = failure.data
|
|
90
|
+
print(
|
|
91
|
+
json.dumps(
|
|
92
|
+
{"schema_version": OUTPUT_SCHEMA_VERSION, **payload}, ensure_ascii=False, sort_keys=True, allow_nan=False
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
raise SystemExit(exit_code)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _index(args):
|
|
99
|
+
return projects.index_local_project(Store(), args.project, _selection(args), args.model)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _search(args):
|
|
103
|
+
request = projects.SearchRequest(args.query, args.path, args.language, args.limit)
|
|
104
|
+
if args.project is None:
|
|
105
|
+
return projects.search_everywhere(Store(), request, args.model)
|
|
106
|
+
return projects.search_project(Store(), args.project, request, args.model)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _status(args):
|
|
110
|
+
return projects.status(Store(), args.project)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _doctor(args):
|
|
114
|
+
from .doctor import diagnose
|
|
115
|
+
|
|
116
|
+
return diagnose(args.model, Store(), args.project)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _add(args):
|
|
120
|
+
if not args.sources:
|
|
121
|
+
return projects.add_picked_repositories(
|
|
122
|
+
Store(), Registry(), args.ref, _selection(args), args.model, sync_now=not args.no_sync
|
|
123
|
+
)
|
|
124
|
+
return projects.add_sources(
|
|
125
|
+
Store(), Registry(), args.sources, args.ref, _selection(args), args.model, sync_now=not args.no_sync
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sync(args):
|
|
130
|
+
registry = Registry()
|
|
131
|
+
return projects.sync(Store(), registry, registry.entries(), args.model)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _list(args):
|
|
135
|
+
return projects.list_projects(Store(), Registry())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _remove(args):
|
|
139
|
+
return projects.remove_project(Store(), Registry(), args.name, keep_index=args.keep_index)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _setup(args):
|
|
143
|
+
from .setup import provision
|
|
144
|
+
|
|
145
|
+
return provision(args.model, args.source)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
COMMANDS: dict[str, Callable[[argparse.Namespace], dict]] = {
|
|
149
|
+
"index": _index,
|
|
150
|
+
"search": _search,
|
|
151
|
+
"status": _status,
|
|
152
|
+
"doctor": _doctor,
|
|
153
|
+
"add": _add,
|
|
154
|
+
"sync": _sync,
|
|
155
|
+
"list": _list,
|
|
156
|
+
"remove": _remove,
|
|
157
|
+
"setup": _setup,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _selection(args) -> FileSelection:
|
|
162
|
+
return FileSelection(args.path, args.language, args.ignore, args.max_bytes)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _add_selection_options(command, indexing):
|
|
166
|
+
command.add_argument("--path", action="append", default=[], help="Project-relative glob; repeat for alternatives")
|
|
167
|
+
command.add_argument("--language", action="append", default=[])
|
|
168
|
+
if indexing:
|
|
169
|
+
command.add_argument("--ignore", action="append", default=[])
|
|
170
|
+
command.add_argument("--max-bytes", type=_positive_integer, default=DEFAULT_MAX_BYTES)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _add_model_option(command, help=None):
|
|
174
|
+
command.add_argument("--model", default=os.environ.get("DELPHI_CODE_MODEL") or model_directory(), help=help)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _positive_integer(text):
|
|
178
|
+
value = int(text)
|
|
179
|
+
if value < 1:
|
|
180
|
+
raise argparse.ArgumentTypeError("must be positive")
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _search_limit(text):
|
|
185
|
+
value = int(text)
|
|
186
|
+
if not 1 <= value <= MAX_SEARCH_LIMIT:
|
|
187
|
+
raise argparse.ArgumentTypeError(f"must be between 1 and {MAX_SEARCH_LIMIT}")
|
|
188
|
+
return value
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sqlite3
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
from .projects import open_model, resolve_existing
|
|
8
|
+
from .store import Store, vector_database
|
|
9
|
+
|
|
10
|
+
REPORTED_DEPENDENCIES = ("cocoindex", "sqlite-vec", "sentence-transformers", "torch")
|
|
11
|
+
PROBE_TEXT = "local code search"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def diagnose(model_location: str, store: Store, project_name: str) -> dict:
|
|
15
|
+
from .indexing import check_storage
|
|
16
|
+
|
|
17
|
+
project, key, index = resolve_existing(store, project_name)
|
|
18
|
+
model = open_model(model_location)
|
|
19
|
+
with tempfile.TemporaryDirectory(prefix="delphi-code-doctor-") as scratch:
|
|
20
|
+
asyncio.run(check_storage(Path(scratch)))
|
|
21
|
+
vector = model.embed([PROBE_TEXT])[0]
|
|
22
|
+
with vector_database(":memory:") as db:
|
|
23
|
+
self_distance = db.execute("SELECT vec_distance_L2(?, ?)", (vector.tobytes(), vector.tobytes())).fetchone()[0]
|
|
24
|
+
return {
|
|
25
|
+
"project": str(project) if project else None,
|
|
26
|
+
"key": key,
|
|
27
|
+
"index_directory": str(index.directory) if index else None,
|
|
28
|
+
"model": str(model.directory),
|
|
29
|
+
"model_sha256": model.sha256,
|
|
30
|
+
"dimensions": len(vector),
|
|
31
|
+
"self_distance": self_distance,
|
|
32
|
+
"device": "cpu",
|
|
33
|
+
"cocoindex_storage": "ok",
|
|
34
|
+
"offline": True,
|
|
35
|
+
"network_guard": "python_audit",
|
|
36
|
+
"sqlite": sqlite3.sqlite_version,
|
|
37
|
+
"dependencies": {name: version(name) for name in REPORTED_DEPENDENCIES},
|
|
38
|
+
}
|
|
@@ -15,7 +15,9 @@ def main():
|
|
|
15
15
|
manifest = json.loads(Path(__file__).with_name("model_provenance.json").read_text())
|
|
16
16
|
destination = Path(sys.argv[1])
|
|
17
17
|
snapshot_download(
|
|
18
|
-
manifest["repository"],
|
|
18
|
+
manifest["repository"],
|
|
19
|
+
revision=manifest["revision"],
|
|
20
|
+
local_dir=destination,
|
|
19
21
|
allow_patterns=list(manifest["sha256"]),
|
|
20
22
|
)
|
|
21
23
|
if not (destination / "LICENSE").exists():
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExitCode(IntEnum):
|
|
5
|
+
USAGE = 2
|
|
6
|
+
RUNTIME_ASSETS = 3
|
|
7
|
+
INDEX_STATE = 4
|
|
8
|
+
OPERATION = 5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Failure(Exception):
|
|
12
|
+
def __init__(self, code: str, message: str, exit_code: ExitCode, data: dict | None = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.code = code
|
|
15
|
+
self.exit_code = exit_code
|
|
16
|
+
self.data = data
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_exception(cls, exc: BaseException) -> "Failure":
|
|
20
|
+
if isinstance(exc, Failure):
|
|
21
|
+
return exc
|
|
22
|
+
if isinstance(exc, ModuleNotFoundError):
|
|
23
|
+
return cls("dependency_missing", str(exc), ExitCode.RUNTIME_ASSETS)
|
|
24
|
+
return cls("runtime_error", str(exc), ExitCode.OPERATION)
|
|
25
|
+
|
|
26
|
+
def to_json(self) -> dict:
|
|
27
|
+
return {"code": self.code, "message": str(self)}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import NamedTuple
|
|
5
|
+
|
|
6
|
+
from pathspec import GitIgnoreSpec
|
|
7
|
+
|
|
8
|
+
from .selection import FileSelection
|
|
9
|
+
|
|
10
|
+
ALWAYS_EXCLUDED_NAMES = {".git", ".delphi-code", ".venv", "venv", "node_modules", "__pycache__", ".models"}
|
|
11
|
+
IGNORE_FILE_NAMES = (".gitignore", ".delphi-codeignore")
|
|
12
|
+
FALLBACK_LANGUAGE = "text"
|
|
13
|
+
|
|
14
|
+
SourceFile = tuple[str, str, str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CollectedFiles(NamedTuple):
|
|
18
|
+
files: dict[str, SourceFile]
|
|
19
|
+
skipped: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def collect(root: Path, selection: FileSelection, excluded: Path | None = None) -> CollectedFiles:
|
|
23
|
+
collector = _Collector(root, selection, excluded)
|
|
24
|
+
collector.walk(root, [])
|
|
25
|
+
return CollectedFiles(collector.files, collector.skipped)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Collector:
|
|
29
|
+
def __init__(self, root: Path, selection: FileSelection, excluded: Path | None):
|
|
30
|
+
self.root = root
|
|
31
|
+
self.selection = selection
|
|
32
|
+
self.excluded = excluded
|
|
33
|
+
self.extra_ignores = GitIgnoreSpec.from_lines(selection.ignores)
|
|
34
|
+
self.files: dict[str, SourceFile] = {}
|
|
35
|
+
self.skipped = 0
|
|
36
|
+
|
|
37
|
+
def walk(self, directory: Path, inherited_rules: list[tuple[Path, GitIgnoreSpec]]):
|
|
38
|
+
rules = inherited_rules + _ignore_rules_in(directory)
|
|
39
|
+
for entry in sorted(directory.iterdir()):
|
|
40
|
+
if self._is_skipped(entry, rules):
|
|
41
|
+
self.skipped += 1
|
|
42
|
+
elif entry.is_dir():
|
|
43
|
+
self.walk(entry, rules)
|
|
44
|
+
elif entry.is_file():
|
|
45
|
+
self._read(entry)
|
|
46
|
+
|
|
47
|
+
def _is_skipped(self, entry: Path, rules: list[tuple[Path, GitIgnoreSpec]]) -> bool:
|
|
48
|
+
if entry.is_symlink() or entry.name in ALWAYS_EXCLUDED_NAMES or entry == self.excluded:
|
|
49
|
+
return True
|
|
50
|
+
suffix = "/" if entry.is_dir() else ""
|
|
51
|
+
ignored = False
|
|
52
|
+
for base, spec in rules:
|
|
53
|
+
match = spec.check_file(entry.relative_to(base).as_posix() + suffix)
|
|
54
|
+
if match.include is not None:
|
|
55
|
+
ignored = match.include
|
|
56
|
+
return ignored or self.extra_ignores.match_file(self._relative(entry) + suffix)
|
|
57
|
+
|
|
58
|
+
def _read(self, entry: Path):
|
|
59
|
+
from cocoindex.ops.text import detect_code_language
|
|
60
|
+
|
|
61
|
+
relative = self._relative(entry)
|
|
62
|
+
language = detect_code_language(filename=entry.name) or FALLBACK_LANGUAGE
|
|
63
|
+
if not self.selection.includes(relative, language):
|
|
64
|
+
return
|
|
65
|
+
content = _text_content(entry, self.selection.max_bytes)
|
|
66
|
+
if content is None:
|
|
67
|
+
self.skipped += 1
|
|
68
|
+
elif content.strip():
|
|
69
|
+
self.files[relative] = (relative, language, content)
|
|
70
|
+
|
|
71
|
+
def _relative(self, entry: Path) -> str:
|
|
72
|
+
return entry.relative_to(self.root).as_posix()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ignore_rules_in(directory: Path) -> list[tuple[Path, GitIgnoreSpec]]:
|
|
76
|
+
rules = []
|
|
77
|
+
for name in IGNORE_FILE_NAMES:
|
|
78
|
+
rule_file = directory / name
|
|
79
|
+
if rule_file.is_file() and not rule_file.is_symlink():
|
|
80
|
+
rules.append((directory, GitIgnoreSpec.from_lines(rule_file.read_text().splitlines())))
|
|
81
|
+
return rules
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _text_content(path: Path, max_bytes: int) -> str | None:
|
|
85
|
+
with path.open("rb") as stream:
|
|
86
|
+
data = stream.read(max_bytes + 1)
|
|
87
|
+
if len(data) > max_bytes or b"\0" in data:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return data.decode("utf-8")
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
return None
|