magic-agent 2.4.2 → 2.6.0

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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +7 -10
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -15,11 +15,13 @@ package client
15
15
  import (
16
16
  "bytes"
17
17
  "context"
18
+ "crypto/sha256"
19
+ "encoding/hex"
18
20
  "encoding/json"
19
21
  "io"
20
22
  "net/http"
21
- "os/exec"
22
23
  "strings"
24
+ "sync"
23
25
  "time"
24
26
  "unicode/utf8"
25
27
 
@@ -41,6 +43,77 @@ const (
41
43
  // 2 = 允许「当前会话收尾」与「刚切过去的另一个会话」各一条,超出直接 429。
42
44
  var suggestSlots = make(chan struct{}, 2)
43
45
 
46
+ // 建议缓存:同一轮问答(user+reply+引擎/模型都相同)的建议只跑一次模型调用。
47
+ // 界面在会话切换 / 重渲染时会重复请求同一轮的建议,没缓存就每次都起一个旁路
48
+ // 引擎进程白烧一次调用(用户 2026-10-24:「会话的建议要有缓存 不能每次都重复跑」)。
49
+ // 只缓存**成功**响应(含空数组——空也是合法结果);失败不缓存,下次重试。
50
+ const (
51
+ suggestCacheTTL = 30 * time.Minute
52
+ suggestCacheMaxSize = 128
53
+ )
54
+
55
+ type suggestCacheEntry struct {
56
+ payload []byte // 成功响应的 JSON(已保证 Suggestions 非 nil)
57
+ expireAt time.Time
58
+ usedAt time.Time // LRU 淘汰用
59
+ }
60
+
61
+ var suggestCache = struct {
62
+ sync.Mutex
63
+ m map[string]suggestCacheEntry
64
+ }{m: make(map[string]suggestCacheEntry)}
65
+
66
+ // suggestCacheKey 按完整请求内容算 key:user/reply 一样但换了引擎/模型就分开算。
67
+ func suggestCacheKey(body struct {
68
+ User string `json:"user"`
69
+ Reply string `json:"reply"`
70
+ Engine string `json:"engine"`
71
+ Model string `json:"model"`
72
+ FallbackEngine string `json:"fallbackEngine"`
73
+ FallbackModel string `json:"fallbackModel"`
74
+ }) string {
75
+ h := sha256.Sum256([]byte(strings.Join([]string{
76
+ body.User, body.Reply, body.Engine, body.Model, body.FallbackEngine, body.FallbackModel,
77
+ }, "\x00")))
78
+ return hex.EncodeToString(h[:])
79
+ }
80
+
81
+ // suggestCacheGet 命中返回响应 JSON;顺带做惰性过期与 LRU 容量控制。
82
+ func suggestCacheGet(key string) ([]byte, bool) {
83
+ suggestCache.Lock()
84
+ defer suggestCache.Unlock()
85
+ e, ok := suggestCache.m[key]
86
+ if !ok {
87
+ return nil, false
88
+ }
89
+ if time.Now().After(e.expireAt) {
90
+ delete(suggestCache.m, key)
91
+ return nil, false
92
+ }
93
+ e.usedAt = time.Now()
94
+ suggestCache.m[key] = e
95
+ return e.payload, true
96
+ }
97
+
98
+ func suggestCachePut(key string, payload []byte) {
99
+ suggestCache.Lock()
100
+ defer suggestCache.Unlock()
101
+ if len(suggestCache.m) >= suggestCacheMaxSize {
102
+ // 淘汰最久未用的一条(缓存最多 128 条,线性找足够快)。
103
+ var oldestKey string
104
+ var oldest time.Time
105
+ first := true
106
+ for k, v := range suggestCache.m {
107
+ if first || v.usedAt.Before(oldest) {
108
+ oldestKey, oldest, first = k, v.usedAt, false
109
+ }
110
+ }
111
+ delete(suggestCache.m, oldestKey)
112
+ }
113
+ now := time.Now()
114
+ suggestCache.m[key] = suggestCacheEntry{payload: payload, expireAt: now.Add(suggestCacheTTL), usedAt: now}
115
+ }
116
+
44
117
  // handleSuggest POST /desk/suggest。
45
118
  //
46
119
  // 请求:{"user":"用户上一句","reply":"本轮回答正文","engine":"可选","model":"可选"}
@@ -55,6 +128,10 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
55
128
  Reply string `json:"reply"`
56
129
  Engine string `json:"engine"`
57
130
  Model string `json:"model"`
131
+ // 建议引擎的主备(2026-10-24,只增字段):主建议引擎失败时先试它,
132
+ // 再落到 core 内置的 llm 兜底。空 = 老行为。
133
+ FallbackEngine string `json:"fallbackEngine"`
134
+ FallbackModel string `json:"fallbackModel"`
58
135
  }
59
136
  if r.Body != nil {
60
137
  if err := json.NewDecoder(io.LimitReader(r.Body, suggestMaxBody)).Decode(&body); err != nil {
@@ -70,6 +147,14 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
70
147
  body.User = clipRunes(strings.TrimSpace(body.User), suggestMaxUserRunes)
71
148
  body.Reply = clipRunes(body.Reply, suggestMaxReplyRunes)
72
149
 
150
+ // 同一轮问答的重复请求直接回缓存(切换会话/重渲染会反复要同一轮的建议)。
151
+ cacheKey := suggestCacheKey(body)
152
+ if payload, ok := suggestCacheGet(cacheKey); ok {
153
+ w.Header().Set("X-Suggest-Cache", "hit")
154
+ writeJSONBytes(w, http.StatusOK, payload)
155
+ return
156
+ }
157
+
73
158
  select {
74
159
  case suggestSlots <- struct{}{}:
75
160
  defer func() { <-suggestSlots }()
@@ -90,7 +175,13 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
90
175
  if m := strings.TrimSpace(body.Model); m != "" {
91
176
  args = append(args, "-m", m)
92
177
  }
93
- cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
178
+ if fb := strings.TrimSpace(body.FallbackEngine); fb != "" {
179
+ args = append(args, "--suggest-fallback-engine", fb)
180
+ if m := strings.TrimSpace(body.FallbackModel); m != "" {
181
+ args = append(args, "--suggest-fallback-model", m)
182
+ }
183
+ }
184
+ cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
94
185
  cmd.Stdin = bytes.NewReader(payload)
95
186
  var stdout, stderr bytes.Buffer
96
187
  cmd.Stdout = &stdout
@@ -119,9 +210,21 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
119
210
  if res.Suggestions == nil {
120
211
  res.Suggestions = []string{}
121
212
  }
213
+ if payload, err := json.Marshal(res); err == nil {
214
+ suggestCachePut(cacheKey, payload)
215
+ writeJSONBytes(w, http.StatusOK, payload)
216
+ return
217
+ }
122
218
  writeJSON(w, http.StatusOK, res)
123
219
  }
124
220
 
221
+ // writeJSONBytes 按预序列化的字节写响应(缓存命中与缓存写入共用同一份形状)。
222
+ func writeJSONBytes(w http.ResponseWriter, code int, payload []byte) {
223
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
224
+ w.WriteHeader(code)
225
+ _, _ = w.Write(payload)
226
+ }
227
+
125
228
  // clipRunes 按 rune 截断(中文不能按字节切)。
126
229
  func clipRunes(s string, max int) string {
127
230
  if max <= 0 || utf8.RuneCountInString(s) <= max {
@@ -6,15 +6,31 @@ import (
6
6
  "net/http/httptest"
7
7
  "os"
8
8
  "path/filepath"
9
+ "runtime"
9
10
  "strings"
10
11
  "testing"
11
12
  )
12
13
 
13
14
  // fakeSuggestCore 写一个假 core 脚本:记录 argv → 吃掉 stdin → 打印一份建议 JSON。
15
+ //
16
+ // Windows 上 exec 不了 sh 脚本(既有基线),改写等价的 .cmd 批处理 —— scriptBody
17
+ // 仍收 sh 语法,由映射表翻成 cmd 等价物;没翻译的写法直接 Fatal,防止静默跑错。
14
18
  func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
15
19
  t.Helper()
16
20
  argLog := filepath.Join(dir, "args.txt")
17
21
  p := filepath.Join(dir, "magic-agent")
22
+ if runtime.GOOS == "windows" {
23
+ p += ".cmd"
24
+ win, ok := shToCmd[scriptBody]
25
+ if !ok {
26
+ t.Fatalf("没给该 sh 脚本体准备 Windows 等价物:%q", scriptBody)
27
+ }
28
+ body := "@echo off\r\necho %*>\"" + argLog + "\"\r\nfind /v \"\" >NUL\r\n" + win + "\r\n"
29
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ return p, argLog
33
+ }
18
34
  body := "#!/bin/sh\nprintf '%s' \"$*\" > " + argLog + "\ncat >/dev/null\n" + scriptBody + "\n"
19
35
  if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
20
36
  t.Fatal(err)
@@ -22,6 +38,15 @@ func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
22
38
  return p, argLog
23
39
  }
24
40
 
41
+ // shToCmd 测试里用到的假 core 脚本体的 cmd 等价写法。
42
+ var shToCmd = map[string]string{
43
+ `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`: `echo {"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}`,
44
+ `printf '{"suggestions":["继续"],"engine":"fake","elapsedMs":9}\n'`: `echo {"suggestions":["继续"],"engine":"fake","elapsedMs":9}`,
45
+ `printf '{"suggestions":[]}\n'`: `echo {"suggestions":[]}`,
46
+ "echo 'llm CLI not found' >&2\nexit 3": `echo llm CLI not found 1>&2&exit /b 3`,
47
+ `printf 'not json\n'`: `echo not json`,
48
+ }
49
+
25
50
  func TestSuggestEndpoint(t *testing.T) {
26
51
  dir := t.TempDir()
27
52
  core, argLog := fakeSuggestCore(t, dir, `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`)
@@ -53,6 +78,67 @@ func TestSuggestEndpoint(t *testing.T) {
53
78
  }
54
79
  }
55
80
 
81
+ func TestSuggestEndpointCacheHit(t *testing.T) {
82
+ dir := t.TempDir()
83
+ core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":["继续"],"engine":"fake","elapsedMs":9}\n'`)
84
+ s := &Service{opts: Options{MagicAgent: core}}
85
+ body := `{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"fake-engine"}`
86
+
87
+ call := func() *httptest.ResponseRecorder {
88
+ rec := httptest.NewRecorder()
89
+ s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(body)))
90
+ return rec
91
+ }
92
+
93
+ first := call()
94
+ if first.Code != http.StatusOK {
95
+ t.Fatalf("首次 code = %d,body=%s", first.Code, first.Body.String())
96
+ }
97
+ if first.Header().Get("X-Suggest-Cache") == "hit" {
98
+ t.Fatalf("首次请求不应命中缓存")
99
+ }
100
+ second := call()
101
+ if second.Code != http.StatusOK {
102
+ t.Fatalf("二次 code = %d,body=%s", second.Code, second.Body.String())
103
+ }
104
+ if second.Header().Get("X-Suggest-Cache") != "hit" {
105
+ t.Fatalf("同内容的第二次请求应命中缓存")
106
+ }
107
+ if second.Body.String() != first.Body.String() {
108
+ t.Fatalf("缓存响应与首次不一致:%s vs %s", second.Body.String(), first.Body.String())
109
+ }
110
+
111
+ // 换引擎 = 换 key,不能复用旧缓存。
112
+ third := httptest.NewRecorder()
113
+ s.handleSuggest(third, httptest.NewRequest(http.MethodPost, "/desk/suggest",
114
+ strings.NewReader(`{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"other-engine"}`)))
115
+ if third.Header().Get("X-Suggest-Cache") == "hit" {
116
+ t.Fatalf("换了引擎应重新生成,不该命中缓存")
117
+ }
118
+
119
+ // 失败不缓存:core 挂了下次要重试。
120
+ failing := &Service{opts: Options{MagicAgent: fakeSuggestCoreFailing(t, dir)}}
121
+ f1 := httptest.NewRecorder()
122
+ failing.handleSuggest(f1, httptest.NewRequest(http.MethodPost, "/desk/suggest",
123
+ strings.NewReader(`{"user":"x","reply":"失败那轮"}`)))
124
+ if f1.Code != http.StatusBadGateway {
125
+ t.Fatalf("失败轮 code = %d,期望 502", f1.Code)
126
+ }
127
+ f2 := httptest.NewRecorder()
128
+ failing.handleSuggest(f2, httptest.NewRequest(http.MethodPost, "/desk/suggest",
129
+ strings.NewReader(`{"user":"x","reply":"失败那轮"}`)))
130
+ if f2.Code != http.StatusBadGateway {
131
+ t.Fatalf("失败后重试仍应走 core(code = %d,期望 502)", f2.Code)
132
+ }
133
+ }
134
+
135
+ // fakeSuggestCoreFailing 恒定失败的假 core(复用 fakeSuggestCore 的平台适配)。
136
+ func fakeSuggestCoreFailing(t *testing.T, dir string) string {
137
+ t.Helper()
138
+ p, _ := fakeSuggestCore(t, dir, "echo 'llm CLI not found' >&2\nexit 3")
139
+ return p
140
+ }
141
+
56
142
  func TestSuggestEndpointBadRequests(t *testing.T) {
57
143
  dir := t.TempDir()
58
144
  core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":[]}\n'`)
@@ -92,21 +92,35 @@ func teamFlags(t *teamRequest, profile Profile, roles []string) ([]string, error
92
92
  r := t.Routes[rawKind]
93
93
  engine := strings.TrimSpace(r.Engine)
94
94
  model := strings.TrimSpace(r.Model)
95
- if engine == "" {
95
+ fbEngine := strings.TrimSpace(r.FallbackEngine)
96
+ fbModel := strings.TrimSpace(r.FallbackModel)
97
+ /* 备用可以单独配(主引擎留空 = 跟随主引擎 + 这一路的备):所以这里不能用
98
+ 「engine 为空就 continue」把整条略掉 —— 有备用就得下发。 */
99
+ if engine != "" {
100
+ if !profile.AllowsEngine(engine) {
101
+ return nil, fmt.Errorf("team.routes.%s 的引擎 %q 不在 profile 白名单内", kind, engine)
102
+ }
103
+ spec := kind + "=" + engine
96
104
  if model != "" {
97
- // 只给模型不给引擎:语义不明(模型属于某一家引擎),不猜。
98
- return nil, fmt.Errorf("team.routes.%s 给了 model 但没给 engine", kind)
105
+ spec += "/" + model
99
106
  }
100
- continue // 整条空 = 该类跟随主引擎
101
- }
102
- if !profile.AllowsEngine(engine) {
103
- return nil, fmt.Errorf("team.routes.%s 的引擎 %q 不在 profile 白名单内", kind, engine)
107
+ args = append(args, "--team-route", spec)
108
+ } else if model != "" {
109
+ // 只给模型不给引擎:语义不明(模型属于某一家引擎),不猜。
110
+ return nil, fmt.Errorf("team.routes.%s 给了 model 但没给 engine", kind)
104
111
  }
105
- spec := kind + "=" + engine
106
- if model != "" {
107
- spec += "/" + model
112
+ if fbEngine != "" {
113
+ if !profile.AllowsEngine(fbEngine) {
114
+ return nil, fmt.Errorf("team.routes.%s 的备用引擎 %q 不在 profile 白名单内", kind, fbEngine)
115
+ }
116
+ spec := kind + "=" + fbEngine
117
+ if fbModel != "" {
118
+ spec += "/" + fbModel
119
+ }
120
+ args = append(args, "--team-fallback", spec)
121
+ } else if fbModel != "" {
122
+ return nil, fmt.Errorf("team.routes.%s 给了备用 model 但没给备用 engine", kind)
108
123
  }
109
- args = append(args, "--team-route", spec)
110
124
  }
111
125
  return args, nil
112
126
  }
@@ -146,6 +146,45 @@ func TestTeamFlagsRejectsModelWithoutEngine(t *testing.T) {
146
146
  }
147
147
  }
148
148
 
149
+ func TestTeamFlagsFallbackRoute(t *testing.T) {
150
+ // 主备(2026-10-24):主 + 备都要翻成 flag;只配备用也要下发(主引擎跟随主引擎)。
151
+ args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
152
+ "image": {Engine: "codebuddy", Model: "hy3", FallbackEngine: "dsh", FallbackModel: "v3"},
153
+ "text": {FallbackEngine: "claude"},
154
+ }}, Profile{}, nil)
155
+ if err != nil {
156
+ t.Fatalf("unexpected err: %v", err)
157
+ }
158
+ joined := strings.Join(args, " ")
159
+ if !strings.Contains(joined, "--team-route image=codebuddy/hy3") {
160
+ t.Fatalf("主引擎没下发:%v", args)
161
+ }
162
+ if !strings.Contains(joined, "--team-fallback image=dsh/v3") {
163
+ t.Fatalf("备用引擎没下发:%v", args)
164
+ }
165
+ if !strings.Contains(joined, "--team-fallback text=claude") {
166
+ t.Fatalf("只配备用的路由没下发:%v", args)
167
+ }
168
+ if strings.Contains(joined, "--team-route text=") {
169
+ t.Fatalf("只配备用不该伪造主引擎:%v", args)
170
+ }
171
+ }
172
+
173
+ func TestTeamFlagsRejectsBadFallback(t *testing.T) {
174
+ // 备用引擎同样要过白名单。
175
+ if _, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
176
+ "image": {Engine: "llm", FallbackEngine: "claude"},
177
+ }}, Profile{Engines: []string{"llm"}}, nil); err == nil {
178
+ t.Fatal("备用引擎不在白名单时应报错")
179
+ }
180
+ // 备用 model 没有备用 engine:语义不明,不猜。
181
+ if _, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
182
+ "image": {FallbackModel: "sonnet"},
183
+ }}, Profile{}, nil); err == nil || !strings.Contains(err.Error(), "备用") {
184
+ t.Fatalf("err = %v,应提到备用", err)
185
+ }
186
+ }
187
+
149
188
  func TestTeamFlagsAcceptsPlanRoute(t *testing.T) {
150
189
  // plan = 计划阶段(谁来拆任务),与五类子任务一样可以单独指定引擎。
151
190
  args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"plan": {Engine: "claude"}}}, Profile{}, nil)
@@ -187,6 +187,10 @@ type bootstrapResult struct {
187
187
  Created int
188
188
  Points int
189
189
  Conclusions int
190
+ // Merged / MergedModules 模块去重合并掉的模块(目录基线自己也可能造近义模块,
191
+ // 之前 auto 归纳攒下的也顺手合并;见 modules.go)。
192
+ Merged int
193
+ MergedModules []string
190
194
  }
191
195
 
192
196
  // bootstrapWorkspace 按工作区目录反推一版需求基线。
@@ -275,6 +279,18 @@ func bootstrapWorkspace(ctx context.Context, ws workspaceEntry, opts Options) (b
275
279
  与 features:[] 同样处理:标记初始化完成,回落到会话增量,不要把旧会话吞掉。 */
276
280
  res.Empty = true
277
281
  }
282
+ /* 模块去重:目录基线自己也可能造出近义模块(模型一次输出 5-20 个功能点),
283
+ 之前 auto 归纳攒下的相似模块也顺手合并。用初始化引擎包一个文本调用器 ——
284
+ 不能走 newCaller(测试钉死:首次初始化不碰文本归纳器)。 */
285
+ if mods := listModules(ws.ID); len(mods) >= 2 && len(similarModulePairs(mods)) > 0 {
286
+ mergeCall := func(prompt string, schema *agent.JSONSchema) (string, error) {
287
+ return runBootstrapAgent(ctx, engineName, bootstrapModel, ws.Dir, prompt, timeout)
288
+ }
289
+ if merged := dedupeModules(ws.ID, mergeCall); len(merged) > 0 {
290
+ res.Merged = len(merged)
291
+ res.MergedModules = merged
292
+ }
293
+ }
278
294
  return res, nil
279
295
  }
280
296
 
@@ -60,6 +60,9 @@ var outSchema = &agent.JSONSchema{
60
60
  "points": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
61
61
  "conclusions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
62
62
  "sources": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
63
+ // page_url:该功能点对应的页面地址(**可选**)。
64
+ // 不进 required:多数功能点没有页面,硬要求会让模型编一个出来。
65
+ "page_url": map[string]any{"type": "string"},
63
66
  },
64
67
  "required": []string{"module", "name", "desc", "points", "conclusions", "sources"},
65
68
  },
@@ -76,6 +79,8 @@ type featureOut struct {
76
79
  Points []string
77
80
  Conclusions []string
78
81
  Sources []int
82
+ // PageURL 该功能点对应的页面地址(记忆里有才填,空 = 没有)。
83
+ PageURL string `json:"page_url"`
79
84
  }
80
85
 
81
86
  // UnmarshalJSON 容忍 sources 里混进字符串编号(模型偶尔给 "#12")。
@@ -87,12 +92,14 @@ func (f *featureOut) UnmarshalJSON(b []byte) error {
87
92
  Points []string `json:"points"`
88
93
  Conclusions []string `json:"conclusions"`
89
94
  Sources []json.RawMessage `json:"sources"`
95
+ PageURL string `json:"page_url"`
90
96
  }
91
97
  if err := json.Unmarshal(b, &raw); err != nil {
92
98
  return err
93
99
  }
94
100
  f.Module, f.Name, f.Desc = raw.Module, raw.Name, raw.Desc
95
101
  f.Points, f.Conclusions = raw.Points, raw.Conclusions
102
+ f.PageURL = strings.TrimSpace(raw.PageURL)
96
103
  for _, s := range raw.Sources {
97
104
  var n int
98
105
  if json.Unmarshal(s, &n) == nil {
@@ -181,6 +188,8 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
181
188
  if boot.Called {
182
189
  res.Calls++ // 反推是一次带工具的 agent 调用(与下面的归纳共用 calls 口径)
183
190
  }
191
+ res.Merged += boot.Merged
192
+ res.MergedModules = append(res.MergedModules, boot.MergedModules...)
184
193
  if !boot.Empty {
185
194
  res.Updated += boot.Updated
186
195
  res.Created += boot.Created
@@ -222,7 +231,30 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
222
231
  if replay {
223
232
  entries = mem
224
233
  }
234
+ engineName := strings.TrimSpace(opts.Engine)
235
+ if engineName == "" {
236
+ engineName = "llm"
237
+ }
238
+ timeout := opts.Timeout
239
+ if timeout <= 0 {
240
+ timeout = DefaultTimeout
241
+ }
225
242
  if len(entries) == 0 && !opts.Force {
243
+ /* 手动空间级「整理」:没有新记忆也顺手做一次模块去重 —— 用户点「整理」
244
+ 就是想清理列表,只回一句 noop 等于没修(2026-10-25 报障:magic-agent
245
+ 空间 12 个模块里躺着多组近义,点整理却只听到「没有新的对话需要整理」)。
246
+ 构造不出 caller(引擎不存在等)就静默回落 noop,不改变现有行为。 */
247
+ if mods := listModules(ws.ID); moduleDedupeWanted(opts, res, mods) {
248
+ if call, cerr := newCaller(ctx, engineName, opts.Model, timeout); cerr == nil {
249
+ if merged := dedupeModules(ws.ID, call); len(merged) > 0 {
250
+ res.Merged = len(merged)
251
+ res.MergedModules = merged
252
+ res.OK = true
253
+ res.Pending = max(0, len(mem)-cursor)
254
+ return res, nil
255
+ }
256
+ }
257
+ }
226
258
  res.OK = true
227
259
  res.Noop = true
228
260
  res.Pending = 0
@@ -238,14 +270,6 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
238
270
  use = use[:MaxEntriesPerRun]
239
271
  }
240
272
 
241
- engineName := strings.TrimSpace(opts.Engine)
242
- if engineName == "" {
243
- engineName = "llm"
244
- }
245
- timeout := opts.Timeout
246
- if timeout <= 0 {
247
- timeout = DefaultTimeout
248
- }
249
273
  call, callErr := newCaller(ctx, engineName, opts.Model, timeout)
250
274
  if callErr != nil {
251
275
  res.Error = callErr.Error()
@@ -335,6 +359,24 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
335
359
  }
336
360
  return out
337
361
  },
362
+ onWrite: func(module, feature string, sources []int) {
363
+ rels := make([]Relation, 0, len(sources))
364
+ for _, n := range sources {
365
+ e, ok := byN[n]
366
+ if !ok || e.Session == "" {
367
+ continue
368
+ }
369
+ rels = append(rels, Relation{
370
+ Ts: time.Now().UnixMilli(),
371
+ Session: e.Session,
372
+ STitle: e.Title,
373
+ Module: module,
374
+ Feature: feature,
375
+ N: n,
376
+ })
377
+ }
378
+ _ = appendRelations(ws.ID, rels)
379
+ },
338
380
  })
339
381
  if !w.ok {
340
382
  continue
@@ -349,6 +391,18 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
349
391
  }
350
392
  }
351
393
 
394
+ /* 模块去重:把跨轮堆积的相似模块合并掉(「引擎管理 / 引擎调用」这种近义一级分组,
395
+ 单靠循环里的「新模块二审」挡不住 —— 那只能管本轮新出现的名字)。模块级整理
396
+ 不做(moduleDedupeWanted 里挡着),自动路径只在本轮真写了新文档时顺手做。 */
397
+ if mods := listModules(ws.ID); moduleDedupeWanted(opts, res, mods) {
398
+ if merged := dedupeModules(ws.ID, call); len(merged) > 0 {
399
+ res.Merged = len(merged)
400
+ res.MergedModules = merged
401
+ }
402
+ }
403
+
404
+ _ = compactRelations(ws.ID) // 归纳收尾去重一次(写入期只 append)
405
+
352
406
  consumed := cursor
353
407
  if !replay && moduleScope == "" {
354
408
  consumed = cursor + len(use)
@@ -573,6 +627,9 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
573
627
  "不要当结论写进去;结论必须是「已经定下来的做法/取舍」。",
574
628
  " 与「已有结论」语义相同的同样逐字照抄;**已有结论为空但记忆里有明确结论的必须补上**;",
575
629
  " · sources:本条目引用的记忆编号(整数数组,取自上面 `#编号`);",
630
+ " · page_url:**该功能点对应的页面地址**(可选)。只有当记忆里明确出现了这个页面" +
631
+ "(http/https 开头)才填,并**原样照抄**那个地址;记忆里没有就填空串 \"\"," +
632
+ "**绝不猜、绝不编**(编出来的地址会让人点开一个不存在的页面);",
576
633
  "5. 只归纳记忆里真实出现的内容,不要发明未提到的功能,**不要写任何日期**;",
577
634
  "6. 没有任何新信息时输出空数组 features: []。",
578
635
  }
@@ -610,10 +667,23 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
610
667
  b.WriteString(strings.Join(rules, "\n"))
611
668
  b.WriteString("\n\n只输出 JSON:{\"features\":[{\"module\":\"≤" + strconv.Itoa(ModuleMaxChars) +
612
669
  "字大功能点\",\"name\":\"≤" + strconv.Itoa(TitleMaxChars) +
613
- "字要点\",\"desc\":\"\",\"points\":[\"≤30字\"],\"conclusions\":[\"≤30字\"],\"sources\":[12]}]}")
670
+ "字要点\",\"desc\":\"\",\"points\":[\"≤30字\"],\"conclusions\":[\"≤30字\"],\"sources\":[12],\"page_url\":\"\"}]}")
614
671
  return b.String()
615
672
  }
616
673
 
674
+ // validPageURL 页面地址是否可用:只认 http/https(其余一律丢弃)。
675
+ //
676
+ // 为什么这么严:模型偶尔会填「无」「待定」「见上文」,或把本地路径当地址。
677
+ // 这些进了文档会变成一个点不开的链接 —— 比没有更糟。
678
+ func validPageURL(u string) bool {
679
+ v := strings.TrimSpace(u)
680
+ if v == "" || isPlaceholder(v) {
681
+ return false
682
+ }
683
+ low := strings.ToLower(v)
684
+ return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
685
+ }
686
+
617
687
  // aliasSchema 模块同义二审的输出契约。
618
688
  var aliasSchema = &agent.JSONSchema{
619
689
  Type: "object",
@@ -769,6 +839,11 @@ type writeCtx struct {
769
839
  kind string
770
840
  all bool
771
841
  sourcesOf func(featureOut) []string
842
+ /* onWrite 文档落盘后的关系回调:module/feature 是**改写后**的最终归属
843
+ (alias、hit 沿用都算过了),sources 是模型给的记忆编号 —— 调用方据此
844
+ 记「会话↔需求」关系(relations.jsonl)。事后用 featureOut 重算会记错
845
+ 模块名(归属在这函数里才定),必须回调。 */
846
+ onWrite func(module, feature string, sources []int)
772
847
  }
773
848
 
774
849
  // writeResult 一条增量的落盘结果。
@@ -845,6 +920,17 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
845
920
  }
846
921
  sources := mergeUnique(prev.Sources, srcLines, MaxSources, false, sourceKey)
847
922
 
923
+ /* 页面地址(2026-10-25 用户:「涉及页面改造时截图标注放进文档」):
924
+ 新值合法才覆盖;本轮没给(或给了非法值)就**保留旧的** ——
925
+ 页面地址是文档里长期有效的事实,不该被某一轮的空值抹掉。 */
926
+ pageURL := prev.PageURL
927
+ if u := strings.TrimSpace(out.PageURL); u != "" && validPageURL(u) {
928
+ pageURL = u
929
+ }
930
+ /* 截图段必须**原样带回**:RenderDoc 是全量重写文档,不带回就会把用户
931
+ 逐张攒的完成情况截图全部抹掉。 */
932
+ shots := TrimShots(prev.Shots)
933
+
848
934
  dPts := len(points) - len(prev.Points)
849
935
  if dPts < 0 {
850
936
  dPts = 0
@@ -874,6 +960,8 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
874
960
  Conclusions: conclusions,
875
961
  Sources: sources,
876
962
  Log: logLines,
963
+ PageURL: pageURL,
964
+ Shots: shots,
877
965
  }
878
966
  if err := os.MkdirAll(dir, 0o700); err != nil {
879
967
  return writeResult{}
@@ -881,6 +969,9 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
881
969
  if err := os.WriteFile(full, []byte(RenderDoc(name, doc)), 0o600); err != nil {
882
970
  return writeResult{}
883
971
  }
972
+ if ctx.onWrite != nil && len(out.Sources) > 0 {
973
+ ctx.onWrite(rawMod, name, out.Sources)
974
+ }
884
975
  if !has {
885
976
  ctx.merger.add(featureFile{Module: rawMod, Name: name, File: full})
886
977
  }