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/scheduler.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Cron-style scheduler for recurring agent prompts.
|
|
2
|
+
|
|
3
|
+
Persistent jobs are stored as JSON in `data/cron.json`. Each job has:
|
|
4
|
+
|
|
5
|
+
id, prompt, schedule, enabled, last_run, next_run
|
|
6
|
+
|
|
7
|
+
`schedule` accepts a tiny subset of cron syntax that's enough for the
|
|
8
|
+
common cases without dragging in a full cron parser:
|
|
9
|
+
|
|
10
|
+
- `every 30s` / `every 5m` / `every 2h` → fixed interval
|
|
11
|
+
- `daily 09:30` → every day at 09:30 local
|
|
12
|
+
- `weekly mon 09:30` → mon/tue/.../sun
|
|
13
|
+
- `at 2026-05-01 14:00` → one-shot
|
|
14
|
+
|
|
15
|
+
The runtime is `cognos cron run` — a foreground daemon that loops over
|
|
16
|
+
all enabled jobs, fires any whose `next_run <= now`, and reschedules.
|
|
17
|
+
That's deliberately simple: a real `cron` /systemd timer is the right
|
|
18
|
+
tool for production; this is for quick "remind me / re-check" loops a
|
|
19
|
+
single user wants alongside the REPL.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import uuid
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import datetime, time as dtime, timedelta
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Awaitable, Callable
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CronJob:
|
|
42
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
|
43
|
+
prompt: str = ""
|
|
44
|
+
schedule: str = ""
|
|
45
|
+
enabled: bool = True
|
|
46
|
+
last_run: str | None = None
|
|
47
|
+
next_run: str | None = None
|
|
48
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_schedule(schedule: str, now: datetime) -> datetime | None:
|
|
52
|
+
"""Compute the next firing time for `schedule` after `now`."""
|
|
53
|
+
s = schedule.strip().lower()
|
|
54
|
+
|
|
55
|
+
# every <N><unit>
|
|
56
|
+
m = re.fullmatch(r"every\s+(\d+)\s*(s|sec|m|min|h|hr|d|day)s?", s)
|
|
57
|
+
if m:
|
|
58
|
+
n = int(m.group(1))
|
|
59
|
+
unit = m.group(2)
|
|
60
|
+
delta = {
|
|
61
|
+
"s": timedelta(seconds=n),
|
|
62
|
+
"sec": timedelta(seconds=n),
|
|
63
|
+
"m": timedelta(minutes=n),
|
|
64
|
+
"min": timedelta(minutes=n),
|
|
65
|
+
"h": timedelta(hours=n),
|
|
66
|
+
"hr": timedelta(hours=n),
|
|
67
|
+
"d": timedelta(days=n),
|
|
68
|
+
"day": timedelta(days=n),
|
|
69
|
+
}[unit]
|
|
70
|
+
return now + delta
|
|
71
|
+
|
|
72
|
+
# daily HH:MM
|
|
73
|
+
m = re.fullmatch(r"daily\s+(\d{1,2}):(\d{2})", s)
|
|
74
|
+
if m:
|
|
75
|
+
target = dtime(int(m.group(1)), int(m.group(2)))
|
|
76
|
+
nxt = now.replace(hour=target.hour, minute=target.minute, second=0, microsecond=0)
|
|
77
|
+
if nxt <= now:
|
|
78
|
+
nxt += timedelta(days=1)
|
|
79
|
+
return nxt
|
|
80
|
+
|
|
81
|
+
# weekly <dow> HH:MM
|
|
82
|
+
m = re.fullmatch(r"weekly\s+(mon|tue|wed|thu|fri|sat|sun)\s+(\d{1,2}):(\d{2})", s)
|
|
83
|
+
if m:
|
|
84
|
+
dow = _DAYS.index(m.group(1))
|
|
85
|
+
target = dtime(int(m.group(2)), int(m.group(3)))
|
|
86
|
+
nxt = now.replace(hour=target.hour, minute=target.minute, second=0, microsecond=0)
|
|
87
|
+
days_ahead = (dow - nxt.weekday()) % 7
|
|
88
|
+
if days_ahead == 0 and nxt <= now:
|
|
89
|
+
days_ahead = 7
|
|
90
|
+
return nxt + timedelta(days=days_ahead)
|
|
91
|
+
|
|
92
|
+
# at YYYY-MM-DD HH:MM (one-shot)
|
|
93
|
+
m = re.fullmatch(r"at\s+(\d{4}-\d{2}-\d{2})\s+(\d{1,2}):(\d{2})", s)
|
|
94
|
+
if m:
|
|
95
|
+
try:
|
|
96
|
+
return datetime.fromisoformat(f"{m.group(1)}T{int(m.group(2)):02d}:{int(m.group(3)):02d}:00")
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CronStore:
|
|
104
|
+
"""Load/save persistence for CronJob entries."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, path: Path):
|
|
107
|
+
self.path = path
|
|
108
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
self._jobs: dict[str, CronJob] = {}
|
|
110
|
+
self._load()
|
|
111
|
+
|
|
112
|
+
def _load(self) -> None:
|
|
113
|
+
if not self.path.exists():
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
raw = json.loads(self.path.read_text())
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning(f"Failed to load cron jobs: {e}")
|
|
119
|
+
return
|
|
120
|
+
for entry in raw.get("jobs", []):
|
|
121
|
+
try:
|
|
122
|
+
self._jobs[entry["id"]] = CronJob(**entry)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.debug(f"Skipping bad cron entry {entry}: {e}")
|
|
125
|
+
|
|
126
|
+
def _save(self) -> None:
|
|
127
|
+
payload = {"jobs": [j.__dict__ for j in self._jobs.values()]}
|
|
128
|
+
self.path.write_text(json.dumps(payload, indent=2))
|
|
129
|
+
|
|
130
|
+
def add(self, prompt: str, schedule: str, **metadata: Any) -> CronJob:
|
|
131
|
+
nxt = _parse_schedule(schedule, datetime.now())
|
|
132
|
+
if nxt is None:
|
|
133
|
+
raise ValueError(f"Unrecognized schedule: {schedule}")
|
|
134
|
+
job = CronJob(
|
|
135
|
+
prompt=prompt,
|
|
136
|
+
schedule=schedule,
|
|
137
|
+
next_run=nxt.isoformat(timespec="seconds"),
|
|
138
|
+
metadata=metadata,
|
|
139
|
+
)
|
|
140
|
+
self._jobs[job.id] = job
|
|
141
|
+
self._save()
|
|
142
|
+
return job
|
|
143
|
+
|
|
144
|
+
def remove(self, job_id: str) -> bool:
|
|
145
|
+
if job_id not in self._jobs:
|
|
146
|
+
return False
|
|
147
|
+
del self._jobs[job_id]
|
|
148
|
+
self._save()
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
def list(self) -> list[CronJob]:
|
|
152
|
+
return sorted(self._jobs.values(), key=lambda j: j.id)
|
|
153
|
+
|
|
154
|
+
def get(self, job_id: str) -> CronJob | None:
|
|
155
|
+
return self._jobs.get(job_id)
|
|
156
|
+
|
|
157
|
+
def mark_run(self, job_id: str, ran_at: datetime) -> None:
|
|
158
|
+
job = self._jobs.get(job_id)
|
|
159
|
+
if job is None:
|
|
160
|
+
return
|
|
161
|
+
job.last_run = ran_at.isoformat(timespec="seconds")
|
|
162
|
+
nxt = _parse_schedule(job.schedule, ran_at)
|
|
163
|
+
# one-shot 'at' schedules can return a time in the past — disable
|
|
164
|
+
if nxt is None or (job.schedule.lower().startswith("at ") and nxt <= ran_at):
|
|
165
|
+
job.enabled = False
|
|
166
|
+
job.next_run = None
|
|
167
|
+
else:
|
|
168
|
+
job.next_run = nxt.isoformat(timespec="seconds")
|
|
169
|
+
self._save()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def run_scheduler(
|
|
173
|
+
store: CronStore,
|
|
174
|
+
fire: Callable[[CronJob], Awaitable[None]],
|
|
175
|
+
poll_seconds: float = 5.0,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Loop forever, firing jobs whose next_run has passed."""
|
|
178
|
+
logger.info(f"Cron scheduler started: {len(store.list())} jobs")
|
|
179
|
+
while True:
|
|
180
|
+
now = datetime.now()
|
|
181
|
+
for job in store.list():
|
|
182
|
+
if not job.enabled or not job.next_run:
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
nxt = datetime.fromisoformat(job.next_run)
|
|
186
|
+
except ValueError:
|
|
187
|
+
continue
|
|
188
|
+
if nxt > now:
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
await fire(job)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.warning(f"Cron job {job.id} fire failed: {e}")
|
|
194
|
+
store.mark_run(job.id, now)
|
|
195
|
+
await asyncio.sleep(poll_seconds)
|
core/schemas.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Core data schemas for the Cognos cognitive architecture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _uuid() -> str:
|
|
14
|
+
return str(uuid.uuid4())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _now() -> datetime:
|
|
18
|
+
return datetime.utcnow()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- Enums ---
|
|
22
|
+
|
|
23
|
+
class TaskStatus(str, Enum):
|
|
24
|
+
PENDING = "pending"
|
|
25
|
+
IN_PROGRESS = "in_progress"
|
|
26
|
+
COMPLETED = "completed"
|
|
27
|
+
FAILED = "failed"
|
|
28
|
+
BLOCKED = "blocked"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GoalStatus(str, Enum):
|
|
32
|
+
ACTIVE = "active"
|
|
33
|
+
ACHIEVED = "achieved"
|
|
34
|
+
FAILED = "failed"
|
|
35
|
+
ABANDONED = "abandoned"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolResultStatus(str, Enum):
|
|
39
|
+
SUCCESS = "success"
|
|
40
|
+
ERROR = "error"
|
|
41
|
+
TIMEOUT = "timeout"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- Core Models ---
|
|
45
|
+
|
|
46
|
+
class Goal(BaseModel):
|
|
47
|
+
"""A high-level objective the agent is trying to achieve."""
|
|
48
|
+
id: str = Field(default_factory=_uuid)
|
|
49
|
+
description: str
|
|
50
|
+
success_criteria: list[str] = Field(default_factory=list)
|
|
51
|
+
status: GoalStatus = GoalStatus.ACTIVE
|
|
52
|
+
created_at: datetime = Field(default_factory=_now)
|
|
53
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Task(BaseModel):
|
|
57
|
+
"""A concrete, executable step within a plan."""
|
|
58
|
+
id: str = Field(default_factory=_uuid)
|
|
59
|
+
goal_id: str
|
|
60
|
+
description: str
|
|
61
|
+
tool_name: str | None = None
|
|
62
|
+
tool_args: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
dependencies: list[str] = Field(default_factory=list) # task IDs
|
|
64
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
65
|
+
result: Any = None
|
|
66
|
+
error: str | None = None
|
|
67
|
+
attempts: int = 0
|
|
68
|
+
created_at: datetime = Field(default_factory=_now)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Plan(BaseModel):
|
|
72
|
+
"""A structured plan: a DAG of tasks to achieve a goal."""
|
|
73
|
+
id: str = Field(default_factory=_uuid)
|
|
74
|
+
goal_id: str
|
|
75
|
+
tasks: list[Task] = Field(default_factory=list)
|
|
76
|
+
reasoning: str = ""
|
|
77
|
+
version: int = 1
|
|
78
|
+
created_at: datetime = Field(default_factory=_now)
|
|
79
|
+
|
|
80
|
+
def get_ready_tasks(self) -> list[Task]:
|
|
81
|
+
"""Return tasks whose dependencies are all completed."""
|
|
82
|
+
completed_ids = {t.id for t in self.tasks if t.status == TaskStatus.COMPLETED}
|
|
83
|
+
return [
|
|
84
|
+
t for t in self.tasks
|
|
85
|
+
if t.status == TaskStatus.PENDING
|
|
86
|
+
and all(dep in completed_ids for dep in t.dependencies)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def is_complete(self) -> bool:
|
|
90
|
+
return all(t.status == TaskStatus.COMPLETED for t in self.tasks)
|
|
91
|
+
|
|
92
|
+
def has_failed(self) -> bool:
|
|
93
|
+
return any(t.status == TaskStatus.FAILED for t in self.tasks)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ToolResult(BaseModel):
|
|
97
|
+
"""Result of executing a tool."""
|
|
98
|
+
tool_name: str
|
|
99
|
+
status: ToolResultStatus
|
|
100
|
+
output: Any = None
|
|
101
|
+
error: str | None = None
|
|
102
|
+
duration_ms: float = 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Episode(BaseModel):
|
|
106
|
+
"""A recorded experience: what happened when the agent did something."""
|
|
107
|
+
id: str = Field(default_factory=_uuid)
|
|
108
|
+
goal_id: str
|
|
109
|
+
task_id: str
|
|
110
|
+
action: str
|
|
111
|
+
tool_name: str | None = None
|
|
112
|
+
tool_args: dict[str, Any] = Field(default_factory=dict)
|
|
113
|
+
result: ToolResult | None = None
|
|
114
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
115
|
+
timestamp: datetime = Field(default_factory=_now)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Reflection(BaseModel):
|
|
119
|
+
"""Agent's self-evaluation of an action or plan."""
|
|
120
|
+
id: str = Field(default_factory=_uuid)
|
|
121
|
+
episode_id: str | None = None
|
|
122
|
+
goal_id: str
|
|
123
|
+
score: float = 0.0 # 0.0 to 1.0
|
|
124
|
+
what_happened: str = ""
|
|
125
|
+
what_worked: str = ""
|
|
126
|
+
what_failed: str = ""
|
|
127
|
+
lesson: str = ""
|
|
128
|
+
should_replan: bool = False
|
|
129
|
+
replan_reason: str = ""
|
|
130
|
+
timestamp: datetime = Field(default_factory=_now)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Strategy(BaseModel):
|
|
134
|
+
"""A learned strategy or heuristic from past experience."""
|
|
135
|
+
id: str = Field(default_factory=_uuid)
|
|
136
|
+
name: str
|
|
137
|
+
description: str
|
|
138
|
+
conditions: str # when to apply this strategy
|
|
139
|
+
actions: str # what to do
|
|
140
|
+
confidence: float = 0.5 # 0.0 to 1.0
|
|
141
|
+
times_used: int = 0
|
|
142
|
+
times_succeeded: int = 0
|
|
143
|
+
created_at: datetime = Field(default_factory=_now)
|
|
144
|
+
updated_at: datetime = Field(default_factory=_now)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Perception(BaseModel):
|
|
148
|
+
"""The agent's current understanding of its situation."""
|
|
149
|
+
goal: Goal
|
|
150
|
+
plan: Plan | None = None
|
|
151
|
+
working_memory: dict[str, Any] = Field(default_factory=dict)
|
|
152
|
+
relevant_episodes: list[Episode] = Field(default_factory=list)
|
|
153
|
+
relevant_knowledge: list[str] = Field(default_factory=list)
|
|
154
|
+
active_strategies: list[Strategy] = Field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- Message Content Blocks (Claude SDK style) ---
|
|
158
|
+
|
|
159
|
+
class TextBlock(BaseModel):
|
|
160
|
+
"""A plain text block from the model."""
|
|
161
|
+
type: str = "text"
|
|
162
|
+
text: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ThinkingBlock(BaseModel):
|
|
166
|
+
"""A thinking/reasoning block — internal chain-of-thought."""
|
|
167
|
+
type: str = "thinking"
|
|
168
|
+
thinking: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ToolUseBlock(BaseModel):
|
|
172
|
+
"""The model's request to invoke a tool."""
|
|
173
|
+
type: str = "tool_use"
|
|
174
|
+
id: str = Field(default_factory=_uuid)
|
|
175
|
+
name: str
|
|
176
|
+
input: dict[str, Any] = Field(default_factory=dict)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ToolResultBlock(BaseModel):
|
|
180
|
+
"""The result of a tool execution, sent back to the model."""
|
|
181
|
+
type: str = "tool_result"
|
|
182
|
+
tool_use_id: str
|
|
183
|
+
content: Any = None
|
|
184
|
+
is_error: bool = False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- Stream Events (for async streaming) ---
|
|
188
|
+
|
|
189
|
+
class StreamEvent(BaseModel):
|
|
190
|
+
"""An event emitted during streaming LLM output.
|
|
191
|
+
|
|
192
|
+
type: one of "text_delta", "thinking_delta", "tool_use_start",
|
|
193
|
+
"tool_use_delta", "tool_use_end", "message_start", "message_stop"
|
|
194
|
+
"""
|
|
195
|
+
type: str
|
|
196
|
+
delta: str | None = None
|
|
197
|
+
block_index: int | None = None
|
|
198
|
+
tool_use_id: str | None = None
|
|
199
|
+
tool_name: str | None = None
|
|
200
|
+
tool_input: dict[str, Any] | None = None
|
|
201
|
+
stop_reason: str | None = None
|
|
202
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
core/session.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Session persistence — save and resume agentic loop conversations.
|
|
2
|
+
|
|
3
|
+
Sessions are stored as JSON files in data/sessions/<session_id>.json. Each
|
|
4
|
+
file contains the message history, creation timestamp, and metadata
|
|
5
|
+
(title, model, tag).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Session(BaseModel):
|
|
23
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
24
|
+
title: str = ""
|
|
25
|
+
tag: str = ""
|
|
26
|
+
model: str = ""
|
|
27
|
+
messages: list[dict[str, Any]] = Field(default_factory=list)
|
|
28
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
29
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
30
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SessionManager:
|
|
34
|
+
"""Save/load/list sessions from a local directory."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, sessions_dir: Path):
|
|
37
|
+
self.sessions_dir = sessions_dir
|
|
38
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def _path(self, session_id: str) -> Path:
|
|
41
|
+
return self.sessions_dir / f"{session_id}.json"
|
|
42
|
+
|
|
43
|
+
def save(self, session: Session) -> None:
|
|
44
|
+
session.updated_at = datetime.utcnow()
|
|
45
|
+
path = self._path(session.id)
|
|
46
|
+
path.write_text(session.model_dump_json(indent=2))
|
|
47
|
+
|
|
48
|
+
def load(self, session_id: str) -> Session | None:
|
|
49
|
+
path = self._path(session_id)
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return Session.model_validate_json(path.read_text())
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.warning(f"Failed to load session {session_id}: {e}")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def delete(self, session_id: str) -> bool:
|
|
59
|
+
path = self._path(session_id)
|
|
60
|
+
if path.exists():
|
|
61
|
+
path.unlink()
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def list(self) -> list[Session]:
|
|
66
|
+
"""Return all sessions, newest first."""
|
|
67
|
+
sessions: list[Session] = []
|
|
68
|
+
for path in self.sessions_dir.glob("*.json"):
|
|
69
|
+
try:
|
|
70
|
+
sessions.append(Session.model_validate_json(path.read_text()))
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.debug(f"Skipping unreadable session {path}: {e}")
|
|
73
|
+
sessions.sort(key=lambda s: s.updated_at, reverse=True)
|
|
74
|
+
return sessions
|
|
75
|
+
|
|
76
|
+
def rename(self, session_id: str, title: str) -> bool:
|
|
77
|
+
session = self.load(session_id)
|
|
78
|
+
if session is None:
|
|
79
|
+
return False
|
|
80
|
+
session.title = title
|
|
81
|
+
self.save(session)
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def tag(self, session_id: str, tag: str) -> bool:
|
|
85
|
+
session = self.load(session_id)
|
|
86
|
+
if session is None:
|
|
87
|
+
return False
|
|
88
|
+
session.tag = tag
|
|
89
|
+
self.save(session)
|
|
90
|
+
return True
|
core/settings.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Settings hierarchy — defaults < user (~/.cognos/settings.json) < project (.cognos/settings.json).
|
|
2
|
+
|
|
3
|
+
Each layer is a JSON object. Later layers shallow-merge over earlier ones.
|
|
4
|
+
The merged result is exposed as a `Settings` mapping that the rest of the
|
|
5
|
+
codebase can consult without caring where a value came from.
|
|
6
|
+
|
|
7
|
+
This intentionally mirrors Claude Code's settings model: project-level
|
|
8
|
+
overrides are committed to a repo so a team shares them; user-level
|
|
9
|
+
overrides are per-machine. Defaults are baked in here so a brand-new
|
|
10
|
+
clone with no settings files still works.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Built-in defaults. Anything here can be overridden by a settings file.
|
|
24
|
+
DEFAULTS: dict[str, Any] = {
|
|
25
|
+
"model": None, # falls back to config.LLM_MODEL
|
|
26
|
+
"permission_mode": "default",
|
|
27
|
+
"permissions": { # rule lists for /permissions
|
|
28
|
+
"allow": [], # [{"tool": "Bash", "pattern": "^ls"}]
|
|
29
|
+
"deny": [],
|
|
30
|
+
},
|
|
31
|
+
"statusline": "{model} | {mood} | session={session_short} | tok={tokens} | ${cost:.4f}",
|
|
32
|
+
"personality": True,
|
|
33
|
+
"thinking": False,
|
|
34
|
+
"stream": True,
|
|
35
|
+
"system1": "ollama/glm-5.1:cloud",
|
|
36
|
+
"system2": "anthropic/claude-haiku-4-5",
|
|
37
|
+
"fallback_models": [], # ordered list, tried on failure
|
|
38
|
+
"notifications": {
|
|
39
|
+
"enabled": True,
|
|
40
|
+
"on_long_task_seconds": 30, # notify if a tool/turn exceeds this
|
|
41
|
+
"on_permission_ask": True,
|
|
42
|
+
},
|
|
43
|
+
"cron_jobs_path": "data/cron.json",
|
|
44
|
+
"background_tasks_path": "data/bg_tasks.json",
|
|
45
|
+
"ide_api_url": "http://127.0.0.1:8000",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _user_settings_path() -> Path:
|
|
50
|
+
return Path.home() / ".cognos" / "settings.json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _project_settings_path() -> Path:
|
|
54
|
+
# We anchor on the cwd so each project can have its own settings.
|
|
55
|
+
return Path.cwd() / ".cognos" / "settings.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return {}
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(path.read_text())
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.warning(f"Failed to read settings at {path}: {e}")
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _deep_merge(base: dict[str, Any], over: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
"""Shallow-merge top-level keys; recursively merge nested dicts."""
|
|
70
|
+
out = dict(base)
|
|
71
|
+
for key, value in over.items():
|
|
72
|
+
if (
|
|
73
|
+
key in out
|
|
74
|
+
and isinstance(out[key], dict)
|
|
75
|
+
and isinstance(value, dict)
|
|
76
|
+
):
|
|
77
|
+
out[key] = _deep_merge(out[key], value)
|
|
78
|
+
else:
|
|
79
|
+
out[key] = value
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Settings:
|
|
84
|
+
"""Read-only view of the merged settings hierarchy."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, data: dict[str, Any], sources: list[Path]):
|
|
87
|
+
self._data = data
|
|
88
|
+
self._sources = sources
|
|
89
|
+
|
|
90
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
91
|
+
return self._data.get(key, default)
|
|
92
|
+
|
|
93
|
+
def __getitem__(self, key: str) -> Any:
|
|
94
|
+
return self._data[key]
|
|
95
|
+
|
|
96
|
+
def __contains__(self, key: str) -> bool:
|
|
97
|
+
return key in self._data
|
|
98
|
+
|
|
99
|
+
def as_dict(self) -> dict[str, Any]:
|
|
100
|
+
return dict(self._data)
|
|
101
|
+
|
|
102
|
+
def sources(self) -> list[Path]:
|
|
103
|
+
"""Files that contributed (in merge order)."""
|
|
104
|
+
return list(self._sources)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def load(cls) -> "Settings":
|
|
108
|
+
sources: list[Path] = []
|
|
109
|
+
merged = dict(DEFAULTS)
|
|
110
|
+
for path in (_user_settings_path(), _project_settings_path()):
|
|
111
|
+
if path.exists():
|
|
112
|
+
merged = _deep_merge(merged, _read(path))
|
|
113
|
+
sources.append(path)
|
|
114
|
+
return cls(merged, sources)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_user_setting(key: str, value: Any) -> None:
|
|
118
|
+
"""Persist a single key to the user-level settings file."""
|
|
119
|
+
path = _user_settings_path()
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
current = _read(path)
|
|
122
|
+
current[key] = value
|
|
123
|
+
path.write_text(json.dumps(current, indent=2))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def write_project_setting(key: str, value: Any) -> None:
|
|
127
|
+
"""Persist a single key to the project-level settings file."""
|
|
128
|
+
path = _project_settings_path()
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
current = _read(path)
|
|
131
|
+
current[key] = value
|
|
132
|
+
path.write_text(json.dumps(current, indent=2))
|