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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|