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.
Files changed (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2168 -143
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -0,0 +1,473 @@
1
+ package requirements
2
+
3
+ // doc.go - 功能点文档的**骨架**:解析 / 合并 / 渲染。
4
+ //
5
+ // 骨架由代码维护,模型只给**增量**(业务项目 2026-09-19 改造的结论,这里原样继承):
6
+ //
7
+ // · 让模型输出整篇 markdown 时,文档会随每轮归纳被重写,越长越容易丢尾巴
8
+ // (喂进去的旧文档还得截断),「来源对话」也会退化成模型摘抄的当轮一条;
9
+ // · 把骨架与证据交给代码之后,模型只负责它擅长的三件事:要点化(desc / points)、
10
+ // 结论提炼(conclusions)、来源引用(sources 用记忆编号),日期与段落顺序都由代码定。
11
+ //
12
+ // 六个段落与顺序(业务项目定稿):最终版置于顶部 —— 读者先看「这个功能点最终要做什么」,
13
+ // 往下才是逐轮累积的证据。空段写「(暂无)」占位,不留半截文件;但**渲染出口会再滤一次
14
+ // 占位符**(模型会把看到的「(暂无)」当内容回吐,实测踩过)。
15
+
16
+ import (
17
+ "os"
18
+ "path/filepath"
19
+ "sort"
20
+ "strings"
21
+ "unicode"
22
+ )
23
+
24
+ // docSections 骨架的六个段落名(顺序即 renderDoc 的写盘顺序)。
25
+ var docSections = []string{"最终版", "需求描述", "需求点", "已定结论", "来源对话", "更新记录"}
26
+
27
+ // Doc 一篇功能点文档的解析结果。
28
+ type Doc struct {
29
+ // Final 「最终版」整段(多行 markdown,原样保留 —— 里面可能有 **目标** 这类小标题,
30
+ // 按行拆成数组会把小标题当成一条要点)。
31
+ Final string
32
+ Desc string
33
+ Points []string
34
+ Conclusions []string
35
+ Sources []string
36
+ Log []string
37
+ }
38
+
39
+ // isPlaceholder 占位符判定:整条就等于占位符的一律丢弃。
40
+ //
41
+ // 为什么要它:空段写「(暂无)」是为了文档不留半截,但模型会把看到的「(暂无)」
42
+ // 当成一条内容回吐(于是文档里出现 `- (暂无)`)。
43
+ func isPlaceholder(s string) bool {
44
+ t := strings.TrimSpace(s)
45
+ t = strings.Trim(t, "()()【】[] ")
46
+ switch strings.ToLower(t) {
47
+ case "", "暂无", "无", "none", "n/a", "na", "待补充", "待定", "待确认", "暂无内容", "待生成":
48
+ return true
49
+ }
50
+ return false
51
+ }
52
+
53
+ // ParseDoc 解析已有文档 → Doc。只认我们自己写的骨架(## 段名),认不出的段落忽略
54
+ // (不猜、不迁移 —— 猜错会把别人的内容搅乱)。
55
+ func ParseDoc(text string) Doc {
56
+ buf := make(map[string][]string, len(docSections))
57
+ cur := ""
58
+ for _, raw := range strings.Split(text, "\n") {
59
+ line := strings.TrimSpace(raw)
60
+ if strings.HasPrefix(line, "## ") {
61
+ name := strings.TrimSpace(strings.TrimPrefix(line, "## "))
62
+ if contains(docSections, name) {
63
+ cur = name
64
+ } else {
65
+ cur = ""
66
+ }
67
+ continue
68
+ }
69
+ if strings.HasPrefix(line, "# ") {
70
+ cur = "" // H1 标题不属任何段
71
+ continue
72
+ }
73
+ if cur == "" || line == "" {
74
+ continue
75
+ }
76
+ buf[cur] = append(buf[cur], line)
77
+ }
78
+ lines := func(k string) []string {
79
+ out := make([]string, 0, len(buf[k]))
80
+ for _, l := range buf[k] {
81
+ v := stripListMarker(l)
82
+ if v == "" || isPlaceholder(v) {
83
+ continue
84
+ }
85
+ out = append(out, v)
86
+ }
87
+ return out
88
+ }
89
+ final := strings.TrimSpace(strings.Join(buf["最终版"], "\n"))
90
+ if isPlaceholder(final) {
91
+ final = ""
92
+ }
93
+ return Doc{
94
+ Final: final,
95
+ Desc: strings.TrimSpace(strings.Join(buf["需求描述"], " ")),
96
+ Points: lines("需求点"),
97
+ Conclusions: lines("已定结论"),
98
+ Sources: lines("来源对话"),
99
+ Log: lines("更新记录"),
100
+ }
101
+ }
102
+
103
+ // stripListMarker 去掉行首的列表标记(`1. ` / `- ` / `* ` / `1、`)。
104
+ func stripListMarker(l string) string {
105
+ s := strings.TrimSpace(l)
106
+ if s == "" {
107
+ return ""
108
+ }
109
+ if strings.HasPrefix(s, "- ") || strings.HasPrefix(s, "* ") || strings.HasPrefix(s, "+ ") {
110
+ return strings.TrimSpace(s[2:])
111
+ }
112
+ i := 0
113
+ for i < len(s) && s[i] >= '0' && s[i] <= '9' {
114
+ i++
115
+ }
116
+ if i > 0 && i < len(s) {
117
+ rest := s[i:]
118
+ for _, p := range []string{". ", "、", ") ", ")"} {
119
+ if strings.HasPrefix(rest, p) {
120
+ return strings.TrimSpace(rest[len(p):])
121
+ }
122
+ }
123
+ }
124
+ return s
125
+ }
126
+
127
+ // RenderDoc 渲染功能点文档(六段固定;空段写「(暂无)」,不留半截文件)。
128
+ //
129
+ // 渲染前再滤一次占位符:这是**唯一的落盘出口**,把过滤收在这里,任何上游
130
+ // (模型回吐 / 历史残留 / 解析)漏进来的「(暂无)」都写不出去。
131
+ func RenderDoc(name string, d Doc) string {
132
+ clean := func(a []string) []string {
133
+ out := make([]string, 0, len(a))
134
+ for _, x := range a {
135
+ v := strings.TrimSpace(x)
136
+ if v == "" || isPlaceholder(v) {
137
+ continue
138
+ }
139
+ out = append(out, v)
140
+ }
141
+ return out
142
+ }
143
+ P, C, S, G := clean(d.Points), clean(d.Conclusions), clean(d.Sources), clean(d.Log)
144
+ final := strings.TrimSpace(d.Final)
145
+ if final == "" {
146
+ final = "(待生成)"
147
+ }
148
+ desc := strings.TrimSpace(d.Desc)
149
+ if desc == "" || isPlaceholder(desc) {
150
+ desc = "(待补充)"
151
+ }
152
+ var b strings.Builder
153
+ b.WriteString("# " + name + "\n\n")
154
+ b.WriteString("## 最终版\n" + final + "\n\n")
155
+ b.WriteString("## 需求描述\n" + desc + "\n\n")
156
+ b.WriteString("## 需求点\n")
157
+ if len(P) == 0 {
158
+ b.WriteString("(暂无)\n")
159
+ } else {
160
+ for i, p := range P {
161
+ b.WriteString(itoa(i+1) + ". " + p + "\n")
162
+ }
163
+ }
164
+ b.WriteString("\n## 已定结论\n")
165
+ if len(C) == 0 {
166
+ b.WriteString("(暂无)\n")
167
+ } else {
168
+ for _, c := range C {
169
+ b.WriteString("- " + c + "\n")
170
+ }
171
+ }
172
+ b.WriteString("\n## 来源对话\n")
173
+ if len(S) == 0 {
174
+ b.WriteString("(暂无)\n")
175
+ } else {
176
+ for _, s := range S {
177
+ b.WriteString("- " + s + "\n")
178
+ }
179
+ }
180
+ b.WriteString("\n## 更新记录\n")
181
+ if len(G) == 0 {
182
+ b.WriteString("(暂无)\n")
183
+ } else {
184
+ for _, g := range G {
185
+ b.WriteString("- " + g + "\n")
186
+ }
187
+ }
188
+ return b.String()
189
+ }
190
+
191
+ // mergeUnique 去重追加。
192
+ //
193
+ // keepHead=true 保留最早的(需求点 / 结论:清单要稳定);false 保留最新的(来源 / 流水)。
194
+ // keyOf 可自定义判重键 ——「来源对话」用它:同一句原话在同义改写后按整行判重会留下两条。
195
+ func mergeUnique(prev, add []string, cap int, keepHead bool, keyOf func(string) string) []string {
196
+ key := keyOf
197
+ if key == nil {
198
+ key = func(s string) string { return strings.Join(strings.Fields(s), "") }
199
+ }
200
+ seen := make(map[string]bool, len(prev))
201
+ out := make([]string, 0, len(prev)+len(add))
202
+ for _, p := range prev {
203
+ k := key(p)
204
+ if k == "" || seen[k] {
205
+ continue
206
+ }
207
+ seen[k] = true
208
+ out = append(out, p)
209
+ }
210
+ for _, a := range add {
211
+ v := strings.TrimSpace(a)
212
+ k := key(v)
213
+ if k == "" || seen[k] {
214
+ continue
215
+ }
216
+ seen[k] = true
217
+ out = append(out, v)
218
+ }
219
+ if cap > 0 && len(out) > cap {
220
+ if keepHead {
221
+ out = out[:cap]
222
+ } else {
223
+ out = out[len(out)-cap:]
224
+ }
225
+ }
226
+ return out
227
+ }
228
+
229
+ // sourceKey 「来源对话」行的判重键:剥掉时间戳 / #编号 / (任务:…)前缀,只留原话本体。
230
+ func sourceKey(s string) string {
231
+ v := strings.TrimSpace(s)
232
+ if strings.HasPrefix(v, "[") {
233
+ if i := strings.Index(v, "]"); i > 0 {
234
+ v = strings.TrimSpace(v[i+1:])
235
+ }
236
+ }
237
+ if strings.HasPrefix(v, "#") {
238
+ i := 1
239
+ for i < len(v) && v[i] >= '0' && v[i] <= '9' {
240
+ i++
241
+ }
242
+ v = strings.TrimSpace(v[i:])
243
+ }
244
+ if strings.HasPrefix(v, "(") || strings.HasPrefix(v, "(") {
245
+ if i := strings.IndexAny(v, "))"); i > 0 {
246
+ v = strings.TrimSpace(v[i+len(")"):])
247
+ }
248
+ }
249
+ return strings.Join(strings.Fields(v), "")
250
+ }
251
+
252
+ // SlugifyFeature 功能点名称 → 安全文件名:保留中文 / 字母 / 数字,压掉非法与空白,限长。
253
+ //
254
+ // 空白与「被删掉的非法字符」两侧会各留一个 '-'(`登录 / 注册` → `登录--注册`),
255
+ // 连续横线压成一个 —— 文件名是用户要在访达里看的东西,别让它长出一串杠。
256
+ func SlugifyFeature(name string) string {
257
+ var b strings.Builder
258
+ for _, r := range strings.TrimSpace(name) {
259
+ switch {
260
+ case strings.ContainsRune(`\/:*?"<>|`, r):
261
+ if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") {
262
+ b.WriteRune('-')
263
+ }
264
+ case unicode.IsSpace(r):
265
+ if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") {
266
+ b.WriteRune('-')
267
+ }
268
+ default:
269
+ b.WriteRune(r)
270
+ }
271
+ }
272
+ s := strings.Trim(b.String(), "-")
273
+ if s == "" {
274
+ return "untitled"
275
+ }
276
+ return truncRunes(s, 30)
277
+ }
278
+
279
+ // SlugifyModule 模块名 → 安全目录名(限 ModuleMaxChars 个字符)。
280
+ func SlugifyModule(m string) string {
281
+ var b strings.Builder
282
+ for _, r := range strings.TrimSpace(m) {
283
+ switch {
284
+ case strings.ContainsRune(`\/:*?"<>|`, r):
285
+ continue
286
+ case unicode.IsSpace(r):
287
+ continue
288
+ default:
289
+ b.WriteRune(r)
290
+ }
291
+ }
292
+ s := strings.Trim(b.String(), ".-")
293
+ return truncRunes(s, ModuleMaxChars)
294
+ }
295
+
296
+ // truncRunes 按字符(不是字节)截断,避免把中文切成半个字。
297
+ func truncRunes(s string, n int) string {
298
+ r := []rune(s)
299
+ if len(r) <= n {
300
+ return s
301
+ }
302
+ return string(r[:n])
303
+ }
304
+
305
+ func itoa(n int) string {
306
+ if n == 0 {
307
+ return "0"
308
+ }
309
+ var buf [8]byte
310
+ i := len(buf)
311
+ for n > 0 {
312
+ i--
313
+ buf[i] = byte('0' + n%10)
314
+ n /= 10
315
+ }
316
+ return string(buf[i:])
317
+ }
318
+
319
+ func contains(list []string, s string) bool {
320
+ for _, x := range list {
321
+ if x == s {
322
+ return true
323
+ }
324
+ }
325
+ return false
326
+ }
327
+
328
+ // featureFile 一个功能点文档的定位信息。
329
+ type featureFile struct {
330
+ Module string
331
+ Name string
332
+ File string
333
+ }
334
+
335
+ // listFeatureFiles 列出功能点文档(两级目录 + 老数据平铺)。
336
+ //
337
+ // 两级:features/<模块>/<名称>.md;平铺:features/<名称>.md(未归类,module 为空)。
338
+ // 跳过点开头的文件与目录(.DS_Store / 临时文件)。
339
+ func listFeatureFiles(id string) []featureFile {
340
+ base := featuresDir(id)
341
+ ents, err := os.ReadDir(base)
342
+ if err != nil {
343
+ return nil
344
+ }
345
+ out := []featureFile{}
346
+ for _, e := range ents {
347
+ name := e.Name()
348
+ if strings.HasPrefix(name, ".") {
349
+ continue
350
+ }
351
+ if e.IsDir() {
352
+ subs, err := os.ReadDir(filepath.Join(base, name))
353
+ if err != nil {
354
+ continue
355
+ }
356
+ for _, s := range subs {
357
+ n := s.Name()
358
+ if s.IsDir() || strings.HasPrefix(n, ".") || !strings.HasSuffix(n, ".md") {
359
+ continue
360
+ }
361
+ out = append(out, featureFile{
362
+ Module: name,
363
+ Name: strings.TrimSuffix(n, ".md"),
364
+ File: filepath.Join(base, name, n),
365
+ })
366
+ }
367
+ continue
368
+ }
369
+ if !strings.HasSuffix(name, ".md") {
370
+ continue
371
+ }
372
+ out = append(out, featureFile{Name: strings.TrimSuffix(name, ".md"), File: filepath.Join(base, name)})
373
+ }
374
+ sort.Slice(out, func(i, j int) bool {
375
+ if out[i].Module != out[j].Module {
376
+ return out[i].Module < out[j].Module
377
+ }
378
+ return out[i].Name < out[j].Name
379
+ })
380
+ return out
381
+ }
382
+
383
+ // listModules 已有模块名(去重、稳定顺序)。
384
+ func listModules(id string) []string {
385
+ seen := map[string]bool{}
386
+ out := []string{}
387
+ for _, f := range listFeatureFiles(id) {
388
+ if f.Module != "" && !seen[f.Module] {
389
+ seen[f.Module] = true
390
+ out = append(out, f.Module)
391
+ }
392
+ }
393
+ sort.Strings(out)
394
+ return out
395
+ }
396
+
397
+ // docMerger 跨批共用的文档匹配池(片间 / 批间匹配一致)。
398
+ //
399
+ // 匹配三级:同名 → 归一化同名(去标点空格)→ 前缀包含(长度 ≥6 才允许,防「任务」
400
+ // 这种短名把别的文档全吃掉)。
401
+ type docMerger struct {
402
+ byName map[string]featureFile
403
+ byNorm map[string]featureFile
404
+ all []featureFile
405
+ usedFile map[string]bool
406
+ }
407
+
408
+ func newDocMerger(id string) *docMerger {
409
+ files := listFeatureFiles(id)
410
+ m := &docMerger{
411
+ byName: make(map[string]featureFile, len(files)),
412
+ byNorm: make(map[string]featureFile, len(files)),
413
+ all: files,
414
+ usedFile: map[string]bool{},
415
+ }
416
+ for _, f := range files {
417
+ m.byName[f.Name] = f
418
+ if n := normName(f.Name); n != "" {
419
+ m.byNorm[n] = f
420
+ }
421
+ }
422
+ return m
423
+ }
424
+
425
+ // normName 归一化名:去掉所有标点与空白,只留文字数字。
426
+ func normName(s string) string {
427
+ var b strings.Builder
428
+ for _, r := range s {
429
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
430
+ b.WriteRune(r)
431
+ }
432
+ }
433
+ return b.String()
434
+ }
435
+
436
+ // match 找同名文档(三级匹配),没有返回 false。
437
+ func (m *docMerger) match(name string) (featureFile, bool) {
438
+ if f, ok := m.byName[name]; ok {
439
+ return f, true
440
+ }
441
+ n := normName(name)
442
+ if n == "" {
443
+ return featureFile{}, false
444
+ }
445
+ if f, ok := m.byNorm[n]; ok {
446
+ return f, true
447
+ }
448
+ var best featureFile
449
+ bestLen := 0
450
+ for _, f := range m.all {
451
+ fn := normName(f.Name)
452
+ if len([]rune(fn)) < 6 || len([]rune(n)) < 6 {
453
+ continue
454
+ }
455
+ if !strings.HasPrefix(fn, n) && !strings.HasPrefix(n, fn) {
456
+ continue
457
+ }
458
+ if l := len([]rune(fn)); l > bestLen {
459
+ best, bestLen = f, l
460
+ }
461
+ }
462
+ return best, bestLen > 0
463
+ }
464
+
465
+ // add 把新建的文档加进匹配池(同一轮后续分片要能命中它)。
466
+ func (m *docMerger) add(f featureFile) {
467
+ m.all = append(m.all, f)
468
+ m.byName[f.Name] = f
469
+ if n := normName(f.Name); n != "" {
470
+ m.byNorm[n] = f
471
+ }
472
+ m.usedFile[f.File] = true
473
+ }