gdmcode 0.1.0__py3-none-any.whl
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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/commands.py
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
"""Slash-command dispatcher for the gdm REPL.
|
|
2
|
+
|
|
3
|
+
Supported commands: /help /model /cost /tasks /btw /compact /clear /status
|
|
4
|
+
/sessions /allow-all /exit /quit /diff /undo /rollback /branch /pr /commit /browser /replay
|
|
5
|
+
/proxy
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Callable
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from src.git_workflow import GitWorkflow
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from src.cost_tracker import CostTracker
|
|
24
|
+
from src.memory.db import GdmDatabase
|
|
25
|
+
|
|
26
|
+
__all__ = ["CommandResult", "CommandDispatcher"]
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_VALID_TIERS: frozenset[str] = frozenset({"scout", "coder", "thinker", "reasoner", "debate"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _relative_time(dt_str: str | None, now: datetime) -> str:
|
|
34
|
+
"""Return a human-readable relative time string (e.g. '5m ago', '3h ago')."""
|
|
35
|
+
if not dt_str:
|
|
36
|
+
return "unknown"
|
|
37
|
+
try:
|
|
38
|
+
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
|
39
|
+
delta = now - dt.astimezone(timezone.utc)
|
|
40
|
+
secs = int(delta.total_seconds())
|
|
41
|
+
if secs < 0:
|
|
42
|
+
return "just now"
|
|
43
|
+
if secs < 60:
|
|
44
|
+
return f"{secs}s ago"
|
|
45
|
+
if secs < 3600:
|
|
46
|
+
return f"{secs // 60}m ago"
|
|
47
|
+
if secs < 86400:
|
|
48
|
+
return f"{secs // 3600}h ago"
|
|
49
|
+
return f"{secs // 86400}d ago"
|
|
50
|
+
except (ValueError, AttributeError):
|
|
51
|
+
return dt_str[:16] if dt_str else "unknown"
|
|
52
|
+
|
|
53
|
+
_COMMANDS: dict[str, str] = {
|
|
54
|
+
"/help": "Show this help message",
|
|
55
|
+
"/model [tier]": "Show or set model tier (scout|coder|thinker|reasoner|debate)",
|
|
56
|
+
"/cost": "Show session cost summary",
|
|
57
|
+
"/tasks": "List pending /btw notes and active tasks",
|
|
58
|
+
"/btw <message>": "Queue a background note for the agent",
|
|
59
|
+
"/compact": "Force context compression (evict old turns)",
|
|
60
|
+
"/clear": "Clear the terminal screen",
|
|
61
|
+
"/status": "Show session info (provider, model, cost, turns)",
|
|
62
|
+
"/sessions": "List recent sessions with ID, start time, turns, cost, status",
|
|
63
|
+
"/daemon [status]": "Show background daemon job queue",
|
|
64
|
+
"/resume [session_id]": "Restore a session transcript from checkpoint (defaults to most recent active)",
|
|
65
|
+
"/review": "Trigger a manual security review of recent writes",
|
|
66
|
+
"/allow-all": "Bypass permission prompts for this session",
|
|
67
|
+
"/diff": "Show git diff of pending gdm changes",
|
|
68
|
+
"/undo": "Undo last checkpoint commit (git reset HEAD~1)",
|
|
69
|
+
"/rollback": "Full rollback to pre-task state (confirms before executing)",
|
|
70
|
+
"/branch": "Show current task branch and checkpoint history",
|
|
71
|
+
"/pr": "Push task branch and generate a PR description",
|
|
72
|
+
"/commit [msg]": "Create a git checkpoint commit with optional message",
|
|
73
|
+
"/browser [start|stop|status]": "Manage the bridge server for browser automation",
|
|
74
|
+
"/remote [start|stop|status|qr]": "Manage remote tunnel (phone pairing via QR code)",
|
|
75
|
+
"/replay [session_id]": "Replay past session; compare/fork/export sub-commands available",
|
|
76
|
+
"/doctor": "Show resolved model IDs for every provider/tier",
|
|
77
|
+
"/reasoning [on|off|auto]": "Toggle per-turn reasoning tier (on=Reasoner, off=Scout, auto=classifier)",
|
|
78
|
+
"/artifacts [view|diff|search|export] ...": "List, view, diff, search, or export saved artifacts",
|
|
79
|
+
"/save [name]": "Save the last assistant response as a named artifact",
|
|
80
|
+
"/proxy [on|off|url <url>|token <tok>|status]": "Route LLM calls via proxy (for geo-blocked regions)",
|
|
81
|
+
"/exit, /quit": "Exit the REPL",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class CommandResult:
|
|
87
|
+
"""Result returned by CommandDispatcher.handle()."""
|
|
88
|
+
|
|
89
|
+
handled: bool
|
|
90
|
+
output: str = ""
|
|
91
|
+
should_exit: bool = False
|
|
92
|
+
force_compact: bool = False
|
|
93
|
+
new_model_tier: str | None = None
|
|
94
|
+
allow_all: bool = False
|
|
95
|
+
force_resume: bool = False
|
|
96
|
+
resume_session_id: str | None = None
|
|
97
|
+
new_reasoning_mode: str | None = None
|
|
98
|
+
save_artifact_name: str | None = None # /save command: loop saves last assistant response
|
|
99
|
+
proxy_action: str | None = None # "enable" | "disable"
|
|
100
|
+
proxy_url: str | None = None
|
|
101
|
+
proxy_token: str | None = None
|
|
102
|
+
prompt_secret: str | None = None # if set, REPL prompts for hidden input then calls apply_proxy_token
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CommandDispatcher:
|
|
106
|
+
"""Parses and executes /commands typed in the REPL."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
session_id: str,
|
|
111
|
+
db: "GdmDatabase",
|
|
112
|
+
cost_tracker: "CostTracker",
|
|
113
|
+
*,
|
|
114
|
+
current_tier: str = "coder",
|
|
115
|
+
provider: str = "grok",
|
|
116
|
+
console: Console | None = None,
|
|
117
|
+
project_root: Path | None = None,
|
|
118
|
+
confirm_fn: "Callable[[], str] | None" = None,
|
|
119
|
+
cfg: "object | None" = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._session_id = session_id
|
|
122
|
+
self._db = db
|
|
123
|
+
self._cost_tracker = cost_tracker
|
|
124
|
+
self._current_tier = current_tier
|
|
125
|
+
self._provider = provider
|
|
126
|
+
self._console = console or Console()
|
|
127
|
+
self._project_root: Path = project_root or Path.cwd()
|
|
128
|
+
# Callable used for destructive-action confirmation (e.g. /rollback).
|
|
129
|
+
# None means fall back to bare input() at call time (patchable in tests).
|
|
130
|
+
self._confirm_fn: "Callable[[], str] | None" = confirm_fn
|
|
131
|
+
# Optional GdmConfig for model-assisted features (PR description, etc.)
|
|
132
|
+
self._cfg = cfg
|
|
133
|
+
# Reasoning mode — shared with AgentLoop; /reasoning command updates both
|
|
134
|
+
self._reasoning_mode: str = "auto"
|
|
135
|
+
# Proxy state — initialised from cfg; kept in sync with AgentLoop
|
|
136
|
+
from src.models.definitions import PROXY_DEFAULT_URL
|
|
137
|
+
self._proxy_enabled: bool = getattr(cfg, "proxy_enabled", False)
|
|
138
|
+
self._proxy_url: str = getattr(cfg, "proxy_url", None) or PROXY_DEFAULT_URL
|
|
139
|
+
self._proxy_token: str = getattr(cfg, "proxy_token", None) or ""
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Public API
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def is_command(self, text: str) -> bool:
|
|
146
|
+
"""Return True if *text* looks like a slash command."""
|
|
147
|
+
return text.strip().startswith("/")
|
|
148
|
+
|
|
149
|
+
def handle(self, text: str) -> CommandResult:
|
|
150
|
+
"""Parse and dispatch *text* as a slash command.
|
|
151
|
+
|
|
152
|
+
Returns CommandResult(handled=False) for unknown commands so the
|
|
153
|
+
caller can decide what to do (e.g. show an error or pass to agent).
|
|
154
|
+
"""
|
|
155
|
+
stripped = text.strip()
|
|
156
|
+
parts = stripped.split(None, 2)
|
|
157
|
+
if not parts:
|
|
158
|
+
return CommandResult(handled=False)
|
|
159
|
+
cmd = parts[0].lower()
|
|
160
|
+
match cmd:
|
|
161
|
+
case "/help":
|
|
162
|
+
return self._cmd_help()
|
|
163
|
+
case "/model":
|
|
164
|
+
return self._cmd_model(parts[1:])
|
|
165
|
+
case "/cost":
|
|
166
|
+
return self._cmd_cost()
|
|
167
|
+
case "/tasks":
|
|
168
|
+
return self._cmd_tasks()
|
|
169
|
+
case "/btw":
|
|
170
|
+
return self._cmd_btw(stripped[len("/btw"):].strip())
|
|
171
|
+
case "/compact":
|
|
172
|
+
return CommandResult(handled=True, force_compact=True, output="Compressing context...")
|
|
173
|
+
case "/clear":
|
|
174
|
+
self._console.clear()
|
|
175
|
+
return CommandResult(handled=True)
|
|
176
|
+
case "/status":
|
|
177
|
+
return self._cmd_status()
|
|
178
|
+
case "/daemon":
|
|
179
|
+
return self._cmd_daemon()
|
|
180
|
+
case "/sessions":
|
|
181
|
+
return self._cmd_sessions()
|
|
182
|
+
case "/resume":
|
|
183
|
+
return self._cmd_resume(parts[1] if len(parts) > 1 else None)
|
|
184
|
+
case "/review":
|
|
185
|
+
return CommandResult(handled=True, output="[dim]Review gate is triggered automatically after security-sensitive writes.[/dim]")
|
|
186
|
+
case "/allow-all":
|
|
187
|
+
return CommandResult(
|
|
188
|
+
handled=True,
|
|
189
|
+
allow_all=True,
|
|
190
|
+
output="[green]Permission checks bypassed for this session.[/green]",
|
|
191
|
+
)
|
|
192
|
+
case "/diff":
|
|
193
|
+
return self._cmd_diff()
|
|
194
|
+
case "/undo":
|
|
195
|
+
n = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 1
|
|
196
|
+
return self._cmd_undo(n=n)
|
|
197
|
+
case "/rollback":
|
|
198
|
+
checkpoint_id = parts[1] if len(parts) > 1 else None
|
|
199
|
+
return self._cmd_rollback(checkpoint_id=checkpoint_id)
|
|
200
|
+
case "/branch":
|
|
201
|
+
return self._cmd_branch()
|
|
202
|
+
case "/pr":
|
|
203
|
+
return self._cmd_pr()
|
|
204
|
+
case "/commit":
|
|
205
|
+
return self._cmd_commit(parts[1:])
|
|
206
|
+
case "/browser":
|
|
207
|
+
return self._cmd_browser(parts[1:])
|
|
208
|
+
case "/remote":
|
|
209
|
+
return self._cmd_remote(parts[1:])
|
|
210
|
+
case "/replay":
|
|
211
|
+
return self._cmd_replay(parts[1:])
|
|
212
|
+
case "/doctor":
|
|
213
|
+
return self._cmd_doctor()
|
|
214
|
+
case "/reasoning":
|
|
215
|
+
return self._cmd_reasoning(parts[1:])
|
|
216
|
+
case "/artifacts":
|
|
217
|
+
return self._cmd_artifacts(parts[1:])
|
|
218
|
+
case "/save":
|
|
219
|
+
return self._cmd_save(parts[1] if len(parts) > 1 else "")
|
|
220
|
+
case "/proxy":
|
|
221
|
+
return self._cmd_proxy(parts[1:])
|
|
222
|
+
case "/exit" | "/quit":
|
|
223
|
+
return CommandResult(handled=True, should_exit=True)
|
|
224
|
+
case _:
|
|
225
|
+
return CommandResult(
|
|
226
|
+
handled=False,
|
|
227
|
+
output=f"[yellow]Unknown command: {cmd!r}. Type /help for help.[/yellow]",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Command implementations
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _cmd_help(self) -> CommandResult:
|
|
235
|
+
"""Display a table of all available slash commands."""
|
|
236
|
+
tbl = Table(title="gdm slash commands", header_style="bold cyan", show_lines=False)
|
|
237
|
+
tbl.add_column("Command", style="cyan", no_wrap=True)
|
|
238
|
+
tbl.add_column("Description")
|
|
239
|
+
for cmd, desc in _COMMANDS.items():
|
|
240
|
+
tbl.add_row(cmd, desc)
|
|
241
|
+
self._console.print(tbl)
|
|
242
|
+
return CommandResult(handled=True)
|
|
243
|
+
|
|
244
|
+
def _cmd_model(self, args: list[str]) -> CommandResult:
|
|
245
|
+
"""Show current model tier, or change it to *args[0]*."""
|
|
246
|
+
if not args:
|
|
247
|
+
return CommandResult(
|
|
248
|
+
handled=True,
|
|
249
|
+
output=f"Current model tier: [cyan]{self._current_tier}[/cyan]",
|
|
250
|
+
)
|
|
251
|
+
tier = args[0].lower()
|
|
252
|
+
if tier not in _VALID_TIERS:
|
|
253
|
+
valid = ", ".join(sorted(_VALID_TIERS))
|
|
254
|
+
return CommandResult(
|
|
255
|
+
handled=True,
|
|
256
|
+
output=f"[red]Invalid tier {tier!r}.[/red] Choose from: {valid}",
|
|
257
|
+
)
|
|
258
|
+
self._current_tier = tier
|
|
259
|
+
return CommandResult(handled=True, new_model_tier=tier, output=f"Model tier set to [cyan]{tier}[/cyan]")
|
|
260
|
+
|
|
261
|
+
def _cmd_cost(self) -> CommandResult:
|
|
262
|
+
"""Display a rich table of the session cost summary."""
|
|
263
|
+
summary = self._cost_tracker.summary()
|
|
264
|
+
tbl = Table(title="Session Cost", show_header=False, min_width=38)
|
|
265
|
+
tbl.add_column("Key", style="dim")
|
|
266
|
+
tbl.add_column("Value", style="bold")
|
|
267
|
+
for key, val in summary.items():
|
|
268
|
+
display = f"${val:.6f}" if key == "total_usd" else str(val)
|
|
269
|
+
tbl.add_row(key.replace("_", " "), display)
|
|
270
|
+
self._console.print(tbl)
|
|
271
|
+
return CommandResult(handled=True)
|
|
272
|
+
|
|
273
|
+
def _cmd_tasks(self) -> CommandResult:
|
|
274
|
+
"""Show unread /btw notes and pending / in-progress tasks."""
|
|
275
|
+
self._show_btw_notes()
|
|
276
|
+
self._show_active_tasks()
|
|
277
|
+
return CommandResult(handled=True)
|
|
278
|
+
|
|
279
|
+
def _show_btw_notes(self) -> None:
|
|
280
|
+
"""Print unread btw_queue rows for this session."""
|
|
281
|
+
try:
|
|
282
|
+
rows = self._db.execute_all(
|
|
283
|
+
"SELECT id, message, created_at FROM btw_queue "
|
|
284
|
+
"WHERE session_id = ? AND read_at IS NULL ORDER BY created_at",
|
|
285
|
+
(self._session_id,),
|
|
286
|
+
)
|
|
287
|
+
except Exception as exc: # noqa: BLE001
|
|
288
|
+
log.warning("btw_queue read failed: %s", exc)
|
|
289
|
+
return
|
|
290
|
+
if not rows:
|
|
291
|
+
return
|
|
292
|
+
tbl = Table(title="Queued Notes (/btw)", header_style="bold yellow", show_lines=True)
|
|
293
|
+
tbl.add_column("#", style="dim", width=5)
|
|
294
|
+
tbl.add_column("Message")
|
|
295
|
+
tbl.add_column("Queued at", style="dim")
|
|
296
|
+
for row in rows:
|
|
297
|
+
tbl.add_row(str(row["id"]), row["message"], row["created_at"])
|
|
298
|
+
self._console.print(tbl)
|
|
299
|
+
|
|
300
|
+
def _show_active_tasks(self) -> None:
|
|
301
|
+
"""Print pending/in-progress tasks from the tasks table for this session."""
|
|
302
|
+
try:
|
|
303
|
+
rows = self._db.execute_all(
|
|
304
|
+
"SELECT task_id, title, status FROM tasks "
|
|
305
|
+
"WHERE session_id = ? AND status IN ('pending','in_progress') ORDER BY status",
|
|
306
|
+
(self._session_id,),
|
|
307
|
+
)
|
|
308
|
+
except Exception as exc: # noqa: BLE001
|
|
309
|
+
log.warning("tasks read failed: %s", exc)
|
|
310
|
+
return
|
|
311
|
+
if not rows:
|
|
312
|
+
self._console.print("[dim]No pending tasks.[/dim]")
|
|
313
|
+
return
|
|
314
|
+
tbl = Table(title="Active Tasks", header_style="bold cyan", show_lines=True)
|
|
315
|
+
tbl.add_column("Status", width=12)
|
|
316
|
+
tbl.add_column("ID", style="dim")
|
|
317
|
+
tbl.add_column("Title")
|
|
318
|
+
for row in rows:
|
|
319
|
+
colour = "yellow" if row["status"] == "in_progress" else "white"
|
|
320
|
+
tbl.add_row(f"[{colour}]{row['status']}[/{colour}]", row["task_id"], row["title"])
|
|
321
|
+
self._console.print(tbl)
|
|
322
|
+
|
|
323
|
+
def _cmd_btw(self, message: str) -> CommandResult:
|
|
324
|
+
"""Insert *message* into btw_queue for the agent to pick up later."""
|
|
325
|
+
if not message:
|
|
326
|
+
return CommandResult(handled=True, output="[yellow]Usage: /btw <message>[/yellow]")
|
|
327
|
+
|
|
328
|
+
# Length guard — prevent transcript bloat / abuse
|
|
329
|
+
_BTW_MAX_LEN = 500
|
|
330
|
+
if len(message) > _BTW_MAX_LEN:
|
|
331
|
+
return CommandResult(
|
|
332
|
+
handled=True,
|
|
333
|
+
output=f"[red]Message too long[/red] ({len(message)} chars, max {_BTW_MAX_LEN}).",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Injection check — warn but still enqueue (user is trusted; just advisory)
|
|
337
|
+
from src.security import check_user_injection
|
|
338
|
+
inj = check_user_injection(message)
|
|
339
|
+
if inj.is_injected:
|
|
340
|
+
log.warning("btw message looks like injection attempt: %s", inj.patterns)
|
|
341
|
+
|
|
342
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
343
|
+
try:
|
|
344
|
+
self._db.execute(
|
|
345
|
+
"INSERT INTO btw_queue (session_id, message, created_at) VALUES (?, ?, ?)",
|
|
346
|
+
(self._session_id, message, ts),
|
|
347
|
+
)
|
|
348
|
+
except Exception as exc: # noqa: BLE001
|
|
349
|
+
log.warning("btw_queue insert failed: %s", exc)
|
|
350
|
+
return CommandResult(handled=True, output=f"[red]Queue failed:[/red] {exc}")
|
|
351
|
+
return CommandResult(handled=True, output=f"[green]Queued:[/green] {message}")
|
|
352
|
+
|
|
353
|
+
def _cmd_daemon(self) -> CommandResult:
|
|
354
|
+
"""Show background daemon job queue status."""
|
|
355
|
+
try:
|
|
356
|
+
from src.agent.daemon import BackgroundDaemon
|
|
357
|
+
daemon = BackgroundDaemon(db=self._db)
|
|
358
|
+
pending = daemon.pending_count()
|
|
359
|
+
running = daemon.is_running
|
|
360
|
+
state = "[green]running[/green]" if running else "[dim]stopped[/dim]"
|
|
361
|
+
msg = f"Daemon: {state} | Pending jobs: [bold]{pending}[/bold]"
|
|
362
|
+
except Exception as exc: # noqa: BLE001
|
|
363
|
+
msg = f"[yellow]Daemon unavailable:[/yellow] {exc}"
|
|
364
|
+
return CommandResult(handled=True, output=msg)
|
|
365
|
+
|
|
366
|
+
def _cmd_status(self) -> CommandResult:
|
|
367
|
+
"""Display a session status table (provider, tier, cost, turns, tokens)."""
|
|
368
|
+
summary = self._cost_tracker.summary()
|
|
369
|
+
total_tokens = (
|
|
370
|
+
int(summary.get("input_tokens", 0)) + int(summary.get("output_tokens", 0))
|
|
371
|
+
)
|
|
372
|
+
tbl = Table(title="Session Status", show_header=False, min_width=38)
|
|
373
|
+
tbl.add_column("Field", style="dim")
|
|
374
|
+
tbl.add_column("Value", style="bold")
|
|
375
|
+
tbl.add_row("Provider", self._provider)
|
|
376
|
+
tbl.add_row("Model tier", self._current_tier)
|
|
377
|
+
tbl.add_row("Session ID", self._session_id[:8] + "...")
|
|
378
|
+
tbl.add_row("Turns used", str(summary.get("turns", 0)))
|
|
379
|
+
tbl.add_row("Cost so far", f"${summary.get('total_usd', 0.0):.6f}")
|
|
380
|
+
tbl.add_row("Total tokens", f"{total_tokens:,}")
|
|
381
|
+
self._console.print(tbl)
|
|
382
|
+
return CommandResult(handled=True)
|
|
383
|
+
|
|
384
|
+
def _cmd_sessions(self) -> CommandResult:
|
|
385
|
+
"""List recent sessions with ID, start time, turns, cost, status."""
|
|
386
|
+
try:
|
|
387
|
+
rows = self._db.execute_all(
|
|
388
|
+
"SELECT session_id, created_at, updated_at, turn_count, cost_usd, status "
|
|
389
|
+
"FROM sessions ORDER BY updated_at DESC LIMIT 20"
|
|
390
|
+
)
|
|
391
|
+
except Exception as exc: # noqa: BLE001
|
|
392
|
+
return CommandResult(handled=True, output=f"[red]Could not load sessions: {exc}[/red]")
|
|
393
|
+
|
|
394
|
+
if not rows:
|
|
395
|
+
return CommandResult(handled=True, output="[dim]No sessions found.[/dim]")
|
|
396
|
+
|
|
397
|
+
tbl = Table(
|
|
398
|
+
title="gdm sessions",
|
|
399
|
+
header_style="bold cyan",
|
|
400
|
+
show_lines=False,
|
|
401
|
+
)
|
|
402
|
+
tbl.add_column("#", style="dim", no_wrap=True)
|
|
403
|
+
tbl.add_column("Session ID", no_wrap=True)
|
|
404
|
+
tbl.add_column("Started", no_wrap=True)
|
|
405
|
+
tbl.add_column("Turns", justify="right")
|
|
406
|
+
tbl.add_column("Cost", justify="right")
|
|
407
|
+
tbl.add_column("Status")
|
|
408
|
+
|
|
409
|
+
now = datetime.now(timezone.utc)
|
|
410
|
+
for i, row in enumerate(rows, 1):
|
|
411
|
+
sid = row["session_id"]
|
|
412
|
+
short_id = sid[:8] + "..."
|
|
413
|
+
started = _relative_time(row["created_at"], now)
|
|
414
|
+
turns = str(row["turn_count"] or 0)
|
|
415
|
+
cost = f"${(row['cost_usd'] or 0.0):.4f}"
|
|
416
|
+
status = row["status"] or "active"
|
|
417
|
+
status_style = (
|
|
418
|
+
"[green]active ← resumable[/green]" if status == "active"
|
|
419
|
+
else f"[dim]{status}[/dim]"
|
|
420
|
+
)
|
|
421
|
+
tbl.add_row(str(i), short_id, started, turns, cost, status_style)
|
|
422
|
+
|
|
423
|
+
self._console.print(tbl)
|
|
424
|
+
return CommandResult(handled=True)
|
|
425
|
+
|
|
426
|
+
def _cmd_resume(self, session_id: str | None = None) -> CommandResult:
|
|
427
|
+
"""Signal the REPL to restore a session transcript from checkpoint.
|
|
428
|
+
|
|
429
|
+
With no args, resumes the most recent active session.
|
|
430
|
+
With a session_id arg, resumes that specific session.
|
|
431
|
+
"""
|
|
432
|
+
if session_id:
|
|
433
|
+
# Validate the session exists before committing to restore
|
|
434
|
+
try:
|
|
435
|
+
row = self._db.execute_one(
|
|
436
|
+
"SELECT session_id FROM sessions WHERE session_id = ?",
|
|
437
|
+
(session_id,),
|
|
438
|
+
)
|
|
439
|
+
if not row:
|
|
440
|
+
# Try prefix match
|
|
441
|
+
rows = self._db.execute_all(
|
|
442
|
+
"SELECT session_id FROM sessions WHERE session_id LIKE ? LIMIT 2",
|
|
443
|
+
(session_id + "%",),
|
|
444
|
+
)
|
|
445
|
+
if not rows:
|
|
446
|
+
return CommandResult(
|
|
447
|
+
handled=True,
|
|
448
|
+
output=f"[red]Session {session_id!r} not found.[/red]",
|
|
449
|
+
)
|
|
450
|
+
if len(rows) > 1:
|
|
451
|
+
return CommandResult(
|
|
452
|
+
handled=True,
|
|
453
|
+
output=f"[red]Ambiguous prefix {session_id!r} — matches multiple sessions. Use more characters.[/red]",
|
|
454
|
+
)
|
|
455
|
+
session_id = rows[0]["session_id"]
|
|
456
|
+
except Exception as exc: # noqa: BLE001
|
|
457
|
+
return CommandResult(
|
|
458
|
+
handled=True,
|
|
459
|
+
output=f"[red]Could not look up session: {exc}[/red]",
|
|
460
|
+
)
|
|
461
|
+
return CommandResult(
|
|
462
|
+
handled=True,
|
|
463
|
+
force_resume=True,
|
|
464
|
+
resume_session_id=session_id,
|
|
465
|
+
output=f"[cyan]Restoring session {session_id[:8]}...[/cyan]",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# No session_id — find most recent active session
|
|
469
|
+
try:
|
|
470
|
+
sessions = self._db.list_incomplete_sessions(limit=1)
|
|
471
|
+
except Exception as exc: # noqa: BLE001
|
|
472
|
+
sessions = []
|
|
473
|
+
log.warning("list_incomplete_sessions failed: %s", exc)
|
|
474
|
+
|
|
475
|
+
if not sessions:
|
|
476
|
+
return CommandResult(
|
|
477
|
+
handled=True,
|
|
478
|
+
output="[yellow]No active sessions found. Start a new conversation.[/yellow]",
|
|
479
|
+
)
|
|
480
|
+
target = sessions[0]["session_id"]
|
|
481
|
+
return CommandResult(
|
|
482
|
+
handled=True,
|
|
483
|
+
force_resume=True,
|
|
484
|
+
resume_session_id=target,
|
|
485
|
+
output=f"[cyan]Restoring session {target[:8]}...[/cyan]",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------------------
|
|
489
|
+
# Git commands
|
|
490
|
+
# ------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
def _git_run(self, args: list[str]) -> tuple[int, str]:
|
|
493
|
+
"""Run a git command in project_root. Returns (returncode, output)."""
|
|
494
|
+
try:
|
|
495
|
+
result = subprocess.run(
|
|
496
|
+
["git"] + args,
|
|
497
|
+
cwd=str(self._project_root),
|
|
498
|
+
capture_output=True,
|
|
499
|
+
text=True,
|
|
500
|
+
timeout=15,
|
|
501
|
+
)
|
|
502
|
+
output = (result.stdout + result.stderr).strip()
|
|
503
|
+
return result.returncode, output
|
|
504
|
+
except FileNotFoundError:
|
|
505
|
+
return -1, "git not found in PATH"
|
|
506
|
+
except subprocess.TimeoutExpired:
|
|
507
|
+
return 1, "git command timed out"
|
|
508
|
+
except Exception as exc: # noqa: BLE001
|
|
509
|
+
return 1, str(exc)
|
|
510
|
+
|
|
511
|
+
def _cmd_diff(self) -> CommandResult:
|
|
512
|
+
"""Show git diff of staged + unstaged changes."""
|
|
513
|
+
rc, out = self._git_run(["diff", "HEAD"])
|
|
514
|
+
if rc == -1:
|
|
515
|
+
return CommandResult(handled=True, output=f"[red]{out}[/red]")
|
|
516
|
+
if not out:
|
|
517
|
+
rc2, stat = self._git_run(["status", "--short"])
|
|
518
|
+
if not stat:
|
|
519
|
+
return CommandResult(handled=True, output="[dim]No changes since last commit.[/dim]")
|
|
520
|
+
out = stat
|
|
521
|
+
# Truncate long diffs
|
|
522
|
+
if len(out) > 4000:
|
|
523
|
+
out = out[:4000] + "\n…(truncated)"
|
|
524
|
+
return CommandResult(handled=True, output=f"[dim]{out}[/dim]")
|
|
525
|
+
|
|
526
|
+
def _cmd_undo(self, n: int = 1) -> CommandResult:
|
|
527
|
+
"""Soft-undo the last *n* [gdm-checkpoint] commits (keeps working tree).
|
|
528
|
+
|
|
529
|
+
Only operates on commits tagged [gdm-checkpoint] so it never undoes
|
|
530
|
+
user commits or moves HEAD past non-gdm history.
|
|
531
|
+
"""
|
|
532
|
+
if n < 1:
|
|
533
|
+
n = 1
|
|
534
|
+
# Find the most recent N gdm-checkpoint commit SHAs
|
|
535
|
+
# --fixed-strings avoids BRE treating [...] as a character class
|
|
536
|
+
rc, log_out = self._git_run(
|
|
537
|
+
["log", "--oneline", "--fixed-strings", "--grep=[gdm-checkpoint]", f"-{n}"]
|
|
538
|
+
)
|
|
539
|
+
if rc == -1:
|
|
540
|
+
return CommandResult(handled=True, output=f"[red]{log_out}[/red]")
|
|
541
|
+
if rc != 0 or not log_out.strip():
|
|
542
|
+
return CommandResult(
|
|
543
|
+
handled=True,
|
|
544
|
+
output="[yellow]No gdm checkpoint commits found. Nothing to undo.[/yellow]",
|
|
545
|
+
)
|
|
546
|
+
lines = [ln for ln in log_out.strip().splitlines() if ln]
|
|
547
|
+
if len(lines) < n:
|
|
548
|
+
return CommandResult(
|
|
549
|
+
handled=True,
|
|
550
|
+
output=f"[yellow]Only {len(lines)} checkpoint(s) found; cannot undo {n}.[/yellow]",
|
|
551
|
+
)
|
|
552
|
+
# Oldest among the N checkpoints is the last line; reset to its parent
|
|
553
|
+
oldest_sha = lines[-1].split()[0]
|
|
554
|
+
rc2, out2 = self._git_run(["reset", "--soft", f"{oldest_sha}~1"])
|
|
555
|
+
if rc2 != 0:
|
|
556
|
+
return CommandResult(handled=True, output=f"[red]undo failed:[/red] {out2[:300]}")
|
|
557
|
+
return CommandResult(
|
|
558
|
+
handled=True,
|
|
559
|
+
output=(
|
|
560
|
+
f"[green]✓ Undid {n} checkpoint commit(s).[/green] "
|
|
561
|
+
"Changes preserved in working tree."
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
def _cmd_rollback(self, checkpoint_id: str | None = None) -> CommandResult:
|
|
566
|
+
"""Hard rollback. Without arg: pre-task state. With arg: named checkpoint SHA."""
|
|
567
|
+
# Safety gate: refuse if working tree is dirty
|
|
568
|
+
wf = GitWorkflow(self._project_root)
|
|
569
|
+
if not wf.is_clean():
|
|
570
|
+
return CommandResult(
|
|
571
|
+
handled=True,
|
|
572
|
+
output=(
|
|
573
|
+
"[red]⚠ Uncommitted changes detected. "
|
|
574
|
+
"Commit ([bold]/commit[/bold]) or stash before rollback.[/red]"
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if checkpoint_id:
|
|
579
|
+
# Validate the SHA exists
|
|
580
|
+
rc_v, _ = self._git_run(["cat-file", "-t", checkpoint_id])
|
|
581
|
+
if rc_v != 0:
|
|
582
|
+
return CommandResult(
|
|
583
|
+
handled=True,
|
|
584
|
+
output=f"[red]Unknown checkpoint: {checkpoint_id!r}[/red]",
|
|
585
|
+
)
|
|
586
|
+
target_ref = checkpoint_id
|
|
587
|
+
description = f"checkpoint {checkpoint_id[:8]}"
|
|
588
|
+
else:
|
|
589
|
+
# Existing logic: find parent of first gdm-checkpoint commit
|
|
590
|
+
rc, log_out = self._git_run(
|
|
591
|
+
["log", "--oneline", "--fixed-strings", "--grep=[gdm-checkpoint]", "--reverse"]
|
|
592
|
+
)
|
|
593
|
+
if rc == -1:
|
|
594
|
+
return CommandResult(handled=True, output=f"[red]{log_out}[/red]")
|
|
595
|
+
if rc != 0 or not log_out.strip():
|
|
596
|
+
return CommandResult(
|
|
597
|
+
handled=True,
|
|
598
|
+
output="[yellow]No gdm checkpoints found. Nothing to roll back to.[/yellow]",
|
|
599
|
+
)
|
|
600
|
+
first_sha = log_out.strip().splitlines()[0].split()[0]
|
|
601
|
+
target_ref = f"{first_sha}~1"
|
|
602
|
+
rc_p, pre_sha = self._git_run(["rev-parse", "--short", target_ref])
|
|
603
|
+
if rc_p != 0:
|
|
604
|
+
return CommandResult(
|
|
605
|
+
handled=True,
|
|
606
|
+
output=f"[red]Cannot find pre-task commit (is this the first commit?)[/red] {pre_sha}",
|
|
607
|
+
)
|
|
608
|
+
description = f"pre-task state ({pre_sha.strip()})"
|
|
609
|
+
|
|
610
|
+
# Confirmation prompt before destructive hard reset
|
|
611
|
+
self._console.print(
|
|
612
|
+
f"[bold red]⚠ Hard rollback[/bold red] to {description}.\n"
|
|
613
|
+
"[red]This DISCARDS all uncommitted and committed changes since that point.[/red]\n"
|
|
614
|
+
"Type [bold]YES[/bold] to confirm, anything else to cancel: ",
|
|
615
|
+
end="",
|
|
616
|
+
)
|
|
617
|
+
try:
|
|
618
|
+
confirm = (self._confirm_fn() if self._confirm_fn is not None else input()).strip()
|
|
619
|
+
except (EOFError, KeyboardInterrupt):
|
|
620
|
+
confirm = ""
|
|
621
|
+
if confirm != "YES":
|
|
622
|
+
return CommandResult(handled=True, output="[dim]Rollback cancelled.[/dim]")
|
|
623
|
+
|
|
624
|
+
rc2, out2 = self._git_run(["reset", "--hard", target_ref])
|
|
625
|
+
if rc2 != 0:
|
|
626
|
+
return CommandResult(handled=True, output=f"[red]rollback failed:[/red] {out2[:300]}")
|
|
627
|
+
return CommandResult(
|
|
628
|
+
handled=True,
|
|
629
|
+
output=f"[green]✓ Rolled back to {description}.[/green] All subsequent changes discarded.",
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def _cmd_branch(self) -> CommandResult:
|
|
633
|
+
"""Show current branch and gdm checkpoint history."""
|
|
634
|
+
rc1, branch = self._git_run(["branch", "--show-current"])
|
|
635
|
+
rc2, log_out = self._git_run(["log", "--oneline", "-10"])
|
|
636
|
+
tbl = Table(title="git branch", show_header=False, min_width=50)
|
|
637
|
+
tbl.add_column("Field", style="dim")
|
|
638
|
+
tbl.add_column("Value")
|
|
639
|
+
tbl.add_row("Branch", branch or "(unknown)")
|
|
640
|
+
tbl.add_row("Recent commits", log_out[:500] if log_out else "(no commits)")
|
|
641
|
+
self._console.print(tbl)
|
|
642
|
+
return CommandResult(handled=True)
|
|
643
|
+
|
|
644
|
+
def _cmd_pr(self) -> CommandResult:
|
|
645
|
+
"""Push the current branch and generate a model-written PR description."""
|
|
646
|
+
rc1, branch = self._git_run(["branch", "--show-current"])
|
|
647
|
+
if not branch or not branch.startswith("gdm/"):
|
|
648
|
+
return CommandResult(
|
|
649
|
+
handled=True,
|
|
650
|
+
output="[yellow]Not on a gdm task branch. Run a task first.[/yellow]",
|
|
651
|
+
)
|
|
652
|
+
rc2, out2 = self._git_run(["push", "-u", "origin", branch])
|
|
653
|
+
if rc2 not in (0, 1):
|
|
654
|
+
return CommandResult(handled=True, output=f"[red]push failed:[/red] {out2[:400]}")
|
|
655
|
+
|
|
656
|
+
rc3, log_out = self._git_run(["log", "--oneline", f"origin/HEAD...{branch}"])
|
|
657
|
+
commit_log = log_out[:800] if log_out else "(no commits ahead of origin)"
|
|
658
|
+
|
|
659
|
+
rc4, diff_text = self._git_run(["diff", f"origin/HEAD...{branch}"])
|
|
660
|
+
description = self._generate_pr_description(branch, diff_text or "", commit_log)
|
|
661
|
+
|
|
662
|
+
# Copy to clipboard if pyperclip is available (soft dependency)
|
|
663
|
+
try:
|
|
664
|
+
import pyperclip # type: ignore[import-untyped]
|
|
665
|
+
pyperclip.copy(description)
|
|
666
|
+
clipboard_note = "\n[dim]✓ PR description copied to clipboard.[/dim]"
|
|
667
|
+
except (ImportError, Exception): # noqa: BLE001
|
|
668
|
+
clipboard_note = ""
|
|
669
|
+
|
|
670
|
+
msg = (
|
|
671
|
+
f"[green]✓ Pushed[/green] [cyan]{branch}[/cyan]\n\n"
|
|
672
|
+
f"{description}"
|
|
673
|
+
f"{clipboard_note}"
|
|
674
|
+
)
|
|
675
|
+
return CommandResult(handled=True, output=msg)
|
|
676
|
+
|
|
677
|
+
def _generate_pr_description(
|
|
678
|
+
self, branch: str, diff_text: str, commit_log: str
|
|
679
|
+
) -> str:
|
|
680
|
+
"""Call the Coder model to generate a structured PR description.
|
|
681
|
+
|
|
682
|
+
Prompt asks for ## Summary, ## Changes, ## Testing, ## Notes sections.
|
|
683
|
+
Falls back to the raw commit log on any exception.
|
|
684
|
+
"""
|
|
685
|
+
if not self._cfg:
|
|
686
|
+
return f"Commits:\n{commit_log}\n\n[dim]Open a PR at your git hosting provider.[/dim]"
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
from src.models.client import GdmClient
|
|
690
|
+
from src.models.definitions import ModelTier, get_model
|
|
691
|
+
|
|
692
|
+
model_def = get_model(ModelTier.CODER, getattr(self._cfg, "provider", "grok"))
|
|
693
|
+
model_id = model_def.id
|
|
694
|
+
client = GdmClient(self._cfg) # type: ignore[arg-type]
|
|
695
|
+
|
|
696
|
+
prompt = (
|
|
697
|
+
"You are writing a GitHub pull request description.\n"
|
|
698
|
+
f"Branch: {branch}\n"
|
|
699
|
+
f"Commits:\n{commit_log}\n\n"
|
|
700
|
+
f"Diff summary (first 4000 chars):\n{diff_text[:4000]}\n\n"
|
|
701
|
+
"Write a PR description with these sections:\n"
|
|
702
|
+
"## Summary (2-3 sentences)\n"
|
|
703
|
+
"## Changes\n"
|
|
704
|
+
"## Testing\n"
|
|
705
|
+
"## Notes (optional)\n"
|
|
706
|
+
"Be precise and factual. Do not invent changes not in the diff."
|
|
707
|
+
)
|
|
708
|
+
response = client.complete(
|
|
709
|
+
messages=[{"role": "user", "content": prompt}],
|
|
710
|
+
model=model_id,
|
|
711
|
+
max_tokens=600,
|
|
712
|
+
temperature=0.2,
|
|
713
|
+
)
|
|
714
|
+
return response.choices[0].message.content.strip()
|
|
715
|
+
except Exception as exc: # noqa: BLE001
|
|
716
|
+
log.warning("_generate_pr_description failed: %s", exc)
|
|
717
|
+
return f"Commits:\n{commit_log}\n\n[dim]Open a PR at your git hosting provider.[/dim]"
|
|
718
|
+
|
|
719
|
+
def _cmd_commit(self, args: list[str]) -> CommandResult:
|
|
720
|
+
"""Create a manual checkpoint commit."""
|
|
721
|
+
msg = " ".join(args).strip() or "manual checkpoint"
|
|
722
|
+
rc1, _ = self._git_run(["add", "-A"])
|
|
723
|
+
rc2, out2 = self._git_run(["commit", "--allow-empty", "-m", f"[gdm-checkpoint] {msg}"])
|
|
724
|
+
if rc2 != 0:
|
|
725
|
+
return CommandResult(handled=True, output=f"[red]commit failed:[/red] {out2[:400]}")
|
|
726
|
+
rc3, sha = self._git_run(["rev-parse", "--short", "HEAD"])
|
|
727
|
+
return CommandResult(
|
|
728
|
+
handled=True,
|
|
729
|
+
output=f"[green]✓ Checkpoint commit {sha}:[/green] {msg}",
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def _cmd_browser(self, args: list[str]) -> CommandResult:
|
|
733
|
+
"""Manage the gdm bridge server for browser automation."""
|
|
734
|
+
import os
|
|
735
|
+
import platform
|
|
736
|
+
import subprocess
|
|
737
|
+
import sys
|
|
738
|
+
|
|
739
|
+
action = args[0].lower() if args else "status"
|
|
740
|
+
pid_file = self._project_root / ".gdm_bridge.pid"
|
|
741
|
+
|
|
742
|
+
match action:
|
|
743
|
+
case "start":
|
|
744
|
+
if pid_file.exists():
|
|
745
|
+
try:
|
|
746
|
+
pid = int(pid_file.read_text().strip())
|
|
747
|
+
# Check if process is alive
|
|
748
|
+
if platform.system() == "Windows":
|
|
749
|
+
r = subprocess.run(
|
|
750
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
751
|
+
capture_output=True, text=True,
|
|
752
|
+
)
|
|
753
|
+
alive = str(pid) in r.stdout
|
|
754
|
+
else:
|
|
755
|
+
try:
|
|
756
|
+
os.kill(pid, 0)
|
|
757
|
+
alive = True
|
|
758
|
+
except ProcessLookupError:
|
|
759
|
+
alive = False
|
|
760
|
+
if alive:
|
|
761
|
+
return CommandResult(
|
|
762
|
+
handled=True,
|
|
763
|
+
output=f"[yellow]Bridge already running (PID {pid}).[/yellow]",
|
|
764
|
+
)
|
|
765
|
+
except Exception: # noqa: BLE001
|
|
766
|
+
pass
|
|
767
|
+
|
|
768
|
+
proc = subprocess.Popen(
|
|
769
|
+
[sys.executable, "-m", "src.server.bridge_cli"],
|
|
770
|
+
stdout=subprocess.DEVNULL,
|
|
771
|
+
stderr=subprocess.DEVNULL,
|
|
772
|
+
)
|
|
773
|
+
pid_file.write_text(str(proc.pid))
|
|
774
|
+
return CommandResult(
|
|
775
|
+
handled=True,
|
|
776
|
+
output=(
|
|
777
|
+
f"[green]✓ Bridge server started (PID {proc.pid})[/green]\n"
|
|
778
|
+
f"[dim]Load the gdm-chrome extension to connect. "
|
|
779
|
+
f"Token at: ~/.config/gdm/browser.token[/dim]"
|
|
780
|
+
),
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
case "stop":
|
|
784
|
+
if not pid_file.exists():
|
|
785
|
+
return CommandResult(handled=True, output="[yellow]Bridge not running.[/yellow]")
|
|
786
|
+
try:
|
|
787
|
+
pid = int(pid_file.read_text().strip())
|
|
788
|
+
if platform.system() == "Windows":
|
|
789
|
+
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
|
|
790
|
+
else:
|
|
791
|
+
os.kill(pid, 15) # SIGTERM
|
|
792
|
+
pid_file.unlink(missing_ok=True)
|
|
793
|
+
return CommandResult(
|
|
794
|
+
handled=True,
|
|
795
|
+
output=f"[green]✓ Bridge stopped (PID {pid}).[/green]",
|
|
796
|
+
)
|
|
797
|
+
except Exception as exc: # noqa: BLE001
|
|
798
|
+
pid_file.unlink(missing_ok=True)
|
|
799
|
+
return CommandResult(handled=True, output=f"[red]Stop failed:[/red] {exc}")
|
|
800
|
+
|
|
801
|
+
case "status":
|
|
802
|
+
if not pid_file.exists():
|
|
803
|
+
return CommandResult(
|
|
804
|
+
handled=True,
|
|
805
|
+
output="[dim]Bridge not running. Use /browser start to launch it.[/dim]",
|
|
806
|
+
)
|
|
807
|
+
try:
|
|
808
|
+
pid = int(pid_file.read_text().strip())
|
|
809
|
+
try:
|
|
810
|
+
import httpx
|
|
811
|
+
r = httpx.get("http://127.0.0.1:9321/health", timeout=2.0)
|
|
812
|
+
data = r.json()
|
|
813
|
+
clients = data.get("clients", {})
|
|
814
|
+
queue = data.get("queue_depth", 0)
|
|
815
|
+
return CommandResult(
|
|
816
|
+
handled=True,
|
|
817
|
+
output=(
|
|
818
|
+
f"[green]✓ Bridge running[/green] (PID {pid})\n"
|
|
819
|
+
f" CLI clients: {clients.get('cli', 0)} "
|
|
820
|
+
f"Extension clients: {clients.get('extension', 0)} "
|
|
821
|
+
f"Queue depth: {queue}"
|
|
822
|
+
),
|
|
823
|
+
)
|
|
824
|
+
except Exception: # noqa: BLE001
|
|
825
|
+
return CommandResult(
|
|
826
|
+
handled=True,
|
|
827
|
+
output=f"[yellow]Bridge process running (PID {pid}) but health check failed.[/yellow]",
|
|
828
|
+
)
|
|
829
|
+
except Exception as exc: # noqa: BLE001
|
|
830
|
+
return CommandResult(handled=True, output=f"[red]Status check failed:[/red] {exc}")
|
|
831
|
+
|
|
832
|
+
case _:
|
|
833
|
+
return CommandResult(
|
|
834
|
+
handled=True,
|
|
835
|
+
output="[yellow]Usage: /browser [start|stop|status][/yellow]",
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
def _cmd_remote(self, args: list[str]) -> CommandResult:
|
|
839
|
+
"""Manage the gdm remote tunnel.
|
|
840
|
+
|
|
841
|
+
Usage:
|
|
842
|
+
/remote start [--port PORT] - start tunnel, print URL and QR
|
|
843
|
+
/remote stop - tear down tunnel
|
|
844
|
+
/remote status - show tunnel state
|
|
845
|
+
/remote qr - display QR code for active tunnel
|
|
846
|
+
"""
|
|
847
|
+
import json
|
|
848
|
+
from pathlib import Path as _Path
|
|
849
|
+
|
|
850
|
+
action = (args[0] if args else "status").lower()
|
|
851
|
+
state_file = _Path.cwd() / ".gdm_remote.json"
|
|
852
|
+
|
|
853
|
+
match action:
|
|
854
|
+
case "start":
|
|
855
|
+
port = 8765
|
|
856
|
+
for i, a in enumerate(args[1:]):
|
|
857
|
+
if a in ("--port", "-p") and i + 1 < len(args) - 1:
|
|
858
|
+
try:
|
|
859
|
+
port = int(args[i + 2])
|
|
860
|
+
except ValueError:
|
|
861
|
+
pass
|
|
862
|
+
try:
|
|
863
|
+
from src.remote.tunnel import TunnelManager
|
|
864
|
+
from src.remote.qr import make_pairing_url, render_qr
|
|
865
|
+
from src.remote.token_manager import PairingTokenService
|
|
866
|
+
|
|
867
|
+
mgr = TunnelManager(port=port)
|
|
868
|
+
url = mgr.start()
|
|
869
|
+
token_svc = PairingTokenService()
|
|
870
|
+
token = token_svc.issue()
|
|
871
|
+
pairing_url = make_pairing_url(url, token)
|
|
872
|
+
st = mgr.status()
|
|
873
|
+
state_file.write_text(
|
|
874
|
+
json.dumps({
|
|
875
|
+
"url": url,
|
|
876
|
+
"pairing_url": pairing_url,
|
|
877
|
+
"provider": st["provider"],
|
|
878
|
+
"port": port,
|
|
879
|
+
}),
|
|
880
|
+
encoding="utf-8",
|
|
881
|
+
)
|
|
882
|
+
qr_text = render_qr(pairing_url)
|
|
883
|
+
lines = [
|
|
884
|
+
f"[green]Tunnel started:[/green] [cyan]{url}[/cyan]",
|
|
885
|
+
f" Provider: [bold]{st['provider']}[/bold]",
|
|
886
|
+
"",
|
|
887
|
+
qr_text,
|
|
888
|
+
f"\n[dim]Pairing URL: {pairing_url}[/dim]",
|
|
889
|
+
]
|
|
890
|
+
mgr.stop()
|
|
891
|
+
return CommandResult(handled=True, output="\n".join(lines))
|
|
892
|
+
except Exception as exc: # noqa: BLE001
|
|
893
|
+
return CommandResult(handled=True, output=f"[red]Remote start failed:[/red] {exc}")
|
|
894
|
+
|
|
895
|
+
case "stop":
|
|
896
|
+
if not state_file.exists():
|
|
897
|
+
return CommandResult(handled=True, output="[yellow]Remote tunnel not running.[/yellow]")
|
|
898
|
+
state_file.unlink(missing_ok=True)
|
|
899
|
+
return CommandResult(handled=True, output="[green]Remote tunnel stopped.[/green]")
|
|
900
|
+
|
|
901
|
+
case "status":
|
|
902
|
+
if not state_file.exists():
|
|
903
|
+
return CommandResult(
|
|
904
|
+
handled=True,
|
|
905
|
+
output="[dim]Remote tunnel not running. Run: /remote start[/dim]",
|
|
906
|
+
)
|
|
907
|
+
try:
|
|
908
|
+
data = json.loads(state_file.read_text(encoding="utf-8"))
|
|
909
|
+
url = data.get("url", "unknown")
|
|
910
|
+
provider = data.get("provider", "unknown")
|
|
911
|
+
return CommandResult(
|
|
912
|
+
handled=True,
|
|
913
|
+
output=(
|
|
914
|
+
f"[green]Remote tunnel active[/green]\n"
|
|
915
|
+
f" URL: [cyan]{url}[/cyan]\n"
|
|
916
|
+
f" Provider: [bold]{provider}[/bold]"
|
|
917
|
+
),
|
|
918
|
+
)
|
|
919
|
+
except Exception as exc: # noqa: BLE001
|
|
920
|
+
return CommandResult(
|
|
921
|
+
handled=True, output=f"[yellow]Could not read tunnel state:[/yellow] {exc}"
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
case "qr":
|
|
925
|
+
if not state_file.exists():
|
|
926
|
+
return CommandResult(
|
|
927
|
+
handled=True, output="[yellow]No active tunnel. Run: /remote start[/yellow]"
|
|
928
|
+
)
|
|
929
|
+
try:
|
|
930
|
+
from src.remote.qr import render_qr
|
|
931
|
+
|
|
932
|
+
data = json.loads(state_file.read_text(encoding="utf-8"))
|
|
933
|
+
pairing_url = data.get("pairing_url") or data.get("url", "")
|
|
934
|
+
if not pairing_url:
|
|
935
|
+
return CommandResult(handled=True, output="[red]No URL found in tunnel state.[/red]")
|
|
936
|
+
qr_text = render_qr(pairing_url)
|
|
937
|
+
return CommandResult(handled=True, output=f"{qr_text}\n[dim]{pairing_url}[/dim]")
|
|
938
|
+
except Exception as exc: # noqa: BLE001
|
|
939
|
+
return CommandResult(handled=True, output=f"[red]QR render failed:[/red] {exc}")
|
|
940
|
+
|
|
941
|
+
case _:
|
|
942
|
+
return CommandResult(
|
|
943
|
+
handled=True,
|
|
944
|
+
output="[yellow]Usage: /remote [start|stop|status|qr][/yellow]",
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
def _cmd_replay(self, args: list[str]) -> CommandResult:
|
|
948
|
+
"""Replay, compare, fork, or export past sessions.
|
|
949
|
+
|
|
950
|
+
Usage:
|
|
951
|
+
/replay {session_id} — interactive step-through
|
|
952
|
+
/replay {session_id} --json — machine-readable summary
|
|
953
|
+
/replay compare {id1} {id2} — divergence summary
|
|
954
|
+
/replay fork {session_id} {turn_index} — fork from turn N
|
|
955
|
+
/replay export {session_id} [--format fmt] — export (openai|anthropic|raw)
|
|
956
|
+
"""
|
|
957
|
+
from src.runtime.replay import ReplayError, SessionReplay
|
|
958
|
+
|
|
959
|
+
if not args:
|
|
960
|
+
return CommandResult(
|
|
961
|
+
handled=True,
|
|
962
|
+
output="[yellow]Usage: /replay {session_id} | compare {id1} {id2} | "
|
|
963
|
+
"fork {id} {n} | export {id} [--format openai|anthropic|raw][/yellow]",
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# handle() splits the input with maxsplit=2 so later tokens may be merged;
|
|
967
|
+
# re-split to get individual tokens.
|
|
968
|
+
args_flat: list[str] = []
|
|
969
|
+
for a in args:
|
|
970
|
+
args_flat.extend(a.split())
|
|
971
|
+
args = args_flat
|
|
972
|
+
|
|
973
|
+
sub = args[0].lower()
|
|
974
|
+
|
|
975
|
+
# --- /replay compare {id1} {id2} ---
|
|
976
|
+
if sub == "compare":
|
|
977
|
+
if len(args) < 3:
|
|
978
|
+
return CommandResult(
|
|
979
|
+
handled=True,
|
|
980
|
+
output="[yellow]Usage: /replay compare {session_id_a} {session_id_b}[/yellow]",
|
|
981
|
+
)
|
|
982
|
+
id_a, id_b = args[1], args[2]
|
|
983
|
+
try:
|
|
984
|
+
replay_a = SessionReplay(self._db, id_a)
|
|
985
|
+
replay_a.load_session()
|
|
986
|
+
diff = replay_a.compare(id_b)
|
|
987
|
+
summary = diff.divergence_summary()
|
|
988
|
+
lines = [
|
|
989
|
+
f"[bold]Session A:[/bold] {id_a} ({len(diff.frames_a)} turns, ${summary['cost_a_usd']:.6f})",
|
|
990
|
+
f"[bold]Session B:[/bold] {id_b} ({len(diff.frames_b)} turns, ${summary['cost_b_usd']:.6f})",
|
|
991
|
+
f"[bold]Common prefix:[/bold] {summary['common_turns']} turns",
|
|
992
|
+
f"[bold]Unique to A:[/bold] {summary['session_a_unique_turns']} turns",
|
|
993
|
+
f"[bold]Unique to B:[/bold] {summary['session_b_unique_turns']} turns",
|
|
994
|
+
]
|
|
995
|
+
return CommandResult(handled=True, output="\n".join(lines))
|
|
996
|
+
except ReplayError as exc:
|
|
997
|
+
return CommandResult(handled=True, output=f"[red]Replay error:[/red] {exc}")
|
|
998
|
+
except Exception as exc: # noqa: BLE001
|
|
999
|
+
return CommandResult(handled=True, output=f"[red]Error:[/red] {exc}")
|
|
1000
|
+
|
|
1001
|
+
# --- /replay fork {session_id} {turn_index} ---
|
|
1002
|
+
if sub == "fork":
|
|
1003
|
+
if len(args) < 3:
|
|
1004
|
+
return CommandResult(
|
|
1005
|
+
handled=True,
|
|
1006
|
+
output="[yellow]Usage: /replay fork {session_id} {turn_index}[/yellow]",
|
|
1007
|
+
)
|
|
1008
|
+
session_id, turn_str = args[1], args[2]
|
|
1009
|
+
try:
|
|
1010
|
+
turn_n = int(turn_str)
|
|
1011
|
+
except ValueError:
|
|
1012
|
+
return CommandResult(
|
|
1013
|
+
handled=True, output=f"[red]turn_index must be an integer, got {turn_str!r}[/red]"
|
|
1014
|
+
)
|
|
1015
|
+
try:
|
|
1016
|
+
import uuid as _uuid
|
|
1017
|
+
new_sid = str(_uuid.uuid4())
|
|
1018
|
+
replay = SessionReplay(self._db, session_id)
|
|
1019
|
+
replay.load_session()
|
|
1020
|
+
replay.fork_from(turn_n, new_sid)
|
|
1021
|
+
return CommandResult(
|
|
1022
|
+
handled=True,
|
|
1023
|
+
output=(
|
|
1024
|
+
f"[green]✓ Forked session {session_id[:8]}… at turn {turn_n}[/green]\n"
|
|
1025
|
+
f"New session: [cyan]{new_sid}[/cyan]"
|
|
1026
|
+
),
|
|
1027
|
+
)
|
|
1028
|
+
except ReplayError as exc:
|
|
1029
|
+
return CommandResult(handled=True, output=f"[red]Replay error:[/red] {exc}")
|
|
1030
|
+
except Exception as exc: # noqa: BLE001
|
|
1031
|
+
return CommandResult(handled=True, output=f"[red]Error:[/red] {exc}")
|
|
1032
|
+
|
|
1033
|
+
# --- /replay export {session_id} [--format fmt] ---
|
|
1034
|
+
if sub == "export":
|
|
1035
|
+
if len(args) < 2:
|
|
1036
|
+
return CommandResult(
|
|
1037
|
+
handled=True,
|
|
1038
|
+
output="[yellow]Usage: /replay export {session_id} [--format openai|anthropic|raw][/yellow]",
|
|
1039
|
+
)
|
|
1040
|
+
session_id = args[1]
|
|
1041
|
+
fmt = "openai"
|
|
1042
|
+
if "--format" in args:
|
|
1043
|
+
idx = args.index("--format")
|
|
1044
|
+
if idx + 1 < len(args):
|
|
1045
|
+
fmt = args[idx + 1]
|
|
1046
|
+
try:
|
|
1047
|
+
replay = SessionReplay(self._db, session_id)
|
|
1048
|
+
replay.load_session()
|
|
1049
|
+
records = replay.export(fmt)
|
|
1050
|
+
import json as _json
|
|
1051
|
+
lines_out = "\n".join(_json.dumps(r) for r in records)
|
|
1052
|
+
return CommandResult(
|
|
1053
|
+
handled=True,
|
|
1054
|
+
output=f"[green]{len(records)} records exported ({fmt}):[/green]\n{lines_out[:2000]}",
|
|
1055
|
+
)
|
|
1056
|
+
except (ReplayError, ValueError) as exc:
|
|
1057
|
+
return CommandResult(handled=True, output=f"[red]Export error:[/red] {exc}")
|
|
1058
|
+
except Exception as exc: # noqa: BLE001
|
|
1059
|
+
return CommandResult(handled=True, output=f"[red]Error:[/red] {exc}")
|
|
1060
|
+
|
|
1061
|
+
# --- /replay {session_id} [--json] --- (default: interactive display)
|
|
1062
|
+
session_id = args[0]
|
|
1063
|
+
as_json = "--json" in args
|
|
1064
|
+
try:
|
|
1065
|
+
replay = SessionReplay(self._db, session_id)
|
|
1066
|
+
replay.load_session()
|
|
1067
|
+
except ReplayError as exc:
|
|
1068
|
+
return CommandResult(handled=True, output=f"[red]Replay error:[/red] {exc}")
|
|
1069
|
+
except Exception as exc: # noqa: BLE001
|
|
1070
|
+
return CommandResult(handled=True, output=f"[red]Error loading session:[/red] {exc}")
|
|
1071
|
+
|
|
1072
|
+
if as_json:
|
|
1073
|
+
import json as _json
|
|
1074
|
+
records = replay.export("raw")
|
|
1075
|
+
return CommandResult(handled=True, output=_json.dumps(records, indent=2)[:4000])
|
|
1076
|
+
|
|
1077
|
+
# Non-blocking textual display: show all frames, then navigation hint
|
|
1078
|
+
lines = [f"[bold]Session {session_id[:8]}…[/bold] — {replay.turn_count} turn(s)\n"]
|
|
1079
|
+
for frame in replay._frames:
|
|
1080
|
+
lines.append(
|
|
1081
|
+
f"[cyan]Turn {frame.turn_index}[/cyan] "
|
|
1082
|
+
f"[dim]{frame.model} ${frame.cost_usd:.6f}[/dim]"
|
|
1083
|
+
)
|
|
1084
|
+
if frame.user_message:
|
|
1085
|
+
snippet = frame.user_message[:120].replace("\n", " ")
|
|
1086
|
+
lines.append(f" User: {snippet}")
|
|
1087
|
+
if frame.assistant_text:
|
|
1088
|
+
snippet = frame.assistant_text[:120].replace("\n", " ")
|
|
1089
|
+
lines.append(f" Asst: {snippet}")
|
|
1090
|
+
if frame.tool_calls:
|
|
1091
|
+
names = ", ".join(tc.get("tool_name", "?") for tc in frame.tool_calls)
|
|
1092
|
+
lines.append(f" Tools: {names}")
|
|
1093
|
+
if frame.annotation:
|
|
1094
|
+
lines.append(f" [yellow]Annotation: {frame.annotation}[/yellow]")
|
|
1095
|
+
lines.append("")
|
|
1096
|
+
|
|
1097
|
+
lines.append(
|
|
1098
|
+
"[dim]Tip: /replay compare {id1} {id2} | /replay fork {id} {n} | "
|
|
1099
|
+
"/replay export {id} --format openai[/dim]"
|
|
1100
|
+
)
|
|
1101
|
+
return CommandResult(handled=True, output="\n".join(lines))
|
|
1102
|
+
|
|
1103
|
+
def _cmd_doctor(self) -> CommandResult:
|
|
1104
|
+
"""Show resolved model IDs for all providers and tiers."""
|
|
1105
|
+
from src.models.definitions import (
|
|
1106
|
+
_CODEX_BY_TIER,
|
|
1107
|
+
_GEMINI_BY_TIER,
|
|
1108
|
+
_GROK_BY_TIER,
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
tbl = Table(title="Resolved Model IDs", header_style="bold cyan", show_lines=True)
|
|
1112
|
+
tbl.add_column("Provider", style="bold")
|
|
1113
|
+
tbl.add_column("Tier")
|
|
1114
|
+
tbl.add_column("Model ID")
|
|
1115
|
+
tbl.add_column("Context (K)")
|
|
1116
|
+
tbl.add_column("$/1M in")
|
|
1117
|
+
tbl.add_column("$/1M out")
|
|
1118
|
+
|
|
1119
|
+
for provider_label, tier_map in (
|
|
1120
|
+
("grok", _GROK_BY_TIER),
|
|
1121
|
+
("gemini", _GEMINI_BY_TIER),
|
|
1122
|
+
("codex", _CODEX_BY_TIER),
|
|
1123
|
+
):
|
|
1124
|
+
for tier_name, model_def in sorted(tier_map.items()):
|
|
1125
|
+
tbl.add_row(
|
|
1126
|
+
provider_label,
|
|
1127
|
+
tier_name,
|
|
1128
|
+
model_def.id,
|
|
1129
|
+
str(model_def.context_window // 1000),
|
|
1130
|
+
f"{model_def.input_per_m:.4f}",
|
|
1131
|
+
f"{model_def.output_per_m:.4f}",
|
|
1132
|
+
)
|
|
1133
|
+
self._console.print(tbl)
|
|
1134
|
+
return CommandResult(handled=True)
|
|
1135
|
+
|
|
1136
|
+
def _cmd_reasoning(self, args: list[str]) -> CommandResult:
|
|
1137
|
+
"""Show or set the per-turn reasoning mode.
|
|
1138
|
+
|
|
1139
|
+
Usage:
|
|
1140
|
+
/reasoning — show current mode
|
|
1141
|
+
/reasoning on — force Reasoner tier every turn
|
|
1142
|
+
/reasoning off — force Scout tier every turn
|
|
1143
|
+
/reasoning auto — use the deterministic scoring classifier
|
|
1144
|
+
"""
|
|
1145
|
+
_VALID_MODES: frozenset[str] = frozenset({"on", "off", "auto"})
|
|
1146
|
+
if not args:
|
|
1147
|
+
return CommandResult(
|
|
1148
|
+
handled=True,
|
|
1149
|
+
output=(
|
|
1150
|
+
f"[cyan]Reasoning mode:[/cyan] [bold]{self._reasoning_mode}[/bold]\n"
|
|
1151
|
+
"[dim]Use /reasoning on|off|auto to change.[/dim]"
|
|
1152
|
+
),
|
|
1153
|
+
)
|
|
1154
|
+
mode = args[0].lower().strip()
|
|
1155
|
+
if mode not in _VALID_MODES:
|
|
1156
|
+
return CommandResult(
|
|
1157
|
+
handled=True,
|
|
1158
|
+
output=f"[yellow]Unknown mode {mode!r}. Valid: on | off | auto[/yellow]",
|
|
1159
|
+
)
|
|
1160
|
+
self._reasoning_mode = mode
|
|
1161
|
+
_DESCRIPTIONS = {
|
|
1162
|
+
"on": "Force [bold]Reasoner[/bold] tier — highest quality, highest cost",
|
|
1163
|
+
"off": "Force [bold]Scout[/bold] tier — cheapest, fastest",
|
|
1164
|
+
"auto": "Use [bold]auto-classifier[/bold] — selects tier per turn by score",
|
|
1165
|
+
}
|
|
1166
|
+
return CommandResult(
|
|
1167
|
+
handled=True,
|
|
1168
|
+
new_reasoning_mode=mode,
|
|
1169
|
+
output=f"[green]✓ Reasoning mode → {mode}[/green] {_DESCRIPTIONS[mode]}",
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
# ------------------------------------------------------------------
|
|
1173
|
+
# /artifacts and /save
|
|
1174
|
+
# ------------------------------------------------------------------
|
|
1175
|
+
|
|
1176
|
+
def _cmd_artifacts(self, args: list[str]) -> CommandResult:
|
|
1177
|
+
"""Handle /artifacts [sub] [args]."""
|
|
1178
|
+
from src.artifacts.artifact_store import ArtifactNotFoundError, ArtifactStore
|
|
1179
|
+
store = ArtifactStore(self._db)
|
|
1180
|
+
|
|
1181
|
+
sub = args[0].lower() if args else ""
|
|
1182
|
+
rest = args[1].split() if len(args) > 1 else []
|
|
1183
|
+
|
|
1184
|
+
if sub == "view":
|
|
1185
|
+
name_or_id = rest[0] if rest else ""
|
|
1186
|
+
if not name_or_id:
|
|
1187
|
+
return CommandResult(handled=True, output="[red]Usage: /artifacts view {name|id}[/red]")
|
|
1188
|
+
try:
|
|
1189
|
+
art = store.get(name_or_id)
|
|
1190
|
+
except ArtifactNotFoundError as exc:
|
|
1191
|
+
return CommandResult(handled=True, output=f"[red]{exc}[/red]")
|
|
1192
|
+
header = (
|
|
1193
|
+
f"[bold]{art.name}[/bold] [dim]{art.type} v{art.version_num} {art.updated_at[:16]}[/dim]\n"
|
|
1194
|
+
)
|
|
1195
|
+
return CommandResult(handled=True, output=header + art.content)
|
|
1196
|
+
|
|
1197
|
+
if sub == "diff":
|
|
1198
|
+
ids = args[1].split() if len(args) > 1 else []
|
|
1199
|
+
if len(ids) < 2:
|
|
1200
|
+
return CommandResult(handled=True, output="[red]Usage: /artifacts diff {name1} {name2}[/red]")
|
|
1201
|
+
try:
|
|
1202
|
+
diff = store.diff(ids[0], ids[1])
|
|
1203
|
+
except ArtifactNotFoundError as exc:
|
|
1204
|
+
return CommandResult(handled=True, output=f"[red]{exc}[/red]")
|
|
1205
|
+
except TypeError as exc:
|
|
1206
|
+
return CommandResult(handled=True, output=f"[red]{exc}[/red]")
|
|
1207
|
+
if not diff.unified_diff:
|
|
1208
|
+
return CommandResult(handled=True, output="[dim]No differences.[/dim]")
|
|
1209
|
+
return CommandResult(handled=True, output=diff.unified_diff)
|
|
1210
|
+
|
|
1211
|
+
if sub == "search":
|
|
1212
|
+
query = " ".join(rest) if rest else (args[1] if len(args) > 1 else "")
|
|
1213
|
+
if not query:
|
|
1214
|
+
return CommandResult(handled=True, output="[red]Usage: /artifacts search {query}[/red]")
|
|
1215
|
+
results = store.search(query)
|
|
1216
|
+
if not results:
|
|
1217
|
+
return CommandResult(handled=True, output=f"[dim]No artifacts matching {query!r}.[/dim]")
|
|
1218
|
+
tbl = Table(title=f"Search: {query}", header_style="bold cyan")
|
|
1219
|
+
tbl.add_column("Name", style="cyan")
|
|
1220
|
+
tbl.add_column("Type")
|
|
1221
|
+
tbl.add_column("v#")
|
|
1222
|
+
tbl.add_column("Updated")
|
|
1223
|
+
for art in results:
|
|
1224
|
+
tbl.add_row(art.name, art.type, str(art.version_num), art.updated_at[:16])
|
|
1225
|
+
self._console.print(tbl)
|
|
1226
|
+
return CommandResult(handled=True)
|
|
1227
|
+
|
|
1228
|
+
if sub == "export":
|
|
1229
|
+
export_args = args[1].split() if len(args) > 1 else []
|
|
1230
|
+
if not export_args:
|
|
1231
|
+
return CommandResult(handled=True, output="[red]Usage: /artifacts export {name|id} [--format raw|html][/red]")
|
|
1232
|
+
name_or_id = export_args[0]
|
|
1233
|
+
fmt = "raw"
|
|
1234
|
+
if "--format" in export_args:
|
|
1235
|
+
idx = export_args.index("--format")
|
|
1236
|
+
if idx + 1 < len(export_args):
|
|
1237
|
+
fmt = export_args[idx + 1]
|
|
1238
|
+
try:
|
|
1239
|
+
content = store.export(name_or_id, fmt=fmt)
|
|
1240
|
+
except ArtifactNotFoundError as exc:
|
|
1241
|
+
return CommandResult(handled=True, output=f"[red]{exc}[/red]")
|
|
1242
|
+
except ValueError as exc:
|
|
1243
|
+
return CommandResult(handled=True, output=f"[red]{exc}[/red]")
|
|
1244
|
+
return CommandResult(handled=True, output=content)
|
|
1245
|
+
|
|
1246
|
+
# Default: list all artifacts
|
|
1247
|
+
artifacts = store.list()
|
|
1248
|
+
if not artifacts:
|
|
1249
|
+
return CommandResult(handled=True, output="[dim]No artifacts saved yet. Use /save [name] to save one.[/dim]")
|
|
1250
|
+
tbl = Table(title="Saved Artifacts", header_style="bold cyan", show_lines=False)
|
|
1251
|
+
tbl.add_column("Name", style="cyan", no_wrap=True)
|
|
1252
|
+
tbl.add_column("Type")
|
|
1253
|
+
tbl.add_column("Ver")
|
|
1254
|
+
tbl.add_column("Session", style="dim")
|
|
1255
|
+
tbl.add_column("Updated")
|
|
1256
|
+
for art in artifacts:
|
|
1257
|
+
session_abbrev = (art.session_id or "")[:8]
|
|
1258
|
+
tbl.add_row(art.name, art.type, str(art.version_num), session_abbrev, art.updated_at[:16])
|
|
1259
|
+
self._console.print(tbl)
|
|
1260
|
+
return CommandResult(handled=True)
|
|
1261
|
+
|
|
1262
|
+
def _cmd_save(self, name_arg: str) -> CommandResult:
|
|
1263
|
+
"""Handle /save [name] — signals loop to save last assistant response."""
|
|
1264
|
+
name = name_arg.strip() or "unnamed"
|
|
1265
|
+
return CommandResult(
|
|
1266
|
+
handled=True,
|
|
1267
|
+
save_artifact_name=name,
|
|
1268
|
+
output=f"[dim]Saving last response as artifact [cyan]{name!r}[/cyan]...[/dim]",
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
def _cmd_proxy(self, args: list[str]) -> CommandResult:
|
|
1272
|
+
"""Handle /proxy [on|off|url <url>|token <tok>|status]."""
|
|
1273
|
+
from src.auth import CredentialStore
|
|
1274
|
+
from src.models.definitions import PROXY_DEFAULT_URL
|
|
1275
|
+
|
|
1276
|
+
sub = args[0].lower() if args else "status"
|
|
1277
|
+
|
|
1278
|
+
if sub == "on":
|
|
1279
|
+
if not self._proxy_token:
|
|
1280
|
+
return CommandResult(
|
|
1281
|
+
handled=True,
|
|
1282
|
+
output=(
|
|
1283
|
+
"[red]No proxy token stored. Set one first:[/red]\n"
|
|
1284
|
+
" /proxy token <your-token>"
|
|
1285
|
+
),
|
|
1286
|
+
)
|
|
1287
|
+
if not self._proxy_url:
|
|
1288
|
+
self._proxy_url = PROXY_DEFAULT_URL
|
|
1289
|
+
self._proxy_enabled = True
|
|
1290
|
+
return CommandResult(
|
|
1291
|
+
handled=True,
|
|
1292
|
+
output=f"[green]Proxy enabled:[/green] [cyan]{self._proxy_url}[/cyan]",
|
|
1293
|
+
proxy_action="enable",
|
|
1294
|
+
proxy_url=self._proxy_url,
|
|
1295
|
+
proxy_token=self._proxy_token,
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
if sub == "off":
|
|
1299
|
+
self._proxy_enabled = False
|
|
1300
|
+
return CommandResult(
|
|
1301
|
+
handled=True,
|
|
1302
|
+
output="[yellow]Proxy disabled.[/yellow] Calls go directly to provider.",
|
|
1303
|
+
proxy_action="disable",
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
if sub == "url":
|
|
1307
|
+
if len(args) < 2:
|
|
1308
|
+
return CommandResult(
|
|
1309
|
+
handled=True,
|
|
1310
|
+
output="[red]Usage:[/red] /proxy url <https://your-proxy/v1>",
|
|
1311
|
+
)
|
|
1312
|
+
url = args[1]
|
|
1313
|
+
_parsed = urlparse(url)
|
|
1314
|
+
_loopback = (
|
|
1315
|
+
_parsed.scheme == "http"
|
|
1316
|
+
and _parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
1317
|
+
and not _parsed.username # reject http://localhost@evil.com/
|
|
1318
|
+
)
|
|
1319
|
+
if _parsed.scheme != "https" and not _loopback:
|
|
1320
|
+
return CommandResult(
|
|
1321
|
+
handled=True,
|
|
1322
|
+
output=(
|
|
1323
|
+
"[red]Proxy URL must use HTTPS to protect your token in transit.[/red]\n"
|
|
1324
|
+
"Exception: http://localhost (exact loopback) is allowed for local testing."
|
|
1325
|
+
),
|
|
1326
|
+
)
|
|
1327
|
+
self._proxy_url = url
|
|
1328
|
+
return CommandResult(
|
|
1329
|
+
handled=True,
|
|
1330
|
+
output=f"Proxy URL set to [cyan]{url}[/cyan]. Run [cyan]/proxy on[/cyan] to activate.",
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
if sub == "token":
|
|
1334
|
+
if len(args) < 2:
|
|
1335
|
+
# Prompt for token via hidden input (never stored in history)
|
|
1336
|
+
return CommandResult(
|
|
1337
|
+
handled=True,
|
|
1338
|
+
prompt_secret="Proxy token: ",
|
|
1339
|
+
)
|
|
1340
|
+
token = args[1]
|
|
1341
|
+
self._proxy_token = token
|
|
1342
|
+
try:
|
|
1343
|
+
CredentialStore().set("proxy", token)
|
|
1344
|
+
masked = "****" + token[-4:] if len(token) >= 4 else "****"
|
|
1345
|
+
return CommandResult(
|
|
1346
|
+
handled=True,
|
|
1347
|
+
output=f"Proxy token saved to OS keychain ([cyan]{masked}[/cyan]).",
|
|
1348
|
+
)
|
|
1349
|
+
except Exception as exc:
|
|
1350
|
+
log.warning("Could not save proxy token to keychain: %s", exc)
|
|
1351
|
+
masked = "****" + token[-4:] if len(token) >= 4 else "****"
|
|
1352
|
+
return CommandResult(
|
|
1353
|
+
handled=True,
|
|
1354
|
+
output=(
|
|
1355
|
+
f"Proxy token set for this session ([cyan]{masked}[/cyan]). "
|
|
1356
|
+
"[yellow]Keychain save failed — token will not persist.[/yellow]"
|
|
1357
|
+
),
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
# /proxy status (default)
|
|
1361
|
+
status = "[green]enabled[/green]" if self._proxy_enabled else "[dim]disabled[/dim]"
|
|
1362
|
+
url = self._proxy_url or PROXY_DEFAULT_URL
|
|
1363
|
+
masked = ("****" + self._proxy_token[-4:]) if len(self._proxy_token) >= 4 else ("[dim]not set[/dim]" if not self._proxy_token else "****")
|
|
1364
|
+
lines = [
|
|
1365
|
+
f" Status: {status}",
|
|
1366
|
+
f" URL: [cyan]{url}[/cyan]",
|
|
1367
|
+
f" Token: {masked}",
|
|
1368
|
+
"",
|
|
1369
|
+
"Commands: [cyan]/proxy on[/cyan] [cyan]/proxy off[/cyan] "
|
|
1370
|
+
"[cyan]/proxy url <url>[/cyan] [cyan]/proxy token[/cyan] (prompts for token)",
|
|
1371
|
+
]
|
|
1372
|
+
return CommandResult(handled=True, output="\n".join(lines))
|
|
1373
|
+
|
|
1374
|
+
def apply_proxy_token(self, token: str) -> CommandResult:
|
|
1375
|
+
"""Store proxy token received via hidden prompt (never written to history)."""
|
|
1376
|
+
token = token.strip()
|
|
1377
|
+
if not token:
|
|
1378
|
+
return CommandResult(handled=True, output="[yellow]No token entered.[/yellow]")
|
|
1379
|
+
self._proxy_token = token
|
|
1380
|
+
try:
|
|
1381
|
+
CredentialStore().set("proxy", token)
|
|
1382
|
+
masked = "****" + token[-4:] if len(token) >= 4 else "****"
|
|
1383
|
+
return CommandResult(
|
|
1384
|
+
handled=True,
|
|
1385
|
+
output=f"Proxy token saved to OS keychain ([cyan]{masked}[/cyan]).",
|
|
1386
|
+
proxy_token=token,
|
|
1387
|
+
)
|
|
1388
|
+
except Exception as exc:
|
|
1389
|
+
log.warning("Could not save proxy token to keychain: %s", exc)
|
|
1390
|
+
masked = "****" + token[-4:] if len(token) >= 4 else "****"
|
|
1391
|
+
return CommandResult(
|
|
1392
|
+
handled=True,
|
|
1393
|
+
output=(
|
|
1394
|
+
f"Proxy token set for this session ([cyan]{masked}[/cyan]). "
|
|
1395
|
+
"[yellow]Keychain save failed — token will not persist.[/yellow]"
|
|
1396
|
+
),
|
|
1397
|
+
proxy_token=token,
|
|
1398
|
+
)
|