pi-web-ui 0.90.1 → 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.
- package/CHANGELOG.md +81 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +1206 -106
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +119 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +28 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +152 -34
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +112 -18
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +91 -0
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +47 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +13 -1
- package/package.json +2 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/catppuccin-latte.css +136 -0
- package/themes/catppuccin.css +140 -0
- package/themes/nord.css +130 -0
- package/themes/one-dark.css +131 -0
- package/themes/paper.css +12 -12
- package/themes/solarized-light.css +131 -0
- package/themes/tokyo-night.css +140 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-BgrnP9Jz.js +364 -0
- package/web/dist/assets/index-Cy8dSTZt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.", "
|
|
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
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
|
253
|
-
|
|
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 =
|
|
256
|
-
const histLines =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|