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.
Files changed (49) hide show
  1. {delphi_code-0.1.1 → delphi_code-0.2.0}/PKG-INFO +78 -6
  2. {delphi_code-0.1.1 → delphi_code-0.2.0}/README.md +76 -5
  3. delphi_code-0.2.0/delphi_code/__init__.py +3 -0
  4. delphi_code-0.2.0/delphi_code/cli.py +188 -0
  5. delphi_code-0.2.0/delphi_code/doctor.py +38 -0
  6. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/download.py +3 -1
  7. delphi_code-0.2.0/delphi_code/errors.py +27 -0
  8. delphi_code-0.2.0/delphi_code/files.py +92 -0
  9. delphi_code-0.2.0/delphi_code/hosts.py +344 -0
  10. delphi_code-0.2.0/delphi_code/indexing.py +109 -0
  11. delphi_code-0.2.0/delphi_code/keys.py +83 -0
  12. delphi_code-0.2.0/delphi_code/manifest.py +122 -0
  13. delphi_code-0.2.0/delphi_code/model.py +115 -0
  14. delphi_code-0.2.0/delphi_code/offline.py +31 -0
  15. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/paths.py +3 -0
  16. delphi_code-0.2.0/delphi_code/picker.py +135 -0
  17. delphi_code-0.2.0/delphi_code/projects.py +307 -0
  18. delphi_code-0.2.0/delphi_code/registry.py +119 -0
  19. delphi_code-0.2.0/delphi_code/selection.py +17 -0
  20. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/setup.py +50 -32
  21. delphi_code-0.2.0/delphi_code/sources.py +196 -0
  22. delphi_code-0.2.0/delphi_code/store.py +236 -0
  23. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/PKG-INFO +78 -6
  24. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/SOURCES.txt +16 -1
  25. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/requires.txt +1 -0
  26. {delphi_code-0.1.1 → delphi_code-0.2.0}/pyproject.toml +26 -1
  27. {delphi_code-0.1.1 → delphi_code-0.2.0}/tests/test_cross_project.py +25 -13
  28. delphi_code-0.2.0/tests/test_index_directory.py +92 -0
  29. {delphi_code-0.1.1 → delphi_code-0.2.0}/tests/test_model_defaults.py +12 -6
  30. delphi_code-0.2.0/tests/test_picker.py +370 -0
  31. delphi_code-0.2.0/tests/test_project_names.py +65 -0
  32. delphi_code-0.2.0/tests/test_registry.py +167 -0
  33. {delphi_code-0.1.1 → delphi_code-0.2.0}/tests/test_setup.py +29 -15
  34. delphi_code-0.2.0/tests/test_sources.py +258 -0
  35. delphi_code-0.1.1/delphi_code/__init__.py +0 -22
  36. delphi_code-0.1.1/delphi_code/cli.py +0 -282
  37. delphi_code-0.1.1/delphi_code/files.py +0 -64
  38. delphi_code-0.1.1/delphi_code/indexing.py +0 -78
  39. delphi_code-0.1.1/delphi_code/model.py +0 -70
  40. delphi_code-0.1.1/tests/test_index_directory.py +0 -36
  41. delphi_code-0.1.1/tests/test_project_names.py +0 -54
  42. {delphi_code-0.1.1 → delphi_code-0.2.0}/LICENSE +0 -0
  43. {delphi_code-0.1.1 → delphi_code-0.2.0}/THIRD_PARTY_NOTICES.md +0 -0
  44. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/__main__.py +0 -0
  45. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code/model_provenance.json +0 -0
  46. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/dependency_links.txt +0 -0
  47. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/entry_points.txt +0 -0
  48. {delphi_code-0.1.1 → delphi_code-0.2.0}/delphi_code.egg-info/top_level.txt +0 -0
  49. {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.1.1
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 myapp 'parse config'`. A bare name selects the uniquely matching indexed project; duplicate names produce an error listing their paths. Use `./name` or an absolute path to select a directory explicitly.
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 at `indexes/<sha256 of the resolved project path>/`, independent of the working directory. It holds CocoIndex's incremental state, the SQLite vector table, and a manifest recording the project path; command output includes this `index_directory`.
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
- - Moving a project changes its index identity.
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 myapp 'parse config'`. A bare name selects the uniquely matching indexed project; duplicate names produce an error listing their paths. Use `./name` or an absolute path to select a directory explicitly.
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 at `indexes/<sha256 of the resolved project path>/`, independent of the working directory. It holds CocoIndex's incremental state, the SQLite vector table, and a manifest recording the project path; command output includes this `index_directory`.
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
- - Moving a project changes its index identity.
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,3 @@
1
+ from .offline import enforce_offline_policy
2
+
3
+ enforce_offline_policy()
@@ -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"], revision=manifest["revision"], local_dir=destination,
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