@evandu/super-pi 0.1.4 → 0.1.6

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="theme-color" content="#101828" />
7
7
  <title>Super Pi</title>
8
- <script type="module" crossorigin src="/assets/index-BsQxHODf.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-B2W1-_yu.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-DP3T9VzH.css">
10
10
  </head>
11
11
  <body>
@@ -36,7 +36,7 @@ export declare class CatalogService {
36
36
  /**
37
37
  * Broadcasts the model catalog to connected clients.
38
38
  */
39
- broadcastCatalog(selection?: ModelSelection): Promise<void>;
39
+ broadcastCatalog(selection?: ModelSelection, conversationId?: string): Promise<void>;
40
40
  /**
41
41
  * Reads one catalog filtered by mode at source.
42
42
  */
@@ -74,9 +74,9 @@ export class CatalogService {
74
74
  /**
75
75
  * Broadcasts the model catalog to connected clients.
76
76
  */
77
- async broadcastCatalog(selection) {
77
+ async broadcastCatalog(selection, conversationId) {
78
78
  const catalog = await this.getCatalog(selection);
79
- this.broadcaster.broadcast({ type: "model.catalog", catalog });
79
+ this.broadcaster.broadcast({ type: "model.catalog", catalog, conversationId });
80
80
  }
81
81
  /**
82
82
  * Reads one catalog filtered by mode at source.
@@ -37,6 +37,7 @@ export declare class ConversationService {
37
37
  /**
38
38
  * Selects a conversation and broadcasts the catalog for that conversation's model.
39
39
  */
40
+ getConversationCatalog(conversationId: string): Promise<import("../types.js").ModelCatalog>;
40
41
  selectConversation(conversationId: string): Promise<void>;
41
42
  /**
42
43
  * Archives a conversation.
@@ -62,11 +63,15 @@ export declare class ConversationService {
62
63
  * Configures or clears the primary agent assigned to a conversation.
63
64
  */
64
65
  setAgent(conversationId: string, agentId: string | null): Promise<void>;
66
+ private resolveAgentConfiguration;
65
67
  /**
66
68
  * Reverts conversation history to a designated message boundary, truncating both
67
69
  * Pi's transcript and state.json.
68
70
  */
69
- revertMessage(conversationId: string, messageId: string): Promise<void>;
71
+ revertMessage(conversationId: string, messageId: string, withdraw?: boolean): Promise<{
72
+ content: string;
73
+ images: import("../types.js").MessageImage[] | undefined;
74
+ } | undefined>;
70
75
  /**
71
76
  * Forks an existing conversation into a new parallel branch, cloning the Pi session.
72
77
  */
@@ -29,15 +29,27 @@ export class ConversationService {
29
29
  * Creates a new conversation in a workspace.
30
30
  */
31
31
  async createConversation(workspaceId) {
32
- await this.store.createConversation(workspaceId);
32
+ const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
33
+ if (!workspace)
34
+ throw new Error("Workspace not found");
35
+ const defaults = workspace.defaultAgentId
36
+ ? await this.resolveAgentConfiguration({ id: workspaceId, model: workspace.defaultModel, thinkingLevel: workspace.defaultThinkingLevel }, workspace.defaultAgentId)
37
+ : undefined;
38
+ await this.store.createConversation(workspaceId, defaults);
33
39
  this.broadcaster.broadcastState();
34
40
  }
35
41
  /**
36
42
  * Selects a conversation and broadcasts the catalog for that conversation's model.
37
43
  */
44
+ async getConversationCatalog(conversationId) {
45
+ const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
46
+ if (!conversation)
47
+ throw new Error("Conversation not found");
48
+ return this.catalogService.getCatalog(conversation.model);
49
+ }
38
50
  async selectConversation(conversationId) {
39
51
  const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
40
- await this.catalogService.broadcastCatalog(conversation?.model);
52
+ await this.catalogService.broadcastCatalog(conversation?.model, conversationId);
41
53
  }
42
54
  /**
43
55
  * Archives a conversation.
@@ -76,7 +88,7 @@ export class ConversationService {
76
88
  : { provider, modelId, label: modelId };
77
89
  await this.store.setConversationModel(conversationId, selection);
78
90
  this.broadcaster.broadcastState();
79
- await this.catalogService.broadcastCatalog(selection);
91
+ await this.catalogService.broadcastCatalog(selection, conversationId);
80
92
  }
81
93
  /**
82
94
  * Sets the thinking level on a conversation, validating against the model's available levels.
@@ -102,42 +114,47 @@ export class ConversationService {
102
114
  this.broadcaster.broadcastState();
103
115
  return;
104
116
  }
117
+ const settings = await this.resolveAgentConfiguration(conversation, agentId);
118
+ await this.store.setConversationConfiguration(conversationId, settings);
119
+ this.broadcaster.broadcastState();
120
+ await this.catalogService.broadcastCatalog(this.store.snapshot().conversations.find((item) => item.id === conversationId)?.model, conversationId);
121
+ }
122
+ async resolveAgentConfiguration(conversation, agentId) {
105
123
  const agent = await this.runtime.agentFor?.({ id: conversation.id, agentId });
106
124
  if (!agent) {
107
125
  const dirsLabel = Array.isArray(this.agentsDir) ? this.agentsDir.join(", ") : this.agentsDir;
108
126
  throw new Error(`Agent ${JSON.stringify(agentId)} 不存在或不是可用的 primary agent(目录:${dirsLabel})`);
109
127
  }
128
+ let selection = conversation.model;
110
129
  if (agent.model) {
111
130
  const catalog = await this.runtime.catalog();
112
131
  if (catalog.source === "pi" && !catalog.models.some((model) => model.provider === agent.model?.provider && model.modelId === agent.model?.modelId)) {
113
132
  throw this.catalogService.notOffered(`Agent ${agent.name} 指定的模型 ${agent.model.provider}/${agent.model.modelId}`, catalog);
114
133
  }
115
134
  const option = catalog.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model?.modelId);
116
- await this.store.setConversationModel(conversationId, option
135
+ selection = option
117
136
  ? { provider: option.provider, modelId: option.modelId, label: option.label }
118
- : { provider: agent.model.provider, modelId: agent.model.modelId, label: `${agent.model.provider}/${agent.model.modelId}` });
137
+ : { provider: agent.model.provider, modelId: agent.model.modelId, label: `${agent.model.provider}/${agent.model.modelId}` };
119
138
  }
120
139
  if (agent.thinkingLevel) {
121
- const catalog = await this.runtime.catalog(this.store.snapshot().conversations.find((item) => item.id === conversationId)?.model);
140
+ const catalog = await this.runtime.catalog(selection);
122
141
  if (catalog.source === "pi" && !catalog.levels.includes(agent.thinkingLevel)) {
123
142
  throw this.catalogService.notOffered(`Agent ${agent.name} 指定的思考等级 ${agent.thinkingLevel}`, catalog);
124
143
  }
125
- await this.store.setConversationThinking(conversationId, agent.thinkingLevel);
126
144
  }
127
- await this.store.setConversationAgent(conversationId, agent.id);
128
- this.broadcaster.broadcastState();
129
- await this.catalogService.broadcastCatalog(this.store.snapshot().conversations.find((item) => item.id === conversationId)?.model);
145
+ return { agentId: agent.id, model: selection, thinkingLevel: agent.thinkingLevel ?? conversation.thinkingLevel };
130
146
  }
131
147
  /**
132
148
  * Reverts conversation history to a designated message boundary, truncating both
133
149
  * Pi's transcript and state.json.
134
150
  */
135
- async revertMessage(conversationId, messageId) {
136
- const plan = this.store.planRevert(conversationId, messageId);
137
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
151
+ async revertMessage(conversationId, messageId, withdraw = false) {
152
+ let plan = this.store.planRevert(conversationId, messageId, withdraw);
153
+ let conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
138
154
  if (!conversation)
139
155
  throw new Error("会话不存在");
140
- const workspace = this.store.snapshot().workspaces.find((item) => item.id === conversation.workspaceId);
156
+ const workspaceId = conversation.workspaceId;
157
+ const workspace = this.store.snapshot().workspaces.find((item) => item.id === workspaceId);
141
158
  if (!workspace)
142
159
  throw new Error(`会话所属的工作区 ${conversation.workspaceId} 已不存在,拒绝回退`);
143
160
  if (!this.runtime.revert) {
@@ -146,6 +163,11 @@ export class ConversationService {
146
163
  if (this.runService.isRunning(conversationId)) {
147
164
  this.extensionUiService.cancelPendingUi(conversationId, "aborted");
148
165
  await this.runService.stopRun(conversationId);
166
+ // The stopping turn may have recorded the user's Pi entry while it drained.
167
+ plan = this.store.planRevert(conversationId, messageId, withdraw);
168
+ conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
169
+ if (!conversation)
170
+ throw new Error("会话不存在");
149
171
  }
150
172
  for (const runId of plan.doomedRunIds) {
151
173
  if (this.subagents.stop(runId)) {
@@ -156,12 +178,15 @@ export class ConversationService {
156
178
  conversation,
157
179
  cwd: workspace.path,
158
180
  boundaryEntryId: plan.boundaryEntryId,
181
+ beforeBoundary: withdraw && Boolean(conversation.messages.find(item => item.id === messageId)?.piEntryId),
159
182
  });
160
- const applied = await this.store.revertMessages(conversationId, messageId);
183
+ const applied = await this.store.revertMessages(conversationId, messageId, withdraw);
161
184
  console.log(`Super Pi: reverted conversation ${conversationId} to ${applied.boundaryIndex + 1} message(s): ` +
162
185
  `${plan.removedMessageIds.length} message(s) and ${applied.doomedRunIds.length} sub-agent run(s) dropped from state.json; ` +
163
186
  `Pi's file ${outcome.sessionFile} kept ${outcome.linesKept} line(s) and lost ${outcome.entriesDropped}.`);
164
187
  this.broadcaster.broadcastState();
188
+ const withdrawn = conversation.messages.find(item => item.id === messageId);
189
+ return withdraw && withdrawn ? { content: withdrawn.content, images: withdrawn.images } : undefined;
165
190
  }
166
191
  /**
167
192
  * Forks an existing conversation into a new parallel branch, cloning the Pi session.
@@ -96,8 +96,13 @@ export class RunService {
96
96
  * Opens a turn by creating user and assistant messages in store.
97
97
  */
98
98
  async openTurn(conversationId, prompt, images) {
99
+ const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
100
+ if (!conversation)
101
+ throw new Error("Conversation not found");
102
+ const agent = await this.runtime.agentFor?.(conversation);
103
+ const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
99
104
  const user = await this.store.addUserMessage(conversationId, prompt ?? "", images);
100
- const assistant = await this.store.beginAssistantMessage(conversationId);
105
+ const assistant = await this.store.beginAssistantMessage(conversationId, identity);
101
106
  this.broadcaster.broadcastState();
102
107
  return { user, assistant };
103
108
  }
@@ -43,7 +43,10 @@ export class CommandHandler {
43
43
  return;
44
44
  }
45
45
  if (command.type === "conversation.select") {
46
- await this.conversationService.selectConversation(command.conversationId);
46
+ if (sendToSocket)
47
+ sendToSocket({ type: "model.catalog", catalog: await this.conversationService.getConversationCatalog(command.conversationId), conversationId: command.conversationId });
48
+ else
49
+ await this.conversationService.selectConversation(command.conversationId);
47
50
  return;
48
51
  }
49
52
  if (command.type === "conversation.archive") {
@@ -94,7 +97,15 @@ export class CommandHandler {
94
97
  return;
95
98
  }
96
99
  if (command.type === "message.revert") {
97
- await this.conversationService.revertMessage(command.conversationId, command.messageId);
100
+ try {
101
+ const withdrawn = await this.conversationService.revertMessage(command.conversationId, command.messageId, command.withdraw);
102
+ sendToSocket?.({ type: "message.revert.result", conversationId: command.conversationId, ok: true, withdrawn });
103
+ }
104
+ catch (error) {
105
+ if (!sendToSocket)
106
+ throw error;
107
+ sendToSocket({ type: "message.revert.result", conversationId: command.conversationId, ok: false, message: error instanceof Error ? error.message : String(error) });
108
+ }
98
109
  return;
99
110
  }
100
111
  if (command.type === "run.stop") {
@@ -125,7 +136,10 @@ export class CommandHandler {
125
136
  return;
126
137
  }
127
138
  if (command.type === "catalog.fetch") {
128
- await this.catalogService.broadcastCatalog();
139
+ if (sendToSocket)
140
+ sendToSocket({ type: "model.catalog", catalog: await this.catalogService.getCatalog() });
141
+ else
142
+ await this.catalogService.broadcastCatalog();
129
143
  return;
130
144
  }
131
145
  if (command.type === "agent.catalog.fetch") {
@@ -112,6 +112,7 @@ export type RevertRequest = {
112
112
  cwd: string;
113
113
  /** Pi's entry id of the last message that SURVIVES. Everything written after it is dropped. */
114
114
  boundaryEntryId: string;
115
+ beforeBoundary?: boolean;
115
116
  };
116
117
  export type RevertOutcome = {
117
118
  /** The file that was truncated, so a caller can log what was rewritten. */
@@ -439,7 +440,7 @@ export declare function killTree(pid: number): Promise<void>;
439
440
  * EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
440
441
  * without touching anybody's real transcript.
441
442
  */
442
- export declare function truncateSessionFile(path: string, boundaryEntryId: string, expectedSessionId: string): Promise<{
443
+ export declare function truncateSessionFile(path: string, boundaryEntryId: string, expectedSessionId: string, beforeBoundary?: boolean): Promise<{
443
444
  linesKept: number;
444
445
  entriesDropped: number;
445
446
  }>;
@@ -620,10 +621,8 @@ export declare class PiRpcRuntime implements AgentRuntime {
620
621
  /**
621
622
  * The agent a conversation runs as, or undefined for Pi's own defaults.
622
623
  *
623
- * A missing file is NOT an error: a conversation that names an agent that has since been deleted
624
- * degrades to running as Pi itself, with a warning, rather than failing every turn forever. A
625
- * `subagent` definition is also ignored here - those are invoked BY a primary agent, never chosen
626
- * as the conversation's own role.
624
+ * A selected role must remain valid. Missing or invalid definitions are refused before
625
+ * spawning Pi so their prompt and tool restrictions cannot silently disappear.
627
626
  */
628
627
  agentFor(conversation: Pick<Conversation, "id" | "agentId">): Promise<AgentDefinition | undefined>;
629
628
  setAgentsDir(dir: string | string[]): void;
@@ -713,7 +712,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
713
712
  * revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
714
713
  * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
715
714
  */
716
- revert({ conversation, cwd, boundaryEntryId }: RevertRequest): Promise<RevertOutcome>;
715
+ revert({ conversation, cwd, boundaryEntryId, beforeBoundary }: RevertRequest): Promise<RevertOutcome>;
717
716
  /**
718
717
  * Copies Pi's own transcript for one conversation into a second, independent file under a new id.
719
718
  *
@@ -298,7 +298,7 @@ export function killTree(pid) {
298
298
  * EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
299
299
  * without touching anybody's real transcript.
300
300
  */
301
- export async function truncateSessionFile(path, boundaryEntryId, expectedSessionId) {
301
+ export async function truncateSessionFile(path, boundaryEntryId, expectedSessionId, beforeBoundary = false) {
302
302
  const raw = await readFile(path, "utf8");
303
303
  // Split, then drop the empty segment a trailing newline leaves behind. Probe A measured that the file
304
304
  // DOES end with a newline, so `split` yields one empty tail element; keeping it would append a blank
@@ -337,9 +337,12 @@ export async function truncateSessionFile(path, boundaryEntryId, expectedSession
337
337
  throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要回退的会话 ${JSON.stringify(expectedSessionId)}。拒绝截断别人的会话。`);
338
338
  if (!raw.endsWith("\n"))
339
339
  throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝回退:${path}`);
340
- const boundary = parsed.findIndex((entry, index) => index > 0 && entry?.id === boundaryEntryId);
340
+ let boundary = boundaryEntryId === "" ? 0 : parsed.findIndex((entry, index) => index > 0 && entry?.id === boundaryEntryId);
341
341
  if (boundary < 0)
342
342
  throw new Error(`在这份 Pi 会话记录里找不到要保留的那条消息(entry ${JSON.stringify(boundaryEntryId)})。这条消息可能是更早的版本写的,Super Pi 当时没有记下它对应 Pi 的哪一条。`);
343
+ const expectedLastId = beforeBoundary ? parsed[boundary - 1]?.id : parsed[boundary]?.id;
344
+ if (beforeBoundary)
345
+ boundary -= 1;
343
346
  // The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
344
347
  // being KEPT must point at something also being kept. A prefix that violates this is not a session,
345
348
  // and writing it would produce a file Pi either rejects or silently misreads.
@@ -368,7 +371,7 @@ export async function truncateSessionFile(path, boundaryEntryId, expectedSession
368
371
  catch {
369
372
  lastEntry = undefined;
370
373
  }
371
- if (verified.length !== boundary + 1 || lastEntry?.id !== boundaryEntryId) {
374
+ if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId) {
372
375
  throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
373
376
  }
374
377
  return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
@@ -619,21 +622,18 @@ export class PiRpcRuntime {
619
622
  /**
620
623
  * The agent a conversation runs as, or undefined for Pi's own defaults.
621
624
  *
622
- * A missing file is NOT an error: a conversation that names an agent that has since been deleted
623
- * degrades to running as Pi itself, with a warning, rather than failing every turn forever. A
624
- * `subagent` definition is also ignored here - those are invoked BY a primary agent, never chosen
625
- * as the conversation's own role.
625
+ * A selected role must remain valid. Missing or invalid definitions are refused before
626
+ * spawning Pi so their prompt and tool restrictions cannot silently disappear.
626
627
  */
627
628
  async agentFor(conversation) {
628
629
  if (!conversation.agentId)
629
630
  return undefined;
630
- const { agents, problems } = await readAgents(this.agents.dir);
631
+ const { agents } = await readAgents(this.agents.dir);
631
632
  const agent = agents.find((item) => item.id === conversation.agentId);
632
633
  if (agent)
633
634
  return agent;
634
635
  const dirLabel = Array.isArray(this.agents.dir) ? this.agents.dir.join(", ") : this.agents.dir;
635
- console.warn(`PiRpcRuntime: conversation ${conversation.id} names agent ${JSON.stringify(conversation.agentId)}, which is not a primary agent in ${dirLabel}${problems.length ? ` (${problems.length} file(s) there are not usable agents)` : ""} - running as Pi itself.`);
636
- return undefined;
636
+ throw new Error(`Agent ${JSON.stringify(conversation.agentId)} 已失效或不是可用的 primary agent(目录:${dirLabel})。请重新选择 Agent,或选择“跟随 Pi 默认”后重试。`);
637
637
  }
638
638
  setAgentsDir(dir) {
639
639
  this.agents.dir = dir;
@@ -1064,7 +1064,7 @@ export class PiRpcRuntime {
1064
1064
  * revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
1065
1065
  * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
1066
1066
  */
1067
- async revert({ conversation, cwd, boundaryEntryId }) {
1067
+ async revert({ conversation, cwd, boundaryEntryId, beforeBoundary }) {
1068
1068
  if (this.runs.has(conversation.id))
1069
1069
  throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
1070
1070
  if (!this.pi.found)
@@ -1120,7 +1120,7 @@ export class PiRpcRuntime {
1120
1120
  // reported, because the alternative is a revert that appears to work and changes nothing.
1121
1121
  if (!exists?.isFile())
1122
1122
  throw new Error(`Pi 还没有为这个会话建立记录文件(${sessionFile}),没有可以回退的历史。`);
1123
- const result = await truncateSessionFile(sessionFile, boundaryEntryId, conversation.id);
1123
+ const result = await truncateSessionFile(sessionFile, boundaryEntryId, conversation.id, beforeBoundary);
1124
1124
  return { sessionFile, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
1125
1125
  }
1126
1126
  /**
@@ -100,7 +100,11 @@ export declare class Store {
100
100
  agentId?: string | null;
101
101
  }): Promise<Workspace>;
102
102
  deleteWorkspace(workspaceId: string): Promise<string[]>;
103
- createConversation(workspaceId: string): Promise<Conversation>;
103
+ createConversation(workspaceId: string, configuration?: {
104
+ agentId?: string;
105
+ model?: ModelSelection;
106
+ thinkingLevel?: string;
107
+ }): Promise<Conversation>;
104
108
  /**
105
109
  * Copies a conversation into a NEW one, under an id the caller has already minted.
106
110
  *
@@ -195,7 +199,12 @@ export declare class Store {
195
199
  * START of the next turn, so the turn in flight is unaffected.
196
200
  */
197
201
  setConversationAgent(conversationId: string, agentId: string | undefined): Promise<Conversation>;
198
- beginAssistantMessage(conversationId: string): Promise<Message>;
202
+ setConversationConfiguration(conversationId: string, settings: {
203
+ agentId?: string;
204
+ model?: ModelSelection;
205
+ thinkingLevel?: string;
206
+ }): Promise<Conversation>;
207
+ beginAssistantMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<Message>;
199
208
  appendAssistantDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
200
209
  /**
201
210
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
@@ -241,7 +250,7 @@ export declare class Store {
241
250
  }): Promise<ToolCallBlock[]>;
242
251
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
243
252
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
244
- planRevert(conversationId: string, messageId: string): RevertPlan;
253
+ planRevert(conversationId: string, messageId: string, withdraw?: boolean): RevertPlan;
245
254
  /**
246
255
  * Records which Pi entry a message became, as reported by Pi while it was still running.
247
256
  *
@@ -269,7 +278,7 @@ export declare class Store {
269
278
  * plan is recomputed here rather than passed in - two callers cannot disagree about what a message id
270
279
  * means, and the recomputation is a pure scan of a small array.
271
280
  */
272
- revertMessages(conversationId: string, messageId: string): Promise<RevertPlan>;
281
+ revertMessages(conversationId: string, messageId: string, withdraw?: boolean): Promise<RevertPlan>;
273
282
  private requireConversation;
274
283
  /**
275
284
  * This conversation's waiting list, oldest first.
@@ -363,7 +363,7 @@ function reconcileQueuedMessages(state) {
363
363
  * own message for "that message is not in this conversation" - and a revert that silently did nothing is
364
364
  * the failure this whole feature exists to avoid.
365
365
  */
366
- function planRevert(state, conversationId, messageId) {
366
+ function planRevert(state, conversationId, messageId, withdraw = false) {
367
367
  const conversation = state.conversations.find((item) => item?.id === conversationId);
368
368
  if (!conversation)
369
369
  throw new Error("会话不存在");
@@ -371,9 +371,11 @@ function planRevert(state, conversationId, messageId) {
371
371
  if (boundaryIndex < 0)
372
372
  throw new Error("要回退的那条消息不在这个会话里");
373
373
  const boundary = conversation.messages[boundaryIndex];
374
- if (!boundary?.piEntryId)
374
+ if (!withdraw && !boundary?.piEntryId)
375
375
  throw new Error("这条消息没有对应的 Pi 记录,不能作为回退位置。请在它之后的任意一条消息上回退。");
376
- const removed = conversation.messages.slice(boundaryIndex + 1);
376
+ if (withdraw && boundary.role !== "user")
377
+ throw new Error("Only user messages can be withdrawn");
378
+ const removed = conversation.messages.slice(boundaryIndex + (withdraw ? 0 : 1));
377
379
  // Unparseable stamps are compared as 0, i.e. "older than everything", which keeps a corrupted run
378
380
  // record ALIVE rather than deleting a child that may still be the user's most recent work. Deleting on a
379
381
  // guess is the irreversible direction; keeping a stale record costs one card that startup reconciliation
@@ -382,7 +384,7 @@ function planRevert(state, conversationId, messageId) {
382
384
  const doomedRunIds = state.subagentRuns
383
385
  .filter((run) => run?.conversationId === conversationId && (Date.parse(run.startedAt) || 0) >= firstRemovedAt)
384
386
  .map((run) => run.id);
385
- return { boundaryIndex, boundaryEntryId: boundary.piEntryId, removedMessageIds: removed.map((item) => item.id), doomedRunIds };
387
+ return { boundaryIndex, boundaryEntryId: boundary.piEntryId ?? conversation.messages.slice(0, boundaryIndex).reverse().find(item => item.piEntryId)?.piEntryId ?? "", removedMessageIds: removed.map((item) => item.id), doomedRunIds };
386
388
  }
387
389
  /**
388
390
  * Brings `state.todos` back to a shape the browser can render, on boot.
@@ -551,7 +553,7 @@ export class Store {
551
553
  await this.persist();
552
554
  return [...doomed];
553
555
  }
554
- async createConversation(workspaceId) {
556
+ async createConversation(workspaceId, configuration) {
555
557
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
556
558
  if (!workspace)
557
559
  throw new Error("Workspace not found");
@@ -567,6 +569,14 @@ export class Store {
567
569
  conversation.thinkingLevel = workspace.defaultThinkingLevel;
568
570
  if (workspace.defaultAgentId)
569
571
  conversation.agentId = workspace.defaultAgentId;
572
+ if (configuration) {
573
+ if (configuration.model)
574
+ conversation.model = { ...configuration.model };
575
+ if (configuration.thinkingLevel)
576
+ conversation.thinkingLevel = configuration.thinkingLevel;
577
+ if (configuration.agentId)
578
+ conversation.agentId = configuration.agentId;
579
+ }
570
580
  this.state.conversations.unshift(conversation);
571
581
  await this.persist();
572
582
  return structuredClone(conversation);
@@ -772,7 +782,34 @@ export class Store {
772
782
  await this.persist();
773
783
  return structuredClone(conversation);
774
784
  }
775
- async beginAssistantMessage(conversationId) { const conversation = this.requireConversation(conversationId); const message = { id: id("message"), role: "assistant", content: "", createdAt: now() }; conversation.messages.push(message); await this.persist(); return structuredClone(message); }
785
+ async setConversationConfiguration(conversationId, settings) {
786
+ const conversation = this.requireConversation(conversationId);
787
+ const previous = { agentId: conversation.agentId, model: conversation.model, thinkingLevel: conversation.thinkingLevel };
788
+ const apply = (value) => {
789
+ if (value.agentId === undefined)
790
+ delete conversation.agentId;
791
+ else
792
+ conversation.agentId = value.agentId;
793
+ if (value.model === undefined)
794
+ delete conversation.model;
795
+ else
796
+ conversation.model = { provider: value.model.provider, modelId: value.model.modelId, label: value.model.label };
797
+ if (value.thinkingLevel === undefined)
798
+ delete conversation.thinkingLevel;
799
+ else
800
+ conversation.thinkingLevel = value.thinkingLevel;
801
+ };
802
+ apply(settings);
803
+ try {
804
+ await this.persist();
805
+ }
806
+ catch (error) {
807
+ apply(previous);
808
+ throw error;
809
+ }
810
+ return structuredClone(conversation);
811
+ }
812
+ async beginAssistantMessage(conversationId, agentIdentity) { const conversation = this.requireConversation(conversationId); const message = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) }; conversation.messages.push(message); await this.persist(); return structuredClone(message); }
776
813
  async appendAssistantDelta(conversationId, messageId, delta) { const conversation = this.requireConversation(conversationId); const message = conversation.messages.find((item) => item.id === messageId); if (!message)
777
814
  throw new Error("Assistant message not found"); message.content += delta; conversation.updatedAt = now(); await this.persist(); }
778
815
  /**
@@ -842,7 +879,7 @@ export class Store {
842
879
  }
843
880
  async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persist(); }
844
881
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
845
- planRevert(conversationId, messageId) { return planRevert(this.state, conversationId, messageId); }
882
+ planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
846
883
  /**
847
884
  * Records which Pi entry a message became, as reported by Pi while it was still running.
848
885
  *
@@ -879,10 +916,10 @@ export class Store {
879
916
  * plan is recomputed here rather than passed in - two callers cannot disagree about what a message id
880
917
  * means, and the recomputation is a pure scan of a small array.
881
918
  */
882
- async revertMessages(conversationId, messageId) {
883
- const plan = planRevert(this.state, conversationId, messageId);
919
+ async revertMessages(conversationId, messageId, withdraw = false) {
920
+ const plan = planRevert(this.state, conversationId, messageId, withdraw);
884
921
  const conversation = this.requireConversation(conversationId);
885
- conversation.messages.splice(plan.boundaryIndex + 1);
922
+ conversation.messages.splice(plan.boundaryIndex + (withdraw ? 0 : 1));
886
923
  if (plan.doomedRunIds.length) {
887
924
  const doomed = new Set(plan.doomedRunIds);
888
925
  this.state.subagentRuns = this.state.subagentRuns.filter((run) => !doomed.has(run.id));
@@ -57,6 +57,10 @@ export type Message = {
57
57
  piEntryId?: string;
58
58
  toolCalls?: ToolCallBlock[];
59
59
  images?: MessageImage[];
60
+ agentIdentity?: {
61
+ id?: string;
62
+ name: string;
63
+ };
60
64
  };
61
65
  /**
62
66
  * ONE PARAMETER OF A TOOL CALL, as a call block lists it, and the boundary that keeps Pi's arguments out
@@ -746,6 +750,7 @@ export type ClientCommand = {
746
750
  type: "message.revert";
747
751
  conversationId: string;
748
752
  messageId: string;
753
+ withdraw?: boolean;
749
754
  }
750
755
  /**
751
756
  * Drops ONE queued message, by Super Pi's id.
@@ -910,9 +915,19 @@ export type ServerEvent = {
910
915
  conversationId: string;
911
916
  requestId: string;
912
917
  reason: ExtensionUiCloseReason;
918
+ } | {
919
+ type: "message.revert.result";
920
+ conversationId: string;
921
+ ok: boolean;
922
+ message?: string;
923
+ withdrawn?: {
924
+ content: string;
925
+ images?: MessageImage[];
926
+ };
913
927
  } | {
914
928
  type: "model.catalog";
915
929
  catalog: ModelCatalog;
930
+ conversationId?: string;
916
931
  } | {
917
932
  type: "agent.catalog";
918
933
  catalog: AgentCatalog;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -72,7 +72,8 @@
72
72
  "scripts/task-tool.test.mjs",
73
73
  "scripts/fork-server.test.mjs",
74
74
  "scripts/styles-no-dangling.test.mjs",
75
- "scripts/theme-no-hardcoded-colours.test.mjs"
75
+ "scripts/theme-no-hardcoded-colours.test.mjs",
76
+ "scripts/agent-config-regression.test.mjs"
76
77
  ],
77
78
  "check:browser": [
78
79
  "scripts/extension-ui-browser.test.mjs",
@@ -102,7 +103,8 @@
102
103
  "scripts/theme-switch-state.test.mjs",
103
104
  "scripts/settings-window-structure.test.mjs",
104
105
  "scripts/file-tab-selected-contrast.test.mjs",
105
- "scripts/help-window.test.mjs"
106
+ "scripts/help-window.test.mjs",
107
+ "scripts/composer-regression-browser.test.mjs"
106
108
  ],
107
109
  "dependencies": {
108
110
  "ws": "latest"