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
main.py ADDED
@@ -0,0 +1,1889 @@
1
+ """Cognos — A Minimal Cognitive Architecture for AGI Research.
2
+
3
+ Main entry point with CLI interface.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ # Force the `transformers` library to skip its TensorFlow integration.
15
+ # A broken/partial TF install (DType serialization conflict on TF 2.21+)
16
+ # can poison the import chain via transformers' lazy loader. We never
17
+ # use TF anywhere in Cognos, so this is purely defensive. Set BEFORE
18
+ # any module that may import transformers.
19
+ os.environ.setdefault("USE_TF", "0")
20
+ os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
21
+
22
+ # Add project root to path
23
+ sys.path.insert(0, str(Path(__file__).parent))
24
+
25
+ import click
26
+ from rich.console import Console
27
+ from rich.panel import Panel
28
+
29
+ from core.agent import CognosAgent
30
+ from config import LLM_MODEL
31
+
32
+ console = Console()
33
+
34
+
35
+ def setup_logging(verbose: bool = False) -> None:
36
+ """Configure logging.
37
+
38
+ The file handler writes to `./logs/cognos.log` when run from a repo
39
+ checkout (the historical layout), otherwise falls back to
40
+ `~/.cognos/logs/cognos.log` so a pip-installed `cognos` works from
41
+ any CWD. Missing parent directories are created.
42
+ """
43
+ import os
44
+ level = logging.DEBUG if verbose else logging.INFO
45
+ repo_logs = os.path.join(os.getcwd(), "logs")
46
+ if os.path.isdir(repo_logs):
47
+ log_path = os.path.join(repo_logs, "cognos.log")
48
+ else:
49
+ user_logs = os.path.join(os.path.expanduser("~"), ".cognos", "logs")
50
+ os.makedirs(user_logs, exist_ok=True)
51
+ log_path = os.path.join(user_logs, "cognos.log")
52
+ logging.basicConfig(
53
+ level=level,
54
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
55
+ handlers=[
56
+ logging.FileHandler(log_path),
57
+ logging.StreamHandler() if verbose else logging.NullHandler(),
58
+ ],
59
+ )
60
+
61
+
62
+ @click.group(invoke_without_command=True)
63
+ @click.option("--model", default=None, help="LLM model")
64
+ @click.option("--think", is_flag=True, help="Enable chain-of-thought from the start")
65
+ @click.option("--no-stream", is_flag=True, help="Disable streaming")
66
+ @click.option("--permissions",
67
+ type=click.Choice(["default", "plan", "accept_edits", "bypass"]),
68
+ default=None)
69
+ @click.option("--resume", default=None, help="Resume a prior session")
70
+ @click.option("--no-personality", is_flag=True)
71
+ @click.option("--verbose-voice", is_flag=True)
72
+ @click.option("--debug", is_flag=True)
73
+ @click.pass_context
74
+ def cli(ctx, model, think, no_stream, permissions, resume,
75
+ no_personality, verbose_voice, debug):
76
+ """Cognos — local-first cognitive agent.
77
+
78
+ Run with no subcommand to drop straight into the unified REPL.
79
+ Inside the REPL, type / for the full command palette
80
+ (voice, draw, serve, model, sessions, tools, …).
81
+ """
82
+ if ctx.invoked_subcommand is not None:
83
+ return
84
+ # First-run: if the user has no settings yet, run the init wizard
85
+ # instead of dumping them into the REPL with a half-wired agent.
86
+ from core.bootstrap import is_first_run
87
+ if is_first_run():
88
+ console.print(
89
+ "[yellow]no settings yet — running [bold]cognos init[/bold] first.[/yellow]"
90
+ )
91
+ ctx.invoke(init_cmd, force=False)
92
+ return
93
+ ctx.invoke(
94
+ interactive,
95
+ model=model, mode="agentic", think=think, no_stream=no_stream,
96
+ permissions=permissions, resume=resume,
97
+ no_personality=no_personality, verbose_voice=verbose_voice,
98
+ system1=None, system2=None, debug=debug,
99
+ )
100
+
101
+
102
+ @cli.command("init")
103
+ @click.option("--force", is_flag=True, help="Overwrite existing settings without asking")
104
+ def init_cmd(force: bool):
105
+ """First-run setup: pick models, download Caudate weights, write settings."""
106
+ from core.bootstrap import cmd_init
107
+ sys.exit(cmd_init(console, force=force))
108
+
109
+
110
+ @cli.command("doctor")
111
+ def doctor_cmd():
112
+ """Diagnose what's wired and what's missing."""
113
+ from core.bootstrap import cmd_doctor
114
+ sys.exit(cmd_doctor(console))
115
+
116
+
117
+ @cli.command()
118
+ @click.argument("goal")
119
+ @click.option("--model", default=None, help="LLM model to use (e.g. ollama/llama3, claude-sonnet-4-20250514)")
120
+ @click.option("--criteria", "-c", multiple=True, help="Success criteria for the goal")
121
+ @click.option("--debug", is_flag=True, help="Enable debug logging")
122
+ def run(goal: str, model: str | None, criteria: tuple, debug: bool):
123
+ """Pursue a goal through the cognitive loop."""
124
+ setup_logging(verbose=debug)
125
+
126
+ console.print(Panel(
127
+ f"[bold]Cognos Cognitive Architecture[/bold]\n"
128
+ f"Model: {model or LLM_MODEL}",
129
+ title="cognos",
130
+ border_style="blue",
131
+ ))
132
+
133
+ agent = CognosAgent(model=model)
134
+ result = asyncio.run(agent.pursue_goal(
135
+ description=goal,
136
+ success_criteria=list(criteria),
137
+ verbose=True,
138
+ ))
139
+
140
+ if result.status.value == "achieved":
141
+ console.print("[green bold]Goal achieved![/green bold]")
142
+ else:
143
+ console.print(f"[red bold]Goal {result.status.value}[/red bold]")
144
+
145
+
146
+ @cli.command()
147
+ @click.option("--model", default=None, help="LLM model to use")
148
+ @click.option("--mode", type=click.Choice(["agentic", "plan"]), default="agentic",
149
+ help="'agentic' = real-time tool-calling loop (default). 'plan' = DAG planner.")
150
+ @click.option("--think", is_flag=True, help="Enable chain-of-thought reasoning (agentic mode only)")
151
+ @click.option("--no-stream", is_flag=True, help="Disable token-by-token streaming in agentic mode")
152
+ @click.option("--permissions",
153
+ type=click.Choice(["default", "plan", "accept_edits", "bypass"]),
154
+ default=None, help="Permission mode (default: from config)")
155
+ @click.option("--resume", default=None, help="Resume a prior session by ID")
156
+ @click.option("--no-personality", is_flag=True,
157
+ help="Disable the personality layer (identity + mood + voice)")
158
+ @click.option("--verbose-voice", is_flag=True,
159
+ help="Print Cognos's inner voice (thinking) to the terminal")
160
+ @click.option("--system1", default=None, help="Fast (System 1) model — enables dual-brain routing when paired with --system2")
161
+ @click.option("--system2", default=None, help="Slow (System 2) model")
162
+ @click.option("--debug", is_flag=True, help="Enable debug logging")
163
+ def interactive(model: str | None, mode: str, think: bool, no_stream: bool,
164
+ permissions: str | None, resume: str | None,
165
+ no_personality: bool, verbose_voice: bool,
166
+ system1: str | None, system2: str | None, debug: bool):
167
+ """Start an interactive session."""
168
+ setup_logging(verbose=debug)
169
+
170
+ def _render_thinking(blocks):
171
+ for b in blocks:
172
+ console.print(Panel(b.thinking, title="thinking", border_style="dim"))
173
+
174
+ def _render_inner_voice(entry):
175
+ console.print(Panel(
176
+ entry.thinking,
177
+ title=f"inner voice ({entry.mood_label})",
178
+ border_style="dim",
179
+ ))
180
+
181
+ agent = CognosAgent(
182
+ model=model,
183
+ mode=mode,
184
+ thinking=think,
185
+ on_thinking=_render_thinking if think else None,
186
+ permission_mode=permissions,
187
+ session_id=resume,
188
+ personality=not no_personality,
189
+ verbose_personality=verbose_voice,
190
+ on_inner_voice=_render_inner_voice if verbose_voice else None,
191
+ system1=system1,
192
+ system2=system2,
193
+ )
194
+
195
+ from core.banner import print_startup_banner
196
+ extras = []
197
+ if think:
198
+ extras.append("thinking")
199
+ if mode == "agentic" and not no_stream:
200
+ extras.append("streaming")
201
+ print_startup_banner(
202
+ agent, console, resumed=bool(resume), extras=extras or None,
203
+ )
204
+ prompt_label = "You>" if mode == "agentic" else "Goal>"
205
+
206
+ from core.input import CognosPrompt
207
+ from core.slash_commands import SlashContext, dispatch, SlashResult, is_slash
208
+ from core.statusline import build_status_values, render_statusline
209
+ from core.settings import Settings
210
+ from core.paste import detect_paste
211
+ from core.file_refs import find_refs, expand_with_attachments
212
+
213
+ settings = Settings.load()
214
+ statusline_template = settings.get("statusline") or ""
215
+ prompt = CognosPrompt(console=console)
216
+ slash_ctx = SlashContext(agent=agent, console=console, settings=settings)
217
+
218
+ while True:
219
+ try:
220
+ if statusline_template:
221
+ console.print(
222
+ f"[dim]{render_statusline(statusline_template, build_status_values(agent))}[/dim]"
223
+ )
224
+ text = prompt.ask(f"{prompt_label} ").strip()
225
+ if not text:
226
+ continue
227
+
228
+ if is_slash(text):
229
+ result = dispatch(text, slash_ctx)
230
+ if result is SlashResult.QUIT:
231
+ break
232
+ if result is SlashResult.RESET:
233
+ continue
234
+ if isinstance(result, str) and result:
235
+ console.print(result)
236
+ continue
237
+
238
+ # @file refs → attachments
239
+ attachments: list[str] = []
240
+ if "@" in text and find_refs(text):
241
+ text, refs = expand_with_attachments(text, agent.files)
242
+ attachments.extend(refs)
243
+ # drag-drop image/PDF detection
244
+ text, pasted = detect_paste(text, agent.files)
245
+ attachments.extend(pasted)
246
+
247
+ if mode == "agentic":
248
+ if no_stream:
249
+ reply = asyncio.run(agent.chat(text, attachments=attachments or None))
250
+ console.print(reply)
251
+ else:
252
+ from ui.display import StreamDisplay
253
+
254
+ async def _stream_turn():
255
+ display = StreamDisplay(console=console)
256
+ # Inject attachments through the agent so the
257
+ # session is persisted with the file_id refs.
258
+ if attachments:
259
+ return await agent.chat(text, attachments=attachments)
260
+ return await display.render(agent.agentic.run_streaming(text))
261
+
262
+ asyncio.run(_stream_turn())
263
+ else:
264
+ asyncio.run(agent.pursue_goal(description=text, verbose=True))
265
+
266
+ except KeyboardInterrupt:
267
+ break
268
+ except Exception as e:
269
+ console.print(f"[red]Error: {e}[/red]")
270
+
271
+ # Show final stats (plan mode only — agentic mode doesn't train strategies)
272
+ if mode == "plan":
273
+ strategies = agent.strategies
274
+ if strategies:
275
+ console.print("\n[bold]Learned Strategies:[/bold]")
276
+ for s in strategies:
277
+ console.print(f" - {s.name} (confidence: {s.confidence:.2f}): {s.description}")
278
+
279
+ console.print("\nGoodbye.")
280
+
281
+
282
+ @cli.command()
283
+ @click.option("--model", default=None, help="LLM model to use")
284
+ @click.option("--stt", "stt_backend", type=click.Choice(["moonshine", "whisper"]),
285
+ default="moonshine", help="Speech-to-text backend")
286
+ @click.option("--stt-model", "stt_model", default=None,
287
+ help="STT model id (Moonshine: 'moonshine/base'|'moonshine/tiny'; Whisper: 'tiny'|'base'|'small'|...)")
288
+ @click.option("--tts", "tts_backend", type=click.Choice(["kokoro", "piper", "xtts"]),
289
+ default="kokoro", help="Text-to-speech backend (kokoro = natural human voice)")
290
+ @click.option("--tts-voice", "tts_voice", default=None,
291
+ help="Voice id (Kokoro: af_heart|af_bella|am_adam|bm_george|... default af_heart)")
292
+ @click.option("--piper-voice", "voice_path", default=None,
293
+ help="Piper-only: path to .onnx voice file")
294
+ @click.option("--no-personality", is_flag=True, help="Disable the personality layer")
295
+ @click.option("--no-greeting", is_flag=True, help="Skip the opening greeting")
296
+ @click.option("--debug", is_flag=True, help="Enable debug logging")
297
+ def talk(model: str | None, stt_backend: str, stt_model: str | None,
298
+ tts_backend: str, tts_voice: str | None,
299
+ voice_path: str | None, no_personality: bool, no_greeting: bool, debug: bool):
300
+ """Start a voice conversation with Cognos — speak and she speaks back."""
301
+ setup_logging(verbose=debug)
302
+
303
+ tts_label = tts_backend
304
+ if tts_backend == "kokoro":
305
+ tts_label += f" ({tts_voice or 'af_heart'})"
306
+ console.print(Panel(
307
+ f"[bold]Cognos Voice Mode[/bold]\n"
308
+ f"LLM: {model or LLM_MODEL}\n"
309
+ f"STT: {stt_backend}{f' ({stt_model})' if stt_model else ''} | TTS: {tts_label}\n"
310
+ f"Ctrl+C interrupts speech. Say 'goodbye' to exit.",
311
+ title="cognos voice",
312
+ border_style="magenta",
313
+ ))
314
+
315
+ from voice.conversation import VoiceConversation
316
+
317
+ agent = CognosAgent(
318
+ model=model,
319
+ mode="agentic",
320
+ permission_mode="bypass", # voice mode can't approve UI prompts
321
+ personality=not no_personality,
322
+ )
323
+
324
+ stt_kwargs: dict = {}
325
+ if stt_model:
326
+ stt_kwargs["model"] = stt_model
327
+
328
+ conv = VoiceConversation(
329
+ agent=agent,
330
+ stt=stt_backend,
331
+ tts=tts_backend,
332
+ voice=tts_voice,
333
+ voice_path=voice_path,
334
+ **stt_kwargs,
335
+ )
336
+ try:
337
+ asyncio.run(conv.run(greeting=not no_greeting))
338
+ finally:
339
+ asyncio.run(agent.stop())
340
+
341
+
342
+ @cli.group()
343
+ def sessions():
344
+ """Manage saved conversations."""
345
+ pass
346
+
347
+
348
+ @sessions.command("list")
349
+ def sessions_list():
350
+ """List saved sessions."""
351
+ from core.session import SessionManager
352
+ from config import SESSIONS_DIR
353
+
354
+ sm = SessionManager(SESSIONS_DIR)
355
+ entries = sm.list()
356
+ if not entries:
357
+ console.print("[dim]No sessions yet.[/dim]")
358
+ return
359
+ for s in entries:
360
+ title = s.title or "(untitled)"
361
+ console.print(
362
+ f"[bold]{s.id}[/bold] {title} "
363
+ f"[dim]({len(s.messages)} msgs, model={s.model}, updated={s.updated_at.isoformat(timespec='seconds')})[/dim]"
364
+ )
365
+
366
+
367
+ @sessions.command("delete")
368
+ @click.argument("session_id")
369
+ def sessions_delete(session_id: str):
370
+ """Delete a session by ID."""
371
+ from core.session import SessionManager
372
+ from config import SESSIONS_DIR
373
+
374
+ sm = SessionManager(SESSIONS_DIR)
375
+ if sm.delete(session_id):
376
+ console.print(f"Deleted {session_id}")
377
+ else:
378
+ console.print(f"[red]Session not found: {session_id}[/red]")
379
+
380
+
381
+ @sessions.command("rename")
382
+ @click.argument("session_id")
383
+ @click.argument("title")
384
+ def sessions_rename(session_id: str, title: str):
385
+ """Rename a session."""
386
+ from core.session import SessionManager
387
+ from config import SESSIONS_DIR
388
+
389
+ sm = SessionManager(SESSIONS_DIR)
390
+ if sm.rename(session_id, title):
391
+ console.print(f"Renamed {session_id} -> {title!r}")
392
+ else:
393
+ console.print(f"[red]Session not found: {session_id}[/red]")
394
+
395
+
396
+ @cli.command("router")
397
+ @click.option("--model", default=None, help="Primary model (fallback when routing disabled)")
398
+ @click.option("--system1", default=None, help="Fast model")
399
+ @click.option("--system2", default=None, help="Slow model")
400
+ @click.argument("prompts", nargs=-1)
401
+ def router_preview(model: str | None, system1: str | None, system2: str | None, prompts: tuple):
402
+ """Preview router decisions for one or more prompts without calling any LLM."""
403
+ from llm.provider import LLMProvider
404
+ from llm.router import Router, RoutingPolicy
405
+ from config import SYSTEM_1_MODEL, SYSTEM_2_MODEL, ROUTER_COMPLEXITY_THRESHOLD
406
+ from rich.table import Table
407
+
408
+ s1 = system1 or SYSTEM_1_MODEL or model
409
+ s2 = system2 or SYSTEM_2_MODEL or model
410
+ if not s1 or not s2:
411
+ console.print("[red]Configure --system1/--system2 (or SYSTEM_1_MODEL/SYSTEM_2_MODEL).[/red]")
412
+ return
413
+
414
+ fast = LLMProvider(model=s1)
415
+ slow = LLMProvider(model=s2)
416
+ r = Router(fast=fast, slow=slow, policy=RoutingPolicy(complexity_threshold=ROUTER_COMPLEXITY_THRESHOLD))
417
+
418
+ if not prompts:
419
+ prompts = (
420
+ "list files here",
421
+ "what is 2+2",
422
+ "analyze the architecture and explain the tradeoffs",
423
+ "design a caching layer for the API",
424
+ "refactor this to use async",
425
+ )
426
+
427
+ table = Table(title="Router preview")
428
+ table.add_column("prompt")
429
+ table.add_column("tier")
430
+ table.add_column("score")
431
+ table.add_column("reasons")
432
+ for p in prompts:
433
+ d = r.choose(messages=[{"role": "user", "content": p}])
434
+ preview = p if len(p) <= 60 else p[:57] + "…"
435
+ table.add_row(preview, d.tier, f"{d.score:.2f}", ", ".join(d.reasons))
436
+ console.print(table)
437
+
438
+
439
+ @cli.group()
440
+ def personality():
441
+ """Inspect and tune Cognos's personality (traits, mood, inner voice)."""
442
+ pass
443
+
444
+
445
+ @personality.command("show")
446
+ def personality_show():
447
+ """Show the current identity, mood, and recent inner-voice entries."""
448
+ from personality import PersonalityEngine
449
+ from config import DATA_DIR
450
+
451
+ engine = PersonalityEngine.load(DATA_DIR)
452
+ console.print(Panel(engine.identity.describe(), title="identity", border_style="cyan"))
453
+ console.print(Panel(
454
+ engine.mood.system_prompt_fragment(),
455
+ title=f"mood ({engine.mood.label()})",
456
+ border_style="magenta",
457
+ ))
458
+
459
+
460
+ @personality.command("set")
461
+ @click.argument("trait")
462
+ @click.argument("value", type=float)
463
+ def personality_set(trait: str, value: float):
464
+ """Set a trait or value to a given [0, 1] level."""
465
+ from personality.identity import Identity
466
+ from config import IDENTITY_PATH
467
+
468
+ identity = Identity.load(IDENTITY_PATH)
469
+ if not hasattr(identity, trait):
470
+ console.print(f"[red]Unknown trait: {trait}[/red]")
471
+ console.print(f"Available: {sorted(identity.model_fields.keys())}")
472
+ return
473
+ setattr(identity, trait, max(0.0, min(1.0, value)))
474
+ identity.save(IDENTITY_PATH)
475
+ console.print(f"Set {trait} = {getattr(identity, trait):.2f}")
476
+
477
+
478
+ @personality.command("reset")
479
+ @click.option("--mood", "reset_mood", is_flag=True, help="Reset mood only")
480
+ @click.option("--identity", "reset_identity", is_flag=True, help="Reset identity only")
481
+ def personality_reset(reset_mood: bool, reset_identity: bool):
482
+ """Reset personality state to defaults."""
483
+ from personality.identity import Identity
484
+ from personality.mood import MoodState
485
+ from config import IDENTITY_PATH, MOOD_PATH
486
+
487
+ do_both = not (reset_mood or reset_identity)
488
+ if reset_identity or do_both:
489
+ Identity().save(IDENTITY_PATH)
490
+ console.print("Identity reset to defaults.")
491
+ if reset_mood or do_both:
492
+ MoodState().save(MOOD_PATH)
493
+ console.print("Mood reset to neutral.")
494
+
495
+
496
+ @cli.command("models")
497
+ @click.option("--vram", "vram_mb", type=int, default=None,
498
+ help="Override available VRAM in MB (defaults to nvidia-smi).")
499
+ def list_models(vram_mb: int | None):
500
+ """List available Ollama models with capability flags + VRAM fit."""
501
+ import asyncio
502
+ from llm.models import (
503
+ ModelRegistry, estimate_vram, compare_to_available,
504
+ detect_available_vram_mb, pick_best_fit,
505
+ )
506
+
507
+ async def _go():
508
+ reg = ModelRegistry()
509
+ await reg.refresh()
510
+ return reg.models()
511
+
512
+ models = asyncio.run(_go())
513
+ if not models:
514
+ console.print("[yellow]No models detected. Is Ollama running?[/yellow]")
515
+ return
516
+
517
+ available_mb = vram_mb if vram_mb is not None else detect_available_vram_mb()
518
+
519
+ from rich.table import Table
520
+ title = "Available Models"
521
+ if available_mb:
522
+ title += f" [GPU: {available_mb} MB]"
523
+ table = Table(title=title)
524
+ table.add_column("id")
525
+ table.add_column("provider")
526
+ table.add_column("tools")
527
+ table.add_column("json")
528
+ table.add_column("ctx")
529
+ table.add_column("size")
530
+ table.add_column("VRAM (Q4)")
531
+ table.add_column("fit")
532
+ for m in sorted(models, key=lambda x: (x.provider, x.name)):
533
+ size = f"{m.size_bytes / (1024**3):.1f}GB" if m.size_bytes else "-"
534
+ est = estimate_vram(m.id)
535
+ if est:
536
+ vram_str = f"{est.vram_mb} MB"
537
+ if available_mb:
538
+ status = compare_to_available(est.vram_mb, available_mb)
539
+ fit = {
540
+ "fits": "[green]✅ fits[/green]",
541
+ "tight": "[yellow]⚠ tight[/yellow]",
542
+ "wont-fit": "[red]❌ won't fit[/red]",
543
+ }[status]
544
+ else:
545
+ fit = "[dim]?[/dim]"
546
+ else:
547
+ vram_str = "-"
548
+ fit = "[dim]cloud/?[/dim]"
549
+ table.add_row(
550
+ m.id,
551
+ m.provider,
552
+ "✓" if m.supports_tool_calling else "-",
553
+ "✓" if m.supports_json_mode else "-",
554
+ f"{m.context_window:,}",
555
+ size,
556
+ vram_str,
557
+ fit,
558
+ )
559
+ console.print(table)
560
+ if available_mb:
561
+ ids = [m.id for m in models if m.provider == "ollama"]
562
+ best = pick_best_fit(ids, available_mb)
563
+ if best:
564
+ mid, est, status = best
565
+ badge = {"fits": "[green]✅", "tight": "[yellow]⚠"}.get(status, "")
566
+ console.print(
567
+ f"\n{badge} Best local fit:[/] {mid} "
568
+ f"({est.params_b}B, ~{est.vram_mb} MB)"
569
+ )
570
+
571
+
572
+ @cli.command("mcp-serve")
573
+ def mcp_serve():
574
+ """Run Cognos as an MCP server (stdio). Exposes all Cognos tools to MCP clients."""
575
+ import asyncio
576
+ from cognos_mcp.server import build_server
577
+
578
+ server = build_server()
579
+ asyncio.run(server.run_stdio_async())
580
+
581
+
582
+ @cli.command("serve")
583
+ @click.option("--host", default="127.0.0.1", help="Bind address")
584
+ @click.option("--port", default=8000, type=int, help="Bind port")
585
+ @click.option("--reload", is_flag=True, help="Auto-reload on code change (dev)")
586
+ def serve(host: str, port: int, reload: bool):
587
+ """Run Cognos as an HTTP API (POST /chat, GET /sessions, GET /tools, GET /models)."""
588
+ # Initialise logging so cognos module loggers (api/, core/, nn/)
589
+ # actually emit. Without this, INFO from constitutional critique,
590
+ # caudate observations, etc. silently drops on the floor.
591
+ setup_logging(verbose=True)
592
+ import uvicorn
593
+ uvicorn.run(
594
+ "api.server:create_app",
595
+ host=host, port=port, reload=reload, factory=True,
596
+ )
597
+
598
+
599
+ @cli.group()
600
+ def nn():
601
+ """Caudate — Cognos's action-selection neural net. Train, eval, status."""
602
+ pass
603
+
604
+
605
+ # `cognos caudate ...` reads better than `cognos nn ...` once you know
606
+ # the name. Both work.
607
+ cli.add_command(nn, name="caudate")
608
+
609
+
610
+ @nn.command("train")
611
+ @click.option("--max-steps", default=None, type=int, help="Override max_steps")
612
+ @click.option("--batch-size", default=None, type=int)
613
+ @click.option("--lr", default=None, type=float)
614
+ @click.option("--from-scratch", is_flag=True, help="Ignore existing checkpoint")
615
+ @click.option("--debug", is_flag=True)
616
+ def nn_train(max_steps: int | None, batch_size: int | None, lr: float | None,
617
+ from_scratch: bool, debug: bool):
618
+ """Train the controller on saved sessions."""
619
+ setup_logging(verbose=debug)
620
+ from nn.config import NNConfig
621
+ from nn.data import load_corpus_from_sessions
622
+ from nn.trainer import Trainer, build_fresh
623
+
624
+ cfg = NNConfig()
625
+ if max_steps is not None: cfg.max_steps = max_steps
626
+ if batch_size is not None: cfg.batch_size = batch_size
627
+ if lr is not None: cfg.learning_rate = lr
628
+
629
+ corpus = load_corpus_from_sessions()
630
+ console.print(f"[dim]loaded {len(corpus)} training samples from saved sessions[/dim]")
631
+
632
+ if not from_scratch and Path(cfg.checkpoint_path).exists():
633
+ trainer = Trainer.load(cfg)
634
+ console.print(f"[dim]resumed from {cfg.checkpoint_path} (step {trainer.step})[/dim]")
635
+ else:
636
+ trainer = build_fresh(cfg)
637
+ console.print(f"[dim]fresh trainer, params={trainer.model.num_parameters():,}[/dim]")
638
+
639
+ # Seed vocab from any tool name appearing in the corpus + Cognos's
640
+ # currently registered tools (so unseen tools still get an id).
641
+ from execution.executor import Executor
642
+ for name in Executor(load_plugins=False).list_tools():
643
+ trainer.vocab.add(name)
644
+ for s in corpus:
645
+ trainer.vocab.add(s.target_tool)
646
+
647
+ if not corpus:
648
+ console.print("[yellow]No training data yet. Use Cognos for a few sessions first, "
649
+ "or seed via the replay buffer.[/yellow]")
650
+ return
651
+
652
+ history = trainer.fit(corpus)
653
+ if history:
654
+ last = history[-1]
655
+ console.print(Panel(
656
+ f"steps: {last.step}\n"
657
+ f"final loss: {last.loss_total:.4f}\n"
658
+ f"tool_acc: {last.tool_acc:.2f} tier_acc: {last.tier_acc:.2f} "
659
+ f"think_acc: {last.think_acc:.2f}\n"
660
+ f"checkpoint: {cfg.checkpoint_path}",
661
+ title="nn train", border_style="green",
662
+ ))
663
+
664
+
665
+ @nn.command("train-forge")
666
+ @click.option("--max-steps", default=400, type=int, help="Training steps")
667
+ @click.option("--batch-size", default=16, type=int)
668
+ @click.option("--lr", default=None, type=float)
669
+ @click.option("--from-scratch", is_flag=True,
670
+ help="Ignore existing checkpoint, train trunk + heads fresh")
671
+ @click.option("--debug", is_flag=True)
672
+ def nn_train_forge(max_steps: int, batch_size: int, lr: float | None,
673
+ from_scratch: bool, debug: bool):
674
+ """Train Caudate's feature_success head on Forge outcomes (ADR 0006 Phase A).
675
+
676
+ Reads `data/nn/feature_outcomes.jsonl`, converts each row to a
677
+ one-message ConversationSample tagged with the originating model, and
678
+ resumes the existing checkpoint. Other heads see no targets in
679
+ these samples (optional_target=True), so their weights drift only
680
+ via the shared trunk — which is what we want for transfer learning."""
681
+ setup_logging(verbose=debug)
682
+ from nn.config import NNConfig
683
+ from nn.data import load_corpus_from_feature_outcomes
684
+ from nn.trainer import Trainer, build_fresh
685
+
686
+ cfg = NNConfig()
687
+ cfg.max_steps = max_steps
688
+ cfg.batch_size = batch_size
689
+ if lr is not None:
690
+ cfg.learning_rate = lr
691
+
692
+ corpus = load_corpus_from_feature_outcomes()
693
+ console.print(
694
+ f"[dim]loaded {len(corpus)} feature-outcome samples from "
695
+ f"data/nn/feature_outcomes.jsonl[/dim]"
696
+ )
697
+ if not corpus:
698
+ console.print(
699
+ "[yellow]No feature outcomes yet. Run Forge first to produce labels.[/yellow]"
700
+ )
701
+ return
702
+
703
+ n_success = sum(1 for s in corpus if (s.target_feature_success or 0) >= 0.5)
704
+ console.print(
705
+ f"[dim]label balance: {n_success} success / {len(corpus) - n_success} fail[/dim]"
706
+ )
707
+
708
+ if not from_scratch and Path(cfg.checkpoint_path).exists():
709
+ trainer = Trainer.load(cfg)
710
+ # The loaded trainer's `step` is from prior runs — reset the
711
+ # counter so `max_steps` applies to this training pass alone.
712
+ trainer.cfg.max_steps = trainer.step + max_steps
713
+ console.print(
714
+ f"[dim]resumed from {cfg.checkpoint_path} "
715
+ f"(prior step={trainer.step}, will run {max_steps} more)[/dim]"
716
+ )
717
+ else:
718
+ trainer = build_fresh(cfg)
719
+ console.print(
720
+ f"[dim]fresh trainer, params={trainer.model.num_parameters():,}[/dim]"
721
+ )
722
+
723
+ # Register the source vocab so model_id strings get stable embedding
724
+ # slots. This is the same idempotent `add` flow that collate uses.
725
+ for s in corpus:
726
+ if s.model_source:
727
+ trainer.source_vocab.add(s.model_source)
728
+
729
+ history = trainer.fit(corpus)
730
+ if history:
731
+ last = history[-1]
732
+ feat_loss = next(
733
+ (m for m in reversed(history)
734
+ if getattr(m, "loss_total", None) is not None),
735
+ None,
736
+ )
737
+ console.print(Panel(
738
+ f"steps: {last.step}\n"
739
+ f"final total loss: {last.loss_total:.4f}\n"
740
+ f"checkpoint: {cfg.checkpoint_path}\n"
741
+ f"head: feature_success",
742
+ title="nn train-forge", border_style="green",
743
+ ))
744
+
745
+
746
+ @nn.command("train-public")
747
+ @click.option("--corpus", "corpus_path", required=True, type=str,
748
+ help="Path to a local HuggingFace cache (the directory "
749
+ "containing the .arrow shards). Or an HF Hub id "
750
+ "(slash-separated) — that path is forwarded to "
751
+ "load_corpus_from_hf for streaming.")
752
+ @click.option("--max-rows", default=10000, type=int,
753
+ help="Cap on rows read from the corpus (smoke-friendly default)")
754
+ @click.option("--max-steps", default=500, type=int)
755
+ @click.option("--batch-size", default=16, type=int)
756
+ @click.option("--lr", default=None, type=float)
757
+ @click.option("--from-scratch", is_flag=True,
758
+ help="Ignore existing checkpoint; train trunk + heads fresh")
759
+ @click.option("--no-tool-weight", default=None, type=float,
760
+ help="Class-balanced loss weight on the contrastive tool head's "
761
+ "<no_tool> slot (slot 0). 1.0 = unweighted; <1.0 = "
762
+ "downweight; >1.0 = upweight. Use to correct slot-0 bias "
763
+ "from corpora dominated by <no_tool> rows.")
764
+ @click.option("--debug", is_flag=True)
765
+ def nn_train_public(
766
+ corpus_path: str, max_rows: int, max_steps: int, batch_size: int,
767
+ lr: float | None, from_scratch: bool, no_tool_weight: float | None,
768
+ debug: bool,
769
+ ):
770
+ """Train Caudate on a public tool-use / reasoning corpus.
771
+
772
+ Auto-detects on-disk layout: a path containing *.arrow files is
773
+ treated as a local HF cache (no network); a slash-separated name
774
+ is forwarded to load_corpus_from_hf as an HF Hub id.
775
+
776
+ Sample emission:
777
+ - Assistant turns with tool_calls → one sample per call (open-vocab tool head trains)
778
+ - Assistant turns without tool_calls → one sample with target_tool="<no_tool>"
779
+ (trunk + "don't call anything" signal — useful for reasoning corpora
780
+ like Kimi-K2.5-Reasoning that have no tool calls at all)
781
+ """
782
+ setup_logging(verbose=debug)
783
+ from pathlib import Path as _P
784
+ from nn.config import NNConfig
785
+ from nn.data import (
786
+ load_corpus_from_glaive_json, load_corpus_from_hf,
787
+ load_corpus_from_local_arrow, load_corpus_from_oai_jsonl,
788
+ )
789
+ from nn.trainer import Trainer, build_fresh
790
+
791
+ cfg = NNConfig()
792
+ cfg.max_steps = max_steps
793
+ cfg.batch_size = batch_size
794
+ if lr is not None:
795
+ cfg.learning_rate = lr
796
+ if no_tool_weight is not None:
797
+ cfg.tool_no_tool_class_weight = no_tool_weight
798
+ console.print(
799
+ f"[dim]tool_no_tool_class_weight = {no_tool_weight} "
800
+ f"(slot-0 cross-entropy rebalance)[/dim]"
801
+ )
802
+
803
+ p = _P(corpus_path)
804
+ if p.exists() and p.is_file() and p.suffix == ".json":
805
+ corpus = load_corpus_from_glaive_json(p, max_rows=max_rows)
806
+ elif p.exists() and p.is_file() and p.suffix == ".jsonl":
807
+ corpus = load_corpus_from_oai_jsonl(p, max_rows=max_rows)
808
+ elif p.exists() and p.is_dir():
809
+ # Prefer arrow shards; fall back to a single JSON file inside.
810
+ if list(p.glob("*.arrow")):
811
+ corpus = load_corpus_from_local_arrow(p, max_rows=max_rows)
812
+ else:
813
+ json_files = sorted(p.glob("*.json"))
814
+ if json_files:
815
+ corpus = load_corpus_from_glaive_json(
816
+ json_files[0], max_rows=max_rows,
817
+ )
818
+ else:
819
+ corpus = []
820
+ else:
821
+ corpus = load_corpus_from_hf(
822
+ corpus_path, max_rows=max_rows, streaming=True,
823
+ )
824
+ console.print(f"[dim]loaded {len(corpus)} samples from {corpus_path}[/dim]")
825
+ if not corpus:
826
+ console.print("[yellow]No samples loaded. Check the path/dataset shape.[/yellow]")
827
+ return
828
+
829
+ # Quick label balance + source distribution sanity check
830
+ tool_targets = [s.target_tool for s in corpus]
831
+ n_no_tool = sum(1 for t in tool_targets if t == "<no_tool>")
832
+ n_with_tool = len(corpus) - n_no_tool
833
+ console.print(
834
+ f"[dim]targets: {n_with_tool} tool-call / {n_no_tool} <no_tool>[/dim]"
835
+ )
836
+
837
+ if not from_scratch and Path(cfg.checkpoint_path).exists():
838
+ trainer = Trainer.load(cfg)
839
+ trainer.cfg.max_steps = trainer.step + max_steps
840
+ console.print(
841
+ f"[dim]resumed from {cfg.checkpoint_path} "
842
+ f"(prior step={trainer.step}, will run {max_steps} more)[/dim]"
843
+ )
844
+ else:
845
+ trainer = build_fresh(cfg)
846
+ console.print(
847
+ f"[dim]fresh trainer, params={trainer.model.num_parameters():,}[/dim]"
848
+ )
849
+
850
+ for s in corpus:
851
+ if s.model_source:
852
+ trainer.source_vocab.add(s.model_source)
853
+ if s.target_tool:
854
+ trainer.vocab.add(s.target_tool)
855
+
856
+ history = trainer.fit(corpus)
857
+ if history:
858
+ last = history[-1]
859
+ console.print(Panel(
860
+ f"steps: {last.step}\n"
861
+ f"final total loss: {last.loss_total:.4f}\n"
862
+ f"tool_acc: {last.tool_acc:.2f}\n"
863
+ f"checkpoint: {cfg.checkpoint_path}",
864
+ title="nn train-public", border_style="green",
865
+ ))
866
+
867
+
868
+ @nn.command("eval")
869
+ def nn_eval():
870
+ """Run evaluation on the held-out split."""
871
+ from nn.config import NNConfig
872
+ from nn.data import load_corpus_from_sessions, split_train_eval
873
+ from nn.trainer import Trainer
874
+
875
+ cfg = NNConfig()
876
+ if not Path(cfg.checkpoint_path).exists():
877
+ console.print(f"[red]No checkpoint at {cfg.checkpoint_path}. Train first.[/red]")
878
+ return
879
+ trainer = Trainer.load(cfg)
880
+ corpus = load_corpus_from_sessions()
881
+ _, ev = split_train_eval(corpus, cfg.eval_split, cfg.seed)
882
+ metrics = trainer.evaluate(ev)
883
+ console.print(metrics or "[dim](no eval data)[/dim]")
884
+
885
+
886
+ @nn.command("status")
887
+ def nn_status():
888
+ """Show controller info, training step, recent advisor predictions."""
889
+ from nn.config import NNConfig
890
+ cfg = NNConfig()
891
+ meta_path = Path(cfg.metadata_path)
892
+ if not meta_path.exists():
893
+ console.print("[dim]No NN trained yet. Run: cognos nn train[/dim]")
894
+ return
895
+ import json as _json
896
+ meta = _json.loads(meta_path.read_text())
897
+ console.print(Panel(
898
+ f"params: {meta['n_params']:,}\n"
899
+ f"step: {meta['step']}\n"
900
+ f"saved_at: {meta['saved_at']}\n"
901
+ f"checkpoint: {cfg.checkpoint_path}",
902
+ title="nn status",
903
+ ))
904
+ log = Path(cfg.advisor_log_path)
905
+ if log.exists():
906
+ lines = log.read_text().splitlines()[-5:]
907
+ if lines:
908
+ console.print("[dim]recent predictions:[/dim]")
909
+ for ln in lines:
910
+ console.print(f" {ln}")
911
+
912
+
913
+ @nn.command("export")
914
+ @click.argument("out_path")
915
+ def nn_export(out_path: str):
916
+ """Copy the latest checkpoint + metadata to a path you can share."""
917
+ import shutil
918
+ from nn.config import NNConfig
919
+ cfg = NNConfig()
920
+ out = Path(out_path)
921
+ out.mkdir(parents=True, exist_ok=True)
922
+ for src in (Path(cfg.checkpoint_path), Path(cfg.metadata_path)):
923
+ if src.exists():
924
+ shutil.copy2(src, out / src.name)
925
+ console.print(f"exported → {out}")
926
+
927
+
928
+ @nn.group("nas")
929
+ def nn_nas():
930
+ """Caudate's neural architecture search — let her evolve her own brain."""
931
+ pass
932
+
933
+
934
+ @nn_nas.command("run")
935
+ @click.option("--strategy", "strategy_name",
936
+ type=click.Choice(["random", "evolutionary", "rl", "darts", "hyperband"]),
937
+ default="random", help="NAS algorithm")
938
+ @click.option("--trials", default=8, type=int, help="How many architectures to try (sample-and-score)")
939
+ @click.option("--steps", default=200, type=int, help="Train steps per trial")
940
+ @click.option("--seed", default=42, type=int)
941
+ @click.option("--epochs", default=5, type=int, help="DARTS only: epochs of supernet training")
942
+ @click.option("--population", default=8, type=int, help="evolutionary only: population size")
943
+ @click.option("--max-budget", default=800, type=int, help="hyperband only: max budget")
944
+ @click.option("--min-budget", default=50, type=int, help="hyperband only: min budget")
945
+ def nn_nas_run(strategy_name: str, trials: int, steps: int, seed: int,
946
+ epochs: int, population: int, max_budget: int, min_budget: int):
947
+ """Run NAS with the chosen strategy."""
948
+ from nn.nas import Searcher
949
+ from nn.nas.searcher import SearcherConfig
950
+ from nn.nas.strategies import make_strategy
951
+ from nn.nas.strategies.base import StrategyConfig
952
+
953
+ scfg = StrategyConfig(n_trials=trials, train_steps_per_trial=steps, seed=seed)
954
+
955
+ if strategy_name == "evolutionary":
956
+ strategy = make_strategy("evolutionary", config=scfg, population_size=population)
957
+ elif strategy_name == "rl":
958
+ strategy = make_strategy("rl", config=scfg)
959
+ elif strategy_name == "darts":
960
+ strategy = make_strategy("darts", config=scfg, epochs=epochs)
961
+ elif strategy_name == "hyperband":
962
+ base = make_strategy("random", config=scfg)
963
+ strategy = make_strategy("hyperband", config=scfg, base=base,
964
+ max_budget=max_budget, min_budget=min_budget)
965
+ else:
966
+ strategy = make_strategy("random", config=scfg)
967
+
968
+ sconf = SearcherConfig(n_trials=trials, train_steps_per_trial=steps, seed=seed)
969
+ s = Searcher(cfg=sconf, strategy=strategy)
970
+ console.print(Panel(
971
+ f"strategy: [bold]{strategy_name}[/bold]\n"
972
+ f"trials: {trials} · steps/trial: {steps} · seed: {seed}",
973
+ title="caudate nas", border_style="cyan",
974
+ ))
975
+ summary = s.search()
976
+ if summary["status"] == "bailed":
977
+ console.print(f"[yellow]bailed: {summary['reason']}, "
978
+ f"corpus={summary['corpus_size']}[/yellow]")
979
+ return
980
+ if summary["status"] == "complete-darts":
981
+ console.print(f"\n[green]DARTS done[/green]")
982
+ console.print(f"discrete arch: {summary['discrete_arch']}")
983
+ console.print(f"params: {summary['n_params']:,}")
984
+ return
985
+ console.print(f"\n[bold]done[/bold] · {summary['n_trials']} trials")
986
+ if summary.get("champion"):
987
+ console.print(f"[green]champion:[/green] {summary['champion']}")
988
+ else:
989
+ console.print("[yellow]no champion (all trials failed)[/yellow]")
990
+
991
+
992
+ @nn_nas.command("status")
993
+ def nn_nas_status():
994
+ """Show current champion + recent trials."""
995
+ from nn.nas.store import NASStore
996
+ store = NASStore()
997
+ champ = store.champion()
998
+ if champ is None:
999
+ console.print("[dim]no champion yet — run `cognos caudate nas run`[/dim]")
1000
+ else:
1001
+ spec = champ.get("spec", {})
1002
+ result = champ.get("result", {})
1003
+ console.print(Panel(
1004
+ f"id: {champ.get('id')}\n"
1005
+ f"saved_at: {champ.get('saved_at')}\n"
1006
+ f"d_model={spec.get('d_model')} L={spec.get('n_layers')} "
1007
+ f"h={spec.get('n_heads')} ff={spec.get('d_ff_multiplier')}\n"
1008
+ f"fitness: {result.get('fitness')}\n"
1009
+ f"composite acc: {result.get('composite')}\n"
1010
+ f"params: {result.get('n_params'):,}",
1011
+ title="champion", border_style="green",
1012
+ ))
1013
+ history = store.history()
1014
+ if history:
1015
+ console.print(f"\n[dim]{len(history)} total trials in history[/dim]")
1016
+ from rich.table import Table
1017
+ t = Table(title="recent trials")
1018
+ t.add_column("id"); t.add_column("d_model"); t.add_column("L")
1019
+ t.add_column("h"); t.add_column("ff"); t.add_column("fitness")
1020
+ t.add_column("tool_acc")
1021
+ for h in history[-10:]:
1022
+ sp, r = h.get("spec", {}), h.get("result", {})
1023
+ t.add_row(
1024
+ h.get("id", "")[:8], str(sp.get("d_model")),
1025
+ str(sp.get("n_layers")), str(sp.get("n_heads")),
1026
+ str(sp.get("d_ff_multiplier")),
1027
+ f"{r.get('fitness', 0):.3f}",
1028
+ f"{r.get('tool_acc', 0):.2f}",
1029
+ )
1030
+ console.print(t)
1031
+
1032
+
1033
+ @nn_nas.command("promote")
1034
+ def nn_nas_promote():
1035
+ """Promote the current NAS champion to be the active Caudate."""
1036
+ from nn.config import NNConfig
1037
+ from nn.nas.store import NASStore
1038
+ cfg = NNConfig()
1039
+ store = NASStore()
1040
+ if store.promote_to_main(cfg.checkpoint_path):
1041
+ console.print(f"[green]promoted champion → {cfg.checkpoint_path}[/green]")
1042
+ else:
1043
+ console.print("[yellow]no champion to promote[/yellow]")
1044
+
1045
+
1046
+ @nn_nas.command("plateau")
1047
+ def nn_nas_plateau():
1048
+ """Show plateau-detection state (auto-fire conditions)."""
1049
+ from nn.nas.scheduler import PlateauScheduler
1050
+ sched = PlateauScheduler()
1051
+ import json as _json
1052
+ console.print(_json.dumps(sched.status(), indent=2))
1053
+
1054
+
1055
+ @cli.command("draw")
1056
+ @click.argument("prompt")
1057
+ @click.option("--backend", type=click.Choice(["diffusers", "comfyui", "litellm"]),
1058
+ default="diffusers", help="Image-gen backend")
1059
+ @click.option("--model", default=None,
1060
+ help="Backend-specific model id (e.g. 'stabilityai/sdxl-turbo', "
1061
+ "'black-forest-labs/FLUX.1-schnell', 'openai/gpt-image-1')")
1062
+ @click.option("--size", default="1024x1024", help="WxH (default 1024x1024)")
1063
+ @click.option("--negative", default="", help="Negative prompt")
1064
+ @click.option("--seed", default=None, type=int, help="Random seed")
1065
+ @click.option("--steps", default=None, type=int, help="Inference steps")
1066
+ @click.option("--out", default=None, help="Output PNG path (default: data/files/<id>.png)")
1067
+ @click.option("--debug", is_flag=True, help="Enable debug logging")
1068
+ def draw_cmd(prompt: str, backend: str, model: str | None, size: str,
1069
+ negative: str, seed: int | None, steps: int | None,
1070
+ out: str | None, debug: bool):
1071
+ """Generate an image from a prompt."""
1072
+ setup_logging(verbose=debug)
1073
+ from core.image import make_image_gen, generate_to_file_store
1074
+ from core.files import FileStore
1075
+ from config import FILES_DIR
1076
+
1077
+ backend_kwargs: dict = {}
1078
+ if model:
1079
+ if backend == "comfyui":
1080
+ backend_kwargs["checkpoint"] = model
1081
+ else:
1082
+ backend_kwargs["model"] = model
1083
+
1084
+ async def _go():
1085
+ store = FileStore(root=FILES_DIR)
1086
+ gen = make_image_gen(backend, **backend_kwargs)
1087
+ kwargs: dict = {}
1088
+ if negative: kwargs["negative_prompt"] = negative
1089
+ if seed is not None: kwargs["seed"] = seed
1090
+ if steps is not None: kwargs["steps"] = steps
1091
+ record = await generate_to_file_store(gen, store, prompt, size=size, **kwargs)
1092
+ return record
1093
+
1094
+ console.print(Panel(
1095
+ f"[bold]Generating image[/bold]\nbackend: {backend}\nprompt: {prompt}\nsize: {size}",
1096
+ border_style="magenta",
1097
+ ))
1098
+ record = asyncio.run(_go())
1099
+
1100
+ final_path = Path(record.path)
1101
+ if out:
1102
+ out_path = Path(out)
1103
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1104
+ out_path.write_bytes(final_path.read_bytes())
1105
+ final_path = out_path
1106
+
1107
+ console.print(f"[green]Saved:[/green] {final_path}")
1108
+ console.print(f"[dim]file_id: {record.id}[/dim]")
1109
+
1110
+
1111
+ @cli.command("bench")
1112
+ @click.option("--out", "out", default=None, help="JSON output path (default: data/bench/<utc>.json)")
1113
+ def bench(out: str | None):
1114
+ """Run the benchmark suite (no LLM calls — uses an in-process stub)."""
1115
+ from bench.runner import main_async
1116
+ asyncio.run(main_async(Path(out) if out else None))
1117
+
1118
+
1119
+ @cli.command()
1120
+ def update():
1121
+ """Pull the latest version (git checkout) or upgrade pip install."""
1122
+ from core.updater import update as _update
1123
+ result = _update()
1124
+ border = "green" if result.changed else "yellow"
1125
+ console.print(Panel(
1126
+ f"mode: {result.mode}\n{result.summary}\n\n{result.details}".strip(),
1127
+ title="cognos update",
1128
+ border_style=border,
1129
+ ))
1130
+
1131
+
1132
+ @cli.group()
1133
+ def cron():
1134
+ """Scheduled prompts."""
1135
+ pass
1136
+
1137
+
1138
+ @cron.command("list")
1139
+ def cron_list():
1140
+ """List scheduled jobs."""
1141
+ from core.scheduler import CronStore
1142
+ from rich.table import Table
1143
+ store = CronStore(Path("data/cron.json"))
1144
+ jobs = store.list()
1145
+ if not jobs:
1146
+ console.print("[dim]No scheduled jobs.[/dim]")
1147
+ return
1148
+ table = Table(title="Cron")
1149
+ table.add_column("id"); table.add_column("schedule"); table.add_column("next"); table.add_column("prompt")
1150
+ for j in jobs:
1151
+ table.add_row(j.id, j.schedule, j.next_run or "-", j.prompt[:60])
1152
+ console.print(table)
1153
+
1154
+
1155
+ @cron.command("add")
1156
+ @click.argument("schedule")
1157
+ @click.argument("prompt")
1158
+ def cron_add(schedule: str, prompt: str):
1159
+ """Schedule a recurring prompt. SCHEDULE is e.g. 'every 1h' or 'daily 09:00'."""
1160
+ from core.scheduler import CronStore
1161
+ store = CronStore(Path("data/cron.json"))
1162
+ try:
1163
+ job = store.add(prompt=prompt, schedule=schedule)
1164
+ except Exception as e:
1165
+ console.print(f"[red]{e}[/red]")
1166
+ return
1167
+ console.print(f"Scheduled {job.id}: next={job.next_run}")
1168
+
1169
+
1170
+ @cron.command("remove")
1171
+ @click.argument("job_id")
1172
+ def cron_remove(job_id: str):
1173
+ """Remove a scheduled job."""
1174
+ from core.scheduler import CronStore
1175
+ store = CronStore(Path("data/cron.json"))
1176
+ if store.remove(job_id):
1177
+ console.print(f"Removed {job_id}")
1178
+ else:
1179
+ console.print(f"[red]Not found: {job_id}[/red]")
1180
+
1181
+
1182
+ @cron.command("run")
1183
+ @click.option("--model", default=None, help="Model used to fire jobs")
1184
+ def cron_run(model: str | None):
1185
+ """Run the scheduler loop in the foreground until Ctrl+C."""
1186
+ from core.scheduler import CronStore, run_scheduler
1187
+
1188
+ async def _go():
1189
+ store = CronStore(Path("data/cron.json"))
1190
+ agent_local = CognosAgent(model=model, mode="agentic", permission_mode="bypass")
1191
+
1192
+ async def fire(job):
1193
+ console.print(f"[dim]cron fire {job.id}: {job.prompt}[/dim]")
1194
+ try:
1195
+ reply = await agent_local.chat(job.prompt)
1196
+ console.print(Panel(reply, title=f"cron {job.id}", border_style="cyan"))
1197
+ except Exception as e:
1198
+ console.print(f"[red]cron {job.id} failed: {e}[/red]")
1199
+
1200
+ await run_scheduler(store, fire)
1201
+
1202
+ try:
1203
+ asyncio.run(_go())
1204
+ except KeyboardInterrupt:
1205
+ console.print("\n[yellow]Scheduler stopped.[/yellow]")
1206
+
1207
+
1208
+ @sessions.command("export")
1209
+ @click.argument("session_id")
1210
+ @click.option("--format", "fmt", type=click.Choice(["markdown", "md", "json", "html"]), default="markdown")
1211
+ @click.option("--out", "out", default=None, help="Output path (defaults to data/exports/<id>.<ext>)")
1212
+ def sessions_export(session_id: str, fmt: str, out: str | None):
1213
+ """Export a session to a file."""
1214
+ from core.session import SessionManager
1215
+ from core.export import export_session
1216
+ from config import SESSIONS_DIR
1217
+ sm = SessionManager(SESSIONS_DIR)
1218
+ s = sm.load(session_id)
1219
+ if s is None:
1220
+ console.print(f"[red]Session not found: {session_id}[/red]")
1221
+ return
1222
+ ext = {"markdown": "md", "md": "md", "json": "json", "html": "html"}[fmt]
1223
+ path = Path(out) if out else Path(f"data/exports/{session_id}.{ext}")
1224
+ export_session(s, path, format=fmt)
1225
+ console.print(f"Exported → {path}")
1226
+
1227
+
1228
+ @cli.command()
1229
+ def info():
1230
+ """Show system information."""
1231
+ from execution.executor import Executor
1232
+ from memory.procedural import ProceduralMemory
1233
+
1234
+ executor = Executor()
1235
+ proc = ProceduralMemory()
1236
+
1237
+ console.print(Panel(
1238
+ f"[bold]Available Tools:[/bold]\n{executor.tool_descriptions()}\n\n"
1239
+ f"[bold]Learned Strategies:[/bold] {len(proc.get_strategies())}\n"
1240
+ f"[bold]Performance Logs:[/bold] {len(proc.get_performance_history())}",
1241
+ title="cognos info",
1242
+ border_style="green",
1243
+ ))
1244
+
1245
+
1246
+ # ────────────────────────── Forge subcommand ─────────────────────────
1247
+ # `cognos forge ...` — autonomous coding harness ported from LocalForge.
1248
+ # Bootstrap a feature backlog from a project description, drive the
1249
+ # orchestrator, manage projects/features. State lives in data/cognos.db
1250
+ # (forge_* tables). See planning/forge_models.py for the schema and
1251
+ # planning/orchestrator.py for execution.
1252
+
1253
+
1254
+ @cli.group()
1255
+ def forge():
1256
+ """Forge — autonomous coding harness. Bootstrap features, run agents.
1257
+
1258
+ Quick start:
1259
+ cognos forge new "my-app" --description "..." --folder /path/to/code
1260
+ cognos forge bootstrap "build a recipe site" --project 1 --stack web
1261
+ cognos forge feature list --project 1
1262
+ cognos forge start --project 1 # run agents on the backlog
1263
+ """
1264
+ pass
1265
+
1266
+
1267
+ @forge.command("new")
1268
+ @click.argument("name")
1269
+ @click.option("--description", default=None, help="Project description")
1270
+ @click.option("--folder", default=None,
1271
+ help="Project working directory (default: sandbox/projects/<slug>/)")
1272
+ def forge_new(name: str, description: str | None, folder: str | None):
1273
+ """Create a new forge project."""
1274
+ from planning.forge_models import init, create_project
1275
+ from pathlib import Path
1276
+ import re
1277
+
1278
+ init()
1279
+ if folder is None:
1280
+ slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", name.lower()).strip("-")
1281
+ folder = str(Path("sandbox/projects") / slug)
1282
+ Path(folder).mkdir(parents=True, exist_ok=True)
1283
+ pid = create_project(name, description, folder)
1284
+ console.print(
1285
+ f"[green]✓[/green] Created project [bold]{name}[/bold] "
1286
+ f"(id={pid}) at [cyan]{folder}[/cyan]"
1287
+ )
1288
+
1289
+
1290
+ @forge.command("projects")
1291
+ def forge_projects():
1292
+ """List all forge projects."""
1293
+ from planning.forge_models import init, list_projects
1294
+ init()
1295
+ rows = list_projects()
1296
+ if not rows:
1297
+ console.print("[dim]No forge projects yet. "
1298
+ "Try: cognos forge new \"my-app\"[/dim]")
1299
+ return
1300
+ for p in rows:
1301
+ console.print(
1302
+ f" [bold]#{p['id']}[/bold] {p['name']} "
1303
+ f"[dim]({p['status']})[/dim] → [cyan]{p['folder_path']}[/cyan]"
1304
+ )
1305
+ if p["description"]:
1306
+ console.print(f" [dim]{p['description']}[/dim]")
1307
+
1308
+
1309
+ @forge.command("bootstrap")
1310
+ @click.argument("goal")
1311
+ @click.option("--project", "-p", "project_id", type=int, required=True,
1312
+ help="Project id to add features to (use `cognos forge projects`)")
1313
+ @click.option("--stack", multiple=True,
1314
+ help="Stack hints, e.g. --stack python --stack fastapi. "
1315
+ "Repeat for multi-tag.")
1316
+ @click.option("--max-features", default=15, type=int,
1317
+ help="Cap the size of the generated backlog")
1318
+ @click.option("--dry-run", is_flag=True,
1319
+ help="Print the proposed backlog but don't write to DB")
1320
+ def forge_bootstrap(goal: str, project_id: int, stack: tuple[str, ...],
1321
+ max_features: int, dry_run: bool):
1322
+ """Decompose a project description into a forge feature backlog.
1323
+
1324
+ Calls the System 2 LLM via the bootstrapper prompt; produces a
1325
+ structured backlog with priorities, dependencies, and a suggested
1326
+ verify_command per feature.
1327
+ """
1328
+ from planning.forge_models import (
1329
+ init, get_project, create_feature, add_dependency,
1330
+ )
1331
+ from planning.planner import Planner
1332
+ from llm.provider import LLMProvider
1333
+ import asyncio
1334
+
1335
+ setup_logging(verbose=False)
1336
+ init()
1337
+
1338
+ proj = get_project(project_id)
1339
+ if not proj:
1340
+ console.print(f"[red]✗ Project {project_id} not found.[/red] "
1341
+ f"List projects: cognos forge projects")
1342
+ raise click.Abort()
1343
+
1344
+ console.print(
1345
+ f"[dim]Bootstrapping features for project "
1346
+ f"[bold]{proj['name']}[/bold] "
1347
+ f"(stack={list(stack) or 'auto'}, max={max_features})…[/dim]"
1348
+ )
1349
+
1350
+ async def _bootstrap():
1351
+ # Bootstrapping is heavy reasoning work — use the System 2 (slow)
1352
+ # Local-first: bootstrap is a structured-output task, well within
1353
+ # reach of System-1 Ollama models (GLM, Qwen, Llama). Only falls
1354
+ # back to System-2 (often a cloud model) if no System-1 is set.
1355
+ from core.settings import Settings
1356
+ from config import SYSTEM_1_MODEL, SYSTEM_2_MODEL
1357
+ settings = Settings.load()
1358
+ chosen = (
1359
+ settings.get("system1") or SYSTEM_1_MODEL
1360
+ or settings.get("system2") or SYSTEM_2_MODEL
1361
+ or LLM_MODEL
1362
+ )
1363
+ console.print(f"[dim]Using model: {chosen}[/dim]")
1364
+ llm = LLMProvider(model=chosen)
1365
+ planner = Planner(llm)
1366
+ # If the chosen model is an Anthropic model, the user likely
1367
+ # authenticates via the Claude Code OAuth token instead of an
1368
+ # API key — flip the subscription scope so LLMProvider picks
1369
+ # it up.
1370
+ from core.anthropic_auth import subscription_auth_scope
1371
+ with subscription_auth_scope():
1372
+ return await planner.decompose_to_features(
1373
+ goal=goal, stack_hints=list(stack), max_features=max_features,
1374
+ )
1375
+
1376
+ try:
1377
+ features = asyncio.run(_bootstrap())
1378
+ except Exception as e:
1379
+ console.print(f"[red]✗ Bootstrapper failed:[/red] {e}")
1380
+ raise click.Abort()
1381
+
1382
+ if not features:
1383
+ console.print("[yellow]Bootstrapper produced 0 features.[/yellow]")
1384
+ return
1385
+
1386
+ # Render proposal first
1387
+ console.print(f"\n[bold]Proposed backlog ({len(features)} features):[/bold]\n")
1388
+ for idx, f in enumerate(features):
1389
+ deps = f"deps={f.depends_on}" if f.depends_on else ""
1390
+ verify = f"verify={f.verify_command!r}" if f.verify_command else ""
1391
+ meta = " ".join(x for x in [deps, verify] if x)
1392
+ console.print(
1393
+ f" [{idx}] [bold]{f.title}[/bold] "
1394
+ f"[dim]P{f.priority} {f.category} {meta}[/dim]"
1395
+ )
1396
+ if f.description:
1397
+ preview = f.description[:120].replace("\n", " ")
1398
+ console.print(f" [dim]{preview}…[/dim]")
1399
+
1400
+ if dry_run:
1401
+ console.print("\n[yellow]Dry-run: nothing written to DB.[/yellow]")
1402
+ return
1403
+
1404
+ # Write features in order, then resolve depends_on indices to ids
1405
+ idx_to_id: dict[int, int] = {}
1406
+ for idx, f in enumerate(features):
1407
+ fid = create_feature(
1408
+ project_id=project_id,
1409
+ title=f.title,
1410
+ description=f.description,
1411
+ acceptance_criteria=f.acceptance_criteria,
1412
+ priority=f.priority,
1413
+ category=f.category if f.category in ("functional", "style")
1414
+ else "functional",
1415
+ verify_command=f.verify_command,
1416
+ )
1417
+ idx_to_id[idx] = fid
1418
+
1419
+ for idx, f in enumerate(features):
1420
+ for dep_idx in f.depends_on:
1421
+ if dep_idx in idx_to_id and dep_idx != idx:
1422
+ try:
1423
+ add_dependency(idx_to_id[idx], idx_to_id[dep_idx])
1424
+ except Exception as e:
1425
+ logger.warning(f"failed to wire dep {idx}→{dep_idx}: {e}")
1426
+
1427
+ console.print(
1428
+ f"\n[green]✓[/green] Wrote {len(features)} features to project "
1429
+ f"[bold]{proj['name']}[/bold]. "
1430
+ f"Inspect: [cyan]cognos forge feature list -p {project_id}[/cyan]"
1431
+ )
1432
+
1433
+
1434
+ @forge.group("feature")
1435
+ def forge_feature():
1436
+ """Manage features inside a project."""
1437
+ pass
1438
+
1439
+
1440
+ @forge_feature.command("list")
1441
+ @click.option("--project", "-p", "project_id", type=int, required=True)
1442
+ def forge_feature_list(project_id: int):
1443
+ """List all features for a project, ordered by priority."""
1444
+ from planning.forge_models import init, list_features, get_project
1445
+ init()
1446
+ proj = get_project(project_id)
1447
+ if not proj:
1448
+ console.print(f"[red]✗ Project {project_id} not found.[/red]")
1449
+ raise click.Abort()
1450
+ rows = list_features(project_id)
1451
+ if not rows:
1452
+ console.print(f"[dim]No features yet for project "
1453
+ f"[bold]{proj['name']}[/bold]. "
1454
+ f"Try: cognos forge bootstrap \"...\" -p {project_id}[/dim]")
1455
+ return
1456
+ by_status = {"backlog": [], "in_progress": [], "completed": []}
1457
+ for r in rows:
1458
+ by_status.setdefault(r["status"], []).append(r)
1459
+ console.print(f"\n[bold]{proj['name']}[/bold] kanban:\n")
1460
+ for status in ("backlog", "in_progress", "completed"):
1461
+ items = by_status.get(status, [])
1462
+ console.print(f" [bold]{status.upper()}[/bold] ({len(items)}):")
1463
+ for r in items:
1464
+ deps = f" deps={r['depends_on']}" if r['depends_on'] else ""
1465
+ verify = f" verify={r['verify_command']!r}" if r['verify_command'] else ""
1466
+ console.print(
1467
+ f" #{r['id']} P{r['priority']} [{r['category']}] "
1468
+ f"{r['title']}[dim]{deps}{verify}[/dim]"
1469
+ )
1470
+
1471
+
1472
+ @forge_feature.command("add")
1473
+ @click.option("--project", "-p", "project_id", type=int, required=True)
1474
+ @click.option("--title", required=True)
1475
+ @click.option("--description", default=None)
1476
+ @click.option("--criteria", "ac", default=None,
1477
+ help="Acceptance criteria (free-form)")
1478
+ @click.option("--priority", default=0, type=int)
1479
+ @click.option("--category",
1480
+ type=click.Choice(["functional", "style"]),
1481
+ default="functional")
1482
+ @click.option("--verify", "verify_command", default=None)
1483
+ def forge_feature_add(project_id: int, title: str, description: str | None,
1484
+ ac: str | None, priority: int, category: str,
1485
+ verify_command: str | None):
1486
+ """Manually add a feature to a project's backlog."""
1487
+ from planning.forge_models import init, create_feature, get_project
1488
+ init()
1489
+ if not get_project(project_id):
1490
+ console.print(f"[red]✗ Project {project_id} not found.[/red]")
1491
+ raise click.Abort()
1492
+ fid = create_feature(
1493
+ project_id=project_id, title=title, description=description,
1494
+ acceptance_criteria=ac, priority=priority, category=category,
1495
+ verify_command=verify_command,
1496
+ )
1497
+ console.print(f"[green]✓[/green] Added feature [bold]#{fid}[/bold]: {title}")
1498
+
1499
+
1500
+ @forge_feature.command("dep")
1501
+ @click.argument("feature_id", type=int)
1502
+ @click.argument("depends_on_feature_id", type=int)
1503
+ def forge_feature_dep(feature_id: int, depends_on_feature_id: int):
1504
+ """Wire a dependency: feature_id depends on depends_on_feature_id."""
1505
+ from planning.forge_models import init, add_dependency
1506
+ init()
1507
+ try:
1508
+ add_dependency(feature_id, depends_on_feature_id)
1509
+ except ValueError as e:
1510
+ console.print(f"[red]✗ {e}[/red]")
1511
+ raise click.Abort()
1512
+ console.print(
1513
+ f"[green]✓[/green] {feature_id} now depends on {depends_on_feature_id}"
1514
+ )
1515
+
1516
+
1517
+ @forge.command("start")
1518
+ @click.option("--project", "-p", "project_id", type=int, required=True)
1519
+ @click.option("--max-concurrent", default=None, type=int,
1520
+ help="Override settings.forge.max_concurrent for this run")
1521
+ def forge_start(project_id: int, max_concurrent: int | None):
1522
+ """Start the orchestrator: pick the next ready feature, run an agent.
1523
+
1524
+ Repeats until there are no ready features or the max-concurrent cap
1525
+ is hit. Auto-continues after each feature finishes.
1526
+ """
1527
+ import asyncio
1528
+ from planning.orchestrator import (
1529
+ start_orchestrator, OrchestratorError, _state, subscribe_global,
1530
+ )
1531
+ setup_logging(verbose=False)
1532
+
1533
+ if max_concurrent is not None:
1534
+ # Inject as an env-style override for this process. The
1535
+ # orchestrator reads from settings on each call.
1536
+ import os
1537
+ os.environ["COGNOS_FORGE_MAX_CONCURRENT"] = str(max_concurrent)
1538
+
1539
+ async def _run():
1540
+ # Kick off one start; auto-continue fills additional slots.
1541
+ try:
1542
+ res = await start_orchestrator(project_id)
1543
+ console.print(
1544
+ f"[green]▶[/green] session #{res['session_id']} on "
1545
+ f"feature [bold]{res['feature_title']}[/bold] "
1546
+ f"(#{res['feature_id']})"
1547
+ )
1548
+ except OrchestratorError as e:
1549
+ console.print(f"[red]✗ {e}[/red]")
1550
+ return
1551
+
1552
+ # Tail global events until no sessions remain running.
1553
+ async for ev in subscribe_global():
1554
+ t = ev.get("type")
1555
+ if t == "log":
1556
+ colour = {"error": "red", "action": "cyan",
1557
+ "test_result": "green"}.get(ev["message_type"], "")
1558
+ pre = f"[{colour}]" if colour else ""
1559
+ post = "[/]" if colour else ""
1560
+ console.print(f" {pre}[{ev['session_id']}] {ev['message']}{post}")
1561
+ elif t == "status":
1562
+ console.print(
1563
+ f"[bold]session {ev['session_id']}[/bold] → "
1564
+ f"{ev['session_status']}"
1565
+ )
1566
+ # When the running map empties, we're done.
1567
+ if not _state().running:
1568
+ console.print("[dim]all agents done[/dim]")
1569
+ return
1570
+
1571
+ try:
1572
+ asyncio.run(_run())
1573
+ except KeyboardInterrupt:
1574
+ console.print("[yellow]interrupted; in-flight sessions will be "
1575
+ "reaped on next start[/yellow]")
1576
+
1577
+
1578
+ @forge.command("stop")
1579
+ @click.option("--session", "session_id", type=int, default=None)
1580
+ @click.option("--project", "project_id", type=int, default=None)
1581
+ @click.option("--all", "stop_all", is_flag=True,
1582
+ help="Stop all running sessions across all projects")
1583
+ def forge_stop(session_id: int | None, project_id: int | None, stop_all: bool):
1584
+ """Stop a running session or sessions."""
1585
+ from planning.orchestrator import (
1586
+ stop_session, stop_all_for_project, _state,
1587
+ )
1588
+ if stop_all:
1589
+ n = 0
1590
+ for sid in list(_state().running.keys()):
1591
+ stop_session(sid)
1592
+ n += 1
1593
+ console.print(f"[yellow]stopped {n} sessions[/yellow]")
1594
+ elif project_id is not None:
1595
+ n = stop_all_for_project(project_id)
1596
+ console.print(
1597
+ f"[yellow]stopped {n} sessions for project {project_id}[/yellow]"
1598
+ )
1599
+ elif session_id is not None:
1600
+ if stop_session(session_id):
1601
+ console.print(f"[yellow]stopped session {session_id}[/yellow]")
1602
+ else:
1603
+ console.print(f"[dim]session {session_id} was not live[/dim]")
1604
+ else:
1605
+ console.print(
1606
+ "[red]✗ pass one of: --session ID | --project ID | --all[/red]"
1607
+ )
1608
+ raise click.Abort()
1609
+
1610
+
1611
+ @forge.command("status")
1612
+ @click.option("--project", "-p", "project_id", type=int, default=None)
1613
+ def forge_status(project_id: int | None):
1614
+ """Print kanban state. Without --project, summarises every project."""
1615
+ from planning.forge_models import init, list_projects
1616
+ from planning.orchestrator import get_kanban_state, _state
1617
+ init()
1618
+ targets = ([project_id] if project_id is not None
1619
+ else [p["id"] for p in list_projects()])
1620
+ if not targets:
1621
+ console.print("[dim]no projects yet[/dim]")
1622
+ return
1623
+ for pid in targets:
1624
+ try:
1625
+ state = get_kanban_state(pid)
1626
+ except Exception as e:
1627
+ console.print(f"[red]✗ {pid}: {e}[/red]")
1628
+ continue
1629
+ proj = state["project"]
1630
+ feats = state["features"]
1631
+ running = state["running_feature_ids"]
1632
+ cap = state["max_concurrent"]
1633
+ cols = {"backlog": [], "in_progress": [], "completed": []}
1634
+ for f in feats:
1635
+ cols.setdefault(f["status"], []).append(f)
1636
+ console.print(
1637
+ f"\n[bold cyan]{proj['name']}[/bold cyan] "
1638
+ f"[dim]({proj['status']}, {len(running)}/{cap} agents live)[/dim]"
1639
+ )
1640
+ for s in ("backlog", "in_progress", "completed"):
1641
+ items = cols.get(s, [])
1642
+ console.print(f" [bold]{s.upper()}[/bold] ({len(items)}):")
1643
+ for f in items:
1644
+ running_marker = "▶" if f["id"] in running else " "
1645
+ deps = f" deps={f['depends_on']}" if f.get("depends_on") else ""
1646
+ console.print(
1647
+ f" {running_marker} #{f['id']} P{f['priority']} "
1648
+ f"{f['title']}[dim]{deps}[/dim]"
1649
+ )
1650
+
1651
+
1652
+ @forge.command("attach")
1653
+ @click.argument("session_id", type=int)
1654
+ def forge_attach(session_id: int):
1655
+ """Tail the live event stream for a session."""
1656
+ import asyncio
1657
+ from planning.orchestrator import subscribe_session
1658
+ setup_logging(verbose=False)
1659
+
1660
+ async def _run():
1661
+ async for ev in subscribe_session(session_id):
1662
+ t = ev.get("type")
1663
+ if t == "log":
1664
+ replay = " [replay]" if ev.get("replay") else ""
1665
+ colour = {"error": "red", "action": "cyan",
1666
+ "test_result": "green"}.get(ev.get("message_type", ""), "")
1667
+ pre = f"[{colour}]" if colour else ""
1668
+ post = "[/]" if colour else ""
1669
+ console.print(f"{pre}{ev['message']}{post}{replay}")
1670
+ elif t == "status":
1671
+ console.print(
1672
+ f"[bold]→ {ev['session_status']}[/bold]"
1673
+ )
1674
+ elif t == "heartbeat":
1675
+ pass
1676
+ try:
1677
+ asyncio.run(_run())
1678
+ except KeyboardInterrupt:
1679
+ pass
1680
+
1681
+
1682
+ @cli.group()
1683
+ def skills():
1684
+ """Manage SKILL.md capability packs (lock, verify, list)."""
1685
+
1686
+
1687
+ @skills.command("list")
1688
+ def skills_list():
1689
+ """List all loaded skills (TOC)."""
1690
+ from core.skills import get_skills
1691
+ reg = get_skills()
1692
+ if not reg.skills:
1693
+ console.print("[yellow]No skills installed.[/yellow]")
1694
+ return
1695
+ from rich.table import Table
1696
+ t = Table(title=f"Skills ({len(reg.skills)})")
1697
+ t.add_column("name"); t.add_column("source"); t.add_column("description")
1698
+ for name in sorted(reg.skills):
1699
+ s = reg.skills[name]
1700
+ t.add_row(s.name, str(s.source_dir.name), s.description[:80])
1701
+ console.print(t)
1702
+
1703
+
1704
+ @skills.command("lock")
1705
+ @click.option("--out", "out_path", default=None,
1706
+ help="Lockfile path (defaults to data/skills.lock.json)")
1707
+ def skills_lock(out_path: str | None):
1708
+ """Write data/skills.lock.json — content hash of every SKILL.md."""
1709
+ from core.skills import lock_skills, get_skills, LOCKFILE_PATH
1710
+ reg = get_skills()
1711
+ target = Path(out_path) if out_path else LOCKFILE_PATH
1712
+ payload = lock_skills(reg, lock_path=target)
1713
+ console.print(
1714
+ f"[green]locked[/] {payload['count']} skill(s) → {target}"
1715
+ )
1716
+
1717
+
1718
+ @skills.command("verify")
1719
+ @click.option("--lock", "lock_path", default=None,
1720
+ help="Lockfile path (defaults to data/skills.lock.json)")
1721
+ def skills_verify(lock_path: str | None):
1722
+ """Compare live skills against the lockfile; non-zero exit on drift."""
1723
+ from core.skills import verify_skills, LOCKFILE_PATH
1724
+ target = Path(lock_path) if lock_path else LOCKFILE_PATH
1725
+ rep = verify_skills(target)
1726
+ if rep.get("error"):
1727
+ console.print(f"[red]{rep['error']}[/red]")
1728
+ raise click.exceptions.Exit(2)
1729
+ if rep["ok"]:
1730
+ console.print(
1731
+ f"[green]✓ verified[/] {rep['unchanged']} skill(s) match {rep['lock_path']}"
1732
+ )
1733
+ return
1734
+ console.print(f"[yellow]⚠ skill drift detected[/yellow] (lock: {rep['lock_path']})")
1735
+ if rep["missing"]:
1736
+ console.print(f" removed: {', '.join(rep['missing'])}")
1737
+ if rep["added"]:
1738
+ console.print(f" added: {', '.join(rep['added'])}")
1739
+ if rep["changed"]:
1740
+ console.print(f" changed: {', '.join(rep['changed'])}")
1741
+ raise click.exceptions.Exit(1)
1742
+
1743
+
1744
+ @skills.command("self")
1745
+ @click.option("--write/--no-write", default=True,
1746
+ help="Write skills/forge/SKILL.md (default) or just print.")
1747
+ def skills_self(write: bool):
1748
+ """Generate the auto-documenting SKILL.md for the forge command.
1749
+
1750
+ The skill teaches future LLMs how to drive ``cognos forge`` from
1751
+ inside a chat session. Regenerated on demand so it stays in sync
1752
+ with the actual CLI.
1753
+ """
1754
+ body = _render_forge_self_skill()
1755
+ target = Path("/home/raveuk/cognos/skills/forge/SKILL.md")
1756
+ if write:
1757
+ target.parent.mkdir(parents=True, exist_ok=True)
1758
+ target.write_text(body)
1759
+ console.print(f"[green]wrote[/] {target} ({len(body)} bytes)")
1760
+ else:
1761
+ console.print(body)
1762
+
1763
+
1764
+ def _render_forge_self_skill() -> str:
1765
+ """Self-documenting SKILL.md derived from this CLI."""
1766
+ return """---
1767
+ name: forge
1768
+ description: Drive `cognos forge` — autonomous coding harness — from a chat. \
1769
+ Decompose a goal into a feature backlog, then run sessions until the project \
1770
+ is complete.
1771
+ ---
1772
+
1773
+ # Cognos Forge
1774
+
1775
+ Forge is Cognos's autonomous coding harness, ported from LocalForge. It
1776
+ turns a natural-language goal into a kanban backlog, then runs an LLM
1777
+ agent on each feature with a watchdog and dependency-aware scheduling.
1778
+
1779
+ ## When to use this skill
1780
+
1781
+ - The user wants to build something multi-step that can be split into
1782
+ small features (each ≤ ~30 minutes of agent work).
1783
+ - They want hands-off execution: kanban runs in the background, the
1784
+ user can watch progress in the web UI or via `cognos forge status`.
1785
+
1786
+ ## Surface area
1787
+
1788
+ CLI (a click group):
1789
+
1790
+ ```
1791
+ cognos forge new <name> [--description ...] [--folder ...]
1792
+ cognos forge projects
1793
+ cognos forge bootstrap <goal> --project-id <id> [--stack python,fastapi]
1794
+ cognos forge feature list --project-id <id>
1795
+ cognos forge feature add --project-id <id> --title ... [--priority N]
1796
+ cognos forge feature dep <feature_id> <depends_on>
1797
+ cognos forge start --project-id <id> [--max-concurrent N]
1798
+ cognos forge stop [--session-id N | --project-id N | --all]
1799
+ cognos forge status [--project-id <id>]
1800
+ cognos forge attach <session-id> # tail logs live
1801
+ ```
1802
+
1803
+ REST + SSE (under `/forge`, served by `cognos serve`):
1804
+
1805
+ ```
1806
+ GET /forge/projects
1807
+ POST /forge/projects {name, description?, folder_path?}
1808
+ GET /forge/projects/{id} → kanban state
1809
+ POST /forge/projects/{id}/bootstrap {goal, stack_hints[], max_features}
1810
+ POST /forge/projects/{id}/start
1811
+ POST /forge/projects/{id}/stop
1812
+ GET /forge/sessions/{id}/events SSE
1813
+ GET /forge/events SSE (global)
1814
+ ```
1815
+
1816
+ Web UI: open the **Forge** tab in the sidebar at
1817
+ `http://<host>:8000/ui/`. The kanban board mirrors the CLI state.
1818
+
1819
+ ## Recommended flow
1820
+
1821
+ 1. `cognos forge new "<project name>"` — creates the row + sandbox dir.
1822
+ 2. `cognos forge bootstrap "<one-paragraph goal>" --project-id <id>` —
1823
+ the System-2 model decomposes into 5–15 features with dependencies.
1824
+ 3. Review with `cognos forge feature list --project-id <id>`.
1825
+ 4. `cognos forge start --project-id <id>` — orchestrator picks the
1826
+ highest-priority backlog feature whose deps are completed and
1827
+ spawns the agent.
1828
+ 5. Watch via `cognos forge attach <session-id>` or the web UI.
1829
+ 6. Auto-continue cascades A → B → C through the backlog.
1830
+
1831
+ ## Storage
1832
+
1833
+ Everything in `data/cognos.db` (SQLAlchemy):
1834
+ - `forge_projects`, `forge_features`, `forge_feature_deps`
1835
+ - `forge_sessions`, `forge_logs`, `forge_chat_messages`, `forge_settings`
1836
+
1837
+ ## Caudate hook
1838
+
1839
+ Each completed/failed session emits a feature-outcome sample to the
1840
+ neural observer (`nn/observer.py`). Caudate learns which models +
1841
+ prompts succeed at which feature shapes and biases future routing.
1842
+
1843
+ ## Don'ts
1844
+
1845
+ - Don't bootstrap into an empty project root that already contains
1846
+ unrelated files — Forge writes inside the project's `folder_path`.
1847
+ - Don't pick a System-2 model for `--system2` that lacks tool calling;
1848
+ the bootstrapper needs structured JSON output.
1849
+ """
1850
+
1851
+
1852
+ # LocalForge dev-script ports — every diagnostic, verify, setup, cleanup
1853
+ # helper from LocalForge's scripts/ ported to Python and registered as a
1854
+ # `cognos forge-tools <cmd>` click subcommand suite. Lazy-loaded so its
1855
+ # rich/sqlalchemy startup cost is paid only when invoked.
1856
+ try:
1857
+ from bin.forge_scripts import cli as _forge_tools_cli
1858
+ cli.add_command(_forge_tools_cli, name="forge-tools")
1859
+ except Exception as _e: # pragma: no cover
1860
+ logging.getLogger(__name__).debug(f"forge-tools registration skipped: {_e}")
1861
+
1862
+
1863
+ @cli.command("test-e2e")
1864
+ @click.option("--headed", is_flag=True, help="Run with the browser visible.")
1865
+ @click.option("--base-url", default="http://127.0.0.1:8000",
1866
+ help="URL of a running cognos-serve.")
1867
+ @click.argument("playwright_args", nargs=-1)
1868
+ def test_e2e(headed: bool, base_url: str, playwright_args: tuple[str, ...]):
1869
+ """Run the Playwright e2e suite for the Forge UI.
1870
+
1871
+ Requires Node + Playwright. Pass through extra Playwright args via:
1872
+ cognos test-e2e -- --grep="celebration"
1873
+ """
1874
+ import os, subprocess
1875
+ e2e_dir = Path("/home/raveuk/cognos/tests/e2e")
1876
+ if not (e2e_dir / "node_modules").exists():
1877
+ console.print("[yellow]Installing Playwright (first run)...[/]")
1878
+ subprocess.check_call(["npm", "install"], cwd=str(e2e_dir))
1879
+ args = ["npx", "playwright", "test"]
1880
+ if headed:
1881
+ args.append("--headed")
1882
+ args.extend(playwright_args)
1883
+ env = dict(os.environ)
1884
+ env["COGNOS_BASE_URL"] = base_url
1885
+ raise SystemExit(subprocess.call(args, cwd=str(e2e_dir), env=env))
1886
+
1887
+
1888
+ if __name__ == "__main__":
1889
+ cli()