@evandu/super-pi 0.1.27 → 0.1.29

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.
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-BNxHYUah.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-B5pyabQ_.css">
11
+ <script type="module" crossorigin src="/assets/index-_xNokuGH.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DoUhMucG.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -36,6 +36,13 @@ export declare class ConversationService {
36
36
  * Creates a new conversation in a workspace.
37
37
  */
38
38
  createConversation(workspaceId: string): Promise<string>;
39
+ /**
40
+ * Name a conversation from what was said in it. Returns the title; the CALLER applies it, because the
41
+ * rename is a second command with its own optimistic path and its own failure toast, and a service that
42
+ * renamed as a side effect of being asked for a name would make those two outcomes indistinguishable in
43
+ * a log and in a retry.
44
+ */
45
+ autoRenameConversation(conversationId: string): Promise<string>;
39
46
  /**
40
47
  * Selects a conversation and broadcasts the catalog for that conversation's model.
41
48
  */
@@ -42,6 +42,24 @@ export class ConversationService {
42
42
  this.broadcaster.broadcastState();
43
43
  return created.id;
44
44
  }
45
+ /**
46
+ * Name a conversation from what was said in it. Returns the title; the CALLER applies it, because the
47
+ * rename is a second command with its own optimistic path and its own failure toast, and a service that
48
+ * renamed as a side effect of being asked for a name would make those two outcomes indistinguishable in
49
+ * a log and in a retry.
50
+ */
51
+ async autoRenameConversation(conversationId) {
52
+ const state = this.store.snapshot();
53
+ const conversation = state.conversations.find(item => item.id === conversationId);
54
+ if (!conversation)
55
+ throw new Error("会话不存在");
56
+ const workspace = state.workspaces.find(item => item.id === conversation.workspaceId);
57
+ if (!workspace)
58
+ throw new Error("会话所属的工作区不存在");
59
+ if (!this.runtime.summarizeConversation)
60
+ throw new Error("当前没有可用的 Pi 进程,无法生成标题");
61
+ return this.runtime.summarizeConversation(conversation, workspace.path);
62
+ }
45
63
  /**
46
64
  * Selects a conversation and broadcasts the catalog for that conversation's model.
47
65
  */
@@ -172,11 +172,17 @@ export class RunService {
172
172
  const at = spoken;
173
173
  enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at }));
174
174
  };
175
+ const onToolUpdate = (call) => {
176
+ const index = call.id ? rowByToolCallId.get(call.id) : undefined;
177
+ if (index === undefined)
178
+ return;
179
+ enqueueTools("a tool call update", () => this.store.updateToolCall(conversation.id, assistant.id, index, { ...(call.args ? { args: call.args } : {}), ...(call.live !== undefined ? { live: call.live } : {}) }));
180
+ };
175
181
  const onToolEnd = (call) => {
176
182
  const index = call.id ? rowByToolCallId.get(call.id) : undefined;
177
183
  if (index === undefined)
178
184
  return;
179
- enqueueTools("a tool call outcome", () => this.store.settleToolCall(conversation.id, assistant.id, index, { status: call.status, durationMs: call.durationMs }));
185
+ enqueueTools("a tool call outcome", () => this.store.settleToolCall(conversation.id, assistant.id, index, { status: call.status, durationMs: call.durationMs, ...(call.result ? { result: call.result } : {}) }));
180
186
  };
181
187
  for await (const delta of this.runtime.answer({
182
188
  conversation,
@@ -187,6 +193,7 @@ export class RunService {
187
193
  onPiQueue: (snapshot) => this.onPiQueue(conversation.id, snapshot),
188
194
  onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
189
195
  onToolCall,
196
+ onToolUpdate,
190
197
  onToolEnd,
191
198
  images,
192
199
  })) {
@@ -66,6 +66,20 @@ export class CommandHandler {
66
66
  if (command.type === "conversation.create") {
67
67
  return await this.conversationService.createConversation(command.workspaceId);
68
68
  }
69
+ if (command.type === "conversation.autoRename") {
70
+ // Answered on a pushed event rather than through the return value: this spends real model time, and
71
+ // `handleCommand`'s return only carries an id for the optimistic-ack path. Swallowing the failure
72
+ // here would turn a model timeout into a menu that closed and never spoke again, so the error goes
73
+ // back on the same channel the success would have used.
74
+ try {
75
+ const title = await this.conversationService.autoRenameConversation(command.conversationId);
76
+ sendToSocket?.({ type: "conversation.autoRename.result", conversationId: command.conversationId, ok: true, title });
77
+ }
78
+ catch (error) {
79
+ sendToSocket?.({ type: "conversation.autoRename.result", conversationId: command.conversationId, ok: false, error: error instanceof Error ? error.message : String(error) });
80
+ }
81
+ return;
82
+ }
69
83
  if (command.type === "conversation.select") {
70
84
  if (sendToSocket) {
71
85
  try {
@@ -83,6 +83,13 @@ export interface AnswerInput {
83
83
  * place a reader would reach for it.
84
84
  */
85
85
  onToolEnd?(call: ToolCallNotice): void;
86
+ /**
87
+ * Called on every tool_execution_update - which is DURING execution, and repeatedly. The companion to
88
+ * onToolCall and onToolEnd rather than a third thing: without it the entire span between a call being
89
+ * announced and a call being reported is silent, and a long-running call is on screen indistinguishable
90
+ * from a frozen agent.
91
+ */
92
+ onToolUpdate?(call: ToolCallNotice): void;
86
93
  }
87
94
  /**
88
95
  * What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
@@ -170,6 +177,23 @@ export interface AgentRuntime {
170
177
  * Absent on a runtime with no Pi behind it (the welcome runtime), which has nothing to steer.
171
178
  */
172
179
  steer?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
180
+ /**
181
+ * ONE-SHOT TITLE GENERATION. Spawns its own short-lived Pi child rather than borrowing a running one.
182
+ *
183
+ * WHY ITS OWN CHILD. `this.runs` only holds a child while a turn is executing, and that child is the
184
+ * one reading the user's stream - writing a prompt at it would land in the conversation it is streaming,
185
+ * and killing it to make room would abort a run the user is watching. So this gets a separate process
186
+ * with its own throwaway session id, which is the only way to ask the model something without touching
187
+ * the run in flight.
188
+ *
189
+ * WHY IT THROWS RATHER THAN FALLS BACK. An earlier version returned `transcript.slice(0, 60)`, which
190
+ * renamed a conversation to a fragment of its own first message while reporting success. A title the
191
+ * user did not write, produced by string slicing, must never reach `conversation.rename`. Every failure
192
+ * here is an exception the caller reports as one.
193
+ *
194
+ * Absent on the welcome runtime, which has no Pi behind it.
195
+ */
196
+ summarizeConversation?(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
173
197
  /**
174
198
  * The agent a conversation runs as, read fresh from the agent directory. Absent on a runtime with
175
199
  * no agents (the welcome runtime), where every conversation runs as Pi itself. Callers that need an
@@ -234,6 +258,14 @@ export type RpcRecord = {
234
258
  error?: string;
235
259
  /** Every `response` record's payload. Kept opaque: only the catalog reader knows these shapes. */
236
260
  data?: unknown;
261
+ /**
262
+ * `tool_execution_end` carries the tool's OUTPUT. Declared so it can be REDUCED by
263
+ * extractToolResultText and then dropped: the raw object must never travel past this record, and that
264
+ * reduction is the whole reason the result is safe to persist. Kept opaque for the same reason `data` is.
265
+ */
266
+ result?: unknown;
267
+ /** The tool_execution_update record's running output. Reduced by the same function as `result`, never carried raw. */
268
+ partialResult?: unknown;
237
269
  /**
238
270
  * The inner event of a `message_update`. `message` rides on `done` - the authoritative final
239
271
  * assistant message, with its text content, its stop reason and its model (pi-ai AssistantMessageEvent).
@@ -664,7 +696,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
664
696
  * own address, so it is the same string on every turn and is read once from the constructor's options.
665
697
  */
666
698
  private taskExtensionEnv;
667
- answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
699
+ answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
668
700
  /**
669
701
  * Reads back the id of the user message this turn just created, and hands it to the caller.
670
702
  *
@@ -790,6 +822,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
790
822
  * time. The caller keeps the message cancellable in the first case because it provably reached no
791
823
  * model; in the second the outcome is genuinely unknown and a `sending` state says so.
792
824
  */
825
+ summarizeConversation(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
793
826
  steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
794
827
  /**
795
828
  * The model/thinking catalog, from Pi, cached.
@@ -8,7 +8,7 @@ import { resolvePiCommand, shellQuote } from "./pi-command.js";
8
8
  import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, rosterPayload, } from "./task-tool.js";
9
9
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
10
10
  import { loadConversationImages } from "./upload.js";
11
- import { describeToolCall, describeToolCallArgs } from "./tool-calls.js";
11
+ import { describeToolCall, describeToolCallArgs, extractToolResultText } from "./tool-calls.js";
12
12
  /** Deterministic offline runtime used by tests and local UI development. */
13
13
  export class WelcomeRuntime {
14
14
  async *answer({ prompt, signal }) {
@@ -700,7 +700,7 @@ export class PiRpcRuntime {
700
700
  [TASK_CAPABILITY_ENV]: mintCapability(conversationId),
701
701
  };
702
702
  }
703
- async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images, memoryReview, memorySourceConversationId }) {
703
+ async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolEnd, images, memoryReview, memorySourceConversationId }) {
704
704
  await mkdir(this.sessionDir, { recursive: true });
705
705
  // Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
706
706
  // turns both into one absolute native path. The directory must exist on disk before spawn - a
@@ -886,6 +886,33 @@ export class PiRpcRuntime {
886
886
  // `message_update` events below: a `thinking_delta` still announces itself, because that is a
887
887
  // documented decision about an event shape rather than an oversight this branch is here to
888
888
  // repair. Only the two tool records move.
889
+ if (record.type === "tool_execution_update") {
890
+ // THE THIRD TOOL RECORD, AND THE ONE THAT WAS MISSING.
891
+ //
892
+ // `tool_execution_start` announces a call and `tool_execution_end` reports how it went, which
893
+ // left the whole span between them silent. For a `bash` that runs a minute or an `edit` whose
894
+ // replacement text is large, that span is the entire execution, and the block sat there with
895
+ // no status, no output and nothing changing - which on screen is indistinguishable from a
896
+ // frozen agent. Pi has been sending this record the whole time, with the arguments as they
897
+ // stand and whatever output has arrived so far.
898
+ //
899
+ // It updates the EXISTING row rather than announcing a new one, so `args` is re-derived on
900
+ // every update (it grows as a tool is prepared) and `live` carries the running output. The
901
+ // reduction is the same one the final result goes through, so a preview can never be bigger
902
+ // than the answer it precedes.
903
+ const id = record.toolCallId ?? "";
904
+ if (id) {
905
+ const name = record.toolName ?? "unknown";
906
+ const live = extractToolResultText(record.partialResult)?.text;
907
+ try {
908
+ onToolUpdate?.({ id, name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args), ...(live ? { live } : {}) });
909
+ }
910
+ catch (error) {
911
+ console.warn(`PiRpcRuntime: the tool update handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
912
+ }
913
+ }
914
+ continue;
915
+ }
889
916
  if (record.type === "tool_execution_start") {
890
917
  // The `args` are READ and summarised, never carried: `describeToolCallArgs` turns an
891
918
  // arbitrary object into at most six capped strings and a non-string becomes a bare type
@@ -913,6 +940,9 @@ export class PiRpcRuntime {
913
940
  id: record.toolCallId ?? "",
914
941
  name: record.toolName ?? "unknown",
915
942
  status: record.isError === true ? "error" : "ok",
943
+ // Reduced ONCE, here, and the raw record.result is dropped here. Anything that reaches a
944
+ // store from this point is the bounded summary, never what Pi sent.
945
+ ...(() => { const summary = extractToolResultText(record.result); return summary ? { result: summary } : {}; })(),
916
946
  ...(typeof record.durationMs === "number" && Number.isFinite(record.durationMs) ? { durationMs: Math.max(0, Math.round(record.durationMs)) } : {}),
917
947
  });
918
948
  }
@@ -1322,6 +1352,76 @@ export class PiRpcRuntime {
1322
1352
  * time. The caller keeps the message cancellable in the first case because it provably reached no
1323
1353
  * model; in the second the outcome is genuinely unknown and a `sending` state says so.
1324
1354
  */
1355
+ async summarizeConversation(conversation, cwd, signal) {
1356
+ if (!this.pi.found)
1357
+ throw new Error(this.pi.reason);
1358
+ const TIMEOUT_MS = 30_000;
1359
+ const MAX_TITLE_CHARS = 40;
1360
+ // A throwaway session id, not the conversation's: this child reads nothing else and its history must
1361
+ // not be filed under the conversation whose title it is producing.
1362
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `autotitle-${Date.now()}`], {
1363
+ cwd: resolve(cwd),
1364
+ stdio: ["pipe", "pipe", "pipe"],
1365
+ shell: process.platform === "win32",
1366
+ });
1367
+ const channel = new RpcChannel(child);
1368
+ const deadline = Date.now() + TIMEOUT_MS;
1369
+ try {
1370
+ const transcript = conversation.messages
1371
+ .filter((message) => (message.role === "user" || message.role === "assistant") && message.content.trim())
1372
+ .map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.content.trim()}`)
1373
+ .join("\n");
1374
+ if (!transcript)
1375
+ throw new Error("这个会话还没有内容,无法据此生成标题");
1376
+ channel.write({
1377
+ id: "autotitle_prompt",
1378
+ type: "prompt",
1379
+ message: [
1380
+ "为下面这段对话起一个简短的标题,用作会话列表里的一行字。",
1381
+ "只输出标题本身:不要引号,不要句末标点,不要「标题:」之类的前缀,不要解释,不要换行。",
1382
+ `长度不超过 ${MAX_TITLE_CHARS} 个字符,用与对话内容相同的语言。`,
1383
+ "",
1384
+ transcript,
1385
+ ].join("\n"),
1386
+ });
1387
+ let settled = false, text = "";
1388
+ while (!settled && Date.now() < deadline) {
1389
+ if (signal?.aborted)
1390
+ throw new Error("已取消");
1391
+ const record = await channel.next(100);
1392
+ if (!record)
1393
+ continue;
1394
+ if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta")
1395
+ text += record.assistantMessageEvent.delta ?? "";
1396
+ if (record.type === "agent_settled")
1397
+ settled = true;
1398
+ if (record.type === "response" && record.id === "autotitle_prompt" && record.success === false)
1399
+ throw new Error(record.error ?? "Pi 拒绝了这次请求");
1400
+ if (child.exitCode !== null)
1401
+ throw new Error("生成标题的进程意外退出");
1402
+ }
1403
+ if (!settled)
1404
+ throw new Error(`模型在 ${TIMEOUT_MS / 1000} 秒内没有返回标题`);
1405
+ // A model asked for "only the title" still wraps it now and then. Stripping the wrappers is not
1406
+ // cosmetic: without it a leading quote or a `Title:` prefix becomes the first characters of every
1407
+ // title in the sidebar.
1408
+ const title = text
1409
+ .replace(/^\s*(?:title|标题)\s*[::]\s*/iu, "")
1410
+ .replace(/^[`"'“”‘’「『]+/u, "")
1411
+ .replace(/[`"'“”‘’」』]+$/u, "")
1412
+ .replace(/[。.!!,,、;;\s]+$/u, "")
1413
+ .trim()
1414
+ .slice(0, MAX_TITLE_CHARS);
1415
+ if (!title)
1416
+ throw new Error("模型没有返回可用的标题");
1417
+ return title;
1418
+ }
1419
+ finally {
1420
+ // The child is this method's alone, so leaving it running would leak one Pi process per rename.
1421
+ if (child.exitCode === null && child.pid !== undefined)
1422
+ await killTree(child.pid);
1423
+ }
1424
+ }
1325
1425
  async steer(conversationId, message, images) {
1326
1426
  const run = this.runs.get(conversationId);
1327
1427
  if (!run)
@@ -1,7 +1,7 @@
1
1
  import type { AppStateRepository } from "./persistence/app-state-repository.js";
2
2
  import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
3
3
  import type { WorkflowRun } from "./workflow.js";
4
- import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallBlock, Workspace } from "./types.js";
4
+ import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
5
5
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
6
6
  *
7
7
  * Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
@@ -252,6 +252,20 @@ export declare class Store {
252
252
  settleToolCall(conversationId: string, messageId: string, index: number, outcome: {
253
253
  status?: "ok" | "error";
254
254
  durationMs?: number;
255
+ result?: ToolResultSummary;
256
+ }): Promise<ToolCallBlock[]>;
257
+ /**
258
+ * REWRITE A CALL THAT IS STILL RUNNING - its arguments as they have grown, and whatever output has
259
+ * arrived so far. Separate from `settleToolCall` because "settle" is the wrong word for a turn that is
260
+ * still going: nothing here decides an outcome, and a method that could be mistaken for it is one a
261
+ * future caller reaches for by mistake.
262
+ *
263
+ * Every field is `undefined`-tested for the same reason `settleToolCall` does it: `""` and `[]` are
264
+ * values a tool can genuinely produce, and treating them as "not reported" would erase a real answer.
265
+ */
266
+ updateToolCall(conversationId: string, messageId: string, index: number, patch: {
267
+ args?: ToolCallArg[];
268
+ live?: string;
255
269
  }): Promise<ToolCallBlock[]>;
256
270
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
257
271
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
@@ -1,5 +1,6 @@
1
1
  import { JsonStateRepository, defaultJsonStatePath } from "./persistence/json-state-repository.js";
2
2
  import { normalizeTodos } from "./task-tool.js";
3
+ import { TOOL_ARG_CAP } from "./tool-calls.js";
3
4
  function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; }
4
5
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
5
6
  *
@@ -910,6 +911,50 @@ export class Store {
910
911
  call.status = outcome.status;
911
912
  if (outcome.durationMs !== undefined)
912
913
  call.durationMs = outcome.durationMs;
914
+ // `!== undefined`, never a truthiness test: an absent result means "this call's output was never
915
+ // recorded", which is a different fact from "recorded, and it was empty", and `""` would state the
916
+ // second while meaning the first.
917
+ if (outcome.result !== undefined)
918
+ call.result = outcome.result;
919
+ // The finished answer REPLACES the running preview, and does not sit beside it. A block showing both
920
+ // would be asking the reader to work out which of two texts is the real one.
921
+ if (outcome.result !== undefined)
922
+ delete call.live;
923
+ conversation.updatedAt = now();
924
+ await this.persist();
925
+ return structuredClone(calls);
926
+ }
927
+ /**
928
+ * REWRITE A CALL THAT IS STILL RUNNING - its arguments as they have grown, and whatever output has
929
+ * arrived so far. Separate from `settleToolCall` because "settle" is the wrong word for a turn that is
930
+ * still going: nothing here decides an outcome, and a method that could be mistaken for it is one a
931
+ * future caller reaches for by mistake.
932
+ *
933
+ * Every field is `undefined`-tested for the same reason `settleToolCall` does it: `""` and `[]` are
934
+ * values a tool can genuinely produce, and treating them as "not reported" would erase a real answer.
935
+ */
936
+ async updateToolCall(conversationId, messageId, index, patch) {
937
+ const conversation = this.requireConversation(conversationId);
938
+ const message = conversation.messages.find((item) => item.id === messageId);
939
+ if (!message)
940
+ throw new Error("Assistant message not found");
941
+ const calls = message.toolCalls;
942
+ const call = calls?.[index];
943
+ if (!calls || !call)
944
+ return structuredClone(calls ?? []);
945
+ // MERGED BY KEY, NOT REPLACED. An update carries the arguments as they stand at that moment, and while a
946
+ // call is being prepared that list GROWS - one key, then two, then six. Replacing the row's list with
947
+ // whatever the latest update happened to carry loses every key the update left out, which on the very
948
+ // first update is all of them. Merging can only ever add, so a sparse update can no longer erase what
949
+ // an earlier one already reported - and the cap is re-applied here rather than trusted from outside.
950
+ if (patch.args !== undefined) {
951
+ const merged = new Map((call.args ?? []).map((arg) => [arg.key, arg]));
952
+ for (const arg of patch.args)
953
+ merged.set(arg.key, arg);
954
+ call.args = [...merged.values()].slice(0, TOOL_ARG_CAP);
955
+ }
956
+ if (patch.live !== undefined)
957
+ call.live = patch.live;
913
958
  conversation.updatedAt = now();
914
959
  await this.persist();
915
960
  return structuredClone(calls);
@@ -1,4 +1,4 @@
1
- import type { ToolCallArg } from "./types.js";
1
+ import type { ToolCallArg, ToolResultSummary } from "./types.js";
2
2
  /**
3
3
  * Turning one Pi tool call into something a human reads, and nothing more.
4
4
  *
@@ -28,6 +28,29 @@ export declare const TOOL_DETAIL_CAP = 120;
28
28
  * past it is announced by the caller through the row count rather than silently.
29
29
  */
30
30
  export declare const TOOL_ARG_CAP = 6;
31
+ /**
32
+ * The longest tool OUTPUT this module lets through, in characters. Eight kilobytes is a screenful of
33
+ * scroll for one call; a `bash` that prints more is still legible because what it found is usually at the
34
+ * end, which is why the cut keeps the tail.
35
+ */
36
+ export declare const TOOL_RESULT_CAP: number;
37
+ /**
38
+ * REDUCE PI'S `tool_execution_end.result` TO WHAT A PERSON READS, or `undefined`.
39
+ *
40
+ * This is the ONLY path from a raw Pi result to anything downstream, and that is the whole reason it is
41
+ * safe for the result to be persisted at all: the untrusted object stops here. Four rules, each for a
42
+ * concrete failure it prevents:
43
+ *
44
+ * 1. TEXT BLOCKS ONLY, IMAGES DROPPED. A `read` of a PNG returns base64 megabytes, and the state file it
45
+ * would land in is rewritten on every text delta of the message it belongs to.
46
+ * 2. THE TAIL, CUT ON A LINE BOUNDARY. Cutting mid-line leaves a final row that is half a sentence and
47
+ * reads as corruption. The tail also beats the head: what a tool just found is at the end.
48
+ * 3. `details.truncation` / `details.fullOutputPath` ARE READ, never inferred. "This was cut" is a fact
49
+ * only Pi knows; deriving it from length would claim Pi truncated something it returned in full.
50
+ * 4. IT RETURNS `undefined` AND NEVER THROWS. These records arrive on an open event family, so a shape
51
+ * this function does not recognise is a fact to decline, not a crash to propagate.
52
+ */
53
+ export declare function extractToolResultText(result: unknown): ToolResultSummary | undefined;
31
54
  /**
32
55
  * The one line that tells a reader what a tool call was FOR: the file it opened, the pattern it searched
33
56
  * for, the command it ran.
@@ -27,6 +27,79 @@ export const TOOL_DETAIL_CAP = 120;
27
27
  * past it is announced by the caller through the row count rather than silently.
28
28
  */
29
29
  export const TOOL_ARG_CAP = 6;
30
+ /**
31
+ * The longest tool OUTPUT this module lets through, in characters. Eight kilobytes is a screenful of
32
+ * scroll for one call; a `bash` that prints more is still legible because what it found is usually at the
33
+ * end, which is why the cut keeps the tail.
34
+ */
35
+ export const TOOL_RESULT_CAP = 8 * 1024;
36
+ /**
37
+ * REDUCE PI'S `tool_execution_end.result` TO WHAT A PERSON READS, or `undefined`.
38
+ *
39
+ * This is the ONLY path from a raw Pi result to anything downstream, and that is the whole reason it is
40
+ * safe for the result to be persisted at all: the untrusted object stops here. Four rules, each for a
41
+ * concrete failure it prevents:
42
+ *
43
+ * 1. TEXT BLOCKS ONLY, IMAGES DROPPED. A `read` of a PNG returns base64 megabytes, and the state file it
44
+ * would land in is rewritten on every text delta of the message it belongs to.
45
+ * 2. THE TAIL, CUT ON A LINE BOUNDARY. Cutting mid-line leaves a final row that is half a sentence and
46
+ * reads as corruption. The tail also beats the head: what a tool just found is at the end.
47
+ * 3. `details.truncation` / `details.fullOutputPath` ARE READ, never inferred. "This was cut" is a fact
48
+ * only Pi knows; deriving it from length would claim Pi truncated something it returned in full.
49
+ * 4. IT RETURNS `undefined` AND NEVER THROWS. These records arrive on an open event family, so a shape
50
+ * this function does not recognise is a fact to decline, not a crash to propagate.
51
+ */
52
+ export function extractToolResultText(result) {
53
+ try {
54
+ if (result === undefined || result === null)
55
+ return undefined;
56
+ const content = result.content;
57
+ const chunks = Array.isArray(content)
58
+ ? content
59
+ : [{ type: "text", text: typeof result === "string" ? result : undefined }];
60
+ let text = "";
61
+ for (const raw of chunks) {
62
+ // An image chunk is dropped WITHOUT being read: its `data` can be megabytes and nothing here wants it.
63
+ const chunk = raw;
64
+ if (!chunk || chunk.type !== "text" || typeof chunk.text !== "string")
65
+ continue;
66
+ text += (text ? "\n" : "") + chunk.text;
67
+ }
68
+ if (!text)
69
+ return undefined;
70
+ const details = result.details;
71
+ const bag = details && typeof details === "object" ? details : {};
72
+ const truncation = bag.truncation;
73
+ const fullOutputPath = typeof bag.fullOutputPath === "string" ? bag.fullOutputPath : undefined;
74
+ let truncated = typeof truncation === "string" && truncation.length > 0;
75
+ let droppedLines;
76
+ if (text.length > TOOL_RESULT_CAP) {
77
+ const lines = text.split("\n");
78
+ const kept = [];
79
+ let used = 0;
80
+ for (let i = lines.length - 1; i >= 0; i--) {
81
+ if (used + lines[i].length > TOOL_RESULT_CAP)
82
+ break;
83
+ used += lines[i].length + 1;
84
+ kept.unshift(lines[i]);
85
+ }
86
+ if (!kept.length)
87
+ kept.push(lines[lines.length - 1].slice(-TOOL_RESULT_CAP));
88
+ droppedLines = lines.length - kept.length;
89
+ text = kept.join("\n");
90
+ truncated = true;
91
+ }
92
+ return {
93
+ text,
94
+ ...(truncated ? { truncated: true } : {}),
95
+ ...(droppedLines ? { droppedLines } : {}),
96
+ ...(fullOutputPath ? { fullOutputPath } : {}),
97
+ };
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
30
103
  /** Argument fields that name a FILE, in the order the tools that read files tend to spell them. */
31
104
  const PATH_KEYS = ["path", "file_path", "filePath", "target_file", "filename", "file"];
32
105
  /** Argument fields that name a SEARCH, which is the thing a human wants to see on a `grep` row. */
@@ -91,15 +91,28 @@ export type ToolCallArg = {
91
91
  * that showed MORE than a sub-agent card row would answer a question the brief does not ask and would make
92
92
  * the two surfaces disagree about what a tool call is.
93
93
  *
94
- * `args` is the ONE addition, and it is what makes a block worth expanding: a card row answers "what was
95
- * this aimed at", while a block also answers "what else was it given" - a `bash` call's timeout, a
96
- * `read`'s offset. Bounded to six entries of capped text by `TOOL_ARG_CAP`, so this stays a summary
97
- * rather than becoming the transcript.
98
- *
99
- * TWO THINGS ARE NOT HERE AND NEVER WILL BE. `result` on `tool_execution_end` is, for a `read`, the file's
100
- * contents; and Pi's `toolCallId` is an identity only Pi can resolve, which is why the record is addressed
101
- * by position (the caller holds a `toolCallId` → row map for the length of a turn, exactly as
102
- * `SubagentRunRecorder` does) rather than by a field persisted forever.
94
+ * `args` and `result` are the two additions, and they are what makes a block worth expanding: a card row
95
+ * answers "what was this aimed at", while a block also answers "what else was it given" AND "what came
96
+ * back". `args` is bounded to six entries by `TOOL_ARG_CAP`; `result` is bounded by `TOOL_RESULT_CAP` and
97
+ * keeps the TAIL of the output, because a tool's answer ends in what it just found.
98
+ *
99
+ * `result` IS HERE NOW, AND ITS ARRIVAL OVERRULED A STANDING DECISION. It used to be written off in this
100
+ * very comment — "`result` on `tool_execution_end` is, for a `read`, the file's contents" — and
101
+ * `scripts/agent-vis-records.test.mjs` asserted the boundary from the outside, failing on the raw bytes of
102
+ * `state.json` if a result ever survived into it. Both said the same thing: a call block describes a call,
103
+ * it does not carry its output. That was right until it wasn't: expanding a `bash` block showed the command
104
+ * a second time and nothing of what the command found, which is the one thing the reader opened it for.
105
+ *
106
+ * What made it safe to reverse is that the bound was always the hard part and it is still enforced:
107
+ * `extractToolResultText` drops images rather than carrying base64, truncates on a line boundary, and is
108
+ * the ONLY path from Pi's result to anything downstream — the raw object never travels. And `result` is
109
+ * OPTIONAL for the same reason `status` is: absent means this call's output was never recorded, which is a
110
+ * different fact from "recorded, and it was empty", and a default of `""` would state the second while
111
+ * meaning the first.
112
+ *
113
+ * Pi's `toolCallId` is still not here, and is still right not to be: it is an identity only Pi can resolve,
114
+ * which is why the record is addressed by position (the caller holds a `toolCallId` → row map for the
115
+ * length of a turn, exactly as `SubagentRunRecorder` does) rather than by a field persisted forever.
103
116
  *
104
117
  * `status` is OPTIONAL, not defaulted to `"ok"`, for the same reason it is on `SubagentToolCall`: a default
105
118
  * would claim an outcome nobody observed. Absent means the call is still in flight, which is the state a
@@ -111,8 +124,29 @@ export type ToolCallBlock = {
111
124
  status?: "ok" | "error";
112
125
  durationMs?: number;
113
126
  args?: ToolCallArg[];
127
+ result?: ToolResultSummary;
128
+ /**
129
+ * OUTPUT AS IT IS ARRIVING, while the call is still running. Replaced by `result` the moment the call
130
+ * ends, and absent before the first update - so it is never mistaken for the final answer, which is the
131
+ * distinction that matters when a reader copies it.
132
+ */
133
+ live?: string;
114
134
  at?: number;
115
135
  };
136
+ /**
137
+ * ONE TOOL CALL'S OUTPUT, reduced to the part a person reads and bounded so it cannot become the
138
+ * transcript. The shape is closed on purpose: `text` is what to show, and the three optional fields are
139
+ * what it would be dishonest to omit once the text is known to be short.
140
+ */
141
+ export type ToolResultSummary = {
142
+ text: string;
143
+ /** The output was cut. Never inferred from length alone — only set when Pi said so. */
144
+ truncated?: boolean;
145
+ /** Lines Pi dropped, when it said how many. */
146
+ droppedLines?: number;
147
+ /** Where Pi put the full output, when it was large enough to warrant a file. */
148
+ fullOutputPath?: string;
149
+ };
116
150
  /**
117
151
  * WHAT THE RUNTIME HANDS A CALLER WHEN PI ANNOUNCES OR FINISHES A TOOL CALL.
118
152
  *
@@ -128,6 +162,8 @@ export type ToolCallNotice = {
128
162
  args?: ToolCallArg[];
129
163
  status?: "ok" | "error";
130
164
  durationMs?: number;
165
+ result?: ToolResultSummary;
166
+ live?: string;
131
167
  };
132
168
  /**
133
169
  * `archivedAt` is the whole archive mechanism: a state MARKER, not a move. The conversation object never
@@ -703,6 +739,16 @@ export type ClientCommandBody =
703
739
  conversationId: string;
704
740
  name: string;
705
741
  }
742
+ /**
743
+ * ASK THE MODEL TO NAME THIS CONVERSATION. Separate from conversation.rename on purpose: rename moves a
744
+ * name a person chose, this one is a request whose ANSWER comes back later and may fail - so it cannot
745
+ * ride the optimistic command path, which resolves the moment the socket accepts the write and has no
746
+ * room for an answer that arrives ten seconds later. The result arrives as conversation.autoRename.result.
747
+ */
748
+ | {
749
+ type: "conversation.autoRename";
750
+ conversationId: string;
751
+ }
706
752
  /**
707
753
  * FORKS A WHOLE SESSION: a second conversation that starts from this one's complete history, in Pi's own
708
754
  * transcript as well as in `state.json`.
@@ -982,6 +1028,21 @@ export type ServerEvent = {
982
1028
  type: "model.catalog";
983
1029
  catalog: ModelCatalog;
984
1030
  conversationId?: string;
1031
+ }
1032
+ /**
1033
+ * The answer to conversation.autoRename. Carries a title OR an error and never both, because the client
1034
+ * has exactly two things to say about it: here is the new name, or here is why there isnt one.
1035
+ */
1036
+ | {
1037
+ type: "conversation.autoRename.result";
1038
+ conversationId: string;
1039
+ ok: true;
1040
+ title: string;
1041
+ } | {
1042
+ type: "conversation.autoRename.result";
1043
+ conversationId: string;
1044
+ ok: false;
1045
+ error: string;
985
1046
  } | {
986
1047
  type: "agent.catalog";
987
1048
  catalog: AgentCatalog;