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