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.
Files changed (31) hide show
  1. {expluro-0.2.0 → expluro-0.3.1}/PKG-INFO +1 -1
  2. {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/PKG-INFO +1 -1
  3. {expluro-0.2.0 → expluro-0.3.1}/pyproject.toml +1 -1
  4. {expluro-0.2.0 → expluro-0.3.1}/topo/capture.py +40 -5
  5. {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/_branch_detection.py +59 -3
  6. {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/_permissions.py +25 -4
  7. {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/acp.py +264 -59
  8. {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/claude.py +55 -19
  9. expluro-0.3.1/topo/drivers/kiro_session_files.py +277 -0
  10. {expluro-0.2.0 → expluro-0.3.1}/topo/main.py +7 -2
  11. {expluro-0.2.0 → expluro-0.3.1}/topo/picker.py +24 -5
  12. {expluro-0.2.0 → expluro-0.3.1}/topo/renderer.py +32 -2
  13. {expluro-0.2.0 → expluro-0.3.1}/topo/session.py +183 -25
  14. expluro-0.3.1/topo/snapshot.py +482 -0
  15. expluro-0.2.0/topo/drivers/kiro_session_files.py +0 -167
  16. expluro-0.2.0/topo/snapshot.py +0 -3
  17. {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/SOURCES.txt +0 -0
  18. {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/dependency_links.txt +0 -0
  19. {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/entry_points.txt +0 -0
  20. {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/requires.txt +0 -0
  21. {expluro-0.2.0 → expluro-0.3.1}/expluro.egg-info/top_level.txt +0 -0
  22. {expluro-0.2.0 → expluro-0.3.1}/setup.cfg +0 -0
  23. {expluro-0.2.0 → expluro-0.3.1}/topo/__init__.py +0 -0
  24. {expluro-0.2.0 → expluro-0.3.1}/topo/auth.py +0 -0
  25. {expluro-0.2.0 → expluro-0.3.1}/topo/backend.py +0 -0
  26. {expluro-0.2.0 → expluro-0.3.1}/topo/constants.py +0 -0
  27. {expluro-0.2.0 → expluro-0.3.1}/topo/display.py +0 -0
  28. {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/__init__.py +0 -0
  29. {expluro-0.2.0 → expluro-0.3.1}/topo/drivers/base.py +0 -0
  30. {expluro-0.2.0 → expluro-0.3.1}/topo/session_files.py +0 -0
  31. {expluro-0.2.0 → expluro-0.3.1}/topo/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "expluro"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Expluro CLI — graph layer on top of your AI agent"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -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 float(m.group(1)), buffered_head[m.end():]
90
- return None, buffered_head
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 pause_active_spinner, resume_active_spinner
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> {summary}</ansibrightblack>" if summary else ""
92
+ f"\n <ansibrightblack> {safe_summary}</ansibrightblack>" if safe_summary else ""
79
93
  )
80
94
  header = (
81
- f" <ansicyan>⚙ {tool_name}</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 {tool_name}", "always"),
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: