@gaia-minds/assistant-cli 0.1.0 → 0.2.0
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.
- package/CONSTITUTION.md +208 -0
- package/README.md +85 -158
- package/assistant/README.md +126 -12
- package/package.json +6 -1
- package/tools/agent-actions.py +1213 -0
- package/tools/agent-alignment.py +888 -0
- package/tools/agent-config.yml +20 -5
- package/tools/agent-loop.py +502 -62
- package/tools/agent_actions.py +42 -0
- package/tools/agent_alignment.py +41 -0
- package/tools/gaia-assistant.py +2375 -34
package/tools/gaia-assistant.py
CHANGED
|
@@ -9,11 +9,18 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
11
|
import base64
|
|
12
|
+
import getpass
|
|
13
|
+
import html
|
|
12
14
|
import json
|
|
13
15
|
import os
|
|
16
|
+
import re
|
|
14
17
|
import shutil
|
|
15
18
|
import subprocess
|
|
16
19
|
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
import uuid
|
|
17
24
|
from datetime import datetime, timezone
|
|
18
25
|
from pathlib import Path
|
|
19
26
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -23,7 +30,9 @@ SCRIPT_DIR = Path(__file__).resolve().parent
|
|
|
23
30
|
REPO_ROOT = SCRIPT_DIR.parent
|
|
24
31
|
DEFAULT_HOME = Path(os.environ.get("GAIA_ASSISTANT_HOME", str(Path.home() / ".gaia-assistant"))).expanduser()
|
|
25
32
|
DEFAULT_STATE_DIR = DEFAULT_HOME / "state"
|
|
33
|
+
DEFAULT_DATA_DIR = DEFAULT_HOME / "data"
|
|
26
34
|
DEFAULT_CONFIG_PATH = DEFAULT_HOME / "config.json"
|
|
35
|
+
DEFAULT_SECRET_STORE = DEFAULT_HOME / "secrets.json"
|
|
27
36
|
AGENT_CONFIG_PATH = SCRIPT_DIR / "agent-config.yml"
|
|
28
37
|
AGENT_LOOP_PATH = SCRIPT_DIR / "agent-loop.py"
|
|
29
38
|
DEFAULT_LAUNCHER_HINT = "python3 tools/gaia-assistant.py"
|
|
@@ -34,6 +43,30 @@ DEFAULT_CODEX_AUTH_PATH = Path(
|
|
|
34
43
|
DEFAULT_OPENCLAW_STATE_DIR = Path(
|
|
35
44
|
os.environ.get("OPENCLAW_STATE_DIR", str(Path.home() / ".openclaw"))
|
|
36
45
|
).expanduser()
|
|
46
|
+
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929"
|
|
47
|
+
DEFAULT_OPENAI_MODEL = "gpt-4.1-mini"
|
|
48
|
+
DEFAULT_OPENROUTER_MODEL = "openrouter/auto"
|
|
49
|
+
ONBOARD_PROVIDER_CHOICES = ("openrouter", "openai", "anthropic", "openai-codex")
|
|
50
|
+
PROFILE_VERBOSITY_CHOICES = ("concise", "balanced", "detailed")
|
|
51
|
+
PROFILE_PROVIDER_CHOICES = ("openrouter", "openai", "anthropic", "openai-codex")
|
|
52
|
+
PERMISSION_LEVEL_CHOICES = ("safe", "confirm", "forbidden")
|
|
53
|
+
DEFAULT_SESSION_CONTEXT_TURNS = 20
|
|
54
|
+
DEFAULT_CAPABILITY_LEVELS = {
|
|
55
|
+
"file_read": "safe",
|
|
56
|
+
"file_write": "safe",
|
|
57
|
+
"network_request": "safe",
|
|
58
|
+
"shell_exec": "confirm",
|
|
59
|
+
"delete_files": "confirm",
|
|
60
|
+
"send_email": "forbidden",
|
|
61
|
+
"external_messaging": "forbidden",
|
|
62
|
+
}
|
|
63
|
+
PROFILE_KEY_MAP = {
|
|
64
|
+
"name": ("profile", "name"),
|
|
65
|
+
"timezone": ("profile", "timezone"),
|
|
66
|
+
"verbosity": ("profile", "verbosity"),
|
|
67
|
+
"provider": ("profile", "default_provider"),
|
|
68
|
+
"default_provider": ("profile", "default_provider"),
|
|
69
|
+
}
|
|
37
70
|
|
|
38
71
|
|
|
39
72
|
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
@@ -41,21 +74,55 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|
|
41
74
|
"mode": "continuous",
|
|
42
75
|
"interval_minutes": 60,
|
|
43
76
|
},
|
|
77
|
+
"reasoning": {
|
|
78
|
+
"provider": "anthropic",
|
|
79
|
+
"model": DEFAULT_ANTHROPIC_MODEL,
|
|
80
|
+
},
|
|
81
|
+
"secrets": {
|
|
82
|
+
"store_path": str(DEFAULT_SECRET_STORE),
|
|
83
|
+
},
|
|
44
84
|
"auth": {
|
|
45
85
|
"providers": {
|
|
46
86
|
"anthropic": {
|
|
47
|
-
"subscription_oauth_supported":
|
|
87
|
+
"subscription_oauth_supported": False,
|
|
48
88
|
"api_key_env": "ANTHROPIC_API_KEY",
|
|
49
89
|
},
|
|
50
90
|
"openai": {
|
|
51
91
|
"subscription_oauth_supported": True,
|
|
52
92
|
"api_key_env": "OPENAI_API_KEY",
|
|
53
93
|
},
|
|
94
|
+
"openai-codex": {
|
|
95
|
+
"subscription_oauth_supported": True,
|
|
96
|
+
"api_key_env": "",
|
|
97
|
+
},
|
|
98
|
+
"openrouter": {
|
|
99
|
+
"subscription_oauth_supported": False,
|
|
100
|
+
"api_key_env": "OPENROUTER_API_KEY",
|
|
101
|
+
},
|
|
54
102
|
}
|
|
55
103
|
},
|
|
56
104
|
"tracks": {
|
|
57
105
|
"default": "auto",
|
|
58
106
|
},
|
|
107
|
+
"profile": {
|
|
108
|
+
"name": "",
|
|
109
|
+
"timezone": "UTC",
|
|
110
|
+
"verbosity": "balanced",
|
|
111
|
+
"default_provider": "anthropic",
|
|
112
|
+
},
|
|
113
|
+
"capabilities": {
|
|
114
|
+
"overrides": {},
|
|
115
|
+
},
|
|
116
|
+
"traces": {
|
|
117
|
+
"dir": str(DEFAULT_HOME / "traces"),
|
|
118
|
+
},
|
|
119
|
+
"sessions": {
|
|
120
|
+
"dir": str(DEFAULT_HOME / "sessions"),
|
|
121
|
+
"max_context_turns": DEFAULT_SESSION_CONTEXT_TURNS,
|
|
122
|
+
},
|
|
123
|
+
"storage": {
|
|
124
|
+
"dir": str(DEFAULT_DATA_DIR),
|
|
125
|
+
},
|
|
59
126
|
}
|
|
60
127
|
|
|
61
128
|
|
|
@@ -94,6 +161,13 @@ def _normalize_config(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
94
161
|
runtime.setdefault("mode", "continuous")
|
|
95
162
|
runtime.setdefault("interval_minutes", 60)
|
|
96
163
|
|
|
164
|
+
reasoning = cfg.setdefault("reasoning", {})
|
|
165
|
+
reasoning.setdefault("provider", "anthropic")
|
|
166
|
+
reasoning.setdefault("model", DEFAULT_ANTHROPIC_MODEL)
|
|
167
|
+
|
|
168
|
+
secrets = cfg.setdefault("secrets", {})
|
|
169
|
+
secrets.setdefault("store_path", str(DEFAULT_SECRET_STORE))
|
|
170
|
+
|
|
97
171
|
auth = cfg.setdefault("auth", {})
|
|
98
172
|
providers = auth.setdefault("providers", {})
|
|
99
173
|
auth.setdefault("store_path", str(DEFAULT_GAIA_AUTH_STORE))
|
|
@@ -107,9 +181,724 @@ def _normalize_config(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
107
181
|
|
|
108
182
|
tracks = cfg.setdefault("tracks", {})
|
|
109
183
|
tracks.setdefault("default", "auto")
|
|
184
|
+
|
|
185
|
+
profile = cfg.setdefault("profile", {})
|
|
186
|
+
profile.setdefault("name", "")
|
|
187
|
+
profile.setdefault("timezone", "UTC")
|
|
188
|
+
profile.setdefault("verbosity", "balanced")
|
|
189
|
+
profile.setdefault("default_provider", "anthropic")
|
|
190
|
+
|
|
191
|
+
verbosity = str(profile.get("verbosity", "")).strip().lower()
|
|
192
|
+
if verbosity not in PROFILE_VERBOSITY_CHOICES:
|
|
193
|
+
profile["verbosity"] = "balanced"
|
|
194
|
+
|
|
195
|
+
default_provider = str(profile.get("default_provider", "")).strip().lower()
|
|
196
|
+
if default_provider not in PROFILE_PROVIDER_CHOICES:
|
|
197
|
+
profile["default_provider"] = "anthropic"
|
|
198
|
+
|
|
199
|
+
capabilities = cfg.setdefault("capabilities", {})
|
|
200
|
+
overrides = capabilities.setdefault("overrides", {})
|
|
201
|
+
if not isinstance(overrides, dict):
|
|
202
|
+
capabilities["overrides"] = {}
|
|
203
|
+
|
|
204
|
+
traces = cfg.setdefault("traces", {})
|
|
205
|
+
traces_dir = str(traces.get("dir", "")).strip()
|
|
206
|
+
if not traces_dir:
|
|
207
|
+
traces["dir"] = str(DEFAULT_HOME / "traces")
|
|
208
|
+
|
|
209
|
+
sessions = cfg.setdefault("sessions", {})
|
|
210
|
+
sessions_dir = str(sessions.get("dir", "")).strip()
|
|
211
|
+
if not sessions_dir:
|
|
212
|
+
sessions["dir"] = str(DEFAULT_HOME / "sessions")
|
|
213
|
+
max_context = sessions.get("max_context_turns", DEFAULT_SESSION_CONTEXT_TURNS)
|
|
214
|
+
if not isinstance(max_context, int) or max_context < 2:
|
|
215
|
+
sessions["max_context_turns"] = DEFAULT_SESSION_CONTEXT_TURNS
|
|
216
|
+
|
|
217
|
+
storage = cfg.setdefault("storage", {})
|
|
218
|
+
storage_dir = str(storage.get("dir", "")).strip()
|
|
219
|
+
if not storage_dir:
|
|
220
|
+
storage["dir"] = str(DEFAULT_DATA_DIR)
|
|
221
|
+
|
|
110
222
|
return cfg
|
|
111
223
|
|
|
112
224
|
|
|
225
|
+
def _config_path_for_key(key: str) -> Optional[Tuple[str, str]]:
|
|
226
|
+
return PROFILE_KEY_MAP.get(key.strip().lower())
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _get_nested_value(cfg: Dict[str, Any], path: Tuple[str, str]) -> Any:
|
|
230
|
+
section, field = path
|
|
231
|
+
section_value = cfg.get(section, {})
|
|
232
|
+
if not isinstance(section_value, dict):
|
|
233
|
+
return None
|
|
234
|
+
return section_value.get(field)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _set_nested_value(cfg: Dict[str, Any], path: Tuple[str, str], value: Any) -> None:
|
|
238
|
+
section, field = path
|
|
239
|
+
section_value = cfg.setdefault(section, {})
|
|
240
|
+
if not isinstance(section_value, dict):
|
|
241
|
+
section_value = {}
|
|
242
|
+
cfg[section] = section_value
|
|
243
|
+
section_value[field] = value
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _normalize_profile_value(key: str, value: str) -> Tuple[Optional[str], Optional[str]]:
|
|
247
|
+
canonical_key = key.strip().lower()
|
|
248
|
+
raw = value.strip()
|
|
249
|
+
if canonical_key == "verbosity":
|
|
250
|
+
normalized = raw.lower()
|
|
251
|
+
if normalized not in PROFILE_VERBOSITY_CHOICES:
|
|
252
|
+
return None, (
|
|
253
|
+
f"Invalid verbosity '{value}'. "
|
|
254
|
+
f"Expected one of: {', '.join(PROFILE_VERBOSITY_CHOICES)}."
|
|
255
|
+
)
|
|
256
|
+
return normalized, None
|
|
257
|
+
if canonical_key in ("provider", "default_provider"):
|
|
258
|
+
normalized = raw.lower()
|
|
259
|
+
if normalized not in PROFILE_PROVIDER_CHOICES:
|
|
260
|
+
return None, (
|
|
261
|
+
f"Invalid provider '{value}'. "
|
|
262
|
+
f"Expected one of: {', '.join(PROFILE_PROVIDER_CHOICES)}."
|
|
263
|
+
)
|
|
264
|
+
return normalized, None
|
|
265
|
+
if canonical_key == "timezone":
|
|
266
|
+
if not raw:
|
|
267
|
+
return None, "timezone cannot be empty."
|
|
268
|
+
return raw, None
|
|
269
|
+
if canonical_key == "name":
|
|
270
|
+
return raw, None
|
|
271
|
+
return None, f"Unsupported key: {key}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _resolve_trace_dir(cfg: Dict[str, Any], override_path: Optional[str] = None) -> Path:
|
|
275
|
+
if override_path:
|
|
276
|
+
return Path(override_path).expanduser()
|
|
277
|
+
traces = cfg.get("traces", {})
|
|
278
|
+
if isinstance(traces, dict):
|
|
279
|
+
configured = str(traces.get("dir", "")).strip()
|
|
280
|
+
if configured:
|
|
281
|
+
return Path(configured).expanduser()
|
|
282
|
+
return DEFAULT_HOME / "traces"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _append_jsonl(path: Path, payload: Dict[str, Any]) -> None:
|
|
286
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
288
|
+
handle.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _summarize_text(value: Any, max_chars: int = 180) -> str:
|
|
292
|
+
text = str(value).replace("\n", " ").strip()
|
|
293
|
+
if len(text) <= max_chars:
|
|
294
|
+
return text
|
|
295
|
+
return text[: max_chars - 3] + "..."
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _trace_action_path(trace_dir: Path) -> Path:
|
|
299
|
+
return trace_dir / "actions.jsonl"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _write_action_trace(
|
|
303
|
+
trace_dir: Path,
|
|
304
|
+
action_type: str,
|
|
305
|
+
input_summary: str,
|
|
306
|
+
output_summary: str,
|
|
307
|
+
duration_ms: float,
|
|
308
|
+
permission_level: str,
|
|
309
|
+
status: str = "ok",
|
|
310
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
311
|
+
) -> Dict[str, Any]:
|
|
312
|
+
record: Dict[str, Any] = {
|
|
313
|
+
"id": str(uuid.uuid4()),
|
|
314
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
315
|
+
"action_type": action_type,
|
|
316
|
+
"input_summary": _summarize_text(input_summary),
|
|
317
|
+
"output_summary": _summarize_text(output_summary),
|
|
318
|
+
"duration_ms": round(max(duration_ms, 0.0), 3),
|
|
319
|
+
"permission_level": permission_level,
|
|
320
|
+
"status": status,
|
|
321
|
+
"schema_version": 1,
|
|
322
|
+
}
|
|
323
|
+
if metadata:
|
|
324
|
+
record["metadata"] = metadata
|
|
325
|
+
_append_jsonl(_trace_action_path(trace_dir), record)
|
|
326
|
+
return record
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _read_action_traces(trace_dir: Path) -> List[Dict[str, Any]]:
|
|
330
|
+
path = _trace_action_path(trace_dir)
|
|
331
|
+
if not path.exists():
|
|
332
|
+
return []
|
|
333
|
+
traces: List[Dict[str, Any]] = []
|
|
334
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
335
|
+
line = raw.strip()
|
|
336
|
+
if not line:
|
|
337
|
+
continue
|
|
338
|
+
try:
|
|
339
|
+
payload = json.loads(line)
|
|
340
|
+
except json.JSONDecodeError:
|
|
341
|
+
continue
|
|
342
|
+
if isinstance(payload, dict):
|
|
343
|
+
traces.append(payload)
|
|
344
|
+
return traces
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _capability_registry(cfg: Dict[str, Any]) -> Dict[str, str]:
|
|
348
|
+
registry = dict(DEFAULT_CAPABILITY_LEVELS)
|
|
349
|
+
capabilities = cfg.get("capabilities", {})
|
|
350
|
+
overrides = {}
|
|
351
|
+
if isinstance(capabilities, dict):
|
|
352
|
+
raw_overrides = capabilities.get("overrides", {})
|
|
353
|
+
if isinstance(raw_overrides, dict):
|
|
354
|
+
overrides = raw_overrides
|
|
355
|
+
for capability, level in overrides.items():
|
|
356
|
+
normalized_level = str(level).strip().lower()
|
|
357
|
+
if normalized_level in PERMISSION_LEVEL_CHOICES:
|
|
358
|
+
registry[str(capability).strip()] = normalized_level
|
|
359
|
+
return registry
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _permission_for_capability(cfg: Dict[str, Any], capability: str) -> str:
|
|
363
|
+
registry = _capability_registry(cfg)
|
|
364
|
+
return registry.get(capability, "confirm")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _check_capability_permission(
|
|
368
|
+
cfg: Dict[str, Any],
|
|
369
|
+
capability: str,
|
|
370
|
+
user_prompt: Optional[str],
|
|
371
|
+
non_interactive: bool,
|
|
372
|
+
) -> Tuple[bool, str, str]:
|
|
373
|
+
level = _permission_for_capability(cfg, capability)
|
|
374
|
+
if level == "safe":
|
|
375
|
+
return True, level, "allowed"
|
|
376
|
+
if level == "forbidden":
|
|
377
|
+
return False, level, f"blocked by policy for capability '{capability}'"
|
|
378
|
+
|
|
379
|
+
prompt = user_prompt or f"Capability '{capability}' requires confirmation. Continue?"
|
|
380
|
+
allowed = _prompt_yes_no(prompt, default=False, non_interactive=non_interactive)
|
|
381
|
+
if allowed:
|
|
382
|
+
return True, level, "allowed after confirmation"
|
|
383
|
+
return False, level, "user denied confirmation"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class ProviderError(RuntimeError):
|
|
387
|
+
"""Raised when the configured reasoning provider call fails."""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class ProviderTokenLimitError(ProviderError):
|
|
391
|
+
"""Raised when provider rejects a request due to context/token limits."""
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _resolve_session_dir(cfg: Dict[str, Any], override_path: Optional[str] = None) -> Path:
|
|
395
|
+
if override_path:
|
|
396
|
+
return Path(override_path).expanduser()
|
|
397
|
+
sessions = cfg.get("sessions", {})
|
|
398
|
+
if isinstance(sessions, dict):
|
|
399
|
+
configured = str(sessions.get("dir", "")).strip()
|
|
400
|
+
if configured:
|
|
401
|
+
return Path(configured).expanduser()
|
|
402
|
+
return DEFAULT_HOME / "sessions"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _resolve_storage_dir(cfg: Dict[str, Any], override_path: Optional[str] = None) -> Path:
|
|
406
|
+
if override_path:
|
|
407
|
+
return Path(override_path).expanduser()
|
|
408
|
+
storage = cfg.get("storage", {})
|
|
409
|
+
if isinstance(storage, dict):
|
|
410
|
+
configured = str(storage.get("dir", "")).strip()
|
|
411
|
+
if configured:
|
|
412
|
+
return Path(configured).expanduser()
|
|
413
|
+
return DEFAULT_DATA_DIR
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _load_records(path: Path) -> List[Dict[str, Any]]:
|
|
417
|
+
payload = _load_json(path)
|
|
418
|
+
if not payload:
|
|
419
|
+
return []
|
|
420
|
+
if isinstance(payload, list):
|
|
421
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
422
|
+
if isinstance(payload, dict):
|
|
423
|
+
items = payload.get("items", [])
|
|
424
|
+
if isinstance(items, list):
|
|
425
|
+
return [item for item in items if isinstance(item, dict)]
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _save_records(path: Path, items: List[Dict[str, Any]]) -> None:
|
|
430
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
path.write_text(json.dumps(items, indent=2) + "\n", encoding="utf-8")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _notes_path(storage_dir: Path) -> Path:
|
|
435
|
+
return storage_dir / "notes.json"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _tasks_path(storage_dir: Path) -> Path:
|
|
439
|
+
return storage_dir / "tasks.json"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _summaries_path(storage_dir: Path) -> Path:
|
|
443
|
+
return storage_dir / "summaries.json"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _plans_path(storage_dir: Path) -> Path:
|
|
447
|
+
return storage_dir / "plans.json"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _new_record_id(prefix: str) -> str:
|
|
451
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
452
|
+
return f"{prefix}{timestamp}-{uuid.uuid4().hex[:6]}"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _create_note_record(text: str, source: str) -> Dict[str, Any]:
|
|
456
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
457
|
+
return {
|
|
458
|
+
"id": _new_record_id("n"),
|
|
459
|
+
"text": text.strip(),
|
|
460
|
+
"created_at": now,
|
|
461
|
+
"updated_at": now,
|
|
462
|
+
"source": source,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _create_task_record(text: str, source: str) -> Dict[str, Any]:
|
|
467
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
468
|
+
return {
|
|
469
|
+
"id": _new_record_id("t"),
|
|
470
|
+
"text": text.strip(),
|
|
471
|
+
"status": "open",
|
|
472
|
+
"created_at": now,
|
|
473
|
+
"updated_at": now,
|
|
474
|
+
"source": source,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _parse_since_date(raw: str) -> Optional[datetime]:
|
|
479
|
+
value = raw.strip()
|
|
480
|
+
if not value:
|
|
481
|
+
return None
|
|
482
|
+
try:
|
|
483
|
+
return datetime.fromisoformat(value)
|
|
484
|
+
except ValueError:
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _captures_note_intent(user_input: str) -> Optional[Tuple[str, bool]]:
|
|
489
|
+
text = user_input.strip()
|
|
490
|
+
lower = text.lower()
|
|
491
|
+
note_markers = ("capture this as a note", "save as note", "note this")
|
|
492
|
+
task_markers = ("capture this as a task", "save as task", "add task")
|
|
493
|
+
|
|
494
|
+
for marker in note_markers:
|
|
495
|
+
if lower.startswith(marker):
|
|
496
|
+
content = text[len(marker) :].lstrip(" :,-")
|
|
497
|
+
return (content, False)
|
|
498
|
+
for marker in task_markers:
|
|
499
|
+
if lower.startswith(marker):
|
|
500
|
+
content = text[len(marker) :].lstrip(" :,-")
|
|
501
|
+
return (content, True)
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _extract_title_and_text(raw_html: str) -> Tuple[str, str]:
|
|
506
|
+
title = ""
|
|
507
|
+
title_match = re.search(r"<title[^>]*>(.*?)</title>", raw_html, flags=re.IGNORECASE | re.DOTALL)
|
|
508
|
+
if title_match:
|
|
509
|
+
title = html.unescape(re.sub(r"\s+", " ", title_match.group(1)).strip())
|
|
510
|
+
|
|
511
|
+
no_script = re.sub(r"<script[^>]*>.*?</script>", " ", raw_html, flags=re.IGNORECASE | re.DOTALL)
|
|
512
|
+
no_style = re.sub(r"<style[^>]*>.*?</style>", " ", no_script, flags=re.IGNORECASE | re.DOTALL)
|
|
513
|
+
text_only = re.sub(r"<[^>]+>", " ", no_style)
|
|
514
|
+
normalized = html.unescape(re.sub(r"\s+", " ", text_only)).strip()
|
|
515
|
+
return title, normalized
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _fetch_url_content(url: str, timeout: int = 15) -> Tuple[str, str]:
|
|
519
|
+
req = urllib.request.Request(
|
|
520
|
+
url,
|
|
521
|
+
headers={"User-Agent": "GaiaAssistant/0.1"},
|
|
522
|
+
method="GET",
|
|
523
|
+
)
|
|
524
|
+
try:
|
|
525
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
526
|
+
charset = response.headers.get_content_charset() or "utf-8"
|
|
527
|
+
raw = response.read().decode(charset, errors="replace")
|
|
528
|
+
except urllib.error.HTTPError as exc:
|
|
529
|
+
raise ProviderError(f"HTTP {exc.code} for {url}") from exc
|
|
530
|
+
except urllib.error.URLError as exc:
|
|
531
|
+
raise ProviderError(f"Network error for {url}: {exc.reason}") from exc
|
|
532
|
+
except TimeoutError as exc:
|
|
533
|
+
raise ProviderError(f"Timeout fetching {url}") from exc
|
|
534
|
+
return _extract_title_and_text(raw)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _key_points_from_text(text: str, max_points: int = 4) -> List[str]:
|
|
538
|
+
if not text.strip():
|
|
539
|
+
return []
|
|
540
|
+
pieces = re.split(r"(?<=[.!?])\s+", text.strip())
|
|
541
|
+
points: List[str] = []
|
|
542
|
+
for piece in pieces:
|
|
543
|
+
cleaned = piece.strip()
|
|
544
|
+
if len(cleaned) < 30:
|
|
545
|
+
continue
|
|
546
|
+
points.append(_summarize_text(cleaned, max_chars=200))
|
|
547
|
+
if len(points) >= max_points:
|
|
548
|
+
break
|
|
549
|
+
if points:
|
|
550
|
+
return points
|
|
551
|
+
return [_summarize_text(text, max_chars=200)]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _estimate_plan_complexity(goal: str) -> str:
|
|
555
|
+
words = [part for part in goal.strip().split() if part]
|
|
556
|
+
count = len(words)
|
|
557
|
+
if count <= 5:
|
|
558
|
+
return "low"
|
|
559
|
+
if count <= 12:
|
|
560
|
+
return "medium"
|
|
561
|
+
return "high"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _derive_plan_dependencies(goal: str) -> List[str]:
|
|
565
|
+
normalized = goal.strip().lower()
|
|
566
|
+
dependencies: List[str] = []
|
|
567
|
+
if any(token in normalized for token in ("research", "knowledge", "study", "read")):
|
|
568
|
+
dependencies.append("reference material access")
|
|
569
|
+
if any(token in normalized for token in ("setup", "install", "deploy", "configure")):
|
|
570
|
+
dependencies.append("runtime environment access")
|
|
571
|
+
if any(token in normalized for token in ("team", "collaborate", "stakeholder")):
|
|
572
|
+
dependencies.append("stakeholder coordination")
|
|
573
|
+
dependencies.append("time allocation")
|
|
574
|
+
seen = set()
|
|
575
|
+
deduped: List[str] = []
|
|
576
|
+
for item in dependencies:
|
|
577
|
+
if item in seen:
|
|
578
|
+
continue
|
|
579
|
+
seen.add(item)
|
|
580
|
+
deduped.append(item)
|
|
581
|
+
return deduped
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _generate_plan_steps(goal: str, refinement: str = "") -> List[str]:
|
|
585
|
+
steps = [
|
|
586
|
+
f"Clarify the objective and success criteria for '{goal.strip()}'.",
|
|
587
|
+
"Break the objective into concrete milestones with owners and deadlines.",
|
|
588
|
+
"Execute the milestones in priority order and capture outputs.",
|
|
589
|
+
"Review results, identify gaps, and schedule next iteration.",
|
|
590
|
+
]
|
|
591
|
+
refinement_text = refinement.strip()
|
|
592
|
+
if refinement_text:
|
|
593
|
+
steps.append(f"Refinement request: {refinement_text}")
|
|
594
|
+
return steps
|
|
595
|
+
|
|
596
|
+
def _session_file_path(session_dir: Path, session_id: str) -> Path:
|
|
597
|
+
return session_dir / f"{session_id}.json"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _load_session(session_dir: Path, session_id: str) -> Optional[Dict[str, Any]]:
|
|
601
|
+
path = _session_file_path(session_dir, session_id)
|
|
602
|
+
payload = _load_json(path)
|
|
603
|
+
if not payload:
|
|
604
|
+
return None
|
|
605
|
+
if not isinstance(payload.get("turns"), list):
|
|
606
|
+
payload["turns"] = []
|
|
607
|
+
return payload
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _save_session(session_dir: Path, session: Dict[str, Any]) -> None:
|
|
611
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
612
|
+
_write_json(_session_file_path(session_dir, str(session["id"])), session)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _list_sessions(session_dir: Path) -> List[Dict[str, Any]]:
|
|
616
|
+
if not session_dir.exists():
|
|
617
|
+
return []
|
|
618
|
+
sessions: List[Dict[str, Any]] = []
|
|
619
|
+
for path in session_dir.glob("*.json"):
|
|
620
|
+
payload = _load_json(path)
|
|
621
|
+
if not payload:
|
|
622
|
+
continue
|
|
623
|
+
session_id = str(payload.get("id", "")).strip()
|
|
624
|
+
updated_at = str(payload.get("updated_at", "")).strip()
|
|
625
|
+
if not session_id:
|
|
626
|
+
continue
|
|
627
|
+
sessions.append({"id": session_id, "updated_at": updated_at})
|
|
628
|
+
sessions.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
|
|
629
|
+
return sessions
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _create_session(provider: str, model: str) -> Dict[str, Any]:
|
|
633
|
+
session_id = datetime.now(timezone.utc).strftime("s%Y%m%d%H%M%S") + "-" + uuid.uuid4().hex[:6]
|
|
634
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
635
|
+
return {
|
|
636
|
+
"id": session_id,
|
|
637
|
+
"created_at": now,
|
|
638
|
+
"updated_at": now,
|
|
639
|
+
"provider": provider,
|
|
640
|
+
"model": model,
|
|
641
|
+
"turns": [],
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _resolve_resume_session_id(session_dir: Path, resume: str) -> Optional[str]:
|
|
646
|
+
target = str(resume).strip()
|
|
647
|
+
if not target:
|
|
648
|
+
return None
|
|
649
|
+
if target != "last":
|
|
650
|
+
return target
|
|
651
|
+
sessions = _list_sessions(session_dir)
|
|
652
|
+
if not sessions:
|
|
653
|
+
return None
|
|
654
|
+
return str(sessions[0]["id"])
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _extract_last_user_text(messages: List[Dict[str, str]]) -> str:
|
|
658
|
+
for msg in reversed(messages):
|
|
659
|
+
if str(msg.get("role", "")).strip() == "user":
|
|
660
|
+
return str(msg.get("content", "")).strip()
|
|
661
|
+
return ""
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _mock_provider_response(provider: str, model: str, messages: List[Dict[str, str]]) -> str:
|
|
665
|
+
prompt = _extract_last_user_text(messages)
|
|
666
|
+
prompt_summary = _summarize_text(prompt, max_chars=140)
|
|
667
|
+
return (
|
|
668
|
+
f"[local-{provider}] {prompt_summary}\n"
|
|
669
|
+
f"Context messages: {len(messages)} | model: {model}."
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _extract_http_error_details(exc: urllib.error.HTTPError) -> str:
|
|
674
|
+
try:
|
|
675
|
+
raw = exc.read().decode("utf-8")
|
|
676
|
+
except Exception:
|
|
677
|
+
return str(exc)
|
|
678
|
+
if not raw:
|
|
679
|
+
return str(exc)
|
|
680
|
+
try:
|
|
681
|
+
payload = json.loads(raw)
|
|
682
|
+
except json.JSONDecodeError:
|
|
683
|
+
return raw
|
|
684
|
+
if isinstance(payload, dict):
|
|
685
|
+
if isinstance(payload.get("error"), dict):
|
|
686
|
+
message = payload["error"].get("message")
|
|
687
|
+
if isinstance(message, str) and message.strip():
|
|
688
|
+
return message.strip()
|
|
689
|
+
message = payload.get("message")
|
|
690
|
+
if isinstance(message, str) and message.strip():
|
|
691
|
+
return message.strip()
|
|
692
|
+
return raw
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _looks_like_token_limit(text: str) -> bool:
|
|
696
|
+
normalized = text.strip().lower()
|
|
697
|
+
markers = (
|
|
698
|
+
"context length",
|
|
699
|
+
"too many tokens",
|
|
700
|
+
"token limit",
|
|
701
|
+
"maximum context",
|
|
702
|
+
"context_window_exceeded",
|
|
703
|
+
"context_length_exceeded",
|
|
704
|
+
)
|
|
705
|
+
return any(marker in normalized for marker in markers)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _http_json_request(
|
|
709
|
+
url: str,
|
|
710
|
+
headers: Dict[str, str],
|
|
711
|
+
payload: Dict[str, Any],
|
|
712
|
+
timeout: int = 30,
|
|
713
|
+
) -> Dict[str, Any]:
|
|
714
|
+
data = json.dumps(payload).encode("utf-8")
|
|
715
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
716
|
+
try:
|
|
717
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
718
|
+
raw = response.read().decode("utf-8")
|
|
719
|
+
except urllib.error.HTTPError as exc:
|
|
720
|
+
details = _extract_http_error_details(exc)
|
|
721
|
+
if exc.code in (413, 429) or _looks_like_token_limit(details):
|
|
722
|
+
raise ProviderTokenLimitError(details) from exc
|
|
723
|
+
raise ProviderError(f"HTTP {exc.code}: {details}") from exc
|
|
724
|
+
except urllib.error.URLError as exc:
|
|
725
|
+
raise ProviderError(f"Network error: {exc.reason}") from exc
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
decoded = json.loads(raw)
|
|
729
|
+
except json.JSONDecodeError as exc:
|
|
730
|
+
raise ProviderError("Provider returned non-JSON response") from exc
|
|
731
|
+
if not isinstance(decoded, dict):
|
|
732
|
+
raise ProviderError("Provider returned invalid payload")
|
|
733
|
+
return decoded
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _call_openai_chat(
|
|
737
|
+
base_url: str,
|
|
738
|
+
api_key: str,
|
|
739
|
+
model: str,
|
|
740
|
+
messages: List[Dict[str, str]],
|
|
741
|
+
) -> str:
|
|
742
|
+
payload = {
|
|
743
|
+
"model": model,
|
|
744
|
+
"messages": messages,
|
|
745
|
+
"temperature": 0.2,
|
|
746
|
+
}
|
|
747
|
+
response = _http_json_request(
|
|
748
|
+
url=f"{base_url.rstrip('/')}/chat/completions",
|
|
749
|
+
headers={
|
|
750
|
+
"Authorization": f"Bearer {api_key}",
|
|
751
|
+
"Content-Type": "application/json",
|
|
752
|
+
},
|
|
753
|
+
payload=payload,
|
|
754
|
+
)
|
|
755
|
+
choices = response.get("choices", [])
|
|
756
|
+
if not isinstance(choices, list) or not choices:
|
|
757
|
+
raise ProviderError("Provider returned no choices")
|
|
758
|
+
first = choices[0]
|
|
759
|
+
if not isinstance(first, dict):
|
|
760
|
+
raise ProviderError("Provider returned invalid choice")
|
|
761
|
+
message = first.get("message", {})
|
|
762
|
+
if not isinstance(message, dict):
|
|
763
|
+
raise ProviderError("Provider returned invalid message payload")
|
|
764
|
+
content = message.get("content")
|
|
765
|
+
if isinstance(content, str) and content.strip():
|
|
766
|
+
return content.strip()
|
|
767
|
+
if isinstance(content, list):
|
|
768
|
+
parts = [part.get("text", "") for part in content if isinstance(part, dict)]
|
|
769
|
+
merged = "\n".join([p for p in parts if p.strip()]).strip()
|
|
770
|
+
if merged:
|
|
771
|
+
return merged
|
|
772
|
+
raise ProviderError("Provider returned empty response")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _call_anthropic_chat(api_key: str, model: str, messages: List[Dict[str, str]]) -> str:
|
|
776
|
+
payload = {
|
|
777
|
+
"model": model,
|
|
778
|
+
"max_tokens": 700,
|
|
779
|
+
"messages": messages,
|
|
780
|
+
}
|
|
781
|
+
response = _http_json_request(
|
|
782
|
+
url="https://api.anthropic.com/v1/messages",
|
|
783
|
+
headers={
|
|
784
|
+
"x-api-key": api_key,
|
|
785
|
+
"anthropic-version": "2023-06-01",
|
|
786
|
+
"Content-Type": "application/json",
|
|
787
|
+
},
|
|
788
|
+
payload=payload,
|
|
789
|
+
)
|
|
790
|
+
content = response.get("content", [])
|
|
791
|
+
if not isinstance(content, list):
|
|
792
|
+
raise ProviderError("Provider returned invalid content")
|
|
793
|
+
parts: List[str] = []
|
|
794
|
+
for item in content:
|
|
795
|
+
if not isinstance(item, dict):
|
|
796
|
+
continue
|
|
797
|
+
if item.get("type") != "text":
|
|
798
|
+
continue
|
|
799
|
+
text = item.get("text")
|
|
800
|
+
if isinstance(text, str) and text.strip():
|
|
801
|
+
parts.append(text.strip())
|
|
802
|
+
if not parts:
|
|
803
|
+
raise ProviderError("Provider returned empty response")
|
|
804
|
+
return "\n".join(parts)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _resolve_runtime_provider(cfg: Dict[str, Any]) -> Tuple[str, str]:
|
|
808
|
+
reasoning = cfg.get("reasoning", {})
|
|
809
|
+
reasoning = reasoning if isinstance(reasoning, dict) else {}
|
|
810
|
+
profile = cfg.get("profile", {})
|
|
811
|
+
profile = profile if isinstance(profile, dict) else {}
|
|
812
|
+
|
|
813
|
+
provider = str(reasoning.get("provider", "")).strip().lower()
|
|
814
|
+
if not provider:
|
|
815
|
+
provider = str(profile.get("default_provider", "anthropic")).strip().lower()
|
|
816
|
+
if provider not in PROFILE_PROVIDER_CHOICES:
|
|
817
|
+
provider = "anthropic"
|
|
818
|
+
|
|
819
|
+
model = str(reasoning.get("model", "")).strip()
|
|
820
|
+
if not model:
|
|
821
|
+
model = _default_model_for_provider(provider)
|
|
822
|
+
return provider, model
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _resolve_provider_api_key(
|
|
826
|
+
provider: str,
|
|
827
|
+
cfg_path: Path,
|
|
828
|
+
secret_store_override: Optional[str],
|
|
829
|
+
) -> str:
|
|
830
|
+
env_key = {
|
|
831
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
832
|
+
"openai": "OPENAI_API_KEY",
|
|
833
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
834
|
+
}.get(provider, "")
|
|
835
|
+
if not env_key:
|
|
836
|
+
return ""
|
|
837
|
+
from_env = os.environ.get(env_key, "").strip()
|
|
838
|
+
if from_env:
|
|
839
|
+
return from_env
|
|
840
|
+
secret_store = _resolve_secret_store(cfg_path, secret_store_override)
|
|
841
|
+
return _read_secret_api_key(secret_store, env_key)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _reasoning_response(
|
|
845
|
+
provider: str,
|
|
846
|
+
model: str,
|
|
847
|
+
messages: List[Dict[str, str]],
|
|
848
|
+
cfg_path: Path,
|
|
849
|
+
secret_store_override: Optional[str],
|
|
850
|
+
) -> str:
|
|
851
|
+
if provider == "openai-codex":
|
|
852
|
+
return _mock_provider_response(provider, model, messages)
|
|
853
|
+
|
|
854
|
+
api_key = _resolve_provider_api_key(provider, cfg_path, secret_store_override)
|
|
855
|
+
if not api_key:
|
|
856
|
+
return _mock_provider_response(provider, model, messages)
|
|
857
|
+
|
|
858
|
+
if provider == "openai":
|
|
859
|
+
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
|
860
|
+
return _call_openai_chat(base_url=base_url, api_key=api_key, model=model, messages=messages)
|
|
861
|
+
if provider == "openrouter":
|
|
862
|
+
return _call_openai_chat(
|
|
863
|
+
base_url="https://openrouter.ai/api/v1",
|
|
864
|
+
api_key=api_key,
|
|
865
|
+
model=model,
|
|
866
|
+
messages=messages,
|
|
867
|
+
)
|
|
868
|
+
if provider == "anthropic":
|
|
869
|
+
return _call_anthropic_chat(api_key=api_key, model=model, messages=messages)
|
|
870
|
+
raise ProviderError(f"Unsupported provider: {provider}")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _infer_capability_from_prompt(text: str) -> Tuple[str, Optional[str]]:
|
|
874
|
+
prompt = text.strip().lower()
|
|
875
|
+
if not prompt:
|
|
876
|
+
return "file_read", None
|
|
877
|
+
if "send email" in prompt or "email " in prompt:
|
|
878
|
+
return "send_email", "This action sends outbound email. Continue?"
|
|
879
|
+
if "delete" in prompt and ("file" in prompt or "folder" in prompt):
|
|
880
|
+
return "delete_files", "This action may delete files. Continue?"
|
|
881
|
+
if prompt.startswith("run ") or "execute " in prompt:
|
|
882
|
+
return "shell_exec", "This action executes shell commands. Continue?"
|
|
883
|
+
if "http://" in prompt or "https://" in prompt:
|
|
884
|
+
return "network_request", "This action performs a network request. Continue?"
|
|
885
|
+
return "file_read", None
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _build_context_messages(
|
|
889
|
+
turns: List[Dict[str, Any]],
|
|
890
|
+
user_prompt: str,
|
|
891
|
+
max_turns: int,
|
|
892
|
+
) -> List[Dict[str, str]]:
|
|
893
|
+
bounded = turns[-max_turns:] if max_turns > 0 else turns
|
|
894
|
+
messages: List[Dict[str, str]] = []
|
|
895
|
+
for turn in bounded:
|
|
896
|
+
role = str(turn.get("role", "")).strip()
|
|
897
|
+
content = str(turn.get("content", "")).strip()
|
|
898
|
+
if role in ("user", "assistant") and content:
|
|
899
|
+
messages.append({"role": role, "content": content})
|
|
900
|
+
messages.append({"role": "user", "content": user_prompt})
|
|
901
|
+
return messages
|
|
113
902
|
def _ensure_config_exists(cfg_path: Path) -> Dict[str, Any]:
|
|
114
903
|
cfg = _load_json(cfg_path)
|
|
115
904
|
if not cfg:
|
|
@@ -122,6 +911,264 @@ def _ensure_config_exists(cfg_path: Path) -> Dict[str, Any]:
|
|
|
122
911
|
return cfg
|
|
123
912
|
|
|
124
913
|
|
|
914
|
+
def _prompt_yes_no(question: str, default: bool = True, non_interactive: bool = False) -> bool:
|
|
915
|
+
if non_interactive:
|
|
916
|
+
return default
|
|
917
|
+
suffix = "[Y/n]" if default else "[y/N]"
|
|
918
|
+
raw = input(f"{question} {suffix}: ").strip().lower()
|
|
919
|
+
if not raw:
|
|
920
|
+
return default
|
|
921
|
+
return raw in ("y", "yes")
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _prompt_choice(
|
|
925
|
+
question: str,
|
|
926
|
+
options: List[Tuple[str, str]],
|
|
927
|
+
default_index: int = 0,
|
|
928
|
+
non_interactive: bool = False,
|
|
929
|
+
) -> str:
|
|
930
|
+
if not options:
|
|
931
|
+
raise ValueError("options list cannot be empty")
|
|
932
|
+
if default_index < 0 or default_index >= len(options):
|
|
933
|
+
default_index = 0
|
|
934
|
+
if non_interactive:
|
|
935
|
+
return options[default_index][0]
|
|
936
|
+
|
|
937
|
+
print(question)
|
|
938
|
+
for idx, (_, label) in enumerate(options, start=1):
|
|
939
|
+
marker = " (default)" if idx - 1 == default_index else ""
|
|
940
|
+
print(f" {idx}) {label}{marker}")
|
|
941
|
+
|
|
942
|
+
while True:
|
|
943
|
+
raw = input(f"Select [1-{len(options)}] (default {default_index + 1}): ").strip()
|
|
944
|
+
if not raw:
|
|
945
|
+
return options[default_index][0]
|
|
946
|
+
if raw.isdigit():
|
|
947
|
+
selected = int(raw)
|
|
948
|
+
if 1 <= selected <= len(options):
|
|
949
|
+
return options[selected - 1][0]
|
|
950
|
+
print("Invalid selection. Please enter a valid number.")
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _resolve_secret_store(cfg_path: Path, override_path: Optional[str]) -> Path:
|
|
954
|
+
if override_path:
|
|
955
|
+
return Path(override_path).expanduser()
|
|
956
|
+
cfg = _load_json(cfg_path)
|
|
957
|
+
if cfg:
|
|
958
|
+
store = cfg.get("secrets", {}).get("store_path")
|
|
959
|
+
if isinstance(store, str) and store.strip():
|
|
960
|
+
return Path(store).expanduser()
|
|
961
|
+
return cfg_path.parent / "secrets.json"
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _load_secret_store(path: Path) -> Dict[str, Any]:
|
|
965
|
+
payload = _load_json(path)
|
|
966
|
+
if not payload:
|
|
967
|
+
return {"version": 1, "api_keys": {}}
|
|
968
|
+
if not isinstance(payload.get("api_keys"), dict):
|
|
969
|
+
payload["api_keys"] = {}
|
|
970
|
+
if "version" not in payload:
|
|
971
|
+
payload["version"] = 1
|
|
972
|
+
return payload
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _save_secret_store(path: Path, payload: Dict[str, Any]) -> None:
|
|
976
|
+
_write_secret_json(path, payload)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _read_secret_api_key(path: Path, env_var: str) -> str:
|
|
980
|
+
payload = _load_secret_store(path)
|
|
981
|
+
api_keys = payload.get("api_keys", {})
|
|
982
|
+
if not isinstance(api_keys, dict):
|
|
983
|
+
return ""
|
|
984
|
+
value = api_keys.get(env_var)
|
|
985
|
+
if not isinstance(value, str):
|
|
986
|
+
return ""
|
|
987
|
+
return value.strip()
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _write_secret_api_key(path: Path, env_var: str, value: str) -> None:
|
|
991
|
+
payload = _load_secret_store(path)
|
|
992
|
+
api_keys = payload.setdefault("api_keys", {})
|
|
993
|
+
if not isinstance(api_keys, dict):
|
|
994
|
+
api_keys = {}
|
|
995
|
+
payload["api_keys"] = api_keys
|
|
996
|
+
api_keys[env_var] = value
|
|
997
|
+
_save_secret_store(path, payload)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _default_model_for_provider(provider: str) -> str:
|
|
1001
|
+
if provider == "openrouter":
|
|
1002
|
+
return DEFAULT_OPENROUTER_MODEL
|
|
1003
|
+
if provider == "openai":
|
|
1004
|
+
return DEFAULT_OPENAI_MODEL
|
|
1005
|
+
return DEFAULT_ANTHROPIC_MODEL
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _set_reasoning_config(cfg_path: Path, provider: str, model: Optional[str]) -> None:
|
|
1009
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
1010
|
+
reasoning = cfg.setdefault("reasoning", {})
|
|
1011
|
+
if not isinstance(reasoning, dict):
|
|
1012
|
+
reasoning = {}
|
|
1013
|
+
cfg["reasoning"] = reasoning
|
|
1014
|
+
|
|
1015
|
+
reasoning["provider"] = provider
|
|
1016
|
+
if model and model.strip():
|
|
1017
|
+
reasoning["model"] = model.strip()
|
|
1018
|
+
else:
|
|
1019
|
+
reasoning["model"] = _default_model_for_provider(provider)
|
|
1020
|
+
_write_json(cfg_path, cfg)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _set_secret_store_config(cfg_path: Path, store_path: Path) -> None:
|
|
1024
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
1025
|
+
secrets = cfg.setdefault("secrets", {})
|
|
1026
|
+
if not isinstance(secrets, dict):
|
|
1027
|
+
secrets = {}
|
|
1028
|
+
cfg["secrets"] = secrets
|
|
1029
|
+
secrets["store_path"] = str(store_path)
|
|
1030
|
+
_write_json(cfg_path, cfg)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _is_zero_pricing(value: Any) -> bool:
|
|
1034
|
+
if isinstance(value, (int, float)):
|
|
1035
|
+
return float(value) == 0.0
|
|
1036
|
+
if isinstance(value, str):
|
|
1037
|
+
try:
|
|
1038
|
+
return float(value.strip()) == 0.0
|
|
1039
|
+
except ValueError:
|
|
1040
|
+
return False
|
|
1041
|
+
return False
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _fetch_openrouter_free_models(api_key: str) -> List[str]:
|
|
1045
|
+
if not api_key:
|
|
1046
|
+
return []
|
|
1047
|
+
req = urllib.request.Request(
|
|
1048
|
+
"https://openrouter.ai/api/v1/models",
|
|
1049
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
1050
|
+
method="GET",
|
|
1051
|
+
)
|
|
1052
|
+
try:
|
|
1053
|
+
with urllib.request.urlopen(req, timeout=12) as response:
|
|
1054
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1055
|
+
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, TimeoutError):
|
|
1056
|
+
return []
|
|
1057
|
+
|
|
1058
|
+
data = payload.get("data", [])
|
|
1059
|
+
if not isinstance(data, list):
|
|
1060
|
+
return []
|
|
1061
|
+
|
|
1062
|
+
free: List[str] = []
|
|
1063
|
+
for item in data:
|
|
1064
|
+
if not isinstance(item, dict):
|
|
1065
|
+
continue
|
|
1066
|
+
model_id = str(item.get("id", "")).strip()
|
|
1067
|
+
if not model_id:
|
|
1068
|
+
continue
|
|
1069
|
+
if ":free" in model_id:
|
|
1070
|
+
free.append(model_id)
|
|
1071
|
+
continue
|
|
1072
|
+
pricing = item.get("pricing", {})
|
|
1073
|
+
if isinstance(pricing, dict):
|
|
1074
|
+
prompt = pricing.get("prompt")
|
|
1075
|
+
completion = pricing.get("completion")
|
|
1076
|
+
if _is_zero_pricing(prompt) and _is_zero_pricing(completion):
|
|
1077
|
+
free.append(model_id)
|
|
1078
|
+
seen = set()
|
|
1079
|
+
uniq = []
|
|
1080
|
+
for model in sorted(free):
|
|
1081
|
+
if model in seen:
|
|
1082
|
+
continue
|
|
1083
|
+
uniq.append(model)
|
|
1084
|
+
seen.add(model)
|
|
1085
|
+
return uniq[:8]
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _select_openrouter_model(
|
|
1089
|
+
api_key: str,
|
|
1090
|
+
explicit_model: Optional[str],
|
|
1091
|
+
non_interactive: bool,
|
|
1092
|
+
) -> str:
|
|
1093
|
+
if explicit_model and explicit_model.strip():
|
|
1094
|
+
return explicit_model.strip()
|
|
1095
|
+
if non_interactive:
|
|
1096
|
+
return DEFAULT_OPENROUTER_MODEL
|
|
1097
|
+
|
|
1098
|
+
free_models = _fetch_openrouter_free_models(api_key)
|
|
1099
|
+
options: List[Tuple[str, str]] = [("openrouter/auto", "openrouter/auto (recommended)")]
|
|
1100
|
+
for model in free_models:
|
|
1101
|
+
options.append((model, f"{model} (detected free model)"))
|
|
1102
|
+
options.append(("__custom__", "Custom model id"))
|
|
1103
|
+
|
|
1104
|
+
selection = _prompt_choice(
|
|
1105
|
+
"Choose your OpenRouter model:",
|
|
1106
|
+
options,
|
|
1107
|
+
default_index=0,
|
|
1108
|
+
non_interactive=non_interactive,
|
|
1109
|
+
)
|
|
1110
|
+
if selection != "__custom__":
|
|
1111
|
+
return selection
|
|
1112
|
+
|
|
1113
|
+
while True:
|
|
1114
|
+
custom = input("Enter OpenRouter model id (example: provider/model:free): ").strip()
|
|
1115
|
+
if custom:
|
|
1116
|
+
return custom
|
|
1117
|
+
print("Model id cannot be empty.")
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _configure_api_key_provider(
|
|
1121
|
+
cfg_path: Path,
|
|
1122
|
+
provider: str,
|
|
1123
|
+
api_key_env: str,
|
|
1124
|
+
model: Optional[str],
|
|
1125
|
+
api_key: Optional[str],
|
|
1126
|
+
secret_store_override: Optional[str],
|
|
1127
|
+
no_prompt: bool,
|
|
1128
|
+
no_store_api_key: bool,
|
|
1129
|
+
) -> int:
|
|
1130
|
+
secret_store = _resolve_secret_store(cfg_path, secret_store_override)
|
|
1131
|
+
_set_secret_store_config(cfg_path, secret_store)
|
|
1132
|
+
env_value = os.environ.get(api_key_env, "").strip()
|
|
1133
|
+
secret_value = _read_secret_api_key(secret_store, api_key_env)
|
|
1134
|
+
provided = api_key.strip() if isinstance(api_key, str) else ""
|
|
1135
|
+
|
|
1136
|
+
selected_key = provided or env_value or secret_value
|
|
1137
|
+
if not selected_key and no_prompt:
|
|
1138
|
+
print(
|
|
1139
|
+
f"API key for {provider} is required in non-interactive mode.\n"
|
|
1140
|
+
f"Provide --api-key or set {api_key_env}.",
|
|
1141
|
+
file=sys.stderr,
|
|
1142
|
+
)
|
|
1143
|
+
return 1
|
|
1144
|
+
|
|
1145
|
+
if not selected_key:
|
|
1146
|
+
selected_key = getpass.getpass(f"Enter {api_key_env}: ").strip()
|
|
1147
|
+
if not selected_key:
|
|
1148
|
+
print(f"{api_key_env} was empty; onboarding canceled.", file=sys.stderr)
|
|
1149
|
+
return 1
|
|
1150
|
+
|
|
1151
|
+
should_store = not no_store_api_key
|
|
1152
|
+
if not no_prompt and not no_store_api_key:
|
|
1153
|
+
should_store = _prompt_yes_no(
|
|
1154
|
+
f"Store {api_key_env} in local secret store ({secret_store})?",
|
|
1155
|
+
default=True,
|
|
1156
|
+
non_interactive=no_prompt,
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
if should_store:
|
|
1160
|
+
_write_secret_api_key(secret_store, api_key_env, selected_key)
|
|
1161
|
+
print(f"[ok] stored {api_key_env} in local secret store: {secret_store}")
|
|
1162
|
+
else:
|
|
1163
|
+
print(f"[warn] {api_key_env} not stored; keep it exported in your shell.")
|
|
1164
|
+
|
|
1165
|
+
_set_reasoning_config(cfg_path, provider, model)
|
|
1166
|
+
print("[ok] reasoning provider configured")
|
|
1167
|
+
print(f" provider: {provider}")
|
|
1168
|
+
print(f" model: {model or _default_model_for_provider(provider)}")
|
|
1169
|
+
return 0
|
|
1170
|
+
|
|
1171
|
+
|
|
125
1172
|
def _resolve_openclaw_auth_store(openclaw_agent: str, override_path: Optional[str]) -> Path:
|
|
126
1173
|
if override_path:
|
|
127
1174
|
return Path(override_path).expanduser()
|
|
@@ -445,14 +1492,6 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
445
1492
|
else:
|
|
446
1493
|
print(f"[warn] optional command not found: {name}")
|
|
447
1494
|
|
|
448
|
-
anth = os.environ.get("ANTHROPIC_API_KEY")
|
|
449
|
-
oai = os.environ.get("OPENAI_API_KEY")
|
|
450
|
-
if anth or oai:
|
|
451
|
-
print("[ok] at least one API auth env var is present")
|
|
452
|
-
else:
|
|
453
|
-
print("[warn] no API key env vars found (ANTHROPIC_API_KEY / OPENAI_API_KEY)")
|
|
454
|
-
print(" subscription OAuth profiles may still be used depending on your runtime setup")
|
|
455
|
-
|
|
456
1495
|
cfg = _load_json(cfg_path)
|
|
457
1496
|
if not cfg:
|
|
458
1497
|
print(f"[warn] launcher config not found yet: {cfg_path}")
|
|
@@ -460,10 +1499,34 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
460
1499
|
print(f" run `{launcher} init` or `{launcher} onboard`")
|
|
461
1500
|
else:
|
|
462
1501
|
cfg = _normalize_config(cfg)
|
|
1502
|
+
reasoning_cfg = cfg.get("reasoning", {})
|
|
1503
|
+
reasoning_provider = ""
|
|
1504
|
+
if isinstance(reasoning_cfg, dict):
|
|
1505
|
+
reasoning_provider = str(reasoning_cfg.get("provider", "")).strip()
|
|
1506
|
+
print(
|
|
1507
|
+
"[ok] reasoning config: "
|
|
1508
|
+
f"{reasoning_cfg.get('provider', '?')}/{reasoning_cfg.get('model', '?')}"
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
secret_store = _resolve_secret_store(cfg_path, None)
|
|
1512
|
+
anth = os.environ.get("ANTHROPIC_API_KEY", "").strip() or _read_secret_api_key(secret_store, "ANTHROPIC_API_KEY")
|
|
1513
|
+
oai = os.environ.get("OPENAI_API_KEY", "").strip() or _read_secret_api_key(secret_store, "OPENAI_API_KEY")
|
|
1514
|
+
openrouter = os.environ.get("OPENROUTER_API_KEY", "").strip() or _read_secret_api_key(
|
|
1515
|
+
secret_store, "OPENROUTER_API_KEY"
|
|
1516
|
+
)
|
|
1517
|
+
if anth or oai or openrouter:
|
|
1518
|
+
print("[ok] at least one API auth credential is available (env or secret store)")
|
|
1519
|
+
else:
|
|
1520
|
+
print("[warn] no API credentials found (env or secret store)")
|
|
1521
|
+
print(" expected: ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY")
|
|
1522
|
+
|
|
463
1523
|
active = cfg.get("auth", {}).get("active_profile")
|
|
464
1524
|
if not isinstance(active, dict):
|
|
465
|
-
|
|
466
|
-
|
|
1525
|
+
if reasoning_provider == "openai-codex":
|
|
1526
|
+
print("[warn] no linked OAuth profile in launcher config")
|
|
1527
|
+
print(f" run `{_launcher_hint()} onboard --provider openai-codex`")
|
|
1528
|
+
else:
|
|
1529
|
+
print("[info] no linked OAuth profile (not required for API-key providers)")
|
|
467
1530
|
else:
|
|
468
1531
|
credential, error = _read_linked_credential(active)
|
|
469
1532
|
if credential is None:
|
|
@@ -510,30 +1573,94 @@ def cmd_onboard(args: argparse.Namespace) -> int:
|
|
|
510
1573
|
print(f"[ok] using existing launcher config: {cfg_path}")
|
|
511
1574
|
print(f"[ok] state directory ready: {state_dir}")
|
|
512
1575
|
print("")
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
1576
|
+
provider = (args.provider or "").strip().lower()
|
|
1577
|
+
if not provider:
|
|
1578
|
+
provider = _prompt_choice(
|
|
1579
|
+
"Choose provider to connect:",
|
|
1580
|
+
[
|
|
1581
|
+
("openrouter", "OpenRouter (API key + model selection)"),
|
|
1582
|
+
("openai", "OpenAI (API key)"),
|
|
1583
|
+
("anthropic", "Anthropic (API key)"),
|
|
1584
|
+
("openai-codex", "OpenAI Codex (OAuth via Codex CLI)"),
|
|
1585
|
+
],
|
|
1586
|
+
default_index=0,
|
|
1587
|
+
non_interactive=args.yes,
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
if provider not in ONBOARD_PROVIDER_CHOICES:
|
|
1591
|
+
print(f"Unsupported onboarding provider: {provider}", file=sys.stderr)
|
|
1592
|
+
print(f"Supported providers: {', '.join(ONBOARD_PROVIDER_CHOICES)}", file=sys.stderr)
|
|
1593
|
+
return 1
|
|
1594
|
+
print(f"[ok] selected provider: {provider}")
|
|
1595
|
+
|
|
1596
|
+
if provider == "openai-codex":
|
|
1597
|
+
print("OAuth flow selected: OpenAI Codex via Codex CLI")
|
|
1598
|
+
print("This opens browser/device auth and links profile into Gaia local auth store.")
|
|
1599
|
+
should_start = _prompt_yes_no(
|
|
1600
|
+
"Start OpenAI Codex OAuth login now?",
|
|
1601
|
+
default=True,
|
|
1602
|
+
non_interactive=args.yes,
|
|
1603
|
+
)
|
|
1604
|
+
if not should_start:
|
|
1605
|
+
print("Skipped OAuth login. Run this later:")
|
|
1606
|
+
print(f" {_launcher_hint()} auth login --provider openai-codex")
|
|
1607
|
+
return 0
|
|
1608
|
+
login_args = argparse.Namespace(
|
|
1609
|
+
config=str(cfg_path),
|
|
1610
|
+
provider="openai-codex",
|
|
1611
|
+
source="codex-cli",
|
|
1612
|
+
codex_auth_path=args.codex_auth_path,
|
|
1613
|
+
gaia_auth_store=args.gaia_auth_store,
|
|
1614
|
+
openclaw_agent="main",
|
|
1615
|
+
openclaw_auth_store=None,
|
|
1616
|
+
no_prompt=True,
|
|
1617
|
+
profile_id=None,
|
|
1618
|
+
)
|
|
1619
|
+
return cmd_auth_login(login_args)
|
|
525
1620
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1621
|
+
api_key_env_by_provider = {
|
|
1622
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
1623
|
+
"openai": "OPENAI_API_KEY",
|
|
1624
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
1625
|
+
}
|
|
1626
|
+
api_key_env = api_key_env_by_provider.get(provider, "")
|
|
1627
|
+
if not api_key_env:
|
|
1628
|
+
print(f"Provider is not configured for API-key onboarding: {provider}", file=sys.stderr)
|
|
1629
|
+
return 1
|
|
1630
|
+
|
|
1631
|
+
provider_model: Optional[str] = args.model
|
|
1632
|
+
if provider == "openrouter":
|
|
1633
|
+
seed_key = (
|
|
1634
|
+
(args.api_key.strip() if isinstance(args.api_key, str) else "")
|
|
1635
|
+
or os.environ.get(api_key_env, "").strip()
|
|
1636
|
+
or _read_secret_api_key(_resolve_secret_store(cfg_path, args.secret_store), api_key_env)
|
|
1637
|
+
)
|
|
1638
|
+
provider_model = _select_openrouter_model(
|
|
1639
|
+
api_key=seed_key,
|
|
1640
|
+
explicit_model=args.model,
|
|
1641
|
+
non_interactive=args.yes,
|
|
1642
|
+
)
|
|
1643
|
+
elif not provider_model or not provider_model.strip():
|
|
1644
|
+
provider_model = _default_model_for_provider(provider)
|
|
1645
|
+
|
|
1646
|
+
rc = _configure_api_key_provider(
|
|
1647
|
+
cfg_path=cfg_path,
|
|
1648
|
+
provider=provider,
|
|
1649
|
+
api_key_env=api_key_env,
|
|
1650
|
+
model=provider_model,
|
|
1651
|
+
api_key=args.api_key,
|
|
1652
|
+
secret_store_override=args.secret_store,
|
|
1653
|
+
no_prompt=args.yes,
|
|
1654
|
+
no_store_api_key=args.no_store_api_key,
|
|
535
1655
|
)
|
|
536
|
-
|
|
1656
|
+
if rc != 0:
|
|
1657
|
+
return rc
|
|
1658
|
+
|
|
1659
|
+
print("")
|
|
1660
|
+
print("Onboarding complete.")
|
|
1661
|
+
print(f"Next: {_launcher_hint()} doctor")
|
|
1662
|
+
print(f"Then: {_launcher_hint()} run --mode single --dry-run")
|
|
1663
|
+
return 0
|
|
537
1664
|
|
|
538
1665
|
|
|
539
1666
|
def cmd_auth_login(args: argparse.Namespace) -> int:
|
|
@@ -695,7 +1822,14 @@ def cmd_auth_status(args: argparse.Namespace) -> int:
|
|
|
695
1822
|
cfg = _normalize_config(cfg)
|
|
696
1823
|
active = cfg.get("auth", {}).get("active_profile")
|
|
697
1824
|
if not isinstance(active, dict):
|
|
1825
|
+
reasoning_cfg = cfg.get("reasoning", {})
|
|
1826
|
+
reasoning_provider = ""
|
|
1827
|
+
if isinstance(reasoning_cfg, dict):
|
|
1828
|
+
reasoning_provider = str(reasoning_cfg.get("provider", "")).strip()
|
|
698
1829
|
print("No linked auth profile in launcher config.")
|
|
1830
|
+
if reasoning_provider in ("openrouter", "openai", "anthropic"):
|
|
1831
|
+
print("This is expected when using API-key providers.")
|
|
1832
|
+
return 0
|
|
699
1833
|
print(f"Run: {_launcher_hint()} auth login --provider openai-codex")
|
|
700
1834
|
return 1
|
|
701
1835
|
|
|
@@ -729,8 +1863,1028 @@ def cmd_auth_status(args: argparse.Namespace) -> int:
|
|
|
729
1863
|
return 0
|
|
730
1864
|
|
|
731
1865
|
|
|
1866
|
+
def _print_chat_help() -> None:
|
|
1867
|
+
print("Chat commands:")
|
|
1868
|
+
print(" /help Show this help")
|
|
1869
|
+
print(" /session Show current session id")
|
|
1870
|
+
print(" /exit Exit chat")
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def cmd_chat(args: argparse.Namespace) -> int:
|
|
1874
|
+
cfg_path = Path(args.config).expanduser()
|
|
1875
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
1876
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
1877
|
+
session_dir = _resolve_session_dir(cfg, args.session_dir)
|
|
1878
|
+
storage_dir = _resolve_storage_dir(cfg, args.storage_dir)
|
|
1879
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
1880
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
1881
|
+
|
|
1882
|
+
provider, model = _resolve_runtime_provider(cfg)
|
|
1883
|
+
sessions_cfg = cfg.get("sessions", {})
|
|
1884
|
+
sessions_cfg = sessions_cfg if isinstance(sessions_cfg, dict) else {}
|
|
1885
|
+
configured_max_turns = sessions_cfg.get("max_context_turns", DEFAULT_SESSION_CONTEXT_TURNS)
|
|
1886
|
+
max_turns = args.max_context_turns if args.max_context_turns else configured_max_turns
|
|
1887
|
+
if not isinstance(max_turns, int) or max_turns < 2:
|
|
1888
|
+
max_turns = DEFAULT_SESSION_CONTEXT_TURNS
|
|
1889
|
+
|
|
1890
|
+
session: Optional[Dict[str, Any]] = None
|
|
1891
|
+
if args.resume:
|
|
1892
|
+
target_session_id = _resolve_resume_session_id(session_dir, str(args.resume))
|
|
1893
|
+
if not target_session_id:
|
|
1894
|
+
print("No session available to resume.", file=sys.stderr)
|
|
1895
|
+
return 1
|
|
1896
|
+
session = _load_session(session_dir, target_session_id)
|
|
1897
|
+
if session is None:
|
|
1898
|
+
print(f"Session not found: {target_session_id}", file=sys.stderr)
|
|
1899
|
+
return 1
|
|
1900
|
+
print(f"Resumed session: {target_session_id}")
|
|
1901
|
+
else:
|
|
1902
|
+
session = _create_session(provider=provider, model=model)
|
|
1903
|
+
_save_session(session_dir, session)
|
|
1904
|
+
print(f"Started session: {session['id']}")
|
|
1905
|
+
|
|
1906
|
+
assert session is not None
|
|
1907
|
+
print(f"Provider: {provider} | Model: {model}")
|
|
1908
|
+
print("Type /help for commands.")
|
|
1909
|
+
|
|
1910
|
+
while True:
|
|
1911
|
+
try:
|
|
1912
|
+
raw = input("you> ")
|
|
1913
|
+
except EOFError:
|
|
1914
|
+
print("")
|
|
1915
|
+
break
|
|
1916
|
+
except KeyboardInterrupt:
|
|
1917
|
+
print("")
|
|
1918
|
+
break
|
|
1919
|
+
|
|
1920
|
+
user_input = raw.strip()
|
|
1921
|
+
if not user_input:
|
|
1922
|
+
continue
|
|
1923
|
+
if user_input in ("/exit", "exit", "quit"):
|
|
1924
|
+
break
|
|
1925
|
+
if user_input in ("/help", "help"):
|
|
1926
|
+
_print_chat_help()
|
|
1927
|
+
continue
|
|
1928
|
+
if user_input == "/session":
|
|
1929
|
+
print(session["id"])
|
|
1930
|
+
continue
|
|
1931
|
+
|
|
1932
|
+
capture_intent = _captures_note_intent(user_input)
|
|
1933
|
+
if capture_intent is not None:
|
|
1934
|
+
captured_text, as_task = capture_intent
|
|
1935
|
+
if not captured_text:
|
|
1936
|
+
guidance = "Provide text after the capture command, for example: capture this as a note: draft agenda"
|
|
1937
|
+
print(f"gaia> {guidance}")
|
|
1938
|
+
continue
|
|
1939
|
+
|
|
1940
|
+
turn_start = time.perf_counter()
|
|
1941
|
+
allowed, permission_level = _enforce_capability(
|
|
1942
|
+
cfg=cfg,
|
|
1943
|
+
capability="file_write",
|
|
1944
|
+
input_summary=user_input,
|
|
1945
|
+
trace_dir=trace_dir,
|
|
1946
|
+
non_interactive=False,
|
|
1947
|
+
)
|
|
1948
|
+
if not allowed:
|
|
1949
|
+
blocked_reply = "Action blocked by capability policy."
|
|
1950
|
+
print(f"gaia> {blocked_reply}")
|
|
1951
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
1952
|
+
session["turns"].append({"role": "user", "content": user_input, "timestamp": timestamp})
|
|
1953
|
+
session["turns"].append({"role": "assistant", "content": blocked_reply, "timestamp": timestamp})
|
|
1954
|
+
session["updated_at"] = timestamp
|
|
1955
|
+
_save_session(session_dir, session)
|
|
1956
|
+
_write_action_trace(
|
|
1957
|
+
trace_dir=trace_dir,
|
|
1958
|
+
action_type="chat_turn",
|
|
1959
|
+
input_summary=user_input,
|
|
1960
|
+
output_summary=blocked_reply,
|
|
1961
|
+
duration_ms=(time.perf_counter() - turn_start) * 1000,
|
|
1962
|
+
permission_level=permission_level,
|
|
1963
|
+
status="blocked",
|
|
1964
|
+
metadata={"session_id": session["id"], "provider": provider, "model": model},
|
|
1965
|
+
)
|
|
1966
|
+
continue
|
|
1967
|
+
|
|
1968
|
+
if as_task:
|
|
1969
|
+
items = _load_records(_tasks_path(storage_dir))
|
|
1970
|
+
record = _create_task_record(captured_text, source="chat")
|
|
1971
|
+
items.append(record)
|
|
1972
|
+
_save_records(_tasks_path(storage_dir), items)
|
|
1973
|
+
reply = f"Captured task {record['id']}"
|
|
1974
|
+
action_type = "task_capture"
|
|
1975
|
+
else:
|
|
1976
|
+
items = _load_records(_notes_path(storage_dir))
|
|
1977
|
+
record = _create_note_record(captured_text, source="chat")
|
|
1978
|
+
items.append(record)
|
|
1979
|
+
_save_records(_notes_path(storage_dir), items)
|
|
1980
|
+
reply = f"Captured note {record['id']}"
|
|
1981
|
+
action_type = "note_capture"
|
|
1982
|
+
|
|
1983
|
+
print(f"gaia> {reply}")
|
|
1984
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
1985
|
+
session["turns"].append({"role": "user", "content": user_input, "timestamp": timestamp})
|
|
1986
|
+
session["turns"].append({"role": "assistant", "content": reply, "timestamp": timestamp})
|
|
1987
|
+
session["updated_at"] = timestamp
|
|
1988
|
+
_save_session(session_dir, session)
|
|
1989
|
+
_write_action_trace(
|
|
1990
|
+
trace_dir=trace_dir,
|
|
1991
|
+
action_type=action_type,
|
|
1992
|
+
input_summary=user_input,
|
|
1993
|
+
output_summary=reply,
|
|
1994
|
+
duration_ms=(time.perf_counter() - turn_start) * 1000,
|
|
1995
|
+
permission_level=permission_level,
|
|
1996
|
+
metadata={"session_id": session["id"], "source": "chat"},
|
|
1997
|
+
)
|
|
1998
|
+
continue
|
|
1999
|
+
|
|
2000
|
+
capability, confirm_prompt = _infer_capability_from_prompt(user_input)
|
|
2001
|
+
turn_start = time.perf_counter()
|
|
2002
|
+
allowed, permission_level = _enforce_capability(
|
|
2003
|
+
cfg=cfg,
|
|
2004
|
+
capability=capability,
|
|
2005
|
+
input_summary=user_input,
|
|
2006
|
+
trace_dir=trace_dir,
|
|
2007
|
+
non_interactive=False,
|
|
2008
|
+
prompt=confirm_prompt,
|
|
2009
|
+
)
|
|
2010
|
+
if not allowed:
|
|
2011
|
+
blocked_reply = "Action blocked by capability policy."
|
|
2012
|
+
print(f"gaia> {blocked_reply}")
|
|
2013
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
2014
|
+
session["turns"].append({"role": "user", "content": user_input, "timestamp": timestamp})
|
|
2015
|
+
session["turns"].append({"role": "assistant", "content": blocked_reply, "timestamp": timestamp})
|
|
2016
|
+
session["updated_at"] = timestamp
|
|
2017
|
+
_save_session(session_dir, session)
|
|
2018
|
+
_write_action_trace(
|
|
2019
|
+
trace_dir=trace_dir,
|
|
2020
|
+
action_type="chat_turn",
|
|
2021
|
+
input_summary=user_input,
|
|
2022
|
+
output_summary=blocked_reply,
|
|
2023
|
+
duration_ms=(time.perf_counter() - turn_start) * 1000,
|
|
2024
|
+
permission_level=permission_level,
|
|
2025
|
+
status="blocked",
|
|
2026
|
+
metadata={"session_id": session["id"], "provider": provider, "model": model},
|
|
2027
|
+
)
|
|
2028
|
+
continue
|
|
2029
|
+
|
|
2030
|
+
messages = _build_context_messages(session["turns"], user_input, max_turns=max_turns)
|
|
2031
|
+
status = "ok"
|
|
2032
|
+
try:
|
|
2033
|
+
assistant_reply = _reasoning_response(
|
|
2034
|
+
provider=provider,
|
|
2035
|
+
model=model,
|
|
2036
|
+
messages=messages,
|
|
2037
|
+
cfg_path=cfg_path,
|
|
2038
|
+
secret_store_override=args.secret_store,
|
|
2039
|
+
)
|
|
2040
|
+
except ProviderTokenLimitError:
|
|
2041
|
+
compact_turns = session["turns"][-max(4, max_turns // 2) :]
|
|
2042
|
+
compact_messages = _build_context_messages(
|
|
2043
|
+
compact_turns,
|
|
2044
|
+
user_input,
|
|
2045
|
+
max_turns=max(4, max_turns // 2),
|
|
2046
|
+
)
|
|
2047
|
+
try:
|
|
2048
|
+
assistant_reply = _reasoning_response(
|
|
2049
|
+
provider=provider,
|
|
2050
|
+
model=model,
|
|
2051
|
+
messages=compact_messages,
|
|
2052
|
+
cfg_path=cfg_path,
|
|
2053
|
+
secret_store_override=args.secret_store,
|
|
2054
|
+
)
|
|
2055
|
+
status = "ok"
|
|
2056
|
+
except ProviderError as exc:
|
|
2057
|
+
assistant_reply = f"[provider-error] {exc}"
|
|
2058
|
+
status = "error"
|
|
2059
|
+
except ProviderError as exc:
|
|
2060
|
+
assistant_reply = f"[provider-error] {exc}"
|
|
2061
|
+
status = "error"
|
|
2062
|
+
|
|
2063
|
+
print(f"gaia> {assistant_reply}")
|
|
2064
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
2065
|
+
session["turns"].append({"role": "user", "content": user_input, "timestamp": timestamp})
|
|
2066
|
+
session["turns"].append({"role": "assistant", "content": assistant_reply, "timestamp": timestamp})
|
|
2067
|
+
session["updated_at"] = timestamp
|
|
2068
|
+
_save_session(session_dir, session)
|
|
2069
|
+
_write_action_trace(
|
|
2070
|
+
trace_dir=trace_dir,
|
|
2071
|
+
action_type="chat_turn",
|
|
2072
|
+
input_summary=user_input,
|
|
2073
|
+
output_summary=assistant_reply,
|
|
2074
|
+
duration_ms=(time.perf_counter() - turn_start) * 1000,
|
|
2075
|
+
permission_level=permission_level,
|
|
2076
|
+
status=status,
|
|
2077
|
+
metadata={
|
|
2078
|
+
"session_id": session["id"],
|
|
2079
|
+
"provider": provider,
|
|
2080
|
+
"model": model,
|
|
2081
|
+
"context_messages": len(messages),
|
|
2082
|
+
},
|
|
2083
|
+
)
|
|
2084
|
+
|
|
2085
|
+
_save_session(session_dir, session)
|
|
2086
|
+
print(f"Session saved: {session['id']}")
|
|
2087
|
+
return 0
|
|
2088
|
+
|
|
2089
|
+
|
|
2090
|
+
def cmd_config_get(args: argparse.Namespace) -> int:
|
|
2091
|
+
cfg_path = Path(args.config).expanduser()
|
|
2092
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2093
|
+
trace_dir = _resolve_trace_dir(cfg)
|
|
2094
|
+
start = time.perf_counter()
|
|
2095
|
+
key = str(args.key).strip().lower()
|
|
2096
|
+
path = _config_path_for_key(key)
|
|
2097
|
+
if path is None:
|
|
2098
|
+
_write_action_trace(
|
|
2099
|
+
trace_dir=trace_dir,
|
|
2100
|
+
action_type="config_get",
|
|
2101
|
+
input_summary=f"key={key}",
|
|
2102
|
+
output_summary="unsupported key",
|
|
2103
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2104
|
+
permission_level="safe",
|
|
2105
|
+
status="error",
|
|
2106
|
+
)
|
|
2107
|
+
print(
|
|
2108
|
+
"Unsupported key. Supported keys: "
|
|
2109
|
+
+ ", ".join(sorted(PROFILE_KEY_MAP.keys())),
|
|
2110
|
+
file=sys.stderr,
|
|
2111
|
+
)
|
|
2112
|
+
return 1
|
|
2113
|
+
|
|
2114
|
+
value = _get_nested_value(cfg, path)
|
|
2115
|
+
if value is None:
|
|
2116
|
+
_write_action_trace(
|
|
2117
|
+
trace_dir=trace_dir,
|
|
2118
|
+
action_type="config_get",
|
|
2119
|
+
input_summary=f"key={key}",
|
|
2120
|
+
output_summary="empty value",
|
|
2121
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2122
|
+
permission_level="safe",
|
|
2123
|
+
)
|
|
2124
|
+
print("", end="")
|
|
2125
|
+
return 0
|
|
2126
|
+
if isinstance(value, (dict, list)):
|
|
2127
|
+
print(json.dumps(value, indent=2))
|
|
2128
|
+
else:
|
|
2129
|
+
print(value)
|
|
2130
|
+
_write_action_trace(
|
|
2131
|
+
trace_dir=trace_dir,
|
|
2132
|
+
action_type="config_get",
|
|
2133
|
+
input_summary=f"key={key}",
|
|
2134
|
+
output_summary=f"value={_summarize_text(value)}",
|
|
2135
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2136
|
+
permission_level="safe",
|
|
2137
|
+
)
|
|
2138
|
+
return 0
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def cmd_config_set(args: argparse.Namespace) -> int:
|
|
2142
|
+
cfg_path = Path(args.config).expanduser()
|
|
2143
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2144
|
+
trace_dir = _resolve_trace_dir(cfg)
|
|
2145
|
+
start = time.perf_counter()
|
|
2146
|
+
key = str(args.key).strip().lower()
|
|
2147
|
+
path = _config_path_for_key(key)
|
|
2148
|
+
if path is None:
|
|
2149
|
+
_write_action_trace(
|
|
2150
|
+
trace_dir=trace_dir,
|
|
2151
|
+
action_type="config_set",
|
|
2152
|
+
input_summary=f"key={key}",
|
|
2153
|
+
output_summary="unsupported key",
|
|
2154
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2155
|
+
permission_level="safe",
|
|
2156
|
+
status="error",
|
|
2157
|
+
)
|
|
2158
|
+
print(
|
|
2159
|
+
"Unsupported key. Supported keys: "
|
|
2160
|
+
+ ", ".join(sorted(PROFILE_KEY_MAP.keys())),
|
|
2161
|
+
file=sys.stderr,
|
|
2162
|
+
)
|
|
2163
|
+
return 1
|
|
2164
|
+
|
|
2165
|
+
allowed, permission_level = _enforce_capability(
|
|
2166
|
+
cfg=cfg,
|
|
2167
|
+
capability="file_write",
|
|
2168
|
+
input_summary=f"config set {key}",
|
|
2169
|
+
trace_dir=trace_dir,
|
|
2170
|
+
non_interactive=False,
|
|
2171
|
+
)
|
|
2172
|
+
if not allowed:
|
|
2173
|
+
_write_action_trace(
|
|
2174
|
+
trace_dir=trace_dir,
|
|
2175
|
+
action_type="config_set",
|
|
2176
|
+
input_summary=f"key={key}",
|
|
2177
|
+
output_summary="blocked by permission policy",
|
|
2178
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2179
|
+
permission_level=permission_level,
|
|
2180
|
+
status="blocked",
|
|
2181
|
+
)
|
|
2182
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2183
|
+
return 1
|
|
2184
|
+
|
|
2185
|
+
normalized, error = _normalize_profile_value(key, str(args.value))
|
|
2186
|
+
if error:
|
|
2187
|
+
_write_action_trace(
|
|
2188
|
+
trace_dir=trace_dir,
|
|
2189
|
+
action_type="config_set",
|
|
2190
|
+
input_summary=f"key={key}",
|
|
2191
|
+
output_summary=error,
|
|
2192
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2193
|
+
permission_level=permission_level,
|
|
2194
|
+
status="error",
|
|
2195
|
+
)
|
|
2196
|
+
print(error, file=sys.stderr)
|
|
2197
|
+
return 1
|
|
2198
|
+
assert normalized is not None
|
|
2199
|
+
_set_nested_value(cfg, path, normalized)
|
|
2200
|
+
|
|
2201
|
+
if key in ("provider", "default_provider"):
|
|
2202
|
+
reasoning = cfg.setdefault("reasoning", {})
|
|
2203
|
+
if not isinstance(reasoning, dict):
|
|
2204
|
+
reasoning = {}
|
|
2205
|
+
cfg["reasoning"] = reasoning
|
|
2206
|
+
reasoning["provider"] = normalized
|
|
2207
|
+
reasoning["model"] = _default_model_for_provider(normalized)
|
|
2208
|
+
|
|
2209
|
+
_write_json(cfg_path, _normalize_config(cfg))
|
|
2210
|
+
_write_action_trace(
|
|
2211
|
+
trace_dir=trace_dir,
|
|
2212
|
+
action_type="config_set",
|
|
2213
|
+
input_summary=f"key={key}",
|
|
2214
|
+
output_summary=f"set to {normalized}",
|
|
2215
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2216
|
+
permission_level=permission_level,
|
|
2217
|
+
)
|
|
2218
|
+
print(f"{path[0]}.{path[1]}={normalized}")
|
|
2219
|
+
return 0
|
|
2220
|
+
|
|
2221
|
+
|
|
2222
|
+
def _log_permission_decision(
|
|
2223
|
+
trace_dir: Path,
|
|
2224
|
+
capability: str,
|
|
2225
|
+
input_summary: str,
|
|
2226
|
+
allowed: bool,
|
|
2227
|
+
permission_level: str,
|
|
2228
|
+
reason: str,
|
|
2229
|
+
duration_ms: float,
|
|
2230
|
+
) -> None:
|
|
2231
|
+
_write_action_trace(
|
|
2232
|
+
trace_dir=trace_dir,
|
|
2233
|
+
action_type="permission_decision",
|
|
2234
|
+
input_summary=f"{capability}: {input_summary}",
|
|
2235
|
+
output_summary=reason,
|
|
2236
|
+
duration_ms=duration_ms,
|
|
2237
|
+
permission_level=permission_level,
|
|
2238
|
+
status="ok" if allowed else "blocked",
|
|
2239
|
+
metadata={"capability": capability, "allowed": allowed},
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def _enforce_capability(
|
|
2244
|
+
cfg: Dict[str, Any],
|
|
2245
|
+
capability: str,
|
|
2246
|
+
input_summary: str,
|
|
2247
|
+
trace_dir: Path,
|
|
2248
|
+
non_interactive: bool = False,
|
|
2249
|
+
prompt: Optional[str] = None,
|
|
2250
|
+
) -> Tuple[bool, str]:
|
|
2251
|
+
start = time.perf_counter()
|
|
2252
|
+
allowed, level, reason = _check_capability_permission(
|
|
2253
|
+
cfg=cfg,
|
|
2254
|
+
capability=capability,
|
|
2255
|
+
user_prompt=prompt,
|
|
2256
|
+
non_interactive=non_interactive,
|
|
2257
|
+
)
|
|
2258
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
2259
|
+
_log_permission_decision(
|
|
2260
|
+
trace_dir=trace_dir,
|
|
2261
|
+
capability=capability,
|
|
2262
|
+
input_summary=input_summary,
|
|
2263
|
+
allowed=allowed,
|
|
2264
|
+
permission_level=level,
|
|
2265
|
+
reason=reason,
|
|
2266
|
+
duration_ms=duration_ms,
|
|
2267
|
+
)
|
|
2268
|
+
return allowed, level
|
|
2269
|
+
|
|
2270
|
+
|
|
2271
|
+
def cmd_capability_list(args: argparse.Namespace) -> int:
|
|
2272
|
+
cfg_path = Path(args.config).expanduser()
|
|
2273
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2274
|
+
trace_dir = _resolve_trace_dir(cfg)
|
|
2275
|
+
start = time.perf_counter()
|
|
2276
|
+
|
|
2277
|
+
allowed, permission_level = _enforce_capability(
|
|
2278
|
+
cfg=cfg,
|
|
2279
|
+
capability="file_read",
|
|
2280
|
+
input_summary="capability list",
|
|
2281
|
+
trace_dir=trace_dir,
|
|
2282
|
+
non_interactive=False,
|
|
2283
|
+
)
|
|
2284
|
+
if not allowed:
|
|
2285
|
+
_write_action_trace(
|
|
2286
|
+
trace_dir=trace_dir,
|
|
2287
|
+
action_type="capability_list",
|
|
2288
|
+
input_summary="capability list",
|
|
2289
|
+
output_summary="blocked by permission policy",
|
|
2290
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2291
|
+
permission_level=permission_level,
|
|
2292
|
+
status="blocked",
|
|
2293
|
+
)
|
|
2294
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2295
|
+
return 1
|
|
2296
|
+
|
|
2297
|
+
registry = _capability_registry(cfg)
|
|
2298
|
+
for capability in sorted(registry):
|
|
2299
|
+
print(f"{capability} {registry[capability]}")
|
|
2300
|
+
_write_action_trace(
|
|
2301
|
+
trace_dir=trace_dir,
|
|
2302
|
+
action_type="capability_list",
|
|
2303
|
+
input_summary="capability list",
|
|
2304
|
+
output_summary=f"{len(registry)} capabilities",
|
|
2305
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2306
|
+
permission_level=permission_level,
|
|
2307
|
+
)
|
|
2308
|
+
return 0
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
def cmd_capability_set(args: argparse.Namespace) -> int:
|
|
2312
|
+
cfg_path = Path(args.config).expanduser()
|
|
2313
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2314
|
+
trace_dir = _resolve_trace_dir(cfg)
|
|
2315
|
+
start = time.perf_counter()
|
|
2316
|
+
capability = str(args.capability).strip()
|
|
2317
|
+
if not capability:
|
|
2318
|
+
_write_action_trace(
|
|
2319
|
+
trace_dir=trace_dir,
|
|
2320
|
+
action_type="capability_set",
|
|
2321
|
+
input_summary="capability empty",
|
|
2322
|
+
output_summary="capability cannot be empty",
|
|
2323
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2324
|
+
permission_level="safe",
|
|
2325
|
+
status="error",
|
|
2326
|
+
)
|
|
2327
|
+
print("capability cannot be empty", file=sys.stderr)
|
|
2328
|
+
return 1
|
|
2329
|
+
|
|
2330
|
+
level = str(args.level).strip().lower()
|
|
2331
|
+
if level not in PERMISSION_LEVEL_CHOICES:
|
|
2332
|
+
_write_action_trace(
|
|
2333
|
+
trace_dir=trace_dir,
|
|
2334
|
+
action_type="capability_set",
|
|
2335
|
+
input_summary=f"{capability}={level}",
|
|
2336
|
+
output_summary="invalid level",
|
|
2337
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2338
|
+
permission_level="safe",
|
|
2339
|
+
status="error",
|
|
2340
|
+
)
|
|
2341
|
+
print(
|
|
2342
|
+
f"Invalid level '{args.level}'. "
|
|
2343
|
+
f"Expected one of: {', '.join(PERMISSION_LEVEL_CHOICES)}.",
|
|
2344
|
+
file=sys.stderr,
|
|
2345
|
+
)
|
|
2346
|
+
return 1
|
|
2347
|
+
|
|
2348
|
+
allowed, permission_level = _enforce_capability(
|
|
2349
|
+
cfg=cfg,
|
|
2350
|
+
capability="file_write",
|
|
2351
|
+
input_summary=f"capability set {capability}",
|
|
2352
|
+
trace_dir=trace_dir,
|
|
2353
|
+
non_interactive=False,
|
|
2354
|
+
)
|
|
2355
|
+
if not allowed:
|
|
2356
|
+
_write_action_trace(
|
|
2357
|
+
trace_dir=trace_dir,
|
|
2358
|
+
action_type="capability_set",
|
|
2359
|
+
input_summary=f"{capability}={level}",
|
|
2360
|
+
output_summary="blocked by permission policy",
|
|
2361
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2362
|
+
permission_level=permission_level,
|
|
2363
|
+
status="blocked",
|
|
2364
|
+
)
|
|
2365
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2366
|
+
return 1
|
|
2367
|
+
|
|
2368
|
+
capabilities = cfg.setdefault("capabilities", {})
|
|
2369
|
+
if not isinstance(capabilities, dict):
|
|
2370
|
+
capabilities = {}
|
|
2371
|
+
cfg["capabilities"] = capabilities
|
|
2372
|
+
overrides = capabilities.setdefault("overrides", {})
|
|
2373
|
+
if not isinstance(overrides, dict):
|
|
2374
|
+
overrides = {}
|
|
2375
|
+
capabilities["overrides"] = overrides
|
|
2376
|
+
overrides[capability] = level
|
|
2377
|
+
_write_json(cfg_path, _normalize_config(cfg))
|
|
2378
|
+
_write_action_trace(
|
|
2379
|
+
trace_dir=trace_dir,
|
|
2380
|
+
action_type="capability_set",
|
|
2381
|
+
input_summary=f"{capability}={level}",
|
|
2382
|
+
output_summary="override updated",
|
|
2383
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2384
|
+
permission_level=permission_level,
|
|
2385
|
+
)
|
|
2386
|
+
print(f"capabilities.overrides.{capability}={level}")
|
|
2387
|
+
return 0
|
|
2388
|
+
|
|
2389
|
+
|
|
2390
|
+
def cmd_traces(args: argparse.Namespace) -> int:
|
|
2391
|
+
cfg_path = Path(args.config).expanduser()
|
|
2392
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2393
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
2394
|
+
traces = _read_action_traces(trace_dir)
|
|
2395
|
+
if args.type:
|
|
2396
|
+
wanted = str(args.type).strip()
|
|
2397
|
+
traces = [item for item in traces if str(item.get("action_type", "")).strip() == wanted]
|
|
2398
|
+
|
|
2399
|
+
if args.last and args.last > 0:
|
|
2400
|
+
traces = traces[-args.last :]
|
|
2401
|
+
|
|
2402
|
+
if not traces:
|
|
2403
|
+
print(f"No traces found in {trace_dir}")
|
|
2404
|
+
return 0
|
|
2405
|
+
|
|
2406
|
+
for item in traces:
|
|
2407
|
+
ts = str(item.get("timestamp", "?")).strip()
|
|
2408
|
+
action_type = str(item.get("action_type", "?")).strip()
|
|
2409
|
+
level = str(item.get("permission_level", "?")).strip()
|
|
2410
|
+
status = str(item.get("status", "?")).strip()
|
|
2411
|
+
summary = _summarize_text(item.get("output_summary", ""), max_chars=100)
|
|
2412
|
+
print(f"{ts} | {action_type:<20} | {level:<9} | {status:<7} | {summary}")
|
|
2413
|
+
return 0
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def cmd_note(args: argparse.Namespace) -> int:
|
|
2417
|
+
cfg_path = Path(args.config).expanduser()
|
|
2418
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2419
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
2420
|
+
storage_dir = _resolve_storage_dir(cfg, args.storage_dir)
|
|
2421
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
2422
|
+
start = time.perf_counter()
|
|
2423
|
+
text = str(args.text).strip()
|
|
2424
|
+
if not text:
|
|
2425
|
+
_write_action_trace(
|
|
2426
|
+
trace_dir=trace_dir,
|
|
2427
|
+
action_type="note_capture",
|
|
2428
|
+
input_summary="empty input",
|
|
2429
|
+
output_summary="text cannot be empty",
|
|
2430
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2431
|
+
permission_level="safe",
|
|
2432
|
+
status="error",
|
|
2433
|
+
)
|
|
2434
|
+
print("note text cannot be empty", file=sys.stderr)
|
|
2435
|
+
return 1
|
|
2436
|
+
|
|
2437
|
+
allowed, permission_level = _enforce_capability(
|
|
2438
|
+
cfg=cfg,
|
|
2439
|
+
capability="file_write",
|
|
2440
|
+
input_summary=f"note capture: {text}",
|
|
2441
|
+
trace_dir=trace_dir,
|
|
2442
|
+
non_interactive=False,
|
|
2443
|
+
)
|
|
2444
|
+
if not allowed:
|
|
2445
|
+
_write_action_trace(
|
|
2446
|
+
trace_dir=trace_dir,
|
|
2447
|
+
action_type="note_capture",
|
|
2448
|
+
input_summary=text,
|
|
2449
|
+
output_summary="blocked by permission policy",
|
|
2450
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2451
|
+
permission_level=permission_level,
|
|
2452
|
+
status="blocked",
|
|
2453
|
+
)
|
|
2454
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2455
|
+
return 1
|
|
2456
|
+
|
|
2457
|
+
if args.task:
|
|
2458
|
+
path = _tasks_path(storage_dir)
|
|
2459
|
+
items = _load_records(path)
|
|
2460
|
+
record = _create_task_record(text, source="cli")
|
|
2461
|
+
items.append(record)
|
|
2462
|
+
_save_records(path, items)
|
|
2463
|
+
action_type = "task_capture"
|
|
2464
|
+
print(f"{record['id']} open {record['text']}")
|
|
2465
|
+
else:
|
|
2466
|
+
path = _notes_path(storage_dir)
|
|
2467
|
+
items = _load_records(path)
|
|
2468
|
+
record = _create_note_record(text, source="cli")
|
|
2469
|
+
items.append(record)
|
|
2470
|
+
_save_records(path, items)
|
|
2471
|
+
action_type = "note_capture"
|
|
2472
|
+
print(f"{record['id']} {record['text']}")
|
|
2473
|
+
|
|
2474
|
+
_write_action_trace(
|
|
2475
|
+
trace_dir=trace_dir,
|
|
2476
|
+
action_type=action_type,
|
|
2477
|
+
input_summary=text,
|
|
2478
|
+
output_summary=f"saved {record['id']}",
|
|
2479
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2480
|
+
permission_level=permission_level,
|
|
2481
|
+
metadata={"source": "cli"},
|
|
2482
|
+
)
|
|
2483
|
+
return 0
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
def cmd_tasks(args: argparse.Namespace) -> int:
|
|
2487
|
+
cfg_path = Path(args.config).expanduser()
|
|
2488
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2489
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
2490
|
+
storage_dir = _resolve_storage_dir(cfg, args.storage_dir)
|
|
2491
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
2492
|
+
start = time.perf_counter()
|
|
2493
|
+
|
|
2494
|
+
allowed, permission_level = _enforce_capability(
|
|
2495
|
+
cfg=cfg,
|
|
2496
|
+
capability="file_read",
|
|
2497
|
+
input_summary="list tasks",
|
|
2498
|
+
trace_dir=trace_dir,
|
|
2499
|
+
non_interactive=False,
|
|
2500
|
+
)
|
|
2501
|
+
if not allowed:
|
|
2502
|
+
_write_action_trace(
|
|
2503
|
+
trace_dir=trace_dir,
|
|
2504
|
+
action_type="tasks_list",
|
|
2505
|
+
input_summary="tasks list",
|
|
2506
|
+
output_summary="blocked by permission policy",
|
|
2507
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2508
|
+
permission_level=permission_level,
|
|
2509
|
+
status="blocked",
|
|
2510
|
+
)
|
|
2511
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2512
|
+
return 1
|
|
2513
|
+
|
|
2514
|
+
tasks = _load_records(_tasks_path(storage_dir))
|
|
2515
|
+
status = str(args.status).strip().lower()
|
|
2516
|
+
if status not in ("open", "done", "all"):
|
|
2517
|
+
status = "open"
|
|
2518
|
+
|
|
2519
|
+
if status != "all":
|
|
2520
|
+
tasks = [item for item in tasks if str(item.get("status", "open")).strip().lower() == status]
|
|
2521
|
+
|
|
2522
|
+
query = str(args.q or "").strip().lower()
|
|
2523
|
+
if query:
|
|
2524
|
+
tasks = [item for item in tasks if query in str(item.get("text", "")).lower()]
|
|
2525
|
+
|
|
2526
|
+
since_dt = None
|
|
2527
|
+
if args.since:
|
|
2528
|
+
since_dt = _parse_since_date(str(args.since))
|
|
2529
|
+
if since_dt is None:
|
|
2530
|
+
_write_action_trace(
|
|
2531
|
+
trace_dir=trace_dir,
|
|
2532
|
+
action_type="tasks_list",
|
|
2533
|
+
input_summary=f"since={args.since}",
|
|
2534
|
+
output_summary="invalid --since date",
|
|
2535
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2536
|
+
permission_level=permission_level,
|
|
2537
|
+
status="error",
|
|
2538
|
+
)
|
|
2539
|
+
print("Invalid --since value. Use YYYY-MM-DD.", file=sys.stderr)
|
|
2540
|
+
return 1
|
|
2541
|
+
tasks = [
|
|
2542
|
+
item
|
|
2543
|
+
for item in tasks
|
|
2544
|
+
if str(item.get("created_at", ""))[:10] >= since_dt.date().isoformat()
|
|
2545
|
+
]
|
|
2546
|
+
|
|
2547
|
+
tasks.sort(key=lambda item: str(item.get("created_at", "")))
|
|
2548
|
+
if not tasks:
|
|
2549
|
+
print("No tasks found.")
|
|
2550
|
+
else:
|
|
2551
|
+
for task in tasks:
|
|
2552
|
+
task_id = str(task.get("id", "?"))
|
|
2553
|
+
task_status = str(task.get("status", "open"))
|
|
2554
|
+
created = str(task.get("created_at", "?"))
|
|
2555
|
+
text = str(task.get("text", ""))
|
|
2556
|
+
print(f"{task_id} {task_status:<4} {created} {text}")
|
|
2557
|
+
|
|
2558
|
+
_write_action_trace(
|
|
2559
|
+
trace_dir=trace_dir,
|
|
2560
|
+
action_type="tasks_list",
|
|
2561
|
+
input_summary=f"status={status} query={query}",
|
|
2562
|
+
output_summary=f"{len(tasks)} tasks",
|
|
2563
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2564
|
+
permission_level=permission_level,
|
|
2565
|
+
)
|
|
2566
|
+
return 0
|
|
2567
|
+
|
|
2568
|
+
|
|
2569
|
+
def cmd_summarize(args: argparse.Namespace) -> int:
|
|
2570
|
+
cfg_path = Path(args.config).expanduser()
|
|
2571
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2572
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
2573
|
+
storage_dir = _resolve_storage_dir(cfg, args.storage_dir)
|
|
2574
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
2575
|
+
start = time.perf_counter()
|
|
2576
|
+
|
|
2577
|
+
allowed, permission_level = _enforce_capability(
|
|
2578
|
+
cfg=cfg,
|
|
2579
|
+
capability="network_request",
|
|
2580
|
+
input_summary="summarize urls",
|
|
2581
|
+
trace_dir=trace_dir,
|
|
2582
|
+
non_interactive=False,
|
|
2583
|
+
)
|
|
2584
|
+
if not allowed:
|
|
2585
|
+
_write_action_trace(
|
|
2586
|
+
trace_dir=trace_dir,
|
|
2587
|
+
action_type="summarize_url",
|
|
2588
|
+
input_summary=" ".join(args.urls),
|
|
2589
|
+
output_summary="blocked by permission policy",
|
|
2590
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2591
|
+
permission_level=permission_level,
|
|
2592
|
+
status="blocked",
|
|
2593
|
+
)
|
|
2594
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2595
|
+
return 1
|
|
2596
|
+
|
|
2597
|
+
summaries = _load_records(_summaries_path(storage_dir))
|
|
2598
|
+
failed = 0
|
|
2599
|
+
for raw_url in args.urls:
|
|
2600
|
+
url = str(raw_url).strip()
|
|
2601
|
+
if not url:
|
|
2602
|
+
continue
|
|
2603
|
+
|
|
2604
|
+
item_start = time.perf_counter()
|
|
2605
|
+
try:
|
|
2606
|
+
title, body_text = _fetch_url_content(url)
|
|
2607
|
+
points = _key_points_from_text(body_text)
|
|
2608
|
+
except ProviderError as exc:
|
|
2609
|
+
failed += 1
|
|
2610
|
+
message = f"[warn] {exc}"
|
|
2611
|
+
print(message, file=sys.stderr)
|
|
2612
|
+
_write_action_trace(
|
|
2613
|
+
trace_dir=trace_dir,
|
|
2614
|
+
action_type="summarize_url",
|
|
2615
|
+
input_summary=url,
|
|
2616
|
+
output_summary=message,
|
|
2617
|
+
duration_ms=(time.perf_counter() - item_start) * 1000,
|
|
2618
|
+
permission_level=permission_level,
|
|
2619
|
+
status="error",
|
|
2620
|
+
)
|
|
2621
|
+
continue
|
|
2622
|
+
|
|
2623
|
+
record = {
|
|
2624
|
+
"id": _new_record_id("s"),
|
|
2625
|
+
"url": url,
|
|
2626
|
+
"title": title or "Untitled",
|
|
2627
|
+
"key_points": points,
|
|
2628
|
+
"date_summarized": datetime.now(timezone.utc).isoformat(),
|
|
2629
|
+
"source": "cli",
|
|
2630
|
+
}
|
|
2631
|
+
summaries.append(record)
|
|
2632
|
+
print(f"- {record['title']}")
|
|
2633
|
+
print(f" URL: {url}")
|
|
2634
|
+
print(f" Date: {record['date_summarized']}")
|
|
2635
|
+
for point in points:
|
|
2636
|
+
print(f" * {point}")
|
|
2637
|
+
_write_action_trace(
|
|
2638
|
+
trace_dir=trace_dir,
|
|
2639
|
+
action_type="summarize_url",
|
|
2640
|
+
input_summary=url,
|
|
2641
|
+
output_summary=f"saved summary {record['id']}",
|
|
2642
|
+
duration_ms=(time.perf_counter() - item_start) * 1000,
|
|
2643
|
+
permission_level=permission_level,
|
|
2644
|
+
metadata={"summary_id": record["id"]},
|
|
2645
|
+
)
|
|
2646
|
+
|
|
2647
|
+
_save_records(_summaries_path(storage_dir), summaries)
|
|
2648
|
+
if failed and failed == len(args.urls):
|
|
2649
|
+
return 1
|
|
2650
|
+
return 0
|
|
2651
|
+
|
|
2652
|
+
|
|
2653
|
+
def cmd_summaries(args: argparse.Namespace) -> int:
|
|
2654
|
+
cfg_path = Path(args.config).expanduser()
|
|
2655
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2656
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
2657
|
+
storage_dir = _resolve_storage_dir(cfg, args.storage_dir)
|
|
2658
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
2659
|
+
start = time.perf_counter()
|
|
2660
|
+
|
|
2661
|
+
allowed, permission_level = _enforce_capability(
|
|
2662
|
+
cfg=cfg,
|
|
2663
|
+
capability="file_read",
|
|
2664
|
+
input_summary="list summaries",
|
|
2665
|
+
trace_dir=trace_dir,
|
|
2666
|
+
non_interactive=False,
|
|
2667
|
+
)
|
|
2668
|
+
if not allowed:
|
|
2669
|
+
_write_action_trace(
|
|
2670
|
+
trace_dir=trace_dir,
|
|
2671
|
+
action_type="summaries_list",
|
|
2672
|
+
input_summary="summaries list",
|
|
2673
|
+
output_summary="blocked by permission policy",
|
|
2674
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2675
|
+
permission_level=permission_level,
|
|
2676
|
+
status="blocked",
|
|
2677
|
+
)
|
|
2678
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2679
|
+
return 1
|
|
2680
|
+
|
|
2681
|
+
summaries = _load_records(_summaries_path(storage_dir))
|
|
2682
|
+
summaries.sort(key=lambda item: str(item.get("date_summarized", "")), reverse=True)
|
|
2683
|
+
if args.last and args.last > 0:
|
|
2684
|
+
summaries = summaries[: args.last]
|
|
2685
|
+
|
|
2686
|
+
if not summaries:
|
|
2687
|
+
print("No summaries found.")
|
|
2688
|
+
else:
|
|
2689
|
+
for item in summaries:
|
|
2690
|
+
summary_id = str(item.get("id", "?"))
|
|
2691
|
+
title = str(item.get("title", "Untitled"))
|
|
2692
|
+
url = str(item.get("url", ""))
|
|
2693
|
+
date_summarized = str(item.get("date_summarized", ""))
|
|
2694
|
+
print(f"{summary_id} {date_summarized} {title} ({url})")
|
|
2695
|
+
|
|
2696
|
+
_write_action_trace(
|
|
2697
|
+
trace_dir=trace_dir,
|
|
2698
|
+
action_type="summaries_list",
|
|
2699
|
+
input_summary=f"last={args.last}",
|
|
2700
|
+
output_summary=f"{len(summaries)} summaries",
|
|
2701
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2702
|
+
permission_level=permission_level,
|
|
2703
|
+
)
|
|
2704
|
+
return 0
|
|
2705
|
+
|
|
2706
|
+
|
|
2707
|
+
def cmd_plan(args: argparse.Namespace) -> int:
|
|
2708
|
+
cfg_path = Path(args.config).expanduser()
|
|
2709
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2710
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
2711
|
+
storage_dir = _resolve_storage_dir(cfg, args.storage_dir)
|
|
2712
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
2713
|
+
plans_path = _plans_path(storage_dir)
|
|
2714
|
+
plans = _load_records(plans_path)
|
|
2715
|
+
start = time.perf_counter()
|
|
2716
|
+
|
|
2717
|
+
allowed, permission_level = _enforce_capability(
|
|
2718
|
+
cfg=cfg,
|
|
2719
|
+
capability="file_write",
|
|
2720
|
+
input_summary="plan create/update",
|
|
2721
|
+
trace_dir=trace_dir,
|
|
2722
|
+
non_interactive=False,
|
|
2723
|
+
)
|
|
2724
|
+
if not allowed:
|
|
2725
|
+
_write_action_trace(
|
|
2726
|
+
trace_dir=trace_dir,
|
|
2727
|
+
action_type="plan_create",
|
|
2728
|
+
input_summary="plan command",
|
|
2729
|
+
output_summary="blocked by permission policy",
|
|
2730
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2731
|
+
permission_level=permission_level,
|
|
2732
|
+
status="blocked",
|
|
2733
|
+
)
|
|
2734
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2735
|
+
return 1
|
|
2736
|
+
|
|
2737
|
+
if args.edit:
|
|
2738
|
+
plan_id = str(args.edit).strip()
|
|
2739
|
+
if not plan_id:
|
|
2740
|
+
print("--edit requires a plan id.", file=sys.stderr)
|
|
2741
|
+
return 1
|
|
2742
|
+
target = None
|
|
2743
|
+
for item in plans:
|
|
2744
|
+
if str(item.get("id", "")).strip() == plan_id:
|
|
2745
|
+
target = item
|
|
2746
|
+
break
|
|
2747
|
+
if target is None:
|
|
2748
|
+
print(f"Plan not found: {plan_id}", file=sys.stderr)
|
|
2749
|
+
return 1
|
|
2750
|
+
|
|
2751
|
+
refinement = str(args.update or "").strip()
|
|
2752
|
+
if not refinement:
|
|
2753
|
+
if not sys.stdin.isatty():
|
|
2754
|
+
print("Provide --update in non-interactive mode.", file=sys.stderr)
|
|
2755
|
+
return 1
|
|
2756
|
+
refinement = input("Refinement request: ").strip()
|
|
2757
|
+
if not refinement:
|
|
2758
|
+
print("Refinement text cannot be empty.", file=sys.stderr)
|
|
2759
|
+
return 1
|
|
2760
|
+
|
|
2761
|
+
steps = _generate_plan_steps(str(target.get("objective", "")), refinement=refinement)
|
|
2762
|
+
target["steps"] = steps
|
|
2763
|
+
target["dependencies"] = _derive_plan_dependencies(str(target.get("objective", "")))
|
|
2764
|
+
target["estimated_complexity"] = _estimate_plan_complexity(str(target.get("objective", "")))
|
|
2765
|
+
revisions = target.setdefault("revisions", [])
|
|
2766
|
+
if not isinstance(revisions, list):
|
|
2767
|
+
revisions = []
|
|
2768
|
+
target["revisions"] = revisions
|
|
2769
|
+
revisions.append(
|
|
2770
|
+
{
|
|
2771
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
2772
|
+
"refinement": refinement,
|
|
2773
|
+
}
|
|
2774
|
+
)
|
|
2775
|
+
target["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
2776
|
+
_save_records(plans_path, plans)
|
|
2777
|
+
print(f"Updated plan {plan_id}")
|
|
2778
|
+
_write_action_trace(
|
|
2779
|
+
trace_dir=trace_dir,
|
|
2780
|
+
action_type="plan_update",
|
|
2781
|
+
input_summary=f"plan_id={plan_id}",
|
|
2782
|
+
output_summary="plan updated",
|
|
2783
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2784
|
+
permission_level=permission_level,
|
|
2785
|
+
metadata={"plan_id": plan_id},
|
|
2786
|
+
)
|
|
2787
|
+
return 0
|
|
2788
|
+
|
|
2789
|
+
goal = str(args.goal or "").strip()
|
|
2790
|
+
if not goal:
|
|
2791
|
+
print("Goal is required. Example: gaia plan \"Set up a personal knowledge base\".", file=sys.stderr)
|
|
2792
|
+
return 1
|
|
2793
|
+
|
|
2794
|
+
plan_id = _new_record_id("p")
|
|
2795
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
2796
|
+
plan_record = {
|
|
2797
|
+
"id": plan_id,
|
|
2798
|
+
"objective": goal,
|
|
2799
|
+
"steps": _generate_plan_steps(goal),
|
|
2800
|
+
"estimated_complexity": _estimate_plan_complexity(goal),
|
|
2801
|
+
"dependencies": _derive_plan_dependencies(goal),
|
|
2802
|
+
"created_at": now,
|
|
2803
|
+
"updated_at": now,
|
|
2804
|
+
"revisions": [{"timestamp": now, "refinement": "initial"}],
|
|
2805
|
+
"source": "cli",
|
|
2806
|
+
}
|
|
2807
|
+
plans.append(plan_record)
|
|
2808
|
+
_save_records(plans_path, plans)
|
|
2809
|
+
print(f"Plan {plan_id}")
|
|
2810
|
+
print(f"Objective: {plan_record['objective']}")
|
|
2811
|
+
print(f"Complexity: {plan_record['estimated_complexity']}")
|
|
2812
|
+
print("Dependencies:")
|
|
2813
|
+
for dependency in plan_record["dependencies"]:
|
|
2814
|
+
print(f"- {dependency}")
|
|
2815
|
+
print("Steps:")
|
|
2816
|
+
for idx, step in enumerate(plan_record["steps"], start=1):
|
|
2817
|
+
print(f"{idx}. {step}")
|
|
2818
|
+
|
|
2819
|
+
_write_action_trace(
|
|
2820
|
+
trace_dir=trace_dir,
|
|
2821
|
+
action_type="plan_create",
|
|
2822
|
+
input_summary=goal,
|
|
2823
|
+
output_summary=f"created {plan_id}",
|
|
2824
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2825
|
+
permission_level=permission_level,
|
|
2826
|
+
metadata={"plan_id": plan_id},
|
|
2827
|
+
)
|
|
2828
|
+
return 0
|
|
2829
|
+
|
|
2830
|
+
|
|
2831
|
+
def cmd_plans(args: argparse.Namespace) -> int:
|
|
2832
|
+
cfg_path = Path(args.config).expanduser()
|
|
2833
|
+
cfg = _ensure_config_exists(cfg_path)
|
|
2834
|
+
trace_dir = _resolve_trace_dir(cfg, args.trace_dir)
|
|
2835
|
+
storage_dir = _resolve_storage_dir(cfg, args.storage_dir)
|
|
2836
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
2837
|
+
start = time.perf_counter()
|
|
2838
|
+
|
|
2839
|
+
allowed, permission_level = _enforce_capability(
|
|
2840
|
+
cfg=cfg,
|
|
2841
|
+
capability="file_read",
|
|
2842
|
+
input_summary="list plans",
|
|
2843
|
+
trace_dir=trace_dir,
|
|
2844
|
+
non_interactive=False,
|
|
2845
|
+
)
|
|
2846
|
+
if not allowed:
|
|
2847
|
+
_write_action_trace(
|
|
2848
|
+
trace_dir=trace_dir,
|
|
2849
|
+
action_type="plans_list",
|
|
2850
|
+
input_summary="plans list",
|
|
2851
|
+
output_summary="blocked by permission policy",
|
|
2852
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2853
|
+
permission_level=permission_level,
|
|
2854
|
+
status="blocked",
|
|
2855
|
+
)
|
|
2856
|
+
print("Action blocked by capability policy.", file=sys.stderr)
|
|
2857
|
+
return 1
|
|
2858
|
+
|
|
2859
|
+
plans = _load_records(_plans_path(storage_dir))
|
|
2860
|
+
plans.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
|
|
2861
|
+
if args.last and args.last > 0:
|
|
2862
|
+
plans = plans[: args.last]
|
|
2863
|
+
|
|
2864
|
+
if not plans:
|
|
2865
|
+
print("No plans found.")
|
|
2866
|
+
else:
|
|
2867
|
+
for plan in plans:
|
|
2868
|
+
plan_id = str(plan.get("id", "?"))
|
|
2869
|
+
objective = _summarize_text(plan.get("objective", ""), max_chars=100)
|
|
2870
|
+
complexity = str(plan.get("estimated_complexity", "?"))
|
|
2871
|
+
updated_at = str(plan.get("updated_at", ""))
|
|
2872
|
+
print(f"{plan_id} {complexity:<6} {updated_at} {objective}")
|
|
2873
|
+
|
|
2874
|
+
_write_action_trace(
|
|
2875
|
+
trace_dir=trace_dir,
|
|
2876
|
+
action_type="plans_list",
|
|
2877
|
+
input_summary=f"last={args.last}",
|
|
2878
|
+
output_summary=f"{len(plans)} plans",
|
|
2879
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
2880
|
+
permission_level=permission_level,
|
|
2881
|
+
)
|
|
2882
|
+
return 0
|
|
2883
|
+
|
|
2884
|
+
|
|
732
2885
|
def cmd_run(args: argparse.Namespace) -> int:
|
|
733
2886
|
cfg_path = Path(args.config).expanduser()
|
|
2887
|
+
state_dir = Path(args.state_dir).expanduser()
|
|
734
2888
|
try:
|
|
735
2889
|
cfg = _ensure_config_exists(cfg_path)
|
|
736
2890
|
except PermissionError:
|
|
@@ -740,6 +2894,15 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|
|
740
2894
|
file=sys.stderr,
|
|
741
2895
|
)
|
|
742
2896
|
return 1
|
|
2897
|
+
try:
|
|
2898
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
2899
|
+
except PermissionError:
|
|
2900
|
+
print(
|
|
2901
|
+
"Cannot create runtime state directory due to permissions. "
|
|
2902
|
+
"Set GAIA_ASSISTANT_HOME or pass --state-dir to a writable path.",
|
|
2903
|
+
file=sys.stderr,
|
|
2904
|
+
)
|
|
2905
|
+
return 1
|
|
743
2906
|
|
|
744
2907
|
cmd = [
|
|
745
2908
|
"python3",
|
|
@@ -755,8 +2918,37 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|
|
755
2918
|
cmd.append("--verbose")
|
|
756
2919
|
|
|
757
2920
|
env = os.environ.copy()
|
|
2921
|
+
secret_store = _resolve_secret_store(cfg_path, args.secret_store)
|
|
2922
|
+
loaded_from_secret: List[str] = []
|
|
2923
|
+
for env_var in ("ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"):
|
|
2924
|
+
if env.get(env_var, "").strip():
|
|
2925
|
+
continue
|
|
2926
|
+
stored = _read_secret_api_key(secret_store, env_var)
|
|
2927
|
+
if stored:
|
|
2928
|
+
env[env_var] = stored
|
|
2929
|
+
loaded_from_secret.append(env_var)
|
|
2930
|
+
|
|
2931
|
+
reasoning_cfg = cfg.get("reasoning", {})
|
|
2932
|
+
reasoning_cfg = reasoning_cfg if isinstance(reasoning_cfg, dict) else {}
|
|
2933
|
+
profile_cfg = cfg.get("profile", {})
|
|
2934
|
+
profile_cfg = profile_cfg if isinstance(profile_cfg, dict) else {}
|
|
2935
|
+
profile_provider = str(profile_cfg.get("default_provider", "")).strip()
|
|
2936
|
+
configured_provider = str(reasoning_cfg.get("provider", "")).strip()
|
|
2937
|
+
configured_model = str(reasoning_cfg.get("model", "")).strip()
|
|
2938
|
+
effective_provider = str(args.reasoning_provider or configured_provider or profile_provider).strip()
|
|
2939
|
+
effective_model = str(args.reasoning_model or configured_model).strip()
|
|
2940
|
+
|
|
758
2941
|
if args.track != "auto":
|
|
759
2942
|
env["GAIA_ACTIVE_TRACK_OVERRIDE"] = args.track
|
|
2943
|
+
if effective_provider:
|
|
2944
|
+
env["GAIA_REASONING_PROVIDER"] = effective_provider
|
|
2945
|
+
if effective_model:
|
|
2946
|
+
env["GAIA_REASONING_MODEL"] = effective_model
|
|
2947
|
+
env["GAIA_AGENT_MEMORY_DIR"] = str(state_dir)
|
|
2948
|
+
env["GAIA_ASSISTANT_VERBOSITY"] = str(profile_cfg.get("verbosity", "balanced")).strip() or "balanced"
|
|
2949
|
+
profile_tz = str(profile_cfg.get("timezone", "UTC")).strip()
|
|
2950
|
+
if profile_tz:
|
|
2951
|
+
env["TZ"] = profile_tz
|
|
760
2952
|
|
|
761
2953
|
runtime_cfg = cfg.get("runtime", {})
|
|
762
2954
|
if args.mode == "continuous" and "interval_minutes" in runtime_cfg:
|
|
@@ -765,6 +2957,21 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|
|
765
2957
|
print(f"Running Gaia assistant in {args.mode} mode")
|
|
766
2958
|
if args.track != "auto":
|
|
767
2959
|
print(f"Track override: {args.track}")
|
|
2960
|
+
if args.reasoning_provider:
|
|
2961
|
+
print(f"Reasoning provider override: {args.reasoning_provider}")
|
|
2962
|
+
elif effective_provider:
|
|
2963
|
+
print(f"Reasoning provider: {effective_provider} (from launcher config)")
|
|
2964
|
+
if args.reasoning_model:
|
|
2965
|
+
print(f"Reasoning model override: {args.reasoning_model}")
|
|
2966
|
+
elif effective_model:
|
|
2967
|
+
print(f"Reasoning model: {effective_model} (from launcher config)")
|
|
2968
|
+
profile_name = str(profile_cfg.get("name", "")).strip()
|
|
2969
|
+
if profile_name:
|
|
2970
|
+
print(f"Profile name: {profile_name}")
|
|
2971
|
+
print(f"Profile verbosity: {env['GAIA_ASSISTANT_VERBOSITY']}")
|
|
2972
|
+
print(f"Profile timezone: {profile_tz or 'UTC'}")
|
|
2973
|
+
if loaded_from_secret:
|
|
2974
|
+
print(f"Loaded API credentials from secret store: {', '.join(loaded_from_secret)}")
|
|
768
2975
|
|
|
769
2976
|
active_profile = cfg.get("auth", {}).get("active_profile")
|
|
770
2977
|
if isinstance(active_profile, dict):
|
|
@@ -790,7 +2997,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
790
2997
|
init.add_argument("--force", action="store_true", help="Overwrite config if it exists")
|
|
791
2998
|
init.set_defaults(func=cmd_init)
|
|
792
2999
|
|
|
793
|
-
onboard = sub.add_parser("onboard", help="Guided onboarding
|
|
3000
|
+
onboard = sub.add_parser("onboard", help="Guided provider onboarding (OAuth or API key)")
|
|
794
3001
|
onboard.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
795
3002
|
onboard.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR), help="Local state directory")
|
|
796
3003
|
onboard.add_argument(
|
|
@@ -803,6 +3010,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
803
3010
|
default=None,
|
|
804
3011
|
help="Path to Codex auth.json (optional override)",
|
|
805
3012
|
)
|
|
3013
|
+
onboard.add_argument(
|
|
3014
|
+
"--provider",
|
|
3015
|
+
choices=list(ONBOARD_PROVIDER_CHOICES),
|
|
3016
|
+
default=None,
|
|
3017
|
+
help="Provider to onboard (interactive if omitted)",
|
|
3018
|
+
)
|
|
3019
|
+
onboard.add_argument(
|
|
3020
|
+
"--api-key",
|
|
3021
|
+
default=None,
|
|
3022
|
+
help="API key for API-key providers (openrouter/openai/anthropic)",
|
|
3023
|
+
)
|
|
3024
|
+
onboard.add_argument(
|
|
3025
|
+
"--model",
|
|
3026
|
+
default=None,
|
|
3027
|
+
help="Reasoning model to set during onboarding",
|
|
3028
|
+
)
|
|
3029
|
+
onboard.add_argument(
|
|
3030
|
+
"--secret-store",
|
|
3031
|
+
default=None,
|
|
3032
|
+
help="Path to local secrets.json store (optional override)",
|
|
3033
|
+
)
|
|
3034
|
+
onboard.add_argument(
|
|
3035
|
+
"--no-store-api-key",
|
|
3036
|
+
action="store_true",
|
|
3037
|
+
help="Do not store API key in local secret store",
|
|
3038
|
+
)
|
|
806
3039
|
onboard.add_argument("--yes", action="store_true", help="Skip onboarding confirmations")
|
|
807
3040
|
onboard.set_defaults(func=cmd_onboard)
|
|
808
3041
|
|
|
@@ -810,6 +3043,97 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
810
3043
|
doctor.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
811
3044
|
doctor.set_defaults(func=cmd_doctor)
|
|
812
3045
|
|
|
3046
|
+
config = sub.add_parser("config", help="Read or write local assistant preferences")
|
|
3047
|
+
config_sub = config.add_subparsers(dest="config_command", required=True)
|
|
3048
|
+
|
|
3049
|
+
config_set = config_sub.add_parser("set", help="Set a preference value")
|
|
3050
|
+
config_set.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3051
|
+
config_set.add_argument("key", choices=sorted(PROFILE_KEY_MAP.keys()), help="Preference key")
|
|
3052
|
+
config_set.add_argument("value", help="Preference value")
|
|
3053
|
+
config_set.set_defaults(func=cmd_config_set)
|
|
3054
|
+
|
|
3055
|
+
config_get = config_sub.add_parser("get", help="Get a preference value")
|
|
3056
|
+
config_get.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3057
|
+
config_get.add_argument("key", choices=sorted(PROFILE_KEY_MAP.keys()), help="Preference key")
|
|
3058
|
+
config_get.set_defaults(func=cmd_config_get)
|
|
3059
|
+
|
|
3060
|
+
capability = sub.add_parser("capability", help="Inspect or override capability permissions")
|
|
3061
|
+
capability_sub = capability.add_subparsers(dest="capability_command", required=True)
|
|
3062
|
+
|
|
3063
|
+
capability_list = capability_sub.add_parser("list", help="List effective capability levels")
|
|
3064
|
+
capability_list.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3065
|
+
capability_list.set_defaults(func=cmd_capability_list)
|
|
3066
|
+
|
|
3067
|
+
capability_set = capability_sub.add_parser("set", help="Set local capability override")
|
|
3068
|
+
capability_set.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3069
|
+
capability_set.add_argument("capability", help="Capability name (for example: shell_exec)")
|
|
3070
|
+
capability_set.add_argument("level", choices=list(PERMISSION_LEVEL_CHOICES), help="Permission level")
|
|
3071
|
+
capability_set.set_defaults(func=cmd_capability_set)
|
|
3072
|
+
|
|
3073
|
+
traces = sub.add_parser("traces", help="Show structured action traces")
|
|
3074
|
+
traces.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3075
|
+
traces.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3076
|
+
traces.add_argument("--last", type=int, default=20, help="Number of recent trace entries to show")
|
|
3077
|
+
traces.add_argument("--type", default=None, help="Filter by action type")
|
|
3078
|
+
traces.set_defaults(func=cmd_traces)
|
|
3079
|
+
|
|
3080
|
+
chat = sub.add_parser("chat", help="Start an interactive Gaia chat session")
|
|
3081
|
+
chat.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3082
|
+
chat.add_argument("--secret-store", default=None, help="Path to local secrets.json store (optional override)")
|
|
3083
|
+
chat.add_argument("--session-dir", default=None, help="Session directory override")
|
|
3084
|
+
chat.add_argument("--storage-dir", default=None, help="Data storage directory override")
|
|
3085
|
+
chat.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3086
|
+
chat.add_argument("--resume", default=None, help="Session id or 'last'")
|
|
3087
|
+
chat.add_argument("--max-context-turns", type=int, default=None, help="Override max context turns")
|
|
3088
|
+
chat.set_defaults(func=cmd_chat)
|
|
3089
|
+
|
|
3090
|
+
note = sub.add_parser("note", help="Capture a note or task")
|
|
3091
|
+
note.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3092
|
+
note.add_argument("--storage-dir", default=None, help="Data storage directory override")
|
|
3093
|
+
note.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3094
|
+
note.add_argument("--task", action="store_true", help="Store as task instead of note")
|
|
3095
|
+
note.add_argument("text", help="Note/task text")
|
|
3096
|
+
note.set_defaults(func=cmd_note)
|
|
3097
|
+
|
|
3098
|
+
tasks = sub.add_parser("tasks", help="List saved tasks")
|
|
3099
|
+
tasks.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3100
|
+
tasks.add_argument("--storage-dir", default=None, help="Data storage directory override")
|
|
3101
|
+
tasks.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3102
|
+
tasks.add_argument("--q", default=None, help="Filter tasks by keyword")
|
|
3103
|
+
tasks.add_argument("--since", default=None, help="Filter tasks created since YYYY-MM-DD")
|
|
3104
|
+
tasks.add_argument("--status", choices=["open", "done", "all"], default="open", help="Filter by task status")
|
|
3105
|
+
tasks.set_defaults(func=cmd_tasks)
|
|
3106
|
+
|
|
3107
|
+
summarize = sub.add_parser("summarize", help="Fetch and summarize one or more URLs")
|
|
3108
|
+
summarize.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3109
|
+
summarize.add_argument("--storage-dir", default=None, help="Data storage directory override")
|
|
3110
|
+
summarize.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3111
|
+
summarize.add_argument("urls", nargs="+", help="One or more URLs to summarize")
|
|
3112
|
+
summarize.set_defaults(func=cmd_summarize)
|
|
3113
|
+
|
|
3114
|
+
summaries = sub.add_parser("summaries", help="List saved URL summaries")
|
|
3115
|
+
summaries.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3116
|
+
summaries.add_argument("--storage-dir", default=None, help="Data storage directory override")
|
|
3117
|
+
summaries.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3118
|
+
summaries.add_argument("--last", type=int, default=20, help="Number of entries to show")
|
|
3119
|
+
summaries.set_defaults(func=cmd_summaries)
|
|
3120
|
+
|
|
3121
|
+
plan = sub.add_parser("plan", help="Generate or refine an actionable plan from a goal")
|
|
3122
|
+
plan.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3123
|
+
plan.add_argument("--storage-dir", default=None, help="Data storage directory override")
|
|
3124
|
+
plan.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3125
|
+
plan.add_argument("--edit", default=None, help="Existing plan id to refine")
|
|
3126
|
+
plan.add_argument("--update", default=None, help="Refinement instructions for --edit")
|
|
3127
|
+
plan.add_argument("goal", nargs="?", help="Goal description")
|
|
3128
|
+
plan.set_defaults(func=cmd_plan)
|
|
3129
|
+
|
|
3130
|
+
plans = sub.add_parser("plans", help="List saved plans")
|
|
3131
|
+
plans.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Config JSON path")
|
|
3132
|
+
plans.add_argument("--storage-dir", default=None, help="Data storage directory override")
|
|
3133
|
+
plans.add_argument("--trace-dir", default=None, help="Trace directory override")
|
|
3134
|
+
plans.add_argument("--last", type=int, default=20, help="Number of entries to show")
|
|
3135
|
+
plans.set_defaults(func=cmd_plans)
|
|
3136
|
+
|
|
813
3137
|
auth = sub.add_parser("auth", help="Manage OAuth profile linkage for Gaia assistant")
|
|
814
3138
|
auth_sub = auth.add_subparsers(dest="auth_command", required=True)
|
|
815
3139
|
|
|
@@ -876,8 +3200,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
876
3200
|
|
|
877
3201
|
run = sub.add_parser("run", help="Run Gaia assistant loop")
|
|
878
3202
|
run.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Launcher config JSON path")
|
|
3203
|
+
run.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR), help="Local state directory for memory files")
|
|
3204
|
+
run.add_argument(
|
|
3205
|
+
"--secret-store",
|
|
3206
|
+
default=None,
|
|
3207
|
+
help="Path to local secrets.json store (optional override)",
|
|
3208
|
+
)
|
|
879
3209
|
run.add_argument("--mode", choices=["single", "continuous"], default="single")
|
|
880
3210
|
run.add_argument("--track", choices=["auto", "assistant", "framework"], default="auto")
|
|
3211
|
+
run.add_argument(
|
|
3212
|
+
"--reasoning-provider",
|
|
3213
|
+
choices=["anthropic", "openai", "openrouter"],
|
|
3214
|
+
default=None,
|
|
3215
|
+
help="Override reasoning provider for this run",
|
|
3216
|
+
)
|
|
3217
|
+
run.add_argument(
|
|
3218
|
+
"--reasoning-model",
|
|
3219
|
+
default=None,
|
|
3220
|
+
help="Override reasoning model for this run",
|
|
3221
|
+
)
|
|
881
3222
|
run.add_argument("--dry-run", action="store_true", help="Plan only, do not execute actions")
|
|
882
3223
|
run.add_argument("--verbose", action="store_true", help="Verbose logs")
|
|
883
3224
|
run.set_defaults(func=cmd_run)
|