minimalcode 1.5.2__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 (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
minimalcode/session.py ADDED
@@ -0,0 +1,465 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import tempfile
8
+ import uuid
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import __version__
16
+ from .config import Settings
17
+
18
+ # A compact event carries the whole replacement history, so appending one to a
19
+ # file that already holds that history doubles the file. Past this size the
20
+ # rewrite pays for itself; below it the append-only file is left alone.
21
+ _PRUNE_MIN_BYTES = 64 * 1024
22
+ # Fields that identify the session itself. A later meta event may switch
23
+ # provider/model, but it must not rewrite the session's identity during a prune.
24
+ _HEADER_IDENTITY_KEYS = frozenset(
25
+ {"type", "session_id", "workspace", "created_at", "version"}
26
+ )
27
+
28
+
29
+ def _utc_now() -> str:
30
+ return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
31
+
32
+
33
+ def _workspace_key(cwd: Path) -> str:
34
+ name = (
35
+ re.sub(r"[^A-Za-z0-9._-]+", "-", cwd.name or "root").strip("-")[:32] or "root"
36
+ )
37
+ key = str(cwd)
38
+ if os.name == "nt":
39
+ key = key.casefold()
40
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:12]
41
+ return f"{name}-{digest}"
42
+
43
+
44
+ def _compact_json(value: Any) -> str:
45
+ text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
46
+ try:
47
+ text.encode("utf-8")
48
+ except UnicodeEncodeError:
49
+ # A lone surrogate (an unpaired ``\udXXX`` escape from the provider) has
50
+ # no UTF-8 encoding. Escape it instead of letting the append raise,
51
+ # which would drop the message that produced it.
52
+ text = text.encode("utf-8", "backslashreplace").decode("utf-8")
53
+ return text
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class Session:
58
+ session_id: str
59
+ path: Path | None
60
+ cwd: Path
61
+ messages: list[dict[str, Any]] = field(default_factory=list)
62
+ # Provider/model recorded in the session meta. Resuming must not silently
63
+ # send a history recorded for one wire protocol to another provider.
64
+ provider: str = ""
65
+ model: str = ""
66
+
67
+ def append_event(self, event: dict[str, Any]) -> None:
68
+ if self.path is None:
69
+ return
70
+ if self.path.suffix.lower() != ".jsonl":
71
+ # ``--session <path>`` once accepted any existing file, so a typo
72
+ # appended JSONL to README.md. A session is only ever a .jsonl file.
73
+ raise ValueError(f"session file must end in .jsonl: {self.path}")
74
+ parent_created = not self.path.parent.exists()
75
+ self.path.parent.mkdir(parents=True, exist_ok=True)
76
+ if parent_created:
77
+ # Tighten only a directory this call created: `--session <path>` may
78
+ # point at a user directory that has to keep its own permissions.
79
+ try:
80
+ self.path.parent.chmod(0o700)
81
+ except OSError:
82
+ pass
83
+ line = _compact_json(event) + "\n"
84
+ # Create session files with restrictive permissions from the first
85
+ # byte instead of tightening them after conversation content is
86
+ # already on disk.
87
+ fd = os.open(self.path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
88
+ needs_newline = False
89
+ if os.fstat(fd).st_size:
90
+ os.lseek(fd, -1, os.SEEK_END)
91
+ needs_newline = os.read(fd, 1) != b"\n"
92
+ with os.fdopen(fd, "a", encoding="utf-8", newline="\n") as handle:
93
+ try:
94
+ os.chmod(self.path, 0o600)
95
+ except OSError:
96
+ pass
97
+ if needs_newline:
98
+ # A file whose last line lost its terminator (a torn write, or a
99
+ # file handed to `--session`) would otherwise fuse with this
100
+ # event into one unparseable line and lose both.
101
+ handle.write("\n")
102
+ handle.write(line)
103
+ handle.flush()
104
+ os.fsync(handle.fileno())
105
+
106
+ def append_message(self, message: dict[str, Any]) -> None:
107
+ self.messages.append(message)
108
+ try:
109
+ self.append_event({"type": "message", "message": message})
110
+ except (OSError, TypeError, ValueError):
111
+ self.messages.pop()
112
+ raise
113
+
114
+ def replace_messages(
115
+ self,
116
+ messages: Iterable[dict[str, Any]],
117
+ *,
118
+ summary: str,
119
+ dropped_messages: int,
120
+ ) -> None:
121
+ previous = self.messages
122
+ self.messages = list(messages)
123
+ state = {
124
+ "type": "compact",
125
+ "summary": summary,
126
+ "dropped_messages": dropped_messages,
127
+ "messages": self.messages,
128
+ "at": _utc_now(),
129
+ }
130
+ size_before = self._size()
131
+ try:
132
+ # The append is what makes the new state durable; the rewrite below
133
+ # only reclaims the history this event just superseded.
134
+ self.append_event(state)
135
+ except (OSError, TypeError, ValueError):
136
+ self.messages = previous
137
+ raise
138
+ self._prune_bloat(state, size_before=size_before)
139
+
140
+ def _size(self) -> int | None:
141
+ if self.path is None:
142
+ return None
143
+ try:
144
+ return self.path.stat().st_size
145
+ except OSError:
146
+ return None
147
+
148
+ def _prune_bloat(self, state: dict[str, Any], *, size_before: int | None) -> bool:
149
+ """Rewrite the file to ``meta + state`` when appending duplicated it.
150
+
151
+ A compact event carries the whole replacement history, so a session that
152
+ compacts twice stores the same messages twice and `--continue` replays
153
+ all of it. The rewrite is atomic (temp file, fsync, ``os.replace``), so
154
+ a crash leaves the append-only file untouched, and it is skipped unless
155
+ the file is clearly larger than the state it would keep. The size delta
156
+ also acts as a weak concurrency check: if another process appended
157
+ between the write and the stat, the file is left alone.
158
+ """
159
+ if self.path is None or size_before is None:
160
+ return False
161
+ state_bytes = len((_compact_json(state) + "\n").encode("utf-8"))
162
+ size_after = self._size()
163
+ if size_after is None:
164
+ return False
165
+ if size_after - size_before != state_bytes:
166
+ return False
167
+ if size_after < _PRUNE_MIN_BYTES or size_after < 2 * state_bytes:
168
+ return False
169
+ header = self._header_line()
170
+ if header is None:
171
+ # `SessionStore.latest` identifies a session by its first line, so a
172
+ # file without a meta header keeps its original shape.
173
+ return False
174
+ return self._rewrite(header + _compact_json(state) + "\n")
175
+
176
+ def _header_line(self) -> str | None:
177
+ """Return the session header, with later provider switches applied.
178
+
179
+ ``/model`` appends a second meta event. ``SessionStore.load`` lets the
180
+ last one win, so pruning must merge those fields instead of keeping
181
+ only line one, or ``--continue`` resumes on the wrong provider.
182
+ """
183
+ if self.path is None:
184
+ return None
185
+ header: dict[str, Any] | None = None
186
+ try:
187
+ with self.path.open("r", encoding="utf-8-sig", errors="replace") as handle:
188
+ for line in handle:
189
+ try:
190
+ event = json.loads(line)
191
+ except json.JSONDecodeError:
192
+ continue
193
+ if not isinstance(event, dict) or event.get("type") != "meta":
194
+ continue
195
+ if header is None:
196
+ header = dict(event)
197
+ continue
198
+ for key, value in event.items():
199
+ if key not in _HEADER_IDENTITY_KEYS:
200
+ header[key] = value
201
+ except OSError:
202
+ return None
203
+ if header is None:
204
+ return None
205
+ return _compact_json(header) + "\n"
206
+
207
+ def _rewrite(self, text: str) -> bool:
208
+ """Atomically replace the session file with ``text``."""
209
+ if self.path is None:
210
+ return False
211
+ try:
212
+ fd, temp_name = tempfile.mkstemp(
213
+ prefix=f".{self.path.name}.", dir=self.path.parent
214
+ )
215
+ except OSError:
216
+ return False
217
+ temp_path = Path(temp_name)
218
+ try:
219
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
220
+ handle.write(text)
221
+ handle.flush()
222
+ os.fsync(handle.fileno())
223
+ try:
224
+ os.chmod(temp_path, 0o600)
225
+ except OSError:
226
+ pass
227
+ os.replace(temp_path, self.path)
228
+ except OSError:
229
+ return False
230
+ finally:
231
+ temp_path.unlink(missing_ok=True)
232
+ return True
233
+
234
+ def append_usage(self, usage: dict[str, int]) -> None:
235
+ """Record token usage without aborting the turn when the write fails.
236
+
237
+ Usage is telemetry: losing one record only affects statistics, while
238
+ raising here would discard a reply the model already produced.
239
+ """
240
+ try:
241
+ self.append_event({"type": "usage", "usage": usage, "at": _utc_now()})
242
+ except (OSError, TypeError, ValueError):
243
+ return
244
+
245
+
246
+ class SessionStore:
247
+ def __init__(self, settings: Settings):
248
+ self.settings = settings
249
+ self.root = settings.home / "sessions"
250
+
251
+ def workspace_dir(self, cwd: Path) -> Path:
252
+ return self.root / _workspace_key(cwd.resolve())
253
+
254
+ def create(
255
+ self,
256
+ cwd: Path,
257
+ *,
258
+ provider: str,
259
+ model: str,
260
+ save: bool = True,
261
+ ) -> Session:
262
+ session_id = uuid.uuid4().hex
263
+ if not save:
264
+ return Session(
265
+ session_id=session_id,
266
+ path=None,
267
+ cwd=cwd.resolve(),
268
+ provider=provider,
269
+ model=model,
270
+ )
271
+
272
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
273
+ path = self.workspace_dir(cwd) / f"{stamp}-{session_id[:10]}.jsonl"
274
+ session = Session(
275
+ session_id=session_id,
276
+ path=path,
277
+ cwd=cwd.resolve(),
278
+ provider=provider,
279
+ model=model,
280
+ )
281
+ session.append_event(
282
+ {
283
+ "type": "meta",
284
+ "version": __version__,
285
+ "session_id": session_id,
286
+ "workspace": str(cwd.resolve()),
287
+ "provider": provider,
288
+ "model": model,
289
+ "created_at": _utc_now(),
290
+ }
291
+ )
292
+ return session
293
+
294
+ @staticmethod
295
+ def _recency_key(path: Path) -> tuple[int, str]:
296
+ try:
297
+ modified = path.stat().st_mtime_ns
298
+ except OSError:
299
+ modified = 0
300
+ return modified, path.name
301
+
302
+ def _candidate_files(self, cwd: Path | None = None) -> list[Path]:
303
+ root = self.workspace_dir(cwd) if cwd is not None else self.root
304
+ if not root.is_dir():
305
+ return []
306
+ return sorted(root.rglob("*.jsonl"), key=self._recency_key, reverse=True)
307
+
308
+ def latest(self, cwd: Path) -> Session | None:
309
+ for path in self._candidate_files(cwd):
310
+ if self._first_event(path) is not None:
311
+ return self.load(path, default_workspace=cwd)
312
+ return None
313
+
314
+ def resolve(self, identifier: str, cwd: Path) -> Session:
315
+ try:
316
+ candidate = Path(identifier).expanduser()
317
+ is_file = candidate.is_file()
318
+ except (OSError, RuntimeError, ValueError):
319
+ is_file = False
320
+ candidate = None
321
+ if is_file and candidate is not None:
322
+ return self.load(candidate, default_workspace=cwd)
323
+
324
+ files = self._candidate_files(cwd)
325
+ # A session from another workspace is still addressable by its ID; the
326
+ # sessions listing shows those IDs, so only searching the current
327
+ # workspace made listed sessions fail to resolve here.
328
+ global_files = self._candidate_files()
329
+ if global_files:
330
+ seen_files = set(files)
331
+ files.extend(path for path in global_files if path not in seen_files)
332
+ identities = [(path, self._recorded_session_id(path)) for path in files]
333
+ matches = [
334
+ path
335
+ for path, session_id in identities
336
+ if identifier in session_id
337
+ or identifier in path.stem
338
+ or identifier in path.name
339
+ ]
340
+ if not matches:
341
+ raise FileNotFoundError(f"session not found: {identifier}")
342
+ if len(matches) > 1:
343
+ exact = [
344
+ path
345
+ for path, session_id in identities
346
+ if identifier == session_id or identifier == path.stem
347
+ ]
348
+ if len(exact) != 1:
349
+ raise ValueError(f"session identifier is ambiguous: {identifier}")
350
+ matches = exact
351
+ return self.load(matches[0], default_workspace=cwd)
352
+
353
+ @staticmethod
354
+ def _first_event(path: Path) -> dict[str, Any] | None:
355
+ """Return the session's meta event, or None when it is unreadable."""
356
+ try:
357
+ with path.open("r", encoding="utf-8-sig", errors="replace") as handle:
358
+ event = json.loads(handle.readline())
359
+ except (OSError, json.JSONDecodeError):
360
+ return None
361
+ if isinstance(event, dict) and event.get("type") == "meta":
362
+ return event
363
+ return None
364
+
365
+ @classmethod
366
+ def _recorded_session_id(cls, path: Path) -> str:
367
+ event = cls._first_event(path)
368
+ if event is not None and event.get("type") == "meta":
369
+ session_id = event.get("session_id")
370
+ if isinstance(session_id, str):
371
+ return session_id
372
+ return path.stem
373
+
374
+ def _within_root(self, path: Path) -> bool:
375
+ """Return True when ``path`` is inside the store's sessions directory."""
376
+ try:
377
+ Path(path).resolve(strict=False).relative_to(
378
+ self.root.resolve(strict=False)
379
+ )
380
+ except (OSError, RuntimeError, ValueError):
381
+ return False
382
+ return True
383
+
384
+ def load(self, path: Path, *, default_workspace: Path | None = None) -> Session:
385
+ messages: list[dict[str, Any]] = []
386
+ session_id = path.stem
387
+ recorded_provider = ""
388
+ recorded_model = ""
389
+ if path.suffix.lower() != ".jsonl":
390
+ raise ValueError(f"not a session file (expected .jsonl): {path}")
391
+ # Never default to the sessions directory: a metadata-less file loaded
392
+ # through --session would otherwise run every tool there.
393
+ workspace = default_workspace or Path.cwd()
394
+ # The recorded workspace is tool-restriction input, so it is trusted
395
+ # only for files the store itself wrote. An arbitrary JSONL handed to
396
+ # --session must not be able to point every tool at C:\ or /.
397
+ trust_recorded_workspace = self._within_root(path)
398
+ try:
399
+ with path.open("r", encoding="utf-8-sig", errors="replace") as handle:
400
+ for line in handle:
401
+ try:
402
+ event = json.loads(line)
403
+ except json.JSONDecodeError:
404
+ continue
405
+ if not isinstance(event, dict):
406
+ continue
407
+ if event.get("type") == "meta":
408
+ recorded_id = event.get("session_id")
409
+ if isinstance(recorded_id, str) and recorded_id:
410
+ session_id = recorded_id
411
+ provider = event.get("provider")
412
+ model = event.get("model")
413
+ if isinstance(provider, str):
414
+ recorded_provider = provider
415
+ if isinstance(model, str):
416
+ recorded_model = model
417
+ recorded_workspace = event.get("workspace")
418
+ if (
419
+ trust_recorded_workspace
420
+ and isinstance(recorded_workspace, str)
421
+ and recorded_workspace
422
+ ):
423
+ workspace = Path(recorded_workspace).expanduser()
424
+ elif event.get("type") == "message":
425
+ message = event.get("message")
426
+ if isinstance(message, dict):
427
+ messages.append(message)
428
+ elif event.get("type") == "compact":
429
+ compacted = event.get("messages")
430
+ if isinstance(compacted, list):
431
+ messages = [
432
+ item for item in compacted if isinstance(item, dict)
433
+ ]
434
+ except OSError as exc:
435
+ raise FileNotFoundError(f"cannot read session {path}: {exc}") from exc
436
+ try:
437
+ workspace = workspace.resolve()
438
+ except (OSError, RuntimeError):
439
+ pass
440
+ return Session(
441
+ session_id=session_id,
442
+ path=path,
443
+ cwd=workspace,
444
+ messages=messages,
445
+ provider=recorded_provider,
446
+ model=recorded_model,
447
+ )
448
+
449
+ def list(self) -> list[dict[str, Any]]:
450
+ rows: list[dict[str, Any]] = []
451
+ for path in self._candidate_files():
452
+ first = self._first_event(path)
453
+ if first is None:
454
+ continue
455
+ rows.append(
456
+ {
457
+ "session_id": str(first.get("session_id") or path.stem),
458
+ "workspace": str(first.get("workspace") or ""),
459
+ "provider": str(first.get("provider") or ""),
460
+ "model": str(first.get("model") or ""),
461
+ "created_at": str(first.get("created_at") or ""),
462
+ "path": str(path),
463
+ }
464
+ )
465
+ return rows