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
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
|