expluro 0.2.0__tar.gz → 0.3.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.
- {expluro-0.2.0 → expluro-0.3.1}/PKG-INFO +1 -1
- {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/PKG-INFO +1 -1
- {expluro-0.2.0 → expluro-0.3.1}/pyproject.toml +1 -1
- {expluro-0.2.0 → expluro-0.3.1}/topo/capture.py +40 -5
- {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/_branch_detection.py +59 -3
- {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/_permissions.py +25 -4
- {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/acp.py +264 -59
- {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/claude.py +55 -19
- expluro-0.3.1/topo/drivers/kiro_session_files.py +277 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/main.py +7 -2
- {expluro-0.2.0 → expluro-0.3.1}/topo/picker.py +24 -5
- {expluro-0.2.0 → expluro-0.3.1}/topo/renderer.py +32 -2
- {expluro-0.2.0 → expluro-0.3.1}/topo/session.py +183 -25
- expluro-0.3.1/topo/snapshot.py +482 -0
- expluro-0.2.0/topo/drivers/kiro_session_files.py +0 -167
- expluro-0.2.0/topo/snapshot.py +0 -3
- {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/SOURCES.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/dependency_links.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/entry_points.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/requires.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/top_level.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/setup.cfg +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/__init__.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/auth.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/backend.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/constants.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/display.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/__init__.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/base.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/session_files.py +0 -0
- {expluro-0.2.0 → expluro-0.3.1}/topo/update_check.py +0 -0
|
@@ -27,6 +27,7 @@ from .constants import APP_DIR
|
|
|
27
27
|
if TYPE_CHECKING:
|
|
28
28
|
from .backend import BackendClient
|
|
29
29
|
from .drivers.base import BaseDriver, DriverEvent, ToolHookPayload
|
|
30
|
+
from .snapshot import SnapshotManager
|
|
30
31
|
|
|
31
32
|
logger = logging.getLogger(__name__)
|
|
32
33
|
|
|
@@ -56,10 +57,16 @@ class EventCapture:
|
|
|
56
57
|
backend: "BackendClient",
|
|
57
58
|
graph_id: str,
|
|
58
59
|
node_id: str,
|
|
60
|
+
snapshot_manager: "SnapshotManager | None" = None,
|
|
59
61
|
) -> None:
|
|
60
62
|
self._backend = backend
|
|
61
63
|
self._graph_id = graph_id
|
|
62
64
|
self._node_id = node_id
|
|
65
|
+
# Optional — when present, every side-effecting tool call gets a
|
|
66
|
+
# filesystem snapshot committed to a shadow git repo and the resulting
|
|
67
|
+
# commit hash + diff summary attached to the post_tool_use event.
|
|
68
|
+
# Powers `/branch` and `/checkout` filesystem rewind.
|
|
69
|
+
self._snapshot_manager: "SnapshotManager | None" = snapshot_manager
|
|
63
70
|
self._last_event_id: str | None = None
|
|
64
71
|
# Accumulated streaming text for the current turn.
|
|
65
72
|
# Flushed as message_ai on turn_result if text_complete never fires
|
|
@@ -297,16 +304,44 @@ class EventCapture:
|
|
|
297
304
|
|
|
298
305
|
async def _on_post_tool(self, payload: "ToolHookPayload") -> None:
|
|
299
306
|
is_side_effecting = payload.tool_name in SIDE_EFFECTING_TOOLS
|
|
307
|
+
|
|
308
|
+
# Snapshot the filesystem after a side-effecting tool. Even tools we
|
|
309
|
+
# think are read-only sometimes touch state (e.g. Bash that runs a
|
|
310
|
+
# build, Grep with --rewrite), so we let the SnapshotManager dedupe:
|
|
311
|
+
# if nothing changed, the commit is a no-op and returns the existing
|
|
312
|
+
# HEAD hash. Diff summary is attached to the event for browser display.
|
|
313
|
+
snapshot_hash: str | None = None
|
|
314
|
+
diff_summary: dict | None = None
|
|
315
|
+
if is_side_effecting and self._snapshot_manager is not None:
|
|
316
|
+
try:
|
|
317
|
+
snapshot_hash, diff_summary = await self._snapshot_manager.commit(
|
|
318
|
+
label=f"{payload.tool_name} {payload.tool_use_id[:8]}",
|
|
319
|
+
)
|
|
320
|
+
except Exception:
|
|
321
|
+
logger.exception("snapshot commit failed for %s", payload.tool_name)
|
|
322
|
+
|
|
323
|
+
event_data: dict[str, Any] = {
|
|
324
|
+
"tool_output": _truncate(payload.tool_output),
|
|
325
|
+
"is_error": payload.is_error,
|
|
326
|
+
"is_side_effecting": is_side_effecting,
|
|
327
|
+
}
|
|
328
|
+
if snapshot_hash:
|
|
329
|
+
event_data["snapshot_hash"] = snapshot_hash
|
|
330
|
+
if diff_summary:
|
|
331
|
+
event_data["diff_summary"] = diff_summary
|
|
332
|
+
|
|
300
333
|
event = self._build_event(
|
|
301
334
|
event_type="post_tool_use",
|
|
302
335
|
tool_name=payload.tool_name,
|
|
303
336
|
tool_use_id=payload.tool_use_id,
|
|
304
|
-
data=
|
|
305
|
-
"tool_output": _truncate(payload.tool_output),
|
|
306
|
-
"is_error": payload.is_error,
|
|
307
|
-
"is_side_effecting": is_side_effecting,
|
|
308
|
-
},
|
|
337
|
+
data=event_data,
|
|
309
338
|
)
|
|
339
|
+
# The Event model has a snapshot_hash field — the backend pulls it
|
|
340
|
+
# from the top level of the request payload, not from `data`. Set it
|
|
341
|
+
# here so it lands in the indexed column, not just the JSON blob.
|
|
342
|
+
if snapshot_hash:
|
|
343
|
+
event["snapshot_hash"] = snapshot_hash
|
|
344
|
+
|
|
310
345
|
if self._pending_user_message_text is not None:
|
|
311
346
|
self._buffered_hook_events.append(event)
|
|
312
347
|
return
|
|
@@ -83,8 +83,64 @@ def parse_branch_flag(buffered_head: str) -> tuple[float | None, str]:
|
|
|
83
83
|
Returns (confidence, remaining_text). When the head doesn't start with
|
|
84
84
|
a valid tag, confidence is None and the entire buffered_head is
|
|
85
85
|
returned as remaining_text — caller should treat it as regular content.
|
|
86
|
+
|
|
87
|
+
If the tag matches structurally but the digits are malformed (e.g.
|
|
88
|
+
`<!--BRANCH:.-->`), we still consume the tag bytes from the remaining
|
|
89
|
+
text but return None for confidence. That way a weird model emission
|
|
90
|
+
can't leak the tag to the user OR crash the dispatcher.
|
|
86
91
|
"""
|
|
87
92
|
m = BRANCH_FLAG_START_RE.match(buffered_head)
|
|
88
|
-
if m:
|
|
89
|
-
return
|
|
90
|
-
|
|
93
|
+
if not m:
|
|
94
|
+
return None, buffered_head
|
|
95
|
+
try:
|
|
96
|
+
confidence = float(m.group(1))
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None, buffered_head[m.end():]
|
|
99
|
+
return confidence, buffered_head[m.end():]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def strip_all_branch_tags(text: str) -> tuple[float | None, str]:
|
|
103
|
+
"""Strip every leading BRANCH tag from `text` and return (confidence, rest).
|
|
104
|
+
|
|
105
|
+
Defensive against three model misbehaviours, in one place so every
|
|
106
|
+
driver inherits the same protection:
|
|
107
|
+
|
|
108
|
+
- **Multiple stacked tags** — e.g. `<!--BRANCH:0.0--><!--BRANCH:0.5-->Hi`.
|
|
109
|
+
We loop `parse_branch_flag` and strip them all; the *first*
|
|
110
|
+
confidence wins (subsequent tags are usually a model emitting
|
|
111
|
+
the marker more than once, not actually multiple decisions).
|
|
112
|
+
|
|
113
|
+
- **Partial tag at end** — e.g. `<!--BRANCH:0.5` with no closing
|
|
114
|
+
`-->`. Happens when a tool call interrupts a tag stream. We drop
|
|
115
|
+
the partial-tag bytes so they don't leak into rendered text.
|
|
116
|
+
|
|
117
|
+
- **Malformed digits** — handled inside `parse_branch_flag`
|
|
118
|
+
(the tag is consumed silently, no confidence reported).
|
|
119
|
+
|
|
120
|
+
Returns the first valid confidence found (or None if no parseable
|
|
121
|
+
tag was at the head), and the remaining text after all leading tags
|
|
122
|
+
are stripped (or empty string if the remaining content was a
|
|
123
|
+
partial-tag prefix).
|
|
124
|
+
"""
|
|
125
|
+
first_confidence: float | None = None
|
|
126
|
+
buf = text
|
|
127
|
+
while True:
|
|
128
|
+
confidence, rest = parse_branch_flag(buf)
|
|
129
|
+
if confidence is None and rest == buf:
|
|
130
|
+
# No tag matched at all — buf is plain content.
|
|
131
|
+
break
|
|
132
|
+
# A tag was consumed (confidence may still be None if the tag's
|
|
133
|
+
# digits were malformed). Capture the first valid confidence.
|
|
134
|
+
if first_confidence is None and confidence is not None:
|
|
135
|
+
first_confidence = confidence
|
|
136
|
+
buf = rest
|
|
137
|
+
if not buf:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
# If the buffer is now a partial tag prefix (no closing `-->`),
|
|
141
|
+
# the model started a tag we never saw the end of. Drop it
|
|
142
|
+
# rather than leak `<!--BRANCH:0.5` to the renderer.
|
|
143
|
+
if buf.startswith("<!--BRANCH:") and "-->" not in buf:
|
|
144
|
+
buf = ""
|
|
145
|
+
|
|
146
|
+
return first_confidence, buf
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
import logging
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
from enum import Enum
|
|
21
|
+
from html import escape as _html_escape
|
|
21
22
|
from typing import Any
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
@@ -72,24 +73,44 @@ async def ask_user_for_permission(
|
|
|
72
73
|
"""
|
|
73
74
|
# Late imports to avoid cycles with picker / renderer at module-load time.
|
|
74
75
|
from ..picker import pick_option
|
|
75
|
-
from ..renderer import
|
|
76
|
+
from ..renderer import (
|
|
77
|
+
pause_active_md_stream,
|
|
78
|
+
pause_active_spinner,
|
|
79
|
+
resume_active_spinner,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# prompt_toolkit's HTML(...) is strict XML; any literal `&`, `<`, `>`
|
|
83
|
+
# or quote in agent-provided text (Kiro tool titles like
|
|
84
|
+
# "Run command: cat /etc/hosts 2>&1" — note the bare `&`) crashes the
|
|
85
|
+
# picker with an ExpatError, the wrapper deny-fails closed, and the
|
|
86
|
+
# user sees no prompt at all. Escape user/agent-provided substrings
|
|
87
|
+
# before embedding them inside our HTML-formatted picker text.
|
|
88
|
+
safe_tool_name = _html_escape(tool_name)
|
|
89
|
+
safe_summary = _html_escape(summary) if summary else ""
|
|
76
90
|
|
|
77
91
|
summary_line = (
|
|
78
|
-
f"\n <ansibrightblack> {
|
|
92
|
+
f"\n <ansibrightblack> {safe_summary}</ansibrightblack>" if safe_summary else ""
|
|
79
93
|
)
|
|
80
94
|
header = (
|
|
81
|
-
f" <ansicyan>⚙ {
|
|
95
|
+
f" <ansicyan>⚙ {safe_tool_name}</ansicyan> "
|
|
82
96
|
f"<ansibrightblack>wants to run:</ansibrightblack>{summary_line}"
|
|
83
97
|
)
|
|
84
98
|
|
|
85
99
|
options: list[tuple[str, str]] = [
|
|
86
100
|
("Allow", "allow"),
|
|
87
|
-
(f"Always allow {
|
|
101
|
+
(f"Always allow {safe_tool_name}", "always"),
|
|
88
102
|
("Deny", "deny"),
|
|
89
103
|
("Bypass all checks", "bypass"),
|
|
90
104
|
]
|
|
91
105
|
|
|
106
|
+
# Hand the terminal off to prompt_toolkit cleanly: stop the spinner and
|
|
107
|
+
# flush+stop any active rich.Live markdown stream. If we skip the
|
|
108
|
+
# md-stream flush, an in-progress streaming response will keep
|
|
109
|
+
# repainting the cursor area on every refresh tick and the picker is
|
|
110
|
+
# invisible — the user thinks the agent went silent, but the agent is
|
|
111
|
+
# actually waiting on an approval that never gets a chance to render.
|
|
92
112
|
pause_active_spinner()
|
|
113
|
+
pause_active_md_stream()
|
|
93
114
|
try:
|
|
94
115
|
result = await pick_option(header, options)
|
|
95
116
|
except KeyboardInterrupt:
|