witbitz-code 1.2.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.
@@ -0,0 +1,266 @@
1
+ """Attachments in the Code section, the Claude Code way — tools/code-attachments.mjs + spaces/public/codeAttachments.js.
2
+
3
+ The connector saves every file a message carries on this computer and puts a NOTE in the message in its place; the agent
4
+ opens the file with its read tool when it needs it (docs/code-attachments.md). OpenCode refuses an Excel or Word file part
5
+ for every model, and TrustedRouter answers 502 for a PDF — both measured on opencode 1.18.30, 2026-09-13.
6
+
7
+ Held to the JS module by spaces/test/codeAttachments.vectors.json. One difference, stated: this connector has no attested
8
+ Tinfoil client yet, so it makes no text copies — the note says so, and the agent reads the file itself.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import os
16
+ import re
17
+ import shutil
18
+ import time
19
+ from decimal import ROUND_HALF_UP, Decimal
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from . import _js
24
+
25
+ NOTE_HEAD = "The user attached files. They are saved on this computer — open them with the read tool."
26
+ ATTACHMENT_ROUTE = "/witbitz/attachment"
27
+ MAX_NAME = 100
28
+ MAX_FILE_BYTES = 25 * 1024 * 1024
29
+ MAX_SESSION_BYTES = 200 * 1024 * 1024
30
+ MAX_SERVE_BYTES = 20 * 1024 * 1024
31
+ MAX_ROOT_BYTES = 2 * 1024 * 1024 * 1024 # every session together — a secret-holder cannot fill the disk by inventing sessions
32
+ SHOWN_MAX = 200 # a file name is prompt text the model reads
33
+ NO_COPY = "this computer's Python connector does not make text copies yet"
34
+ _SESSION_RE = re.compile(r"[A-Za-z0-9_-]{1,128}")
35
+ _FILE_RE = re.compile(r"[0-9a-f]{8}-(?!\.)[A-Za-z0-9._ ()-]{1,110}")
36
+ _TEXT_EXT = {"txt", "md", "markdown", "csv", "tsv", "json", "log", "xml", "yaml", "yml", "html", "htm", "js", "mjs", "ts", "py", "sh", "sql", "ini", "toml"}
37
+ _MIME = {"pdf": "application/pdf", "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
38
+ "heic": "image/heic", "md": "text/markdown", "txt": "text/plain", "csv": "text/csv", "json": "application/json",
39
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xls": "application/vnd.ms-excel",
40
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "doc": "application/msword",
41
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"}
42
+
43
+
44
+ def default_root() -> Path:
45
+ return Path(os.environ.get("WITBITZ_CODE_ATTACHMENTS") or Path.home() / ".witbitz" / "code" / "attachments")
46
+
47
+
48
+ def safe_name(name: Any) -> str:
49
+ s = _js.trim(re.split(r"[\\/]", _js.js_string(name) if _js.truthy(name) else "")[-1])
50
+ s = re.sub(r"^\.", "_", re.sub(r"_+", "_", re.sub(r"[^A-Za-z0-9._ ()-]+", "_", s)))
51
+ if not re.sub(r"[._ ]", "", s):
52
+ s = "file"
53
+ if len(s) > MAX_NAME:
54
+ m = re.search(r"\.[A-Za-z0-9]{1,10}\Z", s)
55
+ ext = m.group(0) if m else ""
56
+ s = s[: MAX_NAME - len(ext)] + ext
57
+ return s
58
+
59
+
60
+ def staged_file_name(hash_hex: str, name: Any) -> str:
61
+ return f"{str(hash_hex)[:8]}-{safe_name(name)}"
62
+
63
+
64
+ def _ext(name: Any) -> str:
65
+ m = re.search(r"\.([a-z0-9]+)\Z", _js.js_string(name or "").lower())
66
+ return m.group(1) if m else ""
67
+
68
+
69
+ def needs_text_copy(mime: Any, name: Any) -> bool:
70
+ m = _js.js_string(mime or "").lower()
71
+ if m.startswith("image/") or m.startswith("text/") or m == "application/json":
72
+ return False
73
+ return _ext(name) not in _TEXT_EXT
74
+
75
+
76
+ def _fixed1(x: float) -> str:
77
+ return str(Decimal(x).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)) # Number#toFixed(1) on the exact value
78
+
79
+
80
+ def format_size(n: Any) -> str:
81
+ n = int(n or 0)
82
+ if n < 1024:
83
+ return f"{n} B"
84
+ if n < 1024 * 1023.95:
85
+ return f"{_fixed1(n / 1024)} KB"
86
+ return f"{_fixed1(n / 1024 / 1024)} MB"
87
+
88
+
89
+ def _shown(name: Any) -> str:
90
+ return _js.utf16_slice(re.sub(r"[\r\n]+", " ", _js.js_string(name or "file")).replace(" — ", " - "), 0, SHOWN_MAX)
91
+
92
+
93
+ def attachment_note(entries: list[dict]) -> str:
94
+ lines = [NOTE_HEAD]
95
+ for e in entries:
96
+ lines.append(f"• {_shown(e.get('name'))} — {format_size(e.get('size'))} — {e['path']}")
97
+ if e.get("copy"):
98
+ lines.append(f" Its text — open THIS with the read tool, not the original: {e['copy']}")
99
+ elif e.get("inline"):
100
+ lines.append(" Shown to you with this message.")
101
+ elif e.get("noCopy"):
102
+ lines.append(f" No text copy ({e['noCopy']}) — open it with the read tool if you can; otherwise ask the user before converting it.")
103
+ return "\n".join(lines)
104
+
105
+
106
+ def parse_attachment_note(text: Any) -> list[dict] | None:
107
+ lines = _js.js_string(text or "").split("\n")
108
+ if lines[0] != NOTE_HEAD:
109
+ return None
110
+ out: list[dict] = []
111
+ for line in lines[1:]:
112
+ m = re.fullmatch(r"• (.+?) — (\d+(?:\.\d)? (?:B|KB|MB)) — (/.+)", line)
113
+ if m:
114
+ parts = m.group(3).split("/")
115
+ out.append({"name": m.group(1), "size": m.group(2), "session": parts[-2] if len(parts) > 1 else "", "file": parts[-1], "copy": "", "inline": False})
116
+ continue
117
+ if not out:
118
+ continue
119
+ c = re.fullmatch(r" Its text — .*: (/.+)", line)
120
+ if c:
121
+ out[-1]["copy"] = c.group(1).split("/")[-1]
122
+ elif line == " Shown to you with this message.":
123
+ out[-1]["inline"] = True
124
+ return out or None
125
+
126
+
127
+ def valid_attachment_ref(session: Any, file: Any) -> bool:
128
+ s, f = (_js.js_string(v) if isinstance(v, str) else "" for v in (session, file))
129
+ return bool(_SESSION_RE.fullmatch(s) and _FILE_RE.fullmatch(f) and ".." not in f)
130
+
131
+
132
+ def mime_for_file(file: Any) -> str:
133
+ return _MIME.get(_ext(file), "application/octet-stream")
134
+
135
+
136
+ def attachment_rule(root: Path, session_id: str) -> dict:
137
+ """Reads in THIS session's attachments folder are allowed, nothing else (measured: a same-prefix sibling, another
138
+ session's folder and the root still ask)."""
139
+ return {"permission": "external_directory", "pattern": f"{root}/{session_id}/*", "action": "allow"}
140
+
141
+
142
+ def _data_url(url: Any) -> tuple[str, bytes] | None:
143
+ m = re.fullmatch(r"data:([^;,]*)(?:;[^,]*?)?;base64,(.*)", url, re.S) if isinstance(url, str) else None
144
+ if not m:
145
+ return None
146
+ b64 = re.sub(r"[^A-Za-z0-9+/]", "", m.group(2).replace("-", "+").replace("_", "/")) # as lenient as Buffer.from(…, 'base64')
147
+ return (m.group(1) or "application/octet-stream"), base64.b64decode(b64 + "=" * (-len(b64) % 4))
148
+
149
+
150
+ def _folder_bytes(d: Path) -> int:
151
+ try:
152
+ return sum(p.lstat().st_size for p in d.iterdir())
153
+ except OSError:
154
+ return 0
155
+
156
+
157
+ def _root_bytes(root: Path) -> int:
158
+ try:
159
+ return sum(_folder_bytes(p) for p in Path(root).iterdir() if p.is_dir() and not p.is_symlink())
160
+ except OSError:
161
+ return 0
162
+
163
+
164
+ def _collision(path: Path, digest: str) -> dict:
165
+ return {"error": {"status": 409, "message": f"a different file saved as {path.name} is already in this session — rename it and attach it again"}}
166
+
167
+
168
+ def stage_message_body(body: Any, *, session_id: Any, root: Path, max_file_bytes: int = MAX_FILE_BYTES,
169
+ max_session_bytes: int = MAX_SESSION_BYTES, max_root_bytes: int = MAX_ROOT_BYTES) -> dict | None:
170
+ """Save the files in one turn's body → {"body", "entries"} with the note in their place; None when there is nothing to
171
+ save; {"error": {"status", "message"}} to refuse the turn. Never changes the body it was given."""
172
+ parts = body.get("parts") if isinstance(body, dict) and isinstance(body.get("parts"), list) else []
173
+ files = [(i, p, d) for i, p in enumerate(parts) if isinstance(p, dict) and p.get("type") == "file" for d in [_data_url(p.get("url"))] if d]
174
+ if not files:
175
+ return None
176
+ if not isinstance(session_id, str) or not _SESSION_RE.fullmatch(session_id):
177
+ return {"error": {"status": 400, "message": "not a session id"}}
178
+ d = Path(root) / session_id
179
+ if Path(root).is_symlink() or d.is_symlink():
180
+ return {"error": {"status": 400, "message": "the attachments folder is a link — refusing to write through it"}}
181
+ d.mkdir(parents=True, exist_ok=True, mode=0o700)
182
+ os.chmod(d, 0o700)
183
+ used = _folder_bytes(d)
184
+ total = _root_bytes(root)
185
+ entries: list[dict] = []
186
+ drop: set[int] = set()
187
+ for i, p, (url_mime, data) in files:
188
+ name = _js.js_string(p.get("filename") or "file")
189
+ if len(data) > max_file_bytes:
190
+ return {"error": {"status": 413, "message": f"{name} is over {round(max_file_bytes / 1048576)} MB — too large to attach"}}
191
+ digest = hashlib.sha256(data).hexdigest()
192
+ path = d / staged_file_name(digest, name)
193
+ if path.exists() or path.is_symlink():
194
+ # same name = same first 8 hex of the hash: this file again — unless 32 bits collided (or a link sits there)
195
+ if path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
196
+ return _collision(path, digest)
197
+ else:
198
+ if used + len(data) > max_session_bytes:
199
+ return {"error": {"status": 413, "message": f"this session's attachments are over {round(max_session_bytes / 1048576)} MB — start a new session to attach more"}}
200
+ if total + len(data) > max_root_bytes:
201
+ return {"error": {"status": 413, "message": f"saved attachments on this computer are over {round(max_root_bytes / 1073741824)} GB — delete old sessions to attach more"}}
202
+ try:
203
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
204
+ with os.fdopen(fd, "wb") as fh:
205
+ fh.write(data)
206
+ except FileExistsError:
207
+ if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
208
+ return _collision(path, digest)
209
+ used += len(data)
210
+ total += len(data)
211
+ mime = _js.js_string(p.get("mime") or url_mime).lower()
212
+ entry: dict = {"name": name, "size": len(data), "path": str(path)}
213
+ if mime.startswith("image/"):
214
+ entry["inline"] = True
215
+ else:
216
+ drop.add(i)
217
+ if needs_text_copy(mime, name):
218
+ copy = Path(f"{path}.md")
219
+ if copy.exists():
220
+ entry["copy"] = str(copy)
221
+ else:
222
+ entry["noCopy"] = NO_COPY
223
+ entries.append(entry)
224
+ kept = [p for i, p in enumerate(parts) if i not in drop]
225
+ return {"body": {**body, "parts": [*kept, {"type": "text", "text": attachment_note(entries), "synthetic": True}]}, "entries": entries}
226
+
227
+
228
+ def serve_attachment(*, root: Path, session: Any, file: Any, max_bytes: int = MAX_SERVE_BYTES) -> tuple[int, str]:
229
+ """One staged file (or its text copy) for the page: (status, JSON body) in the connector's reply shape."""
230
+ if not valid_attachment_ref(session, file):
231
+ return 400, _js.stringify({"error": "not an attachment"})
232
+ path = Path(root) / session / file
233
+ if not path.exists():
234
+ return 404, _js.stringify({"error": "that attachment is no longer on this computer"})
235
+ # By REAL path: a link anywhere under the folder (the session folder, or the file) must not lead the read elsewhere.
236
+ real_root = Path(root).resolve()
237
+ if path.resolve() != real_root / session / file:
238
+ return 400, _js.stringify({"error": "not an attachment"})
239
+ size = path.stat().st_size
240
+ if size > max_bytes:
241
+ return 413, _js.stringify({"error": f"the file is over {round(max_bytes / 1048576)} MB — too large to send to the phone"})
242
+ return 200, _js.stringify({"name": file[9:], "mime": mime_for_file(file), "size": size, "b64": base64.b64encode(path.read_bytes()).decode()})
243
+
244
+
245
+ def remove_session_attachments(root: Path, session_id: Any) -> None:
246
+ if isinstance(session_id, str) and _SESSION_RE.fullmatch(session_id):
247
+ shutil.rmtree(Path(root) / session_id, ignore_errors=True)
248
+
249
+
250
+ def prune_attachments(root: Path, *, days: float = 30, now: float | None = None) -> int:
251
+ now = time.time() if now is None else now
252
+ try:
253
+ names = os.listdir(root)
254
+ except OSError:
255
+ return 0
256
+ n = 0
257
+ for s in names:
258
+ if not _SESSION_RE.fullmatch(s):
259
+ continue
260
+ try:
261
+ if (Path(root) / s).stat().st_mtime < now - days * 86400:
262
+ shutil.rmtree(Path(root) / s, ignore_errors=True)
263
+ n += 1
264
+ except OSError:
265
+ pass
266
+ return n