caudate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
core/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))