magic-agent 1.0.49 → 1.0.108
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.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2168 -143
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// team_flags_test.go - 插件侧团队参数的翻译与预校验。
|
|
4
|
+
//
|
|
5
|
+
// 这里只测「字段 → flag」与「请求那一刻就该拒的输入」。路由的最终裁决在
|
|
6
|
+
// core(internal/team/route.go 有自己的测试),两边不重复覆盖同一件事。
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// testRoles core 正常上报的角色清单(顺序同 internal/team 的内置表)。
|
|
14
|
+
var testRoles = []string{"writer", "researcher", "reviewer", "coder", "designer", "director", "builder"}
|
|
15
|
+
|
|
16
|
+
func TestTeamFlagsMinimal(t *testing.T) {
|
|
17
|
+
args, err := teamFlags(&teamRequest{}, Profile{}, nil)
|
|
18
|
+
if err != nil {
|
|
19
|
+
t.Fatalf("unexpected err: %v", err)
|
|
20
|
+
}
|
|
21
|
+
want := []string{"--team"}
|
|
22
|
+
if strings.Join(args, " ") != strings.Join(want, " ") {
|
|
23
|
+
t.Fatalf("args = %v, want %v", args, want)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestTeamFlagsAllOptions(t *testing.T) {
|
|
28
|
+
args, err := teamFlags(&teamRequest{
|
|
29
|
+
PlanOnly: true,
|
|
30
|
+
Parallel: 2,
|
|
31
|
+
TaskTimeout: "5m",
|
|
32
|
+
Routes: map[string]teamRoute{
|
|
33
|
+
"image": {Engine: "llm", Model: "hunyuan-image-3.0"},
|
|
34
|
+
"text": {Engine: "claude"},
|
|
35
|
+
},
|
|
36
|
+
}, Profile{}, testRoles)
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatalf("unexpected err: %v", err)
|
|
39
|
+
}
|
|
40
|
+
got := strings.Join(args, " ")
|
|
41
|
+
for _, want := range []string{
|
|
42
|
+
"--team",
|
|
43
|
+
"--team-plan-only",
|
|
44
|
+
"--team-parallel 2",
|
|
45
|
+
"--team-task-timeout 5m",
|
|
46
|
+
// 路由按类别名排序输出(map 遍历无序,排序后命令行才可复现)。
|
|
47
|
+
"--team-route image=llm/hunyuan-image-3.0 --team-route text=claude",
|
|
48
|
+
} {
|
|
49
|
+
if !strings.Contains(got, want) {
|
|
50
|
+
t.Fatalf("args = %q, 缺少 %q", got, want)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 拆解规则三项:粒度归一后透传,两个布尔按 core 默认值**只在需要时**出现。
|
|
56
|
+
func TestTeamFlagsSplit(t *testing.T) {
|
|
57
|
+
yes, no := true, false
|
|
58
|
+
cases := []struct {
|
|
59
|
+
name string
|
|
60
|
+
req teamRequest
|
|
61
|
+
want []string
|
|
62
|
+
deny []string
|
|
63
|
+
}{
|
|
64
|
+
{"粒度归一", teamRequest{Split: "FINE"}, []string{"--team-split fine"}, nil},
|
|
65
|
+
{"简单代码也拆", teamRequest{SplitCode: &yes}, []string{"--team-split-code"}, nil},
|
|
66
|
+
{"默认值不传", teamRequest{SplitCode: &no, Adaptive: &yes}, nil,
|
|
67
|
+
[]string{"--team-split-code", "--team-adaptive"}},
|
|
68
|
+
{"关掉自动补任务", teamRequest{Adaptive: &no}, []string{"--team-adaptive=false"}, nil},
|
|
69
|
+
}
|
|
70
|
+
for _, c := range cases {
|
|
71
|
+
args, err := teamFlags(&c.req, Profile{}, testRoles)
|
|
72
|
+
if err != nil {
|
|
73
|
+
t.Fatalf("%s: unexpected err: %v", c.name, err)
|
|
74
|
+
}
|
|
75
|
+
got := strings.Join(args, " ")
|
|
76
|
+
for _, w := range c.want {
|
|
77
|
+
if !strings.Contains(got, w) {
|
|
78
|
+
t.Fatalf("%s: args = %q, 缺少 %q", c.name, got, w)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for _, d := range c.deny {
|
|
82
|
+
if strings.Contains(got, d) {
|
|
83
|
+
t.Fatalf("%s: args = %q, 不该有 %q", c.name, got, d)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func TestTeamFlagsRejectsBadSplit(t *testing.T) {
|
|
90
|
+
if _, err := teamFlags(&teamRequest{Split: "tiny"}, Profile{}, testRoles); err == nil {
|
|
91
|
+
t.Fatal("非法粒度应在请求那一刻就报错")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 角色规则:键归一成 role:<name>,空白折叠,按角色名排序(同样的请求 → 同样的命令行)。
|
|
96
|
+
func TestTeamFlagsRules(t *testing.T) {
|
|
97
|
+
args, err := teamFlags(&teamRequest{Rules: map[string]string{
|
|
98
|
+
"writer": " 把 材料写成文档 ",
|
|
99
|
+
"role:coder": "只拆多文件、要构建或测试的改动",
|
|
100
|
+
"reviewer": " ",
|
|
101
|
+
}}, Profile{}, testRoles)
|
|
102
|
+
if err != nil {
|
|
103
|
+
t.Fatalf("unexpected err: %v", err)
|
|
104
|
+
}
|
|
105
|
+
got := strings.Join(args, " ")
|
|
106
|
+
for _, want := range []string{
|
|
107
|
+
"--team-rule role:coder=只拆多文件、要构建或测试的改动",
|
|
108
|
+
"--team-rule role:writer=把 材料写成文档",
|
|
109
|
+
} {
|
|
110
|
+
if !strings.Contains(got, want) {
|
|
111
|
+
t.Fatalf("args = %q, 缺少 %q", got, want)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if strings.Contains(got, "role:reviewer") {
|
|
115
|
+
t.Fatalf("空规则不该传:%q", got)
|
|
116
|
+
}
|
|
117
|
+
if strings.Index(got, "role:coder") > strings.Index(got, "role:writer") {
|
|
118
|
+
t.Fatalf("规则应按角色名排序:%q", got)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func TestTeamFlagsRejectsBadRuleKey(t *testing.T) {
|
|
123
|
+
for _, key := range []string{"image", "role:", "role:nobody"} {
|
|
124
|
+
if _, err := teamFlags(&teamRequest{Rules: map[string]string{key: "x"}}, Profile{}, testRoles); err == nil {
|
|
125
|
+
t.Fatalf("规则键 %q 应被拒绝(只认已知角色)", key)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestTeamFlagsSkipsEmptyRoute(t *testing.T) {
|
|
131
|
+
// 整条空 = 该类跟随主引擎,不该产生 --team-route(否则 core 会当成
|
|
132
|
+
// 「显式指定了空引擎」)。
|
|
133
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"image": {}}}, Profile{}, nil)
|
|
134
|
+
if err != nil {
|
|
135
|
+
t.Fatalf("unexpected err: %v", err)
|
|
136
|
+
}
|
|
137
|
+
if strings.Contains(strings.Join(args, " "), "--team-route") {
|
|
138
|
+
t.Fatalf("空路由不该下发 --team-route:%v", args)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func TestTeamFlagsRejectsModelWithoutEngine(t *testing.T) {
|
|
143
|
+
_, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"video": {Model: "kling-video-v3"}}}, Profile{}, nil)
|
|
144
|
+
if err == nil || !strings.Contains(err.Error(), "没给 engine") {
|
|
145
|
+
t.Fatalf("err = %v, want 提到「没给 engine」", err)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestTeamFlagsAcceptsPlanRoute(t *testing.T) {
|
|
150
|
+
// plan = 计划阶段(谁来拆任务),与五类子任务一样可以单独指定引擎。
|
|
151
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"plan": {Engine: "claude"}}}, Profile{}, nil)
|
|
152
|
+
if err != nil {
|
|
153
|
+
t.Fatalf("unexpected err: %v", err)
|
|
154
|
+
}
|
|
155
|
+
if !strings.Contains(strings.Join(args, " "), "--team-route plan=claude") {
|
|
156
|
+
t.Fatalf("plan 路由没下发:%v", args)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func TestTeamFlagsAcceptsCodeRoute(t *testing.T) {
|
|
161
|
+
// code 类子任务必须能过插件白名单,否则客户端配了代码引擎会被 400。
|
|
162
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"code": {Engine: "claude", Model: "sonnet"}}}, Profile{}, nil)
|
|
163
|
+
if err != nil {
|
|
164
|
+
t.Fatalf("unexpected err: %v", err)
|
|
165
|
+
}
|
|
166
|
+
if !strings.Contains(strings.Join(args, " "), "--team-route code=claude/sonnet") {
|
|
167
|
+
t.Fatalf("code 路由没下发:%v", args)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func TestTeamFlagsRejectsUnknownKind(t *testing.T) {
|
|
172
|
+
_, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"audio": {Engine: "llm"}}}, Profile{}, testRoles)
|
|
173
|
+
if err == nil || !strings.Contains(err.Error(), "未知类别") {
|
|
174
|
+
t.Fatalf("err = %v, want 「未知类别」", err)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestTeamFlagsKindCaseInsensitive(t *testing.T) {
|
|
179
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"IMAGE": {Engine: "llm"}}}, Profile{}, nil)
|
|
180
|
+
if err != nil {
|
|
181
|
+
t.Fatalf("unexpected err: %v", err)
|
|
182
|
+
}
|
|
183
|
+
if !strings.Contains(strings.Join(args, " "), "image=llm") {
|
|
184
|
+
t.Fatalf("类别应归一成小写:%v", args)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func TestTeamFlagsRejectsEngineOutsideProfile(t *testing.T) {
|
|
189
|
+
_, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"image": {Engine: "llm"}}},
|
|
190
|
+
Profile{Engines: []string{"claude"}}, nil)
|
|
191
|
+
if err == nil || !strings.Contains(err.Error(), "白名单") {
|
|
192
|
+
t.Fatalf("err = %v, want 「白名单」", err)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func TestTeamFlagsAcceptsRoleRoute(t *testing.T) {
|
|
197
|
+
// 角色级路由:客户端下发 role:<name>,插件必须原样翻成 --team-route。
|
|
198
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
|
|
199
|
+
"role:writer": {Engine: "claude", Model: "sonnet"},
|
|
200
|
+
"role:Coder": {Engine: "codex"},
|
|
201
|
+
}}, Profile{}, testRoles)
|
|
202
|
+
if err != nil {
|
|
203
|
+
t.Fatalf("unexpected err: %v", err)
|
|
204
|
+
}
|
|
205
|
+
got := strings.Join(args, " ")
|
|
206
|
+
for _, want := range []string{
|
|
207
|
+
"--team-route role:writer=claude/sonnet",
|
|
208
|
+
"--team-route role:coder=codex",
|
|
209
|
+
} {
|
|
210
|
+
if !strings.Contains(got, want) {
|
|
211
|
+
t.Fatalf("args = %q, 缺少 %q", got, want)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func TestTeamFlagsAcceptsBareRoleName(t *testing.T) {
|
|
217
|
+
// 裸角色名与 core 的 ParseRoutes 同口径(只能在不是类别时才认)。
|
|
218
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"writer": {Engine: "claude"}}}, Profile{}, testRoles)
|
|
219
|
+
if err != nil {
|
|
220
|
+
t.Fatalf("unexpected err: %v", err)
|
|
221
|
+
}
|
|
222
|
+
if !strings.Contains(strings.Join(args, " "), "--team-route role:writer=claude") {
|
|
223
|
+
t.Fatalf("裸角色名没归一成 role: 前缀:%v", args)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func TestTeamFlagsRejectsUnknownRole(t *testing.T) {
|
|
228
|
+
_, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"role:nobody": {Engine: "claude"}}},
|
|
229
|
+
Profile{}, testRoles)
|
|
230
|
+
if err == nil || !strings.Contains(err.Error(), "未知角色") {
|
|
231
|
+
t.Fatalf("err = %v, want 「未知角色」", err)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func TestTeamFlagsRoleWithoutCatalogAcceptsAnyName(t *testing.T) {
|
|
236
|
+
// 老 core 没有 teamRoles 清单:插件只做格式校验,名字交给 core 裁决
|
|
237
|
+
// (否则升级插件就得同步升 core)。
|
|
238
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"role:whatever": {Engine: "claude"}}},
|
|
239
|
+
Profile{}, nil)
|
|
240
|
+
if err != nil {
|
|
241
|
+
t.Fatalf("unexpected err: %v", err)
|
|
242
|
+
}
|
|
243
|
+
if !strings.Contains(strings.Join(args, " "), "--team-route role:whatever=claude") {
|
|
244
|
+
t.Fatalf("role 路由没下发:%v", args)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func TestTeamFlagsRejectsEmptyRoleName(t *testing.T) {
|
|
249
|
+
_, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"role:": {Engine: "claude"}}},
|
|
250
|
+
Profile{}, testRoles)
|
|
251
|
+
if err == nil || !strings.Contains(err.Error(), "空角色键") {
|
|
252
|
+
t.Fatalf("err = %v, want 「空角色键」", err)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
// bootstrap.go - 首次「初始化」:按工作区目录反推一版需求基线。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要它(2026-10-22 用户):
|
|
6
|
+
//
|
|
7
|
+
// 「整理需要判断是否有初始化过;没有就按空间目录重新生成需求,否则只处理上次
|
|
8
|
+
// 整理好的新会话。」
|
|
9
|
+
//
|
|
10
|
+
// 业务项目(magic-test desktop/requirements.cjs 的 bootstrapWorkspace)已经验证过这条路:
|
|
11
|
+
// 新建工作区时用**带工具**的 agent 扫目录、读代码,把「已经实现了什么」反推成功能点基线;
|
|
12
|
+
// 之后的对话只做增量补充。本包把它收进 core,但**触发时机不同**:
|
|
13
|
+
//
|
|
14
|
+
// - magic-test 在工作区创建时后台自动跑;
|
|
15
|
+
// - 这里工作区是「会话/目录」隐式形成的,没有创建事件,所以由用户点「整理」时判断:
|
|
16
|
+
// state.BootstrapAt == 0 → 跑一次目录基线;否则只消费上次游标之后的新会话。
|
|
17
|
+
// 自动归纳(防抖 / 每日兜底)不会触发初始化 —— 它可能每轮都跑,不能悄悄花几分钟读代码。
|
|
18
|
+
//
|
|
19
|
+
// 两个必须坚持的口径:
|
|
20
|
+
//
|
|
21
|
+
// ① 反推必须**读代码** → 只能用带工具、且能指定工作目录的引擎。归纳默认的 llm 引擎
|
|
22
|
+
// 没有文件系统语义,拿它反推只会得到一段凭空编的功能清单。
|
|
23
|
+
// ② 产出是**需求**(用户视角),不是技术说明。prompt 里的强制规则与 magic-test 同一套:
|
|
24
|
+
// 不写语言/框架/库/API/参数/文件格式,判据是「用户能不能感知到」。
|
|
25
|
+
|
|
26
|
+
import (
|
|
27
|
+
"context"
|
|
28
|
+
"fmt"
|
|
29
|
+
"os"
|
|
30
|
+
"path/filepath"
|
|
31
|
+
"sort"
|
|
32
|
+
"strings"
|
|
33
|
+
"time"
|
|
34
|
+
|
|
35
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// DefaultBootstrapTimeout 首次初始化(带工具的 agent 读目录)的单次上限。
|
|
39
|
+
//
|
|
40
|
+
// 与归纳的 240s 分开:读代码 + 组织 JSON 是分钟级长任务(magic-test 实测几分钟)。
|
|
41
|
+
// 客户端手动整理的总超时是 10 分钟,这里给 9 分钟,留出进程收尾余量。
|
|
42
|
+
const DefaultBootstrapTimeout = 9 * time.Minute
|
|
43
|
+
|
|
44
|
+
// 目录扫描的上限(与 magic-test 同一口径:给树 + 清单当起点,其余让 agent 自己按需读)。
|
|
45
|
+
const (
|
|
46
|
+
scanMaxEntries = 400
|
|
47
|
+
scanMaxDepth = 3
|
|
48
|
+
scanDirMaxChildren = 14
|
|
49
|
+
scanTreeMax = 12000
|
|
50
|
+
scanManifestMax = 4000
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
var scanSkipDirs = map[string]bool{
|
|
54
|
+
"node_modules": true, ".git": true, "dist": true, "build": true, "out": true,
|
|
55
|
+
".next": true, ".nuxt": true, "target": true, "vendor": true, "coverage": true,
|
|
56
|
+
".cache": true, "__pycache__": true, ".venv": true, "venv": true, "env": true,
|
|
57
|
+
".idea": true, ".vscode": true, "tmp": true, "Pods": true, ".gradle": true,
|
|
58
|
+
".tox": true, "playwright-report": true, ".trae-cn": true,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var scanSkipExt = map[string]bool{
|
|
62
|
+
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".bmp": true,
|
|
63
|
+
".ico": true, ".icns": true, ".svg": true, ".mp4": true, ".mov": true, ".avi": true,
|
|
64
|
+
".mkv": true, ".webm": true, ".mp3": true, ".wav": true, ".m4a": true, ".zip": true,
|
|
65
|
+
".tar": true, ".gz": true, ".7z": true, ".rar": true, ".pdf": true, ".db": true,
|
|
66
|
+
".sqlite": true, ".woff": true, ".woff2": true, ".ttf": true, ".otf": true,
|
|
67
|
+
".eot": true, ".lock": true, ".log": true, ".map": true, ".pyc": true,
|
|
68
|
+
".class": true, ".jar": true, ".dmg": true, ".exe": true,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
var scanManifests = []string{
|
|
72
|
+
"README.md", "readme.md", "README.MD", "package.json", "pyproject.toml",
|
|
73
|
+
"requirements.txt", "Cargo.toml", "go.mod", "composer.json", "Makefile", "index.html",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// projectScan 有界扫描结果(直接渲染进 prompt)。
|
|
77
|
+
type projectScan struct {
|
|
78
|
+
Tree string
|
|
79
|
+
Manifests []scanManifest
|
|
80
|
+
FileCount int
|
|
81
|
+
Truncated bool
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type scanManifest struct {
|
|
85
|
+
Name string
|
|
86
|
+
Content string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// scanProject 扫目录:只列源码/文档,跳过依赖与构建目录;每一项目录最多列 14 项。
|
|
90
|
+
func scanProject(dir string) (projectScan, error) {
|
|
91
|
+
st, err := os.Stat(dir)
|
|
92
|
+
if err != nil {
|
|
93
|
+
return projectScan{}, fmt.Errorf("目录不存在或不可读: %w", err)
|
|
94
|
+
}
|
|
95
|
+
if !st.IsDir() {
|
|
96
|
+
return projectScan{}, fmt.Errorf("不是目录: %s", dir)
|
|
97
|
+
}
|
|
98
|
+
scan := projectScan{}
|
|
99
|
+
lines := make([]string, 0, scanMaxEntries)
|
|
100
|
+
n := 0
|
|
101
|
+
var walk func(d string, depth int, prefix string)
|
|
102
|
+
walk = func(d string, depth int, prefix string) {
|
|
103
|
+
if n >= scanMaxEntries || depth > scanMaxDepth {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
entries, err := os.ReadDir(d)
|
|
107
|
+
if err != nil {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
// 目录在前、同类按名字排序(与 magic-test 一致:先看布局,再看文件)。
|
|
111
|
+
sort.Slice(entries, func(i, j int) bool {
|
|
112
|
+
di, dj := entries[i].IsDir(), entries[j].IsDir()
|
|
113
|
+
if di != dj {
|
|
114
|
+
return di
|
|
115
|
+
}
|
|
116
|
+
return entries[i].Name() < entries[j].Name()
|
|
117
|
+
})
|
|
118
|
+
shown := 0
|
|
119
|
+
for _, e := range entries {
|
|
120
|
+
if n >= scanMaxEntries {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
name := e.Name()
|
|
124
|
+
if strings.HasPrefix(name, ".") {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if e.IsDir() && scanSkipDirs[name] {
|
|
128
|
+
lines = append(lines, prefix+name+"/ …")
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if !e.IsDir() && scanSkipExt[strings.ToLower(filepath.Ext(name))] {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if shown >= scanDirMaxChildren {
|
|
135
|
+
lines = append(lines, prefix+"…(本目录另有若干项,已省略)")
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
shown++
|
|
139
|
+
n++
|
|
140
|
+
if e.IsDir() {
|
|
141
|
+
lines = append(lines, prefix+name+"/")
|
|
142
|
+
walk(filepath.Join(d, name), depth+1, prefix+" ")
|
|
143
|
+
} else {
|
|
144
|
+
lines = append(lines, prefix+name)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
walk(dir, 1, "")
|
|
149
|
+
scan.FileCount = n
|
|
150
|
+
scan.Truncated = n >= scanMaxEntries
|
|
151
|
+
tree := strings.Join(lines, "\n")
|
|
152
|
+
if len([]rune(tree)) > scanTreeMax {
|
|
153
|
+
tree = string([]rune(tree)[:scanTreeMax]) + "\n…(目录树已截断)"
|
|
154
|
+
}
|
|
155
|
+
scan.Tree = tree
|
|
156
|
+
|
|
157
|
+
for _, name := range scanManifests {
|
|
158
|
+
p := filepath.Join(dir, name)
|
|
159
|
+
st, err := os.Stat(p)
|
|
160
|
+
if err != nil || !st.Mode().IsRegular() || st.Size() > 200_000 {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
b, err := os.ReadFile(p)
|
|
164
|
+
if err != nil {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
scan.Manifests = append(scan.Manifests, scanManifest{
|
|
168
|
+
Name: name,
|
|
169
|
+
Content: truncRunes(string(b), scanManifestMax),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
return scan, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// bootstrapResult 一轮初始化(目录反推)的结果。
|
|
176
|
+
type bootstrapResult struct {
|
|
177
|
+
// Empty 没有可用的目录基线 —— 目录为空,或模型明确返回 features:[]。
|
|
178
|
+
// 不是错误:调用方应回落到会话增量,并把初始化标记为完成(否则每次点击都重扫一遍)。
|
|
179
|
+
Empty bool
|
|
180
|
+
// Called 是否真的调用了模型(目录为空时不调)。
|
|
181
|
+
Called bool
|
|
182
|
+
// Engine 实际用来读目录的引擎。
|
|
183
|
+
Engine string
|
|
184
|
+
// Scanned 扫描到的条目数(排障用)。
|
|
185
|
+
Scanned int
|
|
186
|
+
Updated int
|
|
187
|
+
Created int
|
|
188
|
+
Points int
|
|
189
|
+
Conclusions int
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// bootstrapWorkspace 按工作区目录反推一版需求基线。
|
|
193
|
+
//
|
|
194
|
+
// 目录为空时返回 Empty=true(不调模型);有内容但引擎不可用/输出不可解析时返回错误,
|
|
195
|
+
// 让调用方把原因写进 state.LastError,下一次点「整理」还能重试。
|
|
196
|
+
func bootstrapWorkspace(ctx context.Context, ws workspaceEntry, opts Options) (bootstrapResult, error) {
|
|
197
|
+
res := bootstrapResult{}
|
|
198
|
+
if ws.Dir == "" {
|
|
199
|
+
res.Empty = true
|
|
200
|
+
return res, nil
|
|
201
|
+
}
|
|
202
|
+
scan, err := scanProject(ws.Dir)
|
|
203
|
+
if err != nil {
|
|
204
|
+
return res, err
|
|
205
|
+
}
|
|
206
|
+
res.Scanned = scan.FileCount
|
|
207
|
+
if scan.FileCount == 0 {
|
|
208
|
+
res.Empty = true
|
|
209
|
+
return res, nil
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
engineName, err := bootstrapEnginePicker(opts.Engine)
|
|
213
|
+
if err != nil {
|
|
214
|
+
return res, err
|
|
215
|
+
}
|
|
216
|
+
res.Engine = engineName
|
|
217
|
+
|
|
218
|
+
existing := make([]string, 0, 8)
|
|
219
|
+
for _, f := range listFeatureFiles(ws.ID) {
|
|
220
|
+
existing = append(existing, f.Name)
|
|
221
|
+
}
|
|
222
|
+
prompt := buildBootstrapPrompt(ws.Name, scan, existing)
|
|
223
|
+
timeout := bootstrapTimeout(opts.Timeout)
|
|
224
|
+
res.Called = true
|
|
225
|
+
/* 显式默认引擎时,模型也跟着配置走(claude + 它自己的模型是合法组合)。
|
|
226
|
+
自动挑引擎时不能用 opts.Model:那是给另一个(通常是 llm)引擎的模型名,
|
|
227
|
+
传给 claude / codebuddy 只会报「模型不存在」。 */
|
|
228
|
+
bootstrapModel := ""
|
|
229
|
+
if strings.TrimSpace(opts.Engine) != "" {
|
|
230
|
+
bootstrapModel = opts.Model
|
|
231
|
+
}
|
|
232
|
+
text, err := runBootstrapAgent(ctx, engineName, bootstrapModel, ws.Dir, prompt, timeout)
|
|
233
|
+
if err != nil {
|
|
234
|
+
return res, fmt.Errorf("初始化引擎 %s 失败: %w", engineName, err)
|
|
235
|
+
}
|
|
236
|
+
outs, err := parseFeatures(text)
|
|
237
|
+
if err != nil {
|
|
238
|
+
head := collapseSpace(text)
|
|
239
|
+
if len([]rune(head)) > 200 {
|
|
240
|
+
head = string([]rune(head)[:200])
|
|
241
|
+
}
|
|
242
|
+
return res, fmt.Errorf("初始化输出不可解析: %w|开头:%s", err, head)
|
|
243
|
+
}
|
|
244
|
+
if len(outs) == 0 {
|
|
245
|
+
/* 目录里没有可识别的产品能力(只有构建脚本 / 依赖 / 占位文件)也是合法结果:
|
|
246
|
+
标记初始化完成并回落到会话增量。把它当失败会让用户每点一次「整理」就白扫一遍目录。 */
|
|
247
|
+
res.Empty = true
|
|
248
|
+
return res, nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
merger := newDocMerger(ws.ID)
|
|
252
|
+
baseLine := "[初始化] 空间目录基线 · " + todayStr() + "(引擎 " + engineName + ")"
|
|
253
|
+
for _, o := range outs {
|
|
254
|
+
w := writeFeatureItem(ws.ID, o, &writeCtx{
|
|
255
|
+
merger: merger,
|
|
256
|
+
alias: map[string]string{},
|
|
257
|
+
kind: "初始化",
|
|
258
|
+
sourcesOf: func(featureOut) []string {
|
|
259
|
+
return []string{baseLine}
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
if !w.ok {
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
res.Points += w.points
|
|
266
|
+
res.Conclusions += w.conclusions
|
|
267
|
+
if w.created {
|
|
268
|
+
res.Created++
|
|
269
|
+
} else {
|
|
270
|
+
res.Updated++
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if res.Created+res.Updated == 0 {
|
|
274
|
+
/* 模型给了 features 但全是空壳(只有名字、没有描述/要点/结论)→ 没有基线。
|
|
275
|
+
与 features:[] 同样处理:标记初始化完成,回落到会话增量,不要把旧会话吞掉。 */
|
|
276
|
+
res.Empty = true
|
|
277
|
+
}
|
|
278
|
+
return res, nil
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// bootstrapTimeout 初始化单次调用上限。
|
|
282
|
+
//
|
|
283
|
+
// 归纳默认 240s 对「读代码 + 组织输出」太短;但调用方显式设了别的值就尊重它
|
|
284
|
+
// (CLI 的 --requirement-timeout 同时作用于两者,文档里写清楚)。
|
|
285
|
+
func bootstrapTimeout(t time.Duration) time.Duration {
|
|
286
|
+
if t <= 0 || t == DefaultTimeout {
|
|
287
|
+
return DefaultBootstrapTimeout
|
|
288
|
+
}
|
|
289
|
+
return t
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// bootstrapEnginePicker 是 pickBootstrapEngine 的可替换入口(单测不依赖本机装了哪个 CLI)。
|
|
293
|
+
var bootstrapEnginePicker = pickBootstrapEngine
|
|
294
|
+
|
|
295
|
+
// pickBootstrapEngine 选首次初始化用哪个引擎。
|
|
296
|
+
//
|
|
297
|
+
// 口径(2026-10-22 用户):「整理的引擎就是配置的默认引擎」。
|
|
298
|
+
// - 调用方显式给了引擎(移动端 / PC 的默认引擎)→ **就用它**,不再偷偷换成别的;
|
|
299
|
+
// 它不能读目录(llm / arkclaw / openclaw / codebuddy-gateway)或本机不可用,直接报错,
|
|
300
|
+
// 让用户知道该去设置里换一个,而不是拿另一个引擎的结果冒充「默认引擎」。
|
|
301
|
+
// - 调用方没给(「自动(首个可用)」/ 直接跑 CLI)→ 按 claude → codebuddy →
|
|
302
|
+
// codebuddy-ai → codex → trae → dsh 找第一个能读目录且本机可用的。
|
|
303
|
+
func pickBootstrapEngine(preferred string) (string, error) {
|
|
304
|
+
preferred = strings.TrimSpace(preferred)
|
|
305
|
+
if preferred != "" {
|
|
306
|
+
if !bootstrapCapable(preferred) {
|
|
307
|
+
return "", fmt.Errorf("默认引擎 %s 不能读目录,无法按空间目录初始化;请在设置里换一个能读代码的引擎(如 claude / codebuddy / codex / dsh)", preferred)
|
|
308
|
+
}
|
|
309
|
+
if !engineAvailable(preferred) {
|
|
310
|
+
return "", fmt.Errorf("默认引擎 %s 本机不可用,无法按空间目录初始化;请先安装它,或在设置里换一个可用引擎", preferred)
|
|
311
|
+
}
|
|
312
|
+
return preferred, nil
|
|
313
|
+
}
|
|
314
|
+
for _, name := range []string{"claude", "codebuddy", "codebuddy-ai", "codex", "trae", "dsh"} {
|
|
315
|
+
if bootstrapCapable(name) && engineAvailable(name) {
|
|
316
|
+
return name, nil
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return "", fmt.Errorf("没有可用于首次初始化的引擎:需要能读目录的引擎(如 claude / codebuddy / codex / dsh),当前都不可用")
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// bootstrapCapable 引擎能不能在指定目录里读文件。
|
|
323
|
+
func bootstrapCapable(name string) bool {
|
|
324
|
+
if strings.TrimSpace(name) == "" {
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
return agent.WorkspaceSupportOf(name) != "none"
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func engineAvailable(name string) bool {
|
|
331
|
+
e := agent.Lookup(name)
|
|
332
|
+
if e == nil {
|
|
333
|
+
return false
|
|
334
|
+
}
|
|
335
|
+
ok, _ := e.Detect()
|
|
336
|
+
return ok
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// runBootstrapAgent 默认的初始化调用器(单测可替换)。
|
|
340
|
+
//
|
|
341
|
+
// 与归纳的 llm 调用刻意分开:这里是**带工具的 agent**,工作目录必须是目标目录;
|
|
342
|
+
// 权限档位用 auto(沙箱内读代码),只有引擎支持四档权限时才下发 —— 不支持的下发
|
|
343
|
+
// 会被 CLI 以 exit 2 打回,整轮白跑。
|
|
344
|
+
var runBootstrapAgent = func(ctx context.Context, engineName, model, dir, prompt string, timeout time.Duration) (string, error) {
|
|
345
|
+
engine := agent.Lookup(engineName)
|
|
346
|
+
if engine == nil {
|
|
347
|
+
return "", fmt.Errorf("未知引擎: %s", engineName)
|
|
348
|
+
}
|
|
349
|
+
req := agent.Request{
|
|
350
|
+
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
351
|
+
Tools: agent.ToolsOn,
|
|
352
|
+
Model: model,
|
|
353
|
+
Workspace: dir,
|
|
354
|
+
JSONSchema: outSchema,
|
|
355
|
+
Timeout: timeout,
|
|
356
|
+
}
|
|
357
|
+
if agent.PermissionSupportOf(engineName) != "none" {
|
|
358
|
+
req.Permission = agent.PermissionAuto
|
|
359
|
+
}
|
|
360
|
+
runner := &agent.Runner{Engine: engine, Timeout: timeout}
|
|
361
|
+
resp, err := runner.Run(ctx, req)
|
|
362
|
+
if err != nil {
|
|
363
|
+
return "", err
|
|
364
|
+
}
|
|
365
|
+
return strings.TrimSpace(resp.Text), nil
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// buildBootstrapPrompt 组「按目录反推需求」的 prompt。
|
|
369
|
+
//
|
|
370
|
+
// 口径(用户视角 / 只写代码里真实存在的东西 / 有节制地读文件 / 只输出 JSON)与
|
|
371
|
+
// magic-test 一致;这里把规则压短,省下的上下文留给目录树与源码。
|
|
372
|
+
func buildBootstrapPrompt(wsName string, scan projectScan, existing []string) string {
|
|
373
|
+
var manif strings.Builder
|
|
374
|
+
for i, m := range scan.Manifests {
|
|
375
|
+
if i > 0 {
|
|
376
|
+
manif.WriteString("\n\n")
|
|
377
|
+
}
|
|
378
|
+
manif.WriteString("--- " + m.Name + " ---\n" + m.Content)
|
|
379
|
+
}
|
|
380
|
+
if manif.Len() == 0 {
|
|
381
|
+
manif.WriteString("(无)")
|
|
382
|
+
}
|
|
383
|
+
tree := scan.Tree
|
|
384
|
+
if strings.TrimSpace(tree) == "" {
|
|
385
|
+
tree = "(空)"
|
|
386
|
+
}
|
|
387
|
+
var ex strings.Builder
|
|
388
|
+
if len(existing) > 0 {
|
|
389
|
+
ex.WriteString("== 已有功能点(逐字沿用 name,不要重复建)==\n")
|
|
390
|
+
ex.WriteString(strings.Join(existing, "、"))
|
|
391
|
+
ex.WriteString("\n\n")
|
|
392
|
+
}
|
|
393
|
+
return "你是资深产品经理。现在接手一个**已经写好代码**的项目目录(工作区:" + wsName + ")。\n" +
|
|
394
|
+
"任务:**反推**它已经实现的功能点,为「功能点需求文档」建立**基线**。\n" +
|
|
395
|
+
"⚠️ 产出的是**需求**(用户视角),不是**技术说明**(实现视角)—— 读代码是为了知道「用户能做什么」,\n" +
|
|
396
|
+
"然后把代码事实翻译成用户语言。\n" +
|
|
397
|
+
"⚠️ **输出只有一样东西**:最后把 JSON 直接写在回复正文里。不要写文件,不要加解释,不要代码围栏。\n\n" +
|
|
398
|
+
"== 目录结构(有界扫描,已跳过依赖 / 构建目录)==\n" + tree + "\n\n" +
|
|
399
|
+
"== 关键文件(清单 / 说明,已截断)==\n" + manif.String() + "\n\n" +
|
|
400
|
+
ex.String() +
|
|
401
|
+
"== 你要做的 ==\n" +
|
|
402
|
+
"1. 用工具继续读这个目录里的关键源码(入口文件、路由/菜单定义、主要模块、README),\n" +
|
|
403
|
+
" 把「用户实际能做什么」搞清楚;不要只看文件名猜。\n" +
|
|
404
|
+
" 探索要有节制:读 10 个左右最关键的源码文件就够了,不要试图读完全部代码。\n" +
|
|
405
|
+
"2. 按「大功能点(module)→ 功能点(name)」两级归纳已经实现的能力:\n" +
|
|
406
|
+
" · module:产品模块名(如「设置」「需求管理」「对话交互」),≤" + itoa(ModuleMaxChars) + " 个汉字;\n" +
|
|
407
|
+
" · name:这个功能点在做什么,≤" + itoa(TitleMaxChars) + " 个汉字;\n" +
|
|
408
|
+
" · desc:一句话说明**用户拿它做什么**;\n" +
|
|
409
|
+
" · points:用户能做什么 / 看到什么,每条 ≤30 字;\n" +
|
|
410
|
+
" · conclusions:用户可感知的取舍或约束(默认值、可选范围、明确不做的),每条 ≤30 字;\n" +
|
|
411
|
+
" 确认不了就给空数组 [],不要猜。\n" +
|
|
412
|
+
"3. 强制规则(违反即不合格):\n" +
|
|
413
|
+
" · 不写实现方式:编程语言 / 框架 / 库 / API / 命令行参数 / 配置文件 / 文件格式 /\n" +
|
|
414
|
+
" 存储与传输方式 / 数据表与字段名 / 协议 / 类名函数名,一个都不许出现;\n" +
|
|
415
|
+
" · 主语是用户,动词是用户动作(看到 / 点击 / 选择 / 收到 / 切换 / 导入 / 导出 / 记住…);\n" +
|
|
416
|
+
" 判据只有一条:**用户能不能感知到**;\n" +
|
|
417
|
+
" · 素材里本来有技术措辞时,改写成用户语言再写进来;\n" +
|
|
418
|
+
" · 输出前逐条自检:读一遍,问「用户能看到 / 做到这件事吗?」,答不上来的删掉。\n" +
|
|
419
|
+
"4. 只写代码里**真实存在**的东西:不要写「建议」「应该」「待实现」,不要写日期。\n" +
|
|
420
|
+
"5. 数量控制在 5-20 个:只保留真正的产品能力;目录结构、依赖、构建脚本、配置文件都不是功能点。\n" +
|
|
421
|
+
"6. 最后只输出 JSON(不要代码围栏):\n" +
|
|
422
|
+
`{"features":[{"module":"…","name":"…","desc":"…","points":["…"],"conclusions":["…"]}]}`
|
|
423
|
+
}
|