expluro 0.1.8__tar.gz → 0.2.0__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 (30) hide show
  1. {expluro-0.1.8 → expluro-0.2.0}/PKG-INFO +2 -1
  2. {expluro-0.1.8 → expluro-0.2.0}/expluro.egg-info/PKG-INFO +2 -1
  3. {expluro-0.1.8 → expluro-0.2.0}/expluro.egg-info/SOURCES.txt +5 -1
  4. {expluro-0.1.8 → expluro-0.2.0}/expluro.egg-info/requires.txt +1 -0
  5. {expluro-0.1.8 → expluro-0.2.0}/pyproject.toml +2 -1
  6. {expluro-0.1.8 → expluro-0.2.0}/topo/backend.py +8 -1
  7. {expluro-0.1.8 → expluro-0.2.0}/topo/capture.py +48 -0
  8. expluro-0.2.0/topo/drivers/_branch_detection.py +90 -0
  9. expluro-0.2.0/topo/drivers/_permissions.py +135 -0
  10. expluro-0.2.0/topo/drivers/acp.py +973 -0
  11. {expluro-0.1.8 → expluro-0.2.0}/topo/drivers/claude.py +42 -98
  12. expluro-0.2.0/topo/drivers/kiro_session_files.py +167 -0
  13. expluro-0.2.0/topo/main.py +267 -0
  14. {expluro-0.1.8 → expluro-0.2.0}/topo/session.py +44 -9
  15. expluro-0.1.8/topo/main.py +0 -46
  16. {expluro-0.1.8 → expluro-0.2.0}/expluro.egg-info/dependency_links.txt +0 -0
  17. {expluro-0.1.8 → expluro-0.2.0}/expluro.egg-info/entry_points.txt +0 -0
  18. {expluro-0.1.8 → expluro-0.2.0}/expluro.egg-info/top_level.txt +0 -0
  19. {expluro-0.1.8 → expluro-0.2.0}/setup.cfg +0 -0
  20. {expluro-0.1.8 → expluro-0.2.0}/topo/__init__.py +0 -0
  21. {expluro-0.1.8 → expluro-0.2.0}/topo/auth.py +0 -0
  22. {expluro-0.1.8 → expluro-0.2.0}/topo/constants.py +0 -0
  23. {expluro-0.1.8 → expluro-0.2.0}/topo/display.py +0 -0
  24. {expluro-0.1.8 → expluro-0.2.0}/topo/drivers/__init__.py +0 -0
  25. {expluro-0.1.8 → expluro-0.2.0}/topo/drivers/base.py +0 -0
  26. {expluro-0.1.8 → expluro-0.2.0}/topo/picker.py +0 -0
  27. {expluro-0.1.8 → expluro-0.2.0}/topo/renderer.py +0 -0
  28. {expluro-0.1.8 → expluro-0.2.0}/topo/session_files.py +0 -0
  29. {expluro-0.1.8 → expluro-0.2.0}/topo/snapshot.py +0 -0
  30. {expluro-0.1.8 → expluro-0.2.0}/topo/update_check.py +0 -0
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.1.8
3
+ Version: 0.2.0
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
7
+ Requires-Dist: agent-client-protocol<0.10.0,>=0.9.0
7
8
  Requires-Dist: httpx>=0.27
8
9
  Requires-Dist: websockets>=12.0
9
10
  Requires-Dist: rich>=13.7
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.1.8
3
+ Version: 0.2.0
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
7
+ Requires-Dist: agent-client-protocol<0.10.0,>=0.9.0
7
8
  Requires-Dist: httpx>=0.27
8
9
  Requires-Dist: websockets>=12.0
9
10
  Requires-Dist: rich>=13.7
@@ -19,5 +19,9 @@ topo/session_files.py
19
19
  topo/snapshot.py
20
20
  topo/update_check.py
21
21
  topo/drivers/__init__.py
22
+ topo/drivers/_branch_detection.py
23
+ topo/drivers/_permissions.py
24
+ topo/drivers/acp.py
22
25
  topo/drivers/base.py
23
- topo/drivers/claude.py
26
+ topo/drivers/claude.py
27
+ topo/drivers/kiro_session_files.py
@@ -1,4 +1,5 @@
1
1
  claude-agent-sdk>=0.0.1
2
+ agent-client-protocol<0.10.0,>=0.9.0
2
3
  httpx>=0.27
3
4
  websockets>=12.0
4
5
  rich>=13.7
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "expluro"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "Expluro CLI — graph layer on top of your AI agent"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
11
  "claude-agent-sdk>=0.0.1",
12
+ "agent-client-protocol>=0.9.0,<0.10.0",
12
13
  "httpx>=0.27",
13
14
  "websockets>=12.0",
14
15
  "rich>=13.7",
@@ -72,10 +72,17 @@ class BackendClient:
72
72
  # Nodes
73
73
  # ------------------------------------------------------------------
74
74
 
75
- async def create_node(self, graph_id: str, parent_id: str | None = None) -> dict[str, Any]:
75
+ async def create_node(
76
+ self,
77
+ graph_id: str,
78
+ parent_id: str | None = None,
79
+ tool: str | None = None,
80
+ ) -> dict[str, Any]:
76
81
  payload: dict[str, Any] = {"graph": graph_id}
77
82
  if parent_id:
78
83
  payload["parent"] = parent_id
84
+ if tool:
85
+ payload["tool"] = tool
79
86
  return await self._post("/api/nodes/", payload)
80
87
 
81
88
  async def patch_node(self, node_id: str, data: dict[str, Any]) -> dict[str, Any]:
@@ -66,6 +66,16 @@ class EventCapture:
66
66
  # (e.g. when a tool is denied mid-response).
67
67
  self._streamed_text_buffer: str = ""
68
68
  self._text_complete_fired: bool = False
69
+ # Hook-driven persistence (pre/post_tool_use) fires synchronously
70
+ # from inside the driver's session_update handler, BEFORE the REPL
71
+ # loop drains the queued DriverEvent and gets a chance to flush a
72
+ # deferred user message. To preserve correct conversation order on
73
+ # the backend, we hold these events in a buffer while a user
74
+ # message is pending — flush_pending_user_message replays them in
75
+ # order after the user message is recorded. See session.py for the
76
+ # deferred-user-message lifecycle.
77
+ self._pending_user_message_text: str | None = None
78
+ self._buffered_hook_events: list[dict[str, Any]] = []
69
79
 
70
80
  SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
71
81
  self._log_file = self._open_log(node_id)
@@ -114,6 +124,19 @@ class EventCapture:
114
124
  )
115
125
  await self._record(event)
116
126
 
127
+ def set_pending_user_message(self, text: str) -> None:
128
+ """Mark a user message as pending durable persistence.
129
+
130
+ While a message is pending, hook-driven persistence (pre/post_tool_use
131
+ events) is buffered rather than written immediately. Flushing the
132
+ pending message replays the buffered events in order so the backend
133
+ sees correct conversation order: user → tool_use → tool_result → ai.
134
+
135
+ Only the first set_pending_user_message in a turn matters; subsequent
136
+ calls overwrite the text but don't reset the buffer.
137
+ """
138
+ self._pending_user_message_text = text
139
+
117
140
  async def record_user_message(self, text: str) -> None:
118
141
  """Record a user prompt as a durable event on the current node.
119
142
 
@@ -123,6 +146,9 @@ class EventCapture:
123
146
  no branch fired, child if it did — so the durable record always lands
124
147
  on the node that actually owns this message.
125
148
 
149
+ Also flushes any hook events buffered while this message was pending,
150
+ preserving the order user → tool events on the backend.
151
+
126
152
  For the live browser bubble, see broadcast_user_message_live() — that
127
153
  runs *before* the driver send and is non-persistent.
128
154
  """
@@ -137,6 +163,20 @@ class EventCapture:
137
163
  # The backend generates its own PK — the CLI UUID is never stored in the DB.
138
164
  self._last_user_message_event_id = backend_id or event["id"]
139
165
 
166
+ # Done deferring; flush any hook events that fired while we waited.
167
+ self._pending_user_message_text = None
168
+ if self._buffered_hook_events:
169
+ buffered = self._buffered_hook_events
170
+ self._buffered_hook_events = []
171
+ for evt in buffered:
172
+ # Re-anchor each buffered event onto whatever event came last
173
+ # (the user message we just wrote, or a previous buffered tool
174
+ # event) by updating parent_event_id from current state.
175
+ evt["parent_event_id"] = self._last_event_id
176
+ evt_id = await self._record(evt)
177
+ if evt_id:
178
+ self._last_event_id = evt_id
179
+
140
180
  async def broadcast_user_message_live(self, text: str) -> None:
141
181
  """Live (non-persistent) broadcast of the user's just-typed message.
142
182
 
@@ -248,6 +288,11 @@ class EventCapture:
248
288
  tool_use_id=payload.tool_use_id,
249
289
  data={"tool_input": payload.tool_input},
250
290
  )
291
+ if self._pending_user_message_text is not None:
292
+ # Hold this event back so the user message anchors first on the
293
+ # backend. record_user_message replays the buffer in order.
294
+ self._buffered_hook_events.append(event)
295
+ return
251
296
  await self._record(event)
252
297
 
253
298
  async def _on_post_tool(self, payload: "ToolHookPayload") -> None:
@@ -262,6 +307,9 @@ class EventCapture:
262
307
  "is_side_effecting": is_side_effecting,
263
308
  },
264
309
  )
310
+ if self._pending_user_message_text is not None:
311
+ self._buffered_hook_events.append(event)
312
+ return
265
313
  await self._record(event)
266
314
 
267
315
  # ------------------------------------------------------------------
@@ -0,0 +1,90 @@
1
+ """
2
+ Branch detection — shared instruction + tag parser used by every driver.
3
+
4
+ The model is asked to begin every response with `<!--BRANCH:X.XX-->`, where
5
+ X.XX is its 0-1 confidence that the user's last message is a fork point.
6
+ We parse the tag from the front of the streaming response, decide whether
7
+ to fork, and strip the tag before the user ever sees it.
8
+
9
+ Drivers wire this in two places:
10
+ - Inject `BRANCH_DETECTION_INSTRUCTION` so the model knows to emit the tag.
11
+ Claude does this via system_prompt; ACP-based agents (no protocol-level
12
+ system prompt) prepend it to the user's prompt text on each turn.
13
+ - Buffer the first chunk of streamed text per turn, call `parse_branch_flag`,
14
+ and emit a `branch_signal` event before the cleaned text.
15
+
16
+ A backend copy lives at backend/llm/branch_detection.py for the web playground.
17
+ Keep both in sync until the CLI and backend share a Python package.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ BRANCH_DETECTION_INSTRUCTION = (
24
+ "\n\nIMPORTANT — OUTPUT FORMAT (overrides all other formatting rules):\n"
25
+ "Every response must begin with <!--BRANCH:X.XX--> on its own line, followed "
26
+ "by a newline, before any other character. Never skip, explain, or mention "
27
+ "this tag. If you're about to write anything else first, stop and output the tag.\n\n"
28
+ "X.XX is your confidence (0.00–1.00) that the user's most recent message is "
29
+ "a fork point relative to the prior conversation.\n\n"
30
+ "What a fork point is\n"
31
+ "A fork point is a turn where — if the user later replaced their message with "
32
+ "a different one — the resulting conversation would go somewhere meaningfully "
33
+ "different. It's a decision moment worth being able to revisit.\n\n"
34
+ "Judge the semantic shift, not the wording. Words like 'fork', 'pivot', "
35
+ "'different', or 'alternative' are not triggers on their own — 'let's fork into "
36
+ "option B' and 'let's try option B' are the same signal.\n\n"
37
+ "Two kinds of fork points\n"
38
+ "1. Option selection — the user picks one item from a set the assistant raised "
39
+ "(or that's implicit in the thread). E.g. assistant listed formal/natural/social "
40
+ "sciences, user says 'let's go deep on formal sciences.'\n"
41
+ "2. Topic or direction change — the user's message shifts to a substantially "
42
+ "different subject, goal, or approach. Options don't need to have been presented. "
43
+ "E.g. prior turn discussed Premier League, next message is 'when did World War I start?'\n\n"
44
+ "Not fork points\n"
45
+ "- Follow-up, clarification, or drill-down on the current thread.\n"
46
+ "- Correction, bug fix, or refinement of the current output.\n"
47
+ "- Incremental elaboration on the same topic.\n\n"
48
+ "Confidence bands\n"
49
+ "- 0.00–0.20 — follow-up, clarification, refinement\n"
50
+ "- 0.20–0.50 — noticeable shift but on the same general topic\n"
51
+ "- 0.50–0.70 — meaningful shift, ambiguous whether it's a fork\n"
52
+ "- 0.70–1.00 — clear fork: option chosen, or subject/direction changed substantially\n\n"
53
+ "Guardrails\n"
54
+ "- If fewer than 2 prior assistant responses exist, output ≤ 0.10 (not enough "
55
+ "context to fork from).\n"
56
+ "- When uncertain, bias downward. A missed fork is recoverable; a false positive "
57
+ "fragments the graph.\n\n"
58
+ "Examples\n"
59
+ "- Assistant listed options, user says 'dig into option A' → 0.90\n"
60
+ "- Prior turn on Ethiopian football, user asks 'when did WWII happen?' → 0.85\n"
61
+ "- 'Let's try a completely different approach' on the current task → 0.85\n"
62
+ "- Conversation was about database schema, user asks 'how should we handle "
63
+ "migrations?' (related but distinct decision scope) → 0.70\n"
64
+ "- User shifts from implementation details to architecture of the same solution → 0.40\n"
65
+ "- User asks 'how does this work?' on what was just explained → 0.05\n"
66
+ "- 'This is broken, fix the null case' → 0.10\n"
67
+ "- 'I just forked the GitHub repo to experiment locally' (mentions 'fork' "
68
+ "but is describing an action, not expressing branching intent) → 0.10\n"
69
+ "- First message of the session → 0.00\n\n"
70
+ "After the tag, on the next line, write your normal response."
71
+ )
72
+
73
+ BRANCH_FLAG_START_RE = re.compile(r"^<!--BRANCH:([\d.]+)-->\r?\n?")
74
+
75
+ # Flush the head buffer once we have this many chars without a newline —
76
+ # protects against a model that ignores the instruction and never emits one.
77
+ HEAD_BUFFER_MAX = 40
78
+
79
+
80
+ def parse_branch_flag(buffered_head: str) -> tuple[float | None, str]:
81
+ """Parse the BRANCH tag from a buffered head chunk.
82
+
83
+ Returns (confidence, remaining_text). When the head doesn't start with
84
+ a valid tag, confidence is None and the entire buffered_head is
85
+ returned as remaining_text — caller should treat it as regular content.
86
+ """
87
+ 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
@@ -0,0 +1,135 @@
1
+ """
2
+ Shared interactive permission UI used by every driver (Claude native,
3
+ ACP-based agents, etc.).
4
+
5
+ The agent-side details differ per driver: Claude uses a PreToolUse hook,
6
+ ACP uses session/request_permission. Both surfaces converge on the same
7
+ question — does the user want to let this tool run? — and the same UI:
8
+ an arrow-key picker offering Allow / Always allow this tool / Deny /
9
+ Bypass all.
10
+
11
+ Drivers own their own PermissionState (the always-allowed set and
12
+ bypass flag) so each agent has its own trust memory. The helper here
13
+ just shows the picker and tells the driver which choice was made; the
14
+ driver maps that decision to its native protocol response.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from dataclasses import dataclass, field
20
+ from enum import Enum
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class PermissionDecision(Enum):
27
+ """What the user picked. Drivers translate this to their protocol's response."""
28
+ ALLOW_ONCE = "allow_once"
29
+ ALLOW_ALWAYS = "allow_always"
30
+ DENY = "deny"
31
+ BYPASS_ALL = "bypass_all"
32
+
33
+
34
+ @dataclass
35
+ class PermissionState:
36
+ """Per-driver memory for permission shortcuts.
37
+
38
+ `always_allowed` survives across branches because the user has explicitly
39
+ granted persistent trust to that tool. `bypass_all` does NOT survive a
40
+ branch switch — it's too broad to silently inherit on a new branch.
41
+ Drivers reset `bypass_all` at branch boundaries themselves; this struct
42
+ just stores it.
43
+ """
44
+ always_allowed: set[str] = field(default_factory=set)
45
+ bypass_all: bool = False
46
+
47
+ def fast_allow(self, tool_name: str) -> bool:
48
+ """Skip the picker entirely if a prior choice already covers this tool."""
49
+ return self.bypass_all or tool_name in self.always_allowed
50
+
51
+
52
+ async def ask_user_for_permission(
53
+ tool_name: str,
54
+ summary: str,
55
+ *,
56
+ state: PermissionState,
57
+ ) -> PermissionDecision:
58
+ """Show the interactive picker. Mutates `state` on always/bypass choices.
59
+
60
+ Caller should check `state.fast_allow(tool_name)` first and skip this
61
+ function entirely if it returns True. We always show the picker if we
62
+ get here; we don't second-guess the caller.
63
+
64
+ `tool_name` is the short label shown in the header (e.g. "Bash", "Read",
65
+ or for ACP a generic "execute" / "edit" / etc).
66
+
67
+ `summary` is the optional one-line detail shown under the header (e.g.
68
+ the actual command, file path, query). Empty string if no detail.
69
+
70
+ KeyboardInterrupt during the picker is treated as deny — same as the
71
+ user picking "Deny" — so Ctrl+C is a fast-path no.
72
+ """
73
+ # Late imports to avoid cycles with picker / renderer at module-load time.
74
+ from ..picker import pick_option
75
+ from ..renderer import pause_active_spinner, resume_active_spinner
76
+
77
+ summary_line = (
78
+ f"\n <ansibrightblack> {summary}</ansibrightblack>" if summary else ""
79
+ )
80
+ header = (
81
+ f" <ansicyan>⚙ {tool_name}</ansicyan> "
82
+ f"<ansibrightblack>wants to run:</ansibrightblack>{summary_line}"
83
+ )
84
+
85
+ options: list[tuple[str, str]] = [
86
+ ("Allow", "allow"),
87
+ (f"Always allow {tool_name}", "always"),
88
+ ("Deny", "deny"),
89
+ ("Bypass all checks", "bypass"),
90
+ ]
91
+
92
+ pause_active_spinner()
93
+ try:
94
+ result = await pick_option(header, options)
95
+ except KeyboardInterrupt:
96
+ resume_active_spinner()
97
+ logger.info("Permission: cancelled for %s", tool_name)
98
+ return PermissionDecision.DENY
99
+ resume_active_spinner()
100
+
101
+ if result == "always":
102
+ state.always_allowed.add(tool_name)
103
+ logger.info("Permission: always allow %s", tool_name)
104
+ return PermissionDecision.ALLOW_ALWAYS
105
+ if result == "bypass":
106
+ state.bypass_all = True
107
+ logger.info("Permission: bypass all remaining checks")
108
+ return PermissionDecision.BYPASS_ALL
109
+ if result == "allow":
110
+ return PermissionDecision.ALLOW_ONCE
111
+
112
+ logger.info("Permission: user denied %s", tool_name)
113
+ return PermissionDecision.DENY
114
+
115
+
116
+ def decision_is_allow(decision: PermissionDecision) -> bool:
117
+ """Convenience for drivers that just want a yes/no signal."""
118
+ return decision in (
119
+ PermissionDecision.ALLOW_ONCE,
120
+ PermissionDecision.ALLOW_ALWAYS,
121
+ PermissionDecision.BYPASS_ALL,
122
+ )
123
+
124
+
125
+ # Type-only re-export so pydantic / drivers can annotate cleanly.
126
+ __all__ = [
127
+ "PermissionDecision",
128
+ "PermissionState",
129
+ "ask_user_for_permission",
130
+ "decision_is_allow",
131
+ ]
132
+
133
+
134
+ # Avoid unused imports linter noise:
135
+ _: Any = None