magic-agent 2.1.5 → 2.3.2

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 (78) hide show
  1. package/CLIENT-CONTRACT.md +147 -11
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +17 -0
  4. package/client-ui/pc/index.html +5 -0
  5. package/client-ui/pc/js/05-task-list-render.js +28 -4
  6. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  7. package/client-ui/pc/js/93-rail.js +24 -2
  8. package/client-ui/shared/conv/03-chat-message.js +63 -3
  9. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  10. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  11. package/client-ui/shared/conv/19-attachments.js +75 -11
  12. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  13. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  14. package/client-ui/shared/conv/README.md +6 -0
  15. package/client-ui/shared/conv/conv.css +33 -1
  16. package/client-ui/shared/conversation.css +42 -4
  17. package/client-ui/shared/conversation.js +801 -102
  18. package/client-ui/shared/transport-http.js +199 -38
  19. package/client-ui-mobile/index.html +731 -581
  20. package/client-ui-mobile/reqs.css +39 -0
  21. package/client-ui-mobile/reqs.js +520 -0
  22. package/cmd/magic-client/main.go +1 -1
  23. package/internal/agent/capability.go +8 -1
  24. package/internal/agent/claude.go +5 -3
  25. package/internal/agent/codebuddy.go +6 -2
  26. package/internal/agent/codex.go +7 -2
  27. package/internal/agent/dsh_sdk.go +8 -0
  28. package/internal/agent/dsh_sdk_test.go +17 -0
  29. package/internal/agent/engine.go +20 -0
  30. package/internal/agent/engine_test.go +52 -0
  31. package/internal/agent/llmengine.go +14 -0
  32. package/internal/agent/llmengine_test.go +24 -0
  33. package/internal/agent/llmhttp.go +6 -0
  34. package/internal/agent/llmhttp_test.go +27 -0
  35. package/internal/agent/llmmodels.go +48 -0
  36. package/internal/agent/permission.go +5 -0
  37. package/internal/cli/ask.go +38 -17
  38. package/internal/cli/requirements.go +2 -0
  39. package/internal/cli/root.go +5 -0
  40. package/internal/cli/session.go +16 -14
  41. package/internal/cli/sessionlog.go +5 -0
  42. package/internal/cli/sessionlog_user_test.go +49 -0
  43. package/internal/cli/suggest.go +98 -0
  44. package/internal/cli/suggest_test.go +54 -0
  45. package/internal/cli/team.go +111 -24
  46. package/internal/client/requirements.go +27 -7
  47. package/internal/client/requirements_test.go +51 -0
  48. package/internal/client/service.go +35 -3
  49. package/internal/client/service_test.go +35 -0
  50. package/internal/client/suggest.go +131 -0
  51. package/internal/client/suggest_test.go +116 -0
  52. package/internal/requirements/consolidate.go +86 -24
  53. package/internal/requirements/requirements.go +9 -0
  54. package/internal/requirements/requirements_test.go +129 -0
  55. package/internal/requirements/store.go +11 -3
  56. package/internal/session/delete.go +58 -11
  57. package/internal/session/delete_test.go +140 -0
  58. package/internal/session/session.go +25 -0
  59. package/internal/session/session_mark_test.go +62 -0
  60. package/internal/suggest/suggest.go +532 -0
  61. package/internal/suggest/suggest_test.go +268 -0
  62. package/internal/team/board.go +107 -15
  63. package/internal/team/execute.go +1 -1
  64. package/internal/team/team.go +28 -19
  65. package/internal/team/team_test.go +68 -15
  66. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  67. package/npm/apk/version.json +3 -3
  68. package/npm/dist/darwin-arm64/magic-agent +0 -0
  69. package/npm/dist/darwin-arm64/magic-client +0 -0
  70. package/npm/dist/darwin-x64/magic-agent +0 -0
  71. package/npm/dist/darwin-x64/magic-client +0 -0
  72. package/npm/dist/linux-arm64/magic-agent +0 -0
  73. package/npm/dist/linux-arm64/magic-client +0 -0
  74. package/npm/dist/linux-x64/magic-agent +0 -0
  75. package/npm/dist/linux-x64/magic-client +0 -0
  76. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  77. package/npm/dist/win32-x64/magic-client.exe +0 -0
  78. package/package.json +6 -2
@@ -0,0 +1,131 @@
1
+ package client
2
+
3
+ // suggest.go - `POST /desk/suggest`:生成「下一轮快捷回复」(一轮结束后可点的下一句)。
4
+ //
5
+ // 为什么放在插件而不是各端自己调模型(用户 2026-10-23:「在回复结束后 ai 自动生成
6
+ // 下一轮的建议,点击即可发送」,先给了方案、选了「B. 模型生成」):
7
+ // 1. 提示词与解析口径只能有一份 —— 移动端 / PC 各写一份,同一轮回答会给出不同建议;
8
+ // 2. 模型调用要落在桌面端(引擎 CLI、凭据、配额都在那台机器上),手机只拿结果;
9
+ // 3. **不续接当前会话**:core 的 `--suggest-followups` 是一次独立旁路调用(ToolsOff、
10
+ // 不带 SessionID),不会把「请生成建议」这类指令写进用户正在聊的上下文。
11
+ //
12
+ // 与 `/desk/session/{id}/title/summarize` 同一套做法:插件 exec core,不自己实现归纳/提炼。
13
+ // 失败一律如实回错(502/504),界面侧静默不画按钮 —— 没有建议是合法结果,不拿模板凑。
14
+
15
+ import (
16
+ "bytes"
17
+ "context"
18
+ "encoding/json"
19
+ "io"
20
+ "net/http"
21
+ "os/exec"
22
+ "strings"
23
+ "time"
24
+ "unicode/utf8"
25
+
26
+ "github.com/darren/magic-agent/internal/suggest"
27
+ )
28
+
29
+ const (
30
+ // suggestTimeout 单次生成的超时。界面侧 20s 就放弃(见 conversation.js),
31
+ // 这里留一点余量,避免「进程还没退、上下文已经被撤」的夹缝报错。
32
+ suggestTimeout = 25 * time.Second
33
+ // suggestMaxBody 请求体上限:只该带「这一轮」的问答,超了说明调用方塞了整条会话。
34
+ suggestMaxBody = 64 << 10
35
+ // suggestMaxUserRunes / suggestMaxReplyRunes 服务端兜底截断(core 还会再截一次)。
36
+ suggestMaxUserRunes = 4000
37
+ suggestMaxReplyRunes = 16000
38
+ )
39
+
40
+ // suggestSlots 并发闸:建议是锦上添花,不能因为客户端重试/多开把引擎进程堆起来。
41
+ // 2 = 允许「当前会话收尾」与「刚切过去的另一个会话」各一条,超出直接 429。
42
+ var suggestSlots = make(chan struct{}, 2)
43
+
44
+ // handleSuggest POST /desk/suggest。
45
+ //
46
+ // 请求:{"user":"用户上一句","reply":"本轮回答正文","engine":"可选","model":"可选"}
47
+ // 响应:{"suggestions":["…", "…"], "engine":"llm", "elapsedMs":1234}
48
+ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
49
+ if r.Method != http.MethodPost {
50
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
51
+ return
52
+ }
53
+ var body struct {
54
+ User string `json:"user"`
55
+ Reply string `json:"reply"`
56
+ Engine string `json:"engine"`
57
+ Model string `json:"model"`
58
+ }
59
+ if r.Body != nil {
60
+ if err := json.NewDecoder(io.LimitReader(r.Body, suggestMaxBody)).Decode(&body); err != nil {
61
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+truncateForMsg(err.Error()))
62
+ return
63
+ }
64
+ }
65
+ body.Reply = strings.TrimSpace(body.Reply)
66
+ if body.Reply == "" {
67
+ writeErr(w, http.StatusBadRequest, `缺少 reply(本轮回答正文):形如 {"user":"…","reply":"…"}`)
68
+ return
69
+ }
70
+ body.User = clipRunes(strings.TrimSpace(body.User), suggestMaxUserRunes)
71
+ body.Reply = clipRunes(body.Reply, suggestMaxReplyRunes)
72
+
73
+ select {
74
+ case suggestSlots <- struct{}{}:
75
+ defer func() { <-suggestSlots }()
76
+ default:
77
+ writeErr(w, http.StatusTooManyRequests, "建议生成太频繁:上一轮还没结束,请稍后")
78
+ return
79
+ }
80
+
81
+ ctx, cancel := context.WithTimeout(r.Context(), suggestTimeout)
82
+ defer cancel()
83
+
84
+ payload, _ := json.Marshal(suggest.RequestBody{User: body.User, Reply: body.Reply})
85
+ args := []string{"--suggest-followups", "-"}
86
+ /* 引擎/模型由界面带(当前引擎一定装过);不传则 core 用 llm(见 runSuggestFollowups)。 */
87
+ if e := strings.TrimSpace(body.Engine); e != "" {
88
+ args = append(args, "-e", e)
89
+ }
90
+ if m := strings.TrimSpace(body.Model); m != "" {
91
+ args = append(args, "-m", m)
92
+ }
93
+ cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
94
+ cmd.Stdin = bytes.NewReader(payload)
95
+ var stdout, stderr bytes.Buffer
96
+ cmd.Stdout = &stdout
97
+ cmd.Stderr = &stderr
98
+ if err := cmd.Run(); err != nil {
99
+ if ctx.Err() == context.DeadlineExceeded {
100
+ writeErr(w, http.StatusGatewayTimeout, "生成建议超时(>25s):这一轮先不给建议")
101
+ return
102
+ }
103
+ msg := strings.TrimSpace(stderr.String())
104
+ if msg == "" {
105
+ msg = strings.TrimSpace(stdout.String())
106
+ }
107
+ if msg == "" {
108
+ msg = err.Error()
109
+ }
110
+ writeErr(w, http.StatusBadGateway, "生成建议失败:"+truncateForMsg(msg))
111
+ return
112
+ }
113
+ var res suggest.Result
114
+ if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
115
+ // core 换了输出形状就如实报错,别静默回空(那会表现成「功能时灵时不灵」)。
116
+ writeErr(w, http.StatusBadGateway, "core 回的建议不是 JSON:"+truncateForMsg(stdout.String()))
117
+ return
118
+ }
119
+ if res.Suggestions == nil {
120
+ res.Suggestions = []string{}
121
+ }
122
+ writeJSON(w, http.StatusOK, res)
123
+ }
124
+
125
+ // clipRunes 按 rune 截断(中文不能按字节切)。
126
+ func clipRunes(s string, max int) string {
127
+ if max <= 0 || utf8.RuneCountInString(s) <= max {
128
+ return s
129
+ }
130
+ return string([]rune(s)[:max])
131
+ }
@@ -0,0 +1,116 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ // fakeSuggestCore 写一个假 core 脚本:记录 argv → 吃掉 stdin → 打印一份建议 JSON。
14
+ func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
15
+ t.Helper()
16
+ argLog := filepath.Join(dir, "args.txt")
17
+ p := filepath.Join(dir, "magic-agent")
18
+ body := "#!/bin/sh\nprintf '%s' \"$*\" > " + argLog + "\ncat >/dev/null\n" + scriptBody + "\n"
19
+ if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ return p, argLog
23
+ }
24
+
25
+ func TestSuggestEndpoint(t *testing.T) {
26
+ dir := t.TempDir()
27
+ core, argLog := fakeSuggestCore(t, dir, `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`)
28
+ s := &Service{opts: Options{MagicAgent: core}}
29
+
30
+ req := httptest.NewRequest(http.MethodPost, "/desk/suggest",
31
+ strings.NewReader(`{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"fake-engine","model":"fake-model"}`))
32
+ rec := httptest.NewRecorder()
33
+ s.handleSuggest(rec, req)
34
+ if rec.Code != http.StatusOK {
35
+ t.Fatalf("code = %d,body=%s", rec.Code, rec.Body.String())
36
+ }
37
+ var res struct {
38
+ Suggestions []string `json:"suggestions"`
39
+ Engine string `json:"engine"`
40
+ }
41
+ if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
42
+ t.Fatalf("响应不是 JSON:%v", err)
43
+ }
44
+ if len(res.Suggestions) != 2 || res.Suggestions[0] != "好的,执行吧" {
45
+ t.Fatalf("suggestions = %#v", res.Suggestions)
46
+ }
47
+ args, _ := os.ReadFile(argLog)
48
+ if !strings.Contains(string(args), "--suggest-followups -") {
49
+ t.Fatalf("没走 --suggest-followups:%s", args)
50
+ }
51
+ if !strings.Contains(string(args), "-e fake-engine") || !strings.Contains(string(args), "-m fake-model") {
52
+ t.Fatalf("引擎/模型没透传:%s", args)
53
+ }
54
+ }
55
+
56
+ func TestSuggestEndpointBadRequests(t *testing.T) {
57
+ dir := t.TempDir()
58
+ core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":[]}\n'`)
59
+ s := &Service{opts: Options{MagicAgent: core}}
60
+
61
+ cases := []struct {
62
+ name string
63
+ req *http.Request
64
+ code int
65
+ }{
66
+ {"GET 不允许", httptest.NewRequest(http.MethodGet, "/desk/suggest", nil), http.StatusMethodNotAllowed},
67
+ {"缺 reply", httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(`{"user":"只说了半句"}`)), http.StatusBadRequest},
68
+ {"非法 JSON", httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(`nope`)), http.StatusBadRequest},
69
+ }
70
+ for _, c := range cases {
71
+ rec := httptest.NewRecorder()
72
+ s.handleSuggest(rec, c.req)
73
+ if rec.Code != c.code {
74
+ t.Errorf("%s:code = %d,期望 %d(body=%s)", c.name, rec.Code, c.code, rec.Body.String())
75
+ }
76
+ }
77
+ }
78
+
79
+ func TestSuggestEndpointCoreFailure(t *testing.T) {
80
+ dir := t.TempDir()
81
+ core, _ := fakeSuggestCore(t, dir, "echo 'llm CLI not found' >&2\nexit 3")
82
+ s := &Service{opts: Options{MagicAgent: core}}
83
+ rec := httptest.NewRecorder()
84
+ s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest",
85
+ strings.NewReader(`{"user":"x","reply":"要我继续吗?"}`)))
86
+ if rec.Code != http.StatusBadGateway {
87
+ t.Fatalf("code = %d,期望 502", rec.Code)
88
+ }
89
+ if !strings.Contains(rec.Body.String(), "llm CLI not found") {
90
+ t.Fatalf("core 的失败原因没带回:%s", rec.Body.String())
91
+ }
92
+ }
93
+
94
+ func TestSuggestEndpointGarbageCoreOutput(t *testing.T) {
95
+ dir := t.TempDir()
96
+ core, _ := fakeSuggestCore(t, dir, `printf 'not json\n'`)
97
+ s := &Service{opts: Options{MagicAgent: core}}
98
+ rec := httptest.NewRecorder()
99
+ s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest",
100
+ strings.NewReader(`{"user":"x","reply":"要我继续吗?"}`)))
101
+ if rec.Code != http.StatusBadGateway {
102
+ t.Fatalf("code = %d,期望 502", rec.Code)
103
+ }
104
+ if !strings.Contains(rec.Body.String(), "不是 JSON") {
105
+ t.Fatalf("应明确说 core 的输出不是 JSON:%s", rec.Body.String())
106
+ }
107
+ }
108
+
109
+ func TestClipRunes(t *testing.T) {
110
+ if got := clipRunes("一二三四五", 3); got != "一二三" {
111
+ t.Fatalf("clipRunes = %q", got)
112
+ }
113
+ if got := clipRunes("短", 10); got != "短" {
114
+ t.Fatalf("短文本不该变:%q", got)
115
+ }
116
+ }
@@ -128,7 +128,15 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
128
128
  if Root() == "" {
129
129
  return Result{Workspace: ws.ID, Dir: ws.Dir, Error: "无法确定需求存储目录"}, fmt.Errorf("requirements: no storage root")
130
130
  }
131
- res := Result{Workspace: ws.ID, Dir: ws.Dir}
131
+ moduleScope := SlugifyModule(opts.Module)
132
+ res := Result{Workspace: ws.ID, Dir: ws.Dir, Module: moduleScope}
133
+
134
+ // 模块级整理只更新已有模块,不接受一个不存在的名字(否则模型会把新需求
135
+ // 塞进一个凭空的目录,用户在清单里根本找不到它)。
136
+ if moduleScope != "" && !contains(listModules(ws.ID), moduleScope) {
137
+ res.Error = "模块不存在: " + opts.Module
138
+ return res, fmt.Errorf("requirements: module not found: %s", opts.Module)
139
+ }
132
140
 
133
141
  // 锁:同一工作区同时只允许一轮归纳(客户端防抖 + 手动「立即整理」可能撞上)。
134
142
  release, busy := acquireLock(ws.ID)
@@ -152,8 +160,9 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
152
160
  /* 首次「整理」:先按空间目录反推一版需求基线,再把旧会话记为已消费。
153
161
  判断依据是 state.BootstrapAt(不是文档数 / 不是 LastConsolidatedAt):
154
162
  自动归纳可能已经产出过几篇文档,但「有没有按目录初始化过」是另一件事。
155
- opts.Auto 的自动路径不初始化 —— 它每轮都可能跑,不能悄悄花几分钟读代码。 */
156
- if st.BootstrapAt == 0 && !opts.Auto {
163
+ opts.Auto 的自动路径不初始化 —— 它每轮都可能跑,不能悄悄花几分钟读代码。
164
+ 模块级整理也不初始化:目录基线是空间级的,模块只做增量。 */
165
+ if st.BootstrapAt == 0 && !opts.Auto && moduleScope == "" {
157
166
  boot, berr := bootstrapWorkspace(ctx, ws, opts)
158
167
  if berr != nil {
159
168
  res.Error = truncRunes(berr.Error(), 240)
@@ -206,8 +215,11 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
206
215
  if cursor > len(mem) {
207
216
  cursor = len(mem) // 记忆文件被外部截断 → 游标跟着收敛,不越界
208
217
  }
218
+ /* 模块级整理**不重放**(--requirement-all 是空间级回填),只看游标之后的
219
+ 新记忆;也不推进游标,否则别的模块的需求会被这一轮悄悄吞掉。 */
220
+ replay := opts.All && moduleScope == ""
209
221
  entries := mem[cursor:]
210
- if opts.All {
222
+ if replay {
211
223
  entries = mem
212
224
  }
213
225
  if len(entries) == 0 && !opts.Force {
@@ -218,7 +230,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
218
230
  }
219
231
 
220
232
  use := entries
221
- if opts.All {
233
+ if replay {
222
234
  if len(use) > MaxReplayEntries {
223
235
  use = use[len(use)-MaxReplayEntries:]
224
236
  }
@@ -241,7 +253,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
241
253
  return res, callErr
242
254
  }
243
255
 
244
- chunks := chunkEntries(use, opts.All)
256
+ chunks := chunkEntries(use, replay)
245
257
  merger := newDocMerger(ws.ID)
246
258
  byN := make(map[int]Entry, len(mem))
247
259
  for _, e := range mem {
@@ -256,7 +268,15 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
256
268
  // 每片重新读已有文档:上一片刚写的要点必须出现在这一片的「已有需求点」里,
257
269
  // 否则同一件事会被重复追加。
258
270
  docs := featureDocsOf(ws.ID)
259
- prompt := buildPrompt(wsName, docs, chunk, listModules(ws.ID), opts.All)
271
+ modules := listModules(ws.ID)
272
+ /* 模块级整理:只让模型看到这个模块的已有文档与模块名 ——
273
+ 否则它会把别的模块的新需求也一起写回来(那正是用户点的这一下
274
+ 「只整理一个模块」所不能接受的事)。 */
275
+ if moduleScope != "" {
276
+ docs = docsOfModule(docs, moduleScope)
277
+ modules = []string{moduleScope}
278
+ }
279
+ prompt := buildPrompt(wsName, docs, chunk, modules, replay, moduleScope)
260
280
  text, err := call(prompt, outSchema)
261
281
  res.Calls++
262
282
  if err != nil {
@@ -276,28 +296,34 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
276
296
 
277
297
  // 新模块的同义二审:归纳是逐轮独立的,模型容易把同一件事拆成近义模块
278
298
  //(「任务列表」/「对话列表」)。只有确认已有模块里没有同义项时才允许新建。
299
+ // 模块级整理不新建模块(作用域就是这个已有模块),因而也不做二审。
279
300
  alias := map[string]string{}
280
- existing := listModules(ws.ID)
281
- newMods := []string{}
282
- seenNew := map[string]bool{}
283
- for _, o := range outs {
284
- m := SlugifyModule(o.Module)
285
- if m == "" || seenNew[m] || contains(existing, m) {
286
- continue
301
+ if moduleScope == "" {
302
+ existing := listModules(ws.ID)
303
+ newMods := []string{}
304
+ seenNew := map[string]bool{}
305
+ for _, o := range outs {
306
+ m := SlugifyModule(o.Module)
307
+ if m == "" || seenNew[m] || contains(existing, m) {
308
+ continue
309
+ }
310
+ seenNew[m] = true
311
+ newMods = append(newMods, m)
312
+ }
313
+ if len(newMods) > 0 && len(existing) > 0 {
314
+ alias = resolveModuleAlias(call, newMods, existing)
287
315
  }
288
- seenNew[m] = true
289
- newMods = append(newMods, m)
290
- }
291
- if len(newMods) > 0 && len(existing) > 0 {
292
- alias = resolveModuleAlias(call, newMods, existing)
293
316
  }
294
317
 
295
318
  for _, o := range outs {
319
+ if moduleScope != "" && !outputInModule(o, moduleScope, merger) {
320
+ continue
321
+ }
296
322
  w := writeFeatureItem(ws.ID, o, &writeCtx{
297
323
  merger: merger,
298
324
  alias: alias,
299
325
  kind: "归纳",
300
- all: opts.All,
326
+ all: replay,
301
327
  sourcesOf: func(x featureOut) []string {
302
328
  out := make([]string, 0, len(x.Sources))
303
329
  for _, n := range x.Sources {
@@ -324,7 +350,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
324
350
  }
325
351
 
326
352
  consumed := cursor
327
- if !opts.All {
353
+ if !replay && moduleScope == "" {
328
354
  consumed = cursor + len(use)
329
355
  if consumed > len(mem) {
330
356
  consumed = len(mem)
@@ -339,11 +365,11 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
339
365
  }
340
366
  res.OK = true
341
367
  res.Consumed = 0
342
- if !opts.All {
368
+ if !replay && moduleScope == "" {
343
369
  res.Consumed = len(use)
344
370
  }
345
371
  res.Replayed = 0
346
- if opts.All {
372
+ if replay {
347
373
  res.Replayed = len(use)
348
374
  }
349
375
  res.Pending = len(mem) - consumed
@@ -435,12 +461,38 @@ func featureDocsOf(id string) []existsDoc {
435
461
  return out
436
462
  }
437
463
 
464
+ // docsOfModule 只留某个模块下的已有功能点(模块级整理的作用域)。
465
+ func docsOfModule(docs []existsDoc, module string) []existsDoc {
466
+ out := make([]existsDoc, 0, len(docs))
467
+ for _, d := range docs {
468
+ if SlugifyModule(d.Module) == module {
469
+ out = append(out, d)
470
+ }
471
+ }
472
+ return out
473
+ }
474
+
475
+ // outputInModule 一条模型增量是否属于本次模块级整理的作用域。
476
+ //
477
+ // 已有功能点按 name 匹配后以它**当前所属模块**为准(与 writeFeatureItem 同一口径:
478
+ // 文档一旦归类就不搬家);新功能点才看模型给的 module。
479
+ func outputInModule(o featureOut, scope string, merger *docMerger) bool {
480
+ name := strings.TrimSpace(truncRunes(strings.TrimSpace(o.Name), TitleMaxChars))
481
+ if name == "" {
482
+ return false
483
+ }
484
+ if hit, has := merger.match(name); has {
485
+ return SlugifyModule(hit.Module) == scope
486
+ }
487
+ return SlugifyModule(o.Module) == scope
488
+ }
489
+
438
490
  // buildPrompt 组归纳 prompt。
439
491
  //
440
492
  // 规则段是**产品口径**,不是技术参数:哪些话不能写进需求点(缺陷措辞)、
441
493
  // 什么样的模块必须复用、结论与问题的界限。这些是业务项目半年里一条条纠正出来的,
442
494
  // 换实现时原样保留,别按自己的口味重写。
443
- func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []string, replay bool) string {
495
+ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []string, replay bool, scope string) string {
444
496
  mods := make([]string, 0, len(modules))
445
497
  for _, m := range modules {
446
498
  if strings.TrimSpace(m) != "" {
@@ -531,9 +583,19 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
531
583
  "desc 可用记忆重新校核改写(这是唯一允许改写 desc 的场景)," +
532
584
  "需求点/结论里已有的一字不改照抄、缺的补上。"
533
585
  }
586
+ /* 模块级整理:模型只该动这一个模块。必须写清楚「别的记忆忽略」——
587
+ 否则它会把整段对话重新归类,用户点一个模块却把别的模块也改了。 */
588
+ if scope != "" {
589
+ rules[0] = "0. **本次只整理模块「" + scope + "」**:只输出属于这个模块的功能点" +
590
+ "(新功能点的 module 必须逐字写「" + scope + "」,已有功能点沿用它的 name)。" +
591
+ "不属于这个模块的记忆**一律忽略** —— 它们会在「整理整个空间」时处理。"
592
+ }
534
593
 
535
594
  var b strings.Builder
536
595
  b.WriteString("你是资深产品经理,负责把「对话记忆」归纳成**两级**功能点需求文档的**增量**。工作区:" + wsName + "\n\n")
596
+ if scope != "" {
597
+ b.WriteString("【模块级整理】本次作用域:模块「" + scope + "」。\n\n")
598
+ }
537
599
  b.WriteString("== 已有模块(大功能点 · 一级)==\n")
538
600
  if len(mods) == 0 {
539
601
  b.WriteString("(暂无模块,可新建)")
@@ -127,6 +127,13 @@ type Options struct {
127
127
  Engine string
128
128
  // Model 归纳用哪个模型(空 = 引擎默认)。
129
129
  Model string
130
+ // Module 只整理这个模块(空 = 整个空间)。
131
+ //
132
+ // 语义:只把「属于该模块」的增量写回该模块,**不推进空间游标** ——
133
+ // 其它模块的记忆仍留在 pending,等空间级「整理」或下一次自动归纳处理。
134
+ // 因此模块级整理是「只增不减、可重复跑」的,不会吞掉别的模块的需求。
135
+ // ⚠️ 它不会触发首次目录初始化(目录基线天然是空间级的)。
136
+ Module string
130
137
  // Timeout 单次调用超时(0 = 归纳 240s;首次目录初始化 9m)。
131
138
  Timeout time.Duration
132
139
  // Auto 自动归纳(插件的轮末防抖 / 每日兜底)。
@@ -141,6 +148,8 @@ type Result struct {
141
148
  // Workspace / Dir 归纳的是哪个工作区。
142
149
  Workspace string `json:"workspace,omitempty"`
143
150
  Dir string `json:"dir"`
151
+ // Module 这一轮只整理了哪个模块(空 = 整个空间)。
152
+ Module string `json:"module,omitempty"`
144
153
  // Noop 没有未归纳记忆、也没要求强制跑(等于什么都没做,不是错)。
145
154
  Noop bool `json:"noop,omitempty"`
146
155
  // Bootstrap 这一轮是否完成了首次初始化。
@@ -326,6 +326,27 @@ func TestWorkspacesGroupsAndSorts(t *testing.T) {
326
326
  }
327
327
  }
328
328
 
329
+ // TestResolveWorkspaceByID 按 ws id 也能解析(客户端手里可能只有 id、没有目录)。
330
+ //
331
+ // 老实现的 safeID 把前缀 `ws-` 里的 w/s 也按十六进制校验,任何 id 都过不了:
332
+ // `--consolidate-requirements ws-xxxx` 直接报「工作区不存在」。
333
+ func TestResolveWorkspaceByID(t *testing.T) {
334
+ root := testEnv(t)
335
+ proj := filepath.Join(root, "proj-id")
336
+ if err := os.MkdirAll(proj, 0o700); err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ e := registerWorkspace(proj)
340
+ got, ok := resolveWorkspace(e.ID)
341
+ if !ok || got.ID != e.ID || got.Dir != proj {
342
+ t.Fatalf("按 id 应解析出 %s/%s,实际 %+v ok=%v", e.ID, proj, got, ok)
343
+ }
344
+ // 路径穿越仍要被挡住。
345
+ if safeID("ws-../../etc") || safeID("ws-") || safeID("x-ws-1") {
346
+ t.Error("非法 id 必须被拒绝")
347
+ }
348
+ }
349
+
329
350
  // TestConsolidatePipeline 走完整链路:摄入 → prompt → 模型输出 → 合并 → 游标前进。
330
351
  // 模型用假调用器(真实归纳的网络/CLI 由真机验收覆盖),验的是代码这一侧。
331
352
  func TestConsolidatePipeline(t *testing.T) {
@@ -411,6 +432,114 @@ func TestConsolidatePipeline(t *testing.T) {
411
432
  }
412
433
  }
413
434
 
435
+ // TestConsolidateModuleScope 模块级整理只动这个模块,且不推进空间游标。
436
+ //
437
+ // 用户 2026-10-23 报障:「点需求的一个模块的整理却触发了所有的整理」。
438
+ // 这里钉住三件事:
439
+ // - 模型只看到这个模块的已有文档(看不到别的模块,才不会把别的模块也改了);
440
+ // - 模型即使输出别的模块的增量,也不落盘;
441
+ // - 空间游标不动 —— 别的模块的需求留给空间级整理,不能被这一下吞掉。
442
+ func TestConsolidateModuleScope(t *testing.T) {
443
+ root := testEnv(t)
444
+ proj := filepath.Join(root, "proj-module")
445
+ if err := os.MkdirAll(proj, 0o700); err != nil {
446
+ t.Fatal(err)
447
+ }
448
+ writeSessionLog(t, session.Dir(), "s-mod", proj, "手机号登录要加验证码", "购物车要支持结算")
449
+
450
+ id := DirID(proj)
451
+ merger := newDocMerger(id)
452
+ wctx := &writeCtx{merger: merger, kind: "归纳"}
453
+ for _, o := range []featureOut{
454
+ {Module: "登录", Name: "手机号登录", Points: []string{"手机号可登录"}},
455
+ {Module: "支付", Name: "购物车结算", Points: []string{"购物车可结算"}},
456
+ } {
457
+ if w := writeFeatureItem(id, o, wctx); !w.ok {
458
+ t.Fatalf("预置文档失败: %+v", w)
459
+ }
460
+ }
461
+ // 已初始化 + 游标 0:模拟「有一批待整理记忆,已过了首次目录初始化」。
462
+ if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Sessions: map[string]memCursor{}}); err != nil {
463
+ t.Fatal(err)
464
+ }
465
+
466
+ old := newCaller
467
+ defer func() { newCaller = old }()
468
+ var prompts []string
469
+ newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
470
+ return func(prompt string, schema *agent.JSONSchema) (string, error) {
471
+ prompts = append(prompts, prompt)
472
+ return `{"features":[
473
+ {"module":"登录","name":"手机号登录","points":["支持验证码登录"],"sources":[1]},
474
+ {"module":"支付","name":"购物车结算","points":["支持优惠券"],"sources":[2]}
475
+ ]}`, nil
476
+ }, nil
477
+ }
478
+
479
+ res, err := Consolidate(context.Background(), proj, Options{Module: "登录"})
480
+ if err != nil {
481
+ t.Fatalf("模块级整理失败: %v", err)
482
+ }
483
+ if !res.OK || res.Module != "登录" {
484
+ t.Fatalf("结果应标明模块级整理: %+v", res)
485
+ }
486
+ if res.Consumed != 0 {
487
+ t.Errorf("模块级整理不该推进游标,实际 consumed=%d", res.Consumed)
488
+ }
489
+ if st := readState(id); st.Consumed != 0 {
490
+ t.Errorf("state 游标不该被模块级整理推进,实际 %d", st.Consumed)
491
+ }
492
+ if res.Updated != 1 || res.Created != 0 {
493
+ t.Errorf("只该更新登录模块的 1 篇,实际 %+v", res)
494
+ }
495
+ if len(prompts) != 1 {
496
+ t.Fatalf("应只调 1 次模型,实际 %d", len(prompts))
497
+ }
498
+ if !strings.Contains(prompts[0], "本次只整理模块「登录」") || !strings.Contains(prompts[0], "【模块级整理】") {
499
+ t.Errorf("prompt 应写明模块作用域:\n%s", prompts[0])
500
+ }
501
+ if strings.Contains(prompts[0], "购物车结算") || strings.Contains(prompts[0], "[模块 支付]") {
502
+ t.Errorf("prompt 不该带出别的模块的文档:\n%s", prompts[0])
503
+ }
504
+
505
+ moduleOf := func(name string) (string, Doc) {
506
+ for _, f := range listFeatureFiles(id) {
507
+ if f.Name == name {
508
+ return f.Module, ParseDoc(readFile(t, f.File))
509
+ }
510
+ }
511
+ t.Fatalf("找不到文档 %s", name)
512
+ return "", Doc{}
513
+ }
514
+ if mod, d := moduleOf("手机号登录"); mod != "登录" || !contains(d.Points, "支持验证码登录") {
515
+ t.Errorf("登录模块应收到本模块增量,实际[%s]%+v", mod, d.Points)
516
+ }
517
+ if _, d := moduleOf("购物车结算"); len(d.Points) != 1 || contains(d.Points, "支持优惠券") {
518
+ t.Errorf("别的模块不该被模块级整理改动,实际 %+v", d.Points)
519
+ }
520
+
521
+ // 空间级整理仍应消费这批记忆:两个模块都更新,游标前进。
522
+ res2, err := Consolidate(context.Background(), proj, Options{})
523
+ if err != nil {
524
+ t.Fatalf("空间级整理失败: %v", err)
525
+ }
526
+ if res2.Consumed != 2 || res2.Updated != 2 {
527
+ t.Errorf("空间级整理应消费 2 条并更新 2 篇,实际 %+v", res2)
528
+ }
529
+ if st := readState(id); st.Consumed != 2 {
530
+ t.Errorf("空间级整理后游标应为 2,实际 %d", st.Consumed)
531
+ }
532
+ if _, d := moduleOf("购物车结算"); !contains(d.Points, "支持优惠券") {
533
+ t.Errorf("空间级整理应补上支付模块的增量,实际 %+v", d.Points)
534
+ }
535
+
536
+ // 不存在的模块要明确报错,不能凭空建目录。
537
+ if _, err := Consolidate(context.Background(), proj, Options{Module: "不存在"}); err == nil ||
538
+ !strings.Contains(err.Error(), "module not found") {
539
+ t.Errorf("不存在的模块应报错,实际 %v", err)
540
+ }
541
+ }
542
+
414
543
  // TestConsolidateBootstrapsOnceThenIncremental 首次「整理」按目录反推,之后只处理新会话。
415
544
  //
416
545
  // 这是 2026-10-22 用户口径的代码化:
@@ -77,11 +77,19 @@ func registryFile() string { return filepath.Join(Root(), "workspaces.json") }
77
77
 
78
78
  // safeID 工作区 id 会拼进路径 → 只放行本包生成过的形状(挡目录穿越)。
79
79
  func safeID(id string) bool {
80
- if !strings.HasPrefix(id, "ws-") || len(id) > 40 {
80
+ const prefix = "ws-"
81
+ if !strings.HasPrefix(id, prefix) || len(id) > 40 {
81
82
  return false
82
83
  }
83
- for _, r := range id {
84
- if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || r == '-' {
84
+ // 前缀 `ws-` 本身含 w/s,不能拿去跑十六进制校验(老实现的循环把整个 id
85
+ // 都按 [0-9a-f-] 过滤,于是任何 ws id 都过不了 —— 按 id 整理 workspace
86
+ // 会报「工作区不存在」)。只校验前缀之后的部分。
87
+ rest := strings.TrimPrefix(id, prefix)
88
+ if rest == "" {
89
+ return false
90
+ }
91
+ for _, r := range rest {
92
+ if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') {
85
93
  continue
86
94
  }
87
95
  return false