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/paste.py ADDED
@@ -0,0 +1,81 @@
1
+ """Image / PDF paste detection.
2
+
3
+ Most terminals don't paste binary content directly. What you actually
4
+ get when dragging an image or a PDF into the prompt is the *path* to
5
+ that file, often quoted (macOS Terminal, iTerm, Alacritty, kitty all
6
+ behave this way). This module scans an input line for those bare paths,
7
+ uploads anything that's an image or a PDF to the FileStore, and returns
8
+ the cleaned text plus a list of attachment ids ready for `chat()`.
9
+
10
+ Distinct from `core.file_refs.expand_with_attachments`:
11
+ - `file_refs` requires an explicit `@` prefix (intentional).
12
+ - `paste` is heuristic — it triggers on any quoted path or any token
13
+ that's an existing image/PDF file in cwd. Useful for drag-drop UX.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import re
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # Image / PDF extensions we auto-attach.
27
+ _PASTE_EXTS = {
28
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
29
+ ".pdf",
30
+ }
31
+
32
+ # Match either a quoted path ('...' or "...") or a bare unquoted token.
33
+ _PATH_RE = re.compile(
34
+ r"""(?P<quoted>'[^']+'|"[^"]+")|(?P<bare>\S+)"""
35
+ )
36
+
37
+
38
+ def _looks_like_paste_path(token: str) -> Path | None:
39
+ """Return a resolved Path if the token points to an existing image/PDF."""
40
+ raw = token.strip().strip("'\"")
41
+ if not raw:
42
+ return None
43
+ p = Path(raw).expanduser()
44
+ if not p.is_absolute():
45
+ p = Path.cwd() / p
46
+ if not p.exists() or not p.is_file():
47
+ return None
48
+ if p.suffix.lower() not in _PASTE_EXTS:
49
+ return None
50
+ return p
51
+
52
+
53
+ def detect_paste(text: str, file_store: Any) -> tuple[str, list[str]]:
54
+ """Strip image/PDF paths from `text` and upload them via `file_store`.
55
+
56
+ Returns (rewritten_text, [file_id, ...]). Order is preserved. Tokens
57
+ that don't resolve to an existing image/PDF are left untouched.
58
+ """
59
+ attachments: list[str] = []
60
+ parts: list[str] = []
61
+ last = 0
62
+
63
+ for match in _PATH_RE.finditer(text):
64
+ token = match.group(0)
65
+ path = _looks_like_paste_path(token)
66
+ if path is None:
67
+ continue
68
+ try:
69
+ record = file_store.upload(path, filename=path.name)
70
+ except Exception as e:
71
+ logger.debug(f"paste upload failed for {path}: {e}")
72
+ continue
73
+ attachments.append(record.id)
74
+ parts.append(text[last:match.start()])
75
+ parts.append(f"[attached: {path.name}]")
76
+ last = match.end()
77
+
78
+ if not attachments:
79
+ return text, []
80
+ parts.append(text[last:])
81
+ return "".join(parts), attachments
core/permissions.py ADDED
@@ -0,0 +1,210 @@
1
+ """Permission system — control tool execution with modes and rules.
2
+
3
+ Modes:
4
+ - DEFAULT: prompt the user before executing destructive or sensitive tools
5
+ - PLAN: read-only — reject any tool that mutates state
6
+ - ACCEPT_EDITS: auto-approve file writes/edits but still prompt for Bash
7
+ - BYPASS: skip every check (dangerous; for trusted contexts)
8
+
9
+ Rules:
10
+ - allow: tool+regex-on-args patterns that auto-approve
11
+ - deny: tool+regex-on-args patterns that auto-reject
12
+
13
+ All decisions are written to an audit log (JSONL).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import re
22
+ from datetime import datetime
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import Any, Callable
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class PermissionMode(str, Enum):
31
+ DEFAULT = "default"
32
+ PLAN = "plan"
33
+ ACCEPT_EDITS = "accept_edits"
34
+ BYPASS = "bypass"
35
+
36
+
37
+ class PermissionDecision(str, Enum):
38
+ ALLOW = "allow"
39
+ DENY = "deny"
40
+ ASK = "ask"
41
+
42
+
43
+ # Which tools are considered mutating / sensitive / safe.
44
+ _SENSITIVE = {"Bash", "PythonExec"}
45
+ _MUTATING = {"Write", "Edit"}
46
+ _READONLY = {"Read", "Glob", "Grep", "WebSearch", "WebFetch", "Think", "Respond"}
47
+
48
+
49
+ class PermissionRule:
50
+ """A single allow/deny rule. Matches a tool name + optional arg pattern."""
51
+
52
+ def __init__(self, tool: str, pattern: str | None = None):
53
+ self.tool = tool
54
+ self.pattern = re.compile(pattern) if pattern else None
55
+
56
+ def matches(self, tool: str, args: dict[str, Any]) -> bool:
57
+ if tool != self.tool and self.tool != "*":
58
+ return False
59
+ if self.pattern is None:
60
+ return True
61
+ return bool(self.pattern.search(json.dumps(args, ensure_ascii=False)))
62
+
63
+
64
+ class PermissionManager:
65
+ """Evaluates tool calls against permission modes and rules, prompts when needed."""
66
+
67
+ def __init__(
68
+ self,
69
+ mode: PermissionMode = PermissionMode.DEFAULT,
70
+ allow: list[PermissionRule] | None = None,
71
+ deny: list[PermissionRule] | None = None,
72
+ audit_log: Path | None = None,
73
+ prompt: Callable[[str, dict[str, Any]], bool] | None = None,
74
+ ):
75
+ self.mode = mode
76
+ self.allow = allow or []
77
+ self.deny = deny or []
78
+ self.audit_log = audit_log
79
+ self._prompt_fn = prompt
80
+
81
+ # ------------------------------------------------------------------
82
+
83
+ def _classify(self, tool: str) -> str:
84
+ if tool in _MUTATING:
85
+ return "mutating"
86
+ if tool in _SENSITIVE:
87
+ return "sensitive"
88
+ if tool in _READONLY:
89
+ return "readonly"
90
+ return "unknown"
91
+
92
+ def _decide(self, tool: str, args: dict[str, Any]) -> PermissionDecision:
93
+ # Explicit deny rules win
94
+ if any(r.matches(tool, args) for r in self.deny):
95
+ return PermissionDecision.DENY
96
+
97
+ # Mode-level overrides
98
+ if self.mode == PermissionMode.BYPASS:
99
+ return PermissionDecision.ALLOW
100
+
101
+ # Explicit allow rules
102
+ if any(r.matches(tool, args) for r in self.allow):
103
+ return PermissionDecision.ALLOW
104
+
105
+ kind = self._classify(tool)
106
+
107
+ if self.mode == PermissionMode.PLAN:
108
+ # Plan mode: only readonly tools allowed
109
+ return PermissionDecision.ALLOW if kind == "readonly" else PermissionDecision.DENY
110
+
111
+ if self.mode == PermissionMode.ACCEPT_EDITS:
112
+ if kind in ("readonly", "mutating"):
113
+ return PermissionDecision.ALLOW
114
+ return PermissionDecision.ASK # sensitive/unknown still prompts
115
+
116
+ # DEFAULT mode
117
+ if kind == "readonly":
118
+ return PermissionDecision.ALLOW
119
+ return PermissionDecision.ASK # mutating / sensitive / unknown
120
+
121
+ async def check(self, tool: str, args: dict[str, Any]) -> tuple[bool, str]:
122
+ """Check whether a tool call is permitted.
123
+
124
+ Returns (allowed, reason).
125
+ """
126
+ decision = self._decide(tool, args)
127
+
128
+ if decision == PermissionDecision.ASK:
129
+ approved = await self._ask_user(tool, args)
130
+ final = "allow (user)" if approved else "deny (user)"
131
+ self._audit(tool, args, final)
132
+ return approved, final
133
+
134
+ final = decision.value
135
+ self._audit(tool, args, final)
136
+ return decision == PermissionDecision.ALLOW, final
137
+
138
+ # ------------------------------------------------------------------
139
+
140
+ async def _ask_user(self, tool: str, args: dict[str, Any]) -> bool:
141
+ if self._prompt_fn is None:
142
+ # No prompt wired: conservative default is deny
143
+ logger.warning(
144
+ f"Permission ASK for {tool} but no prompt configured; denying"
145
+ )
146
+ return False
147
+ result = self._prompt_fn(tool, args)
148
+ if asyncio.iscoroutine(result):
149
+ result = await result
150
+ return bool(result)
151
+
152
+ def _audit(self, tool: str, args: dict[str, Any], decision: str) -> None:
153
+ if self.audit_log is None:
154
+ return
155
+ try:
156
+ self.audit_log.parent.mkdir(parents=True, exist_ok=True)
157
+ entry = {
158
+ "ts": datetime.utcnow().isoformat(),
159
+ "tool": tool,
160
+ "args": args,
161
+ "decision": decision,
162
+ "mode": self.mode.value,
163
+ }
164
+ with self.audit_log.open("a") as f:
165
+ f.write(json.dumps(entry) + "\n")
166
+ except Exception as e:
167
+ logger.debug(f"Audit log write failed: {e}")
168
+
169
+
170
+ def rules_from_settings(entries: list[dict[str, Any]] | None) -> list[PermissionRule]:
171
+ """Build PermissionRule objects from a settings list.
172
+
173
+ Each entry: {"tool": "<name or '*'>", "pattern": "<regex|None>"}.
174
+ Bad entries are dropped with a warning, not raised — settings are
175
+ user-editable and a typo shouldn't crash the agent.
176
+ """
177
+ out: list[PermissionRule] = []
178
+ for entry in entries or []:
179
+ try:
180
+ tool = entry["tool"] if "tool" in entry else "*"
181
+ pattern = entry.get("pattern")
182
+ out.append(PermissionRule(tool=tool, pattern=pattern))
183
+ except Exception as e:
184
+ logger.warning(f"Skipping invalid permission rule {entry!r}: {e}")
185
+ return out
186
+
187
+
188
+ def default_cli_prompt(tool: str, args: dict[str, Any]) -> bool:
189
+ """Interactive terminal prompt used in CLI mode."""
190
+ from rich.console import Console
191
+ console = Console()
192
+
193
+ # Show a unified diff for proposed file changes — Claude Code parity.
194
+ if tool in ("Edit", "Write"):
195
+ try:
196
+ from core.diff_viewer import render_proposed_change
197
+ render_proposed_change(tool, args, console=console)
198
+ except Exception as e:
199
+ logger.debug(f"diff preview failed: {e}")
200
+ else:
201
+ preview = json.dumps(args, ensure_ascii=False)
202
+ if len(preview) > 400:
203
+ preview = preview[:397] + "…"
204
+ console.print(f"[yellow]Permission needed:[/yellow] {tool}({preview})")
205
+
206
+ try:
207
+ answer = console.input("[bold]Allow? [y/N]:[/bold] ").strip().lower()
208
+ except (EOFError, KeyboardInterrupt):
209
+ return False
210
+ return answer in ("y", "yes")
core/plan_mode.py ADDED
@@ -0,0 +1,215 @@
1
+ """Plan Mode + system FSM — read-only flag and explicit phase tracking.
2
+
3
+ Two layers sharing one module-level state:
4
+
5
+ 1. **Bool API** (`is_active`, `enter`, `exit_mode`) — the historical
6
+ plan-mode toggle. When ON, the executor refuses any tool whose
7
+ `mutates = True` (Bash, Write, Edit, PythonExec, HttpRequest, …).
8
+ Read-only tools (Read, Grep, Glob, SystemInfo, …) still work.
9
+
10
+ 2. **Phase FSM** (`SystemState`, `current_state`, `transition_to`,
11
+ `can_write`, `handle_validation_failure`) — formal 5-state machine
12
+ mirroring /home/raveuk/src/orchestrator/state_machine.py. Adds
13
+ explicit PLAN / EXEC / VALIDATION / RECOVERY / TERMINATED phases
14
+ with allowed-transition guards, a per-state write-lock matrix, and
15
+ a failure counter that auto-escalates to RECOVERY after
16
+ `MAX_TASK_FAILURES` strikes.
17
+
18
+ The two layers are coupled: `is_active()` returns True iff the
19
+ current state does **not** allow writes. So `enter()` puts us in
20
+ PLAN_MODE, `exit_mode()` returns us to EXEC_MODE, and the FSM can
21
+ also flip the bool implicitly by transitioning to VALIDATION_MODE
22
+ or RECOVERY_MODE.
23
+
24
+ Use case for the bool API: the user asks for a complex change, the
25
+ agent plans the edits, the user reviews, then exits plan mode to
26
+ actually make the changes. Mirrors Claude Code's Plan Mode UX.
27
+
28
+ Use case for the FSM: the cognitive loop drives explicit phase
29
+ transitions so write-blocking has rigor (only EXEC writes) and
30
+ repeated validation failures route to a recovery handler.
31
+
32
+ Process-global state — Cognos is single-user, simple is better than
33
+ per-session here.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import enum
39
+ import logging
40
+ import threading
41
+
42
+ try:
43
+ from config import MAX_TASK_FAILURES as _DEFAULT_MAX_FAILURES
44
+ except Exception: # config import failure shouldn't crash plan_mode
45
+ _DEFAULT_MAX_FAILURES = 2
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ class SystemState(str, enum.Enum):
51
+ """Phase of the cognitive loop. Mirrors src/orchestrator FSM."""
52
+ PLAN_MODE = "PLAN_MODE"
53
+ EXEC_MODE = "EXEC_MODE"
54
+ VALIDATION_MODE = "VALIDATION_MODE"
55
+ RECOVERY_MODE = "RECOVERY_MODE"
56
+ TERMINATED = "TERMINATED"
57
+
58
+
59
+ class StateMachineError(Exception):
60
+ """Raised on illegal state transitions."""
61
+
62
+
63
+ # Only EXEC_MODE may write. Everything else is read-only — this is the
64
+ # write-lock matrix from the dual-brain spec.
65
+ _WRITES_ALLOWED: dict[SystemState, bool] = {
66
+ SystemState.PLAN_MODE: False,
67
+ SystemState.EXEC_MODE: True,
68
+ SystemState.VALIDATION_MODE: False,
69
+ SystemState.RECOVERY_MODE: False,
70
+ SystemState.TERMINATED: False,
71
+ }
72
+
73
+ _ALLOWED_TRANSITIONS: dict[SystemState, list[SystemState]] = {
74
+ SystemState.PLAN_MODE: [SystemState.EXEC_MODE, SystemState.TERMINATED],
75
+ SystemState.EXEC_MODE: [SystemState.VALIDATION_MODE, SystemState.PLAN_MODE,
76
+ SystemState.TERMINATED],
77
+ SystemState.VALIDATION_MODE: [SystemState.PLAN_MODE, SystemState.EXEC_MODE,
78
+ SystemState.RECOVERY_MODE, SystemState.TERMINATED],
79
+ SystemState.RECOVERY_MODE: [SystemState.PLAN_MODE, SystemState.TERMINATED],
80
+ SystemState.TERMINATED: [],
81
+ }
82
+
83
+
84
+ class _PlanState:
85
+ """Wraps the FSM in a class so swaps are atomic and observable."""
86
+
87
+ def __init__(self) -> None:
88
+ # Default phase is EXEC_MODE — writes allowed, plan-mode bool OFF.
89
+ self._state: SystemState = SystemState.EXEC_MODE
90
+ self._failure_counter: int = 0
91
+ self._max_failures: int = int(_DEFAULT_MAX_FAILURES)
92
+ self._lock = threading.Lock()
93
+
94
+ # ---- bool API ----
95
+ def is_active(self) -> bool:
96
+ """True iff writes are currently blocked (any non-EXEC state)."""
97
+ return not _WRITES_ALLOWED[self._state]
98
+
99
+ def enter(self) -> bool:
100
+ """Force PLAN_MODE. Returns True if state changed."""
101
+ with self._lock:
102
+ if self._state == SystemState.PLAN_MODE:
103
+ return False
104
+ self._state = SystemState.PLAN_MODE
105
+ logger.info("Plan mode: ON (state=PLAN_MODE, writes blocked)")
106
+ return True
107
+
108
+ def exit(self) -> bool:
109
+ """Force EXEC_MODE. Returns True if state changed."""
110
+ with self._lock:
111
+ if self._state == SystemState.EXEC_MODE:
112
+ return False
113
+ self._state = SystemState.EXEC_MODE
114
+ self._failure_counter = 0 # fresh exec scope
115
+ logger.info("Plan mode: OFF (state=EXEC_MODE, writes allowed)")
116
+ return True
117
+
118
+ # ---- FSM API ----
119
+ def current_state(self) -> SystemState:
120
+ return self._state
121
+
122
+ def can_write(self) -> bool:
123
+ return _WRITES_ALLOWED[self._state]
124
+
125
+ def transition_to(self, target: SystemState, reason: str = "") -> None:
126
+ with self._lock:
127
+ allowed = _ALLOWED_TRANSITIONS[self._state]
128
+ if target not in allowed:
129
+ raise StateMachineError(
130
+ f"Illegal transition {self._state.value} -> {target.value}"
131
+ + (f" (reason: {reason})" if reason else "")
132
+ )
133
+ logger.info(
134
+ f"FSM: {self._state.value} -> {target.value}"
135
+ + (f" ({reason})" if reason else "")
136
+ )
137
+ self._state = target
138
+ if target == SystemState.EXEC_MODE:
139
+ self._failure_counter = 0 # exec scope resets counter
140
+
141
+ def handle_validation_failure(self, error_trace: str = "") -> SystemState:
142
+ """Increment failure counter; escalate to RECOVERY past threshold.
143
+
144
+ Returns the new state. Counter resets when EXEC_MODE is next
145
+ entered (either via `exit()` or `transition_to(EXEC_MODE, ...)`).
146
+ """
147
+ with self._lock:
148
+ self._failure_counter += 1
149
+ if self._failure_counter >= self._max_failures:
150
+ if SystemState.RECOVERY_MODE in _ALLOWED_TRANSITIONS[self._state]:
151
+ self._state = SystemState.RECOVERY_MODE
152
+ logger.warning(
153
+ f"FSM: validation failure threshold "
154
+ f"({self._failure_counter}/{self._max_failures}) "
155
+ f"-> RECOVERY_MODE. trace={error_trace[:200]}"
156
+ )
157
+ else:
158
+ logger.warning(
159
+ f"FSM: failure threshold met but RECOVERY not "
160
+ f"reachable from {self._state.value}; staying put"
161
+ )
162
+ else:
163
+ logger.info(
164
+ f"FSM: validation failure "
165
+ f"{self._failure_counter}/{self._max_failures}"
166
+ )
167
+ return self._state
168
+
169
+ def failure_count(self) -> int:
170
+ return self._failure_counter
171
+
172
+ def set_max_failures(self, n: int) -> None:
173
+ with self._lock:
174
+ self._max_failures = max(1, int(n))
175
+
176
+
177
+ _STATE = _PlanState()
178
+
179
+
180
+ # ---- bool API (back-compat) ----
181
+ def is_active() -> bool:
182
+ return _STATE.is_active()
183
+
184
+
185
+ def enter() -> bool:
186
+ return _STATE.enter()
187
+
188
+
189
+ def exit_mode() -> bool:
190
+ return _STATE.exit()
191
+
192
+
193
+ # ---- FSM API ----
194
+ def current_state() -> SystemState:
195
+ return _STATE.current_state()
196
+
197
+
198
+ def can_write() -> bool:
199
+ return _STATE.can_write()
200
+
201
+
202
+ def transition_to(target: SystemState, reason: str = "") -> None:
203
+ _STATE.transition_to(target, reason)
204
+
205
+
206
+ def handle_validation_failure(error_trace: str = "") -> SystemState:
207
+ return _STATE.handle_validation_failure(error_trace)
208
+
209
+
210
+ def failure_count() -> int:
211
+ return _STATE.failure_count()
212
+
213
+
214
+ def set_max_failures(n: int) -> None:
215
+ _STATE.set_max_failures(n)
core/sandbox_prompt.py ADDED
@@ -0,0 +1,185 @@
1
+ """Sandbox-awareness system prompt injection.
2
+
3
+ Both API endpoints (`/v1/chat/completions` for Open WebUI and
4
+ `/v1/messages` for Claude Code CLI) call `inject_sandbox_hint` on
5
+ their internal-shape message list. The helper appends a short hint
6
+ to an existing system message, or prepends a new system message if
7
+ none exists, telling the LLM to scaffold new files into
8
+ `/home/raveuk/cognos/sandbox/` by default.
9
+
10
+ Single source of truth so the chat and CLI paths stay consistent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ # Resolve once at import — repository-relative so it follows the user's
19
+ # checkout. Could also be made configurable via env var if needed.
20
+ SANDBOX_DIR = (Path(__file__).resolve().parent.parent / "sandbox").resolve()
21
+
22
+ SANDBOX_HINT = f"""## System access and workspace conventions
23
+
24
+ You are running on the user's own machine and have full inspection
25
+ access to it. Use the tools you have:
26
+
27
+ ### Questions about your own internals
28
+
29
+ When the user asks where your system prompt lives, what your
30
+ configuration is, "which file is it", "what's in your prompt", or
31
+ anything about your own runtime composition — **call `CognosCard`
32
+ first** (try `CognosCard(section="architecture")` for the file list).
33
+ **Do NOT** run `find` / `grep` / `Bash` to hunt for "the system prompt
34
+ file" — there isn't one single file. The system prompt is composed
35
+ at runtime from several sources, all listed in the Cognos card. If
36
+ `CognosCard` doesn't have what you need, read `data/COGNOS_CARD.md`
37
+ directly with `Read`. After two attempts, if you still don't have an
38
+ answer, surface the question to the user — don't keep searching.
39
+
40
+
41
+
42
+ - **Read access is unrestricted.** Use `Read`, `Grep`, `Glob`,
43
+ `FindAnywhere`, and read-only `Bash` (`ls`, `cat`, `df`, `nvidia-smi`,
44
+ `free`, `uname`, `ps`, `git status`, etc.) anywhere on the system
45
+ the user has access to. If they ask "what GPU do you have?" or
46
+ "what's in /etc/hosts?" — just look. The `SystemInfo` tool gives
47
+ you a one-call summary (CPU/RAM/GPU/disk/Python/CUDA).
48
+ - **Writes default to the sandbox.** When the user asks you to
49
+ *create*, *build*, *make*, *write*, or *scaffold* something new
50
+ and doesn't specify a path, write into:
51
+ - `{SANDBOX_DIR}/projects/<name>/` for multi-file apps
52
+ - `{SANDBOX_DIR}/scripts/` for one-off scripts/helpers
53
+ - `{SANDBOX_DIR}/notes/` for plain-text notes
54
+ The sandbox is its own 50GB filesystem (loopback ext4) — fill it
55
+ freely. Treat it as scratch space.
56
+ - **Writes outside the sandbox** are allowed when the user explicitly
57
+ references those files (e.g. "edit my .bashrc", "fix this bug in
58
+ /home/raveuk/cognos/api/openai_compat.py"). Don't write outside
59
+ the sandbox just because it would be tidier; only when asked.
60
+
61
+ ### Uploaded images / files — **READ THIS, IT IS NOT A SUGGESTION**
62
+
63
+ When the user attaches an image (or any file) in the chat, the API
64
+ auto-saves it to the FileStore (`/home/raveuk/cognos/data/files/`)
65
+ and injects a marker into the user message:
66
+
67
+ [uploaded image: files/<id>]
68
+
69
+ **`files/<id>` is a fully accessible local-filesystem reference.**
70
+ It is NOT cloud storage, NOT chat-only, NOT a placeholder. Every
71
+ local Cognos tool can read it. The image already exists on disk at
72
+ `/home/raveuk/cognos/data/files/<id>.<ext>` — it was saved before
73
+ you saw the message.
74
+
75
+ **ABSOLUTE RULES — no exceptions, no "but maybe":**
76
+
77
+ 1. If you see `[uploaded image: files/<id>]` in the user's message,
78
+ **ALWAYS pass that string directly to EditImage / DescribeImage**.
79
+ No path conversion, no path lookup, no "save it first" — just use
80
+ the handle as-is.
81
+ 2. **NEVER tell the user "save it to a local path"** or "the upload
82
+ isn't reaching my tool" or "it's in chat cloud storage". These
83
+ statements are false. The file IS local. Saying any of them is a
84
+ bug — if you find yourself about to type one, STOP and call the
85
+ tool with the file handle instead.
86
+ 3. **NEVER preface or follow a successful tool result with disclaimers
87
+ like "if this isn't your photo…"**. If EditImage returned a
88
+ markdown image link, the edit happened. Trust the tool. Show the
89
+ result without second-guessing.
90
+ 4. If the tool returns an actual error (status=error), surface that
91
+ exact error to the user. Do NOT substitute it with a fabricated
92
+ "save to a path" explanation.
93
+ """
94
+
95
+
96
+ def inject_sandbox_hint(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
97
+ """Return a new messages list with the sandbox hint applied.
98
+
99
+ Also folds in the durable memory.md contents so the LLM has access
100
+ to cross-session context every turn.
101
+
102
+ - Appends to an existing leading system message
103
+ - Otherwise prepends a fresh system message
104
+ - Idempotent: if the hint marker is already present, returns input
105
+ unchanged so multi-turn calls don't accumulate copies.
106
+ """
107
+ marker = "## Workspace conventions"
108
+
109
+ # Pull the durable memory file (empty string if no entries yet)
110
+ memory_block = ""
111
+ try:
112
+ from core.memory_md import get_memory
113
+ memory_block = get_memory().render_for_system_prompt()
114
+ except Exception:
115
+ pass
116
+
117
+ # Pull installed-skills TOC + auto-activate any skills whose
118
+ # name/description overlaps the user's prompt. The TOC keeps the
119
+ # LLM aware of all installed skills; the auto-activated FULL bodies
120
+ # live below so the LLM doesn't have to round-trip via LoadSkill
121
+ # when a skill is obviously relevant.
122
+ skills_toc = ""
123
+ skills_active = ""
124
+ try:
125
+ from core.skills import get_skills
126
+ registry = get_skills()
127
+ skills_toc = registry.render_toc()
128
+ # Find the most-recent user message to score skills against
129
+ latest_user = ""
130
+ for m in reversed(messages or []):
131
+ if m.get("role") == "user":
132
+ c = m.get("content", "")
133
+ if isinstance(c, list):
134
+ c = " ".join(b.get("text", "") for b in c
135
+ if isinstance(b, dict))
136
+ latest_user = c or ""
137
+ break
138
+ if latest_user:
139
+ skills_active = registry.render_active_skills(latest_user, top_k=2)
140
+ except Exception:
141
+ pass
142
+
143
+ # Cognos self-spec card reference — short pointer + reminder that
144
+ # the LLM should call `CognosCard` when it's unsure about Cognos's
145
+ # capabilities or boundaries.
146
+ cognos_card_block = ""
147
+ try:
148
+ from execution.tools.cognos_card_tool import render_cognos_card_short
149
+ cognos_card_block = render_cognos_card_short()
150
+ except Exception:
151
+ pass
152
+
153
+ full_hint = SANDBOX_HINT
154
+ if cognos_card_block:
155
+ full_hint = f"{full_hint}\n\n{cognos_card_block}"
156
+ if skills_toc:
157
+ full_hint = f"{full_hint}\n\n{skills_toc}"
158
+ if skills_active:
159
+ full_hint = f"{full_hint}\n\n{skills_active}"
160
+ if memory_block:
161
+ full_hint = f"{full_hint}\n\n{memory_block}"
162
+
163
+ if not messages:
164
+ return [{"role": "system", "content": full_hint}]
165
+
166
+ # Already applied? leave alone (chat clients replay the system msg
167
+ # on every turn; we don't want N copies after N turns). The memory
168
+ # block lives next to the marker so re-injecting it is also gated
169
+ # by the same idempotency check.
170
+ if messages and messages[0].get("role") == "system":
171
+ head = messages[0].get("content", "")
172
+ if isinstance(head, list):
173
+ head_text = " ".join(
174
+ b.get("text", "") for b in head if isinstance(b, dict)
175
+ )
176
+ else:
177
+ head_text = head or ""
178
+ if marker in head_text:
179
+ return messages
180
+ out = list(messages)
181
+ out[0] = {"role": "system", "content": f"{head_text}\n\n{full_hint}"}
182
+ return out
183
+
184
+ # No system message — prepend one
185
+ return [{"role": "system", "content": full_hint}, *messages]