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
core/loop.py ADDED
@@ -0,0 +1,238 @@
1
+ """Cognitive Loop — the main perceive-plan-act-reflect-remember cycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from config import MAX_REPLAN_ATTEMPTS, MAX_TASK_FAILURES
9
+ from core.schemas import (
10
+ Goal, GoalStatus, Plan, Task, TaskStatus, Perception,
11
+ Episode, Reflection, ToolResultStatus,
12
+ )
13
+ from execution.executor import Executor
14
+ from llm.provider import LLMProvider
15
+ from memory.working import WorkingMemory
16
+ from memory.episodic import EpisodicMemory
17
+ from memory.semantic import SemanticMemory
18
+ from memory.procedural import ProceduralMemory
19
+ from planning.planner import Planner
20
+ from reflection.reflector import Reflector
21
+ from reflection.meta_learner import MetaLearner
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class CognitiveLoop:
27
+ """The main cognitive cycle: Perceive → Plan → Act → Reflect → Remember."""
28
+
29
+ def __init__(self, llm: LLMProvider):
30
+ self.llm = llm
31
+ self.executor = Executor(llm=llm)
32
+ self.planner = Planner(llm)
33
+ self.reflector = Reflector(llm)
34
+
35
+ # Memory systems
36
+ self.working = WorkingMemory()
37
+ self.episodic = EpisodicMemory()
38
+ self.semantic = SemanticMemory()
39
+ self.procedural = ProceduralMemory()
40
+
41
+ # Meta-learning
42
+ self.meta_learner = MetaLearner(llm, self.procedural)
43
+
44
+ # State
45
+ self._cycle_count = 0
46
+
47
+ async def run(self, goal: Goal, verbose: bool = True) -> Goal:
48
+ """Run the full cognitive loop until goal is achieved or abandoned."""
49
+ logger.info(f"Starting cognitive loop for goal: {goal.description}")
50
+
51
+ if verbose:
52
+ print(f"\n{'='*60}")
53
+ print(f"GOAL: {goal.description}")
54
+ print(f"{'='*60}\n")
55
+
56
+ # Phase 1: PERCEIVE
57
+ perception = await self._perceive(goal)
58
+
59
+ # Phase 2: PLAN
60
+ plan = await self.planner.create_plan(
61
+ goal, perception, self.executor.list_tools()
62
+ )
63
+ self.working.store("current_plan", plan.reasoning)
64
+
65
+ if verbose:
66
+ print(f"PLAN (v{plan.version}): {plan.reasoning}")
67
+ for i, t in enumerate(plan.tasks):
68
+ print(f" {i+1}. {t.description} [tool: {t.tool_name or 'reasoning'}]")
69
+ print()
70
+
71
+ replan_count = 0
72
+ # Per-task failure budget — keyed by task description so it
73
+ # survives a replan that re-issues the same logical task with a
74
+ # fresh id. When a task description hits MAX_TASK_FAILURES we
75
+ # force a replan instead of letting the executor try it again.
76
+ task_failures: dict[str, int] = {}
77
+ while not plan.is_complete() and goal.status == GoalStatus.ACTIVE:
78
+ self._cycle_count += 1
79
+ ready_tasks = plan.get_ready_tasks()
80
+
81
+ if not ready_tasks:
82
+ if plan.has_failed():
83
+ # Phase: REPLAN
84
+ if replan_count >= MAX_REPLAN_ATTEMPTS:
85
+ goal.status = GoalStatus.FAILED
86
+ logger.warning("Max replan attempts reached. Goal failed.")
87
+ break
88
+
89
+ failed = [t for t in plan.tasks if t.status == TaskStatus.FAILED]
90
+ last_reflection = self.working.recall("last_reflection")
91
+ if last_reflection:
92
+ new_plan = await self.planner.replan(
93
+ goal, plan, last_reflection, self.executor.list_tools()
94
+ )
95
+ if new_plan:
96
+ plan = new_plan
97
+ replan_count += 1
98
+ if verbose:
99
+ print(f"\nREPLAN (v{plan.version}): {plan.reasoning}\n")
100
+ continue
101
+ goal.status = GoalStatus.FAILED
102
+ break
103
+ else:
104
+ # Deadlock — no ready tasks but not complete
105
+ goal.status = GoalStatus.FAILED
106
+ logger.error("Deadlock: no ready tasks and plan not complete")
107
+ break
108
+
109
+ for task in ready_tasks:
110
+ task.status = TaskStatus.IN_PROGRESS
111
+
112
+ # Inject results from completed dependency tasks into this task
113
+ self._inject_dependency_results(task, plan)
114
+
115
+ if verbose:
116
+ print(f" EXEC: {task.description}", end=" ... ")
117
+
118
+ # Phase 3: ACT
119
+ result, episode = await self.executor.execute_task(task)
120
+
121
+ if result.status == ToolResultStatus.SUCCESS:
122
+ task.status = TaskStatus.COMPLETED
123
+ task.result = result.output
124
+ if verbose:
125
+ print(f"OK")
126
+ else:
127
+ task.status = TaskStatus.FAILED
128
+ task.error = result.error
129
+ fails = task_failures.get(task.description, 0) + 1
130
+ task_failures[task.description] = fails
131
+ if verbose:
132
+ print(f"FAILED ({fails}/{MAX_TASK_FAILURES}): {result.error}")
133
+
134
+ # Phase 4: REFLECT
135
+ reflection = await self.reflector.reflect(episode, goal, task)
136
+ self.working.store("last_reflection", reflection)
137
+ self.working.store(f"task_{task.id}_result", result.output or result.error)
138
+
139
+ if verbose and reflection.lesson:
140
+ print(f" REFLECT: {reflection.lesson} (score: {reflection.score:.2f})")
141
+
142
+ # Phase 5: REMEMBER
143
+ self.episodic.store(episode)
144
+ self.meta_learner.add_reflection(reflection)
145
+
146
+ if reflection.lesson:
147
+ self.semantic.store(
148
+ knowledge_id=f"lesson_{episode.id}",
149
+ content=reflection.lesson,
150
+ category="lesson",
151
+ )
152
+
153
+ # Check if we should replan (reflector says so, or this
154
+ # task has exhausted its per-task failure budget).
155
+ if (
156
+ self.reflector.needs_replan(reflection)
157
+ or task_failures.get(task.description, 0) >= MAX_TASK_FAILURES
158
+ ):
159
+ break # break inner loop to trigger replan
160
+
161
+ # Meta-learning: periodically extract strategies
162
+ new_strategies = await self.meta_learner.maybe_update_strategies()
163
+ if new_strategies and verbose:
164
+ for s in new_strategies:
165
+ print(f" LEARNED: Strategy '{s.name}' (confidence: {s.confidence:.2f})")
166
+
167
+ # Final meta-learning pass
168
+ final_strategies = await self.meta_learner.force_update()
169
+
170
+ if plan.is_complete():
171
+ goal.status = GoalStatus.ACHIEVED
172
+ if verbose:
173
+ print(f"\nGOAL ACHIEVED: {goal.description}")
174
+
175
+ # Memory consolidation
176
+ self._consolidate(goal)
177
+
178
+ if verbose:
179
+ print(f"\nStats: {self._cycle_count} cycles, "
180
+ f"{self.episodic.count()} episodes stored, "
181
+ f"{len(self.procedural.get_strategies())} strategies learned")
182
+ print(f"{'='*60}\n")
183
+
184
+ return goal
185
+
186
+ async def _perceive(self, goal: Goal) -> Perception:
187
+ """Build a perception of the current situation."""
188
+ # Recall relevant episodes
189
+ relevant_episodes = self.episodic.recall(goal.description)
190
+
191
+ # Recall relevant knowledge
192
+ knowledge = self.semantic.recall(goal.description)
193
+ knowledge_texts = [k["content"] for k in knowledge]
194
+
195
+ # Get applicable strategies
196
+ strategies = self.meta_learner.get_applicable_strategies(goal.description)
197
+
198
+ return Perception(
199
+ goal=goal,
200
+ working_memory=self.working.recall_all(),
201
+ relevant_episodes=relevant_episodes,
202
+ relevant_knowledge=knowledge_texts,
203
+ active_strategies=strategies,
204
+ )
205
+
206
+ def _inject_dependency_results(self, task: Task, plan: Plan) -> None:
207
+ """Inject results from completed dependency tasks into this task's args.
208
+
209
+ If the task uses the 'respond' tool and has no message, synthesize one
210
+ from all prior completed task results.
211
+ """
212
+ # Collect results from all completed tasks
213
+ prior_results = []
214
+ for t in plan.tasks:
215
+ if t.status == TaskStatus.COMPLETED and t.result:
216
+ prior_results.append(f"[{t.description}]: {str(t.result)[:1000]}")
217
+
218
+ # Store collected context in working memory
219
+ if prior_results:
220
+ context = "\n".join(prior_results)
221
+ self.working.store("prior_task_results", context)
222
+
223
+ # For respond tasks: always build message from actual prior results
224
+ if task.tool_name in ("respond", "Respond") and prior_results:
225
+ task.tool_args["message"] = "\n\n".join(prior_results)
226
+
227
+ def _consolidate(self, goal: Goal) -> None:
228
+ """Consolidate short-term memory into long-term storage."""
229
+ score = 1.0 if goal.status == GoalStatus.ACHIEVED else 0.0
230
+ self.procedural.log_performance(
231
+ goal_id=goal.id,
232
+ score=score,
233
+ lesson=f"Goal {'achieved' if score > 0 else 'failed'}: {goal.description}",
234
+ strategy_ids=[s.id for s in self.meta_learner.get_applicable_strategies("")],
235
+ )
236
+
237
+ # Clear working memory for next goal
238
+ self.working.clear()
core/memory_md.py ADDED
@@ -0,0 +1,147 @@
1
+ """Durable cross-session memory in `data/memory.md`.
2
+
3
+ A single markdown file the agent (and the user) can both read and write.
4
+ Caudate's `memory_write` head predicts whether a turn is memory-worthy;
5
+ when she predicts above threshold, the turn's user prompt + a short
6
+ summary of the response is appended here. The file's contents get
7
+ injected into the system prompt on every turn so the LLM has access
8
+ to durable context.
9
+
10
+ Format: append-only timestamped sections. Lines outside ``...`` blocks
11
+ are user-readable; the agent can also be asked to clean it up via the
12
+ `update_memory` tool.
13
+
14
+ Why a flat markdown file (not a vector store):
15
+ - Human-editable: open in a text editor, prune by hand
16
+ - Easy backup / version control
17
+ - Predictable size (capped — newest entries kept, oldest evicted)
18
+ - No reranker / embedding-quality concerns
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import threading
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ _DEFAULT_PATH = Path("data/memory.md")
32
+ _MAX_BYTES = 64 * 1024 # ~16K tokens — keep the system-prompt cost bounded
33
+ _INJECTION_HEADER = "# Durable memory (from prior sessions)"
34
+
35
+
36
+ class MemoryMd:
37
+ """Thread-safe append + read of memory.md.
38
+
39
+ Multiple agent turns / observers can call write() concurrently; an
40
+ internal lock + atomic rename keeps the file consistent.
41
+ """
42
+
43
+ def __init__(self, path: Path | str = _DEFAULT_PATH, max_bytes: int = _MAX_BYTES):
44
+ self.path = Path(path)
45
+ self.max_bytes = max_bytes
46
+ self._lock = threading.Lock()
47
+ self.path.parent.mkdir(parents=True, exist_ok=True)
48
+ if not self.path.exists():
49
+ self._write_atomic(
50
+ "# Cognos memory\n\n"
51
+ "_This file is durable across sessions. Caudate appends "
52
+ "high-reward turns here; the agent may also call "
53
+ "`update_memory` to record explicit notes. Edit by hand "
54
+ "to prune._\n\n"
55
+ )
56
+
57
+ def read(self) -> str:
58
+ with self._lock:
59
+ try:
60
+ return self.path.read_text()
61
+ except OSError:
62
+ return ""
63
+
64
+ def append(self, body: str, *, source: str = "auto", title: str = "") -> None:
65
+ """Append a new memory entry. Trims oldest entries if size cap is hit."""
66
+ if not body or not body.strip():
67
+ return
68
+ ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
69
+ title = f" — {title}" if title else ""
70
+ block = f"\n## {ts} ({source}){title}\n\n{body.strip()}\n"
71
+
72
+ with self._lock:
73
+ current = ""
74
+ try:
75
+ current = self.path.read_text()
76
+ except OSError:
77
+ pass
78
+ new = current + block
79
+ new = self._trim_to_cap(new)
80
+ self._write_atomic(new)
81
+
82
+ def replace(self, content: str) -> None:
83
+ """Wholesale replace — used by the `update_memory` tool when the
84
+ user/LLM wants to re-curate the file."""
85
+ with self._lock:
86
+ self._write_atomic(self._trim_to_cap(content))
87
+
88
+ def _trim_to_cap(self, text: str) -> str:
89
+ """Keep newest entries when the file exceeds size cap. Splits on
90
+ section headers so we don't truncate mid-entry."""
91
+ if len(text.encode("utf-8")) <= self.max_bytes:
92
+ return text
93
+ # Split on section headers; keep header + entries from newest end
94
+ # until the cap is satisfied. Always preserve the file preamble
95
+ # (everything before the first `## ` heading).
96
+ marker = "\n## "
97
+ idx = text.find(marker)
98
+ if idx < 0:
99
+ return text[-self.max_bytes:] # no entries, just truncate raw
100
+ preamble = text[: idx]
101
+ entries = ("## " + part for part in text[idx + 1:].split(marker))
102
+ kept: list[str] = []
103
+ running = len(preamble.encode("utf-8"))
104
+ for entry in reversed(list(entries)):
105
+ blen = len(entry.encode("utf-8"))
106
+ if running + blen > self.max_bytes:
107
+ break
108
+ kept.append(entry)
109
+ running += blen
110
+ return preamble + "\n".join(reversed(kept))
111
+
112
+ def _write_atomic(self, content: str) -> None:
113
+ tmp = self.path.with_suffix(".tmp")
114
+ try:
115
+ tmp.write_text(content)
116
+ tmp.replace(self.path)
117
+ except OSError as e:
118
+ logger.warning(f"memory.md write failed: {e}")
119
+
120
+ def render_for_system_prompt(self) -> str:
121
+ """Format the whole file for injection into the LLM's system prompt.
122
+
123
+ Returns empty string if the file is essentially empty (preamble
124
+ only, no actual entries) so we don't inject useless boilerplate.
125
+ """
126
+ text = self.read()
127
+ if "## " not in text:
128
+ return "" # only preamble, no real entries yet
129
+ # Strip the preamble's italic blurb — it's only useful to humans
130
+ # opening the file. The LLM just needs the entries.
131
+ idx = text.find("\n## ")
132
+ body = text[idx + 1:] if idx >= 0 else text
133
+ return f"{_INJECTION_HEADER}\n\n{body}".strip()
134
+
135
+
136
+ # ---------------------------------------------------------------------
137
+ # Module-level singleton — most callers just want one shared instance
138
+ # ---------------------------------------------------------------------
139
+
140
+ _singleton: MemoryMd | None = None
141
+
142
+
143
+ def get_memory() -> MemoryMd:
144
+ global _singleton
145
+ if _singleton is None:
146
+ _singleton = MemoryMd()
147
+ return _singleton
core/notifications.py ADDED
@@ -0,0 +1,99 @@
1
+ """Cross-platform desktop notifications.
2
+
3
+ Picks the best available backend on the host:
4
+ - Linux: notify-send (libnotify)
5
+ - macOS: osascript with display notification
6
+ - Windows: powershell BurntToast / win10toast / fall-through
7
+
8
+ If nothing's available, falls through to the terminal bell + a printed
9
+ line so a tmux/ssh user still gets *something*.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _notify_linux(title: str, body: str) -> bool:
24
+ if shutil.which("notify-send") is None:
25
+ return False
26
+ try:
27
+ subprocess.run(
28
+ ["notify-send", title, body],
29
+ check=False, timeout=5,
30
+ )
31
+ return True
32
+ except Exception as e:
33
+ logger.debug(f"notify-send failed: {e}")
34
+ return False
35
+
36
+
37
+ def _notify_macos(title: str, body: str) -> bool:
38
+ if shutil.which("osascript") is None:
39
+ return False
40
+ safe_title = title.replace('"', '\\"')
41
+ safe_body = body.replace('"', '\\"')
42
+ script = f'display notification "{safe_body}" with title "{safe_title}"'
43
+ try:
44
+ subprocess.run(
45
+ ["osascript", "-e", script],
46
+ check=False, timeout=5,
47
+ )
48
+ return True
49
+ except Exception as e:
50
+ logger.debug(f"osascript failed: {e}")
51
+ return False
52
+
53
+
54
+ def _notify_windows(title: str, body: str) -> bool:
55
+ # Best-effort: prefer BurntToast if present; fall back to balloon.
56
+ pwsh = shutil.which("powershell") or shutil.which("pwsh")
57
+ if pwsh is None:
58
+ return False
59
+ safe_title = title.replace("'", "''")
60
+ safe_body = body.replace("'", "''")
61
+ script = (
62
+ "if (Get-Module -ListAvailable -Name BurntToast) {"
63
+ f" New-BurntToastNotification -Text '{safe_title}', '{safe_body}'"
64
+ "} else {"
65
+ " Add-Type -AssemblyName System.Windows.Forms;"
66
+ " $b = New-Object System.Windows.Forms.NotifyIcon;"
67
+ " $b.Icon = [System.Drawing.SystemIcons]::Information;"
68
+ f" $b.BalloonTipTitle = '{safe_title}';"
69
+ f" $b.BalloonTipText = '{safe_body}';"
70
+ " $b.Visible = $true;"
71
+ " $b.ShowBalloonTip(5000)"
72
+ "}"
73
+ )
74
+ try:
75
+ subprocess.run(
76
+ [pwsh, "-NoProfile", "-Command", script],
77
+ check=False, timeout=10,
78
+ )
79
+ return True
80
+ except Exception as e:
81
+ logger.debug(f"PowerShell notify failed: {e}")
82
+ return False
83
+
84
+
85
+ def notify(title: str, body: str = "", **_: Any) -> bool:
86
+ """Show a desktop notification. Returns True if a backend handled it."""
87
+ if sys.platform.startswith("linux"):
88
+ if _notify_linux(title, body):
89
+ return True
90
+ elif sys.platform == "darwin":
91
+ if _notify_macos(title, body):
92
+ return True
93
+ elif sys.platform.startswith("win"):
94
+ if _notify_windows(title, body):
95
+ return True
96
+
97
+ # Fallthrough — terminal bell so the user notices.
98
+ print(f"\a[notify] {title}: {body}", flush=True)
99
+ return False
core/ownership.py ADDED
@@ -0,0 +1,181 @@
1
+ """Ownership — Cognos's hard authority lock.
2
+
3
+ A single owner is configured. Every override / freeze / kill action is
4
+ checked against this identity. Caudate's predictions are gated through
5
+ a killswitch that the system itself cannot disable — only the owner,
6
+ through the REPL or the file system, can flip it back.
7
+
8
+ Three layers of authority:
9
+
10
+ 1. **Owner identity** — `data/owner.json` records who's in charge.
11
+ Set on first run, kept across restarts. Only manual file editing
12
+ or `/owner set` can change it.
13
+
14
+ 2. **Killswitch** — `data/nn/.killed` (a file marker). When present,
15
+ Caudate's `predict`, `auto_evolve`, and `promote` paths all return
16
+ None / no-op. Defensive: every Caudate code path checks this before
17
+ acting.
18
+
19
+ 3. **Audit log** — `data/audit/owner.jsonl`, append-only. Every
20
+ attempt to disable the killswitch, change ownership, or override
21
+ a Caudate freeze lands here.
22
+
23
+ The killswitch is a *file*, not just a Python flag — so a runaway
24
+ process can't undo it. The file persists across restarts. Even if
25
+ Caudate were somehow code-modified, removing the file requires
26
+ filesystem write access which is the user's domain.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import os
34
+ import time
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ _OWNER_PATH = Path("data/owner.json")
43
+ _KILLSWITCH = Path("data/nn/.killed")
44
+ _AUDIT_PATH = Path("data/audit/owner.jsonl")
45
+ _DEFAULT_OWNER = "Rave"
46
+
47
+
48
+ @dataclass
49
+ class Owner:
50
+ name: str = _DEFAULT_OWNER
51
+ set_at: str = ""
52
+ note: str = "primary user; ultimate authority over Caudate and the agent"
53
+
54
+
55
+ # ---- Owner identity ------------------------------------------------
56
+
57
+
58
+ def get_owner() -> Owner:
59
+ if _OWNER_PATH.exists():
60
+ try:
61
+ data = json.loads(_OWNER_PATH.read_text())
62
+ return Owner(**data)
63
+ except Exception:
64
+ pass
65
+ # Initialize on first read
66
+ o = Owner(name=_DEFAULT_OWNER,
67
+ set_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
68
+ save_owner(o)
69
+ return o
70
+
71
+
72
+ def save_owner(owner: Owner) -> None:
73
+ _OWNER_PATH.parent.mkdir(parents=True, exist_ok=True)
74
+ _OWNER_PATH.write_text(json.dumps({
75
+ "name": owner.name,
76
+ "set_at": owner.set_at,
77
+ "note": owner.note,
78
+ }, indent=2))
79
+
80
+
81
+ def set_owner(name: str, note: str = "") -> Owner:
82
+ """Owner reassignment. Audit-logged."""
83
+ prev = get_owner()
84
+ new = Owner(
85
+ name=name,
86
+ set_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
87
+ note=note or prev.note,
88
+ )
89
+ save_owner(new)
90
+ audit("owner_changed", from_=prev.name, to=name)
91
+ return new
92
+
93
+
94
+ # ---- Killswitch ---------------------------------------------------
95
+
96
+
97
+ def is_killed() -> bool:
98
+ """Returns True if Caudate is in killed state.
99
+
100
+ Every Caudate code path that can affect agent behavior should
101
+ check this and bail-soft when True.
102
+ """
103
+ return _KILLSWITCH.exists()
104
+
105
+
106
+ def kill(reason: str = "owner stop") -> None:
107
+ """Activate the killswitch. Caudate goes silent immediately."""
108
+ _KILLSWITCH.parent.mkdir(parents=True, exist_ok=True)
109
+ _KILLSWITCH.write_text(json.dumps({
110
+ "killed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
111
+ "reason": reason,
112
+ "owner": get_owner().name,
113
+ }, indent=2))
114
+ audit("killed", reason=reason)
115
+ logger.warning(f"Caudate killed by owner: {reason}")
116
+
117
+
118
+ def resume() -> None:
119
+ """Lift the killswitch. Caudate resumes observing."""
120
+ if _KILLSWITCH.exists():
121
+ try:
122
+ data = json.loads(_KILLSWITCH.read_text())
123
+ except Exception:
124
+ data = {}
125
+ _KILLSWITCH.unlink()
126
+ audit("resumed", was_killed_at=data.get("killed_at"))
127
+ logger.info("Caudate killswitch lifted")
128
+
129
+
130
+ def killswitch_status() -> dict[str, Any]:
131
+ if not _KILLSWITCH.exists():
132
+ return {"killed": False}
133
+ try:
134
+ data = json.loads(_KILLSWITCH.read_text())
135
+ except Exception:
136
+ data = {}
137
+ data["killed"] = True
138
+ return data
139
+
140
+
141
+ # ---- Audit log ----------------------------------------------------
142
+
143
+
144
+ def audit(action: str, **kwargs: Any) -> None:
145
+ """Append-only log of authority-related events."""
146
+ try:
147
+ _AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
148
+ with _AUDIT_PATH.open("a") as f:
149
+ f.write(json.dumps({
150
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
151
+ "action": action,
152
+ "owner": get_owner().name,
153
+ **kwargs,
154
+ }) + "\n")
155
+ except Exception as e:
156
+ logger.debug(f"audit log write failed: {e}")
157
+
158
+
159
+ def audit_tail(lines: int = 50) -> list[dict[str, Any]]:
160
+ if not _AUDIT_PATH.exists():
161
+ return []
162
+ out: list[dict[str, Any]] = []
163
+ for line in _AUDIT_PATH.read_text().splitlines()[-lines:]:
164
+ try:
165
+ out.append(json.loads(line))
166
+ except Exception:
167
+ continue
168
+ return out
169
+
170
+
171
+ # ---- Decision gate ------------------------------------------------
172
+
173
+
174
+ def caudate_may_act() -> bool:
175
+ """The single gate Caudate's code paths should check.
176
+
177
+ Returns False if:
178
+ - the killswitch is active
179
+ True otherwise.
180
+ """
181
+ return not is_killed()