caudate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
core/anthropic_auth.py ADDED
@@ -0,0 +1,91 @@
1
+ """Subscription-OAuth helper for Cognos's web UI calls to Anthropic.
2
+
3
+ Background
4
+ ----------
5
+ Claude Code (the CLI) authenticates to Anthropic with an OAuth Bearer
6
+ token stored at ``~/.claude/.credentials.json``. The Cognos passthrough
7
+ proxy at ``/v1/messages`` already forwards that token verbatim from
8
+ incoming Claude Code requests — it never has to read the file itself.
9
+
10
+ The web UI is a different story: it calls Cognos's own ``/chat`` endpoint,
11
+ which goes through ``LLMProvider`` → LiteLLM → Anthropic. LiteLLM expects
12
+ ``ANTHROPIC_API_KEY`` in the environment. The user has a subscription, not
13
+ an API key — so without help, the web UI silently fails on Opus.
14
+
15
+ Scope
16
+ -----
17
+ This helper is intentionally **scoped to the web UI only**. It uses a
18
+ ``ContextVar`` set by the ``/chat`` and ``/chat/stream`` endpoints, so
19
+ that the same ``LLMProvider`` instance behaves normally for every other
20
+ caller (background agentic loops, voice, MCP). Other code paths that hit
21
+ Anthropic without a real API key will still fail with the original auth
22
+ error — that's the desired blast radius.
23
+
24
+ Token freshness
25
+ ---------------
26
+ We read the credentials file on every call rather than caching, since
27
+ Claude Code may rotate the access token. The file is ~470 bytes; the
28
+ parse cost is negligible next to the network round-trip to Anthropic.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ from contextlib import contextmanager
36
+ from contextvars import ContextVar
37
+ from pathlib import Path
38
+ from typing import Iterator
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ _CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
44
+
45
+ # Set by web-UI endpoints during their request scope. LLMProvider checks
46
+ # this; if False (the default), behaviour is identical to before.
47
+ _use_subscription: ContextVar[bool] = ContextVar(
48
+ "cognos_use_subscription_auth", default=False,
49
+ )
50
+
51
+
52
+ def is_active() -> bool:
53
+ """True if the current async-context is allowed to use the subscription."""
54
+ return _use_subscription.get()
55
+
56
+
57
+ @contextmanager
58
+ def subscription_auth_scope() -> Iterator[None]:
59
+ """Enable subscription-OAuth auth for the duration of the ``with`` block.
60
+
61
+ Usage (inside a request handler)::
62
+
63
+ with subscription_auth_scope():
64
+ reply = await agent.chat(...)
65
+ """
66
+ token = _use_subscription.set(True)
67
+ try:
68
+ yield
69
+ finally:
70
+ _use_subscription.reset(token)
71
+
72
+
73
+ def read_subscription_token() -> str | None:
74
+ """Read the current Claude Code OAuth access token, or None if missing.
75
+
76
+ Returns None on any failure (file missing, bad JSON, missing key) so
77
+ callers can fall through gracefully rather than crash.
78
+ """
79
+ try:
80
+ with _CREDENTIALS_PATH.open() as f:
81
+ data = json.load(f)
82
+ except (OSError, json.JSONDecodeError) as e:
83
+ logger.debug(f"could not read {_CREDENTIALS_PATH}: {e}")
84
+ return None
85
+ oauth = data.get("claudeAiOauth")
86
+ if not isinstance(oauth, dict):
87
+ return None
88
+ token = oauth.get("accessToken")
89
+ if not isinstance(token, str) or not token:
90
+ return None
91
+ return token
core/background.py ADDED
@@ -0,0 +1,113 @@
1
+ """Background task management — run agent turns asynchronously.
2
+
3
+ A background task is a CognosAgent.chat call that's been kicked off
4
+ without blocking the REPL. The pool tracks each task by id, exposes
5
+ status (pending/running/done/failed), and stores the final reply so the
6
+ user can `watch` it later.
7
+
8
+ This is intentionally process-local: when the CLI exits, in-flight
9
+ tasks are abandoned. Persistence would require a separate worker
10
+ process or supervisor — outside scope of a UX feature.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import time
18
+ import uuid
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Awaitable, Callable
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class BackgroundTask:
27
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
28
+ label: str = ""
29
+ status: str = "pending" # pending | running | done | failed | cancelled
30
+ started_at: float = 0.0
31
+ finished_at: float = 0.0
32
+ result: str | None = None
33
+ error: str | None = None
34
+ _task: asyncio.Task | None = field(default=None, repr=False)
35
+
36
+ @property
37
+ def duration(self) -> float:
38
+ if not self.started_at:
39
+ return 0.0
40
+ end = self.finished_at or time.time()
41
+ return end - self.started_at
42
+
43
+
44
+ class BackgroundTaskPool:
45
+ """In-process pool of CognosAgent invocations."""
46
+
47
+ def __init__(self) -> None:
48
+ self._tasks: dict[str, BackgroundTask] = {}
49
+
50
+ def submit(
51
+ self,
52
+ coro_factory: Callable[[], Awaitable[Any]],
53
+ label: str = "",
54
+ ) -> BackgroundTask:
55
+ """Schedule `coro_factory()` on the running loop.
56
+
57
+ Must be called from within a running asyncio loop. The factory
58
+ is invoked once, immediately, and the resulting awaitable is
59
+ wrapped in a Task. The BackgroundTask handle is returned
60
+ synchronously so the caller can show an id.
61
+ """
62
+ bg = BackgroundTask(label=label)
63
+ bg.status = "running"
64
+ bg.started_at = time.time()
65
+ loop = asyncio.get_running_loop()
66
+
67
+ async def _runner() -> None:
68
+ try:
69
+ result = await coro_factory()
70
+ bg.result = str(result) if result is not None else ""
71
+ bg.status = "done"
72
+ except asyncio.CancelledError:
73
+ bg.status = "cancelled"
74
+ raise
75
+ except Exception as e:
76
+ bg.error = str(e)
77
+ bg.status = "failed"
78
+ logger.exception(f"Background task {bg.id} failed: {e}")
79
+ finally:
80
+ bg.finished_at = time.time()
81
+
82
+ bg._task = loop.create_task(_runner(), name=f"cognos-bg-{bg.id}")
83
+ self._tasks[bg.id] = bg
84
+ return bg
85
+
86
+ def get(self, task_id: str) -> BackgroundTask | None:
87
+ return self._tasks.get(task_id)
88
+
89
+ def list(self) -> list[BackgroundTask]:
90
+ return sorted(self._tasks.values(), key=lambda t: t.started_at, reverse=True)
91
+
92
+ def cancel(self, task_id: str) -> bool:
93
+ bg = self._tasks.get(task_id)
94
+ if bg is None or bg._task is None or bg._task.done():
95
+ return False
96
+ bg._task.cancel()
97
+ return True
98
+
99
+ def prune_finished(self) -> int:
100
+ before = len(self._tasks)
101
+ self._tasks = {
102
+ tid: t for tid, t in self._tasks.items()
103
+ if t.status in ("pending", "running")
104
+ }
105
+ return before - len(self._tasks)
106
+
107
+
108
+ # Process-wide pool — mirrors usage tracker.
109
+ _GLOBAL = BackgroundTaskPool()
110
+
111
+
112
+ def get_global_pool() -> BackgroundTaskPool:
113
+ return _GLOBAL
core/banner.py ADDED
@@ -0,0 +1,134 @@
1
+ """Branded boot banner for the Cognos CLI.
2
+
3
+ Lands on stdout when `cognos` (or `cognos interactive`) starts so the
4
+ launch feels like a real CLI tool — ASCII logo, brain status, a one-
5
+ line tip on slash commands. Replaces the prior ad-hoc `console.print`
6
+ blob in `main.py:interactive`.
7
+
8
+ The banner is plain Rich — no Textual, no full-screen takeover — so
9
+ piping (`cognos -p ...`) and non-TTY contexts still work cleanly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from rich.align import Align
17
+ from rich.console import Console, Group
18
+ from rich.panel import Panel
19
+ from rich.text import Text
20
+
21
+
22
+ _LOGO = r"""
23
+ ____
24
+ / ___|___ __ _ _ __ ___ ___
25
+ | | / _ \ / _` | '_ \ / _ \/ __|
26
+ | |__| (_) | (_| | | | | (_) \__ \
27
+ \____\___/ \__, |_| |_|\___/|___/
28
+ |___/
29
+ """.rstrip("\n").lstrip("\n")
30
+
31
+
32
+ def _version() -> str:
33
+ try:
34
+ from importlib.metadata import version
35
+ return version("cognos")
36
+ except Exception:
37
+ return "0.1.0"
38
+
39
+
40
+ def _brain_summary(agent: Any) -> Text:
41
+ """Two-line summary of which models are wired in."""
42
+ from llm.router import DualLLMProvider
43
+ t = Text()
44
+ if isinstance(agent.llm, DualLLMProvider):
45
+ t.append("S1 ", style="bold cyan")
46
+ t.append(getattr(agent.llm_fast, "model", "?"), style="white")
47
+ t.append(" S2 ", style="bold magenta")
48
+ t.append(getattr(agent.llm_slow, "model", "?"), style="white")
49
+ else:
50
+ t.append("model ", style="bold")
51
+ t.append(getattr(agent.llm, "model", "?"), style="white")
52
+ return t
53
+
54
+
55
+ def _caudate_summary(agent: Any) -> Text | None:
56
+ """Caudate router trust + active model — only if she's loaded."""
57
+ t = Text()
58
+ try:
59
+ caudate = getattr(agent, "caudate", None)
60
+ if caudate is None:
61
+ return None
62
+ trust = getattr(caudate, "trust_level", None) or getattr(caudate, "trust", None) or "whisper"
63
+ params = getattr(caudate, "n_params", None)
64
+ t.append("caudate ", style="bold green")
65
+ t.append(str(trust).lower(), style="white")
66
+ if params:
67
+ t.append(f" {params/1e6:.2f}M params", style="dim")
68
+ return t
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def _session_summary(agent: Any, resumed: bool) -> Text:
74
+ t = Text()
75
+ try:
76
+ sid = agent.session.id
77
+ n = len(agent.session.messages)
78
+ except Exception:
79
+ sid, n = "?", 0
80
+ if resumed:
81
+ t.append("resumed ", style="yellow")
82
+ t.append(f"{sid[:8]} · {n} messages", style="dim")
83
+ else:
84
+ t.append("new session ", style="green")
85
+ t.append(sid[:8], style="dim")
86
+ return t
87
+
88
+
89
+ def print_startup_banner(
90
+ agent: Any,
91
+ console: Console,
92
+ *,
93
+ resumed: bool = False,
94
+ extras: list[str] | None = None,
95
+ ) -> None:
96
+ """Render the boot screen.
97
+
98
+ `extras` is a list of mode flags shown as a footer (e.g. ["streaming",
99
+ "thinking"]). Empty list / None hides that line.
100
+ """
101
+ logo = Text(_LOGO, style="bold cyan")
102
+ version_line = Text()
103
+ version_line.append(f"v{_version()}", style="dim")
104
+ version_line.append(" · ", style="dim")
105
+ version_line.append("local-first cognitive agent", style="italic dim")
106
+
107
+ rows: list[Any] = [
108
+ Align.center(logo),
109
+ Align.center(version_line),
110
+ Text(""),
111
+ _brain_summary(agent),
112
+ ]
113
+ caudate = _caudate_summary(agent)
114
+ if caudate is not None:
115
+ rows.append(caudate)
116
+ rows.append(_session_summary(agent, resumed))
117
+ if extras:
118
+ flags = Text(" ".join(extras), style="dim italic")
119
+ rows.append(flags)
120
+ rows.append(Text(""))
121
+
122
+ tip = Text()
123
+ tip.append("type ", style="dim")
124
+ tip.append("/", style="bold")
125
+ tip.append(" for the command palette · ", style="dim")
126
+ tip.append("/quit", style="bold")
127
+ tip.append(" to exit", style="dim")
128
+ rows.append(tip)
129
+
130
+ console.print(Panel(
131
+ Group(*rows),
132
+ border_style="cyan",
133
+ padding=(1, 2),
134
+ ))
core/bootstrap.py ADDED
@@ -0,0 +1,292 @@
1
+ """First-run bootstrap + health checks.
2
+
3
+ A stranger who runs `pip install cognos` shouldn't have to know about
4
+ Ollama, HuggingFace, Anthropic keys, or `data/nn/caudate.pt`. This
5
+ module covers the cold-start path:
6
+
7
+ - `cognos init` — interactive wizard: picks system1/system2, downloads
8
+ Caudate weights, writes `~/.cognos/settings.json`,
9
+ creates a starter sandbox directory.
10
+ - `cognos doctor` — read-only diagnostic: which dependencies are wired,
11
+ which are missing, exact one-line fixes per gap.
12
+
13
+ Caudate weights live on HuggingFace as a public model repo (set via the
14
+ `COGNOS_CAUDATE_HF_REPO` env var, defaulting to `raveuk/cognos-caudate`).
15
+ The repo is expected to contain `caudate.pt` and `caudate.meta.json`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import shutil
23
+ import socket
24
+ import subprocess
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ from rich.console import Console
30
+ from rich.panel import Panel
31
+ from rich.prompt import Confirm, Prompt
32
+ from rich.table import Table
33
+
34
+
35
+ _DEFAULT_HF_REPO = "raveuk/cognos-caudate"
36
+ _CAUDATE_FILES = ("caudate.pt", "caudate.meta.json")
37
+
38
+
39
+ # ---------------------------------------------------------------------
40
+ # Locations
41
+ # ---------------------------------------------------------------------
42
+ def cognos_home() -> Path:
43
+ """`~/.cognos`. Created on demand."""
44
+ p = Path(os.path.expanduser("~")) / ".cognos"
45
+ p.mkdir(parents=True, exist_ok=True)
46
+ return p
47
+
48
+
49
+ def settings_path() -> Path:
50
+ return cognos_home() / "settings.json"
51
+
52
+
53
+ def caudate_dir() -> Path:
54
+ """Where Caudate weights live for an installed cognos.
55
+
56
+ Repo checkouts have `<repo>/data/nn/caudate.pt`; pip-installed users
57
+ get `~/.cognos/nn/caudate.pt`.
58
+ """
59
+ p = cognos_home() / "nn"
60
+ p.mkdir(parents=True, exist_ok=True)
61
+ return p
62
+
63
+
64
+ def is_first_run() -> bool:
65
+ return not settings_path().exists()
66
+
67
+
68
+ # ---------------------------------------------------------------------
69
+ # Checks
70
+ # ---------------------------------------------------------------------
71
+ @dataclass
72
+ class CheckResult:
73
+ name: str
74
+ ok: bool
75
+ detail: str
76
+ fix: str | None = None
77
+
78
+
79
+ def check_ollama() -> CheckResult:
80
+ """Is Ollama reachable on the default port?"""
81
+ host = os.environ.get("OLLAMA_HOST", "127.0.0.1:11434")
82
+ if "://" in host:
83
+ host = host.split("://", 1)[1]
84
+ h, _, port = host.partition(":")
85
+ port = int(port or "11434")
86
+ try:
87
+ with socket.create_connection((h, port), timeout=1.5):
88
+ pass
89
+ return CheckResult("ollama", True, f"reachable at {h}:{port}")
90
+ except Exception:
91
+ return CheckResult(
92
+ "ollama", False, f"not reachable at {h}:{port}",
93
+ fix="install from https://ollama.com and run `ollama serve`",
94
+ )
95
+
96
+
97
+ def check_caudate() -> CheckResult:
98
+ """Are Caudate weights present (either in repo or in ~/.cognos)?"""
99
+ repo = Path.cwd() / "data" / "nn" / "caudate.pt"
100
+ user = caudate_dir() / "caudate.pt"
101
+ for p in (repo, user):
102
+ if p.exists():
103
+ mb = p.stat().st_size / 1e6
104
+ return CheckResult("caudate", True, f"{p} ({mb:.1f} MB)")
105
+ return CheckResult(
106
+ "caudate", False, "no caudate.pt found",
107
+ fix="run `cognos init` (downloads weights from HuggingFace)",
108
+ )
109
+
110
+
111
+ def check_anthropic_key() -> CheckResult:
112
+ if os.environ.get("ANTHROPIC_API_KEY"):
113
+ return CheckResult("anthropic_key", True, "ANTHROPIC_API_KEY set in env")
114
+ return CheckResult(
115
+ "anthropic_key", False, "ANTHROPIC_API_KEY not set",
116
+ fix="export ANTHROPIC_API_KEY=sk-ant-... (only needed if "
117
+ "system1 or system2 is an `anthropic/...` model)",
118
+ )
119
+
120
+
121
+ def check_settings() -> CheckResult:
122
+ p = settings_path()
123
+ if not p.exists():
124
+ return CheckResult(
125
+ "settings", False, f"{p} missing",
126
+ fix="run `cognos init`",
127
+ )
128
+ try:
129
+ data = json.loads(p.read_text())
130
+ s1 = data.get("system1") or "?"
131
+ s2 = data.get("system2") or "?"
132
+ return CheckResult("settings", True, f"system1={s1}, system2={s2}")
133
+ except Exception as e:
134
+ return CheckResult(
135
+ "settings", False, f"{p} unreadable ({e})",
136
+ fix="delete the file and re-run `cognos init`",
137
+ )
138
+
139
+
140
+ def run_checks() -> list[CheckResult]:
141
+ return [
142
+ check_settings(),
143
+ check_ollama(),
144
+ check_caudate(),
145
+ check_anthropic_key(),
146
+ ]
147
+
148
+
149
+ # ---------------------------------------------------------------------
150
+ # Doctor — read-only diagnostic
151
+ # ---------------------------------------------------------------------
152
+ def cmd_doctor(console: Console) -> int:
153
+ """Print a status table + fix hints. Returns 0 if all green."""
154
+ results = run_checks()
155
+ table = Table(show_header=True, header_style="bold", expand=False)
156
+ table.add_column("check", style="cyan")
157
+ table.add_column("status")
158
+ table.add_column("detail", style="dim")
159
+ for r in results:
160
+ mark = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
161
+ table.add_row(r.name, mark, r.detail)
162
+ console.print(Panel(table, title="cognos doctor", border_style="cyan"))
163
+
164
+ missing = [r for r in results if not r.ok]
165
+ if missing:
166
+ console.print()
167
+ console.print("[bold]fixes:[/bold]")
168
+ for r in missing:
169
+ if r.fix:
170
+ console.print(f" [yellow]{r.name}[/yellow]: {r.fix}")
171
+ return 1
172
+ console.print("[green]all checks passed[/green]")
173
+ return 0
174
+
175
+
176
+ # ---------------------------------------------------------------------
177
+ # Init — interactive wizard
178
+ # ---------------------------------------------------------------------
179
+ _MODEL_PRESETS = [
180
+ ("local-only",
181
+ "ollama/glm-5.1:cloud", "ollama/qwen3.6:35b",
182
+ "fully local; both brains run on Ollama"),
183
+ ("hybrid",
184
+ "ollama/glm-5.1:cloud", "anthropic/claude-haiku-4-5",
185
+ "local fast, hosted slow (recommended)"),
186
+ ("hosted-only",
187
+ "anthropic/claude-haiku-4-5", "anthropic/claude-opus-4-7",
188
+ "no Ollama needed; requires ANTHROPIC_API_KEY"),
189
+ ]
190
+
191
+
192
+ def _download_caudate(console: Console, repo: str) -> bool:
193
+ """Pull caudate.pt + caudate.meta.json from a HF repo into ~/.cognos/nn/.
194
+
195
+ Returns True on success. Best-effort: if `huggingface_hub` isn't
196
+ installed we fall back to printing the manual download URLs.
197
+ """
198
+ target = caudate_dir()
199
+ try:
200
+ from huggingface_hub import hf_hub_download
201
+ except ImportError:
202
+ console.print(
203
+ f"[yellow]huggingface_hub not installed.[/yellow] Download "
204
+ f"these two files into {target}:"
205
+ )
206
+ for fname in _CAUDATE_FILES:
207
+ console.print(
208
+ f" https://huggingface.co/{repo}/resolve/main/{fname}"
209
+ )
210
+ return False
211
+ try:
212
+ for fname in _CAUDATE_FILES:
213
+ console.print(f" fetching [cyan]{fname}[/cyan] from {repo}…")
214
+ src = hf_hub_download(repo_id=repo, filename=fname)
215
+ shutil.copy2(src, target / fname)
216
+ console.print(f"[green]caudate weights ready at {target}[/green]")
217
+ return True
218
+ except Exception as e:
219
+ console.print(f"[red]download failed:[/red] {e}")
220
+ console.print(
221
+ f"manual fallback — get the files from "
222
+ f"https://huggingface.co/{repo} and drop them in {target}"
223
+ )
224
+ return False
225
+
226
+
227
+ def cmd_init(console: Console, *, force: bool = False) -> int:
228
+ """Interactive first-run setup."""
229
+ console.print(Panel(
230
+ "[bold]cognos init[/bold] — first-run setup\n"
231
+ "Picks your fast/slow models, downloads Caudate, "
232
+ "writes ~/.cognos/settings.json",
233
+ border_style="cyan",
234
+ ))
235
+
236
+ sp = settings_path()
237
+ if sp.exists() and not force:
238
+ if not Confirm.ask(
239
+ f"settings already exist at {sp} — overwrite?", default=False,
240
+ ):
241
+ console.print("[dim]aborted — existing settings preserved[/dim]")
242
+ return 1
243
+
244
+ # --- model preset
245
+ console.print("\n[bold]model preset[/bold]")
246
+ table = Table(show_header=True, header_style="bold")
247
+ table.add_column("#", style="cyan")
248
+ table.add_column("name")
249
+ table.add_column("system1 (fast)")
250
+ table.add_column("system2 (slow)")
251
+ table.add_column("notes", style="dim")
252
+ for i, (name, s1, s2, note) in enumerate(_MODEL_PRESETS, 1):
253
+ table.add_row(str(i), name, s1, s2, note)
254
+ console.print(table)
255
+ choice = Prompt.ask(
256
+ "pick a preset (1-3) or [c]ustom", choices=["1", "2", "3", "c"], default="2",
257
+ )
258
+ if choice == "c":
259
+ s1 = Prompt.ask("system1 (fast model)", default="ollama/glm-5.1:cloud")
260
+ s2 = Prompt.ask("system2 (slow model)", default="anthropic/claude-haiku-4-5")
261
+ else:
262
+ _, s1, s2, _ = _MODEL_PRESETS[int(choice) - 1]
263
+
264
+ # --- caudate download
265
+ download = Confirm.ask(
266
+ "\ndownload caudate weights (~62 MB) from HuggingFace?", default=True,
267
+ )
268
+ if download:
269
+ repo = os.environ.get("COGNOS_CAUDATE_HF_REPO", _DEFAULT_HF_REPO)
270
+ _download_caudate(console, repo)
271
+
272
+ # --- write settings
273
+ settings: dict[str, object] = {"system1": s1, "system2": s2}
274
+ if sp.exists():
275
+ try:
276
+ existing = json.loads(sp.read_text())
277
+ existing.update(settings)
278
+ settings = existing
279
+ except Exception:
280
+ pass
281
+ sp.write_text(json.dumps(settings, indent=2) + "\n")
282
+ console.print(f"\n[green]wrote {sp}[/green]")
283
+
284
+ # --- key reminder
285
+ if any("anthropic" in m for m in (s1, s2)) and not os.environ.get("ANTHROPIC_API_KEY"):
286
+ console.print(
287
+ "\n[yellow]heads up:[/yellow] you picked an anthropic model. "
288
+ "set [bold]ANTHROPIC_API_KEY[/bold] in your shell before running."
289
+ )
290
+
291
+ console.print("\n[bold]done.[/bold] try `cognos doctor` to verify, then `cognos`.")
292
+ return 0