magic-agent 1.0.46 → 1.0.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2168 -143
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-agent.js +24 -1
- package/npm/bin/magic-client.js +20 -2
- 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 +1 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
// memory.go - 记忆流:把**会话里的用户原话**增量收进工作区记忆。
|
|
4
|
+
//
|
|
5
|
+
// 与业务项目那份的区别(本包的核心改动):记忆不再由客户端在发问时埋点写
|
|
6
|
+
// memory.jsonl,而是 core 自己从会话事件日志里读 `kind:"user"`(见
|
|
7
|
+
// internal/cli/sessionlog.go 的落盘口径)。于是:
|
|
8
|
+
//
|
|
9
|
+
// · 谁发起的会话都算 —— PC / 移动端 / 直接跑 CLI 的;
|
|
10
|
+
// · 不需要任何客户端配合,功能是 core 的标准能力;
|
|
11
|
+
// · 追问轮次天然带上(每条 user 事件各算一条记忆)。
|
|
12
|
+
//
|
|
13
|
+
// 增量靠**字节偏移**而不是 seq:日志只追加,偏移是最便宜的续读游标(seek 即读,
|
|
14
|
+
// 与文件总长无关)。seq 也记着,仅用于「记忆 → 来源」的引用与排障。
|
|
15
|
+
// ⚠️ 文件被截断(删会话后重建同名)时偏移会大于文件大小 —— 此时从头重读,
|
|
16
|
+
// 代价只有一次。
|
|
17
|
+
|
|
18
|
+
import (
|
|
19
|
+
"bufio"
|
|
20
|
+
"encoding/json"
|
|
21
|
+
"os"
|
|
22
|
+
"path/filepath"
|
|
23
|
+
"sort"
|
|
24
|
+
"strings"
|
|
25
|
+
"time"
|
|
26
|
+
|
|
27
|
+
"github.com/darren/magic-agent/internal/session"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// memCursor 一条会话的摄入游标。
|
|
31
|
+
type memCursor struct {
|
|
32
|
+
// Seq 已摄入到的最大事件 seq(只用于引用与排障)。
|
|
33
|
+
Seq uint64 `json:"seq,omitempty"`
|
|
34
|
+
// Offset 已摄入到的字节偏移(续读用;日志只追加,所以它是可靠的)。
|
|
35
|
+
Offset int64 `json:"offset,omitempty"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// logEvent 只解我们关心的字段(日志里其它字段一概不管)。
|
|
39
|
+
type logEvent struct {
|
|
40
|
+
Seq uint64 `json:"seq"`
|
|
41
|
+
Kind string `json:"kind"`
|
|
42
|
+
Text string `json:"text"`
|
|
43
|
+
At string `json:"at"`
|
|
44
|
+
SessionID string `json:"session_id"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// sessionDirFor 一条登记记录归属的目录。
|
|
48
|
+
//
|
|
49
|
+
// 两条来源,优先显式工作目录:
|
|
50
|
+
// - Workspace:调用方 `-w/--workspace` 指定的(引擎真正干活的目录);
|
|
51
|
+
// - Cwd:**没有** -w 时那条会话实际跑的目录(core 在 Begin 时记下)。
|
|
52
|
+
//
|
|
53
|
+
// 都没有 → 空(不归属任何工作区:宁可漏掉,也不要把它塞进一个错的目录)。
|
|
54
|
+
func sessionDirFor(r session.Record) string {
|
|
55
|
+
if d := strings.TrimSpace(r.Workspace); d != "" {
|
|
56
|
+
return normalizeDir(d)
|
|
57
|
+
}
|
|
58
|
+
return normalizeDir(r.Cwd)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ingest 把「该目录下所有会话的新 user 事件」补进记忆流。
|
|
62
|
+
//
|
|
63
|
+
// 返回新增条数。不做并发保护 —— 调用方(Consolidate)已持有工作区锁。
|
|
64
|
+
func ingest(wsID string, dir string, st *state) (int, error) {
|
|
65
|
+
recs, err := session.List()
|
|
66
|
+
if err != nil {
|
|
67
|
+
return 0, err
|
|
68
|
+
}
|
|
69
|
+
// 日志目录与 core 落盘时同一份(session.Dir 认 MAGIC_AGENT_SESSIONS,
|
|
70
|
+
// 默认 ~/.magic-agent/sessions)—— 用 DefaultLogDir 会绕开环境变量,
|
|
71
|
+
// 单测与「换一套数据」时就找不到日志了。
|
|
72
|
+
logDir := session.Dir()
|
|
73
|
+
added := 0
|
|
74
|
+
memN := len(readMemory(wsID))
|
|
75
|
+
pending := []Entry{}
|
|
76
|
+
for _, r := range recs {
|
|
77
|
+
if sessionDirFor(r) != dir {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
key := strings.TrimSpace(r.SessionID)
|
|
81
|
+
if key == "" {
|
|
82
|
+
key = strings.TrimSpace(r.RunID)
|
|
83
|
+
}
|
|
84
|
+
if key == "" {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
// 日志名可能是 session_id,也可能是 run_id(引擎没吐 id 时先按 run_id 落盘)。
|
|
88
|
+
logID := session.ResolveLogID(logDir, key)
|
|
89
|
+
if logID == "" {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
cur := st.Sessions[key]
|
|
93
|
+
evs, endOff, maxSeq, err := readUserEvents(filepath.Join(logDir, logID+".jsonl"), cur.Offset)
|
|
94
|
+
if err != nil {
|
|
95
|
+
continue // 读不动这一条不影响别的会话(记忆是尽力而为)
|
|
96
|
+
}
|
|
97
|
+
for _, ev := range evs {
|
|
98
|
+
text := strings.TrimSpace(ev.Text)
|
|
99
|
+
if text == "" {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if len([]rune(text)) > MaxMemoryChars {
|
|
103
|
+
text = string([]rune(text)[:MaxMemoryChars])
|
|
104
|
+
}
|
|
105
|
+
memN++
|
|
106
|
+
pending = append(pending, Entry{
|
|
107
|
+
N: memN,
|
|
108
|
+
Ts: parseLogTime(ev.At),
|
|
109
|
+
Session: key,
|
|
110
|
+
Seq: ev.Seq,
|
|
111
|
+
Title: sessionTitle(r),
|
|
112
|
+
Text: strings.Join(strings.Fields(text), " "),
|
|
113
|
+
})
|
|
114
|
+
added++
|
|
115
|
+
}
|
|
116
|
+
if endOff != cur.Offset || maxSeq > cur.Seq {
|
|
117
|
+
seq := cur.Seq
|
|
118
|
+
if maxSeq > seq {
|
|
119
|
+
seq = maxSeq
|
|
120
|
+
}
|
|
121
|
+
st.Sessions[key] = memCursor{Seq: seq, Offset: endOff}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if added > 0 {
|
|
125
|
+
if err := appendMemory(wsID, pending); err != nil {
|
|
126
|
+
return 0, err
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return added, nil
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// sessionTitle 一条会话的展示标题(归纳过的用归纳结果,否则回落提示词开头)。
|
|
133
|
+
//
|
|
134
|
+
// 记忆里带标题只是给 prompt 一点上下文(「(任务: xxx)」)—— 需求标题本身由模型归纳,
|
|
135
|
+
// 不拿会话标题当需求标题:会话标题说的是「这一轮在做什么」,需求标题说的是「这个功能
|
|
136
|
+
// 应当是什么样」。
|
|
137
|
+
func sessionTitle(r session.Record) string {
|
|
138
|
+
if t := session.TitleForAny(r.SessionID, r.RunID); t != "" {
|
|
139
|
+
return t
|
|
140
|
+
}
|
|
141
|
+
return strings.TrimSpace(r.PromptHead)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// readUserEvents 从 offset 起读日志,返回新出现的 user 事件、新偏移、最大 seq。
|
|
145
|
+
//
|
|
146
|
+
// offset 越过文件末尾(日志被截断 / 重建)→ 从头读:宁可重放一次,也不要永久漏掉。
|
|
147
|
+
func readUserEvents(path string, offset int64) ([]logEvent, int64, uint64, error) {
|
|
148
|
+
f, err := os.Open(path)
|
|
149
|
+
if err != nil {
|
|
150
|
+
if os.IsNotExist(err) {
|
|
151
|
+
return nil, offset, 0, nil
|
|
152
|
+
}
|
|
153
|
+
return nil, offset, 0, err
|
|
154
|
+
}
|
|
155
|
+
defer f.Close()
|
|
156
|
+
st, err := f.Stat()
|
|
157
|
+
if err != nil {
|
|
158
|
+
return nil, offset, 0, err
|
|
159
|
+
}
|
|
160
|
+
size := st.Size()
|
|
161
|
+
if offset > size {
|
|
162
|
+
offset = 0
|
|
163
|
+
}
|
|
164
|
+
if _, err := f.Seek(offset, 0); err != nil {
|
|
165
|
+
return nil, offset, 0, err
|
|
166
|
+
}
|
|
167
|
+
var out []logEvent
|
|
168
|
+
var maxSeq uint64
|
|
169
|
+
pos := offset
|
|
170
|
+
rd := bufio.NewReaderSize(f, 64*1024)
|
|
171
|
+
for {
|
|
172
|
+
line, err := rd.ReadString('\n')
|
|
173
|
+
if line != "" {
|
|
174
|
+
// 末尾半行(还没写完换行)不算:下次从这一行的起点重读,不能推进偏移。
|
|
175
|
+
if !strings.HasSuffix(line, "\n") {
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
pos += int64(len(line))
|
|
179
|
+
trimmed := strings.TrimSpace(line)
|
|
180
|
+
if trimmed != "" {
|
|
181
|
+
var ev logEvent
|
|
182
|
+
if json.Unmarshal([]byte(trimmed), &ev) == nil {
|
|
183
|
+
if ev.Seq > maxSeq {
|
|
184
|
+
maxSeq = ev.Seq
|
|
185
|
+
}
|
|
186
|
+
if ev.Kind == "user" {
|
|
187
|
+
out = append(out, ev)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if err != nil {
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out, pos, maxSeq, nil
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// parseLogTime 日志里的 at 是 RFC3339;解不出来用当前时间(时间只用于展示)。
|
|
200
|
+
func parseLogTime(s string) int64 {
|
|
201
|
+
if t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(s)); err == nil {
|
|
202
|
+
return t.UnixMilli()
|
|
203
|
+
}
|
|
204
|
+
return time.Now().UnixMilli()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Workspaces 列出所有工作区及其需求聚合状态。
|
|
208
|
+
//
|
|
209
|
+
// 两个来源合并:① 注册表(有文档、可能已没有会话)② 会话侧反推的目录(有会话但还没
|
|
210
|
+
// 归纳过)。后者也要出现 —— 否则用户看不到「这个目录已经聊了几句、还没成文档」。
|
|
211
|
+
func Workspaces() ([]Workspace, error) {
|
|
212
|
+
seen := map[string]workspaceEntry{}
|
|
213
|
+
for _, e := range readRegistry() {
|
|
214
|
+
seen[e.ID] = e
|
|
215
|
+
}
|
|
216
|
+
recs, _ := session.List()
|
|
217
|
+
for _, r := range recs {
|
|
218
|
+
d := sessionDirFor(r)
|
|
219
|
+
if d == "" {
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
id := DirID(d)
|
|
223
|
+
if id == "" {
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if _, ok := seen[id]; !ok {
|
|
227
|
+
seen[id] = workspaceEntry{ID: id, Dir: d, Name: filepath.Base(d)}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out := make([]Workspace, 0, len(seen))
|
|
231
|
+
for _, e := range seen {
|
|
232
|
+
st := readState(e.ID)
|
|
233
|
+
mem := readMemory(e.ID)
|
|
234
|
+
w := Workspace{
|
|
235
|
+
ID: e.ID,
|
|
236
|
+
Dir: e.Dir,
|
|
237
|
+
Name: e.Name,
|
|
238
|
+
Memory: len(mem),
|
|
239
|
+
Pending: max(0, len(mem)-st.Consumed),
|
|
240
|
+
Initialized: st.BootstrapAt > 0,
|
|
241
|
+
LastConsolidatedAt: st.LastConsolidatedAt,
|
|
242
|
+
LastError: st.LastError,
|
|
243
|
+
Modules: groupModules(e.ID),
|
|
244
|
+
}
|
|
245
|
+
for _, m := range w.Modules {
|
|
246
|
+
w.FeatureCount += len(m.Features)
|
|
247
|
+
}
|
|
248
|
+
if w.Name == "" {
|
|
249
|
+
w.Name = e.ID
|
|
250
|
+
}
|
|
251
|
+
out = append(out, w)
|
|
252
|
+
}
|
|
253
|
+
sort.Slice(out, func(i, j int) bool {
|
|
254
|
+
if out[i].LastConsolidatedAt != out[j].LastConsolidatedAt {
|
|
255
|
+
return out[i].LastConsolidatedAt > out[j].LastConsolidatedAt
|
|
256
|
+
}
|
|
257
|
+
return out[i].Name < out[j].Name
|
|
258
|
+
})
|
|
259
|
+
return out, nil
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// groupModules 按模块分组的功能点清单(未归类的 module 为空串,排在最后)。
|
|
263
|
+
func groupModules(id string) []Module {
|
|
264
|
+
files := listFeatureFiles(id)
|
|
265
|
+
idx := map[string]int{}
|
|
266
|
+
out := []Module{}
|
|
267
|
+
for _, f := range files {
|
|
268
|
+
i, ok := idx[f.Module]
|
|
269
|
+
if !ok {
|
|
270
|
+
i = len(out)
|
|
271
|
+
idx[f.Module] = i
|
|
272
|
+
out = append(out, Module{Name: f.Module})
|
|
273
|
+
}
|
|
274
|
+
out[i].Features = append(out[i].Features, featureSummary(f))
|
|
275
|
+
}
|
|
276
|
+
sort.SliceStable(out, func(i, j int) bool {
|
|
277
|
+
if (out[i].Name == "") != (out[j].Name == "") {
|
|
278
|
+
return out[j].Name == ""
|
|
279
|
+
}
|
|
280
|
+
return out[i].Name < out[j].Name
|
|
281
|
+
})
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// featureSummary 读一篇文档,给出卡片需要的展示口径。
|
|
286
|
+
//
|
|
287
|
+
// 为什么带「首条结论」而不只是条数:这一页最该被看见的是**归纳出来的结论**,
|
|
288
|
+
// 只给条数的话,用户还是只能看到「记录了多少条」。
|
|
289
|
+
func featureSummary(f featureFile) Feature {
|
|
290
|
+
item := Feature{Module: f.Module, Name: f.Name, File: f.File}
|
|
291
|
+
if st, err := os.Stat(f.File); err == nil {
|
|
292
|
+
item.Bytes = st.Size()
|
|
293
|
+
item.UpdatedAt = st.ModTime().UnixMilli()
|
|
294
|
+
}
|
|
295
|
+
b, err := os.ReadFile(f.File)
|
|
296
|
+
if err != nil {
|
|
297
|
+
return item
|
|
298
|
+
}
|
|
299
|
+
d := ParseDoc(string(b))
|
|
300
|
+
item.Desc = d.Desc
|
|
301
|
+
item.Points = len(d.Points)
|
|
302
|
+
item.Conclusions = len(d.Conclusions)
|
|
303
|
+
item.Sources = len(d.Sources)
|
|
304
|
+
item.HasFinal = strings.TrimSpace(d.Final) != ""
|
|
305
|
+
if len(d.Conclusions) > 0 {
|
|
306
|
+
item.Conclusion = d.Conclusions[0]
|
|
307
|
+
}
|
|
308
|
+
for _, line := range strings.Split(d.Final, "\n") {
|
|
309
|
+
v := strings.TrimSpace(line)
|
|
310
|
+
if v == "" || (strings.HasPrefix(v, "**") && strings.HasSuffix(v, "**")) || isPlaceholder(v) {
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
item.FinalPreview = truncRunes(v, 140)
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
return item
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
func max(a, b int) int {
|
|
320
|
+
if a > b {
|
|
321
|
+
return a
|
|
322
|
+
}
|
|
323
|
+
return b
|
|
324
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Package requirements - 「根据会话自动聚合成需求」的 core 侧唯一实现。
|
|
2
|
+
//
|
|
3
|
+
// 能力从业务项目(掌天瓶 / magic-test 的 desktop/requirements.cjs)收进 core
|
|
4
|
+
// (用户 2026-10-22:「将 magic-test 过程已实现的根据会话自动聚合成需求的功能变成
|
|
5
|
+
// magic-agent 的标准能力」)。收进来而不是让每个客户端各写一份的理由,与「会话标题」
|
|
6
|
+
// 「会话状态」同源:同一件事在两处实现,迟早在「归到哪个功能点」「哪份优先」上分叉。
|
|
7
|
+
//
|
|
8
|
+
// 与业务项目那份的两处**实质差异**(不是搬家,是修正):
|
|
9
|
+
//
|
|
10
|
+
// ① 记忆来源改成 core 自己的会话数据 —— 不再要求客户端在发问时埋点写 memory.jsonl。
|
|
11
|
+
// core 本来就把每条会话的用户提问落在 <dir>/<session_id>.jsonl 的 kind:"user" 事件里
|
|
12
|
+
// (见 internal/cli/sessionlog.go),这里按会话增量读它即可。于是「谁发起的会话」
|
|
13
|
+
// 不影响聚合:PC / 移动端 / 直接跑 CLI 的会话一视同仁。
|
|
14
|
+
// 归属用「登记表里的 workspace,空则用那条会话的 cwd」(见 session.Record.Cwd)——
|
|
15
|
+
// 不指定 -w 的会话也要能归到它实际跑的那个目录。
|
|
16
|
+
//
|
|
17
|
+
// ② 记忆编号(#N)是**单调追加**的,游标按条数前进就是安全的。业务项目那份的游标
|
|
18
|
+
// 也按条数,但续跑旧会话时记忆会插进时间序中间 —— 顺序假设不成立。这里的记忆流
|
|
19
|
+
// 只由本包 append(按新会话/新事件追加),编号一旦给出就不再变。
|
|
20
|
+
//
|
|
21
|
+
// 存储(~/.magic-agent/requirements/,可用 MAGIC_AGENT_REQUIREMENTS 覆盖):
|
|
22
|
+
//
|
|
23
|
+
// workspaces.json 工作区注册表(目录 ↔ ws id,给「没有会话也在列表里」用)
|
|
24
|
+
// <wsid>/state.json 归纳游标 + 每会话已读到的 seq + 最后归纳时间/错误
|
|
25
|
+
// <wsid>/memory.jsonl 记忆流(一行一条 {n,ts,session,seq,text},追加型)
|
|
26
|
+
// <wsid>/features/<模块>/<名>.md 功能点需求文档(一人一篇,用户要能在访达里直接打开)
|
|
27
|
+
//
|
|
28
|
+
// 为什么文档不塞进数据库:一个功能点一份 markdown、用户要直接打开看,这是产品形态;
|
|
29
|
+
// 而 core 至今零原生依赖(无 CGO / 无 sqlite 驱动),也不该为这件事引入一个。
|
|
30
|
+
package requirements
|
|
31
|
+
|
|
32
|
+
import "time"
|
|
33
|
+
|
|
34
|
+
// 归纳输入的硬上限(防 prompt 爆炸,与业务项目同一口径)。
|
|
35
|
+
const (
|
|
36
|
+
// MaxEntriesPerRun 单轮增量归纳最多消费的记忆条数。
|
|
37
|
+
MaxEntriesPerRun = 80
|
|
38
|
+
// MaxReplayEntries 全量重放最多回看的历史条数。
|
|
39
|
+
MaxReplayEntries = 400
|
|
40
|
+
// ReplayChunk 全量重放每片喂给模型的条数(分片是因为一次要模型输出太多会被截断)。
|
|
41
|
+
ReplayChunk = 20
|
|
42
|
+
// MaxMemoryChars 单条记忆入库截断。
|
|
43
|
+
MaxMemoryChars = 2000
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// 文档各段的合并上限(增量累积,不整篇重写)。
|
|
47
|
+
//
|
|
48
|
+
// 需求点 / 结论保留最早的(清单要稳定,后来者补在尾部);来源 / 更新记录保留最新的
|
|
49
|
+
// (证据与流水越新越有用)。
|
|
50
|
+
const (
|
|
51
|
+
MaxPoints = 40
|
|
52
|
+
MaxConclusions = 20
|
|
53
|
+
MaxSources = 30
|
|
54
|
+
MaxLogLines = 10
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// SourceChars 「来源对话」里每条原话的截断长度。
|
|
58
|
+
const SourceChars = 60
|
|
59
|
+
|
|
60
|
+
// TitleMaxChars 功能点名称的硬上限(汉字个数)。
|
|
61
|
+
const TitleMaxChars = 10
|
|
62
|
+
|
|
63
|
+
// ModuleMaxChars 模块(大功能点)名硬上限。
|
|
64
|
+
const ModuleMaxChars = 6
|
|
65
|
+
|
|
66
|
+
// Feature 一个功能点(= 一篇文档)在清单里的展示口径。
|
|
67
|
+
type Feature struct {
|
|
68
|
+
// Module 所属模块(大功能点);空 = 未归类(平铺目录里的老数据)。
|
|
69
|
+
Module string `json:"module"`
|
|
70
|
+
// Name 功能点名称(= 文档文件名)。
|
|
71
|
+
Name string `json:"name"`
|
|
72
|
+
// File 文档绝对路径(客户端点开就是它)。
|
|
73
|
+
File string `json:"file"`
|
|
74
|
+
Bytes int64 `json:"bytes"`
|
|
75
|
+
UpdatedAt int64 `json:"updated_at"`
|
|
76
|
+
// Desc 一句话需求描述(文档「需求描述」段)。
|
|
77
|
+
Desc string `json:"desc,omitempty"`
|
|
78
|
+
// Points / Conclusions 需求点与已定结论的条数(卡片上的「归纳成果」)。
|
|
79
|
+
Points int `json:"points"`
|
|
80
|
+
Conclusions int `json:"conclusions"`
|
|
81
|
+
// Conclusion 首条结论(卡片上直接露出:有结论才说明真归纳过)。
|
|
82
|
+
Conclusion string `json:"conclusion,omitempty"`
|
|
83
|
+
// Sources 来源对话条数。
|
|
84
|
+
Sources int `json:"sources"`
|
|
85
|
+
// HasFinal 有没有生成过「最终版」。
|
|
86
|
+
HasFinal bool `json:"has_final"`
|
|
87
|
+
// FinalPreview 最终版的第一句实质内容(卡片头条)。
|
|
88
|
+
FinalPreview string `json:"final_preview,omitempty"`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Module 一个模块(两级结构的一级)及其下的功能点。
|
|
92
|
+
type Module struct {
|
|
93
|
+
Name string `json:"name"`
|
|
94
|
+
Features []Feature `json:"features"`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Workspace 一个工作区的需求聚合状态。
|
|
98
|
+
type Workspace struct {
|
|
99
|
+
ID string `json:"id"`
|
|
100
|
+
Dir string `json:"dir"`
|
|
101
|
+
Name string `json:"name"`
|
|
102
|
+
// Pending 还没被归纳的记忆条数。
|
|
103
|
+
Pending int `json:"pending"`
|
|
104
|
+
// Memory 记忆流总条数。
|
|
105
|
+
Memory int `json:"memory"`
|
|
106
|
+
// Modules 按模块分好组的功能点(未归类的 module 为空串,排在最后)。
|
|
107
|
+
Modules []Module `json:"modules"`
|
|
108
|
+
// FeatureCount 功能点总数。
|
|
109
|
+
FeatureCount int `json:"feature_count"`
|
|
110
|
+
// Initialized 是否已完成首次「按空间目录反推需求基线」。
|
|
111
|
+
// false = 下一次手动「整理」会先扫目录生成基线,而不是只消费新会话。
|
|
112
|
+
Initialized bool `json:"initialized"`
|
|
113
|
+
// LastConsolidatedAt 最后一次归纳完成的时刻(unix 毫秒,0 = 还没归纳过)。
|
|
114
|
+
LastConsolidatedAt int64 `json:"last_consolidated_at"`
|
|
115
|
+
// LastError 上一次归纳失败的原因(空 = 正常)。
|
|
116
|
+
LastError string `json:"last_error,omitempty"`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Options 归纳选项。
|
|
120
|
+
type Options struct {
|
|
121
|
+
// Force 没有未归纳记忆也跑一轮(手动「立即整理」)。
|
|
122
|
+
Force bool
|
|
123
|
+
// All 全量重放:忽略游标把历史记忆整体重放一遍(回填 / 滚动校正)。
|
|
124
|
+
// ⚠️ 仍然走同一套增量合并(只增不减),不会推翻已有内容。
|
|
125
|
+
All bool
|
|
126
|
+
// Engine 归纳用哪个引擎(空 = llm:纯文本、不带工具)。
|
|
127
|
+
Engine string
|
|
128
|
+
// Model 归纳用哪个模型(空 = 引擎默认)。
|
|
129
|
+
Model string
|
|
130
|
+
// Timeout 单次调用超时(0 = 归纳 240s;首次目录初始化 9m)。
|
|
131
|
+
Timeout time.Duration
|
|
132
|
+
// Auto 自动归纳(插件的轮末防抖 / 每日兜底)。
|
|
133
|
+
// 自动路径**不会**触发首次目录初始化:它可能每轮都跑,不能悄悄花几分钟读代码。
|
|
134
|
+
// 用户手动「整理」(--consolidate-requirements)不传这个标记,于是首次点击会初始化。
|
|
135
|
+
Auto bool
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Result 一轮归纳的结果(CLI 输出 / HTTP 响应共用)。
|
|
139
|
+
type Result struct {
|
|
140
|
+
OK bool `json:"ok"`
|
|
141
|
+
// Workspace / Dir 归纳的是哪个工作区。
|
|
142
|
+
Workspace string `json:"workspace,omitempty"`
|
|
143
|
+
Dir string `json:"dir"`
|
|
144
|
+
// Noop 没有未归纳记忆、也没要求强制跑(等于什么都没做,不是错)。
|
|
145
|
+
Noop bool `json:"noop,omitempty"`
|
|
146
|
+
// Bootstrap 这一轮是否完成了首次初始化。
|
|
147
|
+
// 目录为空 / 没识别到功能点也算(标记已落盘,用户点过就该有反馈)。
|
|
148
|
+
Bootstrap bool `json:"bootstrap,omitempty"`
|
|
149
|
+
// BootstrapEngine 初始化用的引擎(Bootstrap=true 时有值)。
|
|
150
|
+
BootstrapEngine string `json:"bootstrap_engine,omitempty"`
|
|
151
|
+
// Scanned 初始化扫描到的目录条目数(排障用)。
|
|
152
|
+
Scanned int `json:"scanned,omitempty"`
|
|
153
|
+
// Updated / Created 本轮更新 / 新建的功能点篇数。
|
|
154
|
+
Updated int `json:"updated"`
|
|
155
|
+
Created int `json:"created"`
|
|
156
|
+
// Points / Conclusions 本轮新增的需求点与结论条数。
|
|
157
|
+
Points int `json:"points"`
|
|
158
|
+
Conclusions int `json:"conclusions"`
|
|
159
|
+
// Consumed 本轮消费的记忆条数(All 模式下为 0:重放不动游标)。
|
|
160
|
+
Consumed int `json:"consumed"`
|
|
161
|
+
// Replayed 全量重放实际重放的历史条数。
|
|
162
|
+
Replayed int `json:"replayed,omitempty"`
|
|
163
|
+
// Calls 本轮实际调了几次 LLM(分片)。
|
|
164
|
+
Calls int `json:"calls"`
|
|
165
|
+
// Pending 归纳完还剩多少条未归纳。
|
|
166
|
+
Pending int `json:"pending"`
|
|
167
|
+
// Error 失败原因(OK=false 时有值)。
|
|
168
|
+
Error string `json:"error,omitempty"`
|
|
169
|
+
}
|