webbee 0.7.2__tar.gz → 0.7.3__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 (160) hide show
  1. {webbee-0.7.2 → webbee-0.7.3}/CHANGELOG.md +19 -0
  2. {webbee-0.7.2 → webbee-0.7.3}/PKG-INFO +3 -3
  3. {webbee-0.7.2 → webbee-0.7.3}/pyproject.toml +3 -3
  4. webbee-0.7.3/src/webbee/__init__.py +1 -0
  5. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/query.py +37 -7
  6. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/service.py +113 -26
  7. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/tools.py +5 -2
  8. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_cache_and_sync_worker.py +1 -1
  9. webbee-0.7.3/tests/test_intel_folder_1106.py +109 -0
  10. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tools.py +8 -1
  11. webbee-0.7.2/src/webbee/__init__.py +0 -1
  12. {webbee-0.7.2 → webbee-0.7.3}/.github/workflows/publish.yml +0 -0
  13. {webbee-0.7.2 → webbee-0.7.3}/.gitignore +0 -0
  14. {webbee-0.7.2 → webbee-0.7.3}/LICENSE +0 -0
  15. {webbee-0.7.2 → webbee-0.7.3}/README.md +0 -0
  16. {webbee-0.7.2 → webbee-0.7.3}/install.sh +0 -0
  17. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/__main__.py +0 -0
  18. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/_launch.py +0 -0
  19. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/account.py +0 -0
  20. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/active_sessions.py +0 -0
  21. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/autoupdate.py +0 -0
  22. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/banner_art.py +0 -0
  23. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/boot.py +0 -0
  24. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/browser.py +0 -0
  25. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/checkpoints.py +0 -0
  26. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/cli.py +0 -0
  27. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/clipboard.py +0 -0
  28. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/clipboard_read.py +0 -0
  29. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/clipboard_session.py +0 -0
  30. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/coding_context.py +0 -0
  31. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/commands.py +0 -0
  32. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/config.py +0 -0
  33. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/consent.py +0 -0
  34. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/details.py +0 -0
  35. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/events.py +0 -0
  36. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/frames.py +0 -0
  37. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/home.py +0 -0
  38. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/home_view.py +0 -0
  39. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/http.py +0 -0
  40. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/instance_lock.py +0 -0
  41. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/__init__.py +0 -0
  42. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/graph.py +0 -0
  43. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/outline.py +0 -0
  44. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/store.py +0 -0
  45. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/tree.py +0 -0
  46. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/vectors.py +0 -0
  47. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/intel/watch.py +0 -0
  48. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/media.py +0 -0
  49. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/modals.py +0 -0
  50. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/mode_store.py +0 -0
  51. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/native_files.py +0 -0
  52. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/native_read.py +0 -0
  53. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/newtab_mode.py +0 -0
  54. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/output_pane.py +0 -0
  55. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/pricing_constants.py +0 -0
  56. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/queue_panel.py +0 -0
  57. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/reflow.py +0 -0
  58. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/remote.py +0 -0
  59. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/render.py +0 -0
  60. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/repl.py +0 -0
  61. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/repo.py +0 -0
  62. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/selection.py +0 -0
  63. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/session.py +0 -0
  64. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/sessions.py +0 -0
  65. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/sizing.py +0 -0
  66. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/slots.py +0 -0
  67. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/steer.py +0 -0
  68. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/stream.py +0 -0
  69. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/tab_store.py +0 -0
  70. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/tabs.py +0 -0
  71. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/thread.py +0 -0
  72. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/tier_store.py +0 -0
  73. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/todo_panel.py +0 -0
  74. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/tokens.py +0 -0
  75. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/tui.py +0 -0
  76. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/update.py +0 -0
  77. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/urlopen.py +0 -0
  78. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/wallet.py +0 -0
  79. {webbee-0.7.2 → webbee-0.7.3}/src/webbee/worktrees.py +0 -0
  80. {webbee-0.7.2 → webbee-0.7.3}/tests/__init__.py +0 -0
  81. {webbee-0.7.2 → webbee-0.7.3}/tests/conftest.py +0 -0
  82. {webbee-0.7.2 → webbee-0.7.3}/tests/test_account.py +0 -0
  83. {webbee-0.7.2 → webbee-0.7.3}/tests/test_active_sessions.py +0 -0
  84. {webbee-0.7.2 → webbee-0.7.3}/tests/test_audit_2026_07_25_findings.py +0 -0
  85. {webbee-0.7.2 → webbee-0.7.3}/tests/test_autoupdate.py +0 -0
  86. {webbee-0.7.2 → webbee-0.7.3}/tests/test_checkpoints.py +0 -0
  87. {webbee-0.7.2 → webbee-0.7.3}/tests/test_checkpoints_nested_1030.py +0 -0
  88. {webbee-0.7.2 → webbee-0.7.3}/tests/test_cli.py +0 -0
  89. {webbee-0.7.2 → webbee-0.7.3}/tests/test_clipboard.py +0 -0
  90. {webbee-0.7.2 → webbee-0.7.3}/tests/test_clipboard_read.py +0 -0
  91. {webbee-0.7.2 → webbee-0.7.3}/tests/test_code_map_fresh.py +0 -0
  92. {webbee-0.7.2 → webbee-0.7.3}/tests/test_coding_context.py +0 -0
  93. {webbee-0.7.2 → webbee-0.7.3}/tests/test_commands.py +0 -0
  94. {webbee-0.7.2 → webbee-0.7.3}/tests/test_config.py +0 -0
  95. {webbee-0.7.2 → webbee-0.7.3}/tests/test_cpc_contract_stable.py +0 -0
  96. {webbee-0.7.2 → webbee-0.7.3}/tests/test_cpc_outline.py +0 -0
  97. {webbee-0.7.2 → webbee-0.7.3}/tests/test_cross_loop_token.py +0 -0
  98. {webbee-0.7.2 → webbee-0.7.3}/tests/test_details.py +0 -0
  99. {webbee-0.7.2 → webbee-0.7.3}/tests/test_events.py +0 -0
  100. {webbee-0.7.2 → webbee-0.7.3}/tests/test_freeze_fix.py +0 -0
  101. {webbee-0.7.2 → webbee-0.7.3}/tests/test_home.py +0 -0
  102. {webbee-0.7.2 → webbee-0.7.3}/tests/test_home_view.py +0 -0
  103. {webbee-0.7.2 → webbee-0.7.3}/tests/test_home_view_mouse.py +0 -0
  104. {webbee-0.7.2 → webbee-0.7.3}/tests/test_inspect_page.py +0 -0
  105. {webbee-0.7.2 → webbee-0.7.3}/tests/test_instance_lock.py +0 -0
  106. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_any_folder.py +0 -0
  107. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_checksum_tree.py +0 -0
  108. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_chunker.py +0 -0
  109. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_embed.py +0 -0
  110. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_graph.py +0 -0
  111. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_no_numpy.py +0 -0
  112. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_query.py +0 -0
  113. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_service.py +0 -0
  114. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_store.py +0 -0
  115. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_vectors.py +0 -0
  116. {webbee-0.7.2 → webbee-0.7.3}/tests/test_intel_watch.py +0 -0
  117. {webbee-0.7.2 → webbee-0.7.3}/tests/test_launch.py +0 -0
  118. {webbee-0.7.2 → webbee-0.7.3}/tests/test_marathon.py +0 -0
  119. {webbee-0.7.2 → webbee-0.7.3}/tests/test_modals_e2e.py +0 -0
  120. {webbee-0.7.2 → webbee-0.7.3}/tests/test_mode_and_remote_0_6_8.py +0 -0
  121. {webbee-0.7.2 → webbee-0.7.3}/tests/test_mode_store.py +0 -0
  122. {webbee-0.7.2 → webbee-0.7.3}/tests/test_native_files.py +0 -0
  123. {webbee-0.7.2 → webbee-0.7.3}/tests/test_newtab_mode.py +0 -0
  124. {webbee-0.7.2 → webbee-0.7.3}/tests/test_packaging.py +0 -0
  125. {webbee-0.7.2 → webbee-0.7.3}/tests/test_probe_cost.py +0 -0
  126. {webbee-0.7.2 → webbee-0.7.3}/tests/test_queue_manage_0337.py +0 -0
  127. {webbee-0.7.2 → webbee-0.7.3}/tests/test_reflow.py +0 -0
  128. {webbee-0.7.2 → webbee-0.7.3}/tests/test_remote_code_reads.py +0 -0
  129. {webbee-0.7.2 → webbee-0.7.3}/tests/test_render.py +0 -0
  130. {webbee-0.7.2 → webbee-0.7.3}/tests/test_repl.py +0 -0
  131. {webbee-0.7.2 → webbee-0.7.3}/tests/test_repo.py +0 -0
  132. {webbee-0.7.2 → webbee-0.7.3}/tests/test_resilient_http.py +0 -0
  133. {webbee-0.7.2 → webbee-0.7.3}/tests/test_review_fixes_1030.py +0 -0
  134. {webbee-0.7.2 → webbee-0.7.3}/tests/test_session.py +0 -0
  135. {webbee-0.7.2 → webbee-0.7.3}/tests/test_session_review_1030.py +0 -0
  136. {webbee-0.7.2 → webbee-0.7.3}/tests/test_sessions.py +0 -0
  137. {webbee-0.7.2 → webbee-0.7.3}/tests/test_sizing.py +0 -0
  138. {webbee-0.7.2 → webbee-0.7.3}/tests/test_slots.py +0 -0
  139. {webbee-0.7.2 → webbee-0.7.3}/tests/test_steer.py +0 -0
  140. {webbee-0.7.2 → webbee-0.7.3}/tests/test_stream.py +0 -0
  141. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tab_bar_stability.py +0 -0
  142. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tab_restore_0337.py +0 -0
  143. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tabs.py +0 -0
  144. {webbee-0.7.2 → webbee-0.7.3}/tests/test_terminal_ux_0336.py +0 -0
  145. {webbee-0.7.2 → webbee-0.7.3}/tests/test_thread.py +0 -0
  146. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tier_invalidation.py +0 -0
  147. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tier_store.py +0 -0
  148. {webbee-0.7.2 → webbee-0.7.3}/tests/test_todo_panel.py +0 -0
  149. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tokens.py +0 -0
  150. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tools_eol_cancel_1030.py +0 -0
  151. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tools_vision_websearch.py +0 -0
  152. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tui.py +0 -0
  153. {webbee-0.7.2 → webbee-0.7.3}/tests/test_tui_hardening.py +0 -0
  154. {webbee-0.7.2 → webbee-0.7.3}/tests/test_update.py +0 -0
  155. {webbee-0.7.2 → webbee-0.7.3}/tests/test_update_badge_0340.py +0 -0
  156. {webbee-0.7.2 → webbee-0.7.3}/tests/test_urlopen.py +0 -0
  157. {webbee-0.7.2 → webbee-0.7.3}/tests/test_version.py +0 -0
  158. {webbee-0.7.2 → webbee-0.7.3}/tests/test_version_badge_corner.py +0 -0
  159. {webbee-0.7.2 → webbee-0.7.3}/tests/test_wallet.py +0 -0
  160. {webbee-0.7.2 → webbee-0.7.3}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.3
4
+
5
+ * **A folder of repositories is a folder** (second 06.11 report).
6
+ * Repositories nested deeper (`core/<repo>`, `modules/<repo>`) are found, up to four levels.
7
+ * Each repository syncs on its own, even when the folder's top holds loose scripts or a manifest. Before, the
8
+ whole folder was one 670-file project.
9
+ * Imports are resolved inside each repository first; a package another repository provides is linked to it.
10
+ * **The cloud graph answers from the first minute.**
11
+ * While the session's first sync runs, the vault answers with the keys it confirmed last time, and the answer
12
+ says so.
13
+ * "pending" ends with that first run. Before, every tool call queued a sync and the kernel always met one running.
14
+ * **Facts, not names.**
15
+ * The `read_file` header lists only the files whose references resolve to the file (it said "used by
16
+ check-paths-agree.py, +50 more").
17
+ * `graph_slice` callers are the real calls, with line and caller.
18
+ * `impact_of_change` says in one sentence what it found, including name-only matches as unproven.
19
+ * Python members resolve through typed locals, `self` attributes and return annotations.
20
+ * Needs imperal-mcp 0.9.3 and imperal-sdk 6.5.1.
21
+
3
22
  ## 0.7.2
4
23
 
5
24
  * **The code graph answers correctly** (06.11 report):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.7.2
3
+ Version: 0.7.3
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.9.2; python_version >= '3.10'
35
- Requires-Dist: imperal-sdk[universal-index]>=6.5.0; python_version >= '3.10'
34
+ Requires-Dist: imperal-mcp>=0.9.3; python_version >= '3.10'
35
+ Requires-Dist: imperal-sdk[universal-index]>=6.5.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.7.2"
3
+ version = "0.7.3"
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.9.2; python_version >= "3.10"', # raised to the newest at each release
17
- 'imperal-sdk[universal-index]>=6.5.0; python_version >= "3.10"',
16
+ 'imperal-mcp>=0.9.3; python_version >= "3.10"', # raised to the newest at each release
17
+ 'imperal-sdk[universal-index]>=6.5.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.3"
@@ -14,22 +14,42 @@ def _sym_item(s) -> dict:
14
14
 
15
15
  def repo_profile(svc) -> dict:
16
16
  p = svc.repo_profile()
17
- item = {"id": p["repo_key"], "title": f"repo ({p['file_count']} files)", "kind": "repo", **p}
17
+ repos = p.get("repos") or []
18
+ title = (f"folder ({p['file_count']} files, {len(repos)} repositories: {', '.join(repos[:12])}"
19
+ + (" ..." if len(repos) > 12 else "") + ")") if repos else f"repo ({p['file_count']} files)"
20
+ item = {"id": p["repo_key"], "title": title, "kind": "folder" if repos else "repo", **p}
18
21
  return _env([item])
19
22
 
20
23
 
21
24
  def graph_slice(svc, symbols, depth: int = 1) -> dict:
25
+ """Each definition a symbol names, with its RESOLVED callers (file, line, the calling definition) and callees
26
+ (what its body's references resolve to) -- the vault's rules (``imperal_sdk.code_links``). Before, the callers
27
+ were every definition of every file where the name appeared (report 06.11 #2)."""
22
28
  if svc is None or svc.graph is None:
23
29
  return {"ok": False, "content": "index not ready"}
24
30
  if isinstance(symbols, str):
25
31
  symbols = [symbols]
26
- g, items = svc.graph, []
32
+ lk = linker(svc)
33
+ links = lk.links()
34
+ items = []
27
35
  for name in (symbols or []):
28
- for s in g.symbol_table.get(name, []):
29
- it = _sym_item(s)
30
- it["callers"] = [c.name for c in g.callers_of(name)][:20]
31
- it["callees"] = g.callees_of(s)[:20]
36
+ for path, qual in lk.symbol_targets(str(name)):
37
+ if not qual:
38
+ continue
39
+ d = lk.defs(path).get(qual)
40
+ it = {"id": f"{path}::{qual}", "title": qual, "kind": getattr(d, "kind", ""),
41
+ "subtitle": f"{path}:{getattr(d, 'start_line', 0)}", "signature": getattr(d, "signature", ""),
42
+ "path": path, "start_line": getattr(d, "start_line", 0), "end_line": getattr(d, "end_line", 0)}
43
+ callers = [{"path": src, "user": user or "(module)", "line": line, "kind": kind}
44
+ for src, user, kind, line, tp, tq, _o in links
45
+ if tp == path and (tq == qual or tq.startswith(qual + ".")) and not (src == path and user == qual)]
46
+ it["callers"] = callers[:20]
47
+ it["callers_total"] = len(callers)
48
+ it["callees"] = sorted({f"{tp}::{tq}" if tq else tp for src, user, _k, _l, tp, tq, _o in links
49
+ if src == path and (user == qual or user.startswith(qual + "."))})[:20]
32
50
  items.append(it)
51
+ if not items:
52
+ return {**_env([]), "note": f"{', '.join(map(str, symbols or []))}: not a definition in this index"}
33
53
  return _env(items)
34
54
 
35
55
 
@@ -247,7 +267,14 @@ def linker(svc):
247
267
  except Exception:
248
268
  continue
249
269
  configs.append({"path": rel.replace("\\", "/"), "kind": kind, "data": data})
250
- lk = Linker(facts, configs)
270
+ roots = []
271
+ if root:
272
+ try:
273
+ from webbee.intel.service import is_repository, sub_repositories
274
+ roots = [] if is_repository(root) else sub_repositories(root)
275
+ except Exception:
276
+ roots = []
277
+ lk = Linker(facts, configs, roots)
251
278
  svc._linker, svc._linker_graph = lk, g
252
279
  return lk
253
280
 
@@ -277,6 +304,9 @@ def impact_of_change(svc, symbols) -> dict:
277
304
  if extra:
278
305
  out["by_name_count"] = int(out.get("by_name_count") or 0) + len(extra)
279
306
  out["by_name"] = sorted(out["by_name"] + extra, key=lambda b: (b["group"] == "tests", b["path"]))[:BY_NAME_SHOWN]
307
+ from imperal_sdk.code_links import impact_summary
308
+ out["summary"] = impact_summary(syms, out["resolved"], len(out["data"]["items"]), int(out.get("by_name_count") or 0),
309
+ len(out.get("same_file") or []))
280
310
  if not out["resolved"]:
281
311
  out["note"] = f"{', '.join(syms)}: not a definition in this index"
282
312
  out["link"] = "resolved locally (the vault's rules); by_name lists name-only matches apart"
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import asyncio
8
8
  import hashlib
9
9
  import logging
10
+ import json
10
11
  import os
11
12
  import subprocess
12
13
  import sys
@@ -754,9 +755,10 @@ class IntelService:
754
755
  if rels is not None: # a change: only the repositories it touched, with their own paths
755
756
  by_repo: dict = {}
756
757
  for rel in rels:
757
- head, _, tail = str(rel).replace(os.sep, "/").partition("/")
758
- if head in subs and tail:
759
- by_repo.setdefault(head, []).append(tail)
758
+ rel = str(rel).replace(os.sep, "/")
759
+ head = repo_of(rel, subs)
760
+ if head and rel != head:
761
+ by_repo.setdefault(head, []).append(rel[len(head) + 1:])
760
762
  todo = {name: by_repo[name] for name in by_repo}
761
763
  else:
762
764
  todo = {name: None for name in subs}
@@ -766,6 +768,8 @@ class IntelService:
766
768
  if out.get("repo_key"):
767
769
  repos[name] = str(out["repo_key"])
768
770
  out = {"repo_key": "", "repos": repos}
771
+ if repos and rels is None:
772
+ remember_keys(self.root, out)
769
773
  if repos:
770
774
  self.last_sync = out
771
775
  self.last_sync_ok_at = time.time()
@@ -815,6 +819,8 @@ class IntelService:
815
819
  return out
816
820
  measured = co_change_pairs(root) if rels is None and history else None
817
821
  out = asyncio.run(vault_sync(factory(), meta, listing, read, measured))
822
+ if out.get("repo_key") and rels is None and os.path.realpath(root) == os.path.realpath(self.root):
823
+ remember_keys(root, out)
818
824
  if sig and out.get("repo_key"):
819
825
  self._synced_sigs[root] = (sig, str(out["repo_key"]), time.time())
820
826
  elif rels is not None:
@@ -844,10 +850,16 @@ class IntelService:
844
850
  extract + embed, minutes) it answers at once ``{pending}`` -- a state, not a failure. ``wait_s``: tests only."""
845
851
  if self.vault_client_factory is None:
846
852
  return {"ok": False, "content": "this session cannot reach the cloud"}
847
- if not self.last_sync and self._sync_busy():
848
- # the repo's first sync (a whole repo: extract + embed) is still running: answered at once, as a state
849
- return {"pending": "the first code sync of this workspace is still running -- the local index answers "
850
- "until it ends"}
853
+ if self._sync_done == 0 and self._sync_busy():
854
+ # the session's FIRST sync is still running (a whole repo: extract + embed): answered at once, as a state
855
+ # -- with the keys the vault confirmed for this folder last time, so the cloud graph answers meanwhile
856
+ # (marked by its age). Only the first run: after it, each answer waits for its own sync (report 06.11 #2:
857
+ # every tool call queued a sync and every kernel ask met one running -- "pending" for a whole session)
858
+ known = remembered_keys(self.root)
859
+ return {"pending": "the first code sync of this workspace is still running -- "
860
+ + ("the cloud graph answers from the last confirmed sync"
861
+ if known else "the local index answers until it ends"),
862
+ **({"known": known} if known else {})}
851
863
  ticket = self._sync_request(None, history=False)
852
864
  with self._sync_cv:
853
865
  done = self._sync_cv.wait_for(lambda: self._sync_done >= ticket, timeout=wait_s)
@@ -1009,38 +1021,113 @@ class IntelService:
1009
1021
 
1010
1022
 
1011
1023
  SUB_REPOS_MAX = 40 # a folder of projects: at most this many of its repositories are synced
1024
+ REPO_DEPTH = 4 # repositories are looked for this deep (~/Projects/<org>/<repo>, a folder's core/<repo>)
1025
+ _REPOS_TTL_S = 30.0 # a folder's repositories, remembered this long (is_repository asks on every tool call)
1026
+ _REPOS_SEEN: dict = {}
1027
+ # a home folder's system trees: never walked for repositories (macOS Library / Applications, Volumes ...)
1028
+ _TOP_NEVER = frozenset({"Library", "Applications", "System", "Volumes", "private", "cores", "dev"})
1029
+
1030
+
1031
+ def find_repositories(root: str, depth: int = REPO_DEPTH) -> list:
1032
+ """The repositories inside ``root`` (folders holding ``.git``: a directory, or a file in a worktree), as
1033
+ root-relative posix paths, at most ``depth`` levels down -- never under a hidden, skipped or linked folder, never
1034
+ inside another repository (report 06.11 #2: core/wpanel-sdk, modules/wpanel-tickets were one level too deep and
1035
+ the whole folder became one project). The ONE rule for Webbee and imperal-mcp."""
1036
+ key = (os.path.realpath(root), int(depth))
1037
+ hit = _REPOS_SEEN.get(key)
1038
+ if hit and time.monotonic() - hit[0] < _REPOS_TTL_S:
1039
+ return list(hit[1])
1040
+ out: list = []
1041
+ frontier = [""]
1042
+ for _ in range(max(1, int(depth))):
1043
+ nxt: list = []
1044
+ for rel in frontier:
1045
+ here = os.path.join(root, rel) if rel else root
1046
+ try:
1047
+ names = sorted(os.listdir(here))
1048
+ except OSError:
1049
+ continue
1050
+ for name in names:
1051
+ full = os.path.join(here, name)
1052
+ if (not rel and name in _TOP_NEVER) or os.path.islink(full) or not os.path.isdir(full) \
1053
+ or skipped_dir(here, name):
1054
+ continue
1055
+ child = f"{rel}/{name}" if rel else name
1056
+ (out if os.path.exists(os.path.join(full, ".git")) else nxt).append(child)
1057
+ frontier = nxt
1058
+ _REPOS_SEEN[key] = (time.monotonic(), tuple(out))
1059
+ return out
1012
1060
 
1013
1061
 
1014
1062
  def sub_repositories(root: str) -> list:
1015
- """The repositories directly inside a folder that is none itself: sub-folders with ``.git`` (never a link, never a
1016
- hidden folder; a manifest alone does not make a sub-folder a project here), sorted, bounded."""
1017
- try:
1018
- names = sorted(os.listdir(root))
1019
- except OSError:
1020
- return []
1021
- out = []
1022
- for name in names:
1023
- full = os.path.join(root, name)
1024
- if name.startswith(".") or os.path.islink(full) or not os.path.isdir(full):
1025
- continue
1026
- try:
1027
- if ".git" in os.listdir(full):
1028
- out.append(name)
1029
- except OSError:
1030
- continue
1031
- return out[:SUB_REPOS_MAX]
1063
+ """The repositories inside a folder that is none itself (``find_repositories``), sorted, bounded."""
1064
+ return find_repositories(root)[:SUB_REPOS_MAX]
1032
1065
 
1033
1066
 
1034
1067
  def is_repository(root: str) -> bool:
1035
- """A repository the code graph may hold: ``.git`` (a dir, or a file in a worktree) or a project manifest at the
1036
- root. A home folder or a folder of projects is none -- its repositories are synced one by one (owner 2026-10-25)."""
1068
+ """A repository the code graph may hold as ONE: ``.git`` at the root; without it, a folder holding repositories
1069
+ is a folder of them (each synced on its own -- even with a manifest or loose scripts at the top, report 06.11
1070
+ #2); otherwise any ecosystem's manifest makes it a project without version control. A home folder or a folder
1071
+ of projects is none (owner 2026-10-25)."""
1037
1072
  try:
1038
1073
  names = set(os.listdir(root))
1039
1074
  except OSError:
1040
1075
  return False
1076
+ if ".git" in names:
1077
+ return True
1078
+ if find_repositories(root):
1079
+ return False
1041
1080
  return indexer.is_project_root(names)
1042
1081
 
1043
1082
 
1083
+ def repo_of(rel: str, repos) -> str:
1084
+ """The repository of a folder-relative path: the longest repository path it lies under, or ""."""
1085
+ rel = str(rel).replace(os.sep, "/").lstrip("./")
1086
+ best = ""
1087
+ for r in repos or ():
1088
+ if (rel == r or rel.startswith(r + "/")) and len(r) > len(best):
1089
+ best = r
1090
+ return best
1091
+
1092
+
1093
+ # the vault's keys of the repositories this machine last synced (a new session answers from the cloud graph while
1094
+ # its own first sync still runs -- report 06.11 #2: a whole session ran on the local index)
1095
+ _KEYS_FILE = os.path.join(os.path.expanduser("~"), ".local", "share", "webbee", "vault-keys.json")
1096
+
1097
+
1098
+ def remembered_keys(root: str) -> dict:
1099
+ """``{repo_key | repos, synced_at}`` the vault last confirmed for ``root`` on this machine, or {}."""
1100
+ try:
1101
+ with open(_KEYS_FILE, encoding="utf-8") as f:
1102
+ got = json.load(f).get(os.path.realpath(root)) or {}
1103
+ return got if isinstance(got, dict) and (got.get("repo_key") or got.get("repos")) else {}
1104
+ except (OSError, ValueError, AttributeError):
1105
+ return {}
1106
+
1107
+
1108
+ def remember_keys(root: str, out: dict) -> None:
1109
+ keys = {k: out[k] for k in ("repo_key", "repos") if out.get(k)}
1110
+ if not keys:
1111
+ return
1112
+ try:
1113
+ try:
1114
+ with open(_KEYS_FILE, encoding="utf-8") as f:
1115
+ data = json.load(f)
1116
+ data = data if isinstance(data, dict) else {}
1117
+ except (OSError, ValueError):
1118
+ data = {}
1119
+ data[os.path.realpath(root)] = {**keys, "synced_at": time.time()}
1120
+ if len(data) > 200: # bounded: the newest roots
1121
+ data = dict(sorted(data.items(), key=lambda kv: -float((kv[1] or {}).get("synced_at") or 0))[:200])
1122
+ os.makedirs(os.path.dirname(_KEYS_FILE), exist_ok=True)
1123
+ tmp = _KEYS_FILE + ".tmp"
1124
+ with open(tmp, "w", encoding="utf-8") as f:
1125
+ json.dump(data, f)
1126
+ os.replace(tmp, _KEYS_FILE)
1127
+ except OSError:
1128
+ pass
1129
+
1130
+
1044
1131
  _MACHINE_ID_FILE = os.path.join(os.path.expanduser("~"), ".local", "share", "webbee", "machine-id")
1045
1132
 
1046
1133
 
@@ -551,8 +551,11 @@ class LocalToolExecutor:
551
551
  parts.append("defines " + ", ".join(names[:2]) + extra)
552
552
  graph = getattr(self.indexer, "graph", None)
553
553
  if graph is not None and fi.symbols:
554
- deps = sorted(graph.dependents_of({s.name for s in fi.symbols},
555
- depth=1) - {rel})
554
+ # the files whose references RESOLVE to this file (the vault's rules) -- never a file that only
555
+ # shares a name with one of its definitions (report 06.11 #2: "used by check-paths-agree.py, +50")
556
+ from webbee.intel.query import linker
557
+ deps = sorted({src for src, _u, _k, _l, dst, _q, _o in linker(self.indexer).links()
558
+ if dst == rel and src != rel})
556
559
  if deps:
557
560
  extra = f" +{len(deps) - 3} more" if len(deps) > 3 else ""
558
561
  parts.append("↔ used by " + ", ".join(deps[:3]) + extra)
@@ -306,7 +306,7 @@ def test_sync_now_waits_for_the_real_sync_and_answers_with_its_confirmation(tmp_
306
306
  kernel's request waits for its own sync to end and answers with what the vault took -- never "busy"."""
307
307
  import threading
308
308
  svc, slow = _service_with_slow_sync(tmp_path, monkeypatch)
309
- svc.last_sync = {"repo_key": "k"} # not the first sync
309
+ svc._sync_taken = svc._sync_done = 1 # not the first sync: a run already ended
310
310
  svc.sync_vault(None)
311
311
  assert slow.started.wait(5)
312
312
  got = {}
@@ -0,0 +1,109 @@
1
+ """Report 06.11 #2: a folder whose repositories sit one level deeper (core/<repo>, modules/<repo>) with loose
2
+ scripts at its top was one 670-file project; the first sync answered "pending" for a whole session; the read
3
+ header and graph_slice linked files by a shared name."""
4
+ import os
5
+ import subprocess
6
+ import threading
7
+ import time
8
+
9
+ import pytest
10
+
11
+ from webbee.intel import service as sv
12
+ from webbee.intel.service import IntelService, find_repositories, is_repository, repo_of, sub_repositories
13
+
14
+
15
+ def _repo(path):
16
+ os.makedirs(path, exist_ok=True)
17
+ os.makedirs(os.path.join(path, ".git"), exist_ok=True)
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def _fresh(monkeypatch, tmp_path):
22
+ monkeypatch.setattr(sv, "_REPOS_SEEN", {})
23
+ monkeypatch.setattr(sv, "_KEYS_FILE", str(tmp_path / "keys" / "vault-keys.json"))
24
+
25
+
26
+ def test_repositories_one_level_deeper_are_found_and_make_a_folder(tmp_path):
27
+ for r in ("core/wpanel-sdk", "modules/wpanel-tickets", "gateway"):
28
+ _repo(tmp_path / r)
29
+ _repo(tmp_path / "core/wpanel-sdk/vendor/inner") # inside a repository: never its own unit
30
+ (tmp_path / "requirements.txt").write_text("x\n") # a manifest and loose scripts at the top
31
+ (tmp_path / "check-paths-agree.py").write_text("print(1)\n")
32
+ assert find_repositories(str(tmp_path)) == ["gateway", "core/wpanel-sdk", "modules/wpanel-tickets"]
33
+ assert not is_repository(str(tmp_path)) # a folder of repositories, not one project
34
+ assert sorted(sub_repositories(str(tmp_path))) == ["core/wpanel-sdk", "gateway", "modules/wpanel-tickets"]
35
+ assert repo_of("core/wpanel-sdk/src/a.py", sub_repositories(str(tmp_path))) == "core/wpanel-sdk"
36
+ assert repo_of("check-paths-agree.py", sub_repositories(str(tmp_path))) == ""
37
+
38
+
39
+ def test_a_manifest_without_repositories_inside_is_a_project(tmp_path):
40
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
41
+ assert is_repository(str(tmp_path))
42
+ _repo(tmp_path / "solo")
43
+ assert is_repository(str(tmp_path / "solo"))
44
+
45
+
46
+ class _Slow:
47
+ def __init__(self, out=None):
48
+ self.started, self.release, self.calls, self.out = threading.Event(), threading.Event(), [], out
49
+
50
+ def __call__(self, rels=None, history=True):
51
+ self.calls.append((rels, history))
52
+ self.started.set()
53
+ self.release.wait(10)
54
+ return self.out if self.out is not None else {}
55
+
56
+
57
+ def _svc(tmp_path, monkeypatch, out=None):
58
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
59
+ svc.vault_client_factory = lambda: object()
60
+ slow = _Slow(out)
61
+ monkeypatch.setattr(svc, "_sync_vault", slow)
62
+ return svc, slow
63
+
64
+
65
+ def test_the_first_sync_running_answers_with_the_keys_confirmed_last_time(tmp_path, monkeypatch):
66
+ sv.remember_keys(str(tmp_path), {"repo_key": "", "repos": {"gateway": "k1", "core/wpanel-sdk": "k2"}})
67
+ svc, slow = _svc(tmp_path, monkeypatch)
68
+ svc.sync_vault(None)
69
+ assert slow.started.wait(5)
70
+ got = svc.sync_now()
71
+ assert "pending" in got and got["known"]["repos"] == {"gateway": "k1", "core/wpanel-sdk": "k2"}
72
+ assert "cloud graph answers" in got["pending"]
73
+ slow.release.set()
74
+
75
+
76
+ def test_pending_ends_with_the_first_run_even_when_it_failed(tmp_path, monkeypatch):
77
+ """Every tool call queued a sync; the kernel's ask always met one running and 'pending' never ended."""
78
+ svc, slow = _svc(tmp_path, monkeypatch, out={"repo_key": "", "partial_reason": "no repository here"})
79
+ slow.release.set()
80
+ svc.sync_vault(None)
81
+ deadline = time.time() + 5
82
+ while svc._sync_done == 0:
83
+ assert time.time() < deadline
84
+ time.sleep(0.01)
85
+ slow.release.clear()
86
+ svc.sync_vault(None) # the next tool call's sync is running
87
+ assert slow.started.wait(5)
88
+ got = {}
89
+ th = threading.Thread(target=lambda: got.update(svc.sync_now()))
90
+ th.start()
91
+ time.sleep(0.2)
92
+ assert th.is_alive() and not got # waits for a real outcome, never "pending" again
93
+ slow.release.set()
94
+ th.join(10)
95
+ assert got.get("ok") is False and "no repository here" in got["content"]
96
+
97
+
98
+ def test_graph_slice_callers_are_resolved_users(tmp_path):
99
+ pytest.importorskip("tree_sitter_python")
100
+ (tmp_path / "lib.py").write_text("def session_for_sid(sid):\n return sid\n")
101
+ (tmp_path / "api.py").write_text("from lib import session_for_sid\n\n\ndef me(sid):\n"
102
+ " return session_for_sid(sid)\n")
103
+ (tmp_path / "other.py").write_text("def unrelated():\n return 'session_for_sid'\n")
104
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=False)
105
+ svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
106
+ svc.build()
107
+ from webbee.intel.query import graph_slice
108
+ item = graph_slice(svc, ["session_for_sid"])["data"]["items"][0]
109
+ assert [(c["path"], c["user"], c["line"]) for c in item["callers"]] == [("api.py", "me", 5)]
@@ -538,13 +538,20 @@ def test_read_header_enriched_with_graph_context(tmp_path):
538
538
  Symbol("helper", "function", "a.py", 4, 5)]),
539
539
  "b.py": FileIndex(path="b.py", lang="python", refs=["Alpha"]),
540
540
  })
541
- class _Svc: # duck-typed IntelService: .index + .graph is all the header reads
541
+ class _Svc: # duck-typed IntelService: .root + .index + .graph is all the header reads
542
+ root = str(tmp_path)
542
543
  index = idx
543
544
  graph = CodeGraph(idx)
544
545
  ex = LocalToolExecutor(str(tmp_path), indexer=_Svc())
545
546
  header = ex.run("read_file", {"path": "a.py"})["content"].split("\n", 1)[0]
546
547
  assert "defines Alpha, helper" in header
547
548
  assert "↔ used by b.py" in header
549
+ # 06.11 #2: a file that only shares a name is no user (it was "used by check-paths-agree.py, +50 more")
550
+ (tmp_path / "c.py").write_text("print('Alpha')\nAlpha = 1\n")
551
+ idx.files["c.py"] = FileIndex(path="c.py", lang="python", refs=["Alpha"])
552
+ _Svc.graph = CodeGraph(idx)
553
+ header = ex.run("read_file", {"path": "a.py"})["content"].split("\n", 1)[0]
554
+ assert "↔ used by b.py" in header and "c.py" not in header
548
555
  assert "5 lines" in header # cheap metadata still present
549
556
 
550
557
 
@@ -1 +0,0 @@
1
- __version__ = "0.7.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes