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/member_ui.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""The lead's member panel and member view.
|
|
2
|
+
|
|
3
|
+
The widgets only show what MossCodeApp hands them; reading the team record and transcripts, and the
|
|
4
|
+
keys that route between them, stay in tui.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual import events
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Vertical
|
|
15
|
+
from textual.widgets import OptionList, RichLog, Static
|
|
16
|
+
|
|
17
|
+
from .appearance import OPERATION_COLOR, TOOL_SUCCESS_COLOR
|
|
18
|
+
from .teams.runtime import STATUS_NAMES, MemberRow
|
|
19
|
+
from .teams.view import Feed
|
|
20
|
+
|
|
21
|
+
COMPACT_ROWS = 4
|
|
22
|
+
EXPANDED_ROWS = 8
|
|
23
|
+
VIEW_MESSAGES = 200
|
|
24
|
+
VIEW_SHARE = 1.0 # Of the rows left by the status line (and a card).
|
|
25
|
+
STOP_CONFIRM = 2.0 # Seconds between the two presses of x.
|
|
26
|
+
ASKING = "待审批"
|
|
27
|
+
SYMBOLS = {
|
|
28
|
+
"running": ("●", TOOL_SUCCESS_COLOR),
|
|
29
|
+
"idle": ("○", "dim"),
|
|
30
|
+
"stopped": ("·", "dim"),
|
|
31
|
+
"asking": ("◆", OPERATION_COLOR),
|
|
32
|
+
}
|
|
33
|
+
PANEL_HELP = "↑ / ↓ 选择 · Enter 查看对话 · x 停止成员 · Esc 收起"
|
|
34
|
+
VIEW_HELP = "PgUp / PgDn 滚动 · ← / → 切换成员 · Esc 返回 Lead"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def elapsed(seconds: float) -> str:
|
|
38
|
+
seconds = max(0, int(seconds))
|
|
39
|
+
if seconds < 60:
|
|
40
|
+
return f"{seconds}s"
|
|
41
|
+
if seconds < 3600:
|
|
42
|
+
return f"{seconds // 60}m{seconds % 60:02d}s"
|
|
43
|
+
return f"{seconds // 3600}h{seconds % 3600 // 60:02d}m"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def state_of(row: MemberRow, asking: dict[str, int]) -> str:
|
|
47
|
+
"""A waiting approval shows over running."""
|
|
48
|
+
return "asking" if asking.get(row.name) and row.status != "stopped" else row.status
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def state_name(row: MemberRow, asking: dict[str, int]) -> str:
|
|
52
|
+
state = state_of(row, asking)
|
|
53
|
+
return ASKING if state == "asking" else STATUS_NAMES.get(state, state)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _grid() -> Table:
|
|
57
|
+
grid = Table.grid(padding=(0, 1), expand=True)
|
|
58
|
+
grid.add_column(no_wrap=True) # Symbol and name.
|
|
59
|
+
grid.add_column(no_wrap=True) # State.
|
|
60
|
+
grid.add_column(ratio=1, no_wrap=True, overflow="ellipsis") # Task and tool: cut, never wrapped.
|
|
61
|
+
grid.add_column(no_wrap=True, justify="right") # Elapsed, pane.
|
|
62
|
+
return grid
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cells(row: MemberRow, asking: dict[str, int], now: float) -> tuple[Text, Text, Text, Text]:
|
|
66
|
+
state = state_of(row, asking)
|
|
67
|
+
symbol, style = SYMBOLS.get(state, ("·", "dim"))
|
|
68
|
+
name = Text(symbol + " ", style=style)
|
|
69
|
+
name.append(row.name, style="bold" if state != "stopped" else "dim")
|
|
70
|
+
detail = " · ".join(part for part in (row.task, row.last_tool if row.status == "running" else "") if part)
|
|
71
|
+
tail = []
|
|
72
|
+
if row.status == "running" and row.since is not None:
|
|
73
|
+
tail.append(elapsed(now - row.since))
|
|
74
|
+
if row.pane:
|
|
75
|
+
tail.append("窗格")
|
|
76
|
+
return (
|
|
77
|
+
name,
|
|
78
|
+
Text(state_name(row, asking), style=style if state != "idle" else "dim"),
|
|
79
|
+
Text(detail, style="dim"),
|
|
80
|
+
Text(" · ".join(tail), style="dim"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def row_line(row: MemberRow, asking: dict[str, int], now: float) -> Table:
|
|
85
|
+
grid = _grid()
|
|
86
|
+
grid.add_row(*_cells(row, asking, now))
|
|
87
|
+
return grid
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def compact_rows(rows: list[MemberRow], asking: dict[str, int], now: float, limit: int = COMPACT_ROWS):
|
|
91
|
+
"""At most `limit` lines; stopped members, then idle ones, then the rest are summed up."""
|
|
92
|
+
live = [row for row in rows if row.status != "stopped"]
|
|
93
|
+
stopped = len(rows) - len(live)
|
|
94
|
+
entries: list[tuple[MemberRow | Text, int]] = [(row, 1) for row in live]
|
|
95
|
+
tail: list[tuple[Text, int]] = [(Text(f"已停止 {stopped}", style="dim"), stopped)] if stopped else []
|
|
96
|
+
if len(entries) + len(tail) > limit:
|
|
97
|
+
idle = [row for row in live if row.status == "idle" and not asking.get(row.name)]
|
|
98
|
+
if len(idle) > 1:
|
|
99
|
+
entries = [(row, 1) for row in live if row not in idle]
|
|
100
|
+
names = "、".join(row.name for row in idle)
|
|
101
|
+
entries.append(
|
|
102
|
+
(Text(f"○ 空闲 {len(idle)}:{names}", style="dim", no_wrap=True, overflow="ellipsis"), len(idle))
|
|
103
|
+
)
|
|
104
|
+
entries += tail
|
|
105
|
+
if len(entries) > limit:
|
|
106
|
+
hidden = sum(count for _, count in entries[limit - 1 :])
|
|
107
|
+
entries = entries[: limit - 1] + [(Text(f"另有 {hidden} 个成员 · Ctrl+T 展开", style="dim"), hidden)]
|
|
108
|
+
grid = _grid()
|
|
109
|
+
for entry, _ in entries:
|
|
110
|
+
if isinstance(entry, MemberRow):
|
|
111
|
+
grid.add_row(*_cells(entry, asking, now))
|
|
112
|
+
else:
|
|
113
|
+
grid.add_row(entry, Text(""), Text(""), Text(""))
|
|
114
|
+
return grid, len(entries)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class MemberList(OptionList):
|
|
118
|
+
"""Keys only; a click does not choose (as KeyboardOptions in tui.py)."""
|
|
119
|
+
|
|
120
|
+
BINDINGS = [Binding("x", "app.stop_member", "停止成员", show=False)]
|
|
121
|
+
|
|
122
|
+
async def _on_click(self, event: events.Click) -> None:
|
|
123
|
+
event.prevent_default()
|
|
124
|
+
event.stop()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MemberPanel(Vertical):
|
|
128
|
+
"""Compact lines under the prompt; Ctrl+T turns it into a list to pick from."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, **kwargs) -> None:
|
|
131
|
+
super().__init__(**kwargs)
|
|
132
|
+
self.rows: list[MemberRow] = []
|
|
133
|
+
self.asking: dict[str, int] = {}
|
|
134
|
+
self.expanded = False
|
|
135
|
+
self.lines = 0 # Rows it takes on screen, for MossCodeApp._fit_chat.
|
|
136
|
+
|
|
137
|
+
def compose(self) -> ComposeResult:
|
|
138
|
+
yield Static("", id="member-compact")
|
|
139
|
+
members = MemberList(id="member-list")
|
|
140
|
+
members.display = False
|
|
141
|
+
yield members
|
|
142
|
+
help_line = Static(PANEL_HELP, id="member-help", markup=False)
|
|
143
|
+
help_line.display = False
|
|
144
|
+
yield help_line
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def live(self) -> bool:
|
|
148
|
+
return any(row.status != "stopped" for row in self.rows)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def selected(self) -> str:
|
|
152
|
+
members = self.query_one("#member-list", MemberList)
|
|
153
|
+
index = members.highlighted
|
|
154
|
+
return self.rows[index].name if index is not None and index < len(self.rows) else ""
|
|
155
|
+
|
|
156
|
+
def show(self, rows: list[MemberRow], asking: dict[str, int], now: float) -> None:
|
|
157
|
+
self.rows, self.asking = rows, asking
|
|
158
|
+
if self.expanded:
|
|
159
|
+
members = self.query_one("#member-list", MemberList)
|
|
160
|
+
chosen = self.selected
|
|
161
|
+
members.clear_options()
|
|
162
|
+
members.add_options([row_line(row, asking, now) for row in rows])
|
|
163
|
+
members.styles.height = max(1, min(EXPANDED_ROWS, len(rows)))
|
|
164
|
+
names = [row.name for row in rows]
|
|
165
|
+
if rows:
|
|
166
|
+
members.highlighted = names.index(chosen) if chosen in names else 0
|
|
167
|
+
self.lines = min(EXPANDED_ROWS, max(1, len(rows))) + 1
|
|
168
|
+
else:
|
|
169
|
+
grid, self.lines = compact_rows(rows, asking, now)
|
|
170
|
+
self.query_one("#member-compact", Static).update(grid)
|
|
171
|
+
|
|
172
|
+
def expand(self, now: float, select: str = "") -> None:
|
|
173
|
+
self.expanded = True
|
|
174
|
+
self.query_one("#member-compact").display = False
|
|
175
|
+
members = self.query_one("#member-list", MemberList)
|
|
176
|
+
members.display = True
|
|
177
|
+
self.query_one("#member-help").display = True
|
|
178
|
+
self.help(PANEL_HELP)
|
|
179
|
+
self.show(self.rows, self.asking, now)
|
|
180
|
+
names = [row.name for row in self.rows]
|
|
181
|
+
if select in names:
|
|
182
|
+
members.highlighted = names.index(select)
|
|
183
|
+
members.focus()
|
|
184
|
+
|
|
185
|
+
def collapse(self, now: float) -> None:
|
|
186
|
+
self.expanded = False
|
|
187
|
+
self.query_one("#member-list").display = False
|
|
188
|
+
self.query_one("#member-help").display = False
|
|
189
|
+
self.query_one("#member-compact").display = True
|
|
190
|
+
self.show(self.rows, self.asking, now)
|
|
191
|
+
|
|
192
|
+
def help(self, text: str) -> None:
|
|
193
|
+
self.query_one("#member-help", Static).update(text)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class MemberLog(RichLog):
|
|
197
|
+
"""PgUp/PgDn, Home/End and ↑/↓ scroll (RichLog's own keys); ←/→ switch members."""
|
|
198
|
+
|
|
199
|
+
BINDINGS = [Binding("left", "app.member_step(-1)", show=False), Binding("right", "app.member_step(1)", show=False)]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class MemberView(Vertical):
|
|
203
|
+
"""Header, the member's conversation, its calls still running, help."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, **kwargs) -> None:
|
|
206
|
+
super().__init__(**kwargs)
|
|
207
|
+
self.name_shown = ""
|
|
208
|
+
self.team = ""
|
|
209
|
+
self.tail = None # teams.view.TranscriptTail of the member shown.
|
|
210
|
+
self.feed = Feed()
|
|
211
|
+
self.unseen = False
|
|
212
|
+
|
|
213
|
+
def compose(self) -> ComposeResult:
|
|
214
|
+
yield Static("", id="member-head", markup=False)
|
|
215
|
+
yield MemberLog(id="member-log", wrap=True, markup=False, max_lines=5000)
|
|
216
|
+
running = Static("", id="member-running", markup=False)
|
|
217
|
+
running.display = False
|
|
218
|
+
yield running
|
|
219
|
+
yield Static(VIEW_HELP, id="member-view-help", markup=False)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def member_log(self) -> MemberLog:
|
|
223
|
+
return self.query_one("#member-log", MemberLog)
|
|
224
|
+
|
|
225
|
+
def head(self, row: MemberRow | None, asking: dict[str, int]) -> None:
|
|
226
|
+
text = Text(f"成员 {self.name_shown} · 团队 {self.team}", style="bold")
|
|
227
|
+
if row is None:
|
|
228
|
+
text.append(" · 已不在团队中", style="dim")
|
|
229
|
+
else:
|
|
230
|
+
text.append(" · " + state_name(row, asking), style=OPERATION_COLOR if row.status != "stopped" else "dim")
|
|
231
|
+
if row.status == "running" and row.last_tool:
|
|
232
|
+
text.append(" · " + row.last_tool, style="dim")
|
|
233
|
+
self.query_one("#member-head", Static).update(text)
|
|
234
|
+
|
|
235
|
+
def reset(self, name: str, team: str, tail) -> None:
|
|
236
|
+
self.name_shown, self.team, self.tail, self.unseen = name, team, tail, False
|
|
237
|
+
self.feed = Feed()
|
|
238
|
+
self.member_log.clear()
|
|
239
|
+
self.show_running()
|
|
240
|
+
self.help()
|
|
241
|
+
|
|
242
|
+
def write(self, lines: list, *, follow: bool | None = None) -> None:
|
|
243
|
+
"""Follow only while at the bottom; otherwise say there is more."""
|
|
244
|
+
if not lines:
|
|
245
|
+
return
|
|
246
|
+
log = self.member_log
|
|
247
|
+
follow = log.is_vertical_scroll_end if follow is None else follow
|
|
248
|
+
for line in lines:
|
|
249
|
+
log.write(line, scroll_end=follow)
|
|
250
|
+
if not follow and not self.unseen:
|
|
251
|
+
self.unseen = True
|
|
252
|
+
self.help()
|
|
253
|
+
|
|
254
|
+
def show_running(self) -> None:
|
|
255
|
+
running = self.query_one("#member-running", Static)
|
|
256
|
+
running.update(self.feed.running_text())
|
|
257
|
+
running.display = bool(self.feed.waiting)
|
|
258
|
+
|
|
259
|
+
def help(self) -> None:
|
|
260
|
+
text = Text(VIEW_HELP, style="dim")
|
|
261
|
+
if self.unseen:
|
|
262
|
+
text = Text("有新内容 · End 回到底部", style=OPERATION_COLOR)
|
|
263
|
+
text.append(" · " + VIEW_HELP, style="dim")
|
|
264
|
+
self.query_one("#member-view-help", Static).update(text)
|
|
265
|
+
|
|
266
|
+
def seen(self) -> None:
|
|
267
|
+
if self.unseen and self.member_log.is_vertical_scroll_end:
|
|
268
|
+
self.unseen = False
|
|
269
|
+
self.help()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = [
|
|
273
|
+
"ASKING",
|
|
274
|
+
"COMPACT_ROWS",
|
|
275
|
+
"EXPANDED_ROWS",
|
|
276
|
+
"MemberList",
|
|
277
|
+
"MemberLog",
|
|
278
|
+
"MemberPanel",
|
|
279
|
+
"MemberView",
|
|
280
|
+
"PANEL_HELP",
|
|
281
|
+
"STOP_CONFIRM",
|
|
282
|
+
"VIEW_HELP",
|
|
283
|
+
"VIEW_MESSAGES",
|
|
284
|
+
"VIEW_SHARE",
|
|
285
|
+
"compact_rows",
|
|
286
|
+
"elapsed",
|
|
287
|
+
"row_line",
|
|
288
|
+
"state_name",
|
|
289
|
+
]
|
mosscode/memory.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Memory notes: two scopes, one Markdown file per note, and a program-generated MEMORY.md.
|
|
2
|
+
|
|
3
|
+
The memory tool and the background extractor share every check here, so neither can write
|
|
4
|
+
outside the two note directories, store a credential, or leave an index out of step with its notes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import date
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .context import estimate_text
|
|
16
|
+
from .paths import mosscode_home, project_dir
|
|
17
|
+
|
|
18
|
+
SLUG = re.compile(r"[a-z0-9][a-z0-9-]{0,59}")
|
|
19
|
+
TYPES = ("user", "feedback", "project", "reference")
|
|
20
|
+
SCOPES = ("user", "project")
|
|
21
|
+
SCOPE_LABELS = {"user": "用户级", "project": "项目级"}
|
|
22
|
+
INDEX_FILE = "MEMORY.md"
|
|
23
|
+
DESCRIPTION_LIMIT = 150
|
|
24
|
+
BODY_LIMIT = 4096 # UTF-8 bytes.
|
|
25
|
+
INDEX_TOKENS = 3000 # Both indexes together, as injected into the system prompt.
|
|
26
|
+
INDEX_LINES = 200 # Per index.
|
|
27
|
+
MAX_OPERATIONS = 5
|
|
28
|
+
_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
|
29
|
+
|
|
30
|
+
# An aid, not a guarantee; the tool description and extraction prompt carry the rule as well.
|
|
31
|
+
SECRET_PATTERNS = (
|
|
32
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{16,}"),
|
|
33
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
34
|
+
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"),
|
|
35
|
+
re.compile(r"(?i)(?:api[_-]?key|token|password|passwd|secret)[\"']?\s*[=::]\s*[\"']?[^\s\"']{8,}"),
|
|
36
|
+
re.compile(r"\b(?:ghp|gho|ghs|ghu|ghr|github_pat)_[A-Za-z0-9_]{16,}"),
|
|
37
|
+
re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MemoryFailure(Exception):
|
|
42
|
+
def __init__(self, code: str, message: str):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.code = code
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Note:
|
|
49
|
+
scope: str
|
|
50
|
+
name: str
|
|
51
|
+
type: str
|
|
52
|
+
description: str
|
|
53
|
+
updated: str
|
|
54
|
+
body: str
|
|
55
|
+
path: Path
|
|
56
|
+
error: str = "" # Set when the file could not be parsed; such notes stay out of the index.
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def filename(self) -> str:
|
|
60
|
+
return self.path.name
|
|
61
|
+
|
|
62
|
+
def index_line(self) -> str:
|
|
63
|
+
return f"- [{self.name}]({self.filename}) — {self.description}"
|
|
64
|
+
|
|
65
|
+
def text(self) -> str:
|
|
66
|
+
return render_note(self.name, self.type, self.description, self.updated, self.body)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_note(name: str, type_: str, description: str, updated: str, body: str) -> str:
|
|
70
|
+
return f"---\nname: {name}\ndescription: {description}\ntype: {type_}\nupdated: {updated}\n---\n\n{body.strip()}\n"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_note(scope: str, path: Path, text: str) -> Note:
|
|
74
|
+
"""The frontmatter this module writes; anything else is marked as a format error."""
|
|
75
|
+
|
|
76
|
+
def broken(reason: str) -> Note:
|
|
77
|
+
return Note(scope, path.stem, "", "", "", text, path, reason)
|
|
78
|
+
|
|
79
|
+
lines = text.lstrip("").replace("\r\n", "\n").split("\n")
|
|
80
|
+
if not lines or lines[0].strip() != "---":
|
|
81
|
+
return broken("缺少 frontmatter")
|
|
82
|
+
try:
|
|
83
|
+
end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
|
|
84
|
+
except StopIteration:
|
|
85
|
+
return broken("frontmatter 没有结束标记")
|
|
86
|
+
fields = {}
|
|
87
|
+
for line in lines[1:end]:
|
|
88
|
+
key, sep, value = line.partition(":")
|
|
89
|
+
if sep and key.strip():
|
|
90
|
+
fields[key.strip()] = value.strip()
|
|
91
|
+
body = "\n".join(lines[end + 1 :]).strip()
|
|
92
|
+
name, type_ = fields.get("name", ""), fields.get("type", "")
|
|
93
|
+
description, updated = fields.get("description", ""), fields.get("updated", "")
|
|
94
|
+
if name != path.stem or not SLUG.fullmatch(name):
|
|
95
|
+
return broken("name 与文件名不一致")
|
|
96
|
+
if type_ not in TYPES:
|
|
97
|
+
return broken("type 不合法")
|
|
98
|
+
if not description:
|
|
99
|
+
return broken("缺少 description")
|
|
100
|
+
if not _DATE.fullmatch(updated):
|
|
101
|
+
return broken("updated 不是 YYYY-MM-DD")
|
|
102
|
+
return Note(scope, name, type_, description, updated, body, path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_secret(*texts: str) -> bool:
|
|
106
|
+
return any(pattern.search(text) for text in texts for pattern in SECRET_PATTERNS)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate(scope, name, type_, description, body) -> None:
|
|
110
|
+
"""Shared by the tool and the extractor; raises MemoryFailure with a code the model can act on."""
|
|
111
|
+
if scope not in SCOPES:
|
|
112
|
+
raise MemoryFailure("invalid_scope", "scope 只能是 user 或 project。")
|
|
113
|
+
if not isinstance(name, str) or not SLUG.fullmatch(name):
|
|
114
|
+
if isinstance(name, str) and any(sep in name for sep in "/\\.:"):
|
|
115
|
+
raise MemoryFailure("invalid_name", "name 不能包含路径分隔符或扩展名;只写笔记名,例如 prefer-pnpm。")
|
|
116
|
+
raise MemoryFailure("invalid_name", "name 只能用小写字母、数字和连字符,以字母或数字开头,最长 60 字符。")
|
|
117
|
+
if type_ not in TYPES:
|
|
118
|
+
raise MemoryFailure("invalid_type", "type 只能是 user、feedback、project 或 reference。")
|
|
119
|
+
if type_ == "project" and scope != "project":
|
|
120
|
+
raise MemoryFailure("scope_mismatch", "project 类型的笔记只能写到项目级(scope: project)。")
|
|
121
|
+
if not isinstance(description, str) or not description.strip():
|
|
122
|
+
raise MemoryFailure("invalid_description", "description 不能为空,用一行说明这条笔记什么时候有用。")
|
|
123
|
+
if "\n" in description.strip() or "\r" in description:
|
|
124
|
+
raise MemoryFailure("invalid_description", "description 只能写一行。")
|
|
125
|
+
if len(description.strip()) > DESCRIPTION_LIMIT:
|
|
126
|
+
raise MemoryFailure(
|
|
127
|
+
"description_too_long", f"description 最多 {DESCRIPTION_LIMIT} 字符,当前 {len(description.strip())}。"
|
|
128
|
+
)
|
|
129
|
+
if not isinstance(body, str) or not body.strip():
|
|
130
|
+
raise MemoryFailure("invalid_body", "body 不能为空。")
|
|
131
|
+
size = len(body.strip().encode("utf-8"))
|
|
132
|
+
if size > BODY_LIMIT:
|
|
133
|
+
raise MemoryFailure("body_too_large", f"body 最多 {BODY_LIMIT} 字节(UTF-8),当前 {size};请精简。")
|
|
134
|
+
if find_secret(name, description, body):
|
|
135
|
+
raise MemoryFailure("secret_detected", "内容疑似包含密钥、令牌或密码,未保存;笔记中不要记录凭据。")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
139
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
temp = path.with_name(f".{path.name}.{secrets.token_hex(4)}.tmp")
|
|
141
|
+
try:
|
|
142
|
+
with open(temp, "w", encoding="utf-8", newline="\n") as handle:
|
|
143
|
+
handle.write(text)
|
|
144
|
+
os.replace(temp, path)
|
|
145
|
+
except BaseException:
|
|
146
|
+
temp.unlink(missing_ok=True)
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class MemoryStore:
|
|
151
|
+
def __init__(self, user_dir: Path, project_dir: Path):
|
|
152
|
+
self.dirs = {"user": Path(user_dir), "project": Path(project_dir)}
|
|
153
|
+
self._lock = threading.Lock()
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def for_project(cls, root: Path, home: Path | None = None) -> "MemoryStore":
|
|
157
|
+
return cls(mosscode_home(home) / "memory", project_dir(root, home) / "memory")
|
|
158
|
+
|
|
159
|
+
def directory(self, scope: str) -> Path:
|
|
160
|
+
return self.dirs[scope]
|
|
161
|
+
|
|
162
|
+
# Reading ----------------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def load(self, scope: str | None = None) -> list[Note]:
|
|
165
|
+
"""Valid and broken notes, newest first within each scope; user scope before project."""
|
|
166
|
+
notes = []
|
|
167
|
+
for name in (scope,) if scope else SCOPES:
|
|
168
|
+
folder = self.dirs[name]
|
|
169
|
+
try:
|
|
170
|
+
files = sorted(p for p in folder.glob("*.md") if p.name != INDEX_FILE and p.is_file())
|
|
171
|
+
except OSError:
|
|
172
|
+
files = []
|
|
173
|
+
loaded = []
|
|
174
|
+
for path in files:
|
|
175
|
+
try:
|
|
176
|
+
loaded.append(parse_note(name, path, path.read_text(encoding="utf-8")))
|
|
177
|
+
except (OSError, UnicodeError):
|
|
178
|
+
loaded.append(Note(name, path.stem, "", "", "", "", path, "无法读取"))
|
|
179
|
+
loaded.sort(key=lambda n: (n.updated, n.name), reverse=True)
|
|
180
|
+
notes += loaded
|
|
181
|
+
return notes
|
|
182
|
+
|
|
183
|
+
def find(self, name: str) -> list[Note]:
|
|
184
|
+
return [note for note in self.load() if note.name == name]
|
|
185
|
+
|
|
186
|
+
def render_index(self, scope: str) -> str:
|
|
187
|
+
notes = [note for note in self.load(scope) if not note.error]
|
|
188
|
+
lines = [note.index_line() for note in notes]
|
|
189
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
190
|
+
|
|
191
|
+
def refresh(self) -> None:
|
|
192
|
+
"""Regenerate both indexes from the notes, e.g. after the user edited one by hand."""
|
|
193
|
+
with self._lock:
|
|
194
|
+
for scope in SCOPES:
|
|
195
|
+
self._write_index(scope)
|
|
196
|
+
|
|
197
|
+
def _write_index(self, scope: str) -> None:
|
|
198
|
+
path = self.dirs[scope] / INDEX_FILE
|
|
199
|
+
text = self.render_index(scope)
|
|
200
|
+
try:
|
|
201
|
+
current = path.read_text(encoding="utf-8")
|
|
202
|
+
except (OSError, UnicodeError):
|
|
203
|
+
current = None
|
|
204
|
+
if current == text or (current is None and not text):
|
|
205
|
+
return
|
|
206
|
+
atomic_write(path, text)
|
|
207
|
+
|
|
208
|
+
def index_section(self, limit: int = INDEX_TOKENS, lines: int = INDEX_LINES) -> str:
|
|
209
|
+
"""Both indexes for the system prompt, oldest entries dropped first beyond the limits."""
|
|
210
|
+
entries = {scope: [n for n in self.load(scope) if not n.error] for scope in SCOPES}
|
|
211
|
+
shown = {scope: notes[:lines] for scope, notes in entries.items()}
|
|
212
|
+
|
|
213
|
+
def render():
|
|
214
|
+
parts = []
|
|
215
|
+
for scope in SCOPES:
|
|
216
|
+
body = [note.index_line() for note in shown[scope]] or ["(暂无)"]
|
|
217
|
+
omitted = len(entries[scope]) - len(shown[scope])
|
|
218
|
+
if omitted:
|
|
219
|
+
body.append(f"另有 {omitted} 条较旧的笔记未列出,可用 glob 查看目录")
|
|
220
|
+
parts.append(f"### {SCOPE_LABELS[scope]}笔记(目录:{self.dirs[scope]})\n" + "\n".join(body))
|
|
221
|
+
return "\n\n".join(parts)
|
|
222
|
+
|
|
223
|
+
text = render()
|
|
224
|
+
while estimate_text(text) > limit and any(shown.values()):
|
|
225
|
+
# Drop the oldest entry across both scopes, then re-measure.
|
|
226
|
+
oldest = min((scope for scope in SCOPES if shown[scope]), key=lambda s: (shown[s][-1].updated, s == "user"))
|
|
227
|
+
shown[oldest] = shown[oldest][:-1]
|
|
228
|
+
text = render()
|
|
229
|
+
return text
|
|
230
|
+
|
|
231
|
+
# Writing ----------------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def save(self, scope, name, type_, description, body, *, today: str | None = None) -> tuple[Note, bool]:
|
|
234
|
+
validate(scope, name, type_, description, body)
|
|
235
|
+
updated = today or date.today().isoformat()
|
|
236
|
+
path = self.dirs[scope] / f"{name}.md"
|
|
237
|
+
note = Note(scope, name, type_, description.strip(), updated, body.strip(), path)
|
|
238
|
+
with self._lock:
|
|
239
|
+
created = not path.exists()
|
|
240
|
+
atomic_write(path, note.text())
|
|
241
|
+
self._write_index(scope)
|
|
242
|
+
return note, created
|
|
243
|
+
|
|
244
|
+
def delete(self, scope, name) -> Path:
|
|
245
|
+
if scope not in SCOPES:
|
|
246
|
+
raise MemoryFailure("invalid_scope", "scope 只能是 user 或 project。")
|
|
247
|
+
if not isinstance(name, str) or not SLUG.fullmatch(name):
|
|
248
|
+
raise MemoryFailure("invalid_name", "name 只能用小写字母、数字和连字符,以字母或数字开头,最长 60 字符。")
|
|
249
|
+
path = self.dirs[scope] / f"{name}.md"
|
|
250
|
+
with self._lock:
|
|
251
|
+
if not path.is_file():
|
|
252
|
+
other = "project" if scope == "user" else "user"
|
|
253
|
+
hint = f";{SCOPE_LABELS[other]}有同名笔记" if (self.dirs[other] / f"{name}.md").is_file() else ""
|
|
254
|
+
raise MemoryFailure("not_found", f"{SCOPE_LABELS[scope]}没有名为 {name} 的笔记{hint}。")
|
|
255
|
+
path.unlink()
|
|
256
|
+
self._write_index(scope)
|
|
257
|
+
return path
|
|
258
|
+
|
|
259
|
+
def apply(self, operations) -> tuple[list[tuple[str, Note | Path, str]], list[tuple[object, str]]]:
|
|
260
|
+
"""At most five operations, each checked on its own; returns (applied, rejected).
|
|
261
|
+
|
|
262
|
+
applied items are (op, note or deleted path, name); rejected items are (operation, reason)."""
|
|
263
|
+
applied, rejected = [], []
|
|
264
|
+
if not isinstance(operations, list):
|
|
265
|
+
return applied, [(operations, "操作列表必须是 JSON 数组。")]
|
|
266
|
+
for operation in operations[:MAX_OPERATIONS]:
|
|
267
|
+
if not isinstance(operation, dict):
|
|
268
|
+
rejected.append((operation, "操作必须是 JSON 对象。"))
|
|
269
|
+
continue
|
|
270
|
+
op = operation.get("op")
|
|
271
|
+
try:
|
|
272
|
+
if op == "save":
|
|
273
|
+
note, _ = self.save(
|
|
274
|
+
operation.get("scope"),
|
|
275
|
+
operation.get("name"),
|
|
276
|
+
operation.get("type"),
|
|
277
|
+
operation.get("description"),
|
|
278
|
+
operation.get("body"),
|
|
279
|
+
)
|
|
280
|
+
applied.append(("save", note, note.name))
|
|
281
|
+
elif op == "delete":
|
|
282
|
+
path = self.delete(operation.get("scope"), operation.get("name"))
|
|
283
|
+
applied.append(("delete", path, path.stem))
|
|
284
|
+
else:
|
|
285
|
+
rejected.append((operation, "op 只能是 save 或 delete。"))
|
|
286
|
+
except MemoryFailure as exc:
|
|
287
|
+
rejected.append((operation, f"{exc.code}: {exc}"))
|
|
288
|
+
except OSError as exc:
|
|
289
|
+
rejected.append((operation, f"写入失败:{exc.strerror or exc}"))
|
|
290
|
+
rejected += [(operation, "超过一次最多 5 个操作的限制。") for operation in operations[MAX_OPERATIONS:]]
|
|
291
|
+
return applied, rejected
|