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
memory/__init__.py
ADDED
|
File without changes
|
memory/episodic.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Episodic Memory — stores and retrieves past experiences using vector similarity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import chromadb
|
|
10
|
+
|
|
11
|
+
from config import CHROMA_DIR, EPISODIC_RECALL_LIMIT
|
|
12
|
+
from core.schemas import Episode
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EpisodicMemory:
|
|
18
|
+
"""Vector-based store of past episodes (experiences).
|
|
19
|
+
|
|
20
|
+
Each episode records what the agent did, what happened, and in what context.
|
|
21
|
+
Retrieval is by semantic similarity to a query.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, persist_dir: str | None = None):
|
|
25
|
+
path = persist_dir or str(CHROMA_DIR)
|
|
26
|
+
self._client = chromadb.PersistentClient(path=path)
|
|
27
|
+
self._collection = self._client.get_or_create_collection(
|
|
28
|
+
name="episodic_memory",
|
|
29
|
+
metadata={"hnsw:space": "cosine"},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def store(self, episode: Episode) -> None:
|
|
33
|
+
"""Store an episode."""
|
|
34
|
+
text = self._episode_to_text(episode)
|
|
35
|
+
metadata = {
|
|
36
|
+
"goal_id": episode.goal_id,
|
|
37
|
+
"task_id": episode.task_id,
|
|
38
|
+
"tool_name": episode.tool_name or "",
|
|
39
|
+
"timestamp": episode.timestamp.isoformat(),
|
|
40
|
+
}
|
|
41
|
+
if episode.result:
|
|
42
|
+
metadata["result_status"] = episode.result.status.value
|
|
43
|
+
|
|
44
|
+
self._collection.add(
|
|
45
|
+
ids=[episode.id],
|
|
46
|
+
documents=[text],
|
|
47
|
+
metadatas=[metadata],
|
|
48
|
+
)
|
|
49
|
+
logger.debug(f"Stored episode {episode.id}")
|
|
50
|
+
|
|
51
|
+
def recall(
|
|
52
|
+
self,
|
|
53
|
+
query: str,
|
|
54
|
+
limit: int = EPISODIC_RECALL_LIMIT,
|
|
55
|
+
goal_id: str | None = None,
|
|
56
|
+
) -> list[Episode]:
|
|
57
|
+
"""Retrieve similar episodes."""
|
|
58
|
+
where = {"goal_id": goal_id} if goal_id else None
|
|
59
|
+
results = self._collection.query(
|
|
60
|
+
query_texts=[query],
|
|
61
|
+
n_results=limit,
|
|
62
|
+
where=where,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
episodes = []
|
|
66
|
+
if results["documents"]:
|
|
67
|
+
for i, doc in enumerate(results["documents"][0]):
|
|
68
|
+
meta = results["metadatas"][0][i] if results["metadatas"] else {}
|
|
69
|
+
kwargs = {
|
|
70
|
+
"id": results["ids"][0][i],
|
|
71
|
+
"goal_id": meta.get("goal_id", ""),
|
|
72
|
+
"task_id": meta.get("task_id", ""),
|
|
73
|
+
"action": doc,
|
|
74
|
+
"tool_name": meta.get("tool_name") or None,
|
|
75
|
+
}
|
|
76
|
+
ts = meta.get("timestamp")
|
|
77
|
+
if ts:
|
|
78
|
+
kwargs["timestamp"] = ts
|
|
79
|
+
episode = Episode(**kwargs)
|
|
80
|
+
episodes.append(episode)
|
|
81
|
+
return episodes
|
|
82
|
+
|
|
83
|
+
def count(self) -> int:
|
|
84
|
+
return self._collection.count()
|
|
85
|
+
|
|
86
|
+
def _episode_to_text(self, episode: Episode) -> str:
|
|
87
|
+
"""Convert episode to searchable text."""
|
|
88
|
+
parts = [f"Action: {episode.action}"]
|
|
89
|
+
if episode.tool_name:
|
|
90
|
+
parts.append(f"Tool: {episode.tool_name}")
|
|
91
|
+
if episode.tool_args:
|
|
92
|
+
parts.append(f"Args: {json.dumps(episode.tool_args)}")
|
|
93
|
+
if episode.result:
|
|
94
|
+
parts.append(f"Result: {episode.result.status.value}")
|
|
95
|
+
if episode.result.output:
|
|
96
|
+
parts.append(f"Output: {str(episode.result.output)[:500]}")
|
|
97
|
+
if episode.result.error:
|
|
98
|
+
parts.append(f"Error: {episode.result.error}")
|
|
99
|
+
return " | ".join(parts)
|
memory/procedural.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Procedural Memory — stores learned strategies and heuristics in SQLite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
from sqlalchemy import create_engine, Column, String, Float, Integer, DateTime, Text
|
|
11
|
+
from sqlalchemy.orm import declarative_base, Session
|
|
12
|
+
|
|
13
|
+
from config import SQLITE_PATH
|
|
14
|
+
from core.schemas import Strategy
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
Base = declarative_base()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StrategyRow(Base):
|
|
22
|
+
__tablename__ = "strategies"
|
|
23
|
+
|
|
24
|
+
id = Column(String, primary_key=True)
|
|
25
|
+
name = Column(String, nullable=False)
|
|
26
|
+
description = Column(Text, nullable=False)
|
|
27
|
+
conditions = Column(Text, nullable=False)
|
|
28
|
+
actions = Column(Text, nullable=False)
|
|
29
|
+
confidence = Column(Float, default=0.5)
|
|
30
|
+
times_used = Column(Integer, default=0)
|
|
31
|
+
times_succeeded = Column(Integer, default=0)
|
|
32
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
33
|
+
updated_at = Column(DateTime, default=datetime.utcnow)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PerformanceLogRow(Base):
|
|
37
|
+
__tablename__ = "performance_log"
|
|
38
|
+
|
|
39
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
40
|
+
goal_id = Column(String, nullable=False)
|
|
41
|
+
score = Column(Float, nullable=False)
|
|
42
|
+
lesson = Column(Text, default="")
|
|
43
|
+
strategy_ids = Column(Text, default="[]") # JSON list
|
|
44
|
+
timestamp = Column(DateTime, default=datetime.utcnow)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProceduralMemory:
|
|
48
|
+
"""SQLite-backed store for learned strategies and performance history."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, db_path: str | None = None):
|
|
51
|
+
path = db_path or str(SQLITE_PATH)
|
|
52
|
+
self._engine = create_engine(f"sqlite:///{path}")
|
|
53
|
+
Base.metadata.create_all(self._engine)
|
|
54
|
+
|
|
55
|
+
def store_strategy(self, strategy: Strategy) -> None:
|
|
56
|
+
"""Store or update a strategy."""
|
|
57
|
+
with Session(self._engine) as session:
|
|
58
|
+
row = session.get(StrategyRow, strategy.id)
|
|
59
|
+
if row:
|
|
60
|
+
row.name = strategy.name
|
|
61
|
+
row.description = strategy.description
|
|
62
|
+
row.conditions = strategy.conditions
|
|
63
|
+
row.actions = strategy.actions
|
|
64
|
+
row.confidence = strategy.confidence
|
|
65
|
+
row.times_used = strategy.times_used
|
|
66
|
+
row.times_succeeded = strategy.times_succeeded
|
|
67
|
+
row.updated_at = datetime.utcnow()
|
|
68
|
+
else:
|
|
69
|
+
row = StrategyRow(
|
|
70
|
+
id=strategy.id,
|
|
71
|
+
name=strategy.name,
|
|
72
|
+
description=strategy.description,
|
|
73
|
+
conditions=strategy.conditions,
|
|
74
|
+
actions=strategy.actions,
|
|
75
|
+
confidence=strategy.confidence,
|
|
76
|
+
times_used=strategy.times_used,
|
|
77
|
+
times_succeeded=strategy.times_succeeded,
|
|
78
|
+
)
|
|
79
|
+
session.add(row)
|
|
80
|
+
session.commit()
|
|
81
|
+
|
|
82
|
+
def get_strategies(self, min_confidence: float = 0.0) -> list[Strategy]:
|
|
83
|
+
"""Retrieve all strategies above a confidence threshold."""
|
|
84
|
+
with Session(self._engine) as session:
|
|
85
|
+
rows = session.query(StrategyRow).filter(
|
|
86
|
+
StrategyRow.confidence >= min_confidence
|
|
87
|
+
).order_by(StrategyRow.confidence.desc()).all()
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
Strategy(
|
|
91
|
+
id=r.id,
|
|
92
|
+
name=r.name,
|
|
93
|
+
description=r.description,
|
|
94
|
+
conditions=r.conditions,
|
|
95
|
+
actions=r.actions,
|
|
96
|
+
confidence=r.confidence,
|
|
97
|
+
times_used=r.times_used,
|
|
98
|
+
times_succeeded=r.times_succeeded,
|
|
99
|
+
created_at=r.created_at,
|
|
100
|
+
updated_at=r.updated_at,
|
|
101
|
+
)
|
|
102
|
+
for r in rows
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
def update_strategy_stats(self, strategy_id: str, succeeded: bool) -> None:
|
|
106
|
+
"""Update usage stats for a strategy after it's been applied."""
|
|
107
|
+
with Session(self._engine) as session:
|
|
108
|
+
row = session.get(StrategyRow, strategy_id)
|
|
109
|
+
if row:
|
|
110
|
+
row.times_used += 1
|
|
111
|
+
if succeeded:
|
|
112
|
+
row.times_succeeded += 1
|
|
113
|
+
# Recalculate confidence as success rate with Bayesian smoothing
|
|
114
|
+
row.confidence = (row.times_succeeded + 1) / (row.times_used + 2)
|
|
115
|
+
row.updated_at = datetime.utcnow()
|
|
116
|
+
session.commit()
|
|
117
|
+
|
|
118
|
+
def log_performance(self, goal_id: str, score: float, lesson: str, strategy_ids: list[str]) -> None:
|
|
119
|
+
"""Log a performance entry."""
|
|
120
|
+
with Session(self._engine) as session:
|
|
121
|
+
session.add(PerformanceLogRow(
|
|
122
|
+
goal_id=goal_id,
|
|
123
|
+
score=score,
|
|
124
|
+
lesson=lesson,
|
|
125
|
+
strategy_ids=json.dumps(strategy_ids),
|
|
126
|
+
))
|
|
127
|
+
session.commit()
|
|
128
|
+
|
|
129
|
+
def get_performance_history(self, limit: int = 20) -> list[dict]:
|
|
130
|
+
"""Get recent performance entries."""
|
|
131
|
+
with Session(self._engine) as session:
|
|
132
|
+
rows = session.query(PerformanceLogRow).order_by(
|
|
133
|
+
PerformanceLogRow.timestamp.desc()
|
|
134
|
+
).limit(limit).all()
|
|
135
|
+
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
"goal_id": r.goal_id,
|
|
139
|
+
"score": r.score,
|
|
140
|
+
"lesson": r.lesson,
|
|
141
|
+
"strategy_ids": json.loads(r.strategy_ids),
|
|
142
|
+
"timestamp": r.timestamp.isoformat(),
|
|
143
|
+
}
|
|
144
|
+
for r in rows
|
|
145
|
+
]
|
memory/semantic.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Semantic Memory — long-term knowledge base using vector similarity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import chromadb
|
|
8
|
+
|
|
9
|
+
from config import CHROMA_DIR, SEMANTIC_RECALL_LIMIT
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SemanticMemory:
|
|
15
|
+
"""Vector-based knowledge store.
|
|
16
|
+
|
|
17
|
+
Stores facts, concepts, and learned information that persists across goals.
|
|
18
|
+
Unlike episodic memory (what happened), semantic memory stores what is known.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, persist_dir: str | None = None):
|
|
22
|
+
path = persist_dir or str(CHROMA_DIR)
|
|
23
|
+
self._client = chromadb.PersistentClient(path=path)
|
|
24
|
+
self._collection = self._client.get_or_create_collection(
|
|
25
|
+
name="semantic_memory",
|
|
26
|
+
metadata={"hnsw:space": "cosine"},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def store(self, knowledge_id: str, content: str, category: str = "general") -> None:
|
|
30
|
+
"""Store a piece of knowledge."""
|
|
31
|
+
self._collection.upsert(
|
|
32
|
+
ids=[knowledge_id],
|
|
33
|
+
documents=[content],
|
|
34
|
+
metadatas=[{"category": category}],
|
|
35
|
+
)
|
|
36
|
+
logger.debug(f"Stored knowledge: {knowledge_id}")
|
|
37
|
+
|
|
38
|
+
def recall(
|
|
39
|
+
self,
|
|
40
|
+
query: str,
|
|
41
|
+
limit: int = SEMANTIC_RECALL_LIMIT,
|
|
42
|
+
category: str | None = None,
|
|
43
|
+
) -> list[dict[str, str]]:
|
|
44
|
+
"""Retrieve relevant knowledge."""
|
|
45
|
+
where = {"category": category} if category else None
|
|
46
|
+
results = self._collection.query(
|
|
47
|
+
query_texts=[query],
|
|
48
|
+
n_results=limit,
|
|
49
|
+
where=where,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
knowledge = []
|
|
53
|
+
if results["documents"]:
|
|
54
|
+
for i, doc in enumerate(results["documents"][0]):
|
|
55
|
+
meta = results["metadatas"][0][i] if results["metadatas"] else {}
|
|
56
|
+
knowledge.append({
|
|
57
|
+
"id": results["ids"][0][i],
|
|
58
|
+
"content": doc,
|
|
59
|
+
"category": meta.get("category", "general"),
|
|
60
|
+
})
|
|
61
|
+
return knowledge
|
|
62
|
+
|
|
63
|
+
def forget(self, knowledge_id: str) -> None:
|
|
64
|
+
"""Remove a piece of knowledge."""
|
|
65
|
+
try:
|
|
66
|
+
self._collection.delete(ids=[knowledge_id])
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def count(self) -> int:
|
|
71
|
+
return self._collection.count()
|
memory/working.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Working Memory — short-term scratchpad for the current cognitive cycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from config import WORKING_MEMORY_CAPACITY
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkingMemory:
|
|
15
|
+
"""Fixed-capacity short-term memory. Oldest items are evicted when full."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, capacity: int = WORKING_MEMORY_CAPACITY):
|
|
18
|
+
self.capacity = capacity
|
|
19
|
+
self._store: OrderedDict[str, Any] = OrderedDict()
|
|
20
|
+
|
|
21
|
+
def store(self, key: str, value: Any) -> None:
|
|
22
|
+
"""Store an item. Evicts oldest if at capacity."""
|
|
23
|
+
if key in self._store:
|
|
24
|
+
self._store.move_to_end(key)
|
|
25
|
+
self._store[key] = value
|
|
26
|
+
while len(self._store) > self.capacity:
|
|
27
|
+
evicted_key, _ = self._store.popitem(last=False)
|
|
28
|
+
logger.debug(f"Working memory evicted: {evicted_key}")
|
|
29
|
+
|
|
30
|
+
def recall(self, key: str) -> Any | None:
|
|
31
|
+
"""Recall an item by key."""
|
|
32
|
+
if key in self._store:
|
|
33
|
+
self._store.move_to_end(key)
|
|
34
|
+
return self._store[key]
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def recall_all(self) -> dict[str, Any]:
|
|
38
|
+
"""Return all items in working memory."""
|
|
39
|
+
return dict(self._store)
|
|
40
|
+
|
|
41
|
+
def remove(self, key: str) -> None:
|
|
42
|
+
"""Remove an item."""
|
|
43
|
+
self._store.pop(key, None)
|
|
44
|
+
|
|
45
|
+
def clear(self) -> None:
|
|
46
|
+
"""Clear all working memory."""
|
|
47
|
+
self._store.clear()
|
|
48
|
+
|
|
49
|
+
def contains(self, key: str) -> bool:
|
|
50
|
+
return key in self._store
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def size(self) -> int:
|
|
54
|
+
return len(self._store)
|
|
55
|
+
|
|
56
|
+
def summary(self) -> str:
|
|
57
|
+
"""Return a text summary of working memory contents."""
|
|
58
|
+
if not self._store:
|
|
59
|
+
return "Working memory is empty."
|
|
60
|
+
lines = [f"Working memory ({self.size}/{self.capacity}):"]
|
|
61
|
+
for key, value in self._store.items():
|
|
62
|
+
val_str = str(value)[:100]
|
|
63
|
+
lines.append(f" - {key}: {val_str}")
|
|
64
|
+
return "\n".join(lines)
|
nn/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Cognos neural-network subsystem — home of Caudate.
|
|
2
|
+
|
|
3
|
+
Caudate is Cognos's action-selection brain — a small PyTorch transformer
|
|
4
|
+
named for the caudate nucleus, the basal-ganglia structure that learns
|
|
5
|
+
from outcomes to bias which action a real brain takes next. She trains
|
|
6
|
+
on Cognos's own session history to predict, per turn:
|
|
7
|
+
|
|
8
|
+
1. which tool to call next
|
|
9
|
+
2. which routing tier (System 1 / System 2) is appropriate
|
|
10
|
+
3. whether engaging chain-of-thought helps
|
|
11
|
+
4. an expected reward / value estimate
|
|
12
|
+
|
|
13
|
+
Caudate does NOT replace the LLM cortex. She runs alongside as an
|
|
14
|
+
*advisor*: every inference is logged, and (later) optionally biases the
|
|
15
|
+
agent's prompt. Bad predictions don't break the agent.
|
|
16
|
+
|
|
17
|
+
Module layout:
|
|
18
|
+
|
|
19
|
+
nn/config.py hyperparameters
|
|
20
|
+
nn/encoder.py state encoder (text + tool history + mood)
|
|
21
|
+
nn/caudate.py Caudate — transformer + 4 output heads
|
|
22
|
+
nn/data.py dataset + replay buffer
|
|
23
|
+
nn/trainer.py optimizer / loss / eval / checkpointing
|
|
24
|
+
nn/consolidator.py offline replay → training pairs
|
|
25
|
+
nn/runtime.py inference loader + CaudateAdvisor
|
|
26
|
+
|
|
27
|
+
Persistent state lives at `data/nn/`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from nn.caudate import Caudate, CognosController # alias kept for compat
|
|
31
|
+
from nn.config import NNConfig
|
|
32
|
+
from nn.encoder import StateEncoder
|
|
33
|
+
from nn.runtime import CaudateAdvisor, NNAdvisor, load_advisor
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"NNConfig",
|
|
37
|
+
"Caudate",
|
|
38
|
+
"CognosController",
|
|
39
|
+
"StateEncoder",
|
|
40
|
+
"CaudateAdvisor",
|
|
41
|
+
"NNAdvisor",
|
|
42
|
+
"load_advisor",
|
|
43
|
+
]
|
nn/auto_evolve.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Auto-evolve — Caudate fires her own NAS runs.
|
|
2
|
+
|
|
3
|
+
Wired into `CaudateObserver._kick_auto_train`. After each background
|
|
4
|
+
training cycle, this module decides whether to launch a NAS run:
|
|
5
|
+
|
|
6
|
+
1. Was the latest training improvement small? (PlateauScheduler)
|
|
7
|
+
2. Has the cooldown passed? (default 6h between NAS runs)
|
|
8
|
+
3. Is there enough VRAM headroom?
|
|
9
|
+
4. Is no NAS run already in flight?
|
|
10
|
+
|
|
11
|
+
If all four pass, picks a strategy (rotating through evolutionary →
|
|
12
|
+
random → rl), runs the search asynchronously, and if a new champion
|
|
13
|
+
beats the current Caudate by a margin, promotes it and hot-swaps the
|
|
14
|
+
advisor.
|
|
15
|
+
|
|
16
|
+
Every decision (fire / skip / promote / reject) lands in
|
|
17
|
+
`data/nn/nas/auto_log.jsonl` for inspection.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Strategies cycled through across firings. Evolutionary first because
|
|
36
|
+
# it's most sample-efficient; random as a sanity check; RL once enough
|
|
37
|
+
# trials accumulated to learn from.
|
|
38
|
+
_ROTATION = ["evolutionary", "random", "rl", "evolutionary", "hyperband"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AutoEvolveConfig:
|
|
43
|
+
enabled: bool = True
|
|
44
|
+
min_vram_gb: float = 3.0 # skip NAS if less than this is free
|
|
45
|
+
cooldown_seconds: float = 6 * 3600 # don't fire more often than this
|
|
46
|
+
n_trials_per_run: int = 8
|
|
47
|
+
train_steps_per_trial: int = 150
|
|
48
|
+
promote_margin: float = 0.005 # new champion must beat by this much
|
|
49
|
+
rotation: list[str] = None # strategies to cycle through
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _free_vram_gb() -> float:
|
|
53
|
+
try:
|
|
54
|
+
out = subprocess.run(
|
|
55
|
+
["nvidia-smi", "--query-gpu=memory.free",
|
|
56
|
+
"--format=csv,noheader,nounits"],
|
|
57
|
+
capture_output=True, text=True, timeout=2,
|
|
58
|
+
).stdout.strip().splitlines()[0]
|
|
59
|
+
return float(out) / 1024.0
|
|
60
|
+
except Exception:
|
|
61
|
+
return float("inf") # if we can't check, assume OK
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AutoEvolver:
|
|
65
|
+
"""Decides + drives autonomous NAS runs from the observer."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, observer: Any, cfg: AutoEvolveConfig | None = None):
|
|
68
|
+
self.observer = observer
|
|
69
|
+
self.cfg = cfg or AutoEvolveConfig()
|
|
70
|
+
if self.cfg.rotation is None:
|
|
71
|
+
self.cfg.rotation = list(_ROTATION)
|
|
72
|
+
self._nas_in_flight: asyncio.Task | None = None
|
|
73
|
+
self._n_fires = 0
|
|
74
|
+
self._last_fire_at: float = 0.0
|
|
75
|
+
self._auto_log = Path(self.observer.cfg.data_dir) / "nas" / "auto_log.jsonl"
|
|
76
|
+
self._auto_log.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def maybe_fire(self) -> None:
|
|
81
|
+
"""Called from CaudateObserver after each auto-train cycle."""
|
|
82
|
+
if not self.cfg.enabled:
|
|
83
|
+
return
|
|
84
|
+
# Owner killswitch — overrides everything else.
|
|
85
|
+
try:
|
|
86
|
+
from core.ownership import caudate_may_act
|
|
87
|
+
if not caudate_may_act():
|
|
88
|
+
self._log("skip", reason="owner_killswitch_active")
|
|
89
|
+
return
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
if self._nas_in_flight is not None and not self._nas_in_flight.done():
|
|
93
|
+
self._log("skip", reason="already_in_flight")
|
|
94
|
+
return
|
|
95
|
+
if time.time() - self._last_fire_at < self.cfg.cooldown_seconds:
|
|
96
|
+
self._log("skip", reason="cooldown")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Plateau check via the existing scheduler
|
|
100
|
+
try:
|
|
101
|
+
from nn.nas.scheduler import PlateauScheduler
|
|
102
|
+
sched = PlateauScheduler()
|
|
103
|
+
if not sched.should_fire():
|
|
104
|
+
self._log("skip", reason="not_plateaued",
|
|
105
|
+
stalled_for=sched.status().get("stalled_for"))
|
|
106
|
+
return
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.debug(f"plateau check failed: {e}")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# VRAM check
|
|
112
|
+
free_gb = _free_vram_gb()
|
|
113
|
+
if free_gb < self.cfg.min_vram_gb:
|
|
114
|
+
self._log("skip", reason="vram_low", free_gb=round(free_gb, 1))
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# All gates passed — schedule a background NAS run.
|
|
118
|
+
try:
|
|
119
|
+
loop = asyncio.get_running_loop()
|
|
120
|
+
except RuntimeError:
|
|
121
|
+
self._log("skip", reason="no_running_loop")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
strategy = self.cfg.rotation[self._n_fires % len(self.cfg.rotation)]
|
|
125
|
+
self._n_fires += 1
|
|
126
|
+
self._last_fire_at = time.time()
|
|
127
|
+
self._log("fire", strategy=strategy, n_fire=self._n_fires,
|
|
128
|
+
free_vram_gb=round(free_gb, 1))
|
|
129
|
+
self._nas_in_flight = loop.create_task(
|
|
130
|
+
self._run_async(strategy)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
async def _run_async(self, strategy_name: str) -> None:
|
|
136
|
+
"""Run NAS in a thread (heavy CPU/GPU work) and post-process."""
|
|
137
|
+
try:
|
|
138
|
+
result = await asyncio.to_thread(self._run_sync, strategy_name)
|
|
139
|
+
self._log("complete", strategy=strategy_name, result=result)
|
|
140
|
+
# Hot-swap the advisor in case champion was promoted
|
|
141
|
+
self.observer.reload_advisor()
|
|
142
|
+
try:
|
|
143
|
+
from nn.nas.scheduler import PlateauScheduler
|
|
144
|
+
PlateauScheduler().mark_fired()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
except Exception as e:
|
|
148
|
+
self._log("error", strategy=strategy_name, error=str(e))
|
|
149
|
+
logger.warning(f"auto-NAS [{strategy_name}] failed: {e}")
|
|
150
|
+
|
|
151
|
+
def _run_sync(self, strategy_name: str) -> dict[str, Any]:
|
|
152
|
+
from nn.nas import Searcher
|
|
153
|
+
from nn.nas.searcher import SearcherConfig
|
|
154
|
+
from nn.nas.strategies import make_strategy
|
|
155
|
+
from nn.nas.strategies.base import StrategyConfig
|
|
156
|
+
|
|
157
|
+
scfg = StrategyConfig(
|
|
158
|
+
n_trials=self.cfg.n_trials_per_run,
|
|
159
|
+
train_steps_per_trial=self.cfg.train_steps_per_trial,
|
|
160
|
+
seed=int(time.time()) % 10000,
|
|
161
|
+
)
|
|
162
|
+
if strategy_name == "hyperband":
|
|
163
|
+
base = make_strategy("random", config=scfg)
|
|
164
|
+
strategy = make_strategy("hyperband", config=scfg, base=base)
|
|
165
|
+
elif strategy_name == "darts":
|
|
166
|
+
strategy = make_strategy("darts", config=scfg, epochs=3, n_layers=4)
|
|
167
|
+
else:
|
|
168
|
+
strategy = make_strategy(strategy_name, config=scfg)
|
|
169
|
+
|
|
170
|
+
searcher = Searcher(
|
|
171
|
+
cfg=SearcherConfig(
|
|
172
|
+
n_trials=self.cfg.n_trials_per_run,
|
|
173
|
+
train_steps_per_trial=self.cfg.train_steps_per_trial,
|
|
174
|
+
seed=scfg.seed,
|
|
175
|
+
cost_cap=8.0,
|
|
176
|
+
min_corpus_size=64,
|
|
177
|
+
promote_margin=self.cfg.promote_margin,
|
|
178
|
+
),
|
|
179
|
+
strategy=strategy,
|
|
180
|
+
)
|
|
181
|
+
summary = searcher.search()
|
|
182
|
+
|
|
183
|
+
# If a new champion exists, promote it over the current Caudate
|
|
184
|
+
if summary.get("status") == "complete" and summary.get("champion"):
|
|
185
|
+
self._maybe_promote_to_main()
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"status": summary.get("status"),
|
|
189
|
+
"n_trials": summary.get("n_trials"),
|
|
190
|
+
"champion": summary.get("champion"),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def _maybe_promote_to_main(self) -> None:
|
|
194
|
+
"""Copy NAS champion → main caudate.pt if it beats current."""
|
|
195
|
+
try:
|
|
196
|
+
from nn.nas.store import NASStore
|
|
197
|
+
store = NASStore()
|
|
198
|
+
champ = store.champion()
|
|
199
|
+
if not champ:
|
|
200
|
+
return
|
|
201
|
+
# Get champion fitness
|
|
202
|
+
champ_fit = float(champ.get("result", {}).get("fitness", 0.0))
|
|
203
|
+
# We promote unconditionally — Searcher already gated on the
|
|
204
|
+
# margin against the previous champion. The Searcher's own
|
|
205
|
+
# save_champion step already wrote champion.pt. Now copy to
|
|
206
|
+
# the main slot so the live observer picks it up.
|
|
207
|
+
ok = store.promote_to_main(self.observer.cfg.checkpoint_path)
|
|
208
|
+
if ok:
|
|
209
|
+
logger.info(
|
|
210
|
+
f"auto-NAS promoted champion → main caudate "
|
|
211
|
+
f"(fitness={champ_fit:.3f})"
|
|
212
|
+
)
|
|
213
|
+
self._log("promote", fitness=champ_fit)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.debug(f"auto-NAS promotion failed: {e}")
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def status(self) -> dict[str, Any]:
|
|
220
|
+
return {
|
|
221
|
+
"enabled": self.cfg.enabled,
|
|
222
|
+
"n_fires": self._n_fires,
|
|
223
|
+
"in_flight": (
|
|
224
|
+
self._nas_in_flight is not None
|
|
225
|
+
and not self._nas_in_flight.done()
|
|
226
|
+
),
|
|
227
|
+
"seconds_since_last_fire": (
|
|
228
|
+
int(time.time() - self._last_fire_at)
|
|
229
|
+
if self._last_fire_at else None
|
|
230
|
+
),
|
|
231
|
+
"cooldown_seconds": self.cfg.cooldown_seconds,
|
|
232
|
+
"min_vram_gb": self.cfg.min_vram_gb,
|
|
233
|
+
"rotation": list(self.cfg.rotation),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def _log(self, action: str, **kwargs: Any) -> None:
|
|
237
|
+
try:
|
|
238
|
+
with self._auto_log.open("a") as f:
|
|
239
|
+
f.write(json.dumps({
|
|
240
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
241
|
+
"action": action,
|
|
242
|
+
**kwargs,
|
|
243
|
+
}) + "\n")
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.debug(f"auto_log write failed: {e}")
|