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
api/server.py ADDED
@@ -0,0 +1,667 @@
1
+ """FastAPI server — exposes Cognos as an HTTP service.
2
+
3
+ Endpoints:
4
+ POST /chat — send a message, get a reply (optional session)
5
+ POST /chat/stream — same, streamed as SSE
6
+ GET /sessions — list saved sessions
7
+ GET /sessions/{id} — fetch one session
8
+ DELETE /sessions/{id} — delete a session
9
+ GET /tools — list registered tools with schemas
10
+ GET /models — list detected models with capability flags
11
+ GET /healthz — liveness probe
12
+
13
+ Every `CognosAgent` is cached by session id so conversations are stateful
14
+ across requests. The API is NOT multi-tenant — it assumes a trusted caller
15
+ (no auth layer in this cut).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ from contextlib import asynccontextmanager
23
+ from typing import Any
24
+
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ from fastapi import FastAPI, File, HTTPException, UploadFile
29
+ from fastapi.responses import (
30
+ FileResponse, HTMLResponse, RedirectResponse, StreamingResponse,
31
+ )
32
+ from fastapi.staticfiles import StaticFiles
33
+ from pydantic import BaseModel, Field
34
+
35
+ from core.agent import CognosAgent
36
+ from core.citations import CitationBlock, Document
37
+ from core.files import FileStore
38
+ from core.session import SessionManager
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # ---- Request / response schemas ----
44
+
45
+
46
+ class ChatRequest(BaseModel):
47
+ message: str
48
+ session_id: str | None = None
49
+ model: str | None = None
50
+ system1: str | None = None
51
+ system2: str | None = None
52
+ permission_mode: str | None = Field(
53
+ default=None,
54
+ description="default|plan|accept_edits|bypass",
55
+ )
56
+ attachments: list[str] = Field(
57
+ default_factory=list,
58
+ description="File IDs previously uploaded via POST /files",
59
+ )
60
+ documents: list[Document] = Field(
61
+ default_factory=list,
62
+ description="Reference documents the model may cite",
63
+ )
64
+
65
+
66
+ class ChatResponse(BaseModel):
67
+ reply: str
68
+ session_id: str
69
+ mood: str | None = None
70
+ tool_calls: int = 0
71
+ citations: list[CitationBlock] = Field(default_factory=list)
72
+
73
+
74
+ class FIMRequest(BaseModel):
75
+ prefix: str
76
+ suffix: str = ""
77
+ # Default model picked at call time from llm.provider.DEFAULT_FIM_MODEL.
78
+ model: str | None = None
79
+ temperature: float | None = None
80
+ max_tokens: int | None = None
81
+ stop: list[str] | None = None
82
+
83
+
84
+ class FIMResponse(BaseModel):
85
+ completion: str
86
+ model: str
87
+
88
+
89
+ # ---- App factory ----
90
+
91
+
92
+ def create_app() -> FastAPI:
93
+ """Build the FastAPI application with its agent cache."""
94
+ agent_cache: dict[str, CognosAgent] = {}
95
+ lock = asyncio.Lock()
96
+
97
+ @asynccontextmanager
98
+ async def lifespan(app: FastAPI):
99
+ logger.info("Cognos API starting")
100
+ # Reclaim any forge sessions that were in_progress at the time
101
+ # the previous server died — flip them to terminated and bounce
102
+ # the feature back to backlog. Idempotent; safe even when no
103
+ # forge tables exist yet.
104
+ try:
105
+ from planning.orchestrator import reconcile_orphaned_sessions
106
+ n = reconcile_orphaned_sessions()
107
+ if n:
108
+ logger.info(f"forge: reconciled {n} orphaned session(s)")
109
+ except Exception as e:
110
+ logger.debug(f"forge reconcile skipped: {e}")
111
+ try:
112
+ yield
113
+ finally:
114
+ logger.info("Cognos API shutting down — stopping agents")
115
+ for a in agent_cache.values():
116
+ try:
117
+ await a.stop()
118
+ except Exception as e:
119
+ logger.debug(f"agent.stop failed: {e}")
120
+ # Reap dev servers spawned by /forge/projects/{id}/dev-server
121
+ try:
122
+ from planning import dev_server as _ds
123
+ n = _ds.stop_all()
124
+ if n:
125
+ logger.info(f"stopped {n} forge dev-server(s)")
126
+ except Exception as e:
127
+ logger.debug(f"dev_server.stop_all failed: {e}")
128
+
129
+ app = FastAPI(title="Cognos", version="1.0", lifespan=lifespan)
130
+
131
+ async def _get_agent(req: ChatRequest) -> CognosAgent:
132
+ """Fetch or create an agent bound to the given session."""
133
+ async with lock:
134
+ session_id = req.session_id
135
+ if session_id and session_id in agent_cache:
136
+ return agent_cache[session_id]
137
+
138
+ agent = CognosAgent(
139
+ model=req.model,
140
+ mode="agentic",
141
+ permission_mode=req.permission_mode,
142
+ session_id=session_id,
143
+ system1=req.system1,
144
+ system2=req.system2,
145
+ )
146
+ agent_cache[agent.session.id] = agent
147
+ return agent
148
+
149
+ # ------------------------------------------------------------------
150
+ # Routes
151
+ # ------------------------------------------------------------------
152
+
153
+ @app.get("/healthz")
154
+ async def healthz() -> dict[str, str]:
155
+ return {"status": "ok"}
156
+
157
+ @app.post("/v1/fim", response_model=FIMResponse)
158
+ async def fim(req: FIMRequest) -> FIMResponse:
159
+ """Fill-in-the-middle code completion.
160
+
161
+ Side-channel for editor autocomplete — does NOT touch the
162
+ System 1 / System 2 chat router. Default model is the local
163
+ qwen2.5-coder for low-latency single-line/short-block fills;
164
+ pass a heavier model id for multi-line gaps.
165
+ """
166
+ from llm.provider import fim_complete as _fim, DEFAULT_FIM_MODEL
167
+ model = req.model or DEFAULT_FIM_MODEL
168
+ try:
169
+ completion = await _fim(
170
+ prefix=req.prefix,
171
+ suffix=req.suffix,
172
+ model=model,
173
+ temperature=req.temperature,
174
+ max_tokens=req.max_tokens,
175
+ stop=req.stop,
176
+ )
177
+ except ValueError as e:
178
+ raise HTTPException(status_code=400, detail=str(e))
179
+ except Exception as e:
180
+ raise HTTPException(status_code=502, detail=f"FIM call failed: {e}")
181
+ return FIMResponse(completion=completion, model=model)
182
+
183
+ @app.post("/chat", response_model=ChatResponse)
184
+ async def chat(req: ChatRequest) -> ChatResponse:
185
+ if not req.message:
186
+ raise HTTPException(400, "message is required")
187
+ agent = await _get_agent(req)
188
+ # Web-UI calls are allowed to use the Claude Code subscription
189
+ # OAuth token (read live from ~/.claude/.credentials.json) when
190
+ # the configured model is anthropic/*. Any non-web caller of
191
+ # LLMProvider sees no change in behaviour.
192
+ from core.anthropic_auth import subscription_auth_scope
193
+ with subscription_auth_scope():
194
+ reply = await agent.chat(
195
+ req.message,
196
+ attachments=req.attachments or None,
197
+ documents=req.documents or None,
198
+ )
199
+ return ChatResponse(
200
+ reply=reply,
201
+ session_id=agent.session.id,
202
+ mood=agent.personality.mood.label() if agent.personality else None,
203
+ tool_calls=sum(1 for m in agent.agentic.messages if m.get("role") == "tool"),
204
+ citations=list(agent.agentic.last_citations),
205
+ )
206
+
207
+ @app.post("/chat/stream")
208
+ async def chat_stream(req: ChatRequest) -> StreamingResponse:
209
+ if not req.message:
210
+ raise HTTPException(400, "message is required")
211
+ agent = await _get_agent(req)
212
+ from core.anthropic_auth import subscription_auth_scope
213
+
214
+ async def _sse():
215
+ import json as _json
216
+ with subscription_auth_scope():
217
+ async for event in agent.agentic.run_streaming(req.message):
218
+ payload = _json.dumps(event.model_dump(mode="json"))
219
+ yield f"event: {event.type}\ndata: {payload}\n\n"
220
+ yield f"event: done\ndata: {{\"session_id\": \"{agent.session.id}\"}}\n\n"
221
+
222
+ return StreamingResponse(_sse(), media_type="text/event-stream")
223
+
224
+ @app.get("/sessions")
225
+ async def list_sessions() -> list[dict[str, Any]]:
226
+ from config import SESSIONS_DIR
227
+ sm = SessionManager(SESSIONS_DIR)
228
+ return [
229
+ {
230
+ "id": s.id, "title": s.title, "model": s.model,
231
+ "messages": len(s.messages),
232
+ "updated_at": s.updated_at.isoformat(timespec="seconds"),
233
+ }
234
+ for s in sm.list()
235
+ ]
236
+
237
+ @app.get("/sessions/{session_id}")
238
+ async def get_session(session_id: str) -> dict[str, Any]:
239
+ from config import SESSIONS_DIR
240
+ sm = SessionManager(SESSIONS_DIR)
241
+ s = sm.load(session_id)
242
+ if s is None:
243
+ raise HTTPException(404, "session not found")
244
+ return s.model_dump(mode="json")
245
+
246
+ @app.delete("/sessions/{session_id}")
247
+ async def delete_session(session_id: str) -> dict[str, bool]:
248
+ from config import SESSIONS_DIR
249
+ sm = SessionManager(SESSIONS_DIR)
250
+ deleted = sm.delete(session_id)
251
+ if session_id in agent_cache:
252
+ try:
253
+ await agent_cache[session_id].stop()
254
+ except Exception:
255
+ pass
256
+ del agent_cache[session_id]
257
+ if not deleted:
258
+ raise HTTPException(404, "session not found")
259
+ return {"deleted": True}
260
+
261
+ @app.post("/files")
262
+ async def upload_file(file: UploadFile = File(...)) -> dict[str, Any]:
263
+ """Upload a file into the local store; returns the new file_id."""
264
+ from config import FILES_DIR
265
+ store = FileStore(root=FILES_DIR)
266
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename or "upload").suffix) as tmp:
267
+ tmp.write(await file.read())
268
+ tmp_path = Path(tmp.name)
269
+ try:
270
+ record = store.upload(tmp_path, filename=file.filename or tmp_path.name)
271
+ finally:
272
+ tmp_path.unlink(missing_ok=True)
273
+ return record.model_dump(mode="json")
274
+
275
+ @app.get("/files")
276
+ async def list_files() -> list[dict[str, Any]]:
277
+ from config import FILES_DIR
278
+ store = FileStore(root=FILES_DIR)
279
+ return [r.model_dump(mode="json") for r in store.list()]
280
+
281
+ @app.delete("/files/{file_id}")
282
+ async def delete_file(file_id: str) -> dict[str, bool]:
283
+ from config import FILES_DIR
284
+ store = FileStore(root=FILES_DIR)
285
+ if not store.delete(file_id):
286
+ raise HTTPException(404, "file not found")
287
+ return {"deleted": True}
288
+
289
+ @app.get("/files/{file_id}/content")
290
+ async def get_file_content(file_id: str):
291
+ """Stream a stored file's bytes — used by the UI for inline image rendering."""
292
+ from config import FILES_DIR
293
+ store = FileStore(root=FILES_DIR)
294
+ rec = store.get(file_id)
295
+ if rec is None:
296
+ raise HTTPException(404, "file not found")
297
+ # Render-in-browser types must serve as `inline`; otherwise
298
+ # iframes / <img> / <audio> see Content-Disposition:attachment
299
+ # and abort the load (was breaking the artifact viewer's
300
+ # iframe for HTML files).
301
+ mime = rec.mime_type or "application/octet-stream"
302
+ inline_kinds = (
303
+ mime.startswith("image/") or
304
+ mime.startswith("audio/") or
305
+ mime.startswith("video/") or
306
+ mime.startswith("text/") or
307
+ mime in ("application/pdf", "application/json",
308
+ "image/svg+xml")
309
+ )
310
+ disposition = "inline" if inline_kinds else "attachment"
311
+ return FileResponse(
312
+ rec.path,
313
+ media_type=mime,
314
+ filename=rec.filename,
315
+ content_disposition_type=disposition,
316
+ )
317
+
318
+ @app.get("/artifact/{file_id}", response_class=HTMLResponse)
319
+ async def get_artifact_viewer(file_id: str):
320
+ """Polished single-page viewer for a FileStore artifact —
321
+ Phase 1 of COGNOS_UI_ROADMAP.md. Renders HTML/SVG/code/
322
+ markdown/image/audio with type-appropriate UI; serves a
323
+ download fallback for unknown binaries."""
324
+ from config import FILES_DIR
325
+ from api.artifact_viewer import render_artifact_page
326
+ store = FileStore(root=FILES_DIR)
327
+ rec = store.get(file_id)
328
+ if rec is None:
329
+ raise HTTPException(404, "file not found")
330
+ try:
331
+ content_bytes = Path(rec.path).read_bytes()
332
+ except OSError as e:
333
+ raise HTTPException(500, f"failed to read artifact: {e}")
334
+ page = render_artifact_page(
335
+ file_id=file_id,
336
+ filename=rec.filename,
337
+ mime_type=rec.mime_type or "",
338
+ size_bytes=rec.size_bytes,
339
+ content_bytes=content_bytes,
340
+ )
341
+ return HTMLResponse(content=page)
342
+
343
+ # ------------------------------------------------------------------
344
+ # Storyboard — dedicated SPA + SSE generation endpoint
345
+ # ------------------------------------------------------------------
346
+
347
+ # ------------------------------------------------------------------
348
+ # Storyboard React app (Approach C) — Vite-built SPA at
349
+ # /storyboard-app/. Falls back to index.html for client-side routes
350
+ # like /storyboard-app/storyboard, /storyboard-app/help, etc.
351
+ # ------------------------------------------------------------------
352
+ _STORYBOARD_DIST = Path(__file__).resolve().parent.parent / "ui" / "storyboard-app" / "dist"
353
+ if _STORYBOARD_DIST.is_dir():
354
+ # Static asset directory (JS, CSS, images bundled by Vite).
355
+ app.mount(
356
+ "/storyboard-app/assets",
357
+ StaticFiles(directory=str(_STORYBOARD_DIST / "assets")),
358
+ name="storyboard-app-assets",
359
+ )
360
+
361
+ # Vite copies anything under public/ to the dist root. We need
362
+ # an explicit route for the favicon (and any future top-level
363
+ # static files) so the catch-all route below doesn't shadow
364
+ # them by returning index.html.
365
+ @app.get("/storyboard-app/favicon.png")
366
+ async def storyboard_app_favicon() -> FileResponse:
367
+ return FileResponse(
368
+ str(_STORYBOARD_DIST / "favicon.png"),
369
+ media_type="image/png",
370
+ )
371
+
372
+ @app.get("/storyboard-app", response_class=HTMLResponse)
373
+ @app.get("/storyboard-app/", response_class=HTMLResponse)
374
+ @app.get("/storyboard-app/{client_route:path}",
375
+ response_class=HTMLResponse)
376
+ async def storyboard_app(client_route: str = "") -> HTMLResponse:
377
+ """Serve the React SPA for any client-side route. The
378
+ React Router (`basename=/storyboard-app`) takes over once
379
+ the JS bundle loads."""
380
+ # Real assets under /storyboard-app/assets are handled by
381
+ # the StaticFiles mount above and never reach this route.
382
+ # Anything else returns the bundled index.html so React
383
+ # Router can resolve the in-app path.
384
+ return HTMLResponse(
385
+ content=(_STORYBOARD_DIST / "index.html").read_text(
386
+ encoding="utf-8",
387
+ ),
388
+ )
389
+ else:
390
+ logger.info(
391
+ "storyboard-app dist not built; "
392
+ "run `cd ui/storyboard-app && npm run build` to enable /storyboard-app"
393
+ )
394
+
395
+ @app.get("/storyboard", response_class=HTMLResponse)
396
+ async def storyboard_page() -> HTMLResponse:
397
+ """Single-page HTML storyboard generator (Approach B).
398
+
399
+ Talks to `POST /storyboard/generate` over Server-Sent Events
400
+ for live progress as each panel renders.
401
+ """
402
+ from api.storyboard_page import render_storyboard_page
403
+ return HTMLResponse(content=render_storyboard_page())
404
+
405
+ @app.post("/storyboard/generate")
406
+ async def storyboard_generate(req: dict) -> StreamingResponse:
407
+ """SSE: stream storyboard breakdown + per-panel completion.
408
+
409
+ Request body (JSON):
410
+ { "story": str, "panels": int, "style": str,
411
+ "size": "1024x1024" | "1280x720" | "768x1024",
412
+ "seed": int | null }
413
+
414
+ Stream events (each as `data: {json}\\n\\n`):
415
+ {type: "breakdown", plan: {...}}
416
+ {type: "panel", index, file_id, scene_action, panel_text,
417
+ character_names_in_scene}
418
+ {type: "done", panel_count, panel_file_ids}
419
+ {type: "error", message}
420
+ """
421
+ import json as _json
422
+
423
+ # Reuse a long-lived storyboard session so the FLUX-schnell +
424
+ # Kontext pipelines stay loaded across requests.
425
+ session_id = "_storyboard_app"
426
+ async with lock:
427
+ agent = agent_cache.get(session_id)
428
+ if agent is None:
429
+ agent = CognosAgent(
430
+ mode="agentic", session_id=session_id,
431
+ )
432
+ agent_cache[session_id] = agent
433
+ tool = agent.loop.executor.get_tool("Storyboard")
434
+ if tool is None or not hasattr(tool, "execute_streaming"):
435
+ raise HTTPException(500, "Storyboard tool not registered")
436
+
437
+ story = (req.get("story") or "").strip()
438
+ if not story:
439
+ raise HTTPException(400, "`story` is required")
440
+
441
+ kwargs: dict[str, Any] = {
442
+ "story": story,
443
+ "panels": int(req.get("panels") or 6),
444
+ "style": (req.get("style") or "").strip(),
445
+ "size": req.get("size") or "1024x1024",
446
+ }
447
+ if req.get("seed") is not None:
448
+ try:
449
+ kwargs["seed"] = int(req["seed"])
450
+ except Exception:
451
+ pass
452
+
453
+ async def _event_stream():
454
+ try:
455
+ async for ev in tool.execute_streaming(**kwargs):
456
+ yield f"data: {_json.dumps(ev)}\n\n".encode()
457
+ except Exception as e:
458
+ logger.exception("storyboard SSE failed")
459
+ err = _json.dumps({"type": "error", "message": str(e)})
460
+ yield f"data: {err}\n\n".encode()
461
+
462
+ return StreamingResponse(
463
+ _event_stream(),
464
+ media_type="text/event-stream",
465
+ headers={
466
+ "Cache-Control": "no-cache",
467
+ "X-Accel-Buffering": "no", # disable nginx buffering
468
+ },
469
+ )
470
+
471
+ @app.get("/tools")
472
+ async def list_tools() -> list[dict[str, Any]]:
473
+ from execution.executor import Executor
474
+ ex = Executor()
475
+ return [
476
+ {"name": t.name, "description": t.description, "input_schema": t.input_schema}
477
+ for t in (ex.get_tool(n) for n in ex.list_tools())
478
+ if t is not None
479
+ ]
480
+
481
+ @app.get("/usage")
482
+ async def get_usage() -> dict[str, Any]:
483
+ """Process-wide token + cost report."""
484
+ from core.usage import get_global_tracker
485
+ return get_global_tracker().report()
486
+
487
+ @app.post("/usage/reset")
488
+ async def reset_usage() -> dict[str, bool]:
489
+ from core.usage import reset_global_tracker
490
+ reset_global_tracker()
491
+ return {"reset": True}
492
+
493
+ @app.get("/personality")
494
+ async def get_personality() -> dict[str, Any]:
495
+ """Current identity + mood snapshot."""
496
+ from config import DATA_DIR
497
+ from personality import PersonalityEngine
498
+ engine = PersonalityEngine.load(DATA_DIR)
499
+ return {
500
+ "identity": {
501
+ "describe": engine.identity.describe(),
502
+ "fields": {k: getattr(engine.identity, k) for k in engine.identity.model_fields},
503
+ },
504
+ "mood": {
505
+ "label": engine.mood.label(),
506
+ "fragment": engine.mood.system_prompt_fragment(),
507
+ "fields": {k: getattr(engine.mood, k) for k in engine.mood.model_fields},
508
+ },
509
+ }
510
+
511
+ @app.post("/sessions/{session_id}/compact")
512
+ async def compact_session(session_id: str) -> dict[str, Any]:
513
+ """Force-compact the message history of a saved session."""
514
+ from config import (
515
+ COMPACT_KEEP_RECENT, COMPACT_THRESHOLD, CONTEXT_WINDOW_SIZE,
516
+ SESSIONS_DIR,
517
+ )
518
+ from core.compaction import ContextCompactor
519
+ from llm.provider import LLMProvider
520
+
521
+ sm = SessionManager(SESSIONS_DIR)
522
+ s = sm.load(session_id)
523
+ if s is None:
524
+ raise HTTPException(404, "session not found")
525
+ compactor = ContextCompactor(
526
+ llm=LLMProvider(model=s.model or None),
527
+ context_window=CONTEXT_WINDOW_SIZE,
528
+ compact_threshold=COMPACT_THRESHOLD,
529
+ keep_recent=COMPACT_KEEP_RECENT,
530
+ )
531
+ before = len(s.messages)
532
+ s.messages = await compactor.compact(s.messages)
533
+ sm.save(s)
534
+ if session_id in agent_cache:
535
+ agent_cache[session_id].agentic.messages = list(s.messages)
536
+ return {"before": before, "after": len(s.messages)}
537
+
538
+ @app.post("/sessions/{session_id}/reset")
539
+ async def reset_session(session_id: str) -> dict[str, bool]:
540
+ """Clear the message history of a session in place."""
541
+ from config import SESSIONS_DIR
542
+ sm = SessionManager(SESSIONS_DIR)
543
+ s = sm.load(session_id)
544
+ if s is None:
545
+ raise HTTPException(404, "session not found")
546
+ s.messages = []
547
+ sm.save(s)
548
+ if session_id in agent_cache:
549
+ agent_cache[session_id].agentic.messages = []
550
+ return {"reset": True}
551
+
552
+ @app.get("/sessions/{session_id}/export")
553
+ async def export_session_endpoint(session_id: str, format: str = "markdown") -> Any:
554
+ """Render a saved session as markdown / json / html."""
555
+ from config import SESSIONS_DIR
556
+ from core.export import to_html, to_json, to_markdown
557
+ sm = SessionManager(SESSIONS_DIR)
558
+ s = sm.load(session_id)
559
+ if s is None:
560
+ raise HTTPException(404, "session not found")
561
+ fmt = format.lower()
562
+ if fmt in ("markdown", "md"):
563
+ from fastapi.responses import PlainTextResponse
564
+ return PlainTextResponse(to_markdown(s))
565
+ if fmt == "json":
566
+ return s.model_dump(mode="json")
567
+ if fmt == "html":
568
+ from fastapi.responses import HTMLResponse
569
+ return HTMLResponse(to_html(s))
570
+ raise HTTPException(400, f"Unknown format: {format}")
571
+
572
+ @app.post("/audit")
573
+ async def audit_tail(lines: int = 50) -> list[dict[str, Any]]:
574
+ """Tail the permission audit log."""
575
+ import json as _json
576
+ from config import AUDIT_LOG_PATH
577
+ if not AUDIT_LOG_PATH.exists():
578
+ return []
579
+ raw = AUDIT_LOG_PATH.read_text().splitlines()[-lines:]
580
+ out: list[dict[str, Any]] = []
581
+ for line in raw:
582
+ try:
583
+ out.append(_json.loads(line))
584
+ except Exception:
585
+ continue
586
+ return out
587
+
588
+ @app.post("/notify")
589
+ async def notify_endpoint(payload: dict[str, Any]) -> dict[str, bool]:
590
+ """Send a desktop notification on the host."""
591
+ from core.notifications import notify
592
+ title = payload.get("title", "Cognos")
593
+ body = payload.get("body", "")
594
+ ok = notify(title, body)
595
+ return {"sent": ok}
596
+
597
+ @app.get("/caudate")
598
+ async def caudate_status() -> dict[str, Any]:
599
+ """Inspect Caudate's current state. Pulls from any cached agent first;
600
+ falls back to a fresh observer (so the UI works even before any
601
+ chat has happened)."""
602
+ from nn.observer import CaudateObserver
603
+ if agent_cache:
604
+ cau = getattr(next(iter(agent_cache.values())), "caudate", None)
605
+ if cau is not None:
606
+ return cau.status()
607
+ return CaudateObserver(auto_train=False).status()
608
+
609
+ @app.post("/caudate/train")
610
+ async def caudate_train_endpoint() -> dict[str, Any]:
611
+ """Trigger a synchronous training burst on the active observer."""
612
+ if not agent_cache:
613
+ raise HTTPException(503, "no active agent")
614
+ cau = getattr(next(iter(agent_cache.values())), "caudate", None)
615
+ if cau is None:
616
+ raise HTTPException(503, "Caudate not initialized")
617
+ await asyncio.to_thread(cau._train_sync)
618
+ cau.reload_advisor()
619
+ return cau.status()
620
+
621
+ # Web UI — mounted at /ui, with `/` redirecting there for convenience.
622
+ web_dir = Path(__file__).resolve().parent.parent / "ui" / "web"
623
+ if web_dir.exists():
624
+ app.mount("/ui", StaticFiles(directory=str(web_dir), html=True), name="ui")
625
+
626
+ @app.get("/", include_in_schema=False)
627
+ async def root_redirect():
628
+ return RedirectResponse(url="/ui")
629
+
630
+ # Forge — autonomous coding harness REST API + SSE event streams.
631
+ # State in data/cognos.db (planning/forge_models.py); execution in
632
+ # planning/orchestrator.py; UI lives at /ui/forge inside the SPA.
633
+ from api.forge_routes import build_router as _build_forge_router
634
+ app.include_router(_build_forge_router())
635
+ from api.forge_system_routes import build_router as _build_forge_system_router
636
+ app.include_router(_build_forge_system_router())
637
+ from api.forge_bootstrapper_routes import build_router as _build_forge_boot_router
638
+ app.include_router(_build_forge_boot_router())
639
+
640
+ # Anthropic-compatible /v1/messages — lets Claude Code (or any
641
+ # Anthropic-format client) treat Cognos as its backend.
642
+ from api.anthropic_compat import build_router as _build_anthropic_router
643
+ app.include_router(_build_anthropic_router(), tags=["anthropic-compat"])
644
+
645
+ # OpenAI-compatible /v1/chat/completions — lets Open WebUI (or any
646
+ # OpenAI-format client) treat Cognos as its backend. Same Caudate
647
+ # observer + subscription auth scope as the web UI's `/chat`.
648
+ from api.openai_compat import build_router as _build_openai_router
649
+ app.include_router(_build_openai_router(), tags=["openai-compat"])
650
+
651
+ @app.get("/models")
652
+ async def list_models() -> list[dict[str, Any]]:
653
+ from llm.models import ModelRegistry
654
+ reg = ModelRegistry()
655
+ await reg.refresh()
656
+ return [
657
+ {
658
+ "id": m.id, "name": m.name, "provider": m.provider,
659
+ "supports_tool_calling": m.supports_tool_calling,
660
+ "supports_json_mode": m.supports_json_mode,
661
+ "context_window": m.context_window,
662
+ "size_bytes": m.size_bytes,
663
+ }
664
+ for m in reg.models()
665
+ ]
666
+
667
+ return app