magic-agent 1.0.3 → 1.0.46

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +78 -10
  134. package/npm/bin/magic-client.js +100 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -3,10 +3,13 @@ package cli
3
3
  import (
4
4
  "context"
5
5
  "encoding/json"
6
+ "os"
6
7
  "path/filepath"
8
+ "strings"
7
9
  "testing"
8
10
 
9
11
  "github.com/darren/magic-agent/internal/agent"
12
+ "github.com/darren/magic-agent/internal/session"
10
13
  )
11
14
 
12
15
  // 用户提问落盘(kind:"user")的端到端验证。
@@ -57,7 +60,9 @@ func TestStreamAskPersistsUserMessage(t *testing.T) {
57
60
  const prompt = "帮我把 guard 的令牌比较改成常数时间,避免时序侧信道"
58
61
  registerFake(&userEchoStreamEngine{name: "fake-user-log", sessionID: sid})
59
62
 
60
- if _, _, err := runAskCmd(t, "", "-e", "fake-user-log", "--stream", prompt); err != nil {
63
+ // --save-history:落盘存档是** opt-in** 的(默认关,见 TestStreamAskNoArchiveByDefault),
64
+ // 这一条要验的是「开了之后落盘内容对不对」,所以显式传。
65
+ if _, _, err := runAskCmd(t, "", "-e", "fake-user-log", "--stream", "--save-history", prompt); err != nil {
61
66
  t.Fatalf("--stream: %v", err)
62
67
  }
63
68
 
@@ -117,6 +122,142 @@ func TestStreamAskPersistsUserMessage(t *testing.T) {
117
122
  }
118
123
  }
119
124
 
125
+ // TestStreamAskNoArchiveByDefault **默认什么都不存**(用户 2026-10-12:
126
+ // 「加一个参数 默认不存档」+「会话登记也不需要」)。
127
+ //
128
+ // 这一条钉住**默认值本身**:不加 --save-registry / --save-history 时,一次跑完的流式
129
+ // 会话不该在磁盘上留下任何痕迹 —— 既没有登记表 <run_id>.json,也没有历史 <session_id>.jsonl。
130
+ // 落盘是副作用,而绝大多数调用(CLI 手工跑一轮、CI、脚本编排)事后没人寻址。
131
+ //
132
+ // 反面同样重要:流本身必须照常跑完(不存档 ≠ 不干活)。
133
+ func TestStreamAskNoArchiveByDefault(t *testing.T) {
134
+ dir := t.TempDir()
135
+ prev := SessionLogDirOverride
136
+ SessionLogDirOverride = dir
137
+ defer func() { SessionLogDirOverride = prev }()
138
+ regDir := t.TempDir()
139
+ t.Setenv(session.EnvDir, regDir)
140
+
141
+ const sid = "sess-no-archive"
142
+ registerFake(&userEchoStreamEngine{name: "fake-no-archive", sessionID: sid})
143
+
144
+ stdout, _, err := runAskCmd(t, "", "-e", "fake-no-archive", "--stream", "跑一轮")
145
+ if err != nil {
146
+ t.Fatalf("默认不存档不该让流失败:%v", err)
147
+ }
148
+ if !strings.Contains(stdout, "收到") {
149
+ t.Errorf("流式输出应照常有正文,实际 %q", stdout)
150
+ }
151
+ // 历史存档不该有:磁盘上没有文件,读端对「文件不在」静默返回空,所以两者都要钉。
152
+ if _, err := os.Stat(filepath.Join(dir, sid+".jsonl")); !os.IsNotExist(err) {
153
+ t.Errorf("默认不该落历史存档(stat err=%v)", err)
154
+ }
155
+ if evs, err := ReadSessionLog(dir, sid); err != nil || len(evs) != 0 {
156
+ t.Errorf("默认不该有可回放历史:%d 条 (err=%v)", len(evs), err)
157
+ }
158
+ // 会话登记也不该有 —— 这条会话不该出现在 --sessions / --stop 的视野里。
159
+ ents, _ := os.ReadDir(regDir)
160
+ for _, e := range ents {
161
+ if strings.HasSuffix(e.Name(), ".json") {
162
+ t.Errorf("默认不该落会话登记,却有 %s", e.Name())
163
+ }
164
+ }
165
+ recs, err := session.List()
166
+ if err != nil {
167
+ t.Fatalf("List: %v", err)
168
+ }
169
+ if len(recs) != 0 {
170
+ t.Errorf("默认不该落会话登记,实际 %d 条:%+v", len(recs), recs)
171
+ }
172
+ }
173
+
174
+ // TestStreamAskSavesBothWhenAsked 两个开关都显式打开时,登记表与历史存档都要落。
175
+ // 它与 TestStreamAskNoArchiveByDefault 成对:一条钉默认关,一条钉开了确实有用。
176
+ func TestStreamAskSavesBothWhenAsked(t *testing.T) {
177
+ dir := t.TempDir()
178
+ prev := SessionLogDirOverride
179
+ SessionLogDirOverride = dir
180
+ defer func() { SessionLogDirOverride = prev }()
181
+ regDir := t.TempDir()
182
+ t.Setenv(session.EnvDir, regDir)
183
+
184
+ const sid = "sess-saved"
185
+ registerFake(&userEchoStreamEngine{name: "fake-saved", sessionID: sid})
186
+
187
+ if _, _, err := runAskCmd(t, "", "--save-registry", "--save-history",
188
+ "-e", "fake-saved", "--stream", "跑一轮"); err != nil {
189
+ t.Fatalf("--stream: %v", err)
190
+ }
191
+ evs, err := ReadSessionLog(dir, sid)
192
+ if err != nil || len(evs) == 0 {
193
+ t.Fatalf("开了 --save-history 就该有可回放历史:%d 条 (err=%v)", len(evs), err)
194
+ }
195
+ recs, err := session.List()
196
+ if err != nil || len(recs) != 1 || recs[0].SessionID != sid {
197
+ t.Fatalf("开了 --save-registry 就该落一条登记:%+v (err=%v)", recs, err)
198
+ }
199
+ }
200
+
201
+ // midRunRegistryEngine 在流的**中途**回头看一眼登记表。
202
+ //
203
+ // 为什么需要它:登记表的 SessionID 以前只在**收尾**(Handle.Finish)才写,于是「正在跑的
204
+ // 会话」在磁盘上只有 run_id —— 客户端的会话列表按 session_id 去重会在这段时间失效
205
+ // (同一段对话以 run_id 为键裂成两条),点开这样的行回传的也是 run_id(引擎不认识 →
206
+ // 续接接不上 / 另起一段)。这一条钉住「引擎报出 id 的那一刻,登记表里就有它」。
207
+ type midRunRegistryEngine struct {
208
+ name string
209
+ sessionID string
210
+ found *bool // 流中途(收尾之前)拿到的那次读结果
211
+ rec *session.Record
212
+ }
213
+
214
+ func (e *midRunRegistryEngine) Name() string { return e.name }
215
+ func (e *midRunRegistryEngine) Detect() (bool, string) { return true, "fake://" + e.name }
216
+
217
+ func (e *midRunRegistryEngine) Complete(_ context.Context, _ agent.Request) (agent.Response, error) {
218
+ return agent.Response{Text: "收到", Model: "fake-model"}, nil
219
+ }
220
+
221
+ func (e *midRunRegistryEngine) Stream(_ context.Context, req agent.Request, onEvent func(agent.StreamEvent)) (agent.StreamResult, error) {
222
+ // 引擎流里报出 session_id 的那一刻(真实引擎在 init 行里报)。
223
+ if req.OnSessionID != nil {
224
+ req.OnSessionID(e.sessionID)
225
+ }
226
+ // 流还没结束 —— 此刻登记表里就应该已经有这个会话 id。
227
+ if rec, ok, err := session.Find(e.sessionID); err == nil && ok {
228
+ *e.found = true
229
+ *e.rec = rec
230
+ }
231
+ onEvent(agent.StreamEvent{Kind: agent.KindText, Text: "收到", SessionID: e.sessionID})
232
+ return agent.StreamResult{
233
+ Response: agent.Response{Engine: e.name, Text: "收到", Model: "fake-model", SessionID: e.sessionID},
234
+ }, nil
235
+ }
236
+
237
+ func TestStreamAskBackfillsSessionIDMidRun(t *testing.T) {
238
+ regDir := t.TempDir()
239
+ t.Setenv(session.EnvDir, regDir)
240
+
241
+ const sid = "sess-mid-run"
242
+ found := false
243
+ var seen session.Record
244
+ registerFake(&midRunRegistryEngine{name: "fake-mid-run", sessionID: sid, found: &found, rec: &seen})
245
+
246
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-mid-run", "--stream", "跑一轮"); err != nil {
247
+ t.Fatalf("--stream: %v", err)
248
+ }
249
+
250
+ // ① 流**中途**(收尾之前)登记表里就该有这条会话的 session_id。
251
+ if !found || seen.SessionID != sid {
252
+ t.Fatalf("引擎报出 id 之后就该回填登记表(收尾前读不到会让正在跑的会话只有 run_id):found=%v rec=%+v", found, seen)
253
+ }
254
+ // ② 收尾后仍在,且不会被覆盖成别的值。
255
+ recs, err := session.List()
256
+ if err != nil || len(recs) != 1 || recs[0].SessionID != sid {
257
+ t.Fatalf("收尾后登记表应仍是这一条:%+v (err=%v)", recs, err)
258
+ }
259
+ }
260
+
120
261
  func dumpKinds(evs []sessionEvent) string {
121
262
  out := ""
122
263
  for i, ev := range evs {
@@ -0,0 +1,141 @@
1
+ package cli
2
+
3
+ // sessiontitle.go - `--summarize-title <id>`:把一条会话的首问归纳成标题。
4
+ //
5
+ // 为什么这条命令存在(用户 2026-10-15:「应该 magic-agent 统一归纳作为会话标题」):
6
+ // 归纳此前只有业务项目(掌天瓶)在做,core 侧只有一张空的 sid→title 覆盖表 —— 于是
7
+ // 同一会话在 PC 与移动端显示**不同的标题**(PC 是本地归纳的要点,移动端回落 prompt_head
8
+ // 首问原文),而且**从移动端发起的会话没人替它归纳**。
9
+ // 把归纳收进 core 之后,归纳口径只有一份(internal/session/title.go),两端只读不自己算。
10
+ //
11
+ // 与 `--sessions` / `--session-log` / `--stop` 同一套思路:会话数据的读写都做成 CLI 命令,
12
+ // 调用方 exec 即可,不必碰本仓库的磁盘格式(core 不跑服务,见 CLIENT-CONTRACT §3.4)。
13
+ //
14
+ // 覆盖表沿用插件时期落的同一个文件(~/.magic-agent/session-titles.json),换实现不换路径。
15
+
16
+ import (
17
+ "fmt"
18
+ "strings"
19
+
20
+ "github.com/spf13/cobra"
21
+
22
+ "github.com/darren/magic-agent/internal/session"
23
+ )
24
+
25
+ // runSummarizeTitle 归纳一条会话的标题:`--summarize-title <session_id|run_id>`。
26
+ //
27
+ // 输入取自**会话自己的历史**(首条 user 消息,见 session.FirstUserText)——
28
+ // 调用方不用把首问原文再传一遍,两端也就不会因为「谁截断的」而归纳出不同标题。
29
+ //
30
+ // 退出码:
31
+ //
32
+ // 0 归纳成功(或本来就已归纳,直接回现有标题:cached=true)
33
+ // 1 归纳失败(引擎不可用 / 读不到会话 / 模型没给出可用标题)
34
+ // 2 参数错(空 id)
35
+ func runSummarizeTitle(cmd *cobra.Command, opts *askOptions) error {
36
+ id := strings.TrimSpace(opts.summarizeTitle)
37
+ if id == "" {
38
+ return &usageError{fmt.Errorf("--summarize-title 需要会话 id:传 session_id 或 run_id(--sessions 可列出全部)")}
39
+ }
40
+
41
+ // 定位会话:登记表里认这条(拿 session_id / prompt_head)。认不到**不算错** ——
42
+ // 只有 `--save-registry`(插件 / 客户端恒传)才落登记表,手工直跑 CLI 与 CI 那种
43
+ // 不留档的调用压根没有记录,所以「昨天跑过、今天仍想归纳」的会话在这里可能认不到。
44
+ rec, found, _ := session.Find(id)
45
+ sessionID, runID := rec.SessionID, rec.RunID
46
+ if !found {
47
+ /* 认不到时把传进来的 id 同时当 session_id 与 run_id 用:
48
+ · 日志按 **session_id** 命名 —— 用 id 去读日志,读到了就说明它本来就是 session_id
49
+ (存量会话多半是这种:旧日志都在,只是登记表清了);
50
+ · 读不到也没损失(run_id 那种新会话首轮本来就没日志)。 */
51
+ sessionID, runID = id, id
52
+ }
53
+ key := sessionID
54
+ if key == "" {
55
+ key = runID
56
+ }
57
+
58
+ /* 已有归纳结果且没要求重来 → 直接回,省一次 LLM 调用。
59
+ ⚠️ **两个键都认**(2026-10-15,用户:「标题只在第一次创建时生成」):首轮归纳时
60
+ 引擎可能还没吐 session_id,那时键落在 run_id 上;而 run_id **每轮追问都换一个** →
61
+ 只按 session_id 查会判成「还没归纳」,于是每轮重算一次,标题就跟着最新那条追问走
62
+ (A 端 B 端各自刷新到的时刻不同,还会显示得不一样)。
63
+ 命中时顺手把标题迁到 session_id 上,此后键固定、标题不再变。 */
64
+ if !opts.titleForce {
65
+ /* 命中判定要认**三个来源**:
66
+ ① 这个 id 直接建的覆盖;② 这条会话当前的 run_id(首轮归纳时还没 session_id);
67
+ ③ 这条会话**其他轮**的 run_id —— 只认前两个的话,存量会话会被判成「还没归纳」,
68
+ 于是把标题**重写一遍**(AI 归纳是独立调用,重写出来的字还不一样)。 */
69
+ t := session.TitleForAny(sessionID, runID)
70
+ if t == "" {
71
+ t = session.TitleForSessionRuns(sessionID)
72
+ }
73
+ if t != "" {
74
+ // 顺手归一:值不动,但把这条会话各轮 run_id 上的旧键清掉、只留 session_id。
75
+ _ = session.ConsolidateTitle(sessionID, runID, t)
76
+ return printJSON(cmd.OutOrStdout(), session.SummaryResult{ID: key, Title: t, Cached: true})
77
+ }
78
+ }
79
+
80
+ /* 首问从**会话历史**里取 —— 归纳的输入必须是这条会话的**第一句**,否则标题会跟着
81
+ 最新那条追问走(这正是「每次追问标题都在变」的来源)。
82
+ ① 日志名可能是 session_id(终名),也可能是 run_id(引擎还没吐 id 时先按它落盘)
83
+ → 先用 ResolveLogID 翻一次名;
84
+ ② **不再拿 prompt_head 当兜底**:它记的是**当前这一轮**的提示词,追问轮用它归纳
85
+ 等于把标题改成最新追问的要点。只有这条会话还没有 session_id(= 首轮,
86
+ prompt_head 就是首问)时才允许用它兜底。
87
+ ⚠️ 目录必须显式取 —— 传空串会退化成相对路径、在 cwd 下找(这条踩过)。 */
88
+ logDir, _ := sessionLogDir()
89
+ first := session.FirstUserText(logDir, session.ResolveLogID(logDir, key), "")
90
+ if strings.TrimSpace(first) == "" {
91
+ if sessionID == "" {
92
+ first = rec.PromptHead // 首轮且没日志:prompt_head 就是首问
93
+ }
94
+ }
95
+ if strings.TrimSpace(first) == "" {
96
+ return fmt.Errorf("--summarize-title %s: 读不到这条会话的**首问**(没有事件日志)——"+
97
+ "标题只按首问归纳,不拿当前这轮的提示词凑数(否则标题会跟着追问变);"+
98
+ "该会话要用 --save-history 跑过才归纳得动", id)
99
+ }
100
+
101
+ res, err := session.Summarize(cmd.Context(), session.SummarizeOptions{
102
+ SessionID: sessionID,
103
+ RunID: runID,
104
+ Text: first,
105
+ Engine: opts.titleEngine,
106
+ Force: true, // 上面已处理过缓存分支,到这里必然要真归纳
107
+ })
108
+ if err != nil {
109
+ return fmt.Errorf("--summarize-title %s: 归纳失败: %w", id, err)
110
+ }
111
+ return printJSON(cmd.OutOrStdout(), res)
112
+ }
113
+
114
+ // runSessionTitle 读一条会话的**已存**标题(不调 LLM):`--session-title <id>`。
115
+ //
116
+ // 为什么要单独一条读命令:列表页要为每行取标题,逐行调 `--summarize-title` 会把
117
+ // 每行都变成一次 LLM 调用。有了覆盖表之后,「读」必须能单独走(GET /desk/titles 同理)。
118
+ func runSessionTitle(cmd *cobra.Command, opts *askOptions) error {
119
+ id := strings.TrimSpace(opts.sessionTitle)
120
+ if id == "" {
121
+ return &usageError{fmt.Errorf("--session-title 需要会话 id:传 session_id 或 run_id")}
122
+ }
123
+ t := session.TitleFor(id)
124
+ if t == "" {
125
+ /* 没有直接用这个 id 建的覆盖 → 认登记表里的**两个键**(session_id / run_id):
126
+ 首轮归纳时引擎可能还没吐 session_id,键落在那一轮的 run_id 上(见 TitleForAny)。
127
+ 不认它的话,按 session_id 读会回落成 prompt_head(= **当前这轮**的提示词),
128
+ 于是同一会话在两个客户端上显示成不同标题。 */
129
+ rec, found, _ := session.Find(id)
130
+ if found {
131
+ t = session.TitleForAny(rec.SessionID, rec.RunID)
132
+ if t == "" {
133
+ t = rec.PromptHead // 没归纳过 = 回落到首问原文。
134
+ }
135
+ }
136
+ }
137
+ return printJSON(cmd.OutOrStdout(), struct {
138
+ ID string `json:"id"`
139
+ Title string `json:"title"`
140
+ }{ID: id, Title: t})
141
+ }