webbee 0.5.7__tar.gz → 0.5.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webbee-0.5.7 → webbee-0.5.9}/CHANGELOG.md +15 -0
- {webbee-0.5.7 → webbee-0.5.9}/PKG-INFO +6 -4
- {webbee-0.5.7 → webbee-0.5.9}/README.md +5 -3
- {webbee-0.5.7 → webbee-0.5.9}/pyproject.toml +1 -1
- webbee-0.5.9/src/webbee/__init__.py +1 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/checkpoints.py +5 -3
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/consent.py +1 -1
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/service.py +18 -2
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/modals.py +63 -17
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tools.py +23 -1
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tui.py +15 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_checkpoints.py +14 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_service.py +39 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_modals_e2e.py +114 -1
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tools.py +37 -0
- webbee-0.5.7/src/webbee/__init__.py +0 -1
- {webbee-0.5.7 → webbee-0.5.9}/.github/workflows/publish.yml +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/.gitignore +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/LICENSE +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/install.sh +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/__main__.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/_launch.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/account.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/active_sessions.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/banner_art.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/boot.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/browser.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/cli.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/clipboard.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/clipboard_session.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/coding_context.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/commands.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/config.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/details.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/events.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/frames.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/home.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/home_view.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/http.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/instance_lock.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/graph.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/outline.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/query.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/store.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/intel/watch.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/media.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/mode_store.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/native_files.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/native_read.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/output_pane.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/pricing_constants.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/queue_panel.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/reflow.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/remote.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/render.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/repl.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/repo.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/selection.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/session.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/sessions.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/sizing.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/slots.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/steer.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/stream.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tab_store.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tabs.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/thread.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tier_store.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/todo_panel.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/tokens.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/update.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/urlopen.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/wallet.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/src/webbee/worktrees.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/__init__.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/conftest.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_account.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_active_sessions.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_cli.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_clipboard.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_clipboard_read.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_coding_context.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_commands.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_config.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_cpc_outline.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_cross_loop_token.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_details.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_events.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_freeze_fix.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_home.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_home_view.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_home_view_mouse.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_inspect_page.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_instance_lock.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_chunker.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_embed.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_graph.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_query.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_store.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_vectors.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_intel_watch.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_launch.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_marathon.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_mode_store.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_native_files.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_newtab_mode.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_packaging.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_queue_manage_0337.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_reflow.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_render.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_repl.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_repo.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_resilient_http.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_session.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_sessions.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_sizing.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_slots.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_steer.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_stream.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tab_bar_stability.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tab_restore_0337.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tabs.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_terminal_ux_0336.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_thread.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tier_invalidation.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tier_store.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_todo_panel.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tokens.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tools_vision_websearch.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tui.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_tui_hardening.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_update.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_update_badge_0340.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_urlopen.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_version.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_version_badge_corner.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_wallet.py +0 -0
- {webbee-0.5.7 → webbee-0.5.9}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.9
|
|
4
|
+
|
|
5
|
+
* **Code questions about your local repo go to the exact code graph in your Imperal vault**: on the kernel's request
|
|
6
|
+
webbee syncs the repo it works in (`intel_sync_local`) and the kernel asks the vault by that repo's key -- who
|
|
7
|
+
calls what by scope rules, uses inside the same file, tests counted apart. When the vault cannot be reached the
|
|
8
|
+
old local index answers and says so (`coarse`).
|
|
9
|
+
* **A remote repo is known by its real path** (a symlinked deploy folder is never synced as a second repo), and a
|
|
10
|
+
code question never waits behind a repo's first sync.
|
|
11
|
+
* **Checkpoints name the paths they changed** -- what imperal-mcp's frame measures an agent's edits by.
|
|
12
|
+
* **Voice**: the recorder stops with its window -- no orphaned ffmpeg filling the disk.
|
|
13
|
+
|
|
14
|
+
## 0.5.8
|
|
15
|
+
|
|
16
|
+
* **README**: the supported Python versions (3.7 – 3.14) at the top of the page.
|
|
17
|
+
|
|
3
18
|
## 0.5.7
|
|
4
19
|
|
|
5
20
|
* **The one-time Python 3.12 setup cannot be steered by the folder it starts in**: uv now runs with
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: webbee
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.9
|
|
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
|
|
@@ -56,10 +56,12 @@ Description-Content-Type: text/markdown
|
|
|
56
56
|
# Webbee 🐝 — The Open-Source ICNLI Coding Agent
|
|
57
57
|
|
|
58
58
|
[](https://pypi.org/project/webbee/)
|
|
59
|
-
[](https://pypi.org/project/webbee/)
|
|
59
|
+
[](https://pypi.org/project/webbee/)
|
|
60
60
|
[](LICENSE)
|
|
61
61
|
[](https://docs.imperal.io)
|
|
62
62
|
|
|
63
|
+
**Python 3.7 – 3.14** · `pip install webbee` works on every one of them.
|
|
64
|
+
|
|
63
65
|
**Webbee** is the free, open-source terminal coding agent of [Imperal Cloud](https://imperal.io) (the ICNLI AI Cloud OS). It acts directly in your local terminal and workspace: reading, writing, editing, refactoring, and verifying code with deep code intelligence, native multimodal vision, and live cross-platform system capabilities.
|
|
64
66
|
|
|
65
67
|
Webbee is powered by **ICNLI** (*Infrastructure Contextual Natural Language Interface* — [icnli.org](https://icnli.org)), the open protocol that maps natural-language intent to context-aware infrastructure actions.
|
|
@@ -100,8 +102,8 @@ Works with **any Python from 3.7**:
|
|
|
100
102
|
- **Python 3.7 – 3.9** (macOS ships 3.9, Ubuntu 20.04 has 3.8) — the install is light, and the first
|
|
101
103
|
`webbee` command sets up the same release on its own Python 3.12, once (about 30–45 s, needs internet;
|
|
102
104
|
it fetches uv to do it). Every start after that is immediate, and it is the full webbee — code index
|
|
103
|
-
and search by meaning included. It lives in `~/.local/share/webbee/runtime`
|
|
104
|
-
|
|
105
|
+
and search by meaning included. It lives in `~/.local/share/webbee/runtime` and changes nothing else on
|
|
106
|
+
your system.
|
|
105
107
|
|
|
106
108
|
<details>
|
|
107
109
|
<summary>Install problems</summary>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# Webbee 🐝 — The Open-Source ICNLI Coding Agent
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/webbee/)
|
|
4
|
-
[](https://pypi.org/project/webbee/)
|
|
4
|
+
[](https://pypi.org/project/webbee/)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://docs.imperal.io)
|
|
7
7
|
|
|
8
|
+
**Python 3.7 – 3.14** · `pip install webbee` works on every one of them.
|
|
9
|
+
|
|
8
10
|
**Webbee** is the free, open-source terminal coding agent of [Imperal Cloud](https://imperal.io) (the ICNLI AI Cloud OS). It acts directly in your local terminal and workspace: reading, writing, editing, refactoring, and verifying code with deep code intelligence, native multimodal vision, and live cross-platform system capabilities.
|
|
9
11
|
|
|
10
12
|
Webbee is powered by **ICNLI** (*Infrastructure Contextual Natural Language Interface* — [icnli.org](https://icnli.org)), the open protocol that maps natural-language intent to context-aware infrastructure actions.
|
|
@@ -45,8 +47,8 @@ Works with **any Python from 3.7**:
|
|
|
45
47
|
- **Python 3.7 – 3.9** (macOS ships 3.9, Ubuntu 20.04 has 3.8) — the install is light, and the first
|
|
46
48
|
`webbee` command sets up the same release on its own Python 3.12, once (about 30–45 s, needs internet;
|
|
47
49
|
it fetches uv to do it). Every start after that is immediate, and it is the full webbee — code index
|
|
48
|
-
and search by meaning included. It lives in `~/.local/share/webbee/runtime`
|
|
49
|
-
|
|
50
|
+
and search by meaning included. It lives in `~/.local/share/webbee/runtime` and changes nothing else on
|
|
51
|
+
your system.
|
|
50
52
|
|
|
51
53
|
<details>
|
|
52
54
|
<summary>Install problems</summary>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.9"
|
|
@@ -107,7 +107,8 @@ class ShadowGit:
|
|
|
107
107
|
|
|
108
108
|
def checkpoint(self, label: str = "", force: bool = False) -> "dict | None":
|
|
109
109
|
"""Commit the workspace state to the shadow history.
|
|
110
|
-
{"id","n","label","changed"} or None when unavailable/failed
|
|
110
|
+
{"id","n","label","changed","paths"} or None when unavailable/failed --
|
|
111
|
+
``paths``: the workspace paths this snapshot changed (sorted; [] when none).
|
|
111
112
|
`force=True` always records a new checkpoint even with nothing dirty
|
|
112
113
|
-- used by rollback() for its mandatory pre-rollback safety snapshot,
|
|
113
114
|
which must exist even when the workspace already matches HEAD."""
|
|
@@ -117,10 +118,11 @@ class ShadowGit:
|
|
|
117
118
|
if self._git("add", "-A").returncode != 0:
|
|
118
119
|
return None # a partial add must never masquerade as a snapshot
|
|
119
120
|
dirty = self._git("status", "--porcelain").stdout.strip()
|
|
121
|
+
paths = sorted({ln[3:].split(" -> ")[-1].strip().strip('"') for ln in dirty.splitlines() if len(ln) > 3})
|
|
120
122
|
head = self._git("rev-parse", "--short", "HEAD")
|
|
121
123
|
if not dirty and head.returncode == 0 and not force:
|
|
122
124
|
return {"id": head.stdout.strip(), "n": self._count(),
|
|
123
|
-
"label": "(no changes)", "changed": False}
|
|
125
|
+
"label": "(no changes)", "changed": False, "paths": []}
|
|
124
126
|
msg = (label or "checkpoint").strip()[:200] or "checkpoint"
|
|
125
127
|
args = ["commit", "-m", msg, "--no-verify"]
|
|
126
128
|
if head.returncode != 0 or not dirty:
|
|
@@ -131,7 +133,7 @@ class ShadowGit:
|
|
|
131
133
|
sha = self._git("rev-parse", "--short", "HEAD").stdout.strip()
|
|
132
134
|
n = self._count() + 1
|
|
133
135
|
self._git("update-ref", f"refs/webbee/cp-{n}", "HEAD")
|
|
134
|
-
return {"id": sha, "n": n, "label": msg, "changed": True}
|
|
136
|
+
return {"id": sha, "n": n, "label": msg, "changed": True, "paths": paths}
|
|
135
137
|
except Exception:
|
|
136
138
|
return None
|
|
137
139
|
|
|
@@ -13,7 +13,7 @@ from webbee.frames import _is_foreign_frame, render_foreign_frame
|
|
|
13
13
|
# Verbs that only read: the kernel never asks consent for them (a verified read, core.action_effect); a
|
|
14
14
|
# confirm_request naming one is answered yes here without a prompt (code graph spec §4.8: index reads
|
|
15
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"})
|
|
16
|
+
READ_VERBS = frozenset({"intel_sync_remote", "intel_sync_local"})
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
|
|
@@ -278,7 +278,7 @@ class IntelService:
|
|
|
278
278
|
if fresh_ids:
|
|
279
279
|
self.vectors.add(fresh_ids, fresh_vecs)
|
|
280
280
|
|
|
281
|
-
def _sync_vault(self, rels=None) -> dict:
|
|
281
|
+
def _sync_vault(self, rels=None, history: bool = True) -> dict:
|
|
282
282
|
"""The workspace (``rels=None``: every indexable file, with the git history's co-change links) or the
|
|
283
283
|
changed files into the vault. Fail-soft: a cloud outage never touches the local index."""
|
|
284
284
|
factory = self.vault_client_factory
|
|
@@ -309,7 +309,7 @@ class IntelService:
|
|
|
309
309
|
"machine_kind": "local", "git": local_git_identity(self.root),
|
|
310
310
|
"local_key": compute_repo_key(self.root), "listing_complete": rels is None}
|
|
311
311
|
listing = _local_listing(self.root, rels)
|
|
312
|
-
measured = co_change_pairs(self.root) if rels is None else None
|
|
312
|
+
measured = co_change_pairs(self.root) if rels is None and history else None
|
|
313
313
|
out = asyncio.run(vault_sync(factory(), meta, listing, read, measured))
|
|
314
314
|
except Exception as exc:
|
|
315
315
|
log.warning("code sync to the vault failed: %s", exc)
|
|
@@ -323,6 +323,22 @@ class IntelService:
|
|
|
323
323
|
return out
|
|
324
324
|
|
|
325
325
|
|
|
326
|
+
def sync_now(self) -> dict:
|
|
327
|
+
"""The kernel asks before a code question (frame phase A): the workspace reaches the vault now -- a full
|
|
328
|
+
listing, so an edit no watcher reported (a shell edit) is sent too; the vault's plan picks the changed files.
|
|
329
|
+
The git history is not re-read. ``{repo_key, synced_at, ...counts}`` or ``{ok: False, content}``."""
|
|
330
|
+
if self.vault_client_factory is None:
|
|
331
|
+
return {"ok": False, "content": "this session cannot reach the cloud"}
|
|
332
|
+
if not self.last_sync and not self._sync_lock.acquire(blocking=False):
|
|
333
|
+
# the repo's first sync (a whole repo: extract + embed) is still running: never wait behind it
|
|
334
|
+
return {"ok": False, "content": "the first code sync of this repo is still running"}
|
|
335
|
+
if not self.last_sync:
|
|
336
|
+
self._sync_lock.release()
|
|
337
|
+
out = self._sync_vault(None, history=False)
|
|
338
|
+
if not out.get("repo_key"):
|
|
339
|
+
return {"ok": False, "content": "the code sync to the vault failed"}
|
|
340
|
+
return {**out, "synced_at": time.time()}
|
|
341
|
+
|
|
326
342
|
def sync_vault(self, rels=None) -> None:
|
|
327
343
|
"""Off the caller's thread: the build and the watcher never wait for the cloud."""
|
|
328
344
|
if self.vault_client_factory is None:
|
|
@@ -10,6 +10,7 @@ Enterprise Grade Multi-OS popups with:
|
|
|
10
10
|
"""
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import atexit
|
|
13
14
|
import itertools
|
|
14
15
|
import os
|
|
15
16
|
import re
|
|
@@ -223,18 +224,56 @@ def get_macos_builtin_audio_device() -> str:
|
|
|
223
224
|
return ":0"
|
|
224
225
|
|
|
225
226
|
|
|
227
|
+
# A voice note never outlives this, even if every stop path fails: ffmpeg ends itself (-t).
|
|
228
|
+
MAX_VOICE_NOTE_SECONDS = 900
|
|
229
|
+
|
|
230
|
+
|
|
226
231
|
def get_system_audio_capture_args(output_path: str) -> List[str]:
|
|
227
|
-
"""Build cross-platform FFmpeg audio recording CLI arguments for macOS, Linux, and Windows.
|
|
232
|
+
"""Build cross-platform FFmpeg audio recording CLI arguments for macOS, Linux, and Windows.
|
|
233
|
+
|
|
234
|
+
-nostdin: ffmpeg never reads the terminal (the TUI owns the keys); -t caps the length."""
|
|
228
235
|
if sys.platform == "darwin":
|
|
229
236
|
dev = get_macos_builtin_audio_device()
|
|
230
|
-
|
|
237
|
+
src = ["-f", "avfoundation", "-i", dev, "-ar", "16000", "-ac", "1"]
|
|
231
238
|
elif sys.platform.startswith("linux"):
|
|
232
239
|
if shutil.which("pactl"):
|
|
233
|
-
|
|
234
|
-
|
|
240
|
+
src = ["-f", "pulse", "-i", "default", "-ar", "16000", "-ac", "1"]
|
|
241
|
+
else:
|
|
242
|
+
src = ["-f", "alsa", "-i", "default", "-ar", "16000", "-ac", "1"]
|
|
235
243
|
elif sys.platform in ("win32", "cygwin"):
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
src = ["-f", "dshow", "-i", "audio=virtual-audio-device", "-ar", "16000", "-ac", "1"]
|
|
245
|
+
else:
|
|
246
|
+
src = ["-i", "default"]
|
|
247
|
+
return ["ffmpeg", "-nostdin", "-y", *src, "-t", str(MAX_VOICE_NOTE_SECONDS), output_path]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# Every recorder still running; atexit stops them so a quit Webbee leaves no microphone open.
|
|
251
|
+
_LIVE_RECORDERS: set = set()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def stop_recorder(proc: Optional[subprocess.Popen]) -> None:
|
|
255
|
+
"""Stop one ffmpeg recorder: SIGTERM (ffmpeg finalizes the file), SIGKILL if it hangs."""
|
|
256
|
+
if proc is None:
|
|
257
|
+
return
|
|
258
|
+
_LIVE_RECORDERS.discard(proc)
|
|
259
|
+
try:
|
|
260
|
+
if proc.poll() is None:
|
|
261
|
+
proc.terminate()
|
|
262
|
+
try:
|
|
263
|
+
proc.wait(timeout=2)
|
|
264
|
+
except subprocess.TimeoutExpired:
|
|
265
|
+
proc.kill()
|
|
266
|
+
proc.wait(timeout=2)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def stop_all_recorders() -> None:
|
|
272
|
+
for proc in list(_LIVE_RECORDERS):
|
|
273
|
+
stop_recorder(proc)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
atexit.register(stop_all_recorders)
|
|
238
277
|
|
|
239
278
|
|
|
240
279
|
def transcribe_audio_file(audio_path: str, api_url: str = "", token: str = "") -> str:
|
|
@@ -397,10 +436,13 @@ def create_mic_dialog(
|
|
|
397
436
|
voice_path = os.path.join(voice_dir, voice_filename)
|
|
398
437
|
|
|
399
438
|
rec_proc: Optional[subprocess.Popen] = None
|
|
439
|
+
sent = False
|
|
400
440
|
|
|
401
441
|
try:
|
|
402
442
|
args = get_system_audio_capture_args(voice_path)
|
|
403
|
-
rec_proc = subprocess.Popen(args,
|
|
443
|
+
rec_proc = subprocess.Popen(args, stdin=subprocess.DEVNULL,
|
|
444
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
445
|
+
_LIVE_RECORDERS.add(rec_proc)
|
|
404
446
|
except Exception:
|
|
405
447
|
pass
|
|
406
448
|
|
|
@@ -417,18 +459,13 @@ def create_mic_dialog(
|
|
|
417
459
|
anim_ctrl = FormattedTextControl(_anim_text)
|
|
418
460
|
|
|
419
461
|
def _stop_rec() -> int:
|
|
420
|
-
nonlocal rec_proc
|
|
421
462
|
elapsed = max(1, int(time.time() - start_time))
|
|
422
|
-
|
|
423
|
-
try:
|
|
424
|
-
if rec_proc.poll() is None:
|
|
425
|
-
rec_proc.terminate()
|
|
426
|
-
rec_proc.wait(timeout=2)
|
|
427
|
-
except Exception:
|
|
428
|
-
pass
|
|
463
|
+
stop_recorder(rec_proc)
|
|
429
464
|
return elapsed
|
|
430
465
|
|
|
431
466
|
def _handle_send():
|
|
467
|
+
nonlocal sent
|
|
468
|
+
sent = True
|
|
432
469
|
rec_seconds = _stop_rec()
|
|
433
470
|
on_close()
|
|
434
471
|
|
|
@@ -481,13 +518,18 @@ def create_mic_dialog(
|
|
|
481
518
|
t = threading.Thread(target=_worker, daemon=True)
|
|
482
519
|
t.start()
|
|
483
520
|
|
|
484
|
-
def
|
|
521
|
+
def _discard():
|
|
522
|
+
if sent:
|
|
523
|
+
return
|
|
485
524
|
_stop_rec()
|
|
486
525
|
try:
|
|
487
526
|
if os.path.exists(voice_path):
|
|
488
527
|
os.remove(voice_path)
|
|
489
528
|
except Exception:
|
|
490
529
|
pass
|
|
530
|
+
|
|
531
|
+
def _handle_cancel():
|
|
532
|
+
_discard()
|
|
491
533
|
on_close()
|
|
492
534
|
|
|
493
535
|
# EXACTLY TWO BUTTONS: Send and Cancel matching all other dialog layouts
|
|
@@ -505,13 +547,17 @@ def create_mic_dialog(
|
|
|
505
547
|
cols = app.output.get_size()[1] if (app and app.output) else 80
|
|
506
548
|
dialog_width = Dimension(min=36, max=76, preferred=min(68, max(36, cols - 6)))
|
|
507
549
|
|
|
508
|
-
|
|
550
|
+
dialog = Dialog(
|
|
509
551
|
title="Voice Recording (STT)",
|
|
510
552
|
body=HSplit(all_elements),
|
|
511
553
|
buttons=[],
|
|
512
554
|
width=dialog_width,
|
|
513
555
|
modal=True,
|
|
514
556
|
)
|
|
557
|
+
# The host calls dispose() whenever the dialog goes away (Esc, another modal) -- not only
|
|
558
|
+
# via the two buttons -- so the microphone is never left recording.
|
|
559
|
+
dialog.dispose = _discard
|
|
560
|
+
return dialog
|
|
515
561
|
|
|
516
562
|
|
|
517
563
|
def create_session_menu_dialog(
|
|
@@ -768,6 +768,24 @@ class LocalToolExecutor:
|
|
|
768
768
|
return []
|
|
769
769
|
|
|
770
770
|
def _cpc(self, verb: str, a: dict) -> dict:
|
|
771
|
+
out = self._cpc_local(verb, a)
|
|
772
|
+
# frame phase A: the kernel asks the vault graph first; this local index is the fallback -- links by name
|
|
773
|
+
if isinstance(out, dict) and out.get("ok", True) is not False:
|
|
774
|
+
out = {**out, "coarse": "vault graph unavailable: links by name"}
|
|
775
|
+
return out
|
|
776
|
+
|
|
777
|
+
def _t_intel_sync_local(self, a: dict) -> dict:
|
|
778
|
+
"""The workspace into the vault's code graph now (frame phase A): the kernel asks before a local code
|
|
779
|
+
question when the vault's copy is behind the last edit. A read: nothing is written or run."""
|
|
780
|
+
if self.indexer is None or not hasattr(self.indexer, "sync_now"):
|
|
781
|
+
return {"ok": False, "content": "intel_sync_local: no code index in this session"}
|
|
782
|
+
out = self.indexer.sync_now()
|
|
783
|
+
if out.get("ok") is False:
|
|
784
|
+
return {"ok": False, "content": f"intel_sync_local: {out.get('content')}"}
|
|
785
|
+
counts = {k: v for k, v in out.items() if k not in ("repo_key", "synced_at")}
|
|
786
|
+
return {"ok": True, "content": {"repo_key": out["repo_key"], "synced_at": out["synced_at"], "counts": counts}}
|
|
787
|
+
|
|
788
|
+
def _cpc_local(self, verb: str, a: dict) -> dict:
|
|
771
789
|
if self.indexer is None:
|
|
772
790
|
return {"ok": False, "content": "intel not available; install webbee[intel]"}
|
|
773
791
|
from webbee.intel import query
|
|
@@ -1136,6 +1154,10 @@ async def remote_sync(client, host: str, root: str, paths: list) -> dict:
|
|
|
1136
1154
|
return {"repo_key": "", "partial_reason": why, "content": f"{host}: {why}"}
|
|
1137
1155
|
try:
|
|
1138
1156
|
repo_root = await ci.sftp_repo_root(sftp, root)
|
|
1157
|
+
try: # the repo's identity is its real path: a symlinked dir names the release it points into
|
|
1158
|
+
real_root = str(await sftp.realpath(repo_root) or repo_root) if hasattr(sftp, "realpath") else repo_root
|
|
1159
|
+
except Exception:
|
|
1160
|
+
real_root = repo_root
|
|
1139
1161
|
meta_files = (await ci.sftp_read_meta(sftp, repo_root))["files"]
|
|
1140
1162
|
if paths:
|
|
1141
1163
|
listing = []
|
|
@@ -1154,7 +1176,7 @@ async def remote_sync(client, host: str, root: str, paths: list) -> dict:
|
|
|
1154
1176
|
meta = {"machine": {"fingerprints": fps,
|
|
1155
1177
|
"machine_id": meta_files.get("/etc/machine-id", b"").decode("utf-8", "replace").strip(),
|
|
1156
1178
|
"label": host},
|
|
1157
|
-
"root":
|
|
1179
|
+
"root": real_root, "root_path": real_root, "os": tree["os"], "source": "system_ssh", "ref": host,
|
|
1158
1180
|
"machine_kind": "ssh", "git": ci.git_identity(meta_files), "listing_complete": not paths}
|
|
1159
1181
|
if tree.get("partial"):
|
|
1160
1182
|
meta["partial_listing"] = tree["partial"]
|
|
@@ -1886,7 +1886,19 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
|
|
|
1886
1886
|
if app is not None:
|
|
1887
1887
|
app.invalidate()
|
|
1888
1888
|
|
|
1889
|
+
def _dispose_modal(dialog):
|
|
1890
|
+
# A dialog that holds a resource (the mic recorder) releases it however it is closed.
|
|
1891
|
+
dispose = getattr(dialog, "dispose", None)
|
|
1892
|
+
if callable(dispose):
|
|
1893
|
+
try:
|
|
1894
|
+
dispose()
|
|
1895
|
+
except Exception:
|
|
1896
|
+
pass
|
|
1897
|
+
|
|
1889
1898
|
def _set_modal_active(dialog):
|
|
1899
|
+
prev = _active_modal.get("dialog")
|
|
1900
|
+
if prev is not None and prev is not dialog:
|
|
1901
|
+
_dispose_modal(prev)
|
|
1890
1902
|
_active_modal["dialog"] = dialog
|
|
1891
1903
|
from prompt_toolkit.application import get_app_or_none
|
|
1892
1904
|
app = get_app_or_none()
|
|
@@ -1900,7 +1912,10 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
|
|
|
1900
1912
|
_invalidate()
|
|
1901
1913
|
|
|
1902
1914
|
def _close_modal():
|
|
1915
|
+
prev = _active_modal.get("dialog")
|
|
1903
1916
|
_active_modal["dialog"] = None
|
|
1917
|
+
if prev is not None:
|
|
1918
|
+
_dispose_modal(prev)
|
|
1904
1919
|
_hover_on["v"] = None
|
|
1905
1920
|
# Guarantee buffer is 100% clean of any mouse escape residue on modal close
|
|
1906
1921
|
buf.text = scrub_mouse_residue(buf.text)
|
|
@@ -276,3 +276,17 @@ def test_describe_surfaces_paused_autocheckpoint(tmp_path):
|
|
|
276
276
|
out = sg.describe()
|
|
277
277
|
assert out.startswith("⚠ Auto-checkpointing is paused")
|
|
278
278
|
assert "cp-1" in out # the list still follows the warning
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_checkpoint_names_the_changed_paths(tmp_path):
|
|
282
|
+
"""Phase B Task 10: a snapshot says which paths it changed (imperal-mcp measures an agent's edits by it)."""
|
|
283
|
+
from webbee.checkpoints import ShadowGit
|
|
284
|
+
ws = tmp_path / "ws"
|
|
285
|
+
ws.mkdir()
|
|
286
|
+
(ws / "a.py").write_text("x=1\n")
|
|
287
|
+
sg = ShadowGit(str(ws), "k", cache_dir=str(tmp_path / "c"))
|
|
288
|
+
assert sg.ensure()
|
|
289
|
+
assert sg.checkpoint("base", force=True)["paths"] == ["a.py"]
|
|
290
|
+
(ws / "a.py").write_text("x=2\n")
|
|
291
|
+
assert sg.checkpoint("next")["paths"] == ["a.py"]
|
|
292
|
+
assert sg.checkpoint("same")["paths"] == []
|
|
@@ -287,3 +287,42 @@ def test_a_link_leading_out_of_the_workspace_is_never_read(tmp_path, monkeypatch
|
|
|
287
287
|
sent = [c[1] for c in cloud.calls if "files" in c[1]]
|
|
288
288
|
assert sent and all(f["path"] != "leak.py" for c in sent for f in c["files"])
|
|
289
289
|
assert "outside the workspace" not in repr(cloud.calls)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_sync_now_sends_an_edit_the_watcher_has_not_reported(tmp_path, monkeypatch):
|
|
293
|
+
"""frame phase A: the kernel asks for a sync before a code question -- the edit reaches the vault at once,
|
|
294
|
+
even one no watcher reported (a shell edit), and without re-reading the git history."""
|
|
295
|
+
(tmp_path / "a.py").write_text("def alpha():\n return 1\n")
|
|
296
|
+
svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
|
|
297
|
+
_synced(svc, monkeypatch)
|
|
298
|
+
cloud = _Cloud()
|
|
299
|
+
svc.build()
|
|
300
|
+
svc.attach_vault(lambda: cloud)
|
|
301
|
+
cloud.calls.clear()
|
|
302
|
+
cloud.have = {"a.py"}
|
|
303
|
+
(tmp_path / "b.py").write_text("def beta():\n return 2\n") # a shell edit, never reported
|
|
304
|
+
out = svc.sync_now()
|
|
305
|
+
assert out["repo_key"] and out["synced_at"] > 0
|
|
306
|
+
assert [f["path"] for f in cloud.calls[1][1]["files"]] == ["b.py"]
|
|
307
|
+
assert "measured" not in cloud.calls[1][1]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_sync_now_without_cloud_says_so(tmp_path):
|
|
311
|
+
svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
|
|
312
|
+
assert svc.sync_now() == {"ok": False, "content": "this session cannot reach the cloud"}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_sync_now_while_the_first_sync_runs_answers_at_once(tmp_path, monkeypatch):
|
|
316
|
+
"""frame phase A (review I7): a big repo's first sync is still running -- the question goes coarse at once
|
|
317
|
+
instead of waiting behind it."""
|
|
318
|
+
(tmp_path / "a.py").write_text("def alpha():\n return 1\n")
|
|
319
|
+
svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
|
|
320
|
+
svc.build()
|
|
321
|
+
svc.vault_client_factory = lambda: _Cloud()
|
|
322
|
+
svc.last_sync = {}
|
|
323
|
+
assert svc._sync_lock.acquire(blocking=False) # the boot-time first sync holds the lock
|
|
324
|
+
try:
|
|
325
|
+
out = svc.sync_now()
|
|
326
|
+
finally:
|
|
327
|
+
svc._sync_lock.release()
|
|
328
|
+
assert out == {"ok": False, "content": "the first code sync of this repo is still running"}
|
|
@@ -73,7 +73,46 @@ def test_file_picker_dialog_callbacks():
|
|
|
73
73
|
assert dialog.title == "Attach File / Document"
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
class _FakeRecorder:
|
|
77
|
+
"""Stands in for the ffmpeg Popen -- tests never open the real microphone."""
|
|
78
|
+
def __init__(self, args, **kw):
|
|
79
|
+
self.args, self.kw = args, kw
|
|
80
|
+
self.alive = True
|
|
81
|
+
self.signals = []
|
|
82
|
+
with open(args[-1], "wb") as f:
|
|
83
|
+
f.write(b"RIFF")
|
|
84
|
+
|
|
85
|
+
def poll(self):
|
|
86
|
+
return None if self.alive else 0
|
|
87
|
+
|
|
88
|
+
def terminate(self):
|
|
89
|
+
self.signals.append("TERM")
|
|
90
|
+
self.alive = False
|
|
91
|
+
|
|
92
|
+
def kill(self):
|
|
93
|
+
self.signals.append("KILL")
|
|
94
|
+
self.alive = False
|
|
95
|
+
|
|
96
|
+
def wait(self, timeout=None):
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.fixture
|
|
101
|
+
def fake_recorder(monkeypatch, tmp_path):
|
|
102
|
+
import webbee.modals as m
|
|
103
|
+
made = []
|
|
104
|
+
|
|
105
|
+
def _popen(args, **kw):
|
|
106
|
+
made.append(_FakeRecorder(args, **kw))
|
|
107
|
+
return made[-1]
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr(m.subprocess, "Popen", _popen)
|
|
110
|
+
monkeypatch.setattr(m, "get_macos_builtin_audio_device", lambda: ":0")
|
|
111
|
+
return made, str(tmp_path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_mic_dialog_callbacks(fake_recorder):
|
|
115
|
+
_, voice_dir = fake_recorder
|
|
77
116
|
transcribed = []
|
|
78
117
|
sent = []
|
|
79
118
|
closed = []
|
|
@@ -82,8 +121,80 @@ def test_mic_dialog_callbacks():
|
|
|
82
121
|
on_transcribe=lambda t: transcribed.append(t),
|
|
83
122
|
on_send_direct=lambda t: sent.append(t),
|
|
84
123
|
on_close=lambda: closed.append(True),
|
|
124
|
+
voice_dir=voice_dir,
|
|
85
125
|
)
|
|
86
126
|
assert dialog.title == "Voice Recording (STT)"
|
|
127
|
+
dialog.dispose()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_mic_dialog_dispose_stops_recording_and_drops_the_file(fake_recorder):
|
|
131
|
+
# Esc / another modal closes the dialog through dispose(), not the buttons:
|
|
132
|
+
# the recorder must stop and the unsent audio must go.
|
|
133
|
+
made, voice_dir = fake_recorder
|
|
134
|
+
dialog = create_mic_dialog(on_transcribe=lambda t: None, on_send_direct=lambda t: None,
|
|
135
|
+
on_close=lambda: None, voice_dir=voice_dir)
|
|
136
|
+
rec = made[0]
|
|
137
|
+
assert rec.kw.get("stdin") is not None # never the terminal's stdin
|
|
138
|
+
dialog.dispose()
|
|
139
|
+
assert rec.signals == ["TERM"] and not rec.alive
|
|
140
|
+
assert os.listdir(voice_dir) == []
|
|
141
|
+
dialog.dispose() # idempotent
|
|
142
|
+
assert rec.signals == ["TERM"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_stop_recorder_kills_a_recorder_that_ignores_term():
|
|
146
|
+
import subprocess
|
|
147
|
+
from webbee.modals import stop_recorder
|
|
148
|
+
|
|
149
|
+
class _Stuck(_FakeRecorder):
|
|
150
|
+
def __init__(self):
|
|
151
|
+
self.alive, self.signals = True, []
|
|
152
|
+
|
|
153
|
+
def terminate(self):
|
|
154
|
+
self.signals.append("TERM") # ignored
|
|
155
|
+
|
|
156
|
+
def wait(self, timeout=None):
|
|
157
|
+
if self.alive:
|
|
158
|
+
raise subprocess.TimeoutExpired("ffmpeg", timeout)
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
p = _Stuck()
|
|
162
|
+
stop_recorder(p)
|
|
163
|
+
assert p.signals == ["TERM", "KILL"] and not p.alive
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_quitting_stops_every_live_recorder(fake_recorder):
|
|
167
|
+
import webbee.modals as m
|
|
168
|
+
made, voice_dir = fake_recorder
|
|
169
|
+
create_mic_dialog(on_transcribe=lambda t: None, on_send_direct=lambda t: None,
|
|
170
|
+
on_close=lambda: None, voice_dir=voice_dir)
|
|
171
|
+
assert made[0] in m._LIVE_RECORDERS
|
|
172
|
+
m.stop_all_recorders() # what atexit runs
|
|
173
|
+
assert not made[0].alive and not m._LIVE_RECORDERS
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_mic_send_keeps_the_audio_for_transcription(fake_recorder, monkeypatch):
|
|
177
|
+
import webbee.modals as m
|
|
178
|
+
made, voice_dir = fake_recorder
|
|
179
|
+
seen = []
|
|
180
|
+
monkeypatch.setattr(m, "transcribe_audio_file",
|
|
181
|
+
lambda path, **kw: seen.append(os.path.exists(path)) or "hello")
|
|
182
|
+
buttons = {}
|
|
183
|
+
real_button = m.make_clean_button
|
|
184
|
+
monkeypatch.setattr(m, "make_clean_button",
|
|
185
|
+
lambda text, handler=None, **kw: buttons.setdefault(text, handler) and real_button(text, handler=handler, **kw))
|
|
186
|
+
host = {}
|
|
187
|
+
dialog = create_mic_dialog(on_transcribe=lambda t: None, on_send_direct=lambda t: None,
|
|
188
|
+
on_close=lambda: host["dialog"].dispose(), voice_dir=voice_dir)
|
|
189
|
+
host["dialog"] = dialog
|
|
190
|
+
buttons["▶ Send Voice Note"]()
|
|
191
|
+
import time
|
|
192
|
+
for _ in range(50):
|
|
193
|
+
if seen:
|
|
194
|
+
break
|
|
195
|
+
time.sleep(0.02)
|
|
196
|
+
assert seen == [True] # host's dispose on close did not delete it
|
|
197
|
+
assert not made[0].alive
|
|
87
198
|
|
|
88
199
|
|
|
89
200
|
def test_session_menu_dialog_callbacks():
|
|
@@ -122,6 +233,8 @@ def test_system_audio_capture_args():
|
|
|
122
233
|
assert len(cmd) > 0
|
|
123
234
|
assert cmd[0] == "ffmpeg"
|
|
124
235
|
assert "/tmp/test_out.wav" in cmd
|
|
236
|
+
assert "-nostdin" in cmd
|
|
237
|
+
assert cmd[cmd.index("-t") + 1] == "900"
|
|
125
238
|
|
|
126
239
|
|
|
127
240
|
def test_modal_input_insulation_prevents_command_line_pollution():
|
|
@@ -923,3 +923,40 @@ def test_an_asyncssh_connect_reports_every_key_of_the_host_the_user_trusts(monke
|
|
|
923
923
|
monkeypatch.setattr(asyncssh, "connect", connect)
|
|
924
924
|
sftp, conn, fps, why = asyncio.run(t._open_sftp("whm-jump"))
|
|
925
925
|
assert sftp == "sftp" and why == "" and fps == ["SHA256:ecdsa", "SHA256:ed", "SHA256:rsa"]
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def test_intel_sync_local_returns_the_vault_key(tmp_path):
|
|
929
|
+
class _Svc:
|
|
930
|
+
def sync_now(self):
|
|
931
|
+
return {"repo_key": "rk1", "synced_at": 5.0, "files": 1}
|
|
932
|
+
ex = LocalToolExecutor(str(tmp_path), indexer=_Svc())
|
|
933
|
+
out = ex._t_intel_sync_local({"root": str(tmp_path)})
|
|
934
|
+
assert out == {"ok": True, "content": {"repo_key": "rk1", "synced_at": 5.0, "counts": {"files": 1}}}
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def test_local_code_answers_marked_coarse(tmp_path):
|
|
938
|
+
from webbee.intel.service import IntelService
|
|
939
|
+
(tmp_path / "a.py").write_text("def alpha():\n return 1\n")
|
|
940
|
+
svc = IntelService(str(tmp_path), "rk", cache_dir=str(tmp_path / "c"))
|
|
941
|
+
svc.build()
|
|
942
|
+
ex = LocalToolExecutor(str(tmp_path), indexer=svc)
|
|
943
|
+
out = ex._t_impact_of_change({"symbols": ["alpha"], "_local_index": True})
|
|
944
|
+
assert out["coarse"] == "vault graph unavailable: links by name"
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def test_intel_sync_local_needs_no_consent():
|
|
948
|
+
from webbee.consent import READ_VERBS
|
|
949
|
+
assert "intel_sync_local" in READ_VERBS
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def test_a_symlinked_remote_repo_is_known_by_its_real_path(monkeypatch):
|
|
953
|
+
"""frame phase A (review I2): the repo's identity is the real path the server reports."""
|
|
954
|
+
sftp = _Sftp(WIN_TREE, WIN_FILES)
|
|
955
|
+
|
|
956
|
+
async def realpath(p):
|
|
957
|
+
return "/C:/real/proj" if p == "/C:/u/proj" else p
|
|
958
|
+
sftp.realpath = realpath
|
|
959
|
+
ex, cloud = _remote(monkeypatch, sftp)
|
|
960
|
+
ex.run("intel_sync_remote", {"host": "winbox", "root": "/C:/u/proj/Pkg"})
|
|
961
|
+
meta = cloud.calls[0][1]["meta"]
|
|
962
|
+
assert meta["root"] == meta["root_path"] == "/C:/real/proj"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.7"
|
|
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
|
|
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
|