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.
- witbitz_code/__init__.py +7 -0
- witbitz_code/__main__.py +3 -0
- witbitz_code/_js.py +312 -0
- witbitz_code/account.py +127 -0
- witbitz_code/asks.py +119 -0
- witbitz_code/attachments.py +266 -0
- witbitz_code/auto.py +561 -0
- witbitz_code/auto_runner.py +481 -0
- witbitz_code/cli.py +223 -0
- witbitz_code/codetools.py +128 -0
- witbitz_code/compress.py +25 -0
- witbitz_code/computers.py +189 -0
- witbitz_code/connector.py +818 -0
- witbitz_code/devicelink.py +159 -0
- witbitz_code/folders.py +250 -0
- witbitz_code/link.py +205 -0
- witbitz_code/net.py +67 -0
- witbitz_code/opencode_plugins/notes-init.md +8 -0
- witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
- witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
- witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
- witbitz_code/outputs.py +115 -0
- witbitz_code/pair.py +222 -0
- witbitz_code/pairings.py +214 -0
- witbitz_code/plugins.py +140 -0
- witbitz_code/policy.py +48 -0
- witbitz_code/qr.py +59 -0
- witbitz_code/relay.py +783 -0
- witbitz_code/seen.py +49 -0
- witbitz_code/tools_probe.py +67 -0
- witbitz_code-1.2.0.dist-info/METADATA +145 -0
- witbitz_code-1.2.0.dist-info/RECORD +36 -0
- witbitz_code-1.2.0.dist-info/WHEEL +5 -0
- witbitz_code-1.2.0.dist-info/entry_points.txt +2 -0
- witbitz_code-1.2.0.dist-info/licenses/LICENSE +21 -0
- witbitz_code-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|