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.
Files changed (159) hide show
  1. {webbee-0.6.18 → webbee-0.7.0}/CHANGELOG.md +37 -0
  2. {webbee-0.6.18 → webbee-0.7.0}/PKG-INFO +3 -3
  3. {webbee-0.6.18 → webbee-0.7.0}/pyproject.toml +3 -3
  4. webbee-0.7.0/src/webbee/__init__.py +1 -0
  5. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/service.py +318 -23
  6. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/store.py +25 -7
  7. webbee-0.7.0/src/webbee/intel/tree.py +117 -0
  8. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/render.py +20 -0
  9. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/session.py +8 -0
  10. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tools.py +112 -0
  11. {webbee-0.6.18 → webbee-0.7.0}/tests/conftest.py +10 -0
  12. webbee-0.7.0/tests/test_intel_any_folder.py +97 -0
  13. webbee-0.7.0/tests/test_intel_checksum_tree.py +119 -0
  14. webbee-0.7.0/tests/test_remote_code_reads.py +47 -0
  15. {webbee-0.6.18 → webbee-0.7.0}/tests/test_render.py +7 -0
  16. {webbee-0.6.18 → webbee-0.7.0}/tests/test_session.py +22 -0
  17. webbee-0.6.18/src/webbee/__init__.py +0 -1
  18. {webbee-0.6.18 → webbee-0.7.0}/.github/workflows/publish.yml +0 -0
  19. {webbee-0.6.18 → webbee-0.7.0}/.gitignore +0 -0
  20. {webbee-0.6.18 → webbee-0.7.0}/LICENSE +0 -0
  21. {webbee-0.6.18 → webbee-0.7.0}/README.md +0 -0
  22. {webbee-0.6.18 → webbee-0.7.0}/install.sh +0 -0
  23. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/__main__.py +0 -0
  24. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/_launch.py +0 -0
  25. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/account.py +0 -0
  26. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/active_sessions.py +0 -0
  27. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/autoupdate.py +0 -0
  28. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/banner_art.py +0 -0
  29. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/boot.py +0 -0
  30. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/browser.py +0 -0
  31. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/checkpoints.py +0 -0
  32. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/cli.py +0 -0
  33. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/clipboard.py +0 -0
  34. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/clipboard_read.py +0 -0
  35. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/clipboard_session.py +0 -0
  36. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/coding_context.py +0 -0
  37. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/commands.py +0 -0
  38. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/config.py +0 -0
  39. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/consent.py +0 -0
  40. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/details.py +0 -0
  41. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/events.py +0 -0
  42. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/frames.py +0 -0
  43. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/home.py +0 -0
  44. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/home_view.py +0 -0
  45. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/http.py +0 -0
  46. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/instance_lock.py +0 -0
  47. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/__init__.py +0 -0
  48. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/graph.py +0 -0
  49. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/outline.py +0 -0
  50. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/query.py +0 -0
  51. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/vectors.py +0 -0
  52. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/intel/watch.py +0 -0
  53. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/media.py +0 -0
  54. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/modals.py +0 -0
  55. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/mode_store.py +0 -0
  56. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/native_files.py +0 -0
  57. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/native_read.py +0 -0
  58. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/newtab_mode.py +0 -0
  59. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/output_pane.py +0 -0
  60. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/pricing_constants.py +0 -0
  61. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/queue_panel.py +0 -0
  62. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/reflow.py +0 -0
  63. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/remote.py +0 -0
  64. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/repl.py +0 -0
  65. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/repo.py +0 -0
  66. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/selection.py +0 -0
  67. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/sessions.py +0 -0
  68. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/sizing.py +0 -0
  69. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/slots.py +0 -0
  70. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/steer.py +0 -0
  71. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/stream.py +0 -0
  72. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tab_store.py +0 -0
  73. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tabs.py +0 -0
  74. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/thread.py +0 -0
  75. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tier_store.py +0 -0
  76. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/todo_panel.py +0 -0
  77. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tokens.py +0 -0
  78. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/tui.py +0 -0
  79. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/update.py +0 -0
  80. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/urlopen.py +0 -0
  81. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/wallet.py +0 -0
  82. {webbee-0.6.18 → webbee-0.7.0}/src/webbee/worktrees.py +0 -0
  83. {webbee-0.6.18 → webbee-0.7.0}/tests/__init__.py +0 -0
  84. {webbee-0.6.18 → webbee-0.7.0}/tests/test_account.py +0 -0
  85. {webbee-0.6.18 → webbee-0.7.0}/tests/test_active_sessions.py +0 -0
  86. {webbee-0.6.18 → webbee-0.7.0}/tests/test_audit_2026_07_25_findings.py +0 -0
  87. {webbee-0.6.18 → webbee-0.7.0}/tests/test_autoupdate.py +0 -0
  88. {webbee-0.6.18 → webbee-0.7.0}/tests/test_checkpoints.py +0 -0
  89. {webbee-0.6.18 → webbee-0.7.0}/tests/test_checkpoints_nested_1030.py +0 -0
  90. {webbee-0.6.18 → webbee-0.7.0}/tests/test_cli.py +0 -0
  91. {webbee-0.6.18 → webbee-0.7.0}/tests/test_clipboard.py +0 -0
  92. {webbee-0.6.18 → webbee-0.7.0}/tests/test_clipboard_read.py +0 -0
  93. {webbee-0.6.18 → webbee-0.7.0}/tests/test_code_map_fresh.py +0 -0
  94. {webbee-0.6.18 → webbee-0.7.0}/tests/test_coding_context.py +0 -0
  95. {webbee-0.6.18 → webbee-0.7.0}/tests/test_commands.py +0 -0
  96. {webbee-0.6.18 → webbee-0.7.0}/tests/test_config.py +0 -0
  97. {webbee-0.6.18 → webbee-0.7.0}/tests/test_cpc_contract_stable.py +0 -0
  98. {webbee-0.6.18 → webbee-0.7.0}/tests/test_cpc_outline.py +0 -0
  99. {webbee-0.6.18 → webbee-0.7.0}/tests/test_cross_loop_token.py +0 -0
  100. {webbee-0.6.18 → webbee-0.7.0}/tests/test_details.py +0 -0
  101. {webbee-0.6.18 → webbee-0.7.0}/tests/test_events.py +0 -0
  102. {webbee-0.6.18 → webbee-0.7.0}/tests/test_freeze_fix.py +0 -0
  103. {webbee-0.6.18 → webbee-0.7.0}/tests/test_home.py +0 -0
  104. {webbee-0.6.18 → webbee-0.7.0}/tests/test_home_view.py +0 -0
  105. {webbee-0.6.18 → webbee-0.7.0}/tests/test_home_view_mouse.py +0 -0
  106. {webbee-0.6.18 → webbee-0.7.0}/tests/test_inspect_page.py +0 -0
  107. {webbee-0.6.18 → webbee-0.7.0}/tests/test_instance_lock.py +0 -0
  108. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_cache_and_sync_worker.py +0 -0
  109. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_chunker.py +0 -0
  110. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_embed.py +0 -0
  111. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_graph.py +0 -0
  112. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_no_numpy.py +0 -0
  113. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_query.py +0 -0
  114. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_service.py +0 -0
  115. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_store.py +0 -0
  116. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_vectors.py +0 -0
  117. {webbee-0.6.18 → webbee-0.7.0}/tests/test_intel_watch.py +0 -0
  118. {webbee-0.6.18 → webbee-0.7.0}/tests/test_launch.py +0 -0
  119. {webbee-0.6.18 → webbee-0.7.0}/tests/test_marathon.py +0 -0
  120. {webbee-0.6.18 → webbee-0.7.0}/tests/test_modals_e2e.py +0 -0
  121. {webbee-0.6.18 → webbee-0.7.0}/tests/test_mode_and_remote_0_6_8.py +0 -0
  122. {webbee-0.6.18 → webbee-0.7.0}/tests/test_mode_store.py +0 -0
  123. {webbee-0.6.18 → webbee-0.7.0}/tests/test_native_files.py +0 -0
  124. {webbee-0.6.18 → webbee-0.7.0}/tests/test_newtab_mode.py +0 -0
  125. {webbee-0.6.18 → webbee-0.7.0}/tests/test_packaging.py +0 -0
  126. {webbee-0.6.18 → webbee-0.7.0}/tests/test_probe_cost.py +0 -0
  127. {webbee-0.6.18 → webbee-0.7.0}/tests/test_queue_manage_0337.py +0 -0
  128. {webbee-0.6.18 → webbee-0.7.0}/tests/test_reflow.py +0 -0
  129. {webbee-0.6.18 → webbee-0.7.0}/tests/test_repl.py +0 -0
  130. {webbee-0.6.18 → webbee-0.7.0}/tests/test_repo.py +0 -0
  131. {webbee-0.6.18 → webbee-0.7.0}/tests/test_resilient_http.py +0 -0
  132. {webbee-0.6.18 → webbee-0.7.0}/tests/test_review_fixes_1030.py +0 -0
  133. {webbee-0.6.18 → webbee-0.7.0}/tests/test_session_review_1030.py +0 -0
  134. {webbee-0.6.18 → webbee-0.7.0}/tests/test_sessions.py +0 -0
  135. {webbee-0.6.18 → webbee-0.7.0}/tests/test_sizing.py +0 -0
  136. {webbee-0.6.18 → webbee-0.7.0}/tests/test_slots.py +0 -0
  137. {webbee-0.6.18 → webbee-0.7.0}/tests/test_steer.py +0 -0
  138. {webbee-0.6.18 → webbee-0.7.0}/tests/test_stream.py +0 -0
  139. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tab_bar_stability.py +0 -0
  140. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tab_restore_0337.py +0 -0
  141. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tabs.py +0 -0
  142. {webbee-0.6.18 → webbee-0.7.0}/tests/test_terminal_ux_0336.py +0 -0
  143. {webbee-0.6.18 → webbee-0.7.0}/tests/test_thread.py +0 -0
  144. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tier_invalidation.py +0 -0
  145. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tier_store.py +0 -0
  146. {webbee-0.6.18 → webbee-0.7.0}/tests/test_todo_panel.py +0 -0
  147. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tokens.py +0 -0
  148. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tools.py +0 -0
  149. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tools_eol_cancel_1030.py +0 -0
  150. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tools_vision_websearch.py +0 -0
  151. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tui.py +0 -0
  152. {webbee-0.6.18 → webbee-0.7.0}/tests/test_tui_hardening.py +0 -0
  153. {webbee-0.6.18 → webbee-0.7.0}/tests/test_update.py +0 -0
  154. {webbee-0.6.18 → webbee-0.7.0}/tests/test_update_badge_0340.py +0 -0
  155. {webbee-0.6.18 → webbee-0.7.0}/tests/test_urlopen.py +0 -0
  156. {webbee-0.6.18 → webbee-0.7.0}/tests/test_version.py +0 -0
  157. {webbee-0.6.18 → webbee-0.7.0}/tests/test_version_badge_corner.py +0 -0
  158. {webbee-0.6.18 → webbee-0.7.0}/tests/test_wallet.py +0 -0
  159. {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.6.18
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.8.12; python_version >= '3.10'
35
- Requires-Dist: imperal-sdk>=6.3.6; python_version >= '3.10'
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.6.18"
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.8.12; python_version >= "3.10"', # raised to the newest at each release
17
- 'imperal-sdk>=6.3.6; python_version >= "3.10"',
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 _is_virtualenv(os.path.join(parent, name))
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 = "") -> None:
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
- walked = _walk(self.root)
205
- stats = _stats_of(self.root, walked)
206
- cached = store.load_with_stats(self.cache_dir, self.repo_key, self.git_ref) if self.git_ref else None
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 = indexer.build_index(self.root, walked)
347
+ idx = self._build_progressive(walked)
210
348
  else:
211
349
  idx, old_stats = cached
212
- changed = [r for r in walked if r in stats and old_stats.get(r) != stats[r]]
213
- gone = sorted((set(idx.files) | set(old_stats)) - set(stats))
214
- reparsed = indexer.build_index(self.root, changed).files if changed else {}
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
- self._embed_build(idx, touched)
229
- if self.git_ref and (cached is None or touched):
230
- self._save_cache() # this index for the next start (a reconciled one too)
231
- self.sync_vault() # code graph everywhere: the whole repo into the vault
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
- updates[rel] = fi
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 or not self.git_ref:
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
- files = list((self.index or ProjectIndex()).files.values())
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
- SCHEMA_VERSION = 4
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) -> 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
- "files": {k: {"path": v.path, "lang": v.lang, "imports": v.imports, "refs": v.refs,
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=data.get("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