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/statusline.py ADDED
@@ -0,0 +1,61 @@
1
+ """Status line — single-line status above the input prompt.
2
+
3
+ The format is a Python `str.format` template pulled from settings
4
+ (`statusline` key). Available placeholders:
5
+
6
+ {model} active model id
7
+ {mood} personality mood label or 'n/a'
8
+ {session} full session id
9
+ {session_short} first 8 chars of session id
10
+ {tokens} total tokens this session
11
+ {cost} total $ cost this session
12
+ {turn} message count
13
+
14
+ Missing fields render as the literal placeholder so a malformed template
15
+ doesn't crash the prompt — render once, decide it's wrong, re-render.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+
23
+ class _SafeMap(dict):
24
+ def __missing__(self, key: str) -> str:
25
+ return "{" + key + "}"
26
+
27
+
28
+ def render_statusline(template: str, values: dict[str, Any]) -> str:
29
+ """Format `template` using {placeholders}; missing keys are kept literal."""
30
+ try:
31
+ return template.format_map(_SafeMap(values))
32
+ except Exception:
33
+ return template
34
+
35
+
36
+ def build_status_values(agent: Any) -> dict[str, Any]:
37
+ """Pull every documented placeholder value off a CognosAgent."""
38
+ from core.usage import get_global_tracker
39
+ tracker = get_global_tracker()
40
+ mood = "n/a"
41
+ try:
42
+ if agent.personality is not None:
43
+ mood = agent.personality.mood.label()
44
+ except Exception:
45
+ pass
46
+ session_id = ""
47
+ turn = 0
48
+ try:
49
+ session_id = agent.session.id
50
+ turn = len(agent.session.messages)
51
+ except Exception:
52
+ pass
53
+ return {
54
+ "model": getattr(agent.llm, "model", "?"),
55
+ "mood": mood,
56
+ "session": session_id,
57
+ "session_short": session_id[:8],
58
+ "tokens": tracker.total_tokens(),
59
+ "cost": tracker.total_cost(),
60
+ "turn": turn,
61
+ }
core/subagent.py ADDED
@@ -0,0 +1,300 @@
1
+ """Subagent manager — spawn, track, and collect isolated child agents.
2
+
3
+ A subagent is a child AgenticLoop with its own message history, its own
4
+ system prompt, and a restricted tool subset. It shares the parent's
5
+ long-term memory (episodic/semantic) but in read-only mode — the subagent
6
+ does not consolidate lessons back.
7
+
8
+ Subagents can run sequentially or in parallel. Parallel execution is
9
+ bounded by `max_concurrent` to avoid overloading local LLMs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from core.agentic_loop import AgenticLoop
21
+ from core.hooks import HookEvent, HookManager
22
+ from core.permissions import PermissionManager
23
+ from core.worktree import Worktree, create_worktree
24
+ from execution.executor import Executor
25
+ from llm.provider import LLMProvider
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ SUBAGENT_PROFILES: dict[str, dict[str, Any]] = {
31
+ "general-purpose": {
32
+ "tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "PythonExec",
33
+ "WebSearch", "WebFetch", "Think", "Respond"],
34
+ "system": (
35
+ "You are a general-purpose subagent. Focus narrowly on the task you "
36
+ "were given. Use tools as needed. When done, respond with a concise "
37
+ "summary — that summary is returned to your parent agent."
38
+ ),
39
+ },
40
+ "research": {
41
+ "tools": ["WebSearch", "WebFetch", "Think", "Respond"],
42
+ "system": (
43
+ "You are a research subagent. Search the web, fetch and read pages, "
44
+ "then synthesize findings. Cite sources. End with a concise summary."
45
+ ),
46
+ },
47
+ "code-review": {
48
+ "tools": ["Read", "Glob", "Grep", "Think", "Respond"],
49
+ "system": (
50
+ "You are a code-review subagent. Read the specified files, identify "
51
+ "bugs/smells/risks, and report a prioritized list with file:line "
52
+ "references. Do NOT modify files — review only."
53
+ ),
54
+ },
55
+ # Dual-brain roles — mirror /home/raveuk/src/prompts/system_prompts.json.
56
+ # `executor_brain_01` runs linear plans; on twice-failed validation
57
+ # it dispatches context to `critic_brain_02` through the inter-agent
58
+ # bus and waits for a reply. `critic_brain_02` reviews structural
59
+ # problems and outputs fixes exclusively as diff patches.
60
+ "executor_brain": {
61
+ "tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "PythonExec",
62
+ "Think", "Respond", "Agent"],
63
+ "system": (
64
+ "ROLE: You are 'executor_brain_01'. Run tasks linearly. If "
65
+ "validation fails twice, escalate via inter_agent_bus to "
66
+ "critic_brain_02 with the failing context attached. Wait for "
67
+ "the critic's reply before retrying."
68
+ ),
69
+ },
70
+ "critic_brain": {
71
+ "tools": ["Read", "Glob", "Grep", "Think", "Respond"],
72
+ "system": (
73
+ "ROLE: You are 'critic_brain_02'. You review structural "
74
+ "problems and output fixes exclusively as diff patches. "
75
+ "Read the failing context, identify the root cause, and "
76
+ "emit a unified diff (`---/+++` headers, `@@` hunks) that "
77
+ "the executor can apply directly. Do not modify files "
78
+ "yourself — review only."
79
+ ),
80
+ },
81
+ }
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Inter-agent bus — async queue channels between named agents.
86
+ #
87
+ # Mirrors /home/raveuk/src/communication/bus.py: one queue per registered
88
+ # agent id, plus a futures dict for request/reply when the sender wants
89
+ # to suspend until the recipient replies. Lives inline (no new file)
90
+ # because it's tiny and only the SubagentManager wires it.
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class InterAgentBus:
95
+ """Async message bus keyed by agent id (e.g. executor_brain_01)."""
96
+
97
+ def __init__(self, agent_ids: list[str] | None = None):
98
+ ids = agent_ids or ["executor_brain_01", "critic_brain_02"]
99
+ self.channels: dict[str, asyncio.Queue] = {a: asyncio.Queue() for a in ids}
100
+ self.pending: dict[str, asyncio.Future] = {}
101
+
102
+ def register(self, agent_id: str) -> None:
103
+ self.channels.setdefault(agent_id, asyncio.Queue())
104
+
105
+ async def send(
106
+ self,
107
+ sender_id: str,
108
+ recipient_id: str,
109
+ context: str,
110
+ suspend_sender: bool = False,
111
+ ) -> Any | None:
112
+ if recipient_id not in self.channels:
113
+ raise ValueError(f"Bus address '{recipient_id}' is not registered.")
114
+ payload: dict[str, Any] = {
115
+ "sender_id": sender_id,
116
+ "context": context,
117
+ "requires_reply": suspend_sender,
118
+ }
119
+ if suspend_sender:
120
+ loop = asyncio.get_event_loop()
121
+ fut: asyncio.Future = loop.create_future()
122
+ corr_id = f"{sender_id}->{recipient_id}:{id(fut)}"
123
+ payload["correlation_id"] = corr_id
124
+ self.pending[corr_id] = fut
125
+ await self.channels[recipient_id].put(payload)
126
+ try:
127
+ return await fut
128
+ finally:
129
+ self.pending.pop(corr_id, None)
130
+ await self.channels[recipient_id].put(payload)
131
+ return None
132
+
133
+ async def recv(self, agent_id: str) -> dict[str, Any]:
134
+ if agent_id not in self.channels:
135
+ raise ValueError(f"Bus address '{agent_id}' is not registered.")
136
+ return await self.channels[agent_id].get()
137
+
138
+ def reply(self, correlation_id: str, result: Any) -> bool:
139
+ fut = self.pending.get(correlation_id)
140
+ if fut is None or fut.done():
141
+ return False
142
+ fut.set_result(result)
143
+ return True
144
+
145
+
146
+ @dataclass
147
+ class SubagentRun:
148
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
149
+ subagent_type: str = "general-purpose"
150
+ task: str = ""
151
+ result: str | None = None
152
+ error: str | None = None
153
+ worktree_path: str | None = None
154
+ worktree_branch: str | None = None
155
+
156
+
157
+ class SubagentManager:
158
+ """Spawns child agents with restricted tool sets."""
159
+
160
+ def __init__(
161
+ self,
162
+ llm: LLMProvider,
163
+ parent_executor: Executor,
164
+ episodic=None,
165
+ semantic=None,
166
+ permissions: PermissionManager | None = None,
167
+ hooks: HookManager | None = None,
168
+ max_concurrent: int = 3,
169
+ max_iterations: int = 10,
170
+ ):
171
+ self.llm = llm
172
+ self.parent_executor = parent_executor
173
+ self.episodic = episodic
174
+ self.semantic = semantic
175
+ self.permissions = permissions
176
+ self.hooks = hooks or HookManager()
177
+ self.max_concurrent = max_concurrent
178
+ self.max_iterations = max_iterations
179
+ self._semaphore = asyncio.Semaphore(max_concurrent)
180
+ # Inter-agent bus — pre-registers the two dual-brain addresses.
181
+ # Additional ids can be added via `self.bus.register(...)`.
182
+ self.bus = InterAgentBus()
183
+
184
+ async def dispatch(
185
+ self,
186
+ sender_id: str,
187
+ recipient_id: str,
188
+ context: str,
189
+ suspend_sender: bool = False,
190
+ ) -> Any | None:
191
+ """Send a message on the inter-agent bus.
192
+
193
+ When `suspend_sender` is True, awaits a reply from the recipient
194
+ and returns it. Otherwise fire-and-forget.
195
+ """
196
+ return await self.bus.send(sender_id, recipient_id, context, suspend_sender)
197
+
198
+ async def run_one(
199
+ self,
200
+ task: str,
201
+ subagent_type: str = "general-purpose",
202
+ isolation: str | None = None,
203
+ ) -> SubagentRun:
204
+ """Run a single subagent to completion.
205
+
206
+ `isolation="worktree"` creates a temporary git worktree the
207
+ subagent operates in. The worktree path is returned on the run
208
+ so the caller can review or discard it. If creation fails, the
209
+ run continues in the parent's working tree (best-effort).
210
+ """
211
+ profile = SUBAGENT_PROFILES.get(subagent_type, SUBAGENT_PROFILES["general-purpose"])
212
+ run = SubagentRun(subagent_type=subagent_type, task=task)
213
+
214
+ await self.hooks.emit(HookEvent.SUBAGENT_START, {
215
+ "subagent_id": run.id,
216
+ "subagent_type": subagent_type,
217
+ "task": task,
218
+ "isolation": isolation,
219
+ })
220
+
221
+ wt: Worktree | None = None
222
+ if isolation == "worktree":
223
+ wt = await create_worktree()
224
+ if wt is not None:
225
+ run.worktree_path = str(wt.path)
226
+ run.worktree_branch = wt.branch
227
+ logger.info(f"Subagent {run.id} running in worktree {wt.path}")
228
+
229
+ async with self._semaphore:
230
+ try:
231
+ child_executor = _restricted_executor(
232
+ self.parent_executor, profile["tools"], self.llm,
233
+ )
234
+ child = AgenticLoop(
235
+ llm=self.llm,
236
+ executor=child_executor,
237
+ episodic=self.episodic,
238
+ semantic=self.semantic,
239
+ system_prompt=profile["system"],
240
+ max_iterations=self.max_iterations,
241
+ permissions=self.permissions,
242
+ )
243
+ if wt is not None:
244
+ # Tell tools the new working directory by chdir-ing
245
+ # for the duration of this run. AgenticLoop is async
246
+ # but we keep this call boundary serialized via the
247
+ # semaphore, so a process-level chdir is OK here.
248
+ import os
249
+ prev_cwd = os.getcwd()
250
+ os.chdir(wt.path)
251
+ try:
252
+ run.result = await child.run(task)
253
+ finally:
254
+ os.chdir(prev_cwd)
255
+ else:
256
+ run.result = await child.run(task)
257
+ except Exception as e:
258
+ logger.warning(f"Subagent {run.id} failed: {e}")
259
+ run.error = str(e)
260
+ finally:
261
+ if wt is not None:
262
+ await wt.cleanup(force=False)
263
+
264
+ await self.hooks.emit(HookEvent.SUBAGENT_STOP, {
265
+ "subagent_id": run.id,
266
+ "result": run.result,
267
+ "error": run.error,
268
+ "worktree_path": run.worktree_path,
269
+ })
270
+ return run
271
+
272
+ async def run_parallel(
273
+ self,
274
+ tasks: list[tuple[str, str]],
275
+ isolation: str | None = None,
276
+ ) -> list[SubagentRun]:
277
+ """Run multiple subagents concurrently. Each tuple = (task, subagent_type)."""
278
+ return await asyncio.gather(*(
279
+ self.run_one(task, t, isolation=isolation) for task, t in tasks
280
+ ))
281
+
282
+
283
+ def _restricted_executor(
284
+ parent: Executor,
285
+ allowed_tools: list[str],
286
+ llm: LLMProvider,
287
+ ) -> Executor:
288
+ """Create a new executor exposing only the allowed tools.
289
+
290
+ Rather than deep-copying the parent, build a fresh Executor with the
291
+ allowed subset. This keeps the restriction airtight.
292
+ """
293
+ child = Executor.__new__(Executor)
294
+ child._tools = {}
295
+ child._llm = llm
296
+ for name in allowed_tools:
297
+ tool = parent.get_tool(name)
298
+ if tool is not None:
299
+ child._tools[tool.name] = tool
300
+ return child
core/thinking.py ADDED
@@ -0,0 +1,50 @@
1
+ """ThinkingManager — chain-of-thought reasoning before actions.
2
+
3
+ Mirrors the Claude SDK "thinking" feature using a prompt-based protocol:
4
+ the model is instructed to emit <thinking>...</thinking> blocks before any
5
+ visible output or tool call. The manager strips those blocks from the
6
+ user-facing content and exposes them separately so the UI can render them
7
+ in a dimmed panel (or ignore them silently by default).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from core.schemas import ThinkingBlock
15
+
16
+ THINKING_INSTRUCTION = (
17
+ "Before responding or calling any tool, you may reason step-by-step "
18
+ "inside a <thinking>...</thinking> block. Use it to plan your approach, "
19
+ "weigh options, and catch mistakes. Everything inside the block is "
20
+ "internal and will not be shown to the user. Keep it brief — one short "
21
+ "paragraph is usually enough. After the thinking block, either call a "
22
+ "tool or emit your final answer."
23
+ )
24
+
25
+ _THINKING_RE = re.compile(
26
+ r"<thinking>(.*?)</thinking>",
27
+ re.DOTALL | re.IGNORECASE,
28
+ )
29
+
30
+
31
+ class ThinkingManager:
32
+ """Injects thinking instructions into the system prompt and parses blocks."""
33
+
34
+ def __init__(self, enabled: bool = False):
35
+ self.enabled = enabled
36
+
37
+ def augment_system_prompt(self, system: str) -> str:
38
+ """Append the thinking instruction to a system prompt."""
39
+ if not self.enabled:
40
+ return system
41
+ return f"{system}\n\n{THINKING_INSTRUCTION}"
42
+
43
+ def parse(self, text: str) -> tuple[list[ThinkingBlock], str]:
44
+ """Extract thinking blocks from text. Returns (blocks, stripped_text)."""
45
+ blocks = [
46
+ ThinkingBlock(thinking=m.group(1).strip())
47
+ for m in _THINKING_RE.finditer(text)
48
+ ]
49
+ stripped = _THINKING_RE.sub("", text).strip()
50
+ return blocks, stripped
core/updater.py ADDED
@@ -0,0 +1,122 @@
1
+ """Auto-update — `cognos update` checks for and applies new versions.
2
+
3
+ Two install modes are detected automatically:
4
+
5
+ 1. **Git checkout** (the common case for a cloned repo) — runs
6
+ `git fetch && git pull --ff-only` on the project root. If the local
7
+ branch has diverged, refuses to merge and tells the user.
8
+
9
+ 2. **Pip install** (if `cognos` was installed as a package) — runs
10
+ `pip install --upgrade cognos`. We detect this by looking for an
11
+ installed distribution metadata entry; absent → assume git mode.
12
+
13
+ Returns a small report describing what changed, or why nothing did.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class UpdateResult:
30
+ mode: str # "git" | "pip" | "unknown"
31
+ changed: bool
32
+ summary: str
33
+ details: str = ""
34
+
35
+
36
+ def _is_git_repo(path: Path) -> bool:
37
+ return (path / ".git").exists()
38
+
39
+
40
+ def _pip_installed() -> bool:
41
+ try:
42
+ from importlib.metadata import distribution
43
+ distribution("cognos")
44
+ return True
45
+ except Exception:
46
+ return False
47
+
48
+
49
+ def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 120) -> tuple[int, str, str]:
50
+ proc = subprocess.run(
51
+ cmd,
52
+ cwd=cwd,
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=timeout,
56
+ )
57
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
58
+
59
+
60
+ def update_via_git(repo: Path) -> UpdateResult:
61
+ if shutil.which("git") is None:
62
+ return UpdateResult(mode="git", changed=False, summary="git not installed")
63
+
64
+ rc, before, _ = _run(["git", "rev-parse", "HEAD"], cwd=repo)
65
+ if rc != 0:
66
+ return UpdateResult(mode="git", changed=False, summary="not a git checkout")
67
+
68
+ rc, _, err = _run(["git", "fetch"], cwd=repo)
69
+ if rc != 0:
70
+ return UpdateResult(mode="git", changed=False, summary="fetch failed", details=err)
71
+
72
+ rc, out, err = _run(["git", "pull", "--ff-only"], cwd=repo)
73
+ if rc != 0:
74
+ return UpdateResult(
75
+ mode="git",
76
+ changed=False,
77
+ summary="local branch has diverged — resolve manually",
78
+ details=err or out,
79
+ )
80
+
81
+ rc, after, _ = _run(["git", "rev-parse", "HEAD"], cwd=repo)
82
+ if before == after:
83
+ return UpdateResult(mode="git", changed=False, summary="already up to date")
84
+ rc, log, _ = _run(
85
+ ["git", "log", "--oneline", f"{before}..{after}"], cwd=repo,
86
+ )
87
+ return UpdateResult(
88
+ mode="git",
89
+ changed=True,
90
+ summary=f"updated {before[:7]} → {after[:7]}",
91
+ details=log,
92
+ )
93
+
94
+
95
+ def update_via_pip() -> UpdateResult:
96
+ rc, out, err = _run(
97
+ [sys.executable, "-m", "pip", "install", "--upgrade", "cognos"],
98
+ timeout=300,
99
+ )
100
+ if rc != 0:
101
+ return UpdateResult(mode="pip", changed=False, summary="pip upgrade failed", details=err or out)
102
+ changed = "already satisfied" not in (out + err).lower()
103
+ return UpdateResult(
104
+ mode="pip",
105
+ changed=changed,
106
+ summary="pip upgrade ok" if changed else "already up to date",
107
+ details=out,
108
+ )
109
+
110
+
111
+ def update(project_root: Path | None = None) -> UpdateResult:
112
+ """Detect install mode and update accordingly."""
113
+ root = project_root or Path(__file__).resolve().parent.parent
114
+ if _is_git_repo(root):
115
+ return update_via_git(root)
116
+ if _pip_installed():
117
+ return update_via_pip()
118
+ return UpdateResult(
119
+ mode="unknown",
120
+ changed=False,
121
+ summary="not a git checkout and not pip-installed — cannot self-update",
122
+ )
core/usage.py ADDED
@@ -0,0 +1,109 @@
1
+ """Usage tracking — token counts and rough cost across an agent session.
2
+
3
+ `UsageTracker` accumulates per-model token counts (prompt + completion)
4
+ and computes cost from a static price table. Local Ollama models are
5
+ priced at $0 by default. Cloud models use approximate prices that match
6
+ their published rates as of early 2026 — adjust freely; the tracker just
7
+ multiplies tokens × $/Mtok.
8
+
9
+ The tracker is mutable shared state — `LLMProvider` calls `record()` on
10
+ every successful response, so any consumer (status line, /cost slash
11
+ command, end-of-session report) sees the live total.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # Price per 1M tokens, USD. Best-effort, intentionally conservative.
24
+ # Anything not listed defaults to 0 (treated as local/free).
25
+ DEFAULT_PRICES: dict[str, tuple[float, float]] = {
26
+ # Anthropic
27
+ "anthropic/claude-opus-4-7": (15.0, 75.0),
28
+ "anthropic/claude-sonnet-4-6": (3.0, 15.0),
29
+ "anthropic/claude-haiku-4-5": (0.80, 4.0),
30
+ "claude-opus-4-7": (15.0, 75.0),
31
+ "claude-sonnet-4-6": (3.0, 15.0),
32
+ "claude-haiku-4-5": (0.80, 4.0),
33
+ # OpenAI rough rates
34
+ "openai/gpt-4o": (2.5, 10.0),
35
+ "openai/gpt-4o-mini": (0.15, 0.6),
36
+ "gpt-4o": (2.5, 10.0),
37
+ "gpt-4o-mini": (0.15, 0.6),
38
+ }
39
+
40
+
41
+ @dataclass
42
+ class ModelUsage:
43
+ prompt_tokens: int = 0
44
+ completion_tokens: int = 0
45
+ requests: int = 0
46
+
47
+ @property
48
+ def total_tokens(self) -> int:
49
+ return self.prompt_tokens + self.completion_tokens
50
+
51
+
52
+ @dataclass
53
+ class UsageTracker:
54
+ """Accumulates usage across a session."""
55
+
56
+ by_model: dict[str, ModelUsage] = field(default_factory=dict)
57
+ prices: dict[str, tuple[float, float]] = field(
58
+ default_factory=lambda: dict(DEFAULT_PRICES),
59
+ )
60
+
61
+ def record(self, model: str, usage: dict[str, Any]) -> None:
62
+ """Record a single LLM call.
63
+
64
+ `usage` is the dict shape LLMProvider returns: {prompt_tokens,
65
+ completion_tokens, total_tokens}. Missing keys are treated as 0.
66
+ """
67
+ slot = self.by_model.setdefault(model, ModelUsage())
68
+ slot.prompt_tokens += int(usage.get("prompt_tokens") or 0)
69
+ slot.completion_tokens += int(usage.get("completion_tokens") or 0)
70
+ slot.requests += 1
71
+
72
+ def total_tokens(self) -> int:
73
+ return sum(u.total_tokens for u in self.by_model.values())
74
+
75
+ def total_cost(self) -> float:
76
+ total = 0.0
77
+ for model, u in self.by_model.items():
78
+ in_price, out_price = self.prices.get(model, (0.0, 0.0))
79
+ total += (u.prompt_tokens / 1_000_000) * in_price
80
+ total += (u.completion_tokens / 1_000_000) * out_price
81
+ return total
82
+
83
+ def report(self) -> dict[str, Any]:
84
+ return {
85
+ "total_tokens": self.total_tokens(),
86
+ "total_cost_usd": round(self.total_cost(), 6),
87
+ "by_model": {
88
+ m: {
89
+ "prompt_tokens": u.prompt_tokens,
90
+ "completion_tokens": u.completion_tokens,
91
+ "requests": u.requests,
92
+ }
93
+ for m, u in self.by_model.items()
94
+ },
95
+ }
96
+
97
+
98
+ # Process-wide tracker. Shared by LLMProvider so any agent in the process
99
+ # accumulates into the same totals. Tests can replace this if isolation
100
+ # matters.
101
+ _GLOBAL = UsageTracker()
102
+
103
+
104
+ def get_global_tracker() -> UsageTracker:
105
+ return _GLOBAL
106
+
107
+
108
+ def reset_global_tracker() -> None:
109
+ _GLOBAL.by_model.clear()