magic-agent 1.0.3 → 1.0.49
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 +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- 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/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -20,11 +20,14 @@ package session
|
|
|
20
20
|
|
|
21
21
|
import (
|
|
22
22
|
"bufio"
|
|
23
|
+
"bytes"
|
|
23
24
|
"errors"
|
|
24
25
|
"fmt"
|
|
25
26
|
"io"
|
|
26
27
|
"os"
|
|
27
28
|
"path/filepath"
|
|
29
|
+
"strings"
|
|
30
|
+
"sync"
|
|
28
31
|
)
|
|
29
32
|
|
|
30
33
|
// DefaultLogDir 返回 ~/.magic-agent/sessions/(与 core CLI 用的目录一致)。
|
|
@@ -39,6 +42,73 @@ func DefaultLogDir() (string, error) {
|
|
|
39
42
|
return filepath.Join(home, ".magic-agent", "sessions"), nil
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
// logPath 一条 id 对应的日志文件路径(不含「文件在不在」的判断)。
|
|
46
|
+
func logPath(dir, id string) string { return filepath.Join(dir, id+".jsonl") }
|
|
47
|
+
|
|
48
|
+
// ResolveLogID 把「会话 id 或运行 id」翻成**该读哪个文件**(返回的是文件名里那一段)。
|
|
49
|
+
//
|
|
50
|
+
// 为什么需要这层翻译(2026-10-15):日志文件按 **session_id** 命名(core 侧见
|
|
51
|
+
// internal/cli/sessionlog.go)—— 但客户端手里的 id 在很多时候还只是 **run_id**:
|
|
52
|
+
// - 正在跑的会话,在引擎吐出 session_id 之前,登记表里只有 run_id;
|
|
53
|
+
// - 引擎整轮都不给 id 的形态(dsh 的 headless 回退轮),run_id 是它唯一的地址;
|
|
54
|
+
// - core 侧现在**一开跑就先按 <run_id>.jsonl 落盘**(拿到 id 才改名),于是
|
|
55
|
+
// 这两个名字都可能命中同一个文件。
|
|
56
|
+
//
|
|
57
|
+
// 不翻译的后果是静默失败:点开「正在跑」的会话读到空历史,看起来像「这条会话没有内容」。
|
|
58
|
+
//
|
|
59
|
+
// 顺序:① 同名文件在 → 就用它(覆盖 session_id 与「先按 run_id 落盘」的中间态);
|
|
60
|
+
//
|
|
61
|
+
// ② 否则查登记表,**两个方向都试**:登记表的 session_id / run_id 各自的文件在就认;
|
|
62
|
+
// ③ 都不成立 → 原样返回(读出来就是空,与「没有历史」同义)。
|
|
63
|
+
//
|
|
64
|
+
// ⚠️ ② 必须双向(2026-10-18 修):文件叫 `<run_id>.jsonl` 是**合法终态**,不是临时态 ——
|
|
65
|
+
// 引擎整轮没吐 id 的形态一直存在(dsh 的 headless 回退轮、部分引擎不调 OnSessionID),
|
|
66
|
+
// 而磁盘上已经躺着一批这么命名的历史文件。此时按 session_id 去读(列表页把 session_id
|
|
67
|
+
// 当主键,客户端点开传的就是它)单向翻译救不了:文件名与 session_id 都对不上,`Find`
|
|
68
|
+
// 查回来又是同一个 session_id,于是读到空 + `has_log=true` 这种最难看的状态
|
|
69
|
+
// (列表说「有内容」、点开说「还没有事件」)。
|
|
70
|
+
//
|
|
71
|
+
// 写盘侧同时尽力把名字**统一成 session_id**(CLI 的事件级兜底 + 各引擎的 OnSessionID,
|
|
72
|
+
// 见 internal/cli/ask.go 与 internal/cli/sessionlog.go),但读取端仍要兜住上面那种形态 ——
|
|
73
|
+
// 「文件叫什么」不能成为客户端必须知道的事。
|
|
74
|
+
//
|
|
75
|
+
// ⚠️ 只在 dir 与登记表目录不一致时也安全:② 多一层「文件在不在」的确认,
|
|
76
|
+
// 不会把别的目录的会话错认进来(测试常传临时 dir)。
|
|
77
|
+
func ResolveLogID(dir, id string) string {
|
|
78
|
+
id = strings.TrimSpace(id)
|
|
79
|
+
if dir == "" || id == "" {
|
|
80
|
+
return id
|
|
81
|
+
}
|
|
82
|
+
if fileExists(logPath(dir, id)) {
|
|
83
|
+
return id
|
|
84
|
+
}
|
|
85
|
+
rec, found, err := Find(id)
|
|
86
|
+
if err == nil && found {
|
|
87
|
+
for _, cand := range []string{rec.SessionID, rec.RunID} {
|
|
88
|
+
c := strings.TrimSpace(cand)
|
|
89
|
+
if c != "" && c != id && fileExists(logPath(dir, c)) {
|
|
90
|
+
return c
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return id
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// HasSessionLog 这条 id(会话 id 或 run id)有没有**可回放**的事件日志:
|
|
98
|
+
// 文件要在、且非空(空文件等于没有内容可看)。
|
|
99
|
+
func HasSessionLog(dir, id string) bool {
|
|
100
|
+
if dir == "" || strings.TrimSpace(id) == "" {
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
st, err := os.Stat(logPath(dir, ResolveLogID(dir, id)))
|
|
104
|
+
return err == nil && st.Size() > 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func fileExists(p string) bool {
|
|
108
|
+
st, err := os.Stat(p)
|
|
109
|
+
return err == nil && !st.IsDir()
|
|
110
|
+
}
|
|
111
|
+
|
|
42
112
|
// SeqOf 从**已解析**的一条事件里取出 seq。
|
|
43
113
|
//
|
|
44
114
|
// 为什么要回调而不是要求 T 带字段:本包被 CLI(`sessionEvent` 结构体)与 plugin
|
|
@@ -72,15 +142,40 @@ func ReadSessionLog[T any](dir, sessionID string, parse func([]byte) (T, error))
|
|
|
72
142
|
// 「客户端的游标是不是超前了」(after > maxSeq ⇒ 客户端状态比磁盘还新,
|
|
73
143
|
// 只可能是日志被重置过、或游标来自另一个会话)。只统计过滤后的事件得不到这个信息。
|
|
74
144
|
//
|
|
75
|
-
// ⚠️
|
|
145
|
+
// ⚠️ 续读走进程内尾部游标(见 tailMark):上一次读完整份文件时记下「maxSeq 之后
|
|
76
146
|
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
147
|
+
// 的字节偏移」,之后 after == maxSeq 的请求直接 seek 到那里只读尾部。
|
|
148
|
+
// 游标未命中(进程重启 / seq 不吻合 / 文件被截断)才退回全量重扫 —— 结果一致,
|
|
149
|
+
// 只是慢。输出形状与语义不变。
|
|
79
150
|
func ReadSessionLogAfter[T any](dir, sessionID string, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error)) ([]T, uint64, error) {
|
|
151
|
+
return readSessionLogAfter(dir, sessionID, after, seqOf, parse, false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ReadSessionLogLean 与 ReadSessionLog 同语义,但在**解码前**摘掉顶层 snapshot 字段。
|
|
155
|
+
//
|
|
156
|
+
// 为什么需要「解码前」:snapshot 是该 item 到此刻的累积整段,每条增量事件都带一份,
|
|
157
|
+
// 长会话的存档因此二次方膨胀(实测一条会话 9.4 万条 thinking = 283MB 快照,整份
|
|
158
|
+
// 日志 338MB)。先 `json.Unmarshal` 再删字段意味着这 283MB 仍要逐条分配 + 反转义,
|
|
159
|
+
// 读一次 6~7s;而回放侧只用增量 `text` 自己拼,没有一个消费方读事件级 snapshot。
|
|
160
|
+
// 字节级摘除(见 stripSnapshotField)不解析它的值,磁盘存档原样保留。
|
|
161
|
+
//
|
|
162
|
+
// ⚠️ 只影响「读」,输出字段集与先读后删完全一致(test 钉着)。
|
|
163
|
+
func ReadSessionLogLean[T any](dir, sessionID string, parse func([]byte) (T, error)) ([]T, error) {
|
|
164
|
+
evs, _, err := readSessionLogAfter(dir, sessionID, 0, nil, parse, true)
|
|
165
|
+
return evs, err
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ReadSessionLogAfterLean 是 ReadSessionLogAfter 的 lean 版本(断线续读 + 摘 snapshot)。
|
|
169
|
+
func ReadSessionLogAfterLean[T any](dir, sessionID string, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error)) ([]T, uint64, error) {
|
|
170
|
+
return readSessionLogAfter(dir, sessionID, after, seqOf, parse, true)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func readSessionLogAfter[T any](dir, sessionID string, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error), slim bool) ([]T, uint64, error) {
|
|
80
174
|
if dir == "" {
|
|
81
175
|
return nil, 0, fmt.Errorf("ReadSessionLog: dir 不能为空(调用方需用 DefaultLogDir() 解析)")
|
|
82
176
|
}
|
|
83
|
-
|
|
177
|
+
path := logPath(dir, sessionID)
|
|
178
|
+
f, err := os.Open(path)
|
|
84
179
|
if err != nil {
|
|
85
180
|
if errors.Is(err, os.ErrNotExist) {
|
|
86
181
|
return nil, 0, nil // 没历史 ≠ 错
|
|
@@ -88,14 +183,482 @@ func ReadSessionLogAfter[T any](dir, sessionID string, after uint64, seqOf SeqOf
|
|
|
88
183
|
return nil, 0, err
|
|
89
184
|
}
|
|
90
185
|
defer f.Close()
|
|
91
|
-
|
|
186
|
+
|
|
187
|
+
// 快路径:这个文件上次刚好读到 after 这条,直接从当时的偏移续读尾部。
|
|
188
|
+
if seqOf != nil && after > 0 {
|
|
189
|
+
if evs, maxSeq, ok := readFromTailMark(f, path, after, seqOf, parse, slim); ok {
|
|
190
|
+
return evs, maxSeq, nil
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
evs, maxSeq, err := readNDJSON(f, after, seqOf, parse, slim)
|
|
195
|
+
if err != nil {
|
|
196
|
+
return evs, maxSeq, err
|
|
197
|
+
}
|
|
198
|
+
if seqOf != nil {
|
|
199
|
+
// 全量扫过一遍 ⇒ maxSeq 与「最后一条完整行」的偏移是权威的,记下来给下次续读。
|
|
200
|
+
if st, serr := f.Stat(); serr == nil {
|
|
201
|
+
storeTailMark(path, maxSeq, completeLogEnd(f, st.Size()))
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return evs, maxSeq, nil
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ReadSessionLogBefore 反向读「从文件尾跳过 skip 条之后的一页」事件(**向上翻历史**用)。
|
|
208
|
+
//
|
|
209
|
+
// 返回 (事件, 下一页游标, 是否还有更早, error);事件按文件物理顺序(旧 → 新)排列。
|
|
210
|
+
// skip = 0 等价于「从文件尾开始」,即离现在最近的一页。
|
|
211
|
+
//
|
|
212
|
+
// 为什么反向读:长会话日志几百 MB,全量扫一遍要数秒,而客户端一次只要一页。
|
|
213
|
+
// 从 completeLogEnd 定起点按块倒读,攒够 limit 条就停 —— 代价与本页字节数成正比,
|
|
214
|
+
// 与会话总长无关。跨块的半行用 pending 拼回去(同一行只会被解析一次)。
|
|
215
|
+
//
|
|
216
|
+
// ⚠️ 游标是**跳过条数**而不是 seq(2026-10-20 修正):同一会话每次续跑都是新进程,
|
|
217
|
+
// 旧版 writer 的 seq 每轮从 1 重来(实测一份 15737 行的日志里有 4 处回落到 1,且都
|
|
218
|
+
// 紧跟一行 user)。拿 seq 当全局游标(`seq < before`)会在 run 边界处把更早的历史
|
|
219
|
+
// 整段滤掉 —— 客户端连翻几页后 firstSeq 撞到 1,入口消失,可还有上万条永远读不到。
|
|
220
|
+
// 跳过条数只依赖行的物理顺序,与 seq 怎么编号无关。
|
|
221
|
+
//
|
|
222
|
+
// ⚠️ 每一条**非空行**都计入游标(含解析失败的行):正向读的 `total` 也是这么数的,
|
|
223
|
+
// 两边口径必须一致。因此下一页游标由本函数算出(= 页首那条之前有多少条非空行),
|
|
224
|
+
// 不能简单用「本页条数」去加 —— 坏行会让两者不相等,越翻越偏。
|
|
225
|
+
//
|
|
226
|
+
// alignTo 非 nil 时做页头对齐:取页内**最早一条**满足它的位置作为页首,更早的几条
|
|
227
|
+
// (通常是上一轮回复的尾巴)留给下一页补 —— 客户端按轮渲染,页头切在轮次中间会让
|
|
228
|
+
// 思考/正文各半截。返回的游标已经扣掉被裁的部分,所以既不重复也不丢。
|
|
229
|
+
//
|
|
230
|
+
// ⚠️ 一页必须**至少含一个轮次起点**(2026-10-20 用户:「滚一下至少也要补一轮回话
|
|
231
|
+
// 不是只补几个子过程」):思考密集的一轮里 200 条事件可能全是思考碎片,页内没有
|
|
232
|
+
// user ⇒ 无从对齐,前插后画布只多出几行子过程,看着像「滚了没反应」。所以凑够
|
|
233
|
+
// limit 后若还没见到 alignTo 就继续往前读,直到见到为止(上限 maxTurnPage,防止
|
|
234
|
+
// 一轮极长时把整份日志读进内存)。
|
|
235
|
+
func ReadSessionLogBefore[T any](dir, sessionID string, skip uint64, limit int, alignTo func(T) bool, parse func([]byte) (T, error)) ([]T, uint64, bool, error) {
|
|
236
|
+
return readSessionLogBefore(dir, sessionID, skip, limit, alignTo, parse, false)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ReadSessionLogBeforeLean 是 ReadSessionLogBefore 的 lean 版本(反向分页 + 摘 snapshot)。
|
|
240
|
+
func ReadSessionLogBeforeLean[T any](dir, sessionID string, skip uint64, limit int, alignTo func(T) bool, parse func([]byte) (T, error)) ([]T, uint64, bool, error) {
|
|
241
|
+
return readSessionLogBefore(dir, sessionID, skip, limit, alignTo, parse, true)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func readSessionLogBefore[T any](dir, sessionID string, skip uint64, limit int, alignTo func(T) bool, parse func([]byte) (T, error), slim bool) ([]T, uint64, bool, error) {
|
|
245
|
+
if dir == "" {
|
|
246
|
+
return nil, 0, false, fmt.Errorf("ReadSessionLogBefore: dir 不能为空(调用方需用 DefaultLogDir() 解析)")
|
|
247
|
+
}
|
|
248
|
+
if limit <= 0 {
|
|
249
|
+
limit = 200
|
|
250
|
+
}
|
|
251
|
+
path := logPath(dir, sessionID)
|
|
252
|
+
f, err := os.Open(path)
|
|
253
|
+
if err != nil {
|
|
254
|
+
if errors.Is(err, os.ErrNotExist) {
|
|
255
|
+
return nil, 0, false, nil // 没历史 ≠ 错
|
|
256
|
+
}
|
|
257
|
+
return nil, 0, false, err
|
|
258
|
+
}
|
|
259
|
+
defer f.Close()
|
|
260
|
+
st, err := f.Stat()
|
|
261
|
+
if err != nil {
|
|
262
|
+
return nil, 0, false, err
|
|
263
|
+
}
|
|
264
|
+
// 只倒读到最后一个换行之后:末尾没写完的半行不能算「更早的历史」。
|
|
265
|
+
end := completeLogEnd(f, st.Size())
|
|
266
|
+
if end <= 0 {
|
|
267
|
+
return nil, 0, false, nil
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const chunk = 512 * 1024
|
|
271
|
+
var rev []T // 倒序收集(新 → 旧),最后反转成升序
|
|
272
|
+
var dist []uint64 // 与 rev 并行:该事件之前(更新一侧)已越过多少条非空行 = 它的绝对游标
|
|
273
|
+
pos := end
|
|
274
|
+
var pending []byte // 上一块开头那段「被切开的后半行」,等更靠前的字节拼回来
|
|
275
|
+
var seen uint64 // 从末尾起已经越过的非空行数(= 客户端已画/已丢的条数)
|
|
276
|
+
more := false
|
|
277
|
+
// 页上限:凑够一个轮次起点后立刻停;某一轮长到 25 页还没起点(极端)时按这个上限收手,
|
|
278
|
+
// 宁可多给几页子过程,也不把一份几十万条的日志整个读进内存。
|
|
279
|
+
maxTurnPage := limit * 25
|
|
280
|
+
sawStart := false
|
|
281
|
+
for pos > 0 && !more {
|
|
282
|
+
start := pos - chunk
|
|
283
|
+
if start < 0 {
|
|
284
|
+
start = 0
|
|
285
|
+
}
|
|
286
|
+
buf := make([]byte, pos-start)
|
|
287
|
+
n, rerr := f.ReadAt(buf, start)
|
|
288
|
+
if rerr != nil && !errors.Is(rerr, io.EOF) {
|
|
289
|
+
return nil, 0, false, rerr
|
|
290
|
+
}
|
|
291
|
+
buf = append(buf[:n], pending...)
|
|
292
|
+
lines := bytes.Split(buf, []byte{'\n'})
|
|
293
|
+
var carry []byte
|
|
294
|
+
if start > 0 {
|
|
295
|
+
// 第一段从块中间切开的半行:它缺的是**前半段**(在更靠前的块里),本块解析不了。
|
|
296
|
+
carry = append([]byte(nil), lines[0]...)
|
|
297
|
+
lines = lines[1:]
|
|
298
|
+
}
|
|
299
|
+
if m := len(lines); m > 0 && len(lines[m-1]) == 0 {
|
|
300
|
+
lines = lines[:m-1] // end 恰好落在 \n 之后,块尾的空段不是事件
|
|
301
|
+
}
|
|
302
|
+
for i := len(lines) - 1; i >= 0; i-- {
|
|
303
|
+
line := lines[i]
|
|
304
|
+
if m := len(line); m > 0 && line[m-1] == '\r' {
|
|
305
|
+
line = line[:m-1]
|
|
306
|
+
}
|
|
307
|
+
if len(line) == 0 {
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
// 跳过客户端已经画在屏幕上的那一段(含解析失败的行 —— 与正向 total 同口径)。
|
|
311
|
+
if seen < skip {
|
|
312
|
+
seen++
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
d := seen
|
|
316
|
+
seen++
|
|
317
|
+
if slim {
|
|
318
|
+
line = stripSnapshotField(line)
|
|
319
|
+
}
|
|
320
|
+
v, perr := parse(line)
|
|
321
|
+
if perr != nil {
|
|
322
|
+
fmt.Fprintf(os.Stderr, "magic-agent: 会话历史行解析失败:%v(跳过该行)\n", perr)
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
rev = append(rev, v)
|
|
326
|
+
dist = append(dist, d)
|
|
327
|
+
if alignTo != nil && alignTo(v) {
|
|
328
|
+
sawStart = true
|
|
329
|
+
}
|
|
330
|
+
// 够一页了,且这页里已经有轮次起点(或已到上限)→ 停。
|
|
331
|
+
// 没有起点就继续往前读:否则这一页全是同一轮的思考碎片,前插后看不出变化。
|
|
332
|
+
if len(rev) >= limit && (alignTo == nil || sawStart || len(rev) >= maxTurnPage) {
|
|
333
|
+
// 还有更早:本块里还有没处理的(i > 0),或者前面还有块。
|
|
334
|
+
more = i > 0 || start > 0
|
|
335
|
+
break
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
pos = start
|
|
339
|
+
pending = carry
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
out := make([]T, len(rev))
|
|
343
|
+
distOut := make([]uint64, len(rev))
|
|
344
|
+
for i := range rev {
|
|
345
|
+
out[len(rev)-1-i] = rev[i]
|
|
346
|
+
distOut[len(rev)-1-i] = dist[i]
|
|
347
|
+
}
|
|
348
|
+
if len(out) == 0 {
|
|
349
|
+
// 窗口里一条都解析不出来(全是坏行)⇒ 没有更早可给的了,游标推到已消费的位置。
|
|
350
|
+
return out, skip + seen, false, nil
|
|
351
|
+
}
|
|
352
|
+
// 页头对齐:切到页内最早一条轮次起点,更早的几条由下一页补(游标已扣掉,不重不漏)。
|
|
353
|
+
keep := 0
|
|
354
|
+
if alignTo != nil {
|
|
355
|
+
for i := range out {
|
|
356
|
+
if alignTo(out[i]) {
|
|
357
|
+
keep = i
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if keep > 0 {
|
|
363
|
+
out = out[keep:]
|
|
364
|
+
more = true
|
|
365
|
+
}
|
|
366
|
+
// 下一页游标 = 页首那条的绝对距离 + 1(跳过它自己)—— 不能拿「本页条数」相加:
|
|
367
|
+
// 坏行占位置但不返回,相加会越翻越偏、把同一条事件重复取回。
|
|
368
|
+
nextSkip := distOut[keep] + 1
|
|
369
|
+
return out, nextSkip, more, nil
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// tailMark 记「某个日志文件已经完整消费到哪儿」:
|
|
373
|
+
// seq 是已读到的最大 seq,offset 是**那一行之后**的文件偏移(下一次从这里续读)。
|
|
374
|
+
//
|
|
375
|
+
// 为什么要它(2026-10-20):日志是纯追加的 NDJSON,但 seq 只能解析出来,无法 seek 定位。
|
|
376
|
+
// 于是每个 `/events?after=N` 都从文件头重扫全文 —— 移动端每 3s 对每个运行中会话轮询
|
|
377
|
+
// 一次,354MB 的会话单次 2~3.4s,还会和历史加载抢磁盘。用进程内游标记住「seq=N 之后
|
|
378
|
+
// 的字节从哪开始」,续读就只碰尾部那几行(实测 2.6s → 1~3ms)。
|
|
379
|
+
//
|
|
380
|
+
// 只放在进程内存里(不落盘):错失最多是退回全量重扫,没有正确性代价。
|
|
381
|
+
type tailMark struct {
|
|
382
|
+
seq uint64
|
|
383
|
+
offset int64
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// tailMarkKeep 每个文件保留几个游标。
|
|
387
|
+
//
|
|
388
|
+
// 为什么不止一个:同一会话可能有多个客户端(PC / 移动端 / 另一个标签页)各自按自己的
|
|
389
|
+
// 游标轮询,而它们的进度天然错开一两拍。只留最新一个的话,别人一推进游标,落后的客户端
|
|
390
|
+
// 每次轮询都得全量重扫(又回到最慢的路径)。留最近几拍即可覆盖这种错位。
|
|
391
|
+
const tailMarkKeep = 8
|
|
392
|
+
|
|
393
|
+
// tailMarkSet 一个文件的游标集合(按 seq 降序,最多 tailMarkKeep 条)。
|
|
394
|
+
type tailMarkSet struct {
|
|
395
|
+
mu sync.Mutex
|
|
396
|
+
marks []tailMark
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
var tailMarks sync.Map // key: 日志文件路径;value: *tailMarkSet
|
|
400
|
+
|
|
401
|
+
// readFromTailMark 尝试按游标续读;不可用(没有匹配的 seq / 文件变短 / seek 失败)返回 ok=false。
|
|
402
|
+
//
|
|
403
|
+
// 并发下多个请求可能同时命中同一个游标:谁都从同一偏移读、都只读完整行 —— 结果是
|
|
404
|
+
// 重复返回,不会漏。游标更新见 storeTailMark。
|
|
405
|
+
func readFromTailMark[T any](f *os.File, path string, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error), slim bool) ([]T, uint64, bool) {
|
|
406
|
+
m, ok := loadTailMark(path, after)
|
|
407
|
+
if !ok {
|
|
408
|
+
return nil, 0, false
|
|
409
|
+
}
|
|
410
|
+
st, err := f.Stat()
|
|
411
|
+
if err != nil || m.offset > st.Size() {
|
|
412
|
+
return nil, 0, false // 文件被截断 / 重置过 ⇒ 游标作废
|
|
413
|
+
}
|
|
414
|
+
if _, err := f.Seek(m.offset, io.SeekStart); err != nil {
|
|
415
|
+
return nil, 0, false
|
|
416
|
+
}
|
|
417
|
+
evs, maxSeq, consumed, err := readNDJSONFrom(f, m.offset, after, seqOf, parse, slim)
|
|
418
|
+
if err != nil {
|
|
419
|
+
return nil, 0, false
|
|
420
|
+
}
|
|
421
|
+
storeTailMark(path, maxSeq, consumed)
|
|
422
|
+
return evs, maxSeq, true
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// loadTailMark 取 seq == after 的游标(offset <= 0 的占位不算)。
|
|
426
|
+
func loadTailMark(path string, after uint64) (tailMark, bool) {
|
|
427
|
+
v, ok := tailMarks.Load(path)
|
|
428
|
+
if !ok {
|
|
429
|
+
return tailMark{}, false
|
|
430
|
+
}
|
|
431
|
+
set, ok := v.(*tailMarkSet)
|
|
432
|
+
if !ok {
|
|
433
|
+
return tailMark{}, false
|
|
434
|
+
}
|
|
435
|
+
set.mu.Lock()
|
|
436
|
+
defer set.mu.Unlock()
|
|
437
|
+
for _, m := range set.marks {
|
|
438
|
+
if m.seq == after && m.offset > 0 {
|
|
439
|
+
return m, true
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return tailMark{}, false
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// storeTailMark 记一个游标;offset<=0(还没有完整行)或 seq==0 时不记。
|
|
446
|
+
// 同一 seq 只留最新一条;超过 tailMarkKeep 条时丢最旧的(seq 最小的)。
|
|
447
|
+
func storeTailMark(path string, seq uint64, offset int64) {
|
|
448
|
+
if seq == 0 || offset <= 0 {
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
v, _ := tailMarks.LoadOrStore(path, &tailMarkSet{})
|
|
452
|
+
set, ok := v.(*tailMarkSet)
|
|
453
|
+
if !ok {
|
|
454
|
+
set = &tailMarkSet{}
|
|
455
|
+
tailMarks.Store(path, set)
|
|
456
|
+
}
|
|
457
|
+
set.mu.Lock()
|
|
458
|
+
defer set.mu.Unlock()
|
|
459
|
+
|
|
460
|
+
next := make([]tailMark, 0, tailMarkKeep)
|
|
461
|
+
inserted := false
|
|
462
|
+
for _, m := range set.marks {
|
|
463
|
+
if m.seq == seq {
|
|
464
|
+
continue // 同一 seq 以新偏移为准
|
|
465
|
+
}
|
|
466
|
+
if !inserted && m.seq < seq {
|
|
467
|
+
next = append(next, tailMark{seq: seq, offset: offset})
|
|
468
|
+
inserted = true
|
|
469
|
+
}
|
|
470
|
+
if len(next) < tailMarkKeep {
|
|
471
|
+
next = append(next, m)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if !inserted && len(next) < tailMarkKeep {
|
|
475
|
+
next = append(next, tailMark{seq: seq, offset: offset})
|
|
476
|
+
}
|
|
477
|
+
set.marks = next
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// completeLogEnd 返回 size 之前「最后一个换行符之后」的偏移(= 可安全消费的字节数)。
|
|
481
|
+
// 末尾还没写完的半行不算:下次续读从那里重新读,等它落完换行再消费。
|
|
482
|
+
// 找不到换行(整份文件就一行半)返回 0。
|
|
483
|
+
func completeLogEnd(f *os.File, size int64) int64 {
|
|
484
|
+
if size <= 0 {
|
|
485
|
+
return 0
|
|
486
|
+
}
|
|
487
|
+
const chunk = 64 * 1024
|
|
488
|
+
buf := make([]byte, chunk)
|
|
489
|
+
for end := size; end > 0; {
|
|
490
|
+
start := end - chunk
|
|
491
|
+
if start < 0 {
|
|
492
|
+
start = 0
|
|
493
|
+
}
|
|
494
|
+
n, _ := f.ReadAt(buf[:end-start], start)
|
|
495
|
+
if n <= 0 {
|
|
496
|
+
return 0
|
|
497
|
+
}
|
|
498
|
+
if i := bytes.LastIndexByte(buf[:n], '\n'); i >= 0 {
|
|
499
|
+
return start + int64(i) + 1
|
|
500
|
+
}
|
|
501
|
+
end = start
|
|
502
|
+
}
|
|
503
|
+
return 0
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// readNDJSONFrom 从 pos 起续读到尾,语义与 readNDJSON 相同(同样的过滤 / 坏行跳过 / slim),
|
|
507
|
+
// 额外返回「已消费到的偏移」—— 只累加**带换行**的完整行,末尾半行留给下次。
|
|
508
|
+
//
|
|
509
|
+
// maxSeq 以 after 为起点:本函数看不到文件前半段,但调用方的前提是 mark.seq == after
|
|
510
|
+
// ⇒ 前半段的最大 seq 就是 after。这样「没有新事件」时 maxSeq 仍等于 after,
|
|
511
|
+
// `after > maxSeq` 不会把「已追平」误判成「游标超前」。
|
|
512
|
+
func readNDJSONFrom[T any](f *os.File, start int64, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error), slim bool) ([]T, uint64, int64, error) {
|
|
513
|
+
var out []T
|
|
514
|
+
maxSeq := after
|
|
515
|
+
pos := start
|
|
516
|
+
br := bufio.NewReaderSize(f, 256*1024)
|
|
517
|
+
for {
|
|
518
|
+
line, err := br.ReadBytes('\n')
|
|
519
|
+
if err != nil {
|
|
520
|
+
if errors.Is(err, io.EOF) {
|
|
521
|
+
return out, maxSeq, pos, nil // 末尾半行还没落完:不消费
|
|
522
|
+
}
|
|
523
|
+
return out, maxSeq, pos, err
|
|
524
|
+
}
|
|
525
|
+
pos += int64(len(line))
|
|
526
|
+
line = line[:len(line)-1]
|
|
527
|
+
if n := len(line); n > 0 && line[n-1] == '\r' {
|
|
528
|
+
line = line[:n-1]
|
|
529
|
+
}
|
|
530
|
+
if len(line) == 0 {
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
if slim {
|
|
534
|
+
line = stripSnapshotField(line)
|
|
535
|
+
}
|
|
536
|
+
v, err := parse(line)
|
|
537
|
+
if err != nil {
|
|
538
|
+
fmt.Fprintf(os.Stderr, "magic-agent: 会话历史行解析失败:%v(跳过该行)\n", err)
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
s := seqOf(v)
|
|
542
|
+
if s > maxSeq {
|
|
543
|
+
maxSeq = s
|
|
544
|
+
}
|
|
545
|
+
if s <= after {
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
out = append(out, v)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
var snapshotKeyPat = []byte(`,"snapshot":`)
|
|
553
|
+
|
|
554
|
+
// stripSnapshotField 从一行 NDJSON 里整段摘掉顶层 `"snapshot"` 字段(**不解析它的值**)。
|
|
555
|
+
//
|
|
556
|
+
// 做法:找到 `,"snapshot":` 这个字面量,跳过它的 JSON 值,把这段字节删掉。
|
|
557
|
+
// - 为什么可以按字面量找:JSON 字符串内部的引号一定是转义的(`\"`),所以
|
|
558
|
+
// `,"snapshot":` 这一串只可能出现在**字段名**位置,text 里出现同名字样不会误伤。
|
|
559
|
+
// - 为什么从前往后找(bytes.Index):snapshot 值本身可能几十 KB,而键在 text 之后、
|
|
560
|
+
// 值之前 —— 正向扫只经过很小的前缀,反向扫要穿过整个值。
|
|
561
|
+
// - 值之后仍有字段(如 error / attachments)时保留后半段;是最后一个字段时
|
|
562
|
+
// 顺带去掉前面的逗号(否则 `{"a":1,}` 不是合法 JSON)。
|
|
563
|
+
//
|
|
564
|
+
// 解析失败(值没闭合等坏行)原样返回:后面 json.Unmarshal 会照常报错并按坏行跳过,
|
|
565
|
+
// 不在这一层发明第二种错误。
|
|
566
|
+
func stripSnapshotField(line []byte) []byte {
|
|
567
|
+
idx := bytes.Index(line, snapshotKeyPat)
|
|
568
|
+
if idx < 0 {
|
|
569
|
+
return line
|
|
570
|
+
}
|
|
571
|
+
keyStart := idx // 保留逗号之前的字节,逗号一起删
|
|
572
|
+
vs := idx + len(snapshotKeyPat)
|
|
573
|
+
for vs < len(line) && (line[vs] == ' ' || line[vs] == '\t') {
|
|
574
|
+
vs++
|
|
575
|
+
}
|
|
576
|
+
ve := skipJSONValue(line, vs)
|
|
577
|
+
if ve < 0 {
|
|
578
|
+
return line
|
|
579
|
+
}
|
|
580
|
+
out := make([]byte, 0, keyStart+len(line)-ve)
|
|
581
|
+
out = append(out, line[:keyStart]...)
|
|
582
|
+
out = append(out, line[ve:]...)
|
|
583
|
+
return out
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// endOfJSONString 返回从 i(必为 `"`)开始的 JSON 字符串值的结束位置(开区间);未闭合返回 -1。
|
|
587
|
+
//
|
|
588
|
+
// 为什么不用逐字节开关扫:snapshot 值动辄几百 KB,逐字节过分支实测只有 1.8GB/s;
|
|
589
|
+
// bytes.IndexByte 是 SIMD 实现(10GB/s+),只对命中的引号数一下前面的连续反斜杠 ——
|
|
590
|
+
// 偶数个 ⇒ 没被转义,就是字符串结尾。
|
|
591
|
+
func endOfJSONString(b []byte, i int) int {
|
|
592
|
+
from := i + 1
|
|
593
|
+
for from < len(b) {
|
|
594
|
+
n := bytes.IndexByte(b[from:], '"')
|
|
595
|
+
if n < 0 {
|
|
596
|
+
return -1
|
|
597
|
+
}
|
|
598
|
+
q := from + n
|
|
599
|
+
bs := 0
|
|
600
|
+
for k := q - 1; k > i && b[k] == '\\'; k-- {
|
|
601
|
+
bs++
|
|
602
|
+
}
|
|
603
|
+
if bs%2 == 0 {
|
|
604
|
+
return q + 1
|
|
605
|
+
}
|
|
606
|
+
from = q + 1
|
|
607
|
+
}
|
|
608
|
+
return -1
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// skipJSONValue 返回从 i 开始的那个 JSON 值**结束位置**(开区间);坏值返回 -1。
|
|
612
|
+
// 只做结构跳过(字符串转义、括号配对),不解释内容 —— 这正是省掉 283MB 反转义的关键。
|
|
613
|
+
func skipJSONValue(b []byte, i int) int {
|
|
614
|
+
if i >= len(b) {
|
|
615
|
+
return -1
|
|
616
|
+
}
|
|
617
|
+
switch b[i] {
|
|
618
|
+
case '"':
|
|
619
|
+
return endOfJSONString(b, i)
|
|
620
|
+
case '[', '{':
|
|
621
|
+
depth := 0
|
|
622
|
+
inStr := false
|
|
623
|
+
for j := i; j < len(b); j++ {
|
|
624
|
+
c := b[j]
|
|
625
|
+
if inStr {
|
|
626
|
+
if c == '\\' {
|
|
627
|
+
j++
|
|
628
|
+
} else if c == '"' {
|
|
629
|
+
inStr = false
|
|
630
|
+
}
|
|
631
|
+
continue
|
|
632
|
+
}
|
|
633
|
+
switch c {
|
|
634
|
+
case '"':
|
|
635
|
+
inStr = true
|
|
636
|
+
case '[', '{':
|
|
637
|
+
depth++
|
|
638
|
+
case ']', '}':
|
|
639
|
+
depth--
|
|
640
|
+
if depth == 0 {
|
|
641
|
+
return j + 1
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return -1
|
|
646
|
+
default: // 数字 / true / false / null
|
|
647
|
+
for j := i; j < len(b); j++ {
|
|
648
|
+
if b[j] == ',' || b[j] == '}' {
|
|
649
|
+
return j
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return len(b)
|
|
653
|
+
}
|
|
92
654
|
}
|
|
93
655
|
|
|
94
656
|
// readNDJSON 流式读 NDJSON。空行 / 坏行跳过(warn 一次)。
|
|
95
657
|
//
|
|
96
658
|
// after / seqOf 都可选:seqOf 为 nil 时不按 seq 过滤(此时 after 无意义),
|
|
97
659
|
// 返回的 maxSeq 也就恒为 0 —— 这两个参数是配套的,调用方别只给一半。
|
|
98
|
-
|
|
660
|
+
// slim 为真时先做 stripSnapshotField(见 ReadSessionLogLean)。
|
|
661
|
+
func readNDJSON[T any](r io.Reader, after uint64, seqOf SeqOf[T], parse func([]byte) (T, error), slim bool) ([]T, uint64, error) {
|
|
99
662
|
var out []T
|
|
100
663
|
var maxSeq uint64
|
|
101
664
|
sc := bufio.NewScanner(r)
|
|
@@ -105,6 +668,9 @@ func readNDJSON[T any](r io.Reader, after uint64, seqOf SeqOf[T], parse func([]b
|
|
|
105
668
|
if len(line) == 0 {
|
|
106
669
|
continue
|
|
107
670
|
}
|
|
671
|
+
if slim {
|
|
672
|
+
line = stripSnapshotField(line)
|
|
673
|
+
}
|
|
108
674
|
v, err := parse(line)
|
|
109
675
|
if err != nil {
|
|
110
676
|
fmt.Fprintf(os.Stderr, "magic-agent: 会话历史行解析失败:%v(跳过该行)\n", err)
|