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/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]
|