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/commands.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Slash commands: one registry for parsing, dispatch, completion and /help."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Protocol
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .config import Settings
|
|
13
|
+
from .conversation import Conversation
|
|
14
|
+
from .skills import SkillCatalog
|
|
15
|
+
|
|
16
|
+
NAME = re.compile(r"[a-z][a-z0-9_-]*")
|
|
17
|
+
WORD = re.compile(r"/[A-Za-z][A-Za-z0-9_-]*")
|
|
18
|
+
_SPLIT = re.compile(r"(\S+)(?:\s+(.*))?", re.DOTALL)
|
|
19
|
+
SUGGEST_CUTOFF = 0.6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandConflict(ValueError):
|
|
23
|
+
"""A built-in command is malformed or reuses a name; raised at import, so startup fails."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Hint(NamedTuple):
|
|
27
|
+
value: str # What Tab writes into the input.
|
|
28
|
+
description: str
|
|
29
|
+
label: str = "" # Shown in the menu instead of value, e.g. with the argument hint.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CommandUI(Protocol):
|
|
33
|
+
"""What a command may do to the interface. State lives on Conversation, not here."""
|
|
34
|
+
|
|
35
|
+
provider_name: str
|
|
36
|
+
model: str
|
|
37
|
+
|
|
38
|
+
async def note(self, text: str, style: str | None = None) -> None: ...
|
|
39
|
+
|
|
40
|
+
async def submit(self, prompt: str, *, command: str | None = None) -> None: ...
|
|
41
|
+
|
|
42
|
+
def refresh_status(self) -> None: ...
|
|
43
|
+
|
|
44
|
+
async def clear_screen(self) -> None: ...
|
|
45
|
+
|
|
46
|
+
def choose(
|
|
47
|
+
self,
|
|
48
|
+
title: str,
|
|
49
|
+
options: Sequence[Any],
|
|
50
|
+
current: int,
|
|
51
|
+
on_pick: Callable[[int], Awaitable[None]],
|
|
52
|
+
on_cancel: Callable[[], Awaitable[None]] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Open a picker and return at once; on_pick runs with the chosen index, Esc runs on_cancel (if any)."""
|
|
55
|
+
|
|
56
|
+
async def compact(self, instructions: str) -> None: ...
|
|
57
|
+
|
|
58
|
+
def resume(self, session_id: str) -> None: ...
|
|
59
|
+
|
|
60
|
+
async def switch_model(self, name: str) -> None: ...
|
|
61
|
+
|
|
62
|
+
def exit(self) -> None: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class CommandContext:
|
|
67
|
+
conversation: Conversation
|
|
68
|
+
ui: CommandUI
|
|
69
|
+
registry: CommandRegistry
|
|
70
|
+
settings: Settings | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Handler = Callable[[CommandContext, str], Awaitable[None]]
|
|
74
|
+
Completer = Callable[[CommandContext], Sequence[tuple[str, str]]]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Command:
|
|
79
|
+
name: str # Lower case, without the slash.
|
|
80
|
+
description: str # One line, for the menu and /help.
|
|
81
|
+
usage: str # "/compact [说明]"
|
|
82
|
+
kind: Literal["local", "prompt", "skill"]
|
|
83
|
+
aliases: tuple[str, ...] = ()
|
|
84
|
+
argument: Literal["none", "optional", "required"] = "none"
|
|
85
|
+
argument_hint: str = "" # Shown after the name in the menu, e.g. "[说明]".
|
|
86
|
+
hidden: bool = False
|
|
87
|
+
handler: Handler | None = None # local
|
|
88
|
+
template: str = "" # prompt
|
|
89
|
+
complete: Completer | None = None # Argument candidates as (value, description).
|
|
90
|
+
skill: str = "" # skill: the SkillDef it runs.
|
|
91
|
+
# Safe during a run (reads state or stops a task); others wait. Or a test of the argument.
|
|
92
|
+
while_running: bool | Callable[[str], bool] = False
|
|
93
|
+
details: str = "" # More lines for /help <command>, e.g. its subcommands.
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def names(self) -> tuple[str, ...]:
|
|
97
|
+
return (self.name, *self.aliases)
|
|
98
|
+
|
|
99
|
+
def runs_while_running(self, argument: str) -> bool:
|
|
100
|
+
return self.while_running(argument) if callable(self.while_running) else self.while_running
|
|
101
|
+
|
|
102
|
+
def expand(self, argument: str) -> str:
|
|
103
|
+
"""The template, with the argument appended as an extra requirement."""
|
|
104
|
+
return self.template + (f"\n\n补充要求:{argument}" if argument else "")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class Parsed:
|
|
109
|
+
kind: Literal["empty", "message", "command", "unknown"]
|
|
110
|
+
text: str = "" # message: what goes to the model
|
|
111
|
+
command: Command | None = None
|
|
112
|
+
argument: str = ""
|
|
113
|
+
word: str = "" # The command word as typed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CommandRegistry:
|
|
117
|
+
def __init__(self, commands: Sequence[Command] = ()):
|
|
118
|
+
self._commands: list[Command] = []
|
|
119
|
+
self._index: dict[str, Command] = {}
|
|
120
|
+
for command in commands:
|
|
121
|
+
self.register(command)
|
|
122
|
+
|
|
123
|
+
def register(self, command: Command) -> None:
|
|
124
|
+
for name in command.names:
|
|
125
|
+
if not NAME.fullmatch(name):
|
|
126
|
+
raise CommandConflict(
|
|
127
|
+
f"命令名 /{name} 不合法:须以小写字母开头,只含小写字母、数字、- 和 _(/{command.name})。"
|
|
128
|
+
)
|
|
129
|
+
folded = [name.lower() for name in command.names]
|
|
130
|
+
if len(set(folded)) != len(folded):
|
|
131
|
+
raise CommandConflict(f"命令 /{command.name} 的名称和别名重复。")
|
|
132
|
+
for name in folded:
|
|
133
|
+
if name in self._index:
|
|
134
|
+
raise CommandConflict(f"/{name} 冲突:命令 /{self._index[name].name} 和 /{command.name} 都使用了它。")
|
|
135
|
+
if command.kind == "local" and command.handler is None:
|
|
136
|
+
raise CommandConflict(f"本地命令 /{command.name} 缺少处理函数。")
|
|
137
|
+
if command.kind == "prompt" and not command.template.strip():
|
|
138
|
+
raise CommandConflict(f"提示词命令 /{command.name} 缺少模板。")
|
|
139
|
+
if command.kind == "skill" and not command.skill:
|
|
140
|
+
raise CommandConflict(f"Skill 命令 /{command.name} 缺少 Skill 名称。")
|
|
141
|
+
self._commands.append(command)
|
|
142
|
+
self._index.update((name, command) for name in folded)
|
|
143
|
+
|
|
144
|
+
def names(self) -> tuple[str, ...]:
|
|
145
|
+
"""Every name and alias, for completion."""
|
|
146
|
+
return tuple(self._index)
|
|
147
|
+
|
|
148
|
+
def with_skills(self, catalog: SkillCatalog) -> tuple[CommandRegistry, tuple[str, ...]]:
|
|
149
|
+
"""A copy for one run with a command per skill; the built-in table is never changed."""
|
|
150
|
+
registry = CommandRegistry(command for command in self._commands if command.kind != "skill")
|
|
151
|
+
return registry, registry.set_skills(catalog)
|
|
152
|
+
|
|
153
|
+
def set_skills(self, catalog: SkillCatalog) -> tuple[str, ...]:
|
|
154
|
+
"""Replace the skill commands; a skill whose name is taken is skipped with a problem."""
|
|
155
|
+
self._commands = [command for command in self._commands if command.kind != "skill"]
|
|
156
|
+
self._index = {name: command for name, command in self._index.items() if command.kind != "skill"}
|
|
157
|
+
problems = []
|
|
158
|
+
for skill in catalog.skills.values():
|
|
159
|
+
hint = skill.argument_hint or "[参数]"
|
|
160
|
+
command = Command(
|
|
161
|
+
skill.name,
|
|
162
|
+
f"{skill.description} [skill]",
|
|
163
|
+
f"/{skill.name} {hint}",
|
|
164
|
+
"skill",
|
|
165
|
+
argument="optional",
|
|
166
|
+
argument_hint=hint,
|
|
167
|
+
hidden=not skill.user_invocable,
|
|
168
|
+
skill=skill.name,
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
self.register(command)
|
|
172
|
+
except CommandConflict as exc:
|
|
173
|
+
problems.append(f"Skill {skill.name} 未登记为命令:{exc}")
|
|
174
|
+
return tuple(problems)
|
|
175
|
+
|
|
176
|
+
def get(self, word: str) -> Command | None:
|
|
177
|
+
return self._index.get(word.removeprefix("/").lower())
|
|
178
|
+
|
|
179
|
+
def visible(self) -> list[Command]:
|
|
180
|
+
return [command for command in self._commands if not command.hidden]
|
|
181
|
+
|
|
182
|
+
def suggest(self, word: str) -> str | None:
|
|
183
|
+
names = [name for command in self.visible() for name in command.names]
|
|
184
|
+
close = difflib.get_close_matches(word.removeprefix("/").lower(), names, n=1, cutoff=SUGGEST_CUTOFF)
|
|
185
|
+
return f"/{self._index[close[0]].name}" if close else None
|
|
186
|
+
|
|
187
|
+
def matches(self, ctx: CommandContext, value: str) -> list[Hint]:
|
|
188
|
+
"""Commands by prefix (aliases point at their command), then argument candidates."""
|
|
189
|
+
if "\n" in value:
|
|
190
|
+
return []
|
|
191
|
+
value = value.lstrip()
|
|
192
|
+
word, separator, argument = value.partition(" ")
|
|
193
|
+
if separator:
|
|
194
|
+
command = self.get(word) if WORD.fullmatch(word) else None
|
|
195
|
+
if command is None or command.complete is None:
|
|
196
|
+
return []
|
|
197
|
+
prefix = argument.lstrip()
|
|
198
|
+
return [
|
|
199
|
+
Hint(f"/{command.name} {item}", description)
|
|
200
|
+
for item, description in command.complete(ctx)
|
|
201
|
+
if item.startswith(prefix)
|
|
202
|
+
]
|
|
203
|
+
if not value.startswith("/"):
|
|
204
|
+
return []
|
|
205
|
+
prefix = value[1:].lower()
|
|
206
|
+
hints = []
|
|
207
|
+
for command in self.visible():
|
|
208
|
+
label = f"/{command.name}" + (f" {command.argument_hint}" if command.argument_hint else "")
|
|
209
|
+
if command.name.startswith(prefix):
|
|
210
|
+
hints.append(Hint(f"/{command.name}", command.description, label))
|
|
211
|
+
elif alias := next((a for a in command.aliases if a.startswith(prefix)), None):
|
|
212
|
+
hints.append(Hint(f"/{command.name}", f"{command.description}(别名 /{alias})", label))
|
|
213
|
+
return hints
|
|
214
|
+
|
|
215
|
+
def takes_completion(self, ctx: CommandContext, value: str) -> bool:
|
|
216
|
+
"""Tab after a full command name moves on to its arguments when there are candidates."""
|
|
217
|
+
command = self.get(value.strip()) if WORD.fullmatch(value.strip()) else None
|
|
218
|
+
return bool(command and command.complete and command.complete(ctx))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_input(text: str, registry: CommandRegistry) -> Parsed:
|
|
222
|
+
"""Commands are one slash word; anything else (paths, "//" escapes) is a message."""
|
|
223
|
+
if not text.strip():
|
|
224
|
+
return Parsed("empty")
|
|
225
|
+
stripped = text.lstrip()
|
|
226
|
+
if stripped.startswith("//"):
|
|
227
|
+
return Parsed("message", stripped[1:])
|
|
228
|
+
if stripped.rstrip() == "/":
|
|
229
|
+
return Parsed("unknown", word="/")
|
|
230
|
+
match = _SPLIT.fullmatch(stripped.rstrip())
|
|
231
|
+
word, rest = match.group(1), match.group(2) or ""
|
|
232
|
+
if not WORD.fullmatch(word):
|
|
233
|
+
return Parsed("message", text)
|
|
234
|
+
command = registry.get(word)
|
|
235
|
+
if command is None:
|
|
236
|
+
return Parsed("unknown", word=word)
|
|
237
|
+
return Parsed("command", command=command, argument=rest.strip(), word=word)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def unknown_note(parsed: Parsed, registry: CommandRegistry) -> str:
|
|
241
|
+
if parsed.word == "/":
|
|
242
|
+
return "输入 /help 查看命令。"
|
|
243
|
+
suggestion = registry.suggest(parsed.word)
|
|
244
|
+
lead = f"是不是想输入 {suggestion}?" if suggestion else ""
|
|
245
|
+
return f"{lead}未知命令 {parsed.word}。输入 /help 查看命令;要把以 / 开头的文字发给模型,在开头再加一个 /。"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def argument_problem(command: Command, argument: str) -> str | None:
|
|
249
|
+
"""None when the argument fits the command."""
|
|
250
|
+
if command.argument == "none" and argument:
|
|
251
|
+
return f"/{command.name} 不接受参数。用法:{command.usage}"
|
|
252
|
+
if command.argument == "required" and not argument:
|
|
253
|
+
return f"/{command.name} 需要参数。用法:{command.usage}"
|
|
254
|
+
return None
|
mosscode/compaction.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Layer 2: summarize older messages in one tool-free request; keep recent ones verbatim.
|
|
2
|
+
|
|
3
|
+
Pure with respect to the agent: returns a new history or raises CompactionError, never mutates the input.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from contextlib import aclosing
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .context import ContextBudget, estimate_messages, estimate_text
|
|
15
|
+
from .messages import Completed, ContentBlock, Message, ProviderError
|
|
16
|
+
from .session import Session
|
|
17
|
+
|
|
18
|
+
KEEP_TOKENS = 10_000
|
|
19
|
+
KEEP_MESSAGES = 5
|
|
20
|
+
KEEP_SHARE = 0.4 # Of the auto threshold: the kept tail never crowds out the summary's benefit.
|
|
21
|
+
QUOTE_TOKENS = 8_000
|
|
22
|
+
QUOTE_CLIP = 200
|
|
23
|
+
RESULT_CLIP = 2_000
|
|
24
|
+
SHRINK_STEPS = 3
|
|
25
|
+
DROP_SHARE = 0.2
|
|
26
|
+
BOUNDARY = "compact"
|
|
27
|
+
QUOTE = "\n\n▸ " # Prefix of each verbatim user quote block in a boundary message.
|
|
28
|
+
CLIPPED = " …(较长,原文见记录文件)"
|
|
29
|
+
NOTHING = "没有可压缩的内容:对话中只有最近一轮。"
|
|
30
|
+
|
|
31
|
+
SYSTEM = "你是对话摘要器。禁止调用任何工具,只输出纯文本;任何工具调用都会被拒绝,本次任务会失败。"
|
|
32
|
+
|
|
33
|
+
SECTIONS = """1. 主要请求和意图:用户要完成什么,包括后来补充或修改的要求
|
|
34
|
+
2. 关键技术概念:涉及的技术、框架、约定
|
|
35
|
+
3. 文件和代码段:查看或修改过的文件,各自的原因和改动;关键代码可以简短引用
|
|
36
|
+
4. 错误和修复:遇到的错误、原因和修复方式,以及用户对修复的反馈
|
|
37
|
+
5. 问题解决过程:已解决的问题和仍在排查的问题
|
|
38
|
+
6. 用户的偏好与约束:用户明确提出的做法、禁止事项和风格要求
|
|
39
|
+
7. 待办任务:用户要求但尚未完成的事项
|
|
40
|
+
8. 当前工作:压缩前正在做什么、做到哪一步、涉及的文件和代码(最详细的一部分)
|
|
41
|
+
9. 下一步:与当前工作直接相关的下一步;没有明确的下一步就写“无”"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CompactionError(Exception):
|
|
45
|
+
"""A safe, user-facing reason. code "nothing" means there was nothing to compact (not a failure)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, reason: str, code: str = "failed"):
|
|
48
|
+
super().__init__(reason)
|
|
49
|
+
self.code = code
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Compacted:
|
|
54
|
+
history: list[Message]
|
|
55
|
+
removed: int
|
|
56
|
+
summary_tokens: int
|
|
57
|
+
record: Path | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _group_start(history: Sequence[Message], index: int) -> int:
|
|
61
|
+
# Never start on tool results: keep the assistant call that produced them.
|
|
62
|
+
while index > 0 and history[index].role == "tool":
|
|
63
|
+
index -= 1
|
|
64
|
+
return index
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _only_boundary(messages: Sequence[Message]) -> bool:
|
|
68
|
+
# Re-summarizing a lone summary gains nothing.
|
|
69
|
+
return all(m.synthetic == BOUNDARY for m in messages)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_point(history: Sequence[Message], threshold: int) -> int:
|
|
73
|
+
"""Index where the kept tail starts; 0 means nothing can be compacted."""
|
|
74
|
+
if not history:
|
|
75
|
+
return 0
|
|
76
|
+
start = _group_start(history, len(history) - 1)
|
|
77
|
+
tokens = estimate_messages(history[start:])
|
|
78
|
+
cap = int(threshold * KEEP_SHARE)
|
|
79
|
+
while start > 0 and (tokens < KEEP_TOKENS or len(history) - start < KEEP_MESSAGES):
|
|
80
|
+
earlier = _group_start(history, start - 1)
|
|
81
|
+
extra = estimate_messages(history[earlier:start])
|
|
82
|
+
if tokens + extra > cap:
|
|
83
|
+
break
|
|
84
|
+
start, tokens = earlier, tokens + extra
|
|
85
|
+
return 0 if _only_boundary(history[:start]) else start
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cut_point(history: Sequence[Message], threshold: int, *, manual: bool = False) -> int:
|
|
89
|
+
"""split_point; a manual /compact on a short history keeps only the last group instead."""
|
|
90
|
+
cut = split_point(history, threshold)
|
|
91
|
+
if cut or not manual or not history:
|
|
92
|
+
return cut
|
|
93
|
+
start = _group_start(history, len(history) - 1)
|
|
94
|
+
return 0 if _only_boundary(history[:start]) else start
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def carried_quotes(message: Message) -> list[str]:
|
|
98
|
+
return [b.text[len(QUOTE) :] for b in message.content if b.text.startswith(QUOTE)]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def collect_quotes(removed: Sequence[Message]) -> list[str]:
|
|
102
|
+
"""User input in time order, including quotes a previous compaction attached."""
|
|
103
|
+
quotes = []
|
|
104
|
+
for message in removed:
|
|
105
|
+
if message.synthetic == BOUNDARY:
|
|
106
|
+
quotes.extend(carried_quotes(message))
|
|
107
|
+
elif message.role == "user" and message.synthetic is None and message.text.strip():
|
|
108
|
+
quotes.append(message.text)
|
|
109
|
+
return quotes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fit_quotes(quotes: Sequence[str], budget: int = QUOTE_TOKENS) -> tuple[list[str], int]:
|
|
113
|
+
"""Newest first: whole if it fits, else the first 200 characters, else omitted."""
|
|
114
|
+
kept, used, omitted = [], 0, 0
|
|
115
|
+
for quote in reversed(quotes):
|
|
116
|
+
for candidate in (quote, quote if quote.endswith(CLIPPED) else quote[:QUOTE_CLIP].rstrip() + CLIPPED):
|
|
117
|
+
cost = estimate_text(candidate)
|
|
118
|
+
if used + cost <= budget:
|
|
119
|
+
kept.append(candidate)
|
|
120
|
+
used += cost
|
|
121
|
+
break
|
|
122
|
+
else:
|
|
123
|
+
omitted += 1
|
|
124
|
+
kept.reverse()
|
|
125
|
+
return kept, omitted
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render(message: Message, shrink: bool = False) -> str:
|
|
129
|
+
"""One message as role-tagged plain text, so no protocol ever sees unpaired tool calls."""
|
|
130
|
+
if message.synthetic == BOUNDARY:
|
|
131
|
+
return "[之前的压缩摘要]\n" + message.text.strip()
|
|
132
|
+
if message.role == "user":
|
|
133
|
+
return "[用户]\n" + message.text
|
|
134
|
+
if message.role == "assistant":
|
|
135
|
+
parts = ["[助手]\n" + message.text] if message.text.strip() else []
|
|
136
|
+
parts += [f"[工具调用] {call.name} {call.arguments}" for call in message.tool_calls]
|
|
137
|
+
return "\n".join(parts) or "[助手](无文本)"
|
|
138
|
+
parts = []
|
|
139
|
+
for result in message.tool_results:
|
|
140
|
+
text = result.model_text()
|
|
141
|
+
if shrink and len(text) > RESULT_CLIP:
|
|
142
|
+
text = text[:RESULT_CLIP] + f"\n…(已缩短,原文 {len(text)} 字符)"
|
|
143
|
+
parts.append(f"[工具结果 · {result.name}]\n{text}")
|
|
144
|
+
return "\n".join(parts)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def transcript(messages: Sequence[Message], *, shrink: bool = False, dropped: int = 0) -> str:
|
|
148
|
+
lead = [f"(更早的 {dropped} 条消息因篇幅省略)"] if dropped else []
|
|
149
|
+
return "\n\n".join(lead + [render(m, shrink) for m in messages])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def summary_request(text: str, instructions: str = "") -> str:
|
|
153
|
+
extra = f"\n用户对本次摘要的额外要求:{instructions.strip()}" if instructions.strip() else ""
|
|
154
|
+
return (
|
|
155
|
+
f"{SYSTEM}\n\n下面是编程助手与用户较早的一段对话记录,请压缩成摘要,供助手在后续工作中代替这段记录使用。\n\n"
|
|
156
|
+
f"<transcript>\n{text}\n</transcript>\n\n"
|
|
157
|
+
"先在 <analysis> 中按时间顺序梳理:用户每次要求了什么,做了哪些修改,涉及哪些文件和代码,"
|
|
158
|
+
"遇到了什么错误、怎么修复的。\n"
|
|
159
|
+
f"再在 <summary> 中按以下 9 部分写正式摘要:\n{SECTIONS}\n"
|
|
160
|
+
"用户原话会由程序另外附上,摘要中不必逐字复述,只需总结其中的要求和约束。"
|
|
161
|
+
f"{extra}\n\n再次提醒:不要调用任何工具,只输出 <analysis> 和 <summary>。"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_summary(response: Completed) -> str:
|
|
166
|
+
"""The <summary> body, or a failure reason."""
|
|
167
|
+
if response.message.tool_calls:
|
|
168
|
+
raise CompactionError("摘要回复包含工具调用。")
|
|
169
|
+
if response.stop_reason == "max_tokens":
|
|
170
|
+
raise CompactionError("摘要回复达到输出上限被截断。")
|
|
171
|
+
found = re.findall(r"<summary>(.*?)</summary>", response.message.text, re.DOTALL)
|
|
172
|
+
summary = found[-1].strip() if found else ""
|
|
173
|
+
if not summary:
|
|
174
|
+
raise CompactionError("摘要回复中没有有效的 <summary>。")
|
|
175
|
+
return summary
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def record_text(removed: Sequence[Message]) -> str:
|
|
179
|
+
stamp = datetime.now().isoformat(timespec="seconds")
|
|
180
|
+
return f"# MossCode 压缩记录\n\n压缩时间:{stamp}\n被压缩的消息:{len(removed)} 条\n\n{transcript(removed)}\n"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def boundary(summary: str, quotes: Sequence[str], omitted: int, record: Path | None) -> Message:
|
|
184
|
+
"""Replaces the compacted messages; quotes stay separate blocks so the next compaction carries them."""
|
|
185
|
+
blocks = [
|
|
186
|
+
ContentBlock("text", "(上下文已压缩:较早的对话由下面的摘要代替,之后的消息保留原文。)"),
|
|
187
|
+
ContentBlock("text", f"\n\n摘要:\n{summary}"),
|
|
188
|
+
]
|
|
189
|
+
if quotes:
|
|
190
|
+
blocks.append(ContentBlock("text", "\n\n用户原话(按时间顺序,由程序原样附上):"))
|
|
191
|
+
blocks += [ContentBlock("text", QUOTE + quote) for quote in quotes]
|
|
192
|
+
if omitted:
|
|
193
|
+
blocks.append(ContentBlock("text", f"\n\n(另有 {omitted} 条更早的用户消息未附上,原文见记录文件。)"))
|
|
194
|
+
where = f"压缩前的完整记录:{record}\n" if record else "(本次未能保存压缩前的完整记录。)\n"
|
|
195
|
+
blocks.append(
|
|
196
|
+
ContentBlock(
|
|
197
|
+
"text",
|
|
198
|
+
f"\n\n{where}需要具体代码、报错或输出时用 read_file 读取记录文件;"
|
|
199
|
+
"文件可能已变化,修改前必须重新读取,不要依据摘要补写代码。",
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return Message("user", tuple(blocks), synthetic=BOUNDARY)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _ask(provider, prompt: str) -> Completed:
|
|
206
|
+
async with aclosing(
|
|
207
|
+
provider.stream([Message("user", (ContentBlock("text", prompt),))], SYSTEM, (), allow_tools=False)
|
|
208
|
+
) as events:
|
|
209
|
+
async for event in events:
|
|
210
|
+
if isinstance(event, Completed):
|
|
211
|
+
return event
|
|
212
|
+
raise ProviderError("摘要回复在完成信号前中断。")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def compact_history(
|
|
216
|
+
provider,
|
|
217
|
+
history: Sequence[Message],
|
|
218
|
+
budget: ContextBudget,
|
|
219
|
+
*,
|
|
220
|
+
session: Session | None = None,
|
|
221
|
+
instructions: str = "",
|
|
222
|
+
manual: bool = False,
|
|
223
|
+
) -> Compacted:
|
|
224
|
+
cut = cut_point(history, budget.auto_threshold, manual=manual)
|
|
225
|
+
if cut == 0:
|
|
226
|
+
raise CompactionError(NOTHING, "nothing")
|
|
227
|
+
removed, tail = list(history[:cut]), list(history[cut:])
|
|
228
|
+
limit = budget.manual_limit
|
|
229
|
+
shrink, dropped = False, 0
|
|
230
|
+
for step in range(SHRINK_STEPS + 1):
|
|
231
|
+
prompt = summary_request(transcript(removed[dropped:], shrink=shrink, dropped=dropped), instructions)
|
|
232
|
+
response = None
|
|
233
|
+
if estimate_text(SYSTEM) + estimate_text(prompt) <= limit:
|
|
234
|
+
try:
|
|
235
|
+
response = await _ask(provider, prompt)
|
|
236
|
+
except ProviderError as exc:
|
|
237
|
+
if exc.code != "context_overflow":
|
|
238
|
+
raise CompactionError(f"摘要请求失败:{exc}") from None
|
|
239
|
+
if response is not None:
|
|
240
|
+
break
|
|
241
|
+
if step == SHRINK_STEPS:
|
|
242
|
+
raise CompactionError(f"摘要请求缩减 {SHRINK_STEPS} 次后仍超出上下文窗口。")
|
|
243
|
+
# Shorten tool results first, then drop the earliest 20% of messages each time.
|
|
244
|
+
if not shrink:
|
|
245
|
+
shrink = True
|
|
246
|
+
continue
|
|
247
|
+
remaining = len(removed) - dropped
|
|
248
|
+
if remaining <= 1:
|
|
249
|
+
raise CompactionError("摘要请求超出上下文窗口,且无法继续缩减。")
|
|
250
|
+
dropped += min(remaining - 1, max(1, math.ceil(remaining * DROP_SHARE)))
|
|
251
|
+
summary = parse_summary(response)
|
|
252
|
+
quotes, omitted = fit_quotes(collect_quotes(removed))
|
|
253
|
+
record = None
|
|
254
|
+
if session is not None:
|
|
255
|
+
try:
|
|
256
|
+
record = session.write_record(record_text(removed))
|
|
257
|
+
except OSError:
|
|
258
|
+
record = None
|
|
259
|
+
return Compacted([boundary(summary, quotes, omitted, record)] + tail, cut, estimate_text(summary), record)
|