magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  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 +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -16,13 +16,18 @@ package requirements
16
16
  import (
17
17
  "os"
18
18
  "path/filepath"
19
+ "regexp"
19
20
  "sort"
20
21
  "strings"
22
+ "time"
21
23
  "unicode"
22
24
  )
23
25
 
24
- // docSections 骨架的六个段落名(顺序即 renderDoc 的写盘顺序)。
25
- var docSections = []string{"最终版", "需求描述", "需求点", "已定结论", "来源对话", "更新记录"}
26
+ // docSections 骨架的段落名(顺序即 renderDoc 的写盘顺序)。
27
+ //
28
+ // 「页面完成情况」是第七段(2026-10-25 用户:「需求文档整理时如果设计页面改造,
29
+ // 调用内置浏览器 MCP 对完成情况截图标注并放在文档中」):记页面地址 + 改造后的截图。
30
+ var docSections = []string{"最终版", "需求描述", "需求点", "已定结论", "页面完成情况", "来源对话", "更新记录"}
26
31
 
27
32
  // Doc 一篇功能点文档的解析结果。
28
33
  type Doc struct {
@@ -34,6 +39,20 @@ type Doc struct {
34
39
  Conclusions []string
35
40
  Sources []string
36
41
  Log []string
42
+ // PageURL 该功能点对应的页面地址(归纳时从对话里提取,可空)。
43
+ PageURL string
44
+ // Shots 页面改造的完成情况截图(按时间正序)。
45
+ Shots []Shot
46
+ }
47
+
48
+ // Shot 一张「页面完成情况」截图。
49
+ type Shot struct {
50
+ // Path 截图在本机的绝对路径(**正斜杠**,跨平台一致)。
51
+ Path string
52
+ // Caption 标注说明(用户写的一句话,可空)。
53
+ Caption string
54
+ // At 截图时刻(unix 毫秒)。
55
+ At int64
37
56
  }
38
57
 
39
58
  // isPlaceholder 占位符判定:整条就等于占位符的一律丢弃。
@@ -90,7 +109,7 @@ func ParseDoc(text string) Doc {
90
109
  if isPlaceholder(final) {
91
110
  final = ""
92
111
  }
93
- return Doc{
112
+ d := Doc{
94
113
  Final: final,
95
114
  Desc: strings.TrimSpace(strings.Join(buf["需求描述"], " ")),
96
115
  Points: lines("需求点"),
@@ -98,6 +117,127 @@ func ParseDoc(text string) Doc {
98
117
  Sources: lines("来源对话"),
99
118
  Log: lines("更新记录"),
100
119
  }
120
+ d.PageURL, d.Shots = parseShots(buf["页面完成情况"])
121
+ return d
122
+ }
123
+
124
+ // shotPathRe 截图行:`![说明](路径)`(路径是本机绝对路径,正斜杠),
125
+ // 末尾可跟一个时间戳注释 `<!-- 2026-10-25T14:30:00Z -->`。
126
+ //
127
+ // 说明里不许出现 `]`(写入时会被清掉),路径用 `<>` 包时能容纳空格与括号
128
+ // (Windows 下 `C:/Users/x/a b (v2)/x.png` 正是这种形状)。
129
+ //
130
+ // 前缀的列表符号 `- ` 是**可选**的:归纳重渲染写的是列表项(`- ![…]`),
131
+ // 文本级插入写的是裸行 —— 两种形状都要能数出、认得出。
132
+ var shotPathRe = regexp.MustCompile(`^(?:[-*+]\s+)?!\[([^\]]*)\]\((.*)\)(?:\s*<!--\s*([^>]*?)\s*-->)?\s*$`)
133
+
134
+ // shotTimeRe 截图时间戳注释里的时间(RFC3339)。
135
+ var shotTimeRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?`)
136
+
137
+ // pageURLPrefix 页面地址行前缀。
138
+ const pageURLPrefix = "页面地址:"
139
+
140
+ // parseShots 解析「页面完成情况」段:页面地址 + 截图列表。
141
+ //
142
+ // 形状(代码写、模型不参与 —— 截图是事实,不能让模型编):
143
+ //
144
+ // 页面地址:https://example.com/a
145
+ // ![改成双列布局](C:/Users/x/.magic-agent/requirements/ws-x/shots/…/1.png) <!-- 2026-10-25T14:30:00Z -->
146
+ func parseShots(raw []string) (string, []Shot) {
147
+ url := ""
148
+ shots := []Shot{}
149
+ for _, line := range raw {
150
+ l := strings.TrimSpace(stripListMarker(strings.TrimSpace(line)))
151
+ if l == "" {
152
+ continue
153
+ }
154
+ if strings.HasPrefix(l, pageURLPrefix) {
155
+ v := strings.TrimSpace(strings.TrimPrefix(l, pageURLPrefix))
156
+ if v != "" && !isPlaceholder(v) {
157
+ url = v
158
+ }
159
+ continue
160
+ }
161
+ m := shotPathRe.FindStringSubmatch(l)
162
+ if m == nil {
163
+ continue
164
+ }
165
+ s := Shot{Path: trimAngle(strings.TrimSpace(m[2])), Caption: strings.TrimSpace(m[1])}
166
+ if m[3] != "" {
167
+ if tm := shotTimeRe.FindString(m[3]); tm != "" {
168
+ if t, err := time.Parse(time.RFC3339, normalizeShotTime(tm)); err == nil {
169
+ s.At = t.UnixMilli()
170
+ }
171
+ }
172
+ }
173
+ shots = append(shots, s)
174
+ }
175
+ return url, shots
176
+ }
177
+
178
+ // normalizeShotTime 补齐 RFC3339 需要的时区。
179
+ //
180
+ // 写盘时用 UTC 的 RFC3339(结尾带 Z),但**历史数据**或手改过的文档可能缺时区 ——
181
+ // 那种按 UTC 补 Z。带偏移(+08:00)的原样保留。
182
+ func normalizeShotTime(s string) string {
183
+ v := strings.TrimSpace(s)
184
+ if v == "" {
185
+ return v
186
+ }
187
+ if strings.HasSuffix(v, "Z") || strings.HasSuffix(v, "z") {
188
+ return v
189
+ }
190
+ // 结尾是 ±HH:MM 偏移 → 已经是合法 RFC3339。
191
+ if len(v) >= 6 {
192
+ tail := v[len(v)-6:]
193
+ if (tail[0] == '+' || tail[0] == '-') && tail[3] == ':' {
194
+ return v
195
+ }
196
+ }
197
+ return v + "Z"
198
+ }
199
+
200
+ // trimAngle 去掉路径外的 `<>`(markdown 的转义写法)。
201
+ func trimAngle(p string) string {
202
+ if len(p) >= 2 && strings.HasPrefix(p, "<") && strings.HasSuffix(p, ">") {
203
+ return strings.TrimSpace(p[1 : len(p)-1])
204
+ }
205
+ return p
206
+ }
207
+
208
+ // shotLine 渲染一行截图:说明 + 路径(+ 时间戳注释)。
209
+ //
210
+ // 路径含空格 / 括号时用 `<>` 包(markdown 的标准转义),否则解析回来会断。
211
+ // 时间戳写成 HTML 注释:markdown 渲染时**看不见**,但解析能取回来
212
+ // (清单上要显示「最后截图时间」,注释里不占视觉位置)。
213
+ func shotLine(caption, path string, at int64) string {
214
+ c := sanitizeCaption(caption)
215
+ p := strings.TrimSpace(path)
216
+ if p == "" {
217
+ return ""
218
+ }
219
+ if strings.ContainsAny(p, " ()") {
220
+ p = "<" + p + ">"
221
+ }
222
+ var b strings.Builder
223
+ b.WriteString("![")
224
+ b.WriteString(c)
225
+ b.WriteString("](")
226
+ b.WriteString(p)
227
+ b.WriteString(")")
228
+ if at > 0 {
229
+ b.WriteString(" <!-- " + time.UnixMilli(at).UTC().Format(time.RFC3339) + " -->")
230
+ }
231
+ return b.String()
232
+ }
233
+
234
+ // sanitizeCaption 清掉会破坏 markdown 结构的字符(`]` 会截断说明、换行会断行)。
235
+ func sanitizeCaption(c string) string {
236
+ s := strings.TrimSpace(c)
237
+ s = strings.ReplaceAll(s, "]", ")")
238
+ s = strings.ReplaceAll(s, "\r", " ")
239
+ s = strings.ReplaceAll(s, "\n", " ")
240
+ return strings.TrimSpace(s)
101
241
  }
102
242
 
103
243
  // stripListMarker 去掉行首的列表标记(`1. ` / `- ` / `* ` / `1、`)。
@@ -169,6 +309,17 @@ func RenderDoc(name string, d Doc) string {
169
309
  b.WriteString("- " + c + "\n")
170
310
  }
171
311
  }
312
+ b.WriteString("\n## 页面完成情况\n")
313
+ if strings.TrimSpace(d.PageURL) == "" && len(d.Shots) == 0 {
314
+ b.WriteString("(暂无)\n")
315
+ } else {
316
+ if u := strings.TrimSpace(d.PageURL); u != "" {
317
+ b.WriteString(pageURLPrefix + u + "\n")
318
+ }
319
+ for _, s := range d.Shots {
320
+ b.WriteString("- " + shotLine(s.Caption, s.Path, s.At) + "\n")
321
+ }
322
+ }
172
323
  b.WriteString("\n## 来源对话\n")
173
324
  if len(S) == 0 {
174
325
  b.WriteString("(暂无)\n")
@@ -0,0 +1,138 @@
1
+ package requirements
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/darren/magic-agent/internal/session"
9
+ )
10
+
11
+ // 会话↔需求关系图谱:把 relations.jsonl 聚合成 nodes/edges 给前端画图。
12
+ // feature 清单以文档目录**现算**为准(模块合并/改名后 relations 里可能残留
13
+ // 旧名 —— 查不到的功能点连同边一起丢弃,不报错),relations 只供会话侧。
14
+
15
+ type graphNode struct {
16
+ ID string `json:"id"`
17
+ Type string `json:"type"` // "session" | "feature"
18
+ Title string `json:"title"`
19
+ Module string `json:"module,omitempty"`
20
+ }
21
+
22
+ type graphEdge struct {
23
+ Source string `json:"source"`
24
+ Target string `json:"target"`
25
+ }
26
+
27
+ type graphWorkspace struct {
28
+ ID string `json:"id"`
29
+ Name string `json:"name"`
30
+ Dir string `json:"dir,omitempty"`
31
+ Nodes []graphNode `json:"nodes"`
32
+ Edges []graphEdge `json:"edges"`
33
+ }
34
+
35
+ type graphOut struct {
36
+ OK bool `json:"ok"`
37
+ Root string `json:"root"`
38
+ Workspaces []graphWorkspace `json:"workspaces"`
39
+ }
40
+
41
+ // BuildGraph 聚合一个工作区(ws id / 目录 / ""=all)的关系图谱。
42
+ func BuildGraph(where string) ([]byte, error) {
43
+ out := graphOut{OK: true, Root: Root()}
44
+ wsids := []workspaceEntry{}
45
+ if where == "" || where == "all" {
46
+ ws, err := Workspaces()
47
+ if err != nil {
48
+ return nil, err
49
+ }
50
+ for _, w := range ws {
51
+ wsids = append(wsids, workspaceEntry{ID: w.ID, Dir: w.Dir, Name: w.Name})
52
+ }
53
+ } else {
54
+ ws, ok := resolveWorkspace(where)
55
+ if !ok {
56
+ return nil, fmt.Errorf("requirements: workspace not found: %s", where)
57
+ }
58
+ wsids = append(wsids, ws)
59
+ }
60
+ for _, ws := range wsids {
61
+ out.Workspaces = append(out.Workspaces, buildGraphWS(ws))
62
+ }
63
+ if out.Workspaces == nil {
64
+ out.Workspaces = []graphWorkspace{}
65
+ }
66
+ b, err := json.Marshal(out)
67
+ if err != nil {
68
+ return nil, err
69
+ }
70
+ return b, nil
71
+ }
72
+
73
+ func buildGraphWS(ws workspaceEntry) graphWorkspace {
74
+ g := graphWorkspace{ID: ws.ID, Name: ws.Name, Dir: ws.Dir, Nodes: []graphNode{}, Edges: []graphEdge{}}
75
+ if g.Name == "" {
76
+ g.Name = ws.ID
77
+ }
78
+
79
+ docs := featureDocsOf(ws.ID)
80
+ featOK := make(map[string]graphNode, len(docs))
81
+ for _, d := range docs {
82
+ if d.Name == "" {
83
+ continue
84
+ }
85
+ n := graphNode{ID: "f:" + d.Module + "/" + d.Name, Type: "feature", Title: d.Name, Module: d.Module}
86
+ featOK[n.ID] = n
87
+ g.Nodes = append(g.Nodes, n)
88
+ }
89
+
90
+ rels := readRelations(ws.ID)
91
+ seenEdge := make(map[string]bool, len(rels))
92
+ sessTitle := make(map[string]string)
93
+ for _, r := range rels {
94
+ if r.Session == "" || r.Feature == "" {
95
+ continue
96
+ }
97
+ fid := "f:" + r.Module + "/" + r.Feature
98
+ if _, ok := featOK[fid]; !ok {
99
+ continue // 功能点已被合并/改名:关系随文档走,旧名丢弃
100
+ }
101
+ sid := "s:" + r.Session
102
+ if _, ok := sessTitle[r.Session]; !ok {
103
+ sessTitle[r.Session] = r.STitle
104
+ }
105
+ ek := r.Session + "\x00" + fid
106
+ if !seenEdge[ek] {
107
+ seenEdge[ek] = true
108
+ g.Edges = append(g.Edges, graphEdge{Source: sid, Target: fid})
109
+ }
110
+ }
111
+
112
+ // 会话节点放最后(标题:关系快照 → 登记表兜底)。
113
+ for s, t := range sessTitle {
114
+ if t == "" {
115
+ t = sessionTitleFallback(s)
116
+ }
117
+ g.Nodes = append(g.Nodes, graphNode{ID: "s:" + s, Type: "session", Title: t})
118
+ }
119
+ return g
120
+ }
121
+
122
+ // sessionTitleFallback 关系快照没存标题时从登记表/标题表兜底。
123
+ func sessionTitleFallback(sessionID string) string {
124
+ if recs, err := session.List(); err == nil {
125
+ for _, r := range recs {
126
+ if r.SessionID == sessionID || r.RunID == sessionID {
127
+ if t := session.TitleForAny(r.SessionID, r.RunID); t != "" {
128
+ return t
129
+ }
130
+ return strings.TrimSpace(r.PromptHead)
131
+ }
132
+ }
133
+ }
134
+ if t := session.TitleForAny(sessionID, ""); t != "" {
135
+ return t
136
+ }
137
+ return sessionID
138
+ }
@@ -302,6 +302,13 @@ func featureSummary(f featureFile) Feature {
302
302
  item.Conclusions = len(d.Conclusions)
303
303
  item.Sources = len(d.Sources)
304
304
  item.HasFinal = strings.TrimSpace(d.Final) != ""
305
+ item.PageURL = d.PageURL
306
+ item.Shots = len(d.Shots)
307
+ for _, s := range d.Shots {
308
+ if s.At > item.ShotAt {
309
+ item.ShotAt = s.At
310
+ }
311
+ }
305
312
  if len(d.Conclusions) > 0 {
306
313
  item.Conclusion = d.Conclusions[0]
307
314
  }
@@ -0,0 +1,312 @@
1
+ package requirements
2
+
3
+ // modules.go - 模块去重:把「表达同一件事」的已有模块合并成一个。
4
+ //
5
+ // 为什么归纳需要它(2026-10-25 用户报障,配图:magic-agent 空间 12 个模块里
6
+ // 躺着「引擎管理 / 引擎调用」「配置管理 / 模型配置」「会话管理 / 对话交互」
7
+ // 这种近义模块):
8
+ //
9
+ // · 现有的同义二审(consolidate.go 的 resolveModuleAlias)只挡**本轮新出现**的
10
+ // 模块名 vs 已有模块 —— 跨轮堆积下来的相似模块(这轮新的、下轮就成了「已有」)
11
+ // 从来没人互相比,用户在清单里看到的就是十几个近义一级分组;
12
+ // · 首次目录初始化(bootstrap.go)写文档时 alias 恒为空,模型一次输出 5-20 个
13
+ // 功能点,想造几个近义模块就造几个。
14
+ //
15
+ // 三条口径(与归纳 prompt 的规则 2 同一套判据,别在这里发明新说法):
16
+ //
17
+ // ① 合并是**语义判断**,交给模型;代码只做确定性候选(名字共享字/包含)当
18
+ // 「要不要问这次模型」的成本闸门 —— 自动路径每轮都可能跑,不能每轮都烧一次;
19
+ // ② 方向归一:并入**功能点更多**的那个模块(更主要的那个),数量持平听模型的;
20
+ // ③ 内容不丢:无同名文档整体搬走 + 补一行「模块合并」记录;同名文档合并
21
+ // (要点/结论保头去重、来源/流水保尾去重),不生成第二篇。
22
+ //
23
+ // 失败一律「不合并」(模型不可用 / 输出不可解析 / 幻觉模块名)—— 去重是质量增强,
24
+ // 不该让归纳整轮失败,更不该把用户的文档搬丢。
25
+
26
+ import (
27
+ "encoding/json"
28
+ "os"
29
+ "path/filepath"
30
+ "sort"
31
+ "strings"
32
+ )
33
+
34
+ // moduleMergeRules 模块合并的判据(与归纳规则 2 同一口径;prompt 与二审共用)。
35
+ const moduleMergeRules = "「任务列表」与「对话列表」都指\"列表\"这件事 → 同一件事,应合并;" +
36
+ "「对话交互」指交互行为、「对话列表」指列表展示 → 不是同一件事,不许合并;" +
37
+ "同一功能域才算同一件事(如「引擎管理」与「引擎调用」同属引擎这一个功能域);" +
38
+ "名字像但功能域不同(如「内容创作」与「团队协作」)不许合并。"
39
+
40
+ // similarModulePairs 确定性候选:两个模块名共享任一字,或互为子串。
41
+ //
42
+ // 它**不做合并判断**(那是模型的语义活),只回答「值不值得为这批模块名跑一次模型」。
43
+ // 中文产品模块名(≤6 字)里真正的同义项几乎都共享字符或互为子串;一个字都不搭的
44
+ // (「远程接入」vs「局域网访问」这种)理论漏判,换取自动路径不为每个工作区每轮烧调用。
45
+ func similarModulePairs(mods []string) [][2]string {
46
+ sets := make([]map[rune]bool, len(mods))
47
+ for i, m := range mods {
48
+ sets[i] = map[rune]bool{}
49
+ for _, r := range m {
50
+ sets[i][r] = true
51
+ }
52
+ }
53
+ out := [][2]string{}
54
+ for i := 0; i < len(mods); i++ {
55
+ for j := i + 1; j < len(mods); j++ {
56
+ if strings.Contains(mods[i], mods[j]) || strings.Contains(mods[j], mods[i]) {
57
+ out = append(out, [2]string{mods[i], mods[j]})
58
+ continue
59
+ }
60
+ for r := range sets[i] {
61
+ if sets[j][r] {
62
+ out = append(out, [2]string{mods[i], mods[j]})
63
+ break
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return out
69
+ }
70
+
71
+ // moduleFeatureCounts 每个模块下的功能点文档数(挑合并目标用:更主要的那个)。
72
+ func moduleFeatureCounts(id string) map[string]int {
73
+ out := map[string]int{}
74
+ for _, f := range listFeatureFiles(id) {
75
+ if f.Module != "" {
76
+ out[f.Module]++
77
+ }
78
+ }
79
+ return out
80
+ }
81
+
82
+ // resolveModuleMerge 问模型:哪些已有模块表达同一件事,需要合并。
83
+ //
84
+ // 输出契约复用 aliasSchema({pairs:[{from,to}]},from=被合并、to=并入)。
85
+ // 校验:from / to 必须原样取自真实模块名(挡幻觉);任何失败返回空表。
86
+ func resolveModuleMerge(call llmCall, id string) [][2]string {
87
+ mods := []string{}
88
+ names := map[string][]string{}
89
+ for _, f := range listFeatureFiles(id) {
90
+ if f.Module == "" {
91
+ continue
92
+ }
93
+ if _, ok := names[f.Module]; !ok {
94
+ mods = append(mods, f.Module)
95
+ }
96
+ names[f.Module] = append(names[f.Module], f.Name)
97
+ }
98
+ if len(mods) < 2 {
99
+ return nil
100
+ }
101
+ var b strings.Builder
102
+ b.WriteString("你是资深产品经理。下面是同一个产品工作区的【已有模块】(一级功能点分组)。\n")
103
+ b.WriteString("请找出哪些模块其实表达**同一件事**(语义相同,不是字面相近),需要合并成一个。\n\n")
104
+ b.WriteString("== 已有模块 ==\n")
105
+ for _, m := range mods {
106
+ b.WriteString("· " + m + "(" + itoa(len(names[m])) + " 个功能点:" + strings.Join(names[m], "、") + ")\n")
107
+ }
108
+ b.WriteString("\n判据(与归纳规则同一口径):\n· " + moduleMergeRules + "\n")
109
+ b.WriteString("没有要合并的就输出空数组 —— **宁可漏,不可错并**。\n")
110
+ b.WriteString("规则:from / to 都必须**原样**取自上面模块名。\n")
111
+ b.WriteString(`只输出 JSON:{"pairs":[{"from":"被合并模块","to":"并入模块"}]}`)
112
+
113
+ text, err := call(b.String(), aliasSchema)
114
+ if err != nil {
115
+ return nil
116
+ }
117
+ obj, ok := firstJSONObject(text)
118
+ if !ok {
119
+ return nil
120
+ }
121
+ var pairs struct {
122
+ Pairs []struct {
123
+ From string `json:"from"`
124
+ To string `json:"to"`
125
+ } `json:"pairs"`
126
+ }
127
+ if json.Unmarshal([]byte(obj), &pairs) != nil {
128
+ return nil
129
+ }
130
+ valid := map[string]bool{}
131
+ for _, m := range mods {
132
+ valid[m] = true
133
+ }
134
+ out := [][2]string{}
135
+ for _, p := range pairs.Pairs {
136
+ from, to := strings.TrimSpace(p.From), strings.TrimSpace(p.To)
137
+ if from == "" || to == "" || from == to || !valid[from] || !valid[to] {
138
+ continue // 幻觉模块名一律不认
139
+ }
140
+ out = append(out, [2]string{from, to})
141
+ }
142
+ return out
143
+ }
144
+
145
+ // dedupeModules 找出「表达同一件事」的已有模块并合并。返回合并清单("from → to")。
146
+ //
147
+ // 流程:问模型 → 方向归一(功能点多的幸存,持平听模型的)→ 解链(A→B、B→C 都归 C,
148
+ // visited 防环)→ 按幸存者分组搬目录。任何一步拿不到结果就原样返回。
149
+ func dedupeModules(id string, call llmCall) []string {
150
+ pairs := resolveModuleMerge(call, id)
151
+ if len(pairs) == 0 {
152
+ return nil
153
+ }
154
+ counts := moduleFeatureCounts(id)
155
+ // target[drop] = keep:drop 被合并掉、keep 幸存。
156
+ target := map[string]string{}
157
+ for _, p := range pairs {
158
+ drop, keep := p[0], p[1]
159
+ if counts[drop] > counts[keep] {
160
+ drop, keep = keep, drop // 功能点更多的幸存(更主要的那个);持平听模型的
161
+ }
162
+ target[drop] = keep
163
+ }
164
+ // 解链:沿着 target 走到不再被合并的那个模块为止。
165
+ root := map[string]string{}
166
+ for d := range target {
167
+ seen := map[string]bool{}
168
+ cur := d
169
+ for {
170
+ if seen[cur] {
171
+ break // 环(方向归一后理论上不会出现):就地停下,不合并这一组
172
+ }
173
+ seen[cur] = true
174
+ next, ok := target[cur]
175
+ if !ok {
176
+ break
177
+ }
178
+ cur = next
179
+ }
180
+ root[d] = cur
181
+ }
182
+ groups := map[string][]string{}
183
+ for d, r := range root {
184
+ if d == r {
185
+ continue
186
+ }
187
+ groups[r] = append(groups[r], d)
188
+ }
189
+ roots := make([]string, 0, len(groups))
190
+ for r := range groups {
191
+ roots = append(roots, r)
192
+ }
193
+ sort.Strings(roots)
194
+ out := []string{}
195
+ for _, r := range roots {
196
+ drops := groups[r]
197
+ sort.Strings(drops)
198
+ for _, d := range drops {
199
+ if err := mergeModuleInto(id, d, r); err == nil {
200
+ out = append(out, d+" → "+r)
201
+ }
202
+ }
203
+ }
204
+ return out
205
+ }
206
+
207
+ // mergeModuleInto 把模块 from 的全部功能点文档并进 to(内容不丢)。
208
+ func mergeModuleInto(id, from, to string) error {
209
+ base := featuresDir(id)
210
+ srcDir := filepath.Join(base, from)
211
+ dstDir := filepath.Join(base, to)
212
+ ents, err := os.ReadDir(srcDir)
213
+ if err != nil {
214
+ return err // from 不存在 = 没东西可搬
215
+ }
216
+ if err := os.MkdirAll(dstDir, 0o700); err != nil {
217
+ return err
218
+ }
219
+ for _, e := range ents {
220
+ if e.IsDir() {
221
+ continue
222
+ }
223
+ n := e.Name()
224
+ if strings.HasPrefix(n, ".") || !strings.HasSuffix(n, ".md") {
225
+ continue
226
+ }
227
+ src := filepath.Join(srcDir, n)
228
+ dst := filepath.Join(dstDir, n)
229
+ if _, err := os.Stat(dst); err == nil {
230
+ // 同名文档:两个模块下有同一个功能点 → 合并内容,不生成第二篇。
231
+ if err := mergeDocFile(src, dst, from, to); err != nil {
232
+ return err
233
+ }
234
+ _ = os.Remove(src)
235
+ continue
236
+ }
237
+ if err := os.Rename(src, dst); err != nil {
238
+ return err
239
+ }
240
+ // 搬走的文档补一行合并记录(可追溯:这篇原来属于哪个模块)。
241
+ noteModuleMerge(dst, from, to)
242
+ }
243
+ _ = os.Remove(srcDir) // 搬空了撤目录;有残留(非 .md)也不阻断(清单只收 .md)
244
+ // 关系流的 module 归属同步改写(relations.jsonl 记的是模块名,不改会悬空)。
245
+ _ = rewriteRelationsModule(id, from, to)
246
+ return nil
247
+ }
248
+
249
+ // mergeDocFile 两个模块下的同名文档 → 合并成一篇。
250
+ //
251
+ // 以目标文档为基座:desc 取目标已有(没有才用来源的);要点/结论目标在前来源在后
252
+ // (保头去重,清单稳定);来源/流水保留最新(保尾去重),合并记录排在最后。
253
+ func mergeDocFile(src, dst, from, to string) error {
254
+ sb, err := os.ReadFile(src)
255
+ if err != nil {
256
+ return err
257
+ }
258
+ db, err := os.ReadFile(dst)
259
+ if err != nil {
260
+ return err
261
+ }
262
+ a, b := ParseDoc(string(sb)), ParseDoc(string(db))
263
+ desc := b.Desc
264
+ if desc == "" {
265
+ desc = a.Desc
266
+ }
267
+ concs := mergeUnique(b.Conclusions, a.Conclusions, MaxConclusions, true, nil)
268
+ line := todayStr() + " 模块合并:从「" + from + "」并入「" + to + "」"
269
+ hist := append(append([]string{}, b.Log...), a.Log...)
270
+ doc := Doc{
271
+ Final: renderFinal(desc, concs),
272
+ Desc: desc,
273
+ Points: mergeUnique(b.Points, a.Points, MaxPoints, true, nil),
274
+ Conclusions: concs,
275
+ Sources: mergeUnique(b.Sources, a.Sources, MaxSources, false, sourceKey),
276
+ Log: mergeUnique(hist, []string{line}, MaxLogLines, false, nil),
277
+ }
278
+ name := strings.TrimSuffix(filepath.Base(dst), ".md")
279
+ return os.WriteFile(dst, []byte(RenderDoc(name, doc)), 0o600)
280
+ }
281
+
282
+ // noteModuleMerge 给整体搬过来的文档补一行「模块合并」记录(尽力而为,失败不阻断)。
283
+ func noteModuleMerge(file, from, to string) {
284
+ b, err := os.ReadFile(file)
285
+ if err != nil {
286
+ return
287
+ }
288
+ d := ParseDoc(string(b))
289
+ line := todayStr() + " 模块合并:从「" + from + "」并入「" + to + "」"
290
+ d.Log = mergeUnique(d.Log, []string{line}, MaxLogLines, false, nil)
291
+ _ = os.WriteFile(file, []byte(RenderDoc(strings.TrimSuffix(filepath.Base(file), ".md"), d)), 0o600)
292
+ }
293
+
294
+ // moduleDedupeWanted 这一轮要不要顺手做模块去重。
295
+ //
296
+ // · 模块级整理(opts.Module 非空)永远不跑 —— 用户点哪个模块就只动那个,
297
+ // 「只整理一个模块」不能顺手把模块结构改了(2026-10-23 报障的同一条纪律);
298
+ // · 名字毫无交集(无确定性候选)不跑 —— 不为「看起来很干净」的清单烧一次模型;
299
+ // · 自动路径(插件轮末防抖,每轮都可能跑)额外要求本轮真写了新文档;
300
+ // · 手动(含 force / all / 无新记忆的 noop):用户点了「整理」就认真查一次。
301
+ func moduleDedupeWanted(opts Options, res Result, mods []string) bool {
302
+ if len(mods) < 2 || opts.Module != "" {
303
+ return false
304
+ }
305
+ if len(similarModulePairs(mods)) == 0 {
306
+ return false
307
+ }
308
+ if opts.Auto {
309
+ return res.Created > 0
310
+ }
311
+ return true
312
+ }