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