mosscode 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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/scrollback.py ADDED
@@ -0,0 +1,106 @@
1
+ """Render settled transcript above the live inline UI into terminal scrollback."""
2
+
3
+ from io import StringIO
4
+
5
+ from rich.console import Console
6
+ from rich.control import Control
7
+ from rich.markdown import CodeBlock, Markdown
8
+ from rich.padding import Padding
9
+ from rich.text import Text
10
+ from rich.theme import Theme
11
+ from textual.geometry import Offset
12
+
13
+ from .appearance import PARAMETER_COLOR
14
+
15
+
16
+ class PlainCodeBlock(CodeBlock):
17
+ def __rich_console__(self, console, options):
18
+ yield Padding(Text(str(self.text).rstrip()), (0, 2))
19
+
20
+
21
+ class TranscriptMarkdown(Markdown):
22
+ elements = {**Markdown.elements, "fence": PlainCodeBlock, "code_block": PlainCodeBlock}
23
+
24
+
25
+ def message_row(marker, body):
26
+ from rich.table import Table
27
+
28
+ row = Table.grid(expand=True)
29
+ row.add_column(width=2)
30
+ # Rich breaks lines only at spaces; a table column would cut a long CJK run with "…".
31
+ row.add_column(ratio=1, overflow="fold")
32
+ row.add_row(Text(marker, style="bold"), body)
33
+ return row
34
+
35
+
36
+ def render_transcript(renderables, width: int) -> str:
37
+ # Rich defaults have backgrounds/dim styles; use the same foreground-only
38
+ # appearance as the live UI, including plain code fences and blue filenames.
39
+ styles = {
40
+ name: "none"
41
+ for name in (
42
+ "markdown.code_block",
43
+ "markdown.block_quote",
44
+ "markdown.list",
45
+ "markdown.item.number",
46
+ "markdown.hr",
47
+ "markdown.table.border",
48
+ "markdown.table.header",
49
+ "markdown.link",
50
+ "markdown.link_url",
51
+ "markdown.h1.border",
52
+ "markdown.h5",
53
+ "markdown.h6",
54
+ "markdown.h7",
55
+ )
56
+ }
57
+ styles.update({f"markdown.h{level}": "bold" for level in range(1, 5)})
58
+ styles["markdown.code"] = f"bold {PARAMETER_COLOR}"
59
+ stream = StringIO()
60
+ console = Console(
61
+ file=stream,
62
+ width=max(8, width),
63
+ force_terminal=True,
64
+ color_system="truecolor",
65
+ no_color=False,
66
+ legacy_windows=False,
67
+ highlight=False,
68
+ theme=Theme(styles),
69
+ )
70
+ for renderable in renderables:
71
+ console.print(Padding(renderable, (0, 2)))
72
+ console.print()
73
+ return stream.getvalue().replace("\n", "\r\n")
74
+
75
+
76
+ def commit_transcript(app, renderables) -> None:
77
+ """Move the inline origin forward; subsequent relative paints stay below it.
78
+
79
+ Textual 8 has no public output-above-inline API. This small adapter owns the
80
+ cursor bookkeeping already used by its inline compositor.
81
+ """
82
+ output = render_transcript(renderables, app.size.width)
83
+ cursor = app._previous_cursor_position
84
+ _write_above(app, Control.move(-cursor.x, -cursor.y).segment.text + "\r\x1b[J" + output)
85
+
86
+
87
+ def redraw_transcript(app, renderables) -> None:
88
+ """Repaint the whole session at the current width after the terminal changed width.
89
+
90
+ Terminals re-wrap existing rows on a width change, so the row count behind
91
+ the relative cursor moves no longer holds and old rows keep the old wrapping.
92
+ Clear screen and scrollback, as Pi does, then print the history again.
93
+ """
94
+ output = render_transcript(renderables, app.size.width) if renderables else ""
95
+ _write_above(app, "\x1b[H\x1b[2J\x1b[3J" + output)
96
+
97
+
98
+ def _write_above(app, sequence: str) -> None:
99
+ # Match Textual's bounded writes; Windows console writes can reject large
100
+ # strings. Chunking is transport-only, never truncates the transcript.
101
+ for start in range(0, len(sequence), 8192):
102
+ app._driver.write(sequence[start : start + 8192])
103
+ app._driver.flush()
104
+ app._previous_cursor_position = Offset(0, 0)
105
+ app._previous_inline_height = None
106
+ app.screen.refresh(layout=True)
mosscode/security.py ADDED
@@ -0,0 +1,282 @@
1
+ """Application-level hard guards, independent of configurable permissions."""
2
+
3
+ import re
4
+ import shlex
5
+ from pathlib import Path
6
+
7
+
8
+ class SecurityError(Exception):
9
+ def __init__(self, code: str, message: str):
10
+ super().__init__(message)
11
+ self.code = code
12
+
13
+
14
+ def resolve_path(root: Path, value: str | Path) -> Path:
15
+ """Resolve links and relative parts; no project boundary check."""
16
+ if not str(value) or "\0" in str(value):
17
+ raise SecurityError("invalid_path", "路径不能为空或包含空字符。")
18
+ try:
19
+ path = Path(value).expanduser()
20
+ return (path if path.is_absolute() else root / path).resolve()
21
+ except (OSError, RuntimeError):
22
+ raise SecurityError("invalid_path", "无法安全解析目标路径或目录链接。") from None
23
+
24
+
25
+ def inside(root: Path, path: Path) -> bool:
26
+ # root is the canonical anchor captured at session creation. Re-resolving
27
+ # it here would silently move the boundary if that directory became a link.
28
+ return path.is_relative_to(root)
29
+
30
+
31
+ def project_path(root: Path, value: str | Path) -> Path:
32
+ path = resolve_path(root, value)
33
+ if not inside(root, path):
34
+ raise SecurityError("path_blocked", "目标解析后位于项目目录之外,此操作不能授权。")
35
+ return path
36
+
37
+
38
+ # These are known-danger signatures, not a shell parser or an OS sandbox.
39
+ # Keep static expressions bounded; configuration cannot replace them.
40
+ def _compile(entries):
41
+ return tuple((re.compile(pattern, re.IGNORECASE), reason) for pattern, reason in entries)
42
+
43
+
44
+ # System-level damage outside any project: never authorizable.
45
+ CATASTROPHIC_COMMANDS = _compile(
46
+ (
47
+ (
48
+ r"\b(?:rm|remove-item|ri|rmdir|rd|del|erase)\b[^\r\n;|&]*\s['\"]?"
49
+ r"(?:/\*?|~[\\/]?\*?|\$home[\\/]?\*?|\$env:userprofile[\\/]?\*?|%userprofile%[\\/]?\*?|[a-z]:[\\/]?\*?)['\"]?(?:\s|$|[;|&])",
50
+ "删除文件系统根目录或用户主目录",
51
+ ),
52
+ (r"--no-preserve-root\b", "关闭根目录保护"),
53
+ (r"\b(?:format-volume|clear-disk|initialize-disk|diskpart|mkfs(?:\.[\w]+)?)\b", "磁盘格式化或分区操作"),
54
+ (r"\bformat(?:\.com)?\s+[a-z]:", "磁盘格式化"),
55
+ (r"\bdd\b[^\r\n;|&]*\bof=/dev/(?:sd|hd|nvme|vd|mmc|disk)", "覆盖块设备"),
56
+ (r">\s*/dev/(?:sd|hd|nvme|vd|mmc|disk)", "覆盖块设备"),
57
+ (r"\b(?:shutdown|reboot|halt|poweroff|restart-computer|stop-computer)\b", "关机或重启"),
58
+ (r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&", "进程炸弹"),
59
+ )
60
+ )
61
+
62
+ # Destructive but sometimes intended: always confirmed, never granted by rules or modes.
63
+ RISKY_COMMANDS = _compile(
64
+ (
65
+ (
66
+ r"\b(?:rm|remove-item|ri)\b[^\r\n;|&]*\s-(?:[a-z]*r[a-z]*f[a-z]*|[a-z]*f[a-z]*r[a-z]*|recurse)\b",
67
+ "递归强制删除",
68
+ ),
69
+ (r"\b(?:rm|remove-item|ri)\b[^\r\n;|&]*\s-(?:r|recursive)\b[^\r\n;|&]*\s-(?:f|force)\b", "递归强制删除"),
70
+ (r"\b(?:rm|remove-item|ri)\b[^\r\n;|&]*\s-(?:f|force)\b[^\r\n;|&]*\s-(?:r|recursive)\b", "递归强制删除"),
71
+ (r"\b(?:rmdir|rd|del|erase)\b[^\r\n;|&]*\s/s\b", "递归删除"),
72
+ (r"\bgit\b[^\r\n;|&]*\breset\s+[^\r\n;|&]*--hard\b", "丢弃 Git 改动"),
73
+ (r"\bgit\b[^\r\n;|&]*\bclean\s+[^\r\n;|&]*-(?:[a-z]*f[a-z]*|force)\b", "强制清理 Git 文件"),
74
+ (r"\bgit\b[^\r\n;|&]*\bpush\s+[^\r\n;|&]*(?:--force(?:-with-lease)?\b|-f\b)", "强制推送"),
75
+ (r"\b(?:invoke-expression|iex)\b", "动态表达式执行"),
76
+ (r"\b(?:powershell|pwsh)(?:\.exe)?\b[^\r\n;|&]*\s-(?:e|en|enc|encodedcommand)\b", "编码命令执行"),
77
+ )
78
+ )
79
+
80
+
81
+ def check_command(command: str) -> None:
82
+ if "\0" in command:
83
+ raise SecurityError("invalid_command", "命令不能包含空字符。")
84
+ for expression, reason in CATASTROPHIC_COMMANDS:
85
+ if expression.search(command):
86
+ raise SecurityError("dangerous_command", f"内置黑名单拒绝:{reason}。此操作不能授权。")
87
+
88
+
89
+ def command_risk(command: str) -> str | None:
90
+ """Reason a command needs confirmation every time, if any."""
91
+ return next((reason for expression, reason in RISKY_COMMANDS if expression.search(command)), None)
92
+
93
+
94
+ def command_words(command: str) -> list[str] | None:
95
+ try:
96
+ return shlex.split(command)
97
+ except ValueError:
98
+ return None
99
+
100
+
101
+ def command_segments(command: str) -> list[str]:
102
+ """Rough split of a compound command, for deny rules only (over-matching is safe there)."""
103
+ return [part.strip() for part in re.split(r"\$\(|[;&|\r\n`(){}]+", command) if part.strip()]
104
+
105
+
106
+ def starts_with(command: str, prefix: str) -> bool:
107
+ """Word-wise prefix: `pytest` matches `pytest -q` but not `pytestx`."""
108
+ words, head = command_words(command), command_words(prefix)
109
+ if words is None:
110
+ words = command.split()
111
+ return bool(head) and words[: len(head)] == head
112
+
113
+
114
+ # Inspection commands that run without approval. Conservative on purpose: every
115
+ # argument must stay inside the project and options that run programs are refused.
116
+ READ_ONLY_GIT = {"status", "diff", "log", "show", "blame", "rev-parse", "ls-files", "describe", "shortlog"}
117
+ GIT_LISTING = {"branch", "remote"}
118
+ LISTING_FLAGS = {"-a", "-r", "-v", "-vv", "--list", "--show-current", "--all", "--remotes", "--verbose"}
119
+ READ_ONLY_PROGRAMS = {
120
+ "ls",
121
+ "pwd",
122
+ "cat",
123
+ "head",
124
+ "tail",
125
+ "wc",
126
+ "stat",
127
+ "tree",
128
+ "which",
129
+ "grep",
130
+ "egrep",
131
+ "fgrep",
132
+ "rg",
133
+ "find",
134
+ "get-childitem",
135
+ "get-content",
136
+ "get-location",
137
+ "select-string",
138
+ "test-path",
139
+ "get-item",
140
+ "gci",
141
+ "gc",
142
+ "sls",
143
+ "dir",
144
+ }
145
+ FIND_ACTIONS = {"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"}
146
+
147
+
148
+ def _short_flag(word: str, letters: str) -> bool:
149
+ return word.startswith("-") and not word.startswith("--") and any(c in word[1:] for c in letters)
150
+
151
+
152
+ def _forbidden_option(program: str, word: str) -> bool:
153
+ if program == "git":
154
+ return word.startswith(("--output", "--ext-diff"))
155
+ if program == "tail":
156
+ return word.startswith("--follow") or _short_flag(word, "fF")
157
+ if program == "rg": # --pre and --search-zip run other programs.
158
+ return word == "--pre" or word.startswith(("--pre=", "--search-zip")) or _short_flag(word, "z")
159
+ if program == "find":
160
+ return word in FIND_ACTIONS
161
+ return False
162
+
163
+
164
+ def _inside_argument(word: str) -> bool:
165
+ value = word.split("=", 1)[1] if word.startswith("-") and "=" in word else word
166
+ if word.startswith("-") and value is word:
167
+ return True
168
+ if value.startswith(("/", "\\", "~")) or re.match(r"[A-Za-z]:", value):
169
+ return False
170
+ return ".." not in re.split(r"[\\/]", value)
171
+
172
+
173
+ def read_only_command(command: str) -> bool:
174
+ words = command_words(command) if simple_command(command) else None
175
+ if not words:
176
+ return False
177
+ program, arguments = words[0].lower(), words[1:]
178
+ if program == "git":
179
+ if not arguments:
180
+ return False
181
+ if arguments[0] in GIT_LISTING:
182
+ return all(word in LISTING_FLAGS for word in arguments[1:])
183
+ if arguments[0] not in READ_ONLY_GIT:
184
+ return False # Also refuses global options such as `git -c`.
185
+ elif program not in READ_ONLY_PROGRAMS:
186
+ return False
187
+ return all(not _forbidden_option(program, word) and _inside_argument(word) for word in arguments)
188
+
189
+
190
+ # Never offered as a prefix grant: they run whatever follows.
191
+ WRAPPERS = {
192
+ "sudo",
193
+ "doas",
194
+ "env",
195
+ "xargs",
196
+ "nohup",
197
+ "timeout",
198
+ "time",
199
+ "nice",
200
+ "exec",
201
+ "eval",
202
+ "source",
203
+ ".",
204
+ "bash",
205
+ "sh",
206
+ "zsh",
207
+ "fish",
208
+ "cmd",
209
+ "pwsh",
210
+ "powershell",
211
+ "start",
212
+ "call",
213
+ "invoke-expression",
214
+ "iex",
215
+ }
216
+ # Need a following word: `python -m pytest`, `deno test`, `npm run test`.
217
+ INTERPRETERS = {"python", "python3", "py", "node", "ruby", "perl", "php", "deno", "bun"}
218
+ MULTI_COMMANDS = {
219
+ "git",
220
+ "npm",
221
+ "pnpm",
222
+ "yarn",
223
+ "npx",
224
+ "uv",
225
+ "uvx",
226
+ "pipx",
227
+ "poetry",
228
+ "pip",
229
+ "cargo",
230
+ "go",
231
+ "dotnet",
232
+ "docker",
233
+ "kubectl",
234
+ "make",
235
+ "gradle",
236
+ "mvn",
237
+ "conda",
238
+ }
239
+
240
+
241
+ def command_prefix(command: str) -> str | None:
242
+ """The leading words worth remembering, e.g. `npm run test` or `python -m pytest`."""
243
+ words = command_words(command) if simple_command(command) else None
244
+ if not words or words[0].lower() in WRAPPERS:
245
+ return None
246
+ prefix = []
247
+ for word in words:
248
+ if len(prefix) == 3:
249
+ break
250
+ if word == "-m" and prefix and prefix[-1].lower() in {"python", "python3", "py"}:
251
+ prefix.append(word)
252
+ continue
253
+ if word.startswith("-") or re.search(r"[/\\.=:*?~$@%\"'\[\]]", word):
254
+ break
255
+ prefix.append(word)
256
+ if not prefix or (prefix[-1] == "-m"):
257
+ return None
258
+ first = prefix[0].lower()
259
+ if len(prefix) == 1 and (first in INTERPRETERS or first in MULTI_COMMANDS):
260
+ return None
261
+ if first in {"python", "python3", "py"} and prefix[1:2] != ["-m"]:
262
+ return None
263
+ return " ".join(prefix)
264
+
265
+
266
+ def simple_command(command: str) -> bool:
267
+ """Conservatively exclude compound/dynamic shell syntax from glob allows."""
268
+ quote = None
269
+ for char in command:
270
+ if char in "\r\n`$" or (ord(char) < 32 and char != "\t"):
271
+ return False
272
+ if quote:
273
+ if char == quote:
274
+ quote = None
275
+ # Backslash quoting differs across shells; prefer confirmation.
276
+ elif char == "\\" and quote == '"':
277
+ return False
278
+ elif char in "'\"":
279
+ quote = char
280
+ elif char in ";&|><(){}\\":
281
+ return False
282
+ return quote is None
@@ -0,0 +1,121 @@
1
+ """Messages to and from JSON for the session transcript; loading is strict so a bad line can be skipped."""
2
+
3
+ from pathlib import PurePath
4
+ from typing import Any
5
+
6
+ from .messages import ContentBlock, Message, ToolCall, ToolResult
7
+
8
+ ROLES = {"user", "assistant", "tool"}
9
+ KINDS = {"text", "thinking"}
10
+
11
+
12
+ def jsonable(value: Any) -> Any:
13
+ """Plain JSON data; paths and other objects become strings, unconvertible keys are dropped."""
14
+ if value is None or isinstance(value, (bool, int, float, str)):
15
+ return value
16
+ if isinstance(value, (list, tuple)):
17
+ return [jsonable(item) for item in value]
18
+ if isinstance(value, dict):
19
+ data = {}
20
+ for key, item in value.items():
21
+ try:
22
+ data[str(key)] = jsonable(item)
23
+ except Exception:
24
+ continue
25
+ return data
26
+ if isinstance(value, PurePath):
27
+ return str(value)
28
+ return str(value)
29
+
30
+
31
+ def message_to_json(message: Message) -> dict:
32
+ data: dict[str, Any] = {
33
+ "role": message.role,
34
+ "content": [{"kind": b.kind, "text": b.text} for b in message.content],
35
+ }
36
+ if message.protocol is not None:
37
+ data["protocol"] = message.protocol
38
+ if message.native:
39
+ data["native"] = [jsonable(item) for item in message.native]
40
+ if message.tool_calls:
41
+ data["tool_calls"] = [{"id": c.id, "name": c.name, "arguments": c.arguments} for c in message.tool_calls]
42
+ if message.tool_results:
43
+ data["tool_results"] = [
44
+ {
45
+ "call_id": r.call_id,
46
+ "name": r.name,
47
+ "output": r.output,
48
+ "is_error": r.is_error,
49
+ "code": r.code,
50
+ "details": jsonable(r.details),
51
+ }
52
+ for r in message.tool_results
53
+ ]
54
+ if message.synthetic is not None:
55
+ data["synthetic"] = message.synthetic
56
+ if message.command is not None:
57
+ data["command"] = message.command
58
+ return data
59
+
60
+
61
+ def _check(condition: bool, field: str) -> None:
62
+ if not condition:
63
+ raise ValueError(f"字段不合法:{field}")
64
+
65
+
66
+ def _string(value: Any, field: str, *, optional: bool = False) -> str | None:
67
+ _check(isinstance(value, str) or (optional and value is None), field)
68
+ return value
69
+
70
+
71
+ def _list(data: dict, key: str) -> list:
72
+ value = data.get(key, [])
73
+ _check(isinstance(value, list), key)
74
+ return value
75
+
76
+
77
+ def message_from_json(data: Any) -> Message:
78
+ _check(isinstance(data, dict), "message")
79
+ role = data.get("role")
80
+ _check(role in ROLES, "role")
81
+ content = []
82
+ for block in _list(data, "content"):
83
+ _check(isinstance(block, dict) and block.get("kind") in KINDS, "content.kind")
84
+ content.append(ContentBlock(block["kind"], _string(block.get("text"), "content.text")))
85
+ native = _list(data, "native")
86
+ _check(all(isinstance(item, dict) for item in native), "native")
87
+ calls = []
88
+ for item in _list(data, "tool_calls"):
89
+ _check(isinstance(item, dict), "tool_calls")
90
+ calls.append(
91
+ ToolCall(
92
+ _string(item.get("id"), "tool_calls.id"),
93
+ _string(item.get("name"), "tool_calls.name"),
94
+ _string(item.get("arguments"), "tool_calls.arguments"),
95
+ )
96
+ )
97
+ results = []
98
+ for item in _list(data, "tool_results"):
99
+ _check(isinstance(item, dict) and isinstance(item.get("is_error"), bool), "tool_results")
100
+ details = item.get("details", {})
101
+ _check(isinstance(details, dict), "tool_results.details")
102
+ results.append(
103
+ ToolResult(
104
+ _string(item.get("call_id"), "tool_results.call_id"),
105
+ _string(item.get("name"), "tool_results.name"),
106
+ _string(item.get("output"), "tool_results.output"),
107
+ item["is_error"],
108
+ _string(item.get("code"), "tool_results.code", optional=True),
109
+ details,
110
+ )
111
+ )
112
+ return Message(
113
+ role,
114
+ tuple(content),
115
+ _string(data.get("protocol"), "protocol", optional=True),
116
+ tuple(native),
117
+ tuple(calls),
118
+ tuple(results),
119
+ _string(data.get("synthetic"), "synthetic", optional=True),
120
+ _string(data.get("command"), "command", optional=True),
121
+ )