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/file_refs.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""@file references — inline or attach files mentioned in user prompts.
|
|
2
|
+
|
|
3
|
+
Two modes, picked per call site:
|
|
4
|
+
|
|
5
|
+
1. **Inline** (default for short prompts) — the file is read and pasted
|
|
6
|
+
into the message inside a fenced code block. Preserves the user's
|
|
7
|
+
literal text but expands the @path token to file contents.
|
|
8
|
+
|
|
9
|
+
2. **Attach** (when `file_store` is provided) — the file is uploaded
|
|
10
|
+
to the FileStore and the @path token is replaced with a small
|
|
11
|
+
`[file:<id>]` marker. The returned attachment list goes through
|
|
12
|
+
the same multimodal path as `chat(attachments=...)` so PDFs and
|
|
13
|
+
images work end-to-end.
|
|
14
|
+
|
|
15
|
+
Tokens follow this grammar:
|
|
16
|
+
- `@path/to/file.py` — relative or absolute, no spaces
|
|
17
|
+
- `@"path with spaces.txt"` — quoted form for paths with spaces
|
|
18
|
+
- `@./local.md`, `@~/home.md` — both supported
|
|
19
|
+
|
|
20
|
+
Globs (`@src/**/*.py`) are NOT auto-expanded — too easy to flood the
|
|
21
|
+
context window. The user can use the Glob tool if they need that.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Match either `@"quoted path"` or `@unquoted_path`. The unquoted form
|
|
35
|
+
# stops at whitespace; we strip a trailing punctuation char (.,;:!?) so
|
|
36
|
+
# `@README.md.` resolves to README.md without the trailing period.
|
|
37
|
+
_REF_RE = re.compile(
|
|
38
|
+
r'@(?:"([^"]+)"|([^\s"]+))'
|
|
39
|
+
)
|
|
40
|
+
_TRAILING_PUNCT = ".,;:!?)]}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_refs(text: str) -> list[str]:
|
|
44
|
+
"""Return all @-referenced paths in `text`, preserving order, deduped."""
|
|
45
|
+
seen: dict[str, None] = {}
|
|
46
|
+
for match in _REF_RE.finditer(text):
|
|
47
|
+
raw = match.group(1) or match.group(2) or ""
|
|
48
|
+
if not match.group(1):
|
|
49
|
+
while raw and raw[-1] in _TRAILING_PUNCT:
|
|
50
|
+
raw = raw[:-1]
|
|
51
|
+
if raw:
|
|
52
|
+
seen.setdefault(raw, None)
|
|
53
|
+
return list(seen.keys())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve(path_str: str) -> Path | None:
|
|
57
|
+
p = Path(path_str).expanduser()
|
|
58
|
+
if not p.is_absolute():
|
|
59
|
+
p = Path.cwd() / p
|
|
60
|
+
return p if p.exists() and p.is_file() else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def expand_inline(text: str, max_chars_per_file: int = 8000) -> str:
|
|
64
|
+
"""Replace each @path with the file's contents in a fenced block."""
|
|
65
|
+
def _sub(match: re.Match) -> str:
|
|
66
|
+
raw = match.group(1) or match.group(2) or ""
|
|
67
|
+
trailing = ""
|
|
68
|
+
if not match.group(1):
|
|
69
|
+
while raw and raw[-1] in _TRAILING_PUNCT:
|
|
70
|
+
trailing = raw[-1] + trailing
|
|
71
|
+
raw = raw[:-1]
|
|
72
|
+
path = _resolve(raw)
|
|
73
|
+
if path is None:
|
|
74
|
+
return match.group(0) # leave the original token in place
|
|
75
|
+
try:
|
|
76
|
+
content = path.read_text(errors="ignore")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.debug(f"@file read failed for {path}: {e}")
|
|
79
|
+
return match.group(0)
|
|
80
|
+
truncated = content[:max_chars_per_file]
|
|
81
|
+
suffix = ""
|
|
82
|
+
if len(content) > max_chars_per_file:
|
|
83
|
+
suffix = f"\n... (truncated at {max_chars_per_file} chars)"
|
|
84
|
+
lang = path.suffix.lstrip(".") or ""
|
|
85
|
+
return f"\n\n```{lang} {raw}\n{truncated}{suffix}\n```\n" + trailing
|
|
86
|
+
return _REF_RE.sub(_sub, text)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def expand_with_attachments(
|
|
90
|
+
text: str,
|
|
91
|
+
file_store: Any,
|
|
92
|
+
) -> tuple[str, list[str]]:
|
|
93
|
+
"""Upload each @path to `file_store` and replace with [file:<id>] markers.
|
|
94
|
+
|
|
95
|
+
Returns (rewritten_text, [file_id, ...]). The returned list is
|
|
96
|
+
intended to be passed straight to `agent.chat(..., attachments=...)`.
|
|
97
|
+
"""
|
|
98
|
+
attachments: list[str] = []
|
|
99
|
+
|
|
100
|
+
def _sub(match: re.Match) -> str:
|
|
101
|
+
raw = match.group(1) or match.group(2) or ""
|
|
102
|
+
trailing = ""
|
|
103
|
+
if not match.group(1):
|
|
104
|
+
while raw and raw[-1] in _TRAILING_PUNCT:
|
|
105
|
+
trailing = raw[-1] + trailing
|
|
106
|
+
raw = raw[:-1]
|
|
107
|
+
path = _resolve(raw)
|
|
108
|
+
if path is None:
|
|
109
|
+
return match.group(0)
|
|
110
|
+
try:
|
|
111
|
+
record = file_store.upload(path, filename=path.name)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.debug(f"@file upload failed for {path}: {e}")
|
|
114
|
+
return match.group(0)
|
|
115
|
+
attachments.append(record.id)
|
|
116
|
+
return f"[file:{record.id} {path.name}]" + trailing
|
|
117
|
+
|
|
118
|
+
rewritten = _REF_RE.sub(_sub, text)
|
|
119
|
+
return rewritten, attachments
|
core/files.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Files API — store, reference, and extract content from uploaded files.
|
|
2
|
+
|
|
3
|
+
Local equivalent of Anthropic's Files API. A file is registered in the
|
|
4
|
+
FileStore (copied to `data/files/`), gets a stable `file_id`, and can be
|
|
5
|
+
referenced in subsequent `chat()` calls. At LLM call time, the attachment
|
|
6
|
+
is materialized:
|
|
7
|
+
|
|
8
|
+
- PDFs → text extraction via pypdf, prepended as a block
|
|
9
|
+
- Text → read as UTF-8, prepended as a block
|
|
10
|
+
- Images → base64-encoded into a multimodal content block (the model must
|
|
11
|
+
be vision-capable, else the image is represented as a path ref)
|
|
12
|
+
|
|
13
|
+
The store is intentionally simple — flat directory, JSON manifest. No
|
|
14
|
+
multi-tenancy, no TTL, no cleanup job. Delete the file to remove it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import mimetypes
|
|
24
|
+
import shutil
|
|
25
|
+
import uuid
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, Field
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Kinds the store recognizes. Anything else is "binary" and rendered as a
|
|
36
|
+
# file-path reference (agents can still `Read` or run tools against it).
|
|
37
|
+
KIND_TEXT = "text"
|
|
38
|
+
KIND_PDF = "pdf"
|
|
39
|
+
KIND_IMAGE = "image"
|
|
40
|
+
KIND_AUDIO = "audio"
|
|
41
|
+
KIND_BINARY = "binary"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
|
45
|
+
TEXT_EXTS = {".txt", ".md", ".py", ".js", ".ts", ".json", ".yaml", ".yml",
|
|
46
|
+
".toml", ".csv", ".html", ".xml", ".rst", ".log"}
|
|
47
|
+
AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".flac", ".m4a"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _classify(path: Path) -> str:
|
|
51
|
+
ext = path.suffix.lower()
|
|
52
|
+
if ext == ".pdf":
|
|
53
|
+
return KIND_PDF
|
|
54
|
+
if ext in IMAGE_EXTS:
|
|
55
|
+
return KIND_IMAGE
|
|
56
|
+
if ext in AUDIO_EXTS:
|
|
57
|
+
return KIND_AUDIO
|
|
58
|
+
if ext in TEXT_EXTS:
|
|
59
|
+
return KIND_TEXT
|
|
60
|
+
return KIND_BINARY
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FileRecord(BaseModel):
|
|
64
|
+
id: str
|
|
65
|
+
filename: str
|
|
66
|
+
path: str # absolute path under FILES_DIR
|
|
67
|
+
kind: str
|
|
68
|
+
mime_type: str = ""
|
|
69
|
+
size_bytes: int = 0
|
|
70
|
+
sha256: str = ""
|
|
71
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
72
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FileStore:
|
|
76
|
+
"""Local file store with stable IDs and a manifest."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, root: Path):
|
|
79
|
+
self.root = root
|
|
80
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
self._manifest_path = self.root / "manifest.json"
|
|
82
|
+
self._records: dict[str, FileRecord] = {}
|
|
83
|
+
self._load()
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# CRUD
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def upload(self, source_path: Path, filename: str | None = None) -> FileRecord:
|
|
90
|
+
"""Copy a file into the store, return its FileRecord."""
|
|
91
|
+
src = Path(source_path).expanduser().resolve()
|
|
92
|
+
if not src.exists() or not src.is_file():
|
|
93
|
+
raise FileNotFoundError(f"Not a file: {src}")
|
|
94
|
+
|
|
95
|
+
name = filename or src.name
|
|
96
|
+
file_id = str(uuid.uuid4())
|
|
97
|
+
dest = self.root / f"{file_id}{src.suffix}"
|
|
98
|
+
shutil.copy2(src, dest)
|
|
99
|
+
|
|
100
|
+
data = dest.read_bytes()
|
|
101
|
+
record = FileRecord(
|
|
102
|
+
id=file_id,
|
|
103
|
+
filename=name,
|
|
104
|
+
path=str(dest),
|
|
105
|
+
kind=_classify(dest),
|
|
106
|
+
mime_type=mimetypes.guess_type(src.name)[0] or "application/octet-stream",
|
|
107
|
+
size_bytes=len(data),
|
|
108
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
109
|
+
)
|
|
110
|
+
self._records[file_id] = record
|
|
111
|
+
self._save()
|
|
112
|
+
logger.info(f"FileStore upload: {name} ({record.kind}, {record.size_bytes}B) → {file_id}")
|
|
113
|
+
return record
|
|
114
|
+
|
|
115
|
+
def get(self, file_id: str) -> FileRecord | None:
|
|
116
|
+
return self._records.get(file_id)
|
|
117
|
+
|
|
118
|
+
def delete(self, file_id: str) -> bool:
|
|
119
|
+
rec = self._records.pop(file_id, None)
|
|
120
|
+
if rec is None:
|
|
121
|
+
return False
|
|
122
|
+
Path(rec.path).unlink(missing_ok=True)
|
|
123
|
+
self._save()
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def list(self) -> list[FileRecord]:
|
|
127
|
+
return sorted(self._records.values(), key=lambda r: r.created_at, reverse=True)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Content extraction
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def extract_text(self, file_id: str, max_chars: int = 100_000) -> str:
|
|
134
|
+
"""Return the textual content of a file (empty string for images etc.)."""
|
|
135
|
+
rec = self.get(file_id)
|
|
136
|
+
if rec is None:
|
|
137
|
+
return ""
|
|
138
|
+
path = Path(rec.path)
|
|
139
|
+
|
|
140
|
+
if rec.kind == KIND_TEXT:
|
|
141
|
+
return path.read_text(errors="ignore")[:max_chars]
|
|
142
|
+
|
|
143
|
+
if rec.kind == KIND_PDF:
|
|
144
|
+
try:
|
|
145
|
+
import pypdf
|
|
146
|
+
except ImportError:
|
|
147
|
+
logger.warning("pypdf not installed — PDF extraction unavailable")
|
|
148
|
+
return f"[PDF file at {path} — install pypdf to extract text]"
|
|
149
|
+
try:
|
|
150
|
+
reader = pypdf.PdfReader(str(path))
|
|
151
|
+
pages = [p.extract_text() or "" for p in reader.pages]
|
|
152
|
+
text = "\n\n".join(pages)
|
|
153
|
+
return text[:max_chars]
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.warning(f"PDF extraction failed for {path}: {e}")
|
|
156
|
+
return f"[PDF extraction failed: {e}]"
|
|
157
|
+
|
|
158
|
+
return "" # images/audio/binary handled separately
|
|
159
|
+
|
|
160
|
+
def to_image_content(self, file_id: str) -> dict[str, Any] | None:
|
|
161
|
+
"""Return a multimodal image content block ready for a chat message.
|
|
162
|
+
|
|
163
|
+
Shape matches OpenAI/LiteLLM multimodal format; Anthropic receives
|
|
164
|
+
equivalent via LiteLLM translation.
|
|
165
|
+
"""
|
|
166
|
+
rec = self.get(file_id)
|
|
167
|
+
if rec is None or rec.kind != KIND_IMAGE:
|
|
168
|
+
return None
|
|
169
|
+
data = Path(rec.path).read_bytes()
|
|
170
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
171
|
+
return {
|
|
172
|
+
"type": "image_url",
|
|
173
|
+
"image_url": {
|
|
174
|
+
"url": f"data:{rec.mime_type};base64,{b64}",
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Manifest persistence
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _load(self) -> None:
|
|
183
|
+
if not self._manifest_path.exists():
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
raw = json.loads(self._manifest_path.read_text())
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.warning(f"Failed to load file manifest: {e}")
|
|
189
|
+
return
|
|
190
|
+
for entry in raw.get("files", []):
|
|
191
|
+
try:
|
|
192
|
+
rec = FileRecord.model_validate(entry)
|
|
193
|
+
self._records[rec.id] = rec
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.debug(f"Skipping bad manifest entry {entry}: {e}")
|
|
196
|
+
|
|
197
|
+
def _save(self) -> None:
|
|
198
|
+
payload = {"files": [r.model_dump(mode="json") for r in self._records.values()]}
|
|
199
|
+
self._manifest_path.write_text(json.dumps(payload, indent=2, default=str))
|
core/hooks.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Hooks system — event-driven extensibility for the agentic loop.
|
|
2
|
+
|
|
3
|
+
Events are fired at key points in the loop (see HookEvent). Each event can
|
|
4
|
+
have zero or more handlers. Handlers are either Python callables or shell
|
|
5
|
+
commands loaded from a JSON config file.
|
|
6
|
+
|
|
7
|
+
Handler signature: async def handler(event: str, payload: dict) -> dict | None
|
|
8
|
+
- Return a dict with "block": True to short-circuit (e.g. PRE_TOOL_USE)
|
|
9
|
+
- Return a dict with "modified_args": {...} to mutate tool args (PRE_TOOL_USE)
|
|
10
|
+
- Return None for pure side-effect handlers (logging, audit, etc.)
|
|
11
|
+
|
|
12
|
+
Shell-command handlers receive the payload via stdin as JSON and may emit
|
|
13
|
+
the same response dict as JSON on stdout.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Awaitable, Callable
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HookEvent(str, Enum):
|
|
30
|
+
PRE_TOOL_USE = "pre_tool_use"
|
|
31
|
+
POST_TOOL_USE = "post_tool_use"
|
|
32
|
+
POST_TOOL_USE_FAILURE = "post_tool_use_failure"
|
|
33
|
+
USER_PROMPT_SUBMIT = "user_prompt_submit"
|
|
34
|
+
STOP = "stop"
|
|
35
|
+
SUBAGENT_START = "subagent_start"
|
|
36
|
+
SUBAGENT_STOP = "subagent_stop"
|
|
37
|
+
PRE_COMPACT = "pre_compact"
|
|
38
|
+
NOTIFICATION = "notification"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
HookHandler = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any] | None] | dict[str, Any] | None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HookMatcher:
|
|
45
|
+
"""A matcher selects which tool/payloads a handler fires on."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
tool: str | None = None,
|
|
50
|
+
pattern: str | None = None,
|
|
51
|
+
):
|
|
52
|
+
self.tool = tool
|
|
53
|
+
self.pattern = re.compile(pattern) if pattern else None
|
|
54
|
+
|
|
55
|
+
def matches(self, payload: dict[str, Any]) -> bool:
|
|
56
|
+
if self.tool:
|
|
57
|
+
tool = payload.get("tool") or payload.get("tool_name")
|
|
58
|
+
if tool != self.tool and self.tool != "*":
|
|
59
|
+
return False
|
|
60
|
+
if self.pattern:
|
|
61
|
+
try:
|
|
62
|
+
serialized = json.dumps(payload, ensure_ascii=False, default=str)
|
|
63
|
+
except Exception:
|
|
64
|
+
serialized = str(payload)
|
|
65
|
+
return bool(self.pattern.search(serialized))
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HookManager:
|
|
70
|
+
"""Registers and dispatches hook handlers."""
|
|
71
|
+
|
|
72
|
+
def __init__(self):
|
|
73
|
+
self._handlers: dict[HookEvent, list[tuple[HookMatcher, HookHandler]]] = {
|
|
74
|
+
e: [] for e in HookEvent
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def register(
|
|
78
|
+
self,
|
|
79
|
+
event: HookEvent,
|
|
80
|
+
handler: HookHandler,
|
|
81
|
+
matcher: HookMatcher | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Register a handler for an event."""
|
|
84
|
+
self._handlers[event].append((matcher or HookMatcher(), handler))
|
|
85
|
+
|
|
86
|
+
def register_shell(
|
|
87
|
+
self,
|
|
88
|
+
event: HookEvent,
|
|
89
|
+
command: str,
|
|
90
|
+
matcher: HookMatcher | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Register a shell command as a hook handler."""
|
|
93
|
+
async def _shell_handler(evt: str, payload: dict[str, Any]):
|
|
94
|
+
return await _run_shell_hook(command, evt, payload)
|
|
95
|
+
|
|
96
|
+
self.register(event, _shell_handler, matcher)
|
|
97
|
+
|
|
98
|
+
async def emit(self, event: HookEvent, payload: dict[str, Any]) -> dict[str, Any]:
|
|
99
|
+
"""Fire all handlers for an event. Returns the aggregated response dict.
|
|
100
|
+
|
|
101
|
+
If any handler returns {"block": True}, subsequent handlers still run
|
|
102
|
+
(for audit), but the combined response will preserve block=True.
|
|
103
|
+
If any handler returns {"modified_args": ...}, the latest such value
|
|
104
|
+
wins (later handlers override earlier ones).
|
|
105
|
+
"""
|
|
106
|
+
combined: dict[str, Any] = {}
|
|
107
|
+
for matcher, handler in self._handlers.get(event, []):
|
|
108
|
+
if not matcher.matches(payload):
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
result = handler(event.value, payload)
|
|
112
|
+
if asyncio.iscoroutine(result):
|
|
113
|
+
result = await result
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.warning(f"Hook {event.value} handler failed: {e}")
|
|
116
|
+
continue
|
|
117
|
+
if isinstance(result, dict):
|
|
118
|
+
if result.get("block"):
|
|
119
|
+
combined["block"] = True
|
|
120
|
+
if "reason" in result:
|
|
121
|
+
combined["reason"] = result["reason"]
|
|
122
|
+
if "modified_args" in result:
|
|
123
|
+
combined["modified_args"] = result["modified_args"]
|
|
124
|
+
return combined
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Config loading
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def load_from_file(self, path: Path) -> int:
|
|
131
|
+
"""Load hook definitions from a JSON config file. Returns count loaded.
|
|
132
|
+
|
|
133
|
+
Format:
|
|
134
|
+
{
|
|
135
|
+
"hooks": [
|
|
136
|
+
{
|
|
137
|
+
"event": "pre_tool_use",
|
|
138
|
+
"command": "/path/to/script.sh",
|
|
139
|
+
"matcher": {"tool": "Bash", "pattern": "rm "}
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
"""
|
|
144
|
+
if not path.exists():
|
|
145
|
+
return 0
|
|
146
|
+
try:
|
|
147
|
+
data = json.loads(path.read_text())
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.warning(f"Failed to load hooks from {path}: {e}")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
count = 0
|
|
153
|
+
for h in data.get("hooks", []):
|
|
154
|
+
try:
|
|
155
|
+
event = HookEvent(h["event"])
|
|
156
|
+
except (KeyError, ValueError):
|
|
157
|
+
logger.warning(f"Invalid hook event: {h.get('event')}")
|
|
158
|
+
continue
|
|
159
|
+
matcher_cfg = h.get("matcher") or {}
|
|
160
|
+
matcher = HookMatcher(
|
|
161
|
+
tool=matcher_cfg.get("tool"),
|
|
162
|
+
pattern=matcher_cfg.get("pattern"),
|
|
163
|
+
)
|
|
164
|
+
if "command" in h:
|
|
165
|
+
self.register_shell(event, h["command"], matcher)
|
|
166
|
+
count += 1
|
|
167
|
+
# Python-callable hooks can't be declared in JSON; would require
|
|
168
|
+
# an import path. For now, only shell hooks are supported via file.
|
|
169
|
+
return count
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _run_shell_hook(
|
|
173
|
+
command: str,
|
|
174
|
+
event: str,
|
|
175
|
+
payload: dict[str, Any],
|
|
176
|
+
) -> dict[str, Any] | None:
|
|
177
|
+
"""Run a shell hook, piping the payload as JSON on stdin."""
|
|
178
|
+
try:
|
|
179
|
+
payload_json = json.dumps({"event": event, **payload}, default=str)
|
|
180
|
+
except Exception:
|
|
181
|
+
payload_json = json.dumps({"event": event})
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
proc = await asyncio.create_subprocess_shell(
|
|
185
|
+
command,
|
|
186
|
+
stdin=asyncio.subprocess.PIPE,
|
|
187
|
+
stdout=asyncio.subprocess.PIPE,
|
|
188
|
+
stderr=asyncio.subprocess.PIPE,
|
|
189
|
+
)
|
|
190
|
+
stdout, stderr = await asyncio.wait_for(
|
|
191
|
+
proc.communicate(payload_json.encode()),
|
|
192
|
+
timeout=30,
|
|
193
|
+
)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.warning(f"Shell hook {command!r} failed: {e}")
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
if proc.returncode != 0:
|
|
199
|
+
logger.debug(f"Shell hook {command!r} exit={proc.returncode}: {stderr.decode(errors='ignore')[:200]}")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
out = stdout.decode(errors="ignore").strip()
|
|
203
|
+
if not out:
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
return json.loads(out)
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
logger.debug(f"Shell hook {command!r} returned non-JSON output")
|
|
209
|
+
return None
|