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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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()
|