webbee 0.6.18__tar.gz → 0.7.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.
- {webbee-0.6.18 → webbee-0.7.0}/CHANGELOG.md +37 -0
- {webbee-0.6.18 → webbee-0.7.0}/PKG-INFO +3 -3
- {webbee-0.6.18 → webbee-0.7.0}/pyproject.toml +3 -3
- webbee-0.7.0/src/webbee/__init__.py +1 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/service.py +318 -23
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/store.py +25 -7
- webbee-0.7.0/src/webbee/intel/tree.py +117 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/render.py +20 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/session.py +8 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tools.py +112 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/conftest.py +10 -0
- webbee-0.7.0/tests/test_intel_any_folder.py +97 -0
- webbee-0.7.0/tests/test_intel_checksum_tree.py +119 -0
- webbee-0.7.0/tests/test_remote_code_reads.py +47 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_render.py +7 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_session.py +22 -0
- webbee-0.6.18/src/webbee/__init__.py +0 -1
- {webbee-0.6.18 → webbee-0.7.0}/.github/workflows/publish.yml +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/.gitignore +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/LICENSE +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/README.md +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/install.sh +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/__main__.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/_launch.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/account.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/active_sessions.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/autoupdate.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/banner_art.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/boot.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/browser.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/checkpoints.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/cli.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/clipboard.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/clipboard_session.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/coding_context.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/commands.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/config.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/consent.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/details.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/events.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/frames.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/home.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/home_view.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/http.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/instance_lock.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/graph.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/outline.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/query.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/watch.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/media.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/modals.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/mode_store.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/native_files.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/native_read.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/output_pane.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/pricing_constants.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/queue_panel.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/reflow.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/remote.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/repl.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/repo.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/selection.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/sessions.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/sizing.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/slots.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/steer.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/stream.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tab_store.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tabs.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/thread.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tier_store.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/todo_panel.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tokens.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tui.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/update.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/urlopen.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/wallet.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/src/webbee/worktrees.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/__init__.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_account.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_active_sessions.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_autoupdate.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_checkpoints.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_checkpoints_nested_1030.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_cli.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_clipboard.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_clipboard_read.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_code_map_fresh.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_coding_context.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_commands.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_config.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_cpc_outline.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_cross_loop_token.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_details.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_events.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_freeze_fix.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_home.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_home_view.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_home_view_mouse.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_inspect_page.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_instance_lock.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_cache_and_sync_worker.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_chunker.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_embed.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_graph.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_query.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_service.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_store.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_vectors.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_watch.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_launch.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_marathon.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_modals_e2e.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_mode_and_remote_0_6_8.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_mode_store.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_native_files.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_newtab_mode.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_packaging.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_probe_cost.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_queue_manage_0337.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_reflow.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_repl.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_repo.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_resilient_http.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_review_fixes_1030.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_session_review_1030.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_sessions.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_sizing.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_slots.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_steer.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_stream.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tab_bar_stability.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tab_restore_0337.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tabs.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_terminal_ux_0336.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_thread.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tier_invalidation.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tier_store.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_todo_panel.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tokens.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tools.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tools_eol_cancel_1030.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tools_vision_websearch.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tui.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_tui_hardening.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_update.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_update_badge_0340.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_urlopen.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_version.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_version_badge_corner.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_wallet.py +0 -0
- {webbee-0.6.18 → webbee-0.7.0}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
* **The local index takes any folder and every file kind**: open webbee in a home folder, a folder of contracts or a
|
|
6
|
+
Kotlin app - code in 20+ languages, documents (DOCX, PDF, ODT, legacy DOC, Markdown, HTML), sheets (XLSX, CSV),
|
|
7
|
+
slides, mail (EML, MBOX) and image metadata are indexed. The folder you work in is indexed first, then what changed
|
|
8
|
+
in the last 30 days, then the rest; a big folder builds in a process pool.
|
|
9
|
+
* **Only code leaves the machine**: what the vault may get is unchanged - code and resolution configs. Documents, mail
|
|
10
|
+
and sheets stay in the local index.
|
|
11
|
+
* **Never read**: secrets by name, keychains, `.gnupg`, browser profiles, wallets, VPN and cloud credential files, iCloud
|
|
12
|
+
placeholders (reading one would download it) and app packages (`.photoslibrary`, `.app`). A secret written later
|
|
13
|
+
is never read either.
|
|
14
|
+
* **Changes found by checksums, not by rescanning**: every indexed folder has a checksum tree - one hash per directory
|
|
15
|
+
over the size and time of what is inside (`webbee.intel.tree`). A start from the cache and every refresh compare the
|
|
16
|
+
trees top down and skip an unchanged directory whole; a git repository is checked through git (HEAD, `git status`
|
|
17
|
+
and the stats of the files it names) with no walk at all. Measured on a folder of 7 repositories (1862 files): a
|
|
18
|
+
start from the cache 0.3 s, a refresh with nothing changed 0.17 s, one edited file found in 0.21 s comparing 19
|
|
19
|
+
directories. `IntelService.load_or_build()`, `refresh()`, `checksum()`.
|
|
20
|
+
* **One listing** for the build, the cache check and every refresh (`_index_listing`): a `.gitignore`'d file of a
|
|
21
|
+
repository is never read, links and anything but a regular file are skipped.
|
|
22
|
+
* The cache keeps each file's kind (a document was read back as code after a restart) and a folder without git is
|
|
23
|
+
cached too (it was indexed from scratch at every start). Cache schema 5.
|
|
24
|
+
* A stuck file never stops a build: documents and big sets are built in a process pool of at most 4 processes; a job
|
|
25
|
+
that runs past its time (or whose process dies) is killed and its files retried one by one, a file still stuck is
|
|
26
|
+
skipped and counted as `timeout` in `progress()`.
|
|
27
|
+
* **A server's code is read on the server**: `remote_read` (a file's lines, at most 2 MB, never a secret by its
|
|
28
|
+
name or where a link leads, never outside the repo) and `remote_grep` (`git grep` there, else `grep -r`; code files
|
|
29
|
+
only) over your own ssh (`BatchMode`, never a prompt). The vault holds the code index, never the code.
|
|
30
|
+
* **Login survives a non-JSON answer**: the device login's token poll crashed on a balancer's page
|
|
31
|
+
(`JSONDecodeError`); it now keeps polling (the fix lives in `imperal-mcp` 0.9.0, now the floor).
|
|
32
|
+
* Needs `imperal-sdk` 6.4.1 with its `universal-index` extra and `imperal-mcp` 0.9.0 (pulled in).
|
|
33
|
+
|
|
34
|
+
## 0.6.19
|
|
35
|
+
|
|
36
|
+
* **Another agent's message to a running session stays on screen**: it printed only as a queue-panel row that left the
|
|
37
|
+
moment the turn took it (seconds) -- Claude's follow-ups to a running marathon were never seen. It now also prints
|
|
38
|
+
one kept line, `✉ [agent] Claude (imperal-mcp): ...`, once per message.
|
|
39
|
+
|
|
3
40
|
## 0.6.18
|
|
4
41
|
|
|
5
42
|
* **A file is read whole -- up to 5000 lines**: a window asked of a file that fits answers with the whole file (the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: webbee
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
|
|
5
5
|
Project-URL: Homepage, https://imperal.io
|
|
6
6
|
Project-URL: Documentation, https://docs.imperal.io
|
|
@@ -31,8 +31,8 @@ Requires-Python: >=3.7
|
|
|
31
31
|
Requires-Dist: asyncssh>=2.14; python_version >= '3.10'
|
|
32
32
|
Requires-Dist: httpx-sse>=0.4; python_version >= '3.10'
|
|
33
33
|
Requires-Dist: httpx>=0.27; python_version >= '3.10'
|
|
34
|
-
Requires-Dist: imperal-mcp>=0.
|
|
35
|
-
Requires-Dist: imperal-sdk>=6.
|
|
34
|
+
Requires-Dist: imperal-mcp>=0.9.0; python_version >= '3.10'
|
|
35
|
+
Requires-Dist: imperal-sdk[universal-index]>=6.4.1; python_version >= '3.10'
|
|
36
36
|
Requires-Dist: model2vec>=0.8.2; python_version >= '3.10' and (sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13')
|
|
37
37
|
Requires-Dist: numpy>=2; python_version >= '3.10' and (sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13')
|
|
38
38
|
Requires-Dist: prompt-toolkit>=3; python_version >= '3.10'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "webbee"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "GPL-3.0-or-later"
|
|
@@ -13,8 +13,8 @@ dependencies = [
|
|
|
13
13
|
# release on a Python 3.12 it sets up once with uv -- `uv` comes as a wheel on 3.8+,
|
|
14
14
|
# 3.7 fetches it with uv's own installer.
|
|
15
15
|
'uv>=0.4; python_version >= "3.8" and python_version < "3.10"',
|
|
16
|
-
'imperal-mcp>=0.
|
|
17
|
-
'imperal-sdk>=6.
|
|
16
|
+
'imperal-mcp>=0.9.0; python_version >= "3.10"', # raised to the newest at each release
|
|
17
|
+
'imperal-sdk[universal-index]>=6.4.1; python_version >= "3.10"',
|
|
18
18
|
'asyncssh>=2.14; python_version >= "3.10"',
|
|
19
19
|
'httpx>=0.27; python_version >= "3.10"',
|
|
20
20
|
'httpx-sse>=0.4; python_version >= "3.10"',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
|
@@ -65,6 +65,9 @@ def _git_ref(root: str) -> str:
|
|
|
65
65
|
# marker below, which is a fact about the directory rather than a guess about
|
|
66
66
|
# its name.
|
|
67
67
|
_SKIP_DIRS = frozenset({"node_modules", "site-packages", "__pycache__"})
|
|
68
|
+
# macOS packages: an app's or a media library's insides, not the person's files (universal index, 2026-11-04)
|
|
69
|
+
_APP_PACKAGES = (".app", ".photoslibrary", ".musiclibrary", ".tvlibrary", ".photolibrary", ".aplibrary",
|
|
70
|
+
".migratedphotolibrary", ".imovielibrary", ".fcpbundle", ".logicx", ".band")
|
|
68
71
|
|
|
69
72
|
|
|
70
73
|
def _is_virtualenv(path: str) -> bool:
|
|
@@ -79,7 +82,8 @@ def skipped_dir(parent: str, name: str) -> bool:
|
|
|
79
82
|
"""The ONE rule for what the index never reads (the full walk and the watcher alike):
|
|
80
83
|
every dotted directory (.git, .next build output, .venv, caches, .webbee), the
|
|
81
84
|
dependency caches, and any virtualenv by its marker."""
|
|
82
|
-
return name.startswith(".") or name in _SKIP_DIRS or
|
|
85
|
+
return (name.startswith(".") or name in _SKIP_DIRS or name.endswith(_APP_PACKAGES)
|
|
86
|
+
or _is_virtualenv(os.path.join(parent, name)))
|
|
83
87
|
|
|
84
88
|
|
|
85
89
|
def ignored_path(root: str, path: str) -> bool:
|
|
@@ -129,9 +133,125 @@ def _walk(root: str, limit: int = 20000) -> list[str]:
|
|
|
129
133
|
return out
|
|
130
134
|
|
|
131
135
|
|
|
136
|
+
# ---- universal index (phase 2, 2026-11-04): any folder, every file kind, the focus first ----
|
|
137
|
+
UNIT_MAX_FILES = 100_000 # one index unit (spec 2026-11-03 §3.2); a bigger folder is split by imperal-mcp
|
|
138
|
+
BUILD_BATCH = 2_000 # files per published step of a first build: answers come before the whole folder
|
|
139
|
+
POOL_MIN_FILES = 500 # below this a build stays in-process (a pool's start costs more than it saves)
|
|
140
|
+
RECENT_S = 30 * 86400 # "changed lately" -- indexed right after the focus
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _git_listed(root: str) -> set | None:
|
|
144
|
+
"""The files git tracks or would track in ``root`` (``ls-files -co --exclude-standard``: a .gitignore'd file is
|
|
145
|
+
never read -- the same rule as the cloud listing), or None when ``root`` is no git repository / git cannot say."""
|
|
146
|
+
if not os.path.exists(os.path.join(root, ".git")):
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
r = subprocess.run(["git", "-C", root, "ls-files", "-co", "--exclude-standard", "-z"],
|
|
150
|
+
capture_output=True, timeout=60)
|
|
151
|
+
except (OSError, subprocess.SubprocessError):
|
|
152
|
+
return None
|
|
153
|
+
if r.returncode != 0:
|
|
154
|
+
return None
|
|
155
|
+
return {p.replace(os.sep, "/") for p in r.stdout.decode("utf-8", "surrogateescape").split("\0") if p}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _index_listing(root: str, focus: str | None = None, limit: int = UNIT_MAX_FILES,
|
|
159
|
+
exclude: tuple = ()) -> tuple[list, dict, dict]:
|
|
160
|
+
"""``(the folder's files the index reads, in priority order; {relpath: [mtime_ns, size]}; {"placeholder": n,
|
|
161
|
+
"over_cap": n})`` -- ONE listing for the first build, the cache check and every refresh. Order: the focus folder,
|
|
162
|
+
files changed in the last 30 days (newest first), then by path. Never read: what ``kind_of`` refuses by the
|
|
163
|
+
file's name and its real path (secrets, caches, dependency trees), a .gitignore'd file of a git repository,
|
|
164
|
+
anything but a regular file, and cloud placeholders (reading one downloads it). ``exclude``: root-relative
|
|
165
|
+
folders another unit indexes (a repository inside a collection), never walked here."""
|
|
166
|
+
from imperal_sdk.universal.index import is_placeholder
|
|
167
|
+
from imperal_sdk.universal.kinds import kind_of
|
|
168
|
+
import stat as _stat
|
|
169
|
+
found: list[tuple[str, float]] = []
|
|
170
|
+
stats: dict = {}
|
|
171
|
+
skipped = {"placeholder": 0, "over_cap": 0}
|
|
172
|
+
excluded = {str(e).strip("/") for e in exclude if str(e).strip("/")}
|
|
173
|
+
listed = _git_listed(root)
|
|
174
|
+
for dp, dns, fns in os.walk(root):
|
|
175
|
+
here = os.path.relpath(dp, root).replace(os.sep, "/")
|
|
176
|
+
dns[:] = sorted(d for d in dns if not skipped_dir(dp, d)
|
|
177
|
+
and (d if here == "." else f"{here}/{d}") not in excluded)
|
|
178
|
+
for fn in sorted(fns):
|
|
179
|
+
rel = fn if here == "." else f"{here}/{fn}"
|
|
180
|
+
if is_placeholder(rel):
|
|
181
|
+
skipped["placeholder"] += 1
|
|
182
|
+
continue
|
|
183
|
+
if listed is not None and rel not in listed:
|
|
184
|
+
continue
|
|
185
|
+
if kind_of(rel, root=root) is None:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
st = os.lstat(os.path.join(dp, fn))
|
|
189
|
+
except OSError:
|
|
190
|
+
continue
|
|
191
|
+
if not _stat.S_ISREG(st.st_mode): # a link, a FIFO, a socket: never read
|
|
192
|
+
continue
|
|
193
|
+
if is_placeholder(rel, st):
|
|
194
|
+
skipped["placeholder"] += 1
|
|
195
|
+
continue
|
|
196
|
+
if len(found) >= limit:
|
|
197
|
+
skipped["over_cap"] += 1
|
|
198
|
+
continue
|
|
199
|
+
found.append((rel, st.st_mtime))
|
|
200
|
+
stats[rel] = [st.st_mtime_ns, st.st_size]
|
|
201
|
+
prefix = (focus or "").strip("/")
|
|
202
|
+
now = time.time()
|
|
203
|
+
|
|
204
|
+
def rank(item):
|
|
205
|
+
rel, mtime = item
|
|
206
|
+
in_focus = bool(prefix) and (rel == prefix or rel.startswith(prefix + "/"))
|
|
207
|
+
recent = now - mtime <= RECENT_S
|
|
208
|
+
return (0 if in_focus else 1, 0 if recent else 1, -mtime if recent else 0.0, rel)
|
|
209
|
+
return [rel for rel, _m in sorted(found, key=rank)], stats, skipped
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _index_walk(root: str, focus: str | None = None, limit: int = UNIT_MAX_FILES,
|
|
213
|
+
exclude: tuple = ()) -> tuple[list[str], dict]:
|
|
214
|
+
"""``(files in priority order, skipped counts)`` -- ``_index_listing`` without the stats."""
|
|
215
|
+
rels, _stats, skipped = _index_listing(root, focus, limit, exclude)
|
|
216
|
+
return rels, skipped
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
POOL_MAX_WORKERS = 4 # parsing holds up to ~0.3 GB per job (imperal_sdk.universal.index.RAW_CAP)
|
|
220
|
+
POOL_CHUNK = 50 # files per pool job
|
|
221
|
+
CHUNK_TIMEOUT_S = 120.0 # one job; past it the job's files are retried one by one
|
|
222
|
+
FILE_TIMEOUT_S = 30.0 # one file; past it the file is skipped as ``timeout``
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _kill_pool(pool) -> None:
|
|
226
|
+
"""A pool with a stuck job: its processes killed (a hung extractor never ends by itself), the rest cancelled."""
|
|
227
|
+
for proc in list((getattr(pool, "_processes", None) or {}).values()):
|
|
228
|
+
try:
|
|
229
|
+
proc.kill()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
try:
|
|
233
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _build_chunk(root: str, rels: list) -> dict:
|
|
239
|
+
"""One process-pool job: these files' index entries (the pool's children run at a lower priority)."""
|
|
240
|
+
try:
|
|
241
|
+
os.nice(10)
|
|
242
|
+
except (AttributeError, OSError):
|
|
243
|
+
pass
|
|
244
|
+
return indexer.build_index(root, rels).files
|
|
245
|
+
|
|
246
|
+
|
|
132
247
|
class IntelService:
|
|
133
|
-
def __init__(self, root: str, repo_key: str, cache_dir: str = ""
|
|
248
|
+
def __init__(self, root: str, repo_key: str, cache_dir: str = "", focus: str | None = None,
|
|
249
|
+
exclude: tuple = ()) -> None:
|
|
134
250
|
self.root = os.path.realpath(root)
|
|
251
|
+
self.focus = focus
|
|
252
|
+
self.exclude = tuple(exclude)
|
|
253
|
+
self._progress = {"indexed": 0, "total": 0, "skipped": {"placeholder": 0, "over_cap": 0, "unread": 0},
|
|
254
|
+
"focus_done": False}
|
|
135
255
|
self.repo_key = repo_key
|
|
136
256
|
self.cache_dir = cache_dir or os.path.expanduser("~/.cache/webbee/intel")
|
|
137
257
|
self.index: ProjectIndex | None = None
|
|
@@ -157,6 +277,9 @@ class IntelService:
|
|
|
157
277
|
self.last_sync_error = "" # why the last sync failed; "" -- it did not
|
|
158
278
|
self.stale_reason = "" # why the index is not known fresh; "" -- it is (or not built)
|
|
159
279
|
self._stats: dict = {} # relpath -> [mtime_ns, size] of what the index holds, saved with it
|
|
280
|
+
self._tree: dict = {} # dir -> hash over self._stats (tree.dir_hashes); {} -- compute it again
|
|
281
|
+
self._checksum = "" # tree.repo_checksum at the last check ("" -- no git: walk)
|
|
282
|
+
self._pool_job = None # the pool job (None: _build_chunk); a test puts a stuck one here
|
|
160
283
|
self._vectors_dirty = False
|
|
161
284
|
self._save_guard = threading.Lock()
|
|
162
285
|
self._save_timer = None
|
|
@@ -200,18 +323,34 @@ class IntelService:
|
|
|
200
323
|
and uncommitted edits stayed invisible after a restart at that HEAD):
|
|
201
324
|
only the files whose mtime or size differ are re-parsed, files gone
|
|
202
325
|
are dropped, new ones added."""
|
|
326
|
+
touched, rebuilt = self.load_or_build()
|
|
327
|
+
self._embed_build(self.index, touched)
|
|
328
|
+
if rebuilt:
|
|
329
|
+
self._save_cache() # this index for the next start (a reconciled one too)
|
|
330
|
+
self.sync_vault() # code graph everywhere: the whole repo into the vault
|
|
331
|
+
|
|
332
|
+
def load_or_build(self) -> tuple[dict | None, bool]:
|
|
333
|
+
"""The index from the cache checked against the disk, else built: ``(touched, changed)`` -- ``touched`` the
|
|
334
|
+
files the check re-read or dropped (None after a full build), ``changed`` whether the index differs from the
|
|
335
|
+
cache (it should be saved). The check compares the checksum trees top down (``tree.changed_files``): a
|
|
336
|
+
directory whose hash is unchanged is skipped whole. A folder without git is cached too (``store.NO_GIT``)."""
|
|
337
|
+
from webbee.intel import tree
|
|
203
338
|
self.git_ref = _git_ref(self.root)
|
|
204
|
-
|
|
205
|
-
stats =
|
|
206
|
-
|
|
339
|
+
checksum = tree.repo_checksum(self.root)
|
|
340
|
+
walked, stats, skipped = _index_listing(self.root, self.focus, exclude=self.exclude)
|
|
341
|
+
self._progress = {"indexed": 0, "total": len(walked), "focus_done": False,
|
|
342
|
+
"skipped": {**skipped, "unread": 0, "timeout": 0}}
|
|
343
|
+
cached = store.load_with_stats(self.cache_dir, self.repo_key, self.git_ref)
|
|
344
|
+
new_tree = tree.dir_hashes(stats)
|
|
207
345
|
touched = None # None: a full index (no cache); else the files the disk changed
|
|
208
346
|
if cached is None:
|
|
209
|
-
idx =
|
|
347
|
+
idx = self._build_progressive(walked)
|
|
210
348
|
else:
|
|
211
349
|
idx, old_stats = cached
|
|
212
|
-
changed
|
|
213
|
-
|
|
214
|
-
|
|
350
|
+
changed, gone, _compared = tree.changed_files(old_stats, stats,
|
|
351
|
+
store.load_tree(self.cache_dir, self.repo_key), new_tree)
|
|
352
|
+
gone = sorted(set(gone) | (set(idx.files) - set(stats))) # a cached file no longer listed
|
|
353
|
+
reparsed = self._build_files(changed) if changed else {}
|
|
215
354
|
for rel in changed: # undecodable now: out of the index, as a full build leaves it
|
|
216
355
|
idx.files.pop(rel, None)
|
|
217
356
|
idx.files.update(reparsed)
|
|
@@ -221,14 +360,155 @@ class IntelService:
|
|
|
221
360
|
idx.git_ref = self.git_ref
|
|
222
361
|
with self._lock:
|
|
223
362
|
self._stats = stats
|
|
363
|
+
self._tree = new_tree
|
|
364
|
+
self._checksum = checksum
|
|
224
365
|
self.index = idx
|
|
225
366
|
self.graph = CodeGraph(idx)
|
|
367
|
+
self._progress.update(indexed=len(walked), focus_done=True) # every listed file processed (read or unread)
|
|
226
368
|
self.fresh = True
|
|
227
369
|
self.stale_reason = ""
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
370
|
+
return touched, cached is None or bool(touched)
|
|
371
|
+
|
|
372
|
+
def checksum(self) -> str:
|
|
373
|
+
"""The folder's checksum: the root of its checksum tree (``tree[""]``) -- equal checksums, equal listings."""
|
|
374
|
+
return (self._tree or {}).get("", "")
|
|
375
|
+
|
|
376
|
+
def refresh(self) -> dict:
|
|
377
|
+
"""The index catches up with the disk without a watcher: a git repository whose checksum (HEAD, status, the
|
|
378
|
+
stats of what it names) is unchanged answers at once, with no walk; else one listing, its checksum tree
|
|
379
|
+
compared top down with the index's, and only the changed files read. A file that left the listing but is
|
|
380
|
+
still on disk (now .gitignore'd, now named like a secret) leaves the index unread.
|
|
381
|
+
``{changed, gone, unlisted, compared}`` or ``{"unchanged": True}``."""
|
|
382
|
+
from webbee.intel import tree
|
|
383
|
+
if self.index is None:
|
|
384
|
+
return {}
|
|
385
|
+
checksum = tree.repo_checksum(self.root)
|
|
386
|
+
if checksum and checksum == self._checksum:
|
|
387
|
+
return {"unchanged": True}
|
|
388
|
+
walked, stats, _skipped = _index_listing(self.root, self.focus, exclude=self.exclude)
|
|
389
|
+
new_tree = tree.dir_hashes(stats)
|
|
390
|
+
with self._lock:
|
|
391
|
+
old_stats, old_tree = dict(self._stats), dict(self._tree or {})
|
|
392
|
+
changed, gone, compared = tree.changed_files(old_stats, stats, old_tree, new_tree)
|
|
393
|
+
with self._lock:
|
|
394
|
+
gone = sorted(set(gone) | (set(self.index.files) - set(stats)))
|
|
395
|
+
deleted = [rel for rel in gone if not os.path.lexists(os.path.join(self.root, rel))]
|
|
396
|
+
unlisted = [rel for rel in gone if rel not in set(deleted)]
|
|
397
|
+
if unlisted:
|
|
398
|
+
self.evict(unlisted)
|
|
399
|
+
if changed or deleted:
|
|
400
|
+
self.apply_changes(changed + deleted)
|
|
401
|
+
with self._lock:
|
|
402
|
+
self._stats = dict(stats)
|
|
403
|
+
self._tree = new_tree
|
|
404
|
+
self._checksum = checksum
|
|
405
|
+
self._progress["total"] = len(walked)
|
|
406
|
+
self._progress["indexed"] = len(walked)
|
|
407
|
+
if unlisted and not (changed or deleted):
|
|
408
|
+
self._save_soon()
|
|
409
|
+
return {"changed": len(changed), "gone": len(gone), "unlisted": len(unlisted), "compared": compared}
|
|
410
|
+
|
|
411
|
+
def evict(self, rels: list) -> None:
|
|
412
|
+
"""Files out of the index without a read (the lock and the graph rebuild apply_changes uses)."""
|
|
413
|
+
with self._lock:
|
|
414
|
+
for rel in rels:
|
|
415
|
+
if self.index is not None:
|
|
416
|
+
self.index.files.pop(rel, None)
|
|
417
|
+
self._stats.pop(rel, None)
|
|
418
|
+
self._tree = {}
|
|
419
|
+
if self.index is not None:
|
|
420
|
+
self.graph = CodeGraph(self.index)
|
|
421
|
+
|
|
422
|
+
def _build_files(self, rels: list) -> dict:
|
|
423
|
+
"""These files' index entries. A small set of code stays in-process; documents (any extractor can meet a
|
|
424
|
+
hostile file) and big sets go to a process pool whose stuck jobs are killed (``_build_in_pool``) -- one hung
|
|
425
|
+
file never stops the build (final review 2026-11-04)."""
|
|
426
|
+
if not rels:
|
|
427
|
+
return {}
|
|
428
|
+
from imperal_sdk.universal.kinds import kind_of
|
|
429
|
+
if len(rels) < POOL_MIN_FILES and all(kind_of(r) in ("code", "config") for r in rels):
|
|
430
|
+
return indexer.build_index(self.root, rels).files
|
|
431
|
+
try:
|
|
432
|
+
return self._build_in_pool(rels)
|
|
433
|
+
except Exception as exc: # no pool here (a sandbox, a frozen app): the same work in-process
|
|
434
|
+
log.info("index pool unavailable, building in-process: %s", exc)
|
|
435
|
+
return indexer.build_index(self.root, rels).files
|
|
436
|
+
|
|
437
|
+
def _build_in_pool(self, rels: list) -> dict:
|
|
438
|
+
"""Jobs of ``POOL_CHUNK`` files on at most ``POOL_MAX_WORKERS`` processes; a job past ``CHUNK_TIMEOUT_S`` (or
|
|
439
|
+
whose process died) has its pool killed and its files retried one per job; a single file past
|
|
440
|
+
``FILE_TIMEOUT_S`` is skipped as ``timeout`` (counted in ``progress``)."""
|
|
441
|
+
workers = max(1, min(POOL_MAX_WORKERS, (os.cpu_count() or 2) // 2))
|
|
442
|
+
size = POOL_CHUNK if len(rels) >= POOL_CHUNK * workers else max(1, -(-len(rels) // workers))
|
|
443
|
+
out: dict = {}
|
|
444
|
+
hung = self._run_pool([rels[i:i + size] for i in range(0, len(rels), size)], workers, CHUNK_TIMEOUT_S, out)
|
|
445
|
+
if hung:
|
|
446
|
+
still = self._run_pool([[r] for part in hung for r in part], workers, FILE_TIMEOUT_S, out)
|
|
447
|
+
for part in still:
|
|
448
|
+
log.warning("index: %s did not finish in %ss -- skipped", part[0], FILE_TIMEOUT_S)
|
|
449
|
+
self._progress.setdefault("skipped", {}).setdefault("timeout", 0)
|
|
450
|
+
self._progress["skipped"]["timeout"] += 1
|
|
451
|
+
return out
|
|
452
|
+
|
|
453
|
+
def _run_pool(self, parts: list, workers: int, timeout: float, out: dict) -> list:
|
|
454
|
+
"""Run ``parts`` in waves of ``workers`` jobs (each job's clock starts with it); the parts that did not end
|
|
455
|
+
in ``timeout`` or whose process died. A pool that cannot start raises (the caller builds in-process)."""
|
|
456
|
+
from concurrent.futures import ProcessPoolExecutor, wait
|
|
457
|
+
hung: list = []
|
|
458
|
+
pending = list(parts)
|
|
459
|
+
pool = None
|
|
460
|
+
try:
|
|
461
|
+
while pending:
|
|
462
|
+
if pool is None:
|
|
463
|
+
pool = ProcessPoolExecutor(max_workers=workers)
|
|
464
|
+
wave, pending = pending[:workers], pending[workers:]
|
|
465
|
+
job = getattr(self, "_pool_job", None) or _build_chunk # tests put a stuck job here
|
|
466
|
+
futs = {pool.submit(job, self.root, part): part for part in wave}
|
|
467
|
+
done, not_done = wait(futs, timeout=timeout)
|
|
468
|
+
broken = False
|
|
469
|
+
for f in done:
|
|
470
|
+
try:
|
|
471
|
+
out.update(f.result())
|
|
472
|
+
except Exception: # the job's process died (a crash, out of memory): its files are retried
|
|
473
|
+
hung.append(futs[f])
|
|
474
|
+
broken = True
|
|
475
|
+
hung.extend(futs[f] for f in not_done)
|
|
476
|
+
if not_done or broken:
|
|
477
|
+
_kill_pool(pool)
|
|
478
|
+
pool = None
|
|
479
|
+
finally:
|
|
480
|
+
if pool is not None:
|
|
481
|
+
pool.shutdown(wait=True)
|
|
482
|
+
return hung
|
|
483
|
+
|
|
484
|
+
def _build_progressive(self, walked: list) -> ProjectIndex:
|
|
485
|
+
"""A first build in published steps: the focus folder first, then ``BUILD_BATCH`` files at a time -- the index
|
|
486
|
+
answers (and says how far it is: ``progress``) long before a home folder is done."""
|
|
487
|
+
idx = ProjectIndex(git_ref=self.git_ref)
|
|
488
|
+
prefix = (self.focus or "").strip("/")
|
|
489
|
+
n_focus = sum(1 for r in walked if prefix and (r == prefix or r.startswith(prefix + "/")))
|
|
490
|
+
steps = ([walked[:n_focus]] if n_focus else []) + [walked[i:i + BUILD_BATCH]
|
|
491
|
+
for i in range(n_focus, len(walked), BUILD_BATCH)]
|
|
492
|
+
done = 0
|
|
493
|
+
for k, step in enumerate(steps):
|
|
494
|
+
files = self._build_files(step)
|
|
495
|
+
done += len(step)
|
|
496
|
+
idx.files.update(files)
|
|
497
|
+
self._progress["skipped"]["unread"] += len(step) - len(files)
|
|
498
|
+
self._progress["indexed"] = done # processed so far; ``skipped.unread`` says how many were not readable
|
|
499
|
+
if done >= n_focus:
|
|
500
|
+
self._progress["focus_done"] = True
|
|
501
|
+
if k == 0 or k % 5 == 0 or k == len(steps) - 1:
|
|
502
|
+
with self._lock: # published: search and outline see what is built so far
|
|
503
|
+
self.index = idx
|
|
504
|
+
self.graph = CodeGraph(idx)
|
|
505
|
+
return idx
|
|
506
|
+
|
|
507
|
+
def progress(self) -> dict:
|
|
508
|
+
"""How far the index is: ``{indexed, total, skipped: {placeholder, over_cap, unread}, focus_done}`` --
|
|
509
|
+
``indexed`` counts the listed files processed (``unread`` of them could not be read: encrypted, broken, over
|
|
510
|
+
their cap); ``indexed == total`` means the build is done."""
|
|
511
|
+
return {**self._progress, "skipped": dict(self._progress["skipped"])}
|
|
232
512
|
|
|
233
513
|
def _embed_build(self, idx: ProjectIndex, touched: dict | None = None) -> None:
|
|
234
514
|
# Off-loop already (build() is called via asyncio.to_thread from the
|
|
@@ -328,6 +608,7 @@ class IntelService:
|
|
|
328
608
|
return
|
|
329
609
|
updates = {}
|
|
330
610
|
stats = {}
|
|
611
|
+
to_read: list = []
|
|
331
612
|
for rel in rel_paths:
|
|
332
613
|
ap = os.path.join(self.root, rel)
|
|
333
614
|
if not os.path.exists(ap):
|
|
@@ -336,14 +617,20 @@ class IntelService:
|
|
|
336
617
|
continue
|
|
337
618
|
try:
|
|
338
619
|
st = os.stat(ap) # before the read: an edit after it differs at the next start
|
|
339
|
-
with open(ap, "r", encoding="utf-8") as f:
|
|
340
|
-
fi = indexer.parse_file(rel, f.read())
|
|
341
|
-
except UnicodeDecodeError:
|
|
342
|
-
fi = None # not text any more: out of the index, as a full build leaves it
|
|
343
620
|
except OSError:
|
|
344
621
|
continue
|
|
345
|
-
|
|
622
|
+
from imperal_sdk.universal.index import is_placeholder
|
|
623
|
+
from imperal_sdk.universal.kinds import kind_of
|
|
624
|
+
if kind_of(rel, root=self.root) is None or is_placeholder(rel, st):
|
|
625
|
+
updates[rel] = None # the same gates as the walk: never read, out of the index
|
|
626
|
+
stats[rel] = None
|
|
627
|
+
continue
|
|
628
|
+
to_read.append(rel)
|
|
346
629
|
stats[rel] = [st.st_mtime_ns, st.st_size]
|
|
630
|
+
# the same reading as a build (every kind through its view, a hung file killed); not readable now: out
|
|
631
|
+
read = self._build_files(to_read) if to_read else {}
|
|
632
|
+
for rel in to_read:
|
|
633
|
+
updates[rel] = read.get(rel)
|
|
347
634
|
|
|
348
635
|
# Embed compute (chunk + backend inference) stays off-lock, mirroring
|
|
349
636
|
# the parse-then-swap pattern above -- only the vector-store mutation
|
|
@@ -373,6 +660,7 @@ class IntelService:
|
|
|
373
660
|
self.graph = CodeGraph(self.index)
|
|
374
661
|
if embedded is not None:
|
|
375
662
|
self._swap_vectors(updates, *embedded)
|
|
663
|
+
self._tree = {} # the stats moved: the tree is computed again at the next check
|
|
376
664
|
if updates:
|
|
377
665
|
self._save_soon()
|
|
378
666
|
|
|
@@ -381,8 +669,6 @@ class IntelService:
|
|
|
381
669
|
def _save_soon(self) -> None:
|
|
382
670
|
"""Save the cache ``SAVE_DEBOUNCE_S`` after the first unsaved change -- never once per change (a branch
|
|
383
671
|
switch is one write); not pushed back by later changes, so a session that edits without pause still saves."""
|
|
384
|
-
if not self.git_ref:
|
|
385
|
-
return
|
|
386
672
|
with self._save_guard:
|
|
387
673
|
if self._save_timer is not None:
|
|
388
674
|
return
|
|
@@ -407,18 +693,22 @@ class IntelService:
|
|
|
407
693
|
|
|
408
694
|
def _save_cache(self) -> None:
|
|
409
695
|
"""The index, the stats of its files and (when they changed) the vectors, into the cache. Fail-soft."""
|
|
410
|
-
if self.index is None
|
|
696
|
+
if self.index is None:
|
|
411
697
|
return
|
|
698
|
+
from webbee.intel import tree
|
|
412
699
|
with self._lock:
|
|
413
700
|
snap = ProjectIndex(git_ref=self.git_ref)
|
|
414
701
|
snap.files = dict(self.index.files)
|
|
415
702
|
stats = dict(self._stats)
|
|
703
|
+
if not self._tree:
|
|
704
|
+
self._tree = tree.dir_hashes(stats)
|
|
705
|
+
dirs = dict(self._tree)
|
|
416
706
|
vecs = None
|
|
417
707
|
if self._vectors_dirty and self.vectors is not None:
|
|
418
708
|
vecs = self.vectors.to_arrays()
|
|
419
709
|
self._vectors_dirty = False
|
|
420
710
|
try:
|
|
421
|
-
store.save(self.cache_dir, self.repo_key, snap, stats)
|
|
711
|
+
store.save(self.cache_dir, self.repo_key, snap, stats, dirs)
|
|
422
712
|
b = self._get_backend() if vecs is not None else None
|
|
423
713
|
if b is not None:
|
|
424
714
|
store.save_vectors(self.cache_dir, self.repo_key, snap.git_ref, b.model_id, *vecs)
|
|
@@ -643,8 +933,11 @@ class IntelService:
|
|
|
643
933
|
# snapshot -- never re-touch the live dict, which apply_changes (run
|
|
644
934
|
# off-loop by the watcher, F3) may be resizing concurrently.
|
|
645
935
|
with self._lock:
|
|
646
|
-
|
|
936
|
+
every = list((self.index or ProjectIndex()).files.values())
|
|
647
937
|
embedded_chunks = len(self.vectors.ids()) if self.vectors else 0
|
|
938
|
+
# the profile is the map of the CODE: a summary that leaves this process never names a document's
|
|
939
|
+
# sections (security review 2026-11-04); other kinds are only counted
|
|
940
|
+
files = [fi for fi in every if getattr(fi, "kind", "code") in ("code", "config")]
|
|
648
941
|
vectors_ready = self.vectors_ready
|
|
649
942
|
content_hashes = sorted(fi.content_hash for fi in files if fi.content_hash)
|
|
650
943
|
endpoints = [e for fi in files for e in fi.endpoints]
|
|
@@ -662,6 +955,8 @@ class IntelService:
|
|
|
662
955
|
"evidence_version": 1,
|
|
663
956
|
"content_digest": digest,
|
|
664
957
|
"file_count": len(files),
|
|
958
|
+
"other_kinds": dict(Counter(getattr(fi, "kind", "code") for fi in every
|
|
959
|
+
if getattr(fi, "kind", "code") not in ("code", "config"))),
|
|
665
960
|
"languages": dict(langs),
|
|
666
961
|
"symbol_kinds": dict(kinds),
|
|
667
962
|
"top_symbols": top, # capped at _MAX_PROFILE_SAMPLE
|
|
@@ -23,20 +23,27 @@ from imperal_sdk.code_index import ProjectIndex, FileIndex, Symbol
|
|
|
23
23
|
# schema 2 holds the OLD id format, so both the index and the vector cache
|
|
24
24
|
# must be treated as a clean miss and rebuilt -- bumping the version is what
|
|
25
25
|
# makes load()/load_vectors() do exactly that.
|
|
26
|
-
|
|
26
|
+
# 5 (2026-11-04): a file's ``kind`` (code | config | document | data | mail | image) is kept -- a schema-4 cache
|
|
27
|
+
# read every document back as code after a restart; a folder without git is cached too (``NO_GIT``) and the
|
|
28
|
+
# checksum tree of the folder (``tree``) is saved with the stats.
|
|
29
|
+
SCHEMA_VERSION = 5
|
|
30
|
+
NO_GIT = "-" # the cache key of a folder that is no git repository (its stats keep it honest)
|
|
27
31
|
|
|
28
32
|
|
|
29
33
|
def _path(cache_dir: str, repo_key: str) -> str:
|
|
30
34
|
return os.path.join(cache_dir, repo_key, "index.json")
|
|
31
35
|
|
|
32
36
|
|
|
33
|
-
def save(cache_dir: str, repo_key: str, index: ProjectIndex, stats: dict | None = None
|
|
37
|
+
def save(cache_dir: str, repo_key: str, index: ProjectIndex, stats: dict | None = None,
|
|
38
|
+
tree: dict | None = None) -> None:
|
|
34
39
|
"""``stats`` -- ``{relpath: [mtime_ns, size]}`` of every file the walk saw when the index was taken: the next
|
|
35
40
|
start re-parses only the files whose stat differs (edits made while Webbee was closed, uncommitted edits)."""
|
|
36
41
|
p = _path(cache_dir, repo_key)
|
|
37
42
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
38
|
-
data = {"schema_version": SCHEMA_VERSION, "git_ref": index.git_ref, "stats": stats or {},
|
|
39
|
-
"
|
|
43
|
+
data = {"schema_version": SCHEMA_VERSION, "git_ref": index.git_ref or NO_GIT, "stats": stats or {},
|
|
44
|
+
"tree": tree or {},
|
|
45
|
+
"files": {k: {"path": v.path, "lang": v.lang, "kind": getattr(v, "kind", "code"), "imports": v.imports,
|
|
46
|
+
"refs": v.refs,
|
|
40
47
|
"content_hash": v.content_hash, "endpoints": v.endpoints, "schemas": v.schemas,
|
|
41
48
|
"symbols": [asdict(s) for s in v.symbols]} for k, v in index.files.items()}}
|
|
42
49
|
tmp = p + ".tmp"
|
|
@@ -50,6 +57,16 @@ def load(cache_dir: str, repo_key: str, git_ref: str) -> ProjectIndex | None:
|
|
|
50
57
|
return got[0] if got is not None else None
|
|
51
58
|
|
|
52
59
|
|
|
60
|
+
def load_tree(cache_dir: str, repo_key: str) -> dict:
|
|
61
|
+
"""The checksum tree saved with the index (``{dir: hash}``), or ``{}``."""
|
|
62
|
+
try:
|
|
63
|
+
with open(_path(cache_dir, repo_key), "r", encoding="utf-8") as f:
|
|
64
|
+
tree = json.load(f).get("tree") or {}
|
|
65
|
+
return {str(k): str(v) for k, v in tree.items()} if isinstance(tree, dict) else {}
|
|
66
|
+
except Exception:
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
|
|
53
70
|
def load_with_stats(cache_dir: str, repo_key: str, git_ref: str) -> tuple[ProjectIndex, dict] | None:
|
|
54
71
|
"""``(index, {relpath: [mtime_ns, size]})`` saved for this HEAD, or None. A cache from before the stats were kept
|
|
55
72
|
gives ``{}``: every file then counts as changed and is re-parsed once."""
|
|
@@ -61,16 +78,17 @@ def load_with_stats(cache_dir: str, repo_key: str, git_ref: str) -> tuple[Projec
|
|
|
61
78
|
try:
|
|
62
79
|
with open(_path(cache_dir, repo_key), "r", encoding="utf-8") as f:
|
|
63
80
|
data = json.load(f)
|
|
64
|
-
if data.get("git_ref") != git_ref:
|
|
81
|
+
if data.get("git_ref") != (git_ref or NO_GIT):
|
|
65
82
|
return None # stale cache -- the caller falls back to a full re-index
|
|
66
83
|
if data.get("schema_version") != SCHEMA_VERSION:
|
|
67
84
|
return None # unknown/old on-disk shape -- clean miss, re-index
|
|
68
|
-
idx = ProjectIndex(git_ref=
|
|
85
|
+
idx = ProjectIndex(git_ref=git_ref or "")
|
|
69
86
|
for k, v in (data.get("files") or {}).items():
|
|
70
87
|
idx.files[k] = FileIndex(path=v["path"], lang=v["lang"],
|
|
71
88
|
imports=v.get("imports", []), refs=v.get("refs", []),
|
|
72
89
|
content_hash=v.get("content_hash", ""), endpoints=v.get("endpoints", []),
|
|
73
|
-
schemas=v.get("schemas", []), symbols=[Symbol(**s) for s in v.get("symbols", [])]
|
|
90
|
+
schemas=v.get("schemas", []), symbols=[Symbol(**s) for s in v.get("symbols", [])],
|
|
91
|
+
kind=v.get("kind") or "code")
|
|
74
92
|
stats = {k: list(v) for k, v in (data.get("stats") or {}).items()
|
|
75
93
|
if isinstance(v, list) and len(v) == 2}
|
|
76
94
|
return idx, stats
|