pi-web-ui 0.91.0 → 0.93.0

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 (64) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +39 -8
  5. package/dist/server/agent-service.js +938 -163
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +115 -0
  8. package/dist/server/claim-store.js +301 -0
  9. package/dist/server/client-state.js +114 -6
  10. package/dist/server/conversation-read-tool.js +371 -28
  11. package/dist/server/conversation-touches.js +357 -0
  12. package/dist/server/delegate-task.js +10 -3
  13. package/dist/server/dsh/dsh-agent-service.js +50 -1
  14. package/dist/server/files-service.js +78 -26
  15. package/dist/server/http-proxy.js +72 -0
  16. package/dist/server/index.js +94 -11
  17. package/dist/server/model-admin.js +124 -2
  18. package/dist/server/model-enrich.js +478 -0
  19. package/dist/server/patch-node-pty.js +11 -0
  20. package/dist/server/plugin-schedule.js +27 -2
  21. package/dist/server/plugins.js +66 -10
  22. package/dist/server/present-files-tool.js +303 -0
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +125 -0
  25. package/dist/server/resolve-global-sdk.js +72 -0
  26. package/dist/server/sdk-origin.js +64 -0
  27. package/dist/server/serialize.js +80 -22
  28. package/dist/server/settings-service.js +19 -1
  29. package/dist/server/skill-tool.js +2 -1
  30. package/dist/server/subagents.js +30 -13
  31. package/dist/server/terminals.js +38 -18
  32. package/dist/server/text-sniff.js +15 -0
  33. package/dist/server/themes.js +32 -2
  34. package/dist/server/tool-info.js +89 -0
  35. package/dist/server/tool-manager.js +110 -11
  36. package/package.json +2 -2
  37. package/plugin-sdk/README.md +1 -0
  38. package/plugins/catalog.json +20 -0
  39. package/themes/ayu-light.css +136 -0
  40. package/themes/catppuccin-latte.css +140 -0
  41. package/themes/catppuccin.css +144 -0
  42. package/themes/codex.css +136 -0
  43. package/themes/cyberpunk.css +4 -0
  44. package/themes/dazzle.css +4 -0
  45. package/themes/everforest-light.css +136 -0
  46. package/themes/geist.css +136 -0
  47. package/themes/gruvbox-light.css +136 -0
  48. package/themes/kanagawa-lotus.css +136 -0
  49. package/themes/md-preview.css +4 -0
  50. package/themes/mist.css +4 -0
  51. package/themes/nord.css +134 -0
  52. package/themes/one-dark.css +135 -0
  53. package/themes/paper.css +16 -12
  54. package/themes/rose-pine-dawn.css +136 -0
  55. package/themes/sakura.css +4 -0
  56. package/themes/solarized-light.css +135 -0
  57. package/themes/tokyo-night.css +144 -0
  58. package/themes/white.css +4 -0
  59. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
  60. package/web/dist/assets/index-B48QWf0I.js +364 -0
  61. package/web/dist/assets/index-DGL5OInG.css +1 -0
  62. package/web/dist/index.html +2 -2
  63. package/web/dist/assets/index-CdsKltOS.css +0 -1
  64. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -22,14 +22,24 @@
22
22
  import { defineTool } from "@earendil-works/pi-coding-agent";
23
23
  import { Type } from "typebox";
24
24
  import { bilingual, pick } from "./i18n.js";
25
+ import { CONVERSATION_READ_TOOL_NAME } from "./tool-manager.js";
26
+ import { extractTouches, formatTouchEntry, formatTouchesCompact, toolCallRefsOfContent, unionTouchLists, } from "./conversation-touches.js";
27
+ /** 由 content 块提 toolCalls 字段(additive;格式化输出不受影响,见接口注释)。 */
28
+ function fillToolCalls(target, content) {
29
+ const refs = toolCallRefsOfContent(content);
30
+ if (refs.length > 0)
31
+ target.toolCalls = refs;
32
+ }
25
33
  /** AgentMessage → 转录最小结构(角色原样保留,内容不动,格式化延后)。 */
26
34
  export function toTranscriptInput(m) {
27
35
  const role = m.role;
28
36
  const r = typeof role === "string" ? role : "unknown";
29
37
  const base = { role: r };
30
38
  const anyM = m;
31
- if (typeof anyM.content !== "undefined")
39
+ if (typeof anyM.content !== "undefined") {
32
40
  base.content = anyM.content;
41
+ fillToolCalls(base, anyM.content);
42
+ }
33
43
  if (typeof anyM.toolName === "string")
34
44
  base.toolName = anyM.toolName;
35
45
  if (typeof anyM.isError === "boolean")
@@ -118,8 +128,14 @@ function roleLabel(m) {
118
128
  return `tool result${m.toolName ? ` (${m.toolName})` : ""}`;
119
129
  return m.role;
120
130
  }
131
+ /** 单条消息截断:超出部分标注剩余字符数(调用方一眼知道丢了多少)。 */
132
+ export function truncateCounted(s, cap) {
133
+ if (s.length <= cap)
134
+ return s;
135
+ return `${s.slice(0, cap)}\n… +${s.length - cap} chars`;
136
+ }
121
137
  function trunc(s, cap) {
122
- return s.length <= cap ? s : `${s.slice(0, cap)}\n… [truncated]`;
138
+ return truncateCounted(s, cap);
123
139
  }
124
140
  /** 转录分页格式化:序号形如 [12/135 user],尾部带翻页提示位(调用方拼 header)。 */
125
141
  export function formatTranscript(messages, opts) {
@@ -165,8 +181,10 @@ export function parseTranscriptLines(text) {
165
181
  if (e?.type === "message" && e.message && typeof e.message.role === "string") {
166
182
  const msg = e.message;
167
183
  const m = { role: msg.role };
168
- if (typeof msg.content !== "undefined")
184
+ if (typeof msg.content !== "undefined") {
169
185
  m.content = msg.content;
186
+ fillToolCalls(m, msg.content);
187
+ }
170
188
  if (typeof msg.toolName === "string")
171
189
  m.toolName = msg.toolName;
172
190
  if (typeof msg.isError === "boolean")
@@ -191,6 +209,134 @@ export function parseTranscriptLines(text) {
191
209
  function matchesHay(hay, q) {
192
210
  return q === "" || hay.toLowerCase().includes(q);
193
211
  }
212
+ /** read 选择器(纯函数):view → query → last 的顺序固定,见注释。 */
213
+ export function selectReadMessages(messages, opts) {
214
+ const list = Array.isArray(messages) ? messages : [];
215
+ const view = opts?.view === "full" ? "full" : "chat";
216
+ const inView = view === "full" ? list : list.filter((m) => m.role === "user" || m.role === "assistant");
217
+ const q = (opts?.query ?? "").trim().toLowerCase();
218
+ let selected = inView;
219
+ let hitIndices = [];
220
+ if (q) {
221
+ // 在 view 内按转录文本搜:hitIndices 只记命中(报数/定位用),
222
+ // selected 是命中 ±1 条上下文展开(去重保序)。
223
+ const hits = [];
224
+ const keep = new Set();
225
+ for (let i = 0; i < inView.length; i++) {
226
+ if (transcriptText(inView[i]).toLowerCase().includes(q)) {
227
+ hits.push(i);
228
+ for (let j = Math.max(0, i - 1); j <= Math.min(inView.length - 1, i + 1); j++)
229
+ keep.add(j);
230
+ }
231
+ }
232
+ hitIndices = hits;
233
+ selected = [...keep].sort((a, b) => a - b).map((i) => inView[i]);
234
+ }
235
+ const last = opts?.last;
236
+ if (typeof last === "number" && Number.isFinite(last)) {
237
+ const n = Math.min(200, Math.max(1, Math.floor(last)));
238
+ if (selected.length > n) {
239
+ selected = selected.slice(selected.length - n);
240
+ // last 裁掉上下文外层时命中序号跟走(只留仍在选区内的)。
241
+ if (hitIndices.length > 0) {
242
+ const keep = new Set(selected);
243
+ hitIndices = hitIndices.filter((i) => keep.has(inView[i]));
244
+ }
245
+ }
246
+ }
247
+ return { selected, totalInView: inView.length, hitIndices };
248
+ }
249
+ /** 工具参数里的一句话提示(path/command,单行 ≤80 字符;拿不到就空串)。 */
250
+ function toolHint(args) {
251
+ if (!args || typeof args !== "object")
252
+ return "";
253
+ const a = args;
254
+ for (const k of ["path", "file_path", "filePath", "command"]) {
255
+ const v = a[k];
256
+ if (typeof v === "string" && v.trim()) {
257
+ const oneLine = v.trim().replace(/\s+/g, " ");
258
+ return oneLine.length > 80 ? `${oneLine.slice(0, 80)}…` : oneLine;
259
+ }
260
+ }
261
+ return "";
262
+ }
263
+ /** action=status 的摘要器(纯函数):只读转录,不碰 host。 */
264
+ export function summarizeConversation(messages) {
265
+ const list = Array.isArray(messages) ? messages : [];
266
+ let lastTool;
267
+ let lastAssistant;
268
+ let lastAskIdx = -1;
269
+ let lastAskResultIdx = -1;
270
+ for (let i = 0; i < list.length; i++) {
271
+ const m = list[i];
272
+ if (!m || typeof m !== "object")
273
+ continue;
274
+ if (m.role === "assistant") {
275
+ const textParts = [];
276
+ const calls = toolCallRefsOfContent(m.content);
277
+ if (m.toolCalls && m.toolCalls.length > 0) {
278
+ for (const c of m.toolCalls) {
279
+ if (c && typeof c.name === "string") {
280
+ lastTool = { name: c.name, hint: toolHint(c.args) };
281
+ if (c.name === "ask_user_question")
282
+ lastAskIdx = i;
283
+ }
284
+ }
285
+ }
286
+ else {
287
+ for (const c of calls) {
288
+ lastTool = { name: c.name, hint: toolHint(c.args) };
289
+ if (c.name === "ask_user_question")
290
+ lastAskIdx = i;
291
+ }
292
+ }
293
+ if (Array.isArray(m.content)) {
294
+ for (const b of m.content) {
295
+ if (b && typeof b === "object" && b.type === "text") {
296
+ const t = b.text;
297
+ if (typeof t === "string" && t)
298
+ textParts.push(t);
299
+ }
300
+ }
301
+ }
302
+ const joined = textParts.join("\n").trim();
303
+ if (joined)
304
+ lastAssistant = joined;
305
+ }
306
+ else if (m.role === "bashExecution" && typeof m.command === "string") {
307
+ lastTool = { name: "bash", hint: toolHint({ command: m.command }) };
308
+ }
309
+ else if (m.role === "toolResult" && m.toolName === "ask_user_question") {
310
+ lastAskResultIdx = i;
311
+ }
312
+ }
313
+ return { lastTool, lastAssistant, waitingQuestion: lastAskIdx >= 0 && lastAskResultIdx < lastAskIdx };
314
+ }
315
+ /** read 的附加说明行:query 命中位置 + chat 视图裁剪说明(纯函数)。 */
316
+ export function readExtraLines(L, query, sel, rawTotal, view) {
317
+ const lines = [];
318
+ const q = query.trim();
319
+ if (q && sel.hitIndices.length > 0) {
320
+ const shown = sel.hitIndices.slice(0, 20).map((i) => i + 1);
321
+ const idx = sel.hitIndices.length > 20 ? `${shown.join("、")}… (+${sel.hitIndices.length - 20})` : shown.join("、");
322
+ lines.push(pick(L, `在对话内搜「${q}」:命中 ${sel.hitIndices.length} 条(选区第 ${idx} 条),上下文共 ${sel.selected.length} 条。`, `Searched for "${q}": ${sel.hitIndices.length} hit(s) (#${idx} in this selection), ${sel.selected.length} with context.`, "convread.read.query.head", { q, hits: sel.hitIndices.length, indices: idx, shown: sel.selected.length }));
323
+ }
324
+ if (view === "chat" && rawTotal > sel.totalInView) {
325
+ lines.push(pick(L, `(chat 视图只含 user/assistant 共 ${sel.totalInView} 条;另有 ${rawTotal - sel.totalInView} 条工具调用/结果,用 view="full" 看。)`, `(chat view: only user/assistant, ${sel.totalInView} messages; ${rawTotal - sel.totalInView} more tool messages with view="full".)`, "convread.read.chat.note", { chat: sel.totalInView, tools: rawTotal - sel.totalInView }));
326
+ }
327
+ return lines.length > 0 ? `\n${lines.join("\n")}` : "";
328
+ }
329
+ /** 认领行格式化(files/status 共用,纯函数;head 文案各调用方自己 pick)。
330
+ * style: bullets = 独立成行(files 块用);inline = 行内逗分(status 一行用)。 */
331
+ export function formatClaimLines(claims, maxItems = 5, style = "bullets") {
332
+ const list = Array.isArray(claims) ? claims : [];
333
+ const shown = list.slice(0, Math.max(1, Math.floor(maxItems)));
334
+ const dash = style === "bullets" ? "- " : "";
335
+ const lines = shown.map((c) => `${dash}${c.path} · 「${c.ownerTitle}」${c.note ? ` · ${c.note}` : ""}`);
336
+ if (list.length > shown.length)
337
+ lines.push(`… (+${list.length - shown.length})`);
338
+ return lines;
339
+ }
194
340
  export function filterRunning(list, query) {
195
341
  const q = query.trim().toLowerCase();
196
342
  if (!q)
@@ -207,25 +353,38 @@ export function filterHistory(list, query) {
207
353
  export function shortPath(p, keep = 48) {
208
354
  return p.length <= keep ? p : `…${p.slice(p.length - keep)}`;
209
355
  }
210
- export function makeConversationReadTool(host, lang) {
356
+ /** action=list 的默认条数:历史默认 15(以前无上限,几百条一次吐完);
357
+ * 运行中默认不限但给上限(并发对话再多也不会撑爆 details 的 64KB)。 */
358
+ export const HISTORY_LIST_DEFAULT = 15;
359
+ export const RUNNING_LIST_CAP = 50;
360
+ /** action=read 单条消息默认截断(约 600 字符,超出标 “… +N chars”;
361
+ * 以前默认 2000,一条 bash 输出就能吃掉整个 maxChars 预算)。 */
362
+ export const READ_PER_MSG_CAP = 600;
363
+ export function makeConversationReadTool(host, lang, extras) {
211
364
  const getLang = lang ?? (() => "en");
212
365
  const text = (t, details = {}) => ({
213
366
  content: [{ type: "text", text: t }],
214
367
  details,
215
368
  });
216
369
  return defineTool({
217
- name: "conversation_read",
370
+ name: CONVERSATION_READ_TOOL_NAME,
218
371
  label: "Read another conversation",
219
- description: bilingual('Read ANOTHER conversation\'s messages: a running conversation of this client (including subagents — use its conversation id like "c3", see action=list) or a persisted history session transcript (use its file path). ' +
372
+ description: bilingual('Read ANOTHER conversation: a running conversation of this client (including subagents — use its conversation id like "c3", see action=list) or a persisted history session transcript (use its file path). ' +
220
373
  'Use it when the user references another chat (a quoted conversation, a pasted conversation id, or "see the other chat about X"). ' +
221
- "action=list shows running conversations (id, title, project, streaming state) plus history sessions; action=read returns one transcript paged (offset/limit, capped chars — re-call with a larger offset for the rest). " +
374
+ "action=list shows running conversations plus history sessions (kind=running|history|all, query filter, limit; history defaults to 15). " +
375
+ 'action=read returns one transcript: view=chat (default: only user/assistant text) or full (incl. tool calls/results); last=N takes the latest N messages instead of guessing offset; query searches within the conversation (hits ±1 context); each message is capped (~600 chars, marked "… +N chars"). ' +
376
+ "action=files lists files the conversation created/modified (with counts). " +
377
+ "action=status gives a short summary: last tool call, last assistant text, recently touched files, whether it is waiting for a question answer. " +
222
378
  "Only sessions from the session list can be read (arbitrary file paths are refused). " +
223
- "Live runs in OTHER browser tabs are not readable here — they appear in history once persisted.", "读取**另一个对话**的消息:本客户端的运行中对话(含子代理——用 action=list 查 conversation id,如 c3)或已落盘的历史会话转录(用它的文件 path)。" +
379
+ "Live runs in OTHER browser tabs are not readable here — they appear in history once persisted.", "读取**另一个对话**:本客户端的运行中对话(含子代理——用 action=list 查 conversation id,如 c3)或已落盘的历史会话转录(用它的文件 path)。" +
224
380
  "用户引用了别的对话(引用 chip、粘过来的对话 id、“看看之前那个关于 X 的对话”)时用它。" +
225
- "action=list 列运行中对话(id、标题、项目、是否进行中)与历史会话;action=read 取某一份转录(offset/limit 翻页、字符封顶——后文用更大的 offset 再取)。" +
381
+ "action=list 列运行中对话与历史会话(kind=running|history|all,query 过滤,limit;历史默认 15 条)。" +
382
+ "action=read 取某一份转录:view=chat(默认:只给 user/assistant 文本)或 full(含工具调用与结果);last=N 取最新 N 条(不用猜 offset);query 在对话内搜索(命中 ±1 条上下文);每条消息独立截断(约 600 字符,超出标 “… +N chars”)。" +
383
+ "action=files 列该对话创建/修改过的文件(含次数)。" +
384
+ "action=status 给一两行摘要:最后一次工具调用、最后一句 assistant、最近动过的文件、是否在等用户回答问卷。" +
226
385
  "只能读会话列表里的转录(任意文件路径会被拒绝)。" +
227
386
  "其他浏览器标签页里的实时运行在这里读不到——它们落盘后会出现在历史里。"),
228
- promptSnippet: "read another conversation's messages (running incl. subagents, or history transcript)",
387
+ promptSnippet: "read another conversation: list/find chats, read transcript (chat view / tail / search), touched files, status summary",
229
388
  parameters: Type.Object({
230
389
  action: Type.Optional(Type.String({
231
390
  description: 'list = show running conversations + history sessions; read = fetch one transcript. Default "list".',
@@ -233,11 +392,22 @@ export function makeConversationReadTool(host, lang) {
233
392
  scope: Type.Optional(Type.String({
234
393
  description: 'list only: "current" = this project, "all" = every project. Default "all".',
235
394
  })),
236
- query: Type.Optional(Type.String({ description: "list only: case-insensitive filter over id/title/path/first message." })),
395
+ kind: Type.Optional(Type.String({
396
+ description: 'list only: "running" | "history" | "all" (which sections to show). Default "all".',
397
+ })),
398
+ query: Type.Optional(Type.String({
399
+ description: "list: case-insensitive filter over id/title/path/first message. read/files: search within the transcript (read returns hits ±1 context).",
400
+ })),
401
+ view: Type.Optional(Type.String({
402
+ description: 'read only: "chat" = only user/assistant text (default, cheap); "full" = incl. tool calls/results.',
403
+ })),
404
+ last: Type.Optional(Type.Number({ description: "read only: take the latest N messages (1-200) instead of guessing offset." })),
237
405
  id: Type.Optional(Type.String({ description: 'read only: running conversation id (e.g. "c3"), from action=list.' })),
238
406
  path: Type.Optional(Type.String({ description: "read only: history session file path, from action=list." })),
239
407
  offset: Type.Optional(Type.Number({ description: "read only: first message index (0-based). Default 0." })),
240
- limit: Type.Optional(Type.Number({ description: "read only: max messages to return (1-200). Default 50." })),
408
+ limit: Type.Optional(Type.Number({
409
+ description: "list: max entries per section (history defaults to 15, running uncapped up to 50). read/files: max messages/entries (1-200, default 50).",
410
+ })),
241
411
  maxChars: Type.Optional(Type.Number({ description: "read only: max characters (1000-60000). Default 20000." })),
242
412
  }),
243
413
  execute: async (_id, p, _signal, _onUpdate, ctx) => {
@@ -248,50 +418,105 @@ export function makeConversationReadTool(host, lang) {
248
418
  return text(pick(getLang(), `scope 非法:${p.scope}(只能是 current 或 all)。`, `Invalid scope: ${p.scope} (must be "current" or "all").`, "convread.list.bad.scope", { "p.scope": p.scope }));
249
419
  }
250
420
  const scope = scopeRaw;
421
+ const kindRaw = (p.kind ?? "all").trim().toLowerCase();
422
+ if (kindRaw !== "running" && kindRaw !== "history" && kindRaw !== "all") {
423
+ return text(pick(getLang(), `kind 非法:${p.kind}(只能是 running、history 或 all)。`, `Invalid kind: ${p.kind} (must be "running", "history" or "all").`, "convread.list.bad.kind", { "p.kind": p.kind }));
424
+ }
425
+ const kind = kindRaw;
251
426
  const query = typeof p.query === "string" ? p.query : "";
252
- const running = filterRunning(host.listRunningConversations(), query);
253
- const history = filterHistory(await host.listHistorySessions(scope, ctx.cwd), query);
427
+ const cap = typeof p.limit === "number" && Number.isFinite(p.limit)
428
+ ? Math.min(200, Math.max(1, Math.floor(p.limit)))
429
+ : undefined;
430
+ // kind 过滤掉的一整节直接不取不展(不为了它多一次 I/O)。
431
+ const running = kind === "history" ? [] : filterRunning(host.listRunningConversations(), query);
432
+ const history = kind === "running" ? [] : filterHistory(await host.listHistorySessions(scope, ctx.cwd), query);
433
+ const shownRunning = running.slice(0, cap ?? RUNNING_LIST_CAP);
434
+ const shownHistory = history.slice(0, cap ?? HISTORY_LIST_DEFAULT);
254
435
  const L = getLang();
255
- const runLines = running.map((c) => `- ${c.id} · ${c.title} · ${c.isSubagent ? (L === "zh" ? "子代理" : "subagent") : L === "zh" ? "对话" : "chat"}${c.isStreaming ? (L === "zh" ? "(进行中)" : " (streaming)") : ""} · ${c.messageCount} msgs · ${c.cwd}`);
256
- const histLines = history.map((s) => `- ${shortPath(s.path)} · ${s.name || s.firstMessage || (L === "zh" ? "(空对话)" : "(empty)")}${s.messageCount ? ` · ${s.messageCount} msgs` : ""}`);
257
- const head = L === "zh"
258
- ? `运行中对话(${running.length})+ 历史会话(${history.length},scope=${scope}):`
259
- : `Running conversations (${running.length}) + history sessions (${history.length}, scope=${scope}):`;
436
+ const runLines = shownRunning.map((c) => `- ${c.id} · ${c.title} · ${c.isSubagent ? (L === "zh" ? "子代理" : "subagent") : L === "zh" ? "对话" : "chat"}${c.isStreaming ? (L === "zh" ? "(进行中)" : " (streaming)") : ""} · ${c.messageCount} msgs · ${c.cwd}`);
437
+ const histLines = shownHistory.map((s) => `- ${shortPath(s.path)} · ${s.name || s.firstMessage || (L === "zh" ? "(空对话)" : "(empty)")}${s.messageCount ? ` · ${s.messageCount} msgs` : ""}`);
438
+ // 超出 cap 时给计数 + 收窄指引(静默丢弃是大忌:调用方会以为这就是全量)。
439
+ if (running.length > shownRunning.length) {
440
+ runLines.push(pick(L, `… 还有 ${running.length - shownRunning.length} 条运行中(用 query 或 limit 收窄)。`, `… ${running.length - shownRunning.length} more running (narrow with query or limit).`, "convread.list.running.more", { n: running.length - shownRunning.length }));
441
+ }
442
+ if (history.length > shownHistory.length) {
443
+ histLines.push(pick(L, `… 还有 ${history.length - shownHistory.length} 条历史(用 query 或 limit 收窄;默认只给 ${HISTORY_LIST_DEFAULT} 条)。`, `… ${history.length - shownHistory.length} more history (narrow with query or limit; default shows ${HISTORY_LIST_DEFAULT}).`, "convread.list.history.more", { n: history.length - shownHistory.length }));
444
+ }
445
+ const head = kind === "running"
446
+ ? pick(L, `运行中对话(${running.length}):`, `Running conversations (${running.length}):`, "convread.list.head.running", { n: running.length })
447
+ : kind === "history"
448
+ ? pick(L, `历史会话(${history.length},scope=${scope}):`, `History sessions (${history.length}, scope=${scope}):`, "convread.list.head.history", { n: history.length })
449
+ : pick(L, `运行中对话(${running.length})+ 历史会话(${history.length},scope=${scope}):`, `Running conversations (${running.length}) + history sessions (${history.length}, scope=${scope}):`, "convread.list.head.all", { running: running.length, history: history.length });
260
450
  const runHead = L === "zh" ? "【运行中】" : "[running]";
261
451
  const histHead = L === "zh" ? "【历史】" : "[history]";
262
452
  const empty = L === "zh" ? "(无)" : "(none)";
263
453
  const tail = L === "zh"
264
- ? `读某一份:conversation_read(action="read", id="c…") 或 conversation_read(action="read", path="…"),长转录用 offset/limit 翻页。`
265
- : `To read one: conversation_read(action="read", id="c…") or conversation_read(action="read", path="…"); page long transcripts with offset/limit.`;
266
- return text(`${head}\n${runHead}\n${runLines.join("\n") || empty}\n${histHead}\n${histLines.join("\n") || empty}\n${tail}`, { running, history, scope });
454
+ ? `读某一份:conversation_read(action="read", id="c…") 或 conversation_read(action="read", path="…"),长转录用 offset/limit 翻页;只看文件用 action="files",先看摘要再决定读不读用 action="status"。`
455
+ : `To read one: conversation_read(action="read", id="c…") or conversation_read(action="read", path="…"); page long transcripts with offset/limit; action="files" lists touched files, action="status" summarizes before you decide to read.`;
456
+ const sections = kind === "history"
457
+ ? `${head}\n${histHead}\n${histLines.join("\n") || empty}`
458
+ : kind === "running"
459
+ ? `${head}\n${runHead}\n${runLines.join("\n") || empty}`
460
+ : `${head}\n${runHead}\n${runLines.join("\n") || empty}\n${histHead}\n${histLines.join("\n") || empty}`;
461
+ // details 同样有界:只带展示出的条目(历史的 firstMessage 再掐到 160
462
+ // 字符),以前整量 history 进 details 会撑爆 64KB 被整段丢弃。
463
+ return text(`${sections}\n${tail}`, {
464
+ scope,
465
+ kind,
466
+ runningTotal: running.length,
467
+ historyTotal: history.length,
468
+ running: shownRunning,
469
+ history: shownHistory.map((s) => ({
470
+ ...s,
471
+ firstMessage: typeof s.firstMessage === "string" && s.firstMessage.length > 160
472
+ ? `${s.firstMessage.slice(0, 160)}…`
473
+ : s.firstMessage,
474
+ })),
475
+ truncated: running.length > shownRunning.length || history.length > shownHistory.length,
476
+ });
267
477
  }
268
478
  if (action === "read") {
479
+ const viewRaw = (p.view ?? "chat").trim().toLowerCase();
480
+ if (viewRaw !== "chat" && viewRaw !== "full") {
481
+ return text(pick(getLang(), `view 非法:${p.view}(只能是 chat 或 full)。`, `Invalid view: ${p.view} (must be "chat" or "full").`, "convread.read.bad.view", { "p.view": p.view }));
482
+ }
483
+ const view = viewRaw;
269
484
  const id = typeof p.id === "string" && p.id.trim() ? p.id.trim() : undefined;
270
485
  const path = typeof p.path === "string" && p.path.trim() ? p.path.trim() : undefined;
271
486
  if ((id && path) || (!id && !path)) {
272
487
  return text(pick(getLang(), `action=read 需要且只需要 id 或 path 其中之一(id 读运行中对话,path 读历史转录)。`, `action=read needs exactly one of id or path (id = running conversation, path = history transcript).`, "convread.read.bad.args"));
273
488
  }
274
- const opts = { offset: p.offset, limit: p.limit, maxChars: p.maxChars };
489
+ const query = typeof p.query === "string" ? p.query : "";
490
+ const opts = { offset: p.offset, limit: p.limit, maxChars: p.maxChars, perMsgCap: READ_PER_MSG_CAP };
275
491
  if (id) {
276
492
  const found = host.readRunningConversation(id);
277
493
  if (!found) {
278
494
  return text(pick(getLang(), `未找到运行中对话 ${id}(可能已关闭;用 action=list 看当前列表,落盘的可按 path 读历史)。`, `Running conversation ${id} not found (may be closed; use action=list for the current list, or read persisted ones by path).`, "convread.read.id.not.found", { id }));
279
495
  }
280
- const f = formatTranscript(found.messages, opts);
496
+ const rawTotal = found.messages.length;
497
+ const sel = selectReadMessages(found.messages, { view, last: p.last, query });
281
498
  const L = getLang();
499
+ if (query.trim() && sel.hitIndices.length === 0) {
500
+ return text(pick(L, `在对话内搜「${query.trim()}」:没有命中。换个词,或用 view="full" 把工具输出也纳入搜索。`, `No hits for "${query.trim()}". Try another term, or use view="full" to include tool outputs.`, "convread.read.query.empty", { q: query.trim() }), { id, title: found.title, view, query: query.trim(), hits: 0 });
501
+ }
502
+ const f = formatTranscript(sel.selected, opts);
282
503
  const head = L === "zh"
283
504
  ? `对话「${found.title}」(id=${id}${found.isSubagent ? ",子代理" : ""},${found.cwd},共 ${f.total} 条,${f.from + 1}-${f.to}):`
284
505
  : `Conversation "${found.title}" (id=${id}${found.isSubagent ? ", subagent" : ""}, ${found.cwd}, ${f.total} messages, showing ${f.from + 1}-${f.to}):`;
506
+ const notes = readExtraLines(L, query, sel, rawTotal, view);
285
507
  const more = f.truncated && f.total > 0
286
508
  ? L === "zh"
287
509
  ? `\n…还有后文(offset=${f.to} 再取)。`
288
510
  : `\n…more below (re-call with offset=${f.to}).`
289
511
  : "";
290
512
  const emptyNote = f.total === 0 ? (L === "zh" ? "(该对话暂无消息)" : "(no messages yet)") : "";
291
- return text(`${head}\n${f.text || emptyNote}${more}`, {
513
+ return text(`${head}${notes}\n${f.text || emptyNote}${more}`, {
292
514
  id,
293
515
  title: found.title,
516
+ view,
517
+ ...(query.trim() ? { query: query.trim(), hitIndices: sel.hitIndices.slice(0, 500) } : {}),
294
518
  total: f.total,
519
+ rawTotal,
295
520
  from: f.from,
296
521
  to: f.to,
297
522
  });
@@ -300,26 +525,144 @@ export function makeConversationReadTool(host, lang) {
300
525
  if (!found) {
301
526
  return text(pick(getLang(), `读不到历史会话 ${path}(不在会话列表里:只能读 action=list 列出的转录路径)。`, `Cannot read history session ${path} (not in the session list: only transcripts from action=list can be read).`, "convread.read.path.not.found", { path }));
302
527
  }
303
- const f = formatTranscript(found.messages, opts);
528
+ const rawTotal = found.messages.length;
529
+ const sel = selectReadMessages(found.messages, { view, last: p.last, query });
304
530
  const L = getLang();
531
+ if (query.trim() && sel.hitIndices.length === 0) {
532
+ return text(pick(L, `在对话内搜「${query.trim()}」:没有命中。换个词,或用 view="full" 把工具输出也纳入搜索。`, `No hits for "${query.trim()}". Try another term, or use view="full" to include tool outputs.`, "convread.read.query.empty", { q: query.trim() }), { path: found.sessionPath, title: found.title, view, query: query.trim(), hits: 0 });
533
+ }
534
+ const f = formatTranscript(sel.selected, opts);
305
535
  const head = L === "zh"
306
536
  ? `历史会话「${found.title || found.sessionPath}」(${found.cwd},共 ${f.total} 条,${f.from + 1}-${f.to}):`
307
537
  : `History session "${found.title || found.sessionPath}" (${found.cwd}, ${f.total} messages, showing ${f.from + 1}-${f.to}):`;
538
+ const notes = readExtraLines(L, query, sel, rawTotal, view);
308
539
  const more = f.truncated && f.total > 0
309
540
  ? L === "zh"
310
541
  ? `\n…还有后文(offset=${f.to} 再取)。`
311
542
  : `\n…more below (re-call with offset=${f.to}).`
312
543
  : "";
313
544
  const emptyNote = f.total === 0 ? (L === "zh" ? "(该会话暂无消息)" : "(no messages yet)") : "";
314
- return text(`${head}\n${f.text || emptyNote}${more}`, {
545
+ return text(`${head}${notes}\n${f.text || emptyNote}${more}`, {
315
546
  path: found.sessionPath,
316
547
  title: found.title,
548
+ view,
549
+ ...(query.trim() ? { query: query.trim(), hitIndices: sel.hitIndices.slice(0, 500) } : {}),
317
550
  total: f.total,
551
+ rawTotal,
318
552
  from: f.from,
319
553
  to: f.to,
320
554
  });
321
555
  }
322
- return text(pick(getLang(), `action 非法:${p.action}(只能是 list 或 read)。`, `Invalid action: ${p.action} (must be "list" or "read").`, "convread.bad.action", { "p.action": p.action }));
556
+ if (action === "files" || action === "status") {
557
+ const id = typeof p.id === "string" && p.id.trim() ? p.id.trim() : undefined;
558
+ const path = typeof p.path === "string" && p.path.trim() ? p.path.trim() : undefined;
559
+ if ((id && path) || (!id && !path)) {
560
+ return text(pick(getLang(), `action=${action} 需要且只需要 id 或 path 其中之一(id 读运行中对话,path 读历史转录)。`, `action=${action} needs exactly one of id or path (id = running conversation, path = history transcript).`, action === "files" ? "convread.files.bad.args" : "convread.status.bad.args"));
561
+ }
562
+ // 取数(错误复用 read 的 not found 文案:同一个“找不到”语义)。
563
+ let title = "";
564
+ let cwd = "";
565
+ let messages = [];
566
+ if (id) {
567
+ const found = host.readRunningConversation(id);
568
+ if (!found) {
569
+ return text(pick(getLang(), `未找到运行中对话 ${id}(可能已关闭;用 action=list 看当前列表,落盘的可按 path 读历史)。`, `Running conversation ${id} not found (may be closed; use action=list for the current list, or read persisted ones by path).`, "convread.read.id.not.found", { id }));
570
+ }
571
+ title = found.title;
572
+ cwd = found.cwd;
573
+ messages = found.messages;
574
+ }
575
+ else {
576
+ const found = await host.readHistorySession(path);
577
+ if (!found) {
578
+ return text(pick(getLang(), `读不到历史会话 ${path}(不在会话列表里:只能读 action=list 列出的转录路径)。`, `Cannot read history session ${path} (not in the session list: only transcripts from action=list can be read).`, "convread.read.path.not.found", { path }));
579
+ }
580
+ title = found.title || found.sessionPath;
581
+ cwd = found.cwd;
582
+ messages = found.messages;
583
+ }
584
+ const L = getLang();
585
+ const where = id ? { id } : { path };
586
+ if (action === "files") {
587
+ const fq = (typeof p.query === "string" ? p.query : "").trim().toLowerCase();
588
+ // sidecar(压缩前的触碰)∪ 实时转录:union 语义,见 unionTouchLists。
589
+ const all = unionTouchLists(host.readTouchSidecar?.(id, path) ?? [], extractTouches(messages));
590
+ const filtered = fq ? all.filter((t) => t.path.toLowerCase().includes(fq)) : all;
591
+ const listedClaims = (() => {
592
+ try {
593
+ const rows = extras?.listClaims?.(cwd) ?? [];
594
+ return fq ? rows.filter((c) => c.path.toLowerCase().includes(fq)) : rows;
595
+ }
596
+ catch {
597
+ return [];
598
+ }
599
+ })();
600
+ const cap = typeof p.limit === "number" && Number.isFinite(p.limit)
601
+ ? Math.min(200, Math.max(1, Math.floor(p.limit)))
602
+ : 50;
603
+ const shown = filtered.slice(0, cap);
604
+ if (filtered.length === 0 && listedClaims.length === 0) {
605
+ return text(pick(L, `对话「${title}」还没有可识别的文件写入(只读围观/刚开场/压缩丢了旧记录都有可能)。`, `Conversation "${title}" has no recognizable file writes yet (read-only so far, just started, or pre-compaction touches lost).`, "convread.files.empty", { title }), { ...where, title, total: 0, files: [] });
606
+ }
607
+ const sections = [];
608
+ if (filtered.length > 0) {
609
+ const lines = shown.map((t) => `- ${formatTouchEntry(t)}`);
610
+ if (filtered.length > shown.length) {
611
+ lines.push(pick(L, `… 还有 ${filtered.length - shown.length} 个(用 query 或 limit 收窄)。`, `… ${filtered.length - shown.length} more (narrow with query or limit).`, "convread.files.more", { n: filtered.length - shown.length }));
612
+ }
613
+ sections.push(`${pick(L, `对话「${title}」创建/修改过的文件(${filtered.length} 个,只算写不算读):`, `Files created/modified by "${title}" (${filtered.length}, writes only):`, "convread.files.head", { title, total: filtered.length })}\n${lines.join("\n")}`);
614
+ }
615
+ if (listedClaims.length > 0) {
616
+ sections.push(`${pick(L, `本项目认领(${listedClaims.length} 条,绕行参考,先到先得):`, `Claims in this project (${listedClaims.length}, steer clear, first-wins):`, "convread.files.claims", { total: listedClaims.length })}\n${formatClaimLines(listedClaims).join("\n")}`);
617
+ }
618
+ return text(sections.join("\n"), {
619
+ ...where,
620
+ title,
621
+ total: filtered.length,
622
+ files: shown,
623
+ claims: listedClaims.map((c) => ({ path: c.path, ownerTitle: c.ownerTitle })),
624
+ });
625
+ }
626
+ // action === "status":一两行摘要,读全文前的低成本侦察。
627
+ const sum = summarizeConversation(messages);
628
+ const touched = unionTouchLists(host.readTouchSidecar?.(id, path) ?? [], extractTouches(messages)).slice(0, 6);
629
+ let statusClaims = [];
630
+ try {
631
+ statusClaims = extras?.listClaims?.(cwd) ?? [];
632
+ }
633
+ catch {
634
+ statusClaims = [];
635
+ }
636
+ const head = pick(L, `对话「${title}」状态(共 ${messages.length} 条):`, `Status of "${title}" (${messages.length} messages):`, "convread.status.head", { title, total: messages.length });
637
+ const toolLine = sum.lastTool
638
+ ? pick(L, `最后工具:${sum.lastTool.name}${sum.lastTool.hint ? `(${sum.lastTool.hint})` : ""}。`, `Last tool: ${sum.lastTool.name}${sum.lastTool.hint ? ` (${sum.lastTool.hint})` : ""}.`, "convread.status.last.tool", { name: sum.lastTool.name, hint: sum.lastTool.hint })
639
+ : pick(L, `还没调用过工具。`, `No tool calls yet.`, "convread.status.no.tool");
640
+ const sayLine = sum.lastAssistant
641
+ ? pick(L, `最后一句:${truncateCounted(sum.lastAssistant, 200)}`, `Last assistant message: ${truncateCounted(sum.lastAssistant, 200)}`, "convread.status.last.say", { text: truncateCounted(sum.lastAssistant, 200) })
642
+ : pick(L, `assistant 还没说过话。`, `No assistant message yet.`, "convread.status.no.say");
643
+ const touchLine = touched.length > 0
644
+ ? pick(L, `最近动过:${formatTouchesCompact(touched, { maxItems: 5 })}。`, `Recently touched: ${formatTouchesCompact(touched, { maxItems: 5 })}.`, "convread.status.touched", { files: formatTouchesCompact(touched, { maxItems: 5 }) })
645
+ : pick(L, `最近没动过文件。`, `No files touched recently.`, "convread.status.touched.none");
646
+ // 等问卷只能从转录推断(ask_user_question 调了没回);DSH 问卷不走
647
+ // 工具调用,推断不出 —— 文案写“转录显示”,不夸大。
648
+ const waitLine = sum.waitingQuestion
649
+ ? `\n${pick(L, `⚠ 转录显示它在等用户回答问卷(先回它,再干别的)。`, `⚠ The transcript shows it waiting for a question answer (answer first).`, "convread.status.waiting")}`
650
+ : "";
651
+ // 认领只在有的时候占一行(没有就不提,省 token)。
652
+ const claimLine = statusClaims.length > 0
653
+ ? `\n- ${pick(L, `认领:${formatClaimLines(statusClaims, 3, "inline").join("、")}。`, `Claims: ${formatClaimLines(statusClaims, 3, "inline").join("; ")}.`, "convread.status.claims", { total: statusClaims.length })}`
654
+ : "";
655
+ return text(`${head}\n- ${toolLine}\n- ${sayLine}\n- ${touchLine}${claimLine}${waitLine}`, {
656
+ ...where,
657
+ title,
658
+ lastTool: sum.lastTool,
659
+ lastAssistant: sum.lastAssistant ? truncateCounted(sum.lastAssistant, 200) : undefined,
660
+ touched: touched.slice(0, 5),
661
+ claims: statusClaims.map((c) => ({ path: c.path, ownerTitle: c.ownerTitle })),
662
+ waitingQuestion: sum.waitingQuestion,
663
+ });
664
+ }
665
+ return text(pick(getLang(), `action 非法:${p.action}(只能是 list、read、files 或 status)。`, `Invalid action: ${p.action} (must be "list", "read", "files" or "status").`, "convread.bad.action", { "p.action": p.action }));
323
666
  },
324
667
  });
325
668
  }