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
cognos_mcp/config.py ADDED
@@ -0,0 +1,49 @@
1
+ """Load MCP server definitions from data/mcp_servers.json.
2
+
3
+ Format:
4
+ {
5
+ "servers": [
6
+ {
7
+ "name": "filesystem",
8
+ "command": "npx",
9
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
10
+ "env": {"KEY": "value"}
11
+ }
12
+ ]
13
+ }
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class MCPServerConfig(BaseModel):
28
+ name: str
29
+ command: str
30
+ args: list[str] = Field(default_factory=list)
31
+ env: dict[str, str] = Field(default_factory=dict)
32
+
33
+
34
+ def load_servers(path: Path) -> list[MCPServerConfig]:
35
+ """Load MCP server configs from a JSON file, returning an empty list on miss."""
36
+ if not path.exists():
37
+ return []
38
+ try:
39
+ raw = json.loads(path.read_text())
40
+ except Exception as e:
41
+ logger.warning(f"Failed to parse MCP server config {path}: {e}")
42
+ return []
43
+ servers: list[MCPServerConfig] = []
44
+ for entry in raw.get("servers", []):
45
+ try:
46
+ servers.append(MCPServerConfig.model_validate(entry))
47
+ except Exception as e:
48
+ logger.warning(f"Invalid MCP server entry {entry}: {e}")
49
+ return servers
cognos_mcp/server.py ADDED
@@ -0,0 +1,66 @@
1
+ """Cognos MCP Server — expose all Cognos tools to external MCP clients.
2
+
3
+ Runs over stdio by default, matching the Claude Desktop / Claude Code
4
+ MCP conventions. Start with: `python3 -m cognos_mcp.server`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import Any
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from execution.executor import Executor
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def build_server() -> FastMCP:
21
+ """Build a FastMCP server wrapping all Cognos tools."""
22
+ server = FastMCP("cognos")
23
+ executor = Executor()
24
+
25
+ for name in executor.list_tools():
26
+ tool = executor.get_tool(name)
27
+ if tool is None:
28
+ continue
29
+ _register_tool(server, executor, tool)
30
+
31
+ return server
32
+
33
+
34
+ def _register_tool(server: FastMCP, executor: Executor, tool) -> None:
35
+ """Register a single Cognos tool with FastMCP.
36
+
37
+ FastMCP infers the MCP tool schema from the Python function signature.
38
+ We wrap each tool in an async shim that accepts **kwargs and delegates
39
+ to the executor, then we manually attach the tool's input_schema via
40
+ FastMCP's lower-level add_tool API.
41
+ """
42
+ name = tool.name
43
+ description = tool.description
44
+
45
+ async def _run(**kwargs: Any) -> str:
46
+ result = await executor.execute_tool(name, kwargs)
47
+ if result.status.value != "success":
48
+ raise RuntimeError(result.error or "tool error")
49
+ return str(result.output)
50
+
51
+ _run.__name__ = name
52
+ _run.__doc__ = description
53
+
54
+ # FastMCP tool() decorator handles both registration and schema
55
+ server.tool(name=name, description=description)(_run)
56
+
57
+
58
+ async def main() -> None:
59
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
60
+ server = build_server()
61
+ # FastMCP's default run() uses stdio
62
+ await server.run_stdio_async()
63
+
64
+
65
+ if __name__ == "__main__":
66
+ asyncio.run(main())
config.py ADDED
@@ -0,0 +1,82 @@
1
+ """Cognos configuration."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Paths
6
+ BASE_DIR = Path(__file__).parent
7
+ DATA_DIR = BASE_DIR / "data"
8
+ LOGS_DIR = BASE_DIR / "logs"
9
+ CHROMA_DIR = DATA_DIR / "chroma"
10
+ SQLITE_PATH = DATA_DIR / "cognos.db"
11
+
12
+ # Ensure dirs exist
13
+ DATA_DIR.mkdir(exist_ok=True)
14
+ LOGS_DIR.mkdir(exist_ok=True)
15
+
16
+ # LLM settings
17
+ LLM_PROVIDER = "ollama" # "ollama", "anthropic", "openai"
18
+ LLM_MODEL = "ollama/gemma3:27b" # primary fallback if no `model` in ~/.cognos/settings.json
19
+ LLM_TEMPERATURE = 0.7
20
+ LLM_MAX_TOKENS = 4096
21
+
22
+ # Memory settings
23
+ WORKING_MEMORY_CAPACITY = 20 # max items in working memory
24
+ EPISODIC_RECALL_LIMIT = 5 # max episodes to recall
25
+ SEMANTIC_RECALL_LIMIT = 5 # max knowledge items to recall
26
+
27
+ # Planning settings
28
+ MAX_PLAN_DEPTH = 5 # max depth of task decomposition
29
+ MAX_REPLAN_ATTEMPTS = 3 # max re-planning attempts before giving up
30
+ # Per-task failure budget within a single plan. Mirrors the dual-brain
31
+ # escalation rule: when the same task fails this many times, stop
32
+ # retrying it and force a replan that avoids that approach. Prevents
33
+ # burning replan attempts on a task the planner keeps re-issuing.
34
+ MAX_TASK_FAILURES = 2
35
+
36
+ # Reflection settings
37
+ SUCCESS_THRESHOLD = 0.7 # minimum score to consider a step successful
38
+ STRATEGY_UPDATE_INTERVAL = 5 # update strategies every N reflections
39
+
40
+ # Permissions
41
+ PERMISSION_MODE = "default" # "default" | "plan" | "accept_edits" | "bypass"
42
+ AUDIT_LOG_PATH = DATA_DIR / "audit.jsonl"
43
+
44
+ # Hooks
45
+ HOOKS_CONFIG_PATH = DATA_DIR / "hooks.json"
46
+
47
+ # Sessions & compaction
48
+ SESSIONS_DIR = DATA_DIR / "sessions"
49
+ CONTEXT_WINDOW_SIZE = 32000 # approximate token budget
50
+ COMPACT_THRESHOLD = 0.8 # fraction of context window before compaction kicks in
51
+ COMPACT_KEEP_RECENT = 6 # recent messages preserved verbatim
52
+
53
+ # MCP
54
+ MCP_CONFIG_PATH = DATA_DIR / "mcp_servers.json"
55
+
56
+ # Personality
57
+ IDENTITY_PATH = DATA_DIR / "identity.json"
58
+ MOOD_PATH = DATA_DIR / "mood.json"
59
+ PERSONALITY_ENABLED = True # set False for a sterile, voice-less agent
60
+
61
+ # Plugins (Phase 5B)
62
+ PLUGINS_DIR = BASE_DIR / "plugins"
63
+
64
+ # Dual-process brain (Phase 4)
65
+ # When both are set, Cognos routes calls between System 1 (fast) and System 2
66
+ # (slow). Set either to empty string or None to disable routing and use the
67
+ # primary LLM_MODEL for everything.
68
+ # Kept in sync with ~/.cognos/settings.json (system1/system2 keys).
69
+ # settings.json wins at runtime if it sets these — these are the fallback
70
+ # defaults so a fresh clone with no settings file behaves the same.
71
+ SYSTEM_1_MODEL = "ollama/glm-5.1:cloud"
72
+ SYSTEM_2_MODEL = "anthropic/claude-haiku-4-5"
73
+ ROUTER_COMPLEXITY_THRESHOLD = 0.40
74
+
75
+ # Prompt caching hints (Anthropic provider only)
76
+ # When True, Cognos wraps the system prompt + tool definitions in cache_control
77
+ # content blocks so Anthropic caches them across turns. Ignored by Ollama/others.
78
+ # Safe to leave on — adds a tiny amount of overhead on non-Anthropic providers.
79
+ PROMPT_CACHING = True
80
+
81
+ # Files API (data/files/)
82
+ FILES_DIR = DATA_DIR / "files"
core/__init__.py ADDED
File without changes
core/agent.py ADDED
@@ -0,0 +1,468 @@
1
+ """Agent — the top-level orchestrator for the Cognos cognitive architecture.
2
+
3
+ Supports two modes:
4
+ - "agentic" (default): real-time tool-calling loop (Claude SDK style)
5
+ - "plan": DAG-based planner + executor (the original Phase 1 loop)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from core.schemas import Goal
13
+ from core.loop import CognitiveLoop
14
+ from core.agentic_loop import AgenticLoop
15
+ from core.citations import CitationBlock, Document
16
+ from core.compaction import ContextCompactor
17
+ from core.files import FileStore
18
+ from core.hooks import HookManager
19
+ from core.permissions import (
20
+ PermissionManager, PermissionMode,
21
+ default_cli_prompt, rules_from_settings,
22
+ )
23
+ from core.settings import Settings
24
+ from core.session import Session, SessionManager
25
+ from core.subagent import SubagentManager
26
+ from cognos_mcp.bridge import MCPToolBridge
27
+ from cognos_mcp.client import MCPClient
28
+ from cognos_mcp.config import load_servers
29
+ from llm.router import DualLLMProvider, RoutingPolicy
30
+ from personality import PersonalityEngine
31
+
32
+
33
+ def _resolve_preset_sync(name_or_preset: str) -> str:
34
+ """Resolve a preset like 'fast'/'balanced'/'powerful' to a concrete model id.
35
+
36
+ Returns the input unchanged if it's not a known preset. Uses subprocess
37
+ synchronously to avoid issues when called from inside an async context.
38
+ """
39
+ preset = name_or_preset.lower()
40
+ if preset not in ("fast", "balanced", "powerful"):
41
+ return name_or_preset
42
+
43
+ import subprocess
44
+ from llm.models import ModelInfo, ModelRegistry, _classify, _parse_size
45
+
46
+ try:
47
+ out = subprocess.run(
48
+ ["ollama", "list"],
49
+ capture_output=True, text=True, timeout=10,
50
+ ).stdout
51
+ except Exception:
52
+ return name_or_preset
53
+
54
+ reg = ModelRegistry()
55
+ for line in out.splitlines()[1:]:
56
+ parts = line.split()
57
+ if len(parts) < 3:
58
+ continue
59
+ name = parts[0]
60
+ size_str = parts[2] + " " + parts[3] if len(parts) >= 4 else "0 MB"
61
+ tool_calling, json_mode, context = _classify(name)
62
+ reg._models[f"ollama/{name}"] = ModelInfo(
63
+ id=f"ollama/{name}",
64
+ name=name,
65
+ provider="ollama",
66
+ supports_tool_calling=tool_calling,
67
+ supports_json_mode=json_mode,
68
+ context_window=context,
69
+ size_bytes=_parse_size(size_str),
70
+ )
71
+ return reg.resolve(name_or_preset)
72
+ from execution.executor import Executor
73
+ from llm.provider import LLMProvider
74
+ from memory.working import WorkingMemory
75
+ from memory.episodic import EpisodicMemory
76
+ from memory.semantic import SemanticMemory
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+
81
+ class CognosAgent:
82
+ """Top-level agent that manages goals and runs the cognitive loop."""
83
+
84
+ def __init__(
85
+ self,
86
+ model: str | None = None,
87
+ mode: str = "agentic",
88
+ thinking: bool = False,
89
+ on_thinking=None,
90
+ permission_mode: str | None = None,
91
+ prompt_fn=None,
92
+ session_id: str | None = None,
93
+ personality: bool = True,
94
+ verbose_personality: bool = False,
95
+ on_inner_voice=None,
96
+ system1: str | None = None,
97
+ system2: str | None = None,
98
+ ):
99
+ from config import (
100
+ LLM_MODEL, PERMISSION_MODE, AUDIT_LOG_PATH, HOOKS_CONFIG_PATH,
101
+ SESSIONS_DIR, CONTEXT_WINDOW_SIZE, COMPACT_THRESHOLD, COMPACT_KEEP_RECENT,
102
+ DATA_DIR, SYSTEM_1_MODEL, SYSTEM_2_MODEL, ROUTER_COMPLEXITY_THRESHOLD,
103
+ FILES_DIR,
104
+ )
105
+ # Layered config — settings file < explicit kwargs.
106
+ settings = Settings.load()
107
+ raw_model = model or settings.get("model") or LLM_MODEL
108
+ resolved = _resolve_preset_sync(raw_model)
109
+ primary_llm = LLMProvider(model=resolved)
110
+
111
+ # Optional fallback chain — wraps the primary so any retryable
112
+ # failure transparently falls through to the next model.
113
+ fallback_models = settings.get("fallback_models") or []
114
+ if fallback_models:
115
+ from llm.fallback import build_fallback
116
+ primary_llm = build_fallback(primary_llm, list(fallback_models))
117
+
118
+ # Dual-process brain — if System 1 and System 2 are both configured,
119
+ # wrap them in a DualLLMProvider that routes per call. Settings file
120
+ # values participate in the same priority chain as `model`.
121
+ s1 = (system1 if system1 is not None
122
+ else settings.get("system1") or SYSTEM_1_MODEL)
123
+ s2 = (system2 if system2 is not None
124
+ else settings.get("system2") or SYSTEM_2_MODEL)
125
+ if s1 and s2:
126
+ self.llm_fast = LLMProvider(model=_resolve_preset_sync(s1))
127
+ self.llm_slow = LLMProvider(model=_resolve_preset_sync(s2))
128
+ policy = RoutingPolicy(complexity_threshold=ROUTER_COMPLEXITY_THRESHOLD)
129
+ self.llm = DualLLMProvider(
130
+ fast=self.llm_fast, slow=self.llm_slow, policy=policy,
131
+ )
132
+ logger.info(
133
+ f"Dual-brain routing enabled: fast={self.llm_fast.model}, "
134
+ f"slow={self.llm_slow.model}"
135
+ )
136
+ else:
137
+ self.llm_fast = primary_llm
138
+ self.llm_slow = primary_llm
139
+ self.llm = primary_llm
140
+
141
+ self.mode = mode
142
+ self._goal_history: list[Goal] = []
143
+
144
+ # Plan mode uses the full Phase 1 loop. Its planner/reflector/
145
+ # meta-learner always want slow-tier — pass the router, which will
146
+ # route them via their caller tags. (If no router, llm_slow IS llm.)
147
+ self.loop = CognitiveLoop(self.llm)
148
+
149
+ # Build permission manager — allow/deny rules come from settings.
150
+ pm_value = permission_mode or settings.get("permission_mode") or PERMISSION_MODE
151
+ perms_cfg = settings.get("permissions") or {}
152
+ permissions = PermissionManager(
153
+ mode=PermissionMode(pm_value),
154
+ allow=rules_from_settings(perms_cfg.get("allow")),
155
+ deny=rules_from_settings(perms_cfg.get("deny")),
156
+ audit_log=AUDIT_LOG_PATH,
157
+ prompt=prompt_fn or default_cli_prompt,
158
+ )
159
+
160
+ # Build hook manager and load any configured hooks
161
+ hooks = HookManager()
162
+ loaded = hooks.load_from_file(HOOKS_CONFIG_PATH)
163
+ if loaded:
164
+ logger.info(f"Loaded {loaded} hooks from {HOOKS_CONFIG_PATH}")
165
+
166
+ # Context compaction
167
+ compactor = ContextCompactor(
168
+ llm=self.llm,
169
+ context_window=CONTEXT_WINDOW_SIZE,
170
+ compact_threshold=COMPACT_THRESHOLD,
171
+ keep_recent=COMPACT_KEEP_RECENT,
172
+ )
173
+
174
+ # Session management — load existing session or prepare a new one
175
+ self.sessions = SessionManager(SESSIONS_DIR)
176
+ self.session: Session
177
+ if session_id:
178
+ loaded_session = self.sessions.load(session_id)
179
+ if loaded_session is None:
180
+ logger.warning(f"Session {session_id} not found — starting new")
181
+ self.session = Session(id=session_id, model=self.llm.model)
182
+ else:
183
+ self.session = loaded_session
184
+ else:
185
+ self.session = Session(model=self.llm.model)
186
+
187
+ # Agentic mode shares the same executor + memory but drives them
188
+ # via a real-time tool-calling loop
189
+ # Personality — identity + mood + inner voice
190
+ self.personality: PersonalityEngine | None = None
191
+ if personality:
192
+ self.personality = PersonalityEngine.load(
193
+ data_dir=DATA_DIR,
194
+ verbose_thinking=verbose_personality,
195
+ on_thinking_display=on_inner_voice,
196
+ )
197
+ self.personality.register_with(hooks)
198
+ # Give the router access to mood so frustration escalates to System 2
199
+ if isinstance(self.llm, DualLLMProvider):
200
+ self.llm.set_mood(self.personality.mood)
201
+
202
+ system_prompt_provider = (
203
+ self.personality.wrap_system_prompt if self.personality else None
204
+ )
205
+ thinking_instruction_wrapper = (
206
+ self.personality.augment_thinking_instruction if self.personality else None
207
+ )
208
+ on_thinking_combined = on_thinking
209
+ if self.personality is not None:
210
+ inner_on_thinking = self.personality.inner_voice.on_thinking
211
+ if on_thinking is None:
212
+ on_thinking_combined = inner_on_thinking
213
+ else:
214
+ # Chain both callbacks
215
+ def _combined(blocks, _ot=on_thinking, _iv=inner_on_thinking):
216
+ _iv(blocks)
217
+ _ot(blocks)
218
+ on_thinking_combined = _combined
219
+
220
+ # File store (attachments / Files-API equivalent)
221
+ self.files = FileStore(root=FILES_DIR)
222
+
223
+ self.agentic = AgenticLoop(
224
+ llm=self.llm,
225
+ executor=self.loop.executor,
226
+ episodic=self.loop.episodic,
227
+ semantic=self.loop.semantic,
228
+ working=self.loop.working,
229
+ thinking=thinking,
230
+ on_thinking=on_thinking_combined,
231
+ permissions=permissions,
232
+ hooks=hooks,
233
+ compactor=compactor,
234
+ session_id=self.session.id,
235
+ system_prompt_provider=system_prompt_provider,
236
+ thinking_instruction_wrapper=thinking_instruction_wrapper,
237
+ file_store=self.files,
238
+ )
239
+ self.permissions = permissions
240
+ self.hooks = hooks
241
+ self.compactor = compactor
242
+
243
+ # Subagent support — wire the AgentTool's manager reference
244
+ self.subagents = SubagentManager(
245
+ llm=self.llm,
246
+ parent_executor=self.loop.executor,
247
+ episodic=self.loop.episodic,
248
+ semantic=self.loop.semantic,
249
+ permissions=permissions,
250
+ hooks=hooks,
251
+ )
252
+ agent_tool = self.loop.executor.get_tool("Agent")
253
+ if agent_tool is not None and hasattr(agent_tool, "set_manager"):
254
+ agent_tool.set_manager(self.subagents)
255
+
256
+ # Wire the Draw tool to the FileStore so generated images get
257
+ # persisted and referenceable. Settings can override the
258
+ # backend (default: diffusers/SDXL-Turbo).
259
+ draw_tool = self.loop.executor.get_tool("Draw")
260
+ if draw_tool is not None and hasattr(draw_tool, "set_file_store"):
261
+ draw_tool.set_file_store(self.files)
262
+ img_cfg = settings.get("image") or {}
263
+ if img_cfg.get("backend"):
264
+ draw_tool._backend_name = img_cfg["backend"]
265
+ if img_cfg.get("backend_kwargs"):
266
+ draw_tool._backend_kwargs = dict(img_cfg["backend_kwargs"])
267
+
268
+ # Wire the Artifact tool to the same FileStore so artifact panes
269
+ # (HTML, SVG, markdown, code) get persisted under /files/ for the
270
+ # chat UI to fetch.
271
+ artifact_tool = self.loop.executor.get_tool("Artifact")
272
+ if artifact_tool is not None and hasattr(artifact_tool, "set_file_store"):
273
+ artifact_tool.set_file_store(self.files)
274
+
275
+ # Wire the Speak tool too — synthesized audio is persisted as
276
+ # WAV under /files/ and surfaced to the chat UI as a markdown
277
+ # audio link the LLM echoes verbatim.
278
+ speak_tool = self.loop.executor.get_tool("Speak")
279
+ if speak_tool is not None and hasattr(speak_tool, "set_file_store"):
280
+ speak_tool.set_file_store(self.files)
281
+
282
+ # Wire EditImage to the FileStore so it can resolve `files/<id>`
283
+ # references from prior Draw / DescribeImage tool outputs and
284
+ # save edited images back to the same store.
285
+ edit_image_tool = self.loop.executor.get_tool("EditImage")
286
+ if edit_image_tool is not None and hasattr(edit_image_tool, "set_file_store"):
287
+ edit_image_tool.set_file_store(self.files)
288
+
289
+ # Wire LongCat Avatar — needs the FileStore to resolve input
290
+ # ref_image / audio FileStore ids AND to persist the output video.
291
+ # Also wire the Speak tool so the agent can pass `text` instead
292
+ # of an `audio` clip — LongCatAvatar will synthesize via Kokoro
293
+ # TTS internally and feed the result into the WAN-S2V graph.
294
+ longcat_avatar_tool = self.loop.executor.get_tool("LongCatAvatar")
295
+ if longcat_avatar_tool is not None and hasattr(longcat_avatar_tool, "set_file_store"):
296
+ longcat_avatar_tool.set_file_store(self.files)
297
+ speak_for_avatar = self.loop.executor.get_tool("Speak")
298
+ if speak_for_avatar is not None and hasattr(longcat_avatar_tool, "set_speak_tool"):
299
+ longcat_avatar_tool.set_speak_tool(speak_for_avatar)
300
+
301
+ # Wire Storyboard — it needs the FileStore to persist panels
302
+ # AND the live LLM (for beat breakdown). It also reuses the
303
+ # already-instantiated image backends from Draw / EditImage so
304
+ # we don't pay the FLUX-schnell + Kontext load cost twice.
305
+ storyboard_tool = self.loop.executor.get_tool("Storyboard")
306
+ if storyboard_tool is not None:
307
+ storyboard_tool.set_file_store(self.files)
308
+ try:
309
+ if draw_tool is not None and hasattr(draw_tool, "_get_backend"):
310
+ storyboard_tool.set_image_gen(draw_tool._get_backend())
311
+ except Exception as e:
312
+ logger.warning(f"Storyboard: could not share Draw backend: {e}")
313
+ try:
314
+ if edit_image_tool is not None and hasattr(edit_image_tool, "_get_backend"):
315
+ storyboard_tool.set_image_edit(edit_image_tool._get_backend())
316
+ except Exception as e:
317
+ logger.warning(f"Storyboard: could not share EditImage backend: {e}")
318
+
319
+ # MCP clients (optional) — lazily connected in `start()`
320
+ self._mcp_clients: list[MCPClient] = []
321
+
322
+ # Wire TaskCreate + MCPListServers to this live agent so they
323
+ # can spawn sub-prompts and inspect the MCP roster respectively.
324
+ for late_wire_name in ("TaskCreate", "MCPListServers"):
325
+ t = self.loop.executor.get_tool(late_wire_name)
326
+ if t is not None and hasattr(t, "set_agent"):
327
+ t.set_agent(self)
328
+
329
+ # Caudate — the action-selection neural net. Loaded if a checkpoint
330
+ # exists; the agent works fine if not. The observer also captures
331
+ # live (state, action, reward) into a replay buffer and triggers
332
+ # auto-training in the background.
333
+ self.caudate = None
334
+ try:
335
+ from nn.observer import CaudateObserver
336
+ self.caudate = CaudateObserver()
337
+ self.agentic.caudate = self.caudate
338
+ # Hand Caudate to the router so she can override tier
339
+ # selection once she earns the ADVISOR trust level.
340
+ if isinstance(self.llm, DualLLMProvider):
341
+ try:
342
+ self.llm.router.set_caudate(self.caudate)
343
+ except Exception:
344
+ pass
345
+ if self.caudate.advisor is not None:
346
+ level = self.caudate.policy.level.label
347
+ logger.info(f"Caudate loaded — trust level: {level}")
348
+ else:
349
+ logger.info(
350
+ "Caudate has no checkpoint yet — observing only. "
351
+ f"After {self.caudate.cfg.min_episodes_to_train} "
352
+ "samples she'll auto-train and start predicting."
353
+ )
354
+ except Exception as e:
355
+ logger.warning(f"Caudate init failed (continuing without): {e}")
356
+
357
+ # Restore any prior messages from the loaded session
358
+ if self.session.messages:
359
+ self.agentic.load_messages(self.session.messages)
360
+
361
+ async def pursue_goal(
362
+ self,
363
+ description: str,
364
+ success_criteria: list[str] | None = None,
365
+ verbose: bool = True,
366
+ ) -> Goal:
367
+ """Create and pursue a goal through the cognitive loop.
368
+
369
+ Always uses plan mode (DAG-based) regardless of agent mode — use
370
+ `chat()` for the agentic conversational loop.
371
+ """
372
+ goal = Goal(
373
+ description=description,
374
+ success_criteria=success_criteria or [],
375
+ )
376
+ self._goal_history.append(goal)
377
+
378
+ result = await self.loop.run(goal, verbose=verbose)
379
+ return result
380
+
381
+ async def start(self) -> None:
382
+ """Connect to configured MCP servers and register their tools.
383
+
384
+ Safe to call multiple times — a no-op after the first successful
385
+ connection for each server.
386
+ """
387
+ from config import MCP_CONFIG_PATH
388
+ if self._mcp_clients:
389
+ return
390
+
391
+ for cfg in load_servers(MCP_CONFIG_PATH):
392
+ client = MCPClient(
393
+ name=cfg.name,
394
+ command=cfg.command,
395
+ args=cfg.args,
396
+ env=cfg.env,
397
+ )
398
+ try:
399
+ await client.connect()
400
+ except Exception as e:
401
+ logger.warning(f"MCP server {cfg.name!r} connect failed: {e}")
402
+ continue
403
+ self._mcp_clients.append(client)
404
+ for remote in client.tools:
405
+ bridged = MCPToolBridge(client, remote)
406
+ self.loop.executor.register_tool(bridged)
407
+ logger.info(f"Registered MCP tool: {bridged.name}")
408
+
409
+ async def stop(self) -> None:
410
+ """Disconnect any open MCP clients."""
411
+ for c in self._mcp_clients:
412
+ try:
413
+ await c.close()
414
+ except Exception as e:
415
+ logger.debug(f"MCP close failed for {c.name}: {e}")
416
+ self._mcp_clients = []
417
+
418
+ async def chat(
419
+ self,
420
+ message: str,
421
+ attachments: list[str] | None = None,
422
+ documents: list[Document] | None = None,
423
+ ) -> str:
424
+ """Multi-turn conversational entry point (agentic loop).
425
+
426
+ `attachments` is a list of file_ids previously registered with
427
+ `self.files.upload()`. `documents` is an optional list of Document
428
+ objects the model may cite; structured citation blocks are
429
+ available on `self.agentic.last_citations` after each call.
430
+
431
+ Persists the conversation to the current session after each turn.
432
+ """
433
+ await self.start()
434
+ reply = await self.agentic.run(
435
+ message, attachments=attachments, documents=documents,
436
+ )
437
+ self.session.messages = list(self.agentic.messages)
438
+ self.session.model = self.llm.model
439
+ if not self.session.title and message:
440
+ self.session.title = message[:80]
441
+ self.sessions.save(self.session)
442
+ return reply
443
+
444
+ def reset_conversation(self) -> None:
445
+ """Clear the agentic loop's conversation history (starts a new session)."""
446
+ self.agentic.reset()
447
+ self.session = Session(model=self.llm.model)
448
+ self.agentic.session_id = self.session.id
449
+
450
+ def register_tool(self, tool) -> None:
451
+ """Register a custom tool with the executor."""
452
+ self.loop.executor.register_tool(tool)
453
+
454
+ def switch_model(self, model: str) -> None:
455
+ """Switch the underlying LLM model."""
456
+ self.llm.switch_model(model)
457
+
458
+ @property
459
+ def goal_history(self) -> list[Goal]:
460
+ return self._goal_history
461
+
462
+ @property
463
+ def strategies(self):
464
+ return self.loop.procedural.get_strategies()
465
+
466
+ @property
467
+ def performance_history(self):
468
+ return self.loop.procedural.get_performance_history()