@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.
@@ -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": True,
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
- print("[warn] no linked OAuth profile in launcher config")
466
- print(f" run `{_launcher_hint()} onboard`")
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
- print("Auth flow selected: Gaia native profile store + Codex web OAuth broker")
514
- print("This opens a browser/device auth flow through Codex CLI.")
515
- print("Tokens are copied into Gaia local auth store (outside this repository).")
516
- sys.stdout.flush()
517
-
518
- proceed = "y"
519
- if not args.yes:
520
- proceed = input("Start web OAuth login now? [Y/n]: ").strip().lower()
521
- if proceed in ("n", "no"):
522
- print("Skipped OAuth login. Run this later:")
523
- print(f" {_launcher_hint()} auth login --provider openai-codex")
524
- return 0
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
- login_args = argparse.Namespace(
527
- config=str(cfg_path),
528
- provider="openai-codex",
529
- source="codex-cli",
530
- codex_auth_path=args.codex_auth_path,
531
- gaia_auth_store=args.gaia_auth_store,
532
- openclaw_agent="main",
533
- openclaw_auth_store=None,
534
- no_prompt=True,
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
- return cmd_auth_login(login_args)
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 with Gaia-native OAuth linking")
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)