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.
- package/CLIENT-CONTRACT.md +147 -11
- package/README.md +10 -4
- package/client-ui/pc/app.css +17 -0
- package/client-ui/pc/index.html +5 -0
- package/client-ui/pc/js/05-task-list-render.js +28 -4
- package/client-ui/pc/js/07-chat-stream-render.js +19 -8
- package/client-ui/pc/js/93-rail.js +24 -2
- package/client-ui/shared/conv/03-chat-message.js +63 -3
- package/client-ui/shared/conv/06-ask-answer.js +30 -7
- package/client-ui/shared/conv/09-stream-apply.js +16 -0
- package/client-ui/shared/conv/19-attachments.js +75 -11
- package/client-ui/shared/conv/27-conv-ledger.js +1 -1
- package/client-ui/shared/conv/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +33 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +801 -102
- package/client-ui/shared/transport-http.js +199 -38
- package/client-ui-mobile/index.html +731 -581
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +520 -0
- package/cmd/magic-client/main.go +1 -1
- package/internal/agent/capability.go +8 -1
- package/internal/agent/claude.go +5 -3
- package/internal/agent/codebuddy.go +6 -2
- package/internal/agent/codex.go +7 -2
- package/internal/agent/dsh_sdk.go +8 -0
- package/internal/agent/dsh_sdk_test.go +17 -0
- package/internal/agent/engine.go +20 -0
- package/internal/agent/engine_test.go +52 -0
- package/internal/agent/llmengine.go +14 -0
- package/internal/agent/llmengine_test.go +24 -0
- package/internal/agent/llmhttp.go +6 -0
- package/internal/agent/llmhttp_test.go +27 -0
- package/internal/agent/llmmodels.go +48 -0
- package/internal/agent/permission.go +5 -0
- package/internal/cli/ask.go +38 -17
- package/internal/cli/requirements.go +2 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/sessionlog.go +5 -0
- package/internal/cli/sessionlog_user_test.go +49 -0
- package/internal/cli/suggest.go +98 -0
- package/internal/cli/suggest_test.go +54 -0
- package/internal/cli/team.go +111 -24
- package/internal/client/requirements.go +27 -7
- package/internal/client/requirements_test.go +51 -0
- package/internal/client/service.go +35 -3
- package/internal/client/service_test.go +35 -0
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/requirements/consolidate.go +86 -24
- package/internal/requirements/requirements.go +9 -0
- package/internal/requirements/requirements_test.go +129 -0
- package/internal/requirements/store.go +11 -3
- package/internal/session/delete.go +58 -11
- package/internal/session/delete_test.go +140 -0
- package/internal/session/session.go +25 -0
- package/internal/session/session_mark_test.go +62 -0
- package/internal/suggest/suggest.go +532 -0
- package/internal/suggest/suggest_test.go +268 -0
- package/internal/team/board.go +107 -15
- package/internal/team/execute.go +1 -1
- package/internal/team/team.go +28 -19
- package/internal/team/team_test.go +68 -15
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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:
|
|
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 !
|
|
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 !
|
|
368
|
+
if !replay && moduleScope == "" {
|
|
343
369
|
res.Consumed = len(use)
|
|
344
370
|
}
|
|
345
371
|
res.Replayed = 0
|
|
346
|
-
if
|
|
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
|
-
|
|
80
|
+
const prefix = "ws-"
|
|
81
|
+
if !strings.HasPrefix(id, prefix) || len(id) > 40 {
|
|
81
82
|
return false
|
|
82
83
|
}
|
|
83
|
-
|
|
84
|
-
|
|
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
|