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/remote/tunnel.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""TunnelManager — multi-provider tunnel support with crash recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_DEFAULT_BACKOFF = [5.0, 15.0, 30.0]
|
|
14
|
+
|
|
15
|
+
_PROVIDERS: list[dict] = [
|
|
16
|
+
{
|
|
17
|
+
"name": "cloudflared",
|
|
18
|
+
"cmd": lambda port: ["cloudflared", "tunnel", "--url", f"http://localhost:{port}"],
|
|
19
|
+
"url_marker": "trycloudflare.com",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "ngrok",
|
|
23
|
+
"cmd": lambda port: ["ngrok", "http", str(port), "--log=stdout", "--log-format=json"],
|
|
24
|
+
"url_marker": "ngrok",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "localtunnel",
|
|
28
|
+
"cmd": lambda port: ["lt", "--port", str(port)],
|
|
29
|
+
"url_marker": "loca.lt",
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _lan_ip() -> str:
|
|
35
|
+
"""Return best-guess LAN IP without sending actual traffic."""
|
|
36
|
+
try:
|
|
37
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
38
|
+
s.connect(("8.8.8.8", 80))
|
|
39
|
+
ip = s.getsockname()[0]
|
|
40
|
+
s.close()
|
|
41
|
+
return ip
|
|
42
|
+
except OSError:
|
|
43
|
+
return "127.0.0.1"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TunnelError(RuntimeError):
|
|
47
|
+
"""Raised when no tunnel provider is available."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TunnelManager:
|
|
51
|
+
"""Start/stop/monitor a public tunnel to *port*.
|
|
52
|
+
|
|
53
|
+
Provider priority: cloudflared → ngrok → localtunnel → LAN.
|
|
54
|
+
Crash recovery is implemented by a background monitor thread that
|
|
55
|
+
restarts the tunnel with exponential back-off on unexpected exit.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
port:
|
|
60
|
+
Local port to expose.
|
|
61
|
+
providers:
|
|
62
|
+
Override the provider list (for testing).
|
|
63
|
+
backoff_delays:
|
|
64
|
+
Seconds to wait between retry attempts (injectable for tests).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
port: int = 8765,
|
|
70
|
+
providers: list[dict] | None = None,
|
|
71
|
+
backoff_delays: list[float] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._port = port
|
|
74
|
+
self._providers = providers if providers is not None else list(_PROVIDERS)
|
|
75
|
+
self._backoff_delays: list[float] = (
|
|
76
|
+
backoff_delays if backoff_delays is not None else _DEFAULT_BACKOFF
|
|
77
|
+
)
|
|
78
|
+
self._url: str | None = None
|
|
79
|
+
self._provider_name: str | None = None
|
|
80
|
+
self._proc: subprocess.Popen | None = None
|
|
81
|
+
self._lock = threading.Lock()
|
|
82
|
+
self._stop_event = threading.Event()
|
|
83
|
+
self._monitor_thread: threading.Thread | None = None
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Public API
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def start(self) -> str:
|
|
90
|
+
"""Start the tunnel and return the public URL."""
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self._url:
|
|
93
|
+
return self._url
|
|
94
|
+
url, name = self._try_start_with_retry()
|
|
95
|
+
self._url = url
|
|
96
|
+
self._provider_name = name
|
|
97
|
+
self._stop_event.clear()
|
|
98
|
+
self._monitor_thread = threading.Thread(
|
|
99
|
+
target=self._monitor_loop, daemon=True, name="gdm-tunnel-monitor"
|
|
100
|
+
)
|
|
101
|
+
self._monitor_thread.start()
|
|
102
|
+
return url
|
|
103
|
+
|
|
104
|
+
def stop(self) -> None:
|
|
105
|
+
"""Stop the tunnel and the monitor thread."""
|
|
106
|
+
self._stop_event.set()
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._kill_proc()
|
|
109
|
+
self._url = None
|
|
110
|
+
self._provider_name = None
|
|
111
|
+
|
|
112
|
+
def status(self) -> dict:
|
|
113
|
+
"""Return current tunnel status dict."""
|
|
114
|
+
with self._lock:
|
|
115
|
+
return {
|
|
116
|
+
"running": self._url is not None,
|
|
117
|
+
"url": self._url,
|
|
118
|
+
"provider": self._provider_name,
|
|
119
|
+
"port": self._port,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Internal helpers
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _lan_fallback(self) -> tuple[str, str]:
|
|
127
|
+
ip = _lan_ip()
|
|
128
|
+
return f"http://{ip}:{self._port}", "lan"
|
|
129
|
+
|
|
130
|
+
def _try_provider(self, provider: dict) -> tuple[str, str] | None:
|
|
131
|
+
"""Try to start *provider*; return (url, name) or None."""
|
|
132
|
+
try:
|
|
133
|
+
cmd = provider["cmd"](self._port)
|
|
134
|
+
proc = subprocess.Popen(
|
|
135
|
+
cmd,
|
|
136
|
+
stdout=subprocess.PIPE,
|
|
137
|
+
stderr=subprocess.STDOUT,
|
|
138
|
+
text=True,
|
|
139
|
+
)
|
|
140
|
+
marker = provider["url_marker"]
|
|
141
|
+
# Read stdout until we see the URL or the process exits
|
|
142
|
+
for _ in range(60):
|
|
143
|
+
if proc.poll() is not None:
|
|
144
|
+
return None
|
|
145
|
+
assert proc.stdout is not None
|
|
146
|
+
line = proc.stdout.readline()
|
|
147
|
+
if not line:
|
|
148
|
+
time.sleep(0.5)
|
|
149
|
+
continue
|
|
150
|
+
if marker in line:
|
|
151
|
+
# Extract the URL from the line
|
|
152
|
+
for token in line.split():
|
|
153
|
+
if marker in token:
|
|
154
|
+
url = token.strip().rstrip(",")
|
|
155
|
+
if not url.startswith("http"):
|
|
156
|
+
url = "https://" + url
|
|
157
|
+
self._proc = proc
|
|
158
|
+
return url, provider["name"]
|
|
159
|
+
proc.kill()
|
|
160
|
+
return None
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
return None
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def _try_start_with_retry(self) -> tuple[str, str]:
|
|
167
|
+
"""Try all providers; retry with back-off; fall back to LAN."""
|
|
168
|
+
delays = list(self._backoff_delays)
|
|
169
|
+
|
|
170
|
+
for delay in delays:
|
|
171
|
+
for provider in self._providers:
|
|
172
|
+
result = self._try_provider(provider)
|
|
173
|
+
if result:
|
|
174
|
+
return result
|
|
175
|
+
time.sleep(delay)
|
|
176
|
+
|
|
177
|
+
# Final attempt before LAN fallback
|
|
178
|
+
for provider in self._providers:
|
|
179
|
+
result = self._try_provider(provider)
|
|
180
|
+
if result:
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
return self._lan_fallback()
|
|
184
|
+
|
|
185
|
+
def _kill_proc(self) -> None:
|
|
186
|
+
if self._proc and self._proc.poll() is None:
|
|
187
|
+
try:
|
|
188
|
+
self._proc.kill()
|
|
189
|
+
self._proc.wait(timeout=5)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
self._proc = None
|
|
193
|
+
|
|
194
|
+
def _monitor_loop(self) -> None:
|
|
195
|
+
"""Watch the subprocess; restart if it dies unexpectedly."""
|
|
196
|
+
while not self._stop_event.is_set():
|
|
197
|
+
time.sleep(1)
|
|
198
|
+
with self._lock:
|
|
199
|
+
if self._stop_event.is_set():
|
|
200
|
+
break
|
|
201
|
+
if self._proc is None:
|
|
202
|
+
continue
|
|
203
|
+
if self._proc.poll() is not None:
|
|
204
|
+
# Process died unexpectedly — restart
|
|
205
|
+
self._proc = None
|
|
206
|
+
try:
|
|
207
|
+
url, name = self._try_start_with_retry()
|
|
208
|
+
self._url = url
|
|
209
|
+
self._provider_name = name
|
|
210
|
+
except Exception:
|
|
211
|
+
self._url = None
|
|
212
|
+
self._provider_name = None
|
src/repl.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""gdm REPL -- interactive coding agent loop.
|
|
2
|
+
|
|
3
|
+
Uses prompt_toolkit for rich input (history, editing) with a plain
|
|
4
|
+
input() fallback when prompt_toolkit is unavailable.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import importlib.util
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import date, datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.status import Status
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from src.config import GdmConfig
|
|
22
|
+
from src.memory.db import GdmDatabase
|
|
23
|
+
|
|
24
|
+
__all__ = ["start_repl"]
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_HISTORY_FILENAME = ".context-memory/gdm_history"
|
|
29
|
+
_PROMPT_HTML = "<ansiCyan>gdm</ansiCyan><ansiwhite> > </ansiwhite>"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Session bootstrap
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _ensure_session(db: "GdmDatabase", project_root: Path) -> str:
|
|
37
|
+
"""Upsert project + session rows so FK constraints are satisfied.
|
|
38
|
+
|
|
39
|
+
Uses a UUID-5 of the project root as a stable project_id, then creates
|
|
40
|
+
a fresh session row. Errors are logged but not re-raised.
|
|
41
|
+
"""
|
|
42
|
+
project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(project_root)))
|
|
43
|
+
session_id = str(uuid.uuid4())
|
|
44
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
45
|
+
try:
|
|
46
|
+
db.execute(
|
|
47
|
+
"INSERT INTO projects (project_id, root_path, name) VALUES (?, ?, ?)"
|
|
48
|
+
' ON CONFLICT(project_id) DO UPDATE SET last_seen = datetime("now")',
|
|
49
|
+
(project_id, str(project_root), project_root.name),
|
|
50
|
+
)
|
|
51
|
+
db.execute(
|
|
52
|
+
"INSERT INTO sessions (session_id, project_id, created_at, updated_at)"
|
|
53
|
+
" VALUES (?, ?, ?, ?)",
|
|
54
|
+
(session_id, project_id, now, now),
|
|
55
|
+
)
|
|
56
|
+
except Exception as exc: # noqa: BLE001
|
|
57
|
+
log.warning("Session DB init failed (%s) -- btw_queue will be unavailable", exc)
|
|
58
|
+
return session_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Pending /btw notes display
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def _show_pending_btw(db: "GdmDatabase", session_id: str, console: Console) -> list[str]:
|
|
66
|
+
"""Print and mark-read pending /btw notes. Returns message strings."""
|
|
67
|
+
try:
|
|
68
|
+
rows = db.execute_all(
|
|
69
|
+
"SELECT id, message FROM btw_queue"
|
|
70
|
+
" WHERE session_id = ? AND read_at IS NULL ORDER BY created_at",
|
|
71
|
+
(session_id,),
|
|
72
|
+
)
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
return []
|
|
75
|
+
if not rows:
|
|
76
|
+
return []
|
|
77
|
+
messages: list[str] = []
|
|
78
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
79
|
+
for row in rows:
|
|
80
|
+
console.print(f"[dim yellow]note:[/dim yellow] {row['message']}")
|
|
81
|
+
messages.append(row["message"])
|
|
82
|
+
try:
|
|
83
|
+
db.execute("UPDATE btw_queue SET read_at = ? WHERE id = ?", (now, row["id"]))
|
|
84
|
+
except Exception: # noqa: BLE001
|
|
85
|
+
pass
|
|
86
|
+
return messages
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Spinner verb selection
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _pick_spinner_verb(db: "GdmDatabase", session_id: str) -> str: # noqa: ARG001
|
|
94
|
+
"""Return a weighted-random spinner verb."""
|
|
95
|
+
import random
|
|
96
|
+
from src._internal.constants import _ALL_SPINNER_VERBS, _PRIORITY_VERBS
|
|
97
|
+
|
|
98
|
+
today = date.today().isoformat()
|
|
99
|
+
seen_today = db.spinner_state_get(today)
|
|
100
|
+
|
|
101
|
+
weights: list[int] = []
|
|
102
|
+
for verb in _ALL_SPINNER_VERBS:
|
|
103
|
+
if verb in _PRIORITY_VERBS:
|
|
104
|
+
weights.append(30 if verb not in seen_today else 15)
|
|
105
|
+
else:
|
|
106
|
+
weights.append(1)
|
|
107
|
+
|
|
108
|
+
chosen = random.choices(list(_ALL_SPINNER_VERBS), weights=weights, k=1)[0]
|
|
109
|
+
if chosen in _PRIORITY_VERBS:
|
|
110
|
+
db.spinner_state_mark_seen(today, chosen)
|
|
111
|
+
return chosen
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Event rendering
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _fmt_args(args: dict) -> str: # type: ignore[type-arg]
|
|
119
|
+
"""Format a tool-args dict for single-line display, truncating long values."""
|
|
120
|
+
parts = []
|
|
121
|
+
for k, v in list(args.items())[:3]:
|
|
122
|
+
sv = str(v)
|
|
123
|
+
sv = sv[:40] + "..." if len(sv) > 40 else sv
|
|
124
|
+
parts.append(f"{k}={sv!r}")
|
|
125
|
+
return ", ".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_event(event: object, status: Status, console: Console) -> None:
|
|
129
|
+
"""Render one AgentEvent to the terminal while the spinner is live."""
|
|
130
|
+
from src.agent.loop import EventType # lazy
|
|
131
|
+
|
|
132
|
+
ev = event # type: ignore[assignment]
|
|
133
|
+
match ev.type: # type: ignore[union-attr]
|
|
134
|
+
case EventType.THINKING:
|
|
135
|
+
snippet = str(ev.content or "")[:100] # type: ignore[union-attr]
|
|
136
|
+
label = f"[dim cyan]Thinking... {snippet}[/dim cyan]"
|
|
137
|
+
status.update(label)
|
|
138
|
+
case EventType.TOOL_CALL:
|
|
139
|
+
console.print(f"[yellow][tool] {ev.tool_name}({_fmt_args(ev.args)})[/yellow]") # type: ignore[union-attr]
|
|
140
|
+
case EventType.TOOL_RESULT:
|
|
141
|
+
result_str = str(ev.result or "")[:80] # type: ignore[union-attr]
|
|
142
|
+
console.print(f"[dim green] -> {result_str}[/dim green]")
|
|
143
|
+
case EventType.RESPONSE:
|
|
144
|
+
status.stop()
|
|
145
|
+
console.print(str(ev.content or "")) # type: ignore[union-attr]
|
|
146
|
+
case EventType.ERROR:
|
|
147
|
+
status.stop()
|
|
148
|
+
console.print(f"[red]Error: {ev.content}[/red]") # type: ignore[union-attr]
|
|
149
|
+
case EventType.COST_UPDATE:
|
|
150
|
+
console.print(f"[dim] [${ev.cost_usd:.5f} | turn {ev.turn}][/dim]") # type: ignore[union-attr]
|
|
151
|
+
case EventType.DONE:
|
|
152
|
+
status.stop()
|
|
153
|
+
console.print("[green]Done[/green]")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Agent turn runner
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _run_agent_turn(
|
|
161
|
+
loop: object,
|
|
162
|
+
user_message: str,
|
|
163
|
+
console: Console,
|
|
164
|
+
db: "GdmDatabase",
|
|
165
|
+
session_id: str,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Run one agent turn, streaming events with a spinner."""
|
|
168
|
+
try:
|
|
169
|
+
from src.agent.loop import AgentLoop # noqa: F401
|
|
170
|
+
except (ImportError, AttributeError):
|
|
171
|
+
console.print("[yellow]Agent loop not yet implemented.[/yellow]")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
verb = _pick_spinner_verb(db, session_id)
|
|
175
|
+
status = Status(f"[cyan]{verb}...[/cyan]", console=console, spinner="dots")
|
|
176
|
+
status.start()
|
|
177
|
+
try:
|
|
178
|
+
for event in loop.run(user_message): # type: ignore[union-attr]
|
|
179
|
+
_render_event(event, status, console)
|
|
180
|
+
except Exception as exc: # noqa: BLE001
|
|
181
|
+
status.stop()
|
|
182
|
+
console.print(f"[red]Agent error: {exc}[/red]")
|
|
183
|
+
log.exception("Agent turn failed")
|
|
184
|
+
finally:
|
|
185
|
+
status.stop()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Input helper
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _build_input_fn(history_path: Path) -> object:
|
|
193
|
+
"""Return a callable() -> str for reading user input.
|
|
194
|
+
|
|
195
|
+
Uses prompt_toolkit when available, falls back to built-in input().
|
|
196
|
+
"""
|
|
197
|
+
if importlib.util.find_spec("prompt_toolkit") is not None:
|
|
198
|
+
from prompt_toolkit import PromptSession
|
|
199
|
+
from prompt_toolkit.formatted_text import HTML
|
|
200
|
+
from prompt_toolkit.history import FileHistory
|
|
201
|
+
import re as _re
|
|
202
|
+
|
|
203
|
+
_SENSITIVE_RE = _re.compile(r"^\s*/proxy\s+token\s+\S", _re.IGNORECASE)
|
|
204
|
+
|
|
205
|
+
class _SafeHistory(FileHistory):
|
|
206
|
+
"""FileHistory that never persists sensitive /proxy token <secret> entries."""
|
|
207
|
+
def append_string(self, string: str) -> None:
|
|
208
|
+
if _SENSITIVE_RE.match(string):
|
|
209
|
+
return # drop silently — token must not reach disk
|
|
210
|
+
super().append_string(string)
|
|
211
|
+
|
|
212
|
+
pt_session: PromptSession[str] = PromptSession(
|
|
213
|
+
history=_SafeHistory(str(history_path)),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _pt_input() -> str:
|
|
217
|
+
return pt_session.prompt(HTML(_PROMPT_HTML))
|
|
218
|
+
|
|
219
|
+
return _pt_input
|
|
220
|
+
|
|
221
|
+
def _builtin_input() -> str:
|
|
222
|
+
return input("gdm > ")
|
|
223
|
+
|
|
224
|
+
return _builtin_input
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Public entry point
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def start_repl(cfg: "GdmConfig", db: "GdmDatabase", *, yes: bool = False, model_override: str | None = None) -> None:
|
|
232
|
+
"""Start the interactive gdm REPL.
|
|
233
|
+
|
|
234
|
+
Bootstraps session, builds the agent loop (or falls back gracefully),
|
|
235
|
+
then enters the prompt/dispatch loop until the user exits.
|
|
236
|
+
"""
|
|
237
|
+
from src.commands import CommandDispatcher
|
|
238
|
+
from src.cost_tracker import CostTracker
|
|
239
|
+
|
|
240
|
+
console = Console()
|
|
241
|
+
project_root = cfg.project_root.resolve()
|
|
242
|
+
|
|
243
|
+
session_id = _ensure_session(db, project_root)
|
|
244
|
+
|
|
245
|
+
history_path = project_root / _HISTORY_FILENAME
|
|
246
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
read_input = _build_input_fn(history_path)
|
|
248
|
+
|
|
249
|
+
cost_tracker = CostTracker(session_id=session_id, provider=cfg.provider)
|
|
250
|
+
dispatcher = CommandDispatcher(
|
|
251
|
+
session_id,
|
|
252
|
+
db,
|
|
253
|
+
cost_tracker,
|
|
254
|
+
current_tier="coder",
|
|
255
|
+
provider=cfg.provider,
|
|
256
|
+
console=console,
|
|
257
|
+
project_root=project_root,
|
|
258
|
+
confirm_fn=read_input,
|
|
259
|
+
cfg=cfg,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
loop: object = None
|
|
263
|
+
permissions: object = None
|
|
264
|
+
try:
|
|
265
|
+
from src.agent.context_budget import ContextBudget
|
|
266
|
+
from src.agent.loop import AgentLoop
|
|
267
|
+
from src.agent.tool_orchestrator import ToolOrchestrator
|
|
268
|
+
from src.agent.transcript import TranscriptStore
|
|
269
|
+
from src.models.definitions import ModelTier, get_model
|
|
270
|
+
from src.models.router import ModelRouter
|
|
271
|
+
from src.permissions import PermissionContext
|
|
272
|
+
|
|
273
|
+
tier = model_override or ModelTier.CODER
|
|
274
|
+
model_def = get_model(tier, cfg.provider)
|
|
275
|
+
_project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(project_root)))
|
|
276
|
+
transcript = TranscriptStore(max_tokens=model_def.context_window)
|
|
277
|
+
budget = ContextBudget(model_context_window=model_def.context_window)
|
|
278
|
+
permissions = PermissionContext(db=db, session_id=session_id, non_interactive=False)
|
|
279
|
+
if yes:
|
|
280
|
+
permissions.allow_all_session()
|
|
281
|
+
router = ModelRouter()
|
|
282
|
+
orchestrator = ToolOrchestrator(
|
|
283
|
+
permissions=permissions, db=db, session_id=session_id, project_id=_project_id
|
|
284
|
+
)
|
|
285
|
+
loop = AgentLoop(
|
|
286
|
+
cfg=cfg,
|
|
287
|
+
orchestrator=orchestrator,
|
|
288
|
+
transcript=transcript,
|
|
289
|
+
budget=budget,
|
|
290
|
+
cost_tracker=cost_tracker,
|
|
291
|
+
model_tier=tier,
|
|
292
|
+
router=router,
|
|
293
|
+
db=db,
|
|
294
|
+
session_id=session_id,
|
|
295
|
+
project_id=_project_id,
|
|
296
|
+
)
|
|
297
|
+
except (ImportError, AttributeError, TypeError) as exc:
|
|
298
|
+
log.info("AgentLoop unavailable: %s", exc)
|
|
299
|
+
|
|
300
|
+
# Graceful shutdown: threading.Event flag pattern — no I/O in signal handlers.
|
|
301
|
+
import atexit
|
|
302
|
+
import signal as _sig
|
|
303
|
+
import threading
|
|
304
|
+
|
|
305
|
+
_shutdown_event: threading.Event = threading.Event()
|
|
306
|
+
_flushed: threading.Event = threading.Event()
|
|
307
|
+
|
|
308
|
+
def _flush() -> None:
|
|
309
|
+
if _flushed.is_set():
|
|
310
|
+
return
|
|
311
|
+
_flushed.set()
|
|
312
|
+
if loop is not None:
|
|
313
|
+
try:
|
|
314
|
+
loop._flush_checkpoint_sync() # type: ignore[union-attr]
|
|
315
|
+
except Exception: # noqa: BLE001
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
def _handle_sigterm(*_: object) -> None:
|
|
319
|
+
"""SIGTERM handler — sets shutdown flag only. No I/O here."""
|
|
320
|
+
_shutdown_event.set()
|
|
321
|
+
|
|
322
|
+
def _handle_sigint(sig: int, frame: object) -> None:
|
|
323
|
+
"""SIGINT handler — first Ctrl+C requests shutdown; second forces exit."""
|
|
324
|
+
if not _shutdown_event.is_set():
|
|
325
|
+
_shutdown_event.set()
|
|
326
|
+
else:
|
|
327
|
+
_flush()
|
|
328
|
+
sys.exit(130)
|
|
329
|
+
|
|
330
|
+
atexit.register(_flush)
|
|
331
|
+
try:
|
|
332
|
+
_sig.signal(_sig.SIGTERM, _handle_sigterm)
|
|
333
|
+
_sig.signal(_sig.SIGINT, _handle_sigint)
|
|
334
|
+
if sys.platform == "win32":
|
|
335
|
+
_sig.signal(_sig.SIGBREAK, _handle_sigterm) # type: ignore[attr-defined]
|
|
336
|
+
except (OSError, ValueError):
|
|
337
|
+
pass # Not in main thread or platform limitation
|
|
338
|
+
|
|
339
|
+
# Startup: check for resumable active sessions from previous runs
|
|
340
|
+
if loop is not None:
|
|
341
|
+
try:
|
|
342
|
+
incomplete = db.list_incomplete_sessions()
|
|
343
|
+
# Exclude the session we just created (it has no turns yet)
|
|
344
|
+
incomplete = [s for s in incomplete if s["session_id"] != session_id]
|
|
345
|
+
if incomplete:
|
|
346
|
+
sess = incomplete[0]
|
|
347
|
+
sid_short = sess["session_id"][:8]
|
|
348
|
+
turns = sess["turn_count"] or 0
|
|
349
|
+
started = sess["updated_at"] or sess["created_at"] or ""
|
|
350
|
+
console.print(
|
|
351
|
+
f"[yellow]⚡ Session {sid_short}... was in progress "
|
|
352
|
+
f"({turns} turn(s), last active: {started[:16].replace('T', ' ')})."
|
|
353
|
+
" Resume? [Y/n][/yellow]"
|
|
354
|
+
)
|
|
355
|
+
try:
|
|
356
|
+
ans = input("").strip().lower()
|
|
357
|
+
if not ans or ans.startswith("y"):
|
|
358
|
+
n = loop.restore_from_db(sess["session_id"]) # type: ignore[union-attr]
|
|
359
|
+
if n:
|
|
360
|
+
console.print(f"[green]✓ Restored {n} turns from previous session.[/green]")
|
|
361
|
+
else:
|
|
362
|
+
console.print("[dim]No checkpoint data found; starting fresh.[/dim]")
|
|
363
|
+
except (EOFError, KeyboardInterrupt):
|
|
364
|
+
pass
|
|
365
|
+
except Exception: # noqa: BLE001
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
# Startup hints from ContinuousMemory (non-blocking, max 3 seconds)
|
|
369
|
+
if not getattr(cfg, "gdm_quiet", False):
|
|
370
|
+
try:
|
|
371
|
+
from src.memory.continuous_memory import ContinuousMemory as _CM
|
|
372
|
+
_cm_path = str(project_root / ".gdm" / "memory.db")
|
|
373
|
+
_cm = _CM(db_path=_cm_path)
|
|
374
|
+
_cm_project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(project_root)))
|
|
375
|
+
_hints = _cm.get_session_hints([], _cm_project_id)
|
|
376
|
+
if _hints:
|
|
377
|
+
console.print("[dim]hints (press Enter to skip):[/dim]")
|
|
378
|
+
for _h in _hints:
|
|
379
|
+
console.print(f"[dim] * {_h}[/dim]")
|
|
380
|
+
_skip_event: threading.Event = threading.Event()
|
|
381
|
+
|
|
382
|
+
def _wait_skip() -> None:
|
|
383
|
+
try:
|
|
384
|
+
input()
|
|
385
|
+
_skip_event.set()
|
|
386
|
+
except Exception: # noqa: BLE001
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
threading.Thread(target=_wait_skip, daemon=True).start()
|
|
390
|
+
_skip_event.wait(3.0)
|
|
391
|
+
except Exception: # noqa: BLE001
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
console.print("[bold green]gdm[/bold green] [dim]ready -- type /help for commands, /exit to quit[/dim]")
|
|
395
|
+
|
|
396
|
+
while True:
|
|
397
|
+
try:
|
|
398
|
+
raw = read_input() # type: ignore[call-arg]
|
|
399
|
+
except (EOFError, KeyboardInterrupt):
|
|
400
|
+
console.print("\n[dim]Bye.[/dim]")
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
text = raw.strip()
|
|
404
|
+
if not text:
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
if dispatcher.is_command(text):
|
|
408
|
+
result = dispatcher.handle(text)
|
|
409
|
+
if result.output:
|
|
410
|
+
console.print(result.output)
|
|
411
|
+
if result.prompt_secret:
|
|
412
|
+
# Token must be collected via hidden input — never stored in history
|
|
413
|
+
try:
|
|
414
|
+
if importlib.util.find_spec("prompt_toolkit") is not None:
|
|
415
|
+
from prompt_toolkit.shortcuts import prompt as _pt_prompt
|
|
416
|
+
_secret = _pt_prompt(result.prompt_secret, is_password=True)
|
|
417
|
+
else:
|
|
418
|
+
import getpass as _gp
|
|
419
|
+
_secret = _gp.getpass(result.prompt_secret)
|
|
420
|
+
except (EOFError, KeyboardInterrupt):
|
|
421
|
+
_secret = ""
|
|
422
|
+
sub = dispatcher.apply_proxy_token(_secret)
|
|
423
|
+
if sub.output:
|
|
424
|
+
console.print(sub.output)
|
|
425
|
+
if result.should_exit:
|
|
426
|
+
console.print("[dim]Bye.[/dim]")
|
|
427
|
+
break
|
|
428
|
+
if result.allow_all and permissions is not None:
|
|
429
|
+
try:
|
|
430
|
+
permissions.allow_all_session() # type: ignore[union-attr]
|
|
431
|
+
except AttributeError:
|
|
432
|
+
pass
|
|
433
|
+
if result.force_compact and loop is not None:
|
|
434
|
+
try:
|
|
435
|
+
n = loop._maybe_compress() # type: ignore[union-attr]
|
|
436
|
+
console.print(f"[dim]Compacted {n} turn(s).[/dim]")
|
|
437
|
+
except AttributeError:
|
|
438
|
+
pass
|
|
439
|
+
if result.new_model_tier and loop is not None:
|
|
440
|
+
try:
|
|
441
|
+
loop.set_tier(result.new_model_tier) # type: ignore[union-attr]
|
|
442
|
+
except AttributeError:
|
|
443
|
+
pass
|
|
444
|
+
if result.proxy_action == "enable" and loop is not None and result.proxy_url and result.proxy_token:
|
|
445
|
+
try:
|
|
446
|
+
loop.set_proxy(result.proxy_url, result.proxy_token) # type: ignore[union-attr]
|
|
447
|
+
except AttributeError:
|
|
448
|
+
pass
|
|
449
|
+
elif result.proxy_action == "disable" and loop is not None:
|
|
450
|
+
try:
|
|
451
|
+
loop.clear_proxy() # type: ignore[union-attr]
|
|
452
|
+
except AttributeError:
|
|
453
|
+
pass
|
|
454
|
+
if result.force_resume and loop is not None:
|
|
455
|
+
try:
|
|
456
|
+
restore_id = result.resume_session_id # may be None
|
|
457
|
+
n = loop.restore_from_db(restore_id) # type: ignore[union-attr]
|
|
458
|
+
if n:
|
|
459
|
+
console.print(f"[green]✓ Restored {n} turns from checkpoint.[/green]")
|
|
460
|
+
else:
|
|
461
|
+
console.print("[yellow]No checkpoint found for this session.[/yellow]")
|
|
462
|
+
except AttributeError:
|
|
463
|
+
console.print("[yellow]Restore not supported by current agent.[/yellow]")
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
pending = _show_pending_btw(db, session_id, console)
|
|
467
|
+
full_message = text
|
|
468
|
+
if pending:
|
|
469
|
+
notes = "\n".join(f"[context note: {m}]" for m in pending)
|
|
470
|
+
full_message = f"{notes}\n{text}"
|
|
471
|
+
|
|
472
|
+
if loop is None:
|
|
473
|
+
console.print("[yellow]Agent loop not yet implemented.[/yellow]")
|
|
474
|
+
else:
|
|
475
|
+
_run_agent_turn(loop, full_message, console, db, session_id)
|
src/runtime/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gdm runtime — branch farming, replay engine, and parallel evaluation."""
|