webbee 0.4.4__tar.gz → 0.5.1__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 (146) hide show
  1. {webbee-0.4.4 → webbee-0.5.1}/CHANGELOG.md +20 -0
  2. {webbee-0.4.4 → webbee-0.5.1}/PKG-INFO +3 -2
  3. {webbee-0.4.4 → webbee-0.5.1}/pyproject.toml +3 -2
  4. webbee-0.5.1/src/webbee/__init__.py +1 -0
  5. webbee-0.5.1/src/webbee/browser.py +261 -0
  6. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/consent.py +7 -1
  7. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/intel/graph.py +1 -1
  8. webbee-0.5.1/src/webbee/intel/outline.py +248 -0
  9. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/intel/service.py +210 -17
  10. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/intel/store.py +1 -1
  11. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/intel/watch.py +10 -9
  12. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/tools.py +343 -0
  13. {webbee-0.4.4 → webbee-0.5.1}/tests/test_audit_2026_07_25_findings.py +2 -2
  14. webbee-0.5.1/tests/test_cpc_outline.py +103 -0
  15. webbee-0.5.1/tests/test_inspect_page.py +147 -0
  16. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_chunker.py +3 -3
  17. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_embed.py +1 -1
  18. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_graph.py +1 -1
  19. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_no_numpy.py +2 -1
  20. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_service.py +113 -0
  21. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_store.py +2 -1
  22. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tools.py +198 -1
  23. webbee-0.4.4/src/webbee/__init__.py +0 -1
  24. webbee-0.4.4/src/webbee/intel/chunker.py +0 -104
  25. webbee-0.4.4/src/webbee/intel/contracts.py +0 -183
  26. webbee-0.4.4/src/webbee/intel/embed.py +0 -72
  27. webbee-0.4.4/src/webbee/intel/indexer.py +0 -124
  28. webbee-0.4.4/src/webbee/intel/models.py +0 -30
  29. webbee-0.4.4/tests/test_intel_contracts.py +0 -101
  30. webbee-0.4.4/tests/test_intel_indexer.py +0 -53
  31. {webbee-0.4.4 → webbee-0.5.1}/.github/workflows/publish.yml +0 -0
  32. {webbee-0.4.4 → webbee-0.5.1}/.gitignore +0 -0
  33. {webbee-0.4.4 → webbee-0.5.1}/LICENSE +0 -0
  34. {webbee-0.4.4 → webbee-0.5.1}/README.md +0 -0
  35. {webbee-0.4.4 → webbee-0.5.1}/install.sh +0 -0
  36. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/__main__.py +0 -0
  37. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/account.py +0 -0
  38. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/active_sessions.py +0 -0
  39. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/banner_art.py +0 -0
  40. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/boot.py +0 -0
  41. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/checkpoints.py +0 -0
  42. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/cli.py +0 -0
  43. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/clipboard.py +0 -0
  44. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/clipboard_read.py +0 -0
  45. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/clipboard_session.py +0 -0
  46. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/coding_context.py +0 -0
  47. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/commands.py +0 -0
  48. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/config.py +0 -0
  49. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/details.py +0 -0
  50. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/events.py +0 -0
  51. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/frames.py +0 -0
  52. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/home.py +0 -0
  53. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/home_view.py +0 -0
  54. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/http.py +0 -0
  55. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/instance_lock.py +0 -0
  56. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/intel/__init__.py +0 -0
  57. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/intel/query.py +0 -0
  58. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/intel/vectors.py +0 -0
  59. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/media.py +0 -0
  60. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/modals.py +0 -0
  61. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/mode_store.py +0 -0
  62. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/native_files.py +0 -0
  63. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/native_read.py +0 -0
  64. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/newtab_mode.py +0 -0
  65. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/output_pane.py +0 -0
  66. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/pricing_constants.py +0 -0
  67. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/queue_panel.py +0 -0
  68. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/reflow.py +0 -0
  69. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/remote.py +0 -0
  70. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/render.py +0 -0
  71. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/repl.py +0 -0
  72. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/repo.py +0 -0
  73. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/selection.py +0 -0
  74. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/session.py +0 -0
  75. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/sessions.py +0 -0
  76. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/sizing.py +0 -0
  77. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/slots.py +0 -0
  78. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/steer.py +0 -0
  79. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/stream.py +0 -0
  80. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/tab_store.py +0 -0
  81. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/tabs.py +0 -0
  82. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/thread.py +0 -0
  83. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/tier_store.py +0 -0
  84. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/todo_panel.py +0 -0
  85. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/tokens.py +0 -0
  86. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/tui.py +0 -0
  87. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/update.py +0 -0
  88. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/urlopen.py +0 -0
  89. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/wallet.py +0 -0
  90. {webbee-0.4.4 → webbee-0.5.1}/src/webbee/worktrees.py +0 -0
  91. {webbee-0.4.4 → webbee-0.5.1}/tests/__init__.py +0 -0
  92. {webbee-0.4.4 → webbee-0.5.1}/tests/conftest.py +0 -0
  93. {webbee-0.4.4 → webbee-0.5.1}/tests/test_account.py +0 -0
  94. {webbee-0.4.4 → webbee-0.5.1}/tests/test_active_sessions.py +0 -0
  95. {webbee-0.4.4 → webbee-0.5.1}/tests/test_checkpoints.py +0 -0
  96. {webbee-0.4.4 → webbee-0.5.1}/tests/test_cli.py +0 -0
  97. {webbee-0.4.4 → webbee-0.5.1}/tests/test_clipboard.py +0 -0
  98. {webbee-0.4.4 → webbee-0.5.1}/tests/test_clipboard_read.py +0 -0
  99. {webbee-0.4.4 → webbee-0.5.1}/tests/test_coding_context.py +0 -0
  100. {webbee-0.4.4 → webbee-0.5.1}/tests/test_commands.py +0 -0
  101. {webbee-0.4.4 → webbee-0.5.1}/tests/test_config.py +0 -0
  102. {webbee-0.4.4 → webbee-0.5.1}/tests/test_cpc_contract_stable.py +0 -0
  103. {webbee-0.4.4 → webbee-0.5.1}/tests/test_cross_loop_token.py +0 -0
  104. {webbee-0.4.4 → webbee-0.5.1}/tests/test_details.py +0 -0
  105. {webbee-0.4.4 → webbee-0.5.1}/tests/test_events.py +0 -0
  106. {webbee-0.4.4 → webbee-0.5.1}/tests/test_freeze_fix.py +0 -0
  107. {webbee-0.4.4 → webbee-0.5.1}/tests/test_home.py +0 -0
  108. {webbee-0.4.4 → webbee-0.5.1}/tests/test_home_view.py +0 -0
  109. {webbee-0.4.4 → webbee-0.5.1}/tests/test_instance_lock.py +0 -0
  110. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_query.py +0 -0
  111. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_vectors.py +0 -0
  112. {webbee-0.4.4 → webbee-0.5.1}/tests/test_intel_watch.py +0 -0
  113. {webbee-0.4.4 → webbee-0.5.1}/tests/test_marathon.py +0 -0
  114. {webbee-0.4.4 → webbee-0.5.1}/tests/test_modals_e2e.py +0 -0
  115. {webbee-0.4.4 → webbee-0.5.1}/tests/test_mode_store.py +0 -0
  116. {webbee-0.4.4 → webbee-0.5.1}/tests/test_native_files.py +0 -0
  117. {webbee-0.4.4 → webbee-0.5.1}/tests/test_newtab_mode.py +0 -0
  118. {webbee-0.4.4 → webbee-0.5.1}/tests/test_packaging.py +0 -0
  119. {webbee-0.4.4 → webbee-0.5.1}/tests/test_queue_manage_0337.py +0 -0
  120. {webbee-0.4.4 → webbee-0.5.1}/tests/test_reflow.py +0 -0
  121. {webbee-0.4.4 → webbee-0.5.1}/tests/test_render.py +0 -0
  122. {webbee-0.4.4 → webbee-0.5.1}/tests/test_repl.py +0 -0
  123. {webbee-0.4.4 → webbee-0.5.1}/tests/test_repo.py +0 -0
  124. {webbee-0.4.4 → webbee-0.5.1}/tests/test_session.py +0 -0
  125. {webbee-0.4.4 → webbee-0.5.1}/tests/test_sessions.py +0 -0
  126. {webbee-0.4.4 → webbee-0.5.1}/tests/test_sizing.py +0 -0
  127. {webbee-0.4.4 → webbee-0.5.1}/tests/test_slots.py +0 -0
  128. {webbee-0.4.4 → webbee-0.5.1}/tests/test_steer.py +0 -0
  129. {webbee-0.4.4 → webbee-0.5.1}/tests/test_stream.py +0 -0
  130. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tab_restore_0337.py +0 -0
  131. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tabs.py +0 -0
  132. {webbee-0.4.4 → webbee-0.5.1}/tests/test_terminal_ux_0336.py +0 -0
  133. {webbee-0.4.4 → webbee-0.5.1}/tests/test_thread.py +0 -0
  134. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tier_store.py +0 -0
  135. {webbee-0.4.4 → webbee-0.5.1}/tests/test_todo_panel.py +0 -0
  136. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tokens.py +0 -0
  137. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tools_vision_websearch.py +0 -0
  138. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tui.py +0 -0
  139. {webbee-0.4.4 → webbee-0.5.1}/tests/test_tui_hardening.py +0 -0
  140. {webbee-0.4.4 → webbee-0.5.1}/tests/test_update.py +0 -0
  141. {webbee-0.4.4 → webbee-0.5.1}/tests/test_update_badge_0340.py +0 -0
  142. {webbee-0.4.4 → webbee-0.5.1}/tests/test_urlopen.py +0 -0
  143. {webbee-0.4.4 → webbee-0.5.1}/tests/test_version.py +0 -0
  144. {webbee-0.4.4 → webbee-0.5.1}/tests/test_version_badge_corner.py +0 -0
  145. {webbee-0.4.4 → webbee-0.5.1}/tests/test_wallet.py +0 -0
  146. {webbee-0.4.4 → webbee-0.5.1}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ **Security fixes for the remote and local code sync.**
6
+
7
+ * The system-`sftp` fallback refuses a remote path with a control character: a file name from a server can
8
+ no longer start a second batch command (sftp's `!` runs a local shell).
9
+ * The local sync never reads a symlink or a path that resolves outside the workspace.
10
+
11
+ ## 0.5.0
12
+
13
+ **Webbee v0.5.0: one code graph for every source.**
14
+
15
+ * **The code index feeds the Tenant Vault**: the workspace's files, definitions and links go into the vault's
16
+ code graph, so the panel, Telegram and the kernel answer code questions about your repos from the same graph.
17
+ * **`intel_sync_remote`**: a repo on a server you reach with your own `ssh` is synced over SFTP (no remote
18
+ commands, nothing written, symlinks never followed, never a password prompt) -- code questions about it are
19
+ answered from the vault; a key that needs interactive login is said plainly.
20
+ * **Shared extractor**: parsing, chunks and embeddings come from `imperal-sdk` (`imperal_sdk.code_index`, >= 6.3.2).
21
+ * Requires `asyncssh` for direct SFTP (falls back to the system `sftp` through an open master connection).
22
+
3
23
  ## 0.4.4
4
24
 
5
25
  **Webbee v0.4.4: Clean Tool Rendering, Real-Time Action Cost Estimation & Terminal Robustness.**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.4.4
3
+ Version: 0.5.1
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
@@ -25,10 +25,11 @@ Classifier: Programming Language :: Python :: 3.14
25
25
  Classifier: Topic :: Software Development
26
26
  Classifier: Topic :: Utilities
27
27
  Requires-Python: >=3.9
28
+ Requires-Dist: asyncssh>=2.14
28
29
  Requires-Dist: httpx-sse>=0.4
29
30
  Requires-Dist: httpx>=0.27
30
31
  Requires-Dist: imperal-mcp>=0.5.0
31
- Requires-Dist: imperal-sdk>=5.9.3
32
+ Requires-Dist: imperal-sdk>=6.3.2
32
33
  Requires-Dist: model2vec>=0.8.2; sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13'
33
34
  Requires-Dist: numpy>=2; sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13'
34
35
  Requires-Dist: prompt-toolkit>=3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.4.4"
3
+ version = "0.5.1"
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"
@@ -9,7 +9,8 @@ requires-python = ">=3.9"
9
9
  authors = [{ name = "Imperal, Inc.", email = "hello@imperal.io" }]
10
10
  dependencies = [
11
11
  "imperal-mcp>=0.5.0",
12
- "imperal-sdk>=5.9.3",
12
+ "imperal-sdk>=6.3.2",
13
+ "asyncssh>=2.14",
13
14
  "httpx>=0.27",
14
15
  "httpx-sse>=0.4",
15
16
  "rich>=13.7",
@@ -0,0 +1 @@
1
+ __version__ = "0.5.1"
@@ -0,0 +1,261 @@
1
+ """inspect_page: look at a web page in the local browser over the Chrome DevTools Protocol.
2
+
3
+ One call returns what the kernel needs to MEASURE a screen (truth-loop stage 10): per viewport
4
+ width, the visible element boxes with their roles, text, z-index and computed colors (the painted
5
+ background behind each, not a guess), the page's scroll width, and one screenshot. The agent no
6
+ longer writes its own websocket scripts against port 9222 (live: 56 of 205 shell calls of one
7
+ UI task were such scripts).
8
+
9
+ Stdlib only: a minimal RFC 6455 client (text frames, client masking, ping/pong) -- no new
10
+ dependency for a verb most sessions never call.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import os
17
+ import socket
18
+ import struct
19
+ import time
20
+ import urllib.parse
21
+ import urllib.request
22
+
23
+ DEFAULT_PORT = 9222
24
+ DEFAULT_WIDTHS = (375, 768, 1440)
25
+ VIEW_HEIGHT = 900
26
+ MAX_ELEMENTS = 400
27
+ _TIMEOUT = 20.0
28
+
29
+ # the page side: visible boxes worth measuring, in page coordinates, with the painted background
30
+ _COLLECT_JS = r"""
31
+ (() => {
32
+ const MAX = %d;
33
+ const vw = window.innerWidth, vh = window.innerHeight;
34
+ const sx = window.scrollX, sy = window.scrollY;
35
+ const IMPLICIT = {A: 'link', BUTTON: 'button', INPUT: 'input', SELECT: 'select', TEXTAREA: 'textbox',
36
+ H1: 'h1', H2: 'h2', H3: 'h3', IMG: 'img', NAV: 'navigation', HEADER: 'banner',
37
+ FOOTER: 'contentinfo', MAIN: 'main', LABEL: 'label', P: 'text', LI: 'listitem'};
38
+ const painted = (el) => {
39
+ for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
40
+ const c = getComputedStyle(e).backgroundColor;
41
+ if (c && c !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(c)) return c;
42
+ }
43
+ return 'rgb(255, 255, 255)';
44
+ };
45
+ const ownText = (el) => Array.from(el.childNodes).filter(n => n.nodeType === 3)
46
+ .map(n => n.textContent).join(' ').replace(/\s+/g, ' ').trim();
47
+ const out = [];
48
+ const all = document.body ? document.body.querySelectorAll('*') : [];
49
+ for (const el of all) {
50
+ if (out.length >= MAX) break;
51
+ const r = el.getBoundingClientRect();
52
+ if (r.width < 1 || r.height < 1) continue;
53
+ const cs = getComputedStyle(el);
54
+ if (cs.visibility === 'hidden' || cs.display === 'none' || parseFloat(cs.opacity) === 0) continue;
55
+ const role = (el.getAttribute('role') || IMPLICIT[el.tagName] || '').toLowerCase();
56
+ const text = ownText(el);
57
+ const handlers = !!(el.onclick || el.getAttribute('onclick') || cs.cursor === 'pointer');
58
+ if (!role && !text && !handlers) continue;
59
+ out.push({
60
+ id: el.id || (el.getAttribute('data-testid') || '') || (el.tagName.toLowerCase() + '_' + out.length),
61
+ tag: el.tagName.toLowerCase(), role: role, text: (text || el.getAttribute('aria-label') || el.alt || '').slice(0, 100),
62
+ x: Math.round(r.left + sx), y: Math.round(r.top + sy), w: Math.round(r.width), h: Math.round(r.height),
63
+ z_index: parseInt(cs.zIndex) || 0, color: cs.color, background: painted(el),
64
+ font_size: parseFloat(cs.fontSize) || 0, font_weight: parseInt(cs.fontWeight) || 400,
65
+ has_handlers: handlers,
66
+ });
67
+ }
68
+ return JSON.stringify({width: vw, height: vh, scroll_width: document.documentElement.scrollWidth,
69
+ title: document.title, url: location.href, elements: out});
70
+ })()
71
+ """ % MAX_ELEMENTS
72
+
73
+
74
+ class CDPError(Exception):
75
+ pass
76
+
77
+
78
+ class _WebSocket:
79
+ """A minimal websocket client: enough for CDP (text frames in, text frames out)."""
80
+
81
+ def __init__(self, url: str, timeout: float = _TIMEOUT):
82
+ u = urllib.parse.urlparse(url)
83
+ self.sock = socket.create_connection((u.hostname, u.port or 80), timeout=timeout)
84
+ key = base64.b64encode(os.urandom(16)).decode()
85
+ path = u.path + (f"?{u.query}" if u.query else "")
86
+ self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {u.hostname}:{u.port}\r\nUpgrade: websocket\r\n"
87
+ f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
88
+ .encode())
89
+ head = b""
90
+ while b"\r\n\r\n" not in head:
91
+ chunk = self.sock.recv(4096)
92
+ if not chunk:
93
+ raise CDPError("the browser closed the connection during the handshake")
94
+ head += chunk
95
+ status = head.split(b"\r\n", 1)[0].decode(errors="replace")
96
+ if " 101 " not in status:
97
+ raise CDPError(f"websocket handshake refused: {status}")
98
+ self.buf = head.split(b"\r\n\r\n", 1)[1]
99
+
100
+ def _recv_exact(self, n: int) -> bytes:
101
+ while len(self.buf) < n:
102
+ chunk = self.sock.recv(max(65536, n - len(self.buf)))
103
+ if not chunk:
104
+ raise CDPError("the browser closed the connection")
105
+ self.buf += chunk
106
+ out, self.buf = self.buf[:n], self.buf[n:]
107
+ return out
108
+
109
+ def send(self, text: str) -> None:
110
+ data = text.encode()
111
+ head = bytearray([0x81])
112
+ n = len(data)
113
+ if n < 126:
114
+ head.append(0x80 | n)
115
+ elif n < 65536:
116
+ head += bytes([0x80 | 126]) + struct.pack(">H", n)
117
+ else:
118
+ head += bytes([0x80 | 127]) + struct.pack(">Q", n)
119
+ mask = os.urandom(4)
120
+ self.sock.sendall(bytes(head) + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
121
+
122
+ def _send_control(self, opcode: int, payload: bytes = b"") -> None:
123
+ mask = os.urandom(4)
124
+ self.sock.sendall(bytes([0x80 | opcode, 0x80 | len(payload)]) + mask
125
+ + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
126
+
127
+ def recv(self) -> str:
128
+ parts = []
129
+ while True:
130
+ b1, b2 = self._recv_exact(2)
131
+ opcode, n = b1 & 0x0F, b2 & 0x7F
132
+ if n == 126:
133
+ n = struct.unpack(">H", self._recv_exact(2))[0]
134
+ elif n == 127:
135
+ n = struct.unpack(">Q", self._recv_exact(8))[0]
136
+ mask = self._recv_exact(4) if b2 & 0x80 else b""
137
+ data = self._recv_exact(n)
138
+ if mask:
139
+ data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
140
+ if opcode == 0x9:
141
+ self._send_control(0xA, data)
142
+ continue
143
+ if opcode == 0x8:
144
+ raise CDPError("the browser closed the page connection")
145
+ if opcode in (0x1, 0x2, 0x0):
146
+ parts.append(data)
147
+ if b1 & 0x80:
148
+ return b"".join(parts).decode(errors="replace")
149
+
150
+ def close(self) -> None:
151
+ try:
152
+ self._send_control(0x8)
153
+ self.sock.close()
154
+ except OSError:
155
+ pass
156
+
157
+
158
+ class _Page:
159
+ def __init__(self, ws_url: str):
160
+ self.ws = _WebSocket(ws_url)
161
+ self.n = 0
162
+
163
+ def call(self, method: str, params: dict | None = None, timeout: float = _TIMEOUT) -> dict:
164
+ self.n += 1
165
+ mid = self.n
166
+ self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
167
+ end = time.monotonic() + timeout
168
+ while time.monotonic() < end:
169
+ msg = json.loads(self.ws.recv())
170
+ if msg.get("id") == mid:
171
+ if "error" in msg:
172
+ raise CDPError(f"{method}: {msg['error'].get('message')}")
173
+ return msg.get("result") or {}
174
+ raise CDPError(f"{method}: no answer in {timeout:.0f}s")
175
+
176
+ def evaluate(self, expr: str):
177
+ res = self.call("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True})
178
+ if res.get("exceptionDetails"):
179
+ raise CDPError(f"page script failed: {res['exceptionDetails'].get('text')}")
180
+ return (res.get("result") or {}).get("value")
181
+
182
+ def wait_loaded(self, timeout: float = 15.0) -> None:
183
+ end = time.monotonic() + timeout
184
+ while time.monotonic() < end:
185
+ if self.evaluate("document.readyState") == "complete":
186
+ return
187
+ time.sleep(0.25)
188
+
189
+
190
+ def _http_json(port: int, path: str, method: str = "GET"):
191
+ req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", method=method)
192
+ with urllib.request.urlopen(req, timeout=5) as r:
193
+ return json.loads(r.read().decode() or "null")
194
+
195
+
196
+ def _pick_tab(port: int, url: str, tab: str) -> dict:
197
+ tabs = [t for t in _http_json(port, "/json/list") or [] if t.get("type") == "page"]
198
+ if tab:
199
+ for t in tabs:
200
+ if tab in (t.get("id"), ) or tab in str(t.get("url") or "") or tab in str(t.get("title") or ""):
201
+ return t
202
+ raise CDPError(f"no open tab matches {tab!r} (open: {', '.join(str(t.get('url'))[:60] for t in tabs[:8])})")
203
+ if url:
204
+ for t in tabs:
205
+ if str(t.get("url") or "").rstrip("/") == url.rstrip("/"):
206
+ return t
207
+ quoted = urllib.parse.quote(url, safe=":/?&=#%")
208
+ try:
209
+ return _http_json(port, f"/json/new?{quoted}", method="PUT")
210
+ except Exception:
211
+ return _http_json(port, f"/json/new?{quoted}")
212
+ if not tabs:
213
+ raise CDPError("the browser has no open page; pass url")
214
+ return tabs[0]
215
+
216
+
217
+ def inspect_page(url: str = "", tab: str = "", widths=None, screenshot: bool = True,
218
+ port: int = DEFAULT_PORT) -> dict:
219
+ """The page's boxes per width and one screenshot (at the widest width), or ``ok: False`` with why."""
220
+ widths = [int(w) for w in (widths or DEFAULT_WIDTHS) if 200 <= int(w) <= 4000][:5] or list(DEFAULT_WIDTHS)
221
+ try:
222
+ target = _pick_tab(port, url, tab)
223
+ except (OSError, ValueError) as e:
224
+ return {"ok": False, "content": f"inspect_page: no browser with remote debugging on port {port} ({e}). "
225
+ f"Start Chrome/Brave with --remote-debugging-port={port}."}
226
+ except CDPError as e:
227
+ return {"ok": False, "content": f"inspect_page: {e}"}
228
+ page = None
229
+ try:
230
+ page = _Page(target["webSocketDebuggerUrl"])
231
+ page.call("Page.enable")
232
+ if url and str(target.get("url") or "").rstrip("/") != url.rstrip("/"):
233
+ page.call("Page.navigate", {"url": url})
234
+ elif url:
235
+ page.call("Page.reload", {"ignoreCache": True})
236
+ page.wait_loaded()
237
+ viewports, title, href = [], "", url
238
+ for w in widths:
239
+ page.call("Emulation.setDeviceMetricsOverride",
240
+ {"width": w, "height": VIEW_HEIGHT, "deviceScaleFactor": 1, "mobile": w < 768})
241
+ time.sleep(0.4) # layout settles (media queries, transitions)
242
+ got = json.loads(page.evaluate(_COLLECT_JS) or "{}")
243
+ viewports.append({"width": w, "height": got.get("height") or VIEW_HEIGHT,
244
+ "scroll_width": got.get("scroll_width"), "elements": got.get("elements") or []})
245
+ title, href = got.get("title") or "", got.get("url") or url
246
+ png = ""
247
+ if screenshot:
248
+ shot = page.call("Page.captureScreenshot", {"format": "jpeg", "quality": 60})
249
+ png = "data:image/jpeg;base64," + str(shot.get("data") or "") if shot.get("data") else ""
250
+ page.call("Emulation.clearDeviceMetricsOverride")
251
+ except (CDPError, OSError, ValueError, KeyError) as e:
252
+ return {"ok": False, "content": f"inspect_page: {e}"}
253
+ finally:
254
+ if page is not None:
255
+ page.ws.close()
256
+ summary = "; ".join(f"{v['width']}px: {len(v['elements'])} boxes"
257
+ + (f", page {v['scroll_width']}px wide" if (v.get("scroll_width") or 0) > v["width"] else "")
258
+ for v in viewports)
259
+ return {"ok": True, "content": f"inspect_page {href}: {summary}"
260
+ + (f"; screenshot at {widths[-1]}px" if png else ""),
261
+ "url": href, "title": title, "viewports": viewports, "png_base64": png}
@@ -10,6 +10,12 @@ from dataclasses import dataclass
10
10
  from webbee.frames import _is_foreign_frame, render_foreign_frame
11
11
 
12
12
 
13
+ # Verbs that only read: the kernel never asks consent for them (a verified read, core.action_effect); a
14
+ # confirm_request naming one is answered yes here without a prompt (code graph spec §4.8: index reads
15
+ # never ask -- intel_sync_remote lists and reads a repo over SFTP, writes nothing, runs nothing there).
16
+ READ_VERBS = frozenset({"intel_sync_remote"})
17
+
18
+
13
19
  async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
14
20
  """ICNLI consent path. The client does NOT interpret consent words — it
15
21
  relays the user's RAW reply; the kernel brain interprets intent
@@ -17,7 +23,7 @@ async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
17
23
  `ask_consent(app_id, tool, args)` is an async coroutine returning the raw
18
24
  reply (resolved through the pinned dock, or a sync fallback reader)."""
19
25
  req_id = frame["req_id"]
20
- if mode == "autopilot":
26
+ if mode == "autopilot" or (not frame.get("app_id") and frame.get("tool") in READ_VERBS):
21
27
  return {"req_id": req_id, "result": {"approved": True}}
22
28
  if mode == "plan":
23
29
  return {"req_id": req_id, "result": {"approved": False, "reason": "plan_mode"}}
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
  from collections import defaultdict
3
3
 
4
- from webbee.intel.models import ProjectIndex, Symbol
4
+ from imperal_sdk.code_index import ProjectIndex, Symbol
5
5
 
6
6
 
7
7
  class CodeGraph:
@@ -0,0 +1,248 @@
1
+ """A code file read by meaning, not by windows (CPC ``outline`` / ``read_symbol``).
2
+
3
+ The same tree-sitter packages as the index (``indexer.py``) give, for one file:
4
+ - ``outline``: the definitions AND the significant nodes inside them with line ranges --
5
+ a big assignment (``security_content = Stack(``), a big keyword argument or object
6
+ property, a big returned tree, a comment header. For a declarative UI file this is its map;
7
+ - ``read_symbol``: exactly one such node, whole (in the read_file card form);
8
+ - ``syntax_errors``: the first ERROR / MISSING nodes.
9
+
10
+ Kept in step with the kernel's ``imperal_kernel/core/code_outline.py`` (it parses remote
11
+ files the same way). Fail-soft: no parser -> None, never a crash."""
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ LANG_BY_EXT = {".py": "python", ".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "javascript",
19
+ ".mjs": "javascript", ".cjs": "javascript"}
20
+ BIG_LINES = 12 # an inner node this long is a region of the file worth naming
21
+ MAX_NODES = 400 # one outline never floods a frame
22
+ _HEADER = re.compile(r"^\s*(?:#|//|/\*+)\s*(?:[=\-#*─—_~]{3,}\s*)?([^=\-#*─—_~\s][^\n]*?)\s*(?:[=\-#*─—_~]{3,})?\s*(?:\*/)?$")
23
+ _RULE = re.compile(r"^\s*(?:#|//|/\*+)\s*[=\-#*─—_~]{3,}")
24
+
25
+ _DEF_NODES = {
26
+ "python": {"function_definition": "function", "class_definition": "class"},
27
+ "typescript": {"function_declaration": "function", "class_declaration": "class", "method_definition": "method",
28
+ "interface_declaration": "class", "type_alias_declaration": "type", "enum_declaration": "class"},
29
+ "javascript": {"function_declaration": "function", "class_declaration": "class", "method_definition": "method"},
30
+ }
31
+ _DEF_NODES["tsx"] = _DEF_NODES["typescript"]
32
+ # inner nodes that name a region: (node type -> the field that holds its name)
33
+ _REGION_NODES = {
34
+ "python": {"assignment": "left", "keyword_argument": "name", "pair": "key", "decorated_definition": ""},
35
+ "typescript": {"variable_declarator": "name", "pair": "key", "assignment_expression": "left",
36
+ "public_field_definition": "name"},
37
+ "javascript": {"variable_declarator": "name", "pair": "key", "assignment_expression": "left",
38
+ "field_definition": "property"},
39
+ }
40
+ _REGION_NODES["tsx"] = _REGION_NODES["typescript"]
41
+ _FUNCTION_VALUES = {"arrow_function", "function_expression", "function", "lambda"}
42
+ _LANG_CACHE: Dict[str, Any] = {}
43
+
44
+
45
+ def lang_of(path: str) -> str:
46
+ return LANG_BY_EXT.get(os.path.splitext(str(path or ""))[1].lower(), "")
47
+
48
+
49
+ def _language(lang: str):
50
+ if lang in _LANG_CACHE:
51
+ return _LANG_CACHE[lang]
52
+ try:
53
+ from tree_sitter import Language
54
+ if lang == "python":
55
+ import tree_sitter_python as m
56
+ obj = Language(m.language())
57
+ elif lang in ("typescript", "tsx"):
58
+ import tree_sitter_typescript as m
59
+ obj = Language(m.language_tsx() if lang == "tsx" else m.language_typescript())
60
+ elif lang == "javascript":
61
+ import tree_sitter_javascript as m
62
+ obj = Language(m.language())
63
+ else:
64
+ obj = None
65
+ except Exception:
66
+ obj = None
67
+ _LANG_CACHE[lang] = obj
68
+ return obj
69
+
70
+
71
+ def parse(path: str, text: str):
72
+ """``(tree, source bytes, lang)``, or None when the file is not code the parser knows."""
73
+ lang = lang_of(path)
74
+ language = _language(lang) if lang else None
75
+ if language is None:
76
+ return None
77
+ from tree_sitter import Parser
78
+ src = str(text or "").encode("utf-8", "replace")
79
+ return Parser(language).parse(src), src, lang
80
+
81
+
82
+ def _text(src: bytes, node) -> str:
83
+ return src[node.start_byte:node.end_byte].decode("utf-8", "replace")
84
+
85
+
86
+ def _first_line(src: bytes, node, n: int = 80) -> str:
87
+ return " ".join(_text(src, node).split("\n", 1)[0].split())[:n]
88
+
89
+
90
+ def _lines(node) -> Tuple[int, int]:
91
+ return node.start_point[0] + 1, node.end_point[0] + 1
92
+
93
+
94
+ def _value_of(node):
95
+ for f in ("right", "value"):
96
+ v = node.child_by_field_name(f)
97
+ if v is not None:
98
+ return v
99
+ return None
100
+
101
+
102
+ def _callee(src: bytes, value) -> str:
103
+ """What a region holds, in a few words: ``Stack(``, ``<Dialog>``, ``[...]``."""
104
+ if value is None:
105
+ return ""
106
+ if value.type in ("call", "call_expression", "new_expression"):
107
+ fn = value.child_by_field_name("function") or value.child_by_field_name("constructor")
108
+ return (_first_line(src, fn, 60) if fn is not None else "") + "("
109
+ if value.type in ("jsx_element", "jsx_self_closing_element", "parenthesized_expression"):
110
+ return "<" + _first_line(src, value, 60).lstrip("(<").split(" ", 1)[0].split(">", 1)[0] + ">"
111
+ if value.type in ("list", "array"):
112
+ return "[...]"
113
+ if value.type in ("dictionary", "object"):
114
+ return "{...}"
115
+ return ""
116
+
117
+
118
+ def outline(path: str, text: str) -> Optional[List[Dict[str, Any]]]:
119
+ """The file's map: ``[{name, kind, start, end, depth}]`` in line order (definitions, their big
120
+ inner regions, comment headers). None when the file is not code the parser knows."""
121
+ parsed = parse(path, text)
122
+ if parsed is None:
123
+ return None
124
+ tree, src, lang = parsed
125
+ defs, regions = _DEF_NODES.get(lang, {}), _REGION_NODES.get(lang, {})
126
+ out: List[Dict[str, Any]] = []
127
+
128
+ def add(name: str, kind: str, node, depth: int) -> None:
129
+ a, b = _lines(node)
130
+ out.append({"name": name, "kind": kind, "start": a, "end": b, "depth": depth})
131
+
132
+ stack = [(tree.root_node, 0, "", 0)]
133
+ while stack:
134
+ node, depth, owner, nest = stack.pop() # nest: regions this node sits in
135
+ kind = defs.get(node.type)
136
+ child_depth, child_owner, child_nest = depth, owner, nest
137
+ if kind:
138
+ nm = node.child_by_field_name("name")
139
+ name = _text(src, nm) if nm is not None else _first_line(src, node, 40)
140
+ full = f"{owner}.{name}" if owner else name
141
+ add(full, kind, node, depth)
142
+ child_depth, child_owner, child_nest = depth + 1, full, 0
143
+ elif node.type in regions and node.type != "decorated_definition":
144
+ field = regions[node.type]
145
+ nm = node.child_by_field_name(field) if field else None
146
+ value = _value_of(node)
147
+ a, b = _lines(node)
148
+ if nm is not None and value is not None and value.type in _FUNCTION_VALUES and lang != "python":
149
+ full = f"{owner}.{_text(src, nm)}" if owner else _text(src, nm)
150
+ add(full, "function", node, depth) # const Component = () => ...
151
+ child_depth, child_owner, child_nest = depth + 1, full, 0
152
+ elif nm is not None and b - a + 1 >= BIG_LINES * (1 + 2 * nest):
153
+ # a region inside a region is named only when it is a large part of it
154
+ label = " ".join(_text(src, nm).split())[:60]
155
+ what = _callee(src, value)
156
+ add(f"{owner}.{label}" if owner else label, "region", node, depth)
157
+ out[-1]["holds"] = what
158
+ child_depth, child_nest = depth + 1, nest + 1
159
+ elif node.type in ("return_statement",) and owner:
160
+ a, b = _lines(node)
161
+ value = node.named_children[0] if node.named_children else None
162
+ if value is not None and b - a + 1 >= BIG_LINES:
163
+ add(f"{owner}.return", "region", node, depth)
164
+ out[-1]["holds"] = _callee(src, value)
165
+ elif node.type == "comment":
166
+ line = _text(src, node).split("\n", 1)[0]
167
+ prev = node.prev_sibling
168
+ ruled = bool(_RULE.match(line)) or (prev is not None and prev.type == "comment"
169
+ and bool(_RULE.match(_text(src, prev))))
170
+ m = _HEADER.match(line)
171
+ if ruled and m and m.group(1).strip() and not _RULE.fullmatch(line.strip()):
172
+ add(m.group(1).strip()[:80], "section", node, depth)
173
+ for ch in reversed(node.children):
174
+ stack.append((ch, child_depth, child_owner, child_nest))
175
+ out.sort(key=lambda d: (d["start"], d["depth"]))
176
+ return out[:MAX_NODES]
177
+
178
+
179
+ def render_outline(path: str, nodes: List[Dict[str, Any]], total_lines: int) -> str:
180
+ """The outline as the model reads it: one line per node, indented by depth."""
181
+ head = f"OUTLINE of {path} ({total_lines} lines; read a part whole with read_symbol(path, name), "\
182
+ "or read_file with offset/limit):"
183
+ rows = [f"{' ' * int(n['depth'])}{n['start']}-{n['end']} {n['kind']} {n['name']}"
184
+ + (f" = {n['holds']}" if n.get("holds") else "") for n in nodes]
185
+ return "\n".join([head] + rows)
186
+
187
+
188
+ _RANGE = re.compile(r"^L?(\d+)\s*[-:]\s*L?(\d+)$")
189
+
190
+
191
+ def find_symbol(nodes: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
192
+ """The outline node ``name`` names: an exact full name, then the last dotted part, then case-blind."""
193
+ want = str(name or "").strip()
194
+ if not want:
195
+ return None
196
+ m = _RANGE.match(want)
197
+ if m:
198
+ return {"name": want, "kind": "lines", "start": int(m.group(1)), "end": int(m.group(2)), "depth": 0}
199
+ for pick in (lambda n: n["name"] == want,
200
+ lambda n: n["name"].rsplit(".", 1)[-1] == want,
201
+ lambda n: n["name"].lower() == want.lower() or n["name"].rsplit(".", 1)[-1].lower() == want.lower()):
202
+ hit = next((n for n in nodes if pick(n)), None)
203
+ if hit is not None:
204
+ return hit
205
+ return None
206
+
207
+
208
+ def read_symbol(path: str, text: str, name: str) -> Optional[Dict[str, Any]]:
209
+ """``{name, kind, start, end, body}`` of one node, whole -- in the shape of a read_file
210
+ window, so the kernel remembers it as the file's exact lines. None when not found."""
211
+ nodes = outline(path, text) or []
212
+ hit = find_symbol(nodes, name)
213
+ if hit is None:
214
+ return None
215
+ lines = str(text or "").split("\n")
216
+ a, b = max(1, hit["start"]), min(len(lines), hit["end"])
217
+ return {**hit, "start": a, "end": b, "body": "\n".join(lines[a - 1:b])}
218
+
219
+
220
+ def read_card(path: str, total_lines: int, a: int, b: int, body: str) -> str:
221
+ """A part of a file in the read_file card form (``strip_file_card`` reads it back)."""
222
+ return f"⟦ {path} · {total_lines} lines ⟧ [showing lines {a}-{b} of {total_lines}]\n{body}"
223
+
224
+
225
+ def syntax_errors(path: str, text: str, limit: int = 3) -> Optional[List[Tuple[int, int, str]]]:
226
+ """The first ERROR / MISSING nodes: ``[(line, column, what)]``; [] when the file parses;
227
+ None when the file is not code the parser knows."""
228
+ parsed = parse(path, text)
229
+ if parsed is None:
230
+ return None
231
+ tree, src, _lang = parsed
232
+ if not tree.root_node.has_error:
233
+ return []
234
+ out: List[Tuple[int, int, str]] = []
235
+ stack = [tree.root_node]
236
+ while stack and len(out) < limit:
237
+ node = stack.pop()
238
+ if node.is_missing:
239
+ out.append((node.start_point[0] + 1, node.start_point[1] + 1, f"missing {node.type!r}"))
240
+ continue
241
+ if node.type == "ERROR":
242
+ out.append((node.start_point[0] + 1, node.start_point[1] + 1,
243
+ f"unexpected {_first_line(src, node, 40)!r}"))
244
+ continue
245
+ if node.has_error:
246
+ stack.extend(reversed(node.children))
247
+ return sorted(out)
248
+