@evandu/super-pi 0.1.137 → 0.1.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/agents/super-pi-knowledge/index.ts +11 -13
  2. package/agents/super-pi-memory/index.ts +11 -5
  3. package/bin/super-pi.js +7 -11
  4. package/dist/.super-pi-check-inputs.json +2 -2
  5. package/dist/client/.super-pi-client-sources.json +6 -6
  6. package/dist/client/assets/{CodeMirrorEditor-CkA_5D5r.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
  7. package/dist/client/assets/{dist-DcdzfM-n.js → dist-2XXLO-IZ.js} +1 -1
  8. package/dist/client/assets/{dist-ufRHdQ8G.js → dist-BEM6tttY.js} +1 -1
  9. package/dist/client/assets/{dist-9Fm8XKu4.js → dist-BfUeRJKr.js} +1 -1
  10. package/dist/client/assets/{dist-CrxmpY93.js → dist-BokzcFLE.js} +1 -1
  11. package/dist/client/assets/{dist-BfEmujEC.js → dist-BoucVzKA.js} +1 -1
  12. package/dist/client/assets/{dist-B02Erd0t.js → dist-BxTGjU84.js} +1 -1
  13. package/dist/client/assets/{dist-DGhdkud4.js → dist-C-MUXK_t.js} +1 -1
  14. package/dist/client/assets/{dist-k6bOz9RS.js → dist-C8NllalC.js} +1 -1
  15. package/dist/client/assets/{dist-C2tSd-AY.js → dist-CSdzvQnf.js} +1 -1
  16. package/dist/client/assets/{dist-BySjislr.js → dist-CUNctZ5v.js} +1 -1
  17. package/dist/client/assets/{dist-B3Mfuv1D.js → dist-CZ9Krsno.js} +1 -1
  18. package/dist/client/assets/{dist-DbovsYeH.js → dist-C_ftUQZN.js} +1 -1
  19. package/dist/client/assets/{dist-j9X7j381.js → dist-CuMnZOGE.js} +1 -1
  20. package/dist/client/assets/{dist-D-LtG92p.js → dist-CukPKrEn.js} +1 -1
  21. package/dist/client/assets/{dist-b9CfAgzG.js → dist-D3Ta0y4e.js} +1 -1
  22. package/dist/client/assets/{dist-BsqzrZ5R.js → dist-DJ8SwQl_.js} +1 -1
  23. package/dist/client/assets/{dist-BqGjijI6.js → dist-DLKVFZhO.js} +1 -1
  24. package/dist/client/assets/{dist-MC2XuZgq.js → dist-Jw-GQ-re.js} +1 -1
  25. package/dist/client/assets/{dist-D_umYm0o.js → dist-NlopeJ5R.js} +1 -1
  26. package/dist/client/assets/{dist-CsNytPug.js → dist-POoQ4j29.js} +1 -1
  27. package/dist/client/assets/{dist-DdVs8Y0X.js → dist-cP2vnTP_.js} +1 -1
  28. package/dist/client/assets/{dist-DL4Q05ts.js → dist-zUJqdbfo.js} +1 -1
  29. package/dist/client/assets/{index-DVmE8H4Y.js → index-B32VZVYx.js} +13 -13
  30. package/dist/client/assets/index-Bdym8BPo.css +1 -0
  31. package/dist/client/index.html +2 -2
  32. package/dist/server/application/conversation-service.d.ts +13 -4
  33. package/dist/server/application/conversation-service.js +112 -61
  34. package/dist/server/application/run-service.d.ts +13 -0
  35. package/dist/server/application/run-service.js +255 -69
  36. package/dist/server/application/scheduled-task-service.js +8 -1
  37. package/dist/server/application/workspace-service.js +16 -1
  38. package/dist/server/feishu-cli.js +15 -4
  39. package/dist/server/file-index.d.ts +30 -0
  40. package/dist/server/file-index.js +49 -4
  41. package/dist/server/files.d.ts +21 -0
  42. package/dist/server/files.js +38 -0
  43. package/dist/server/http-server.js +1455 -1412
  44. package/dist/server/index.js +3 -4
  45. package/dist/server/logging.js +56 -3
  46. package/dist/server/pi-session-pool.d.ts +2 -0
  47. package/dist/server/pi-session-pool.js +1 -0
  48. package/dist/server/pi-session.d.ts +118 -0
  49. package/dist/server/pi-session.js +132 -10
  50. package/dist/server/runtime.d.ts +47 -0
  51. package/dist/server/runtime.js +183 -84
  52. package/dist/server/store.d.ts +44 -1
  53. package/dist/server/store.js +114 -12
  54. package/dist/server/subagent-runs.js +18 -7
  55. package/dist/server/subagents.js +7 -0
  56. package/dist/server/tool-runner.js +13 -4
  57. package/dist/server/types.d.ts +27 -0
  58. package/dist/server/ws-server.d.ts +34 -5
  59. package/dist/server/ws-server.js +170 -46
  60. package/package.json +15 -1
  61. package/scripts/diagnose.mjs +161 -58
  62. package/dist/client/assets/index-FxTrUapG.css +0 -1
@@ -1,4 +1,4 @@
1
- import { assertModelAllowed, conversationWorkspace } from "../model-filter.js";
1
+ import { assertModelAllowed } from "../model-filter.js";
2
2
  import { newConversationId } from "../store.js";
3
3
  /**
4
4
  * Service managing conversation lifecycle: creation, selection, archiving,
@@ -14,7 +14,13 @@ export class ConversationService {
14
14
  extensionUiService;
15
15
  agentsDir;
16
16
  /** Conversations whose Pi transcript is being cut RIGHT NOW. Read by the send path, below. */
17
- reverting = new Set();
17
+ // This is a reference count rather than a Set. A revert is claimed at socket arrival and claimed a
18
+ // second time when the service starts the cut. More than one socket can also click revert before the
19
+ // first one finishes. A Set lets the first owner release the entry while the second cut is still
20
+ // running, admitting a send onto a transcript that has not been cut yet.
21
+ reverting = new Map();
22
+ /** Reverts for one conversation must never truncate the same Pi file concurrently. */
23
+ revertOps = new Map();
18
24
  /** The memory store, so a revert can undo what that conversation taught it. Set by `setMemory`. */
19
25
  memory;
20
26
  constructor(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, agentsDir) {
@@ -34,7 +40,7 @@ export class ConversationService {
34
40
  * Creates a new conversation in a workspace.
35
41
  */
36
42
  async createConversation(workspaceId) {
37
- const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
43
+ const workspace = this.store.workspace(workspaceId);
38
44
  if (!workspace)
39
45
  throw new Error("找不到这个工作区。");
40
46
  // Workspace defaults are copied at birth, but Pi's model list can change after the
@@ -46,14 +52,20 @@ export class ConversationService {
46
52
  // override: a workspace that has a default of its own keeps it, and only a workspace with none
47
53
  // inherits the app-wide one. Both are copied onto the conversation and never consulted again,
48
54
  // so changing either default never rewrites an existing conversation.
49
- const appDefault = this.store.snapshot().defaultModel;
50
- const inheritsAppDefault = !workspace.defaultModel && Boolean(appDefault);
55
+ const appDefault = this.store.defaultModel();
51
56
  let model = workspace.defaultModel ?? appDefault;
52
57
  let thinkingLevel = workspace.defaultThinkingLevel;
58
+ // WHY A DEFAULT MODEL CAN BE DROPPED, IN WORDS. The two ways a default does not survive are
59
+ // both real and both the user's problem: Pi may no longer offer the model, and the workspace's
60
+ // model rules may forbid it. Either way the conversation starts on Pi's own default - and the
61
+ // user has to be TOLD, because the alternative is a conversation silently running on a model
62
+ // nobody chose (TEST-062). The note is carried on the conversation and read by the panel.
63
+ let modelNotice;
53
64
  const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
54
65
  if (catalog?.source === "pi" && model) {
55
66
  const option = catalog.models.find(item => item.provider === model?.provider && item.modelId === model?.modelId);
56
67
  if (!option) {
68
+ modelNotice = `你设的默认模型 ${model.label ?? `${model.provider}/${model.modelId}`} 现在不在 Pi 的模型列表里了,这个会话改用 Pi 自己的默认模型。`;
57
69
  model = undefined;
58
70
  thinkingLevel = undefined;
59
71
  }
@@ -66,15 +78,23 @@ export class ConversationService {
66
78
  }
67
79
  }
68
80
  }
69
- if (inheritsAppDefault && model) {
81
+ if (model) {
82
+ // THE MODEL RULES HOLD AT BIRTH, FOR EVERY DEFAULT. The check used to be guarded by
83
+ // `inheritsAppDefault` - that is, it only ran when the model came from the APP-wide default.
84
+ // A workspace with a default of its own skipped the check entirely, so the one default the
85
+ // user had chosen FOR THIS WORKSPACE was the one the workspace's own rules never applied to
86
+ // (TEST-061). The rules say 「this workspace only runs THIS model」; that is a statement about
87
+ // the workspace, so it holds for every model the workspace starts on.
88
+ //
70
89
  // An app-wide default belongs to no workspace, so the workspace's model rules are checked HERE,
71
90
  // at birth, where the workspace is finally known. A default the rules forbid is not stamped
72
91
  // on: the conversation starts on Pi's own default instead, which is the same outcome as a
73
92
  // workspace default that pointed at a model Pi no longer offers.
74
93
  try {
75
- assertModelAllowed(this.store.snapshot().modelFilterRules, workspace, model);
94
+ assertModelAllowed(this.store.modelFilterRules(), workspace, model);
76
95
  }
77
- catch {
96
+ catch (error) {
97
+ modelNotice = `你设的默认模型 ${model.label ?? `${model.provider}/${model.modelId}`} 被这个工作区的模型规则拦下了(${String(error instanceof Error ? error.message : error)}),这个会话改用 Pi 自己的默认模型。`;
78
98
  model = undefined;
79
99
  thinkingLevel = undefined;
80
100
  }
@@ -84,7 +104,7 @@ export class ConversationService {
84
104
  : { model, thinkingLevel };
85
105
  // `null` is intentional here: Store uses it to remove a workspace value that it
86
106
  // would otherwise have copied before applying the resolved configuration.
87
- const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null };
107
+ const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null, ...(modelNotice ? { modelNotice } : {}) };
88
108
  const created = await this.store.createConversation(workspaceId, defaults);
89
109
  this.broadcaster.broadcastState();
90
110
  return created.id;
@@ -96,13 +116,13 @@ export class ConversationService {
96
116
  * a log and in a retry.
97
117
  */
98
118
  async autoRenameConversation(conversationId) {
99
- const state = this.store.snapshot();
100
- const conversation = state.conversations.find(item => item.id === conversationId);
119
+ const context = this.store.turnContext(conversationId);
120
+ const conversation = context?.conversation;
101
121
  if (!conversation)
102
122
  throw new Error("会话不存在");
103
123
  if (conversation.archivedAt)
104
124
  throw new Error("已归档的会话无法重命名,请先恢复会话");
105
- const workspace = state.workspaces.find(item => item.id === conversation.workspaceId);
125
+ const workspace = context.workspace;
106
126
  if (!workspace)
107
127
  throw new Error("会话所属的工作区不存在");
108
128
  if (!this.runtime.summarizeConversation)
@@ -119,19 +139,21 @@ export class ConversationService {
119
139
  modelSelectionFor(conversationId) {
120
140
  if (!conversationId)
121
141
  return undefined;
122
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
142
+ const conversation = this.store.conversation(conversationId);
123
143
  return conversation?.model;
124
144
  }
125
145
  /**
126
146
  * Selects a conversation and broadcasts the catalog for that conversation's model.
127
147
  */
128
148
  async getConversationCatalog(conversationId) {
129
- const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
149
+ const conversation = this.store.conversation(conversationId);
130
150
  if (!conversation)
131
151
  throw new Error("找不到这个会话。");
132
- const state = this.store.snapshot();
152
+ const workspace = this.store.workspaceForConversation(conversationId);
153
+ if (!workspace)
154
+ throw new Error("找不到这个工作区。");
133
155
  const catalog = await this.runtime.catalog();
134
- assertModelAllowed(state.modelFilterRules, conversationWorkspace(state, conversationId), conversation.model ?? catalog.defaultModel);
156
+ assertModelAllowed(this.store.modelFilterRules(), workspace, conversation.model ?? catalog.defaultModel);
135
157
  return this.catalogService.getCatalog(conversation.model, conversationId);
136
158
  }
137
159
  async selectConversation(conversationId) {
@@ -156,7 +178,7 @@ export class ConversationService {
156
178
  * Renames a conversation.
157
179
  */
158
180
  async renameConversation(conversationId, name) {
159
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
181
+ const conversation = this.store.conversation(conversationId);
160
182
  if (conversation?.archivedAt)
161
183
  throw new Error("已归档的会话无法重命名,请先恢复会话");
162
184
  await this.store.renameConversation(conversationId, name);
@@ -176,8 +198,10 @@ export class ConversationService {
176
198
  const selection = option
177
199
  ? { provider: option.provider, modelId: option.modelId, label: option.label }
178
200
  : { provider, modelId, label: modelId };
179
- const state = this.store.snapshot();
180
- assertModelAllowed(state.modelFilterRules, conversationWorkspace(state, conversationId), selection);
201
+ const workspace = this.store.workspaceForConversation(conversationId);
202
+ if (!workspace)
203
+ throw new Error("找不到这个工作区。");
204
+ assertModelAllowed(this.store.modelFilterRules(), workspace, selection);
181
205
  await this.store.setConversationModel(conversationId, selection);
182
206
  this.broadcaster.broadcastState();
183
207
  await this.catalogService.broadcastCatalog(selection, conversationId);
@@ -186,7 +210,7 @@ export class ConversationService {
186
210
  * Sets the thinking level on a conversation, validating against the model's available levels.
187
211
  */
188
212
  async setThinking(conversationId, level) {
189
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
213
+ const conversation = this.store.conversation(conversationId);
190
214
  const catalog = await this.runtime.catalog(conversation?.model);
191
215
  if (catalog.source === "pi" && !catalog.levels.includes(level)) {
192
216
  throw this.catalogService.notOffered(`思考等级 ${level}`, catalog);
@@ -198,7 +222,7 @@ export class ConversationService {
198
222
  * Configures or clears the primary agent assigned to a conversation.
199
223
  */
200
224
  async setAgent(conversationId, agentId) {
201
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
225
+ const conversation = this.store.conversation(conversationId);
202
226
  if (!conversation)
203
227
  throw new Error("找不到这个会话。");
204
228
  if (agentId === null) {
@@ -209,7 +233,7 @@ export class ConversationService {
209
233
  const settings = await this.resolveAgentConfiguration(conversation, agentId);
210
234
  await this.store.setConversationConfiguration(conversationId, settings);
211
235
  this.broadcaster.broadcastState();
212
- await this.catalogService.broadcastCatalog(this.store.snapshot().conversations.find((item) => item.id === conversationId)?.model, conversationId);
236
+ await this.catalogService.broadcastCatalog(this.store.conversation(conversationId)?.model, conversationId);
213
237
  }
214
238
  /**
215
239
  * Resolves the agent's model/thinking defaults against the catalog and the workspace's model rules.
@@ -217,7 +241,7 @@ export class ConversationService {
217
241
  * `workspaceId` must be passed when `conversation` is a workspace-shaped placeholder (the
218
242
  * createConversation path passes `{ id: workspaceId, ... }`), because matching a workspace by a
219
243
  * CONVERSATION id would never hit. Real conversations omit it: their workspace is resolved through
220
- * `conversationWorkspace`, which reads `conversation.workspaceId`.
244
+ * `workspaceForConversation`, which resolves `conversation.workspaceId` without copying messages.
221
245
  */
222
246
  async resolveAgentConfiguration(conversation, agentId, workspaceId) {
223
247
  const agent = await this.runtime.agentFor?.({ id: conversation.id, agentId });
@@ -242,13 +266,12 @@ export class ConversationService {
242
266
  throw this.catalogService.notOffered(`Agent ${agent.name} 指定的思考等级 ${agent.thinkingLevel}`, catalog);
243
267
  }
244
268
  }
245
- const state = this.store.snapshot();
246
269
  const workspace = workspaceId
247
- ? state.workspaces.find(item => item.id === workspaceId)
248
- : conversationWorkspace(state, conversation.id);
270
+ ? this.store.workspace(workspaceId)
271
+ : this.store.workspaceForConversation(conversation.id);
249
272
  if (!workspace)
250
273
  throw new Error("找不到这个工作区。");
251
- assertModelAllowed(state.modelFilterRules, workspace, selection ?? (await this.runtime.catalog()).defaultModel);
274
+ assertModelAllowed(this.store.modelFilterRules(), workspace, selection ?? (await this.runtime.catalog()).defaultModel);
252
275
  return { agentId: agent.id, model: selection, thinkingLevel: agent.thinkingLevel ?? conversation.thinkingLevel };
253
276
  }
254
277
  /**
@@ -257,21 +280,18 @@ export class ConversationService {
257
280
  */
258
281
  async revertMessage(conversationId, messageId, withdraw = false) {
259
282
  // CLAIMED FOR THE WHOLE CUT, and released in a `finally` however this ends - a revert that threw
260
- // halfway must not leave the conversation permanently unable to send.
261
- //
262
- // WHY THE CLAIM EXISTS AT ALL. Truncating Pi's transcript is not instantaneous: it spawns a Pi to ask
263
- // which file the session lives in, and only then writes. A message sent in that window would be
264
- // appended to a history the model still reads in full - the very turn the person just withdrew would
265
- // still be in its context, and the new answer would be built on top of it. So the send is what waits,
266
- // NOT the text coming back: the browser restores the message optimistically and the person can edit
267
- // it immediately, and what is refused is the next SEND until the cut has landed.
268
- this.reverting.add(conversationId);
269
- try {
270
- return await this.cutTo(conversationId, messageId, withdraw);
271
- }
272
- finally {
273
- this.reverting.delete(conversationId);
274
- }
283
+ // halfway must not leave the conversation permanently unable to send. The per-conversation lane is
284
+ // separate from the WebSocket command queue: revert may start immediately, but two clicks against
285
+ // the same transcript still run strictly in order.
286
+ return this.serializeRevert(conversationId, async () => {
287
+ this.claimRevert(conversationId);
288
+ try {
289
+ return await this.cutTo(conversationId, messageId, withdraw);
290
+ }
291
+ finally {
292
+ this.releaseRevert(conversationId);
293
+ }
294
+ });
275
295
  }
276
296
  /**
277
297
  * The memory store, injected rather than constructed: `ActiveMemory` is built once in `index.ts` and
@@ -284,7 +304,7 @@ export class ConversationService {
284
304
  }
285
305
  /** Whether a revert is currently cutting this conversation's history. The send path asks. */
286
306
  isReverting(conversationId) {
287
- return this.reverting.has(conversationId);
307
+ return (this.reverting.get(conversationId) ?? 0) > 0;
288
308
  }
289
309
  /**
290
310
  * CLAIM THE CUT AT THE MOMENT THE COMMAND ARRIVES, not at the moment it runs.
@@ -296,9 +316,9 @@ export class ConversationService {
296
316
  * started yet, and was admitted onto a transcript the cut was about to remove. The guard existed and
297
317
  * still could not see the case it was written for.
298
318
  *
299
- * The claim therefore moves to the arrival edge, and `releaseRevert` below gives it back. A Set makes
300
- * the two owners safe together: the service releases when the cut finishes, and the socket releases
301
- * when the command finishes, and whichever runs first ends the claim - both are the same moment.
319
+ * The claim therefore moves to the arrival edge, and `releaseRevert` below gives it back. The claim is
320
+ * reference-counted: the service owns one count while the cut runs, and the socket owns one until the
321
+ * command finishes. This also remains safe when two browser tabs click revert before either cut ends.
302
322
  *
303
323
  * The cost is that a revert sitting behind a long queue refuses sends from the moment it ARRIVES
304
324
  * rather than from the moment it STARTS. That is the direction to err in: a send admitted during the
@@ -306,19 +326,46 @@ export class ConversationService {
306
326
  * is in its context.
307
327
  */
308
328
  claimRevert(conversationId) {
309
- this.reverting.add(conversationId);
329
+ this.reverting.set(conversationId, (this.reverting.get(conversationId) ?? 0) + 1);
330
+ this.runService.holdHistoryCut?.(conversationId);
310
331
  }
311
332
  /** Give the claim back once the command that took it has finished, however it ended. */
312
333
  releaseRevert(conversationId) {
313
- this.reverting.delete(conversationId);
334
+ const count = this.reverting.get(conversationId) ?? 0;
335
+ if (count <= 1)
336
+ this.reverting.delete(conversationId);
337
+ else
338
+ this.reverting.set(conversationId, count - 1);
339
+ this.runService.releaseHistoryCut?.(conversationId);
340
+ }
341
+ /**
342
+ * Serializes destructive transcript cuts for one conversation while keeping different conversations
343
+ * independent. The lane is intentionally local to this service: the socket layer can dispatch a
344
+ * revert without waiting behind unrelated commands, and this lane is the ordering boundary that the
345
+ * Pi file actually requires.
346
+ */
347
+ serializeRevert(conversationId, work) {
348
+ const previous = this.revertOps.get(conversationId) ?? Promise.resolve();
349
+ const next = previous.catch(() => undefined).then(work);
350
+ const lane = next.then(() => undefined, () => undefined);
351
+ this.revertOps.set(conversationId, lane);
352
+ void lane.finally(() => {
353
+ if (this.revertOps.get(conversationId) === lane)
354
+ this.revertOps.delete(conversationId);
355
+ });
356
+ return next;
314
357
  }
315
358
  async cutTo(conversationId, messageId, withdraw) {
316
359
  let plan = this.store.planRevert(conversationId, messageId, withdraw);
317
- let conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
360
+ // Revert is already a heavy path (Pi transcript truncation and memory cleanup), but it should not
361
+ // pay for cloning every other conversation merely to obtain these two rows. `turnContext` returns
362
+ // detached copies of exactly this conversation and its workspace and keeps the full-state clone
363
+ // out of the click-to-result path. This matters most when the user has many long historical tabs.
364
+ let context = this.store.turnContext(conversationId);
365
+ let conversation = context?.conversation;
366
+ let workspace = context?.workspace;
318
367
  if (!conversation)
319
368
  throw new Error("会话不存在");
320
- const workspaceId = conversation.workspaceId;
321
- const workspace = this.store.snapshot().workspaces.find((item) => item.id === workspaceId);
322
369
  if (!workspace)
323
370
  throw new Error(`会话所属的工作区 ${conversation.workspaceId} 已不存在,拒绝回退`);
324
371
  if (!this.runtime.revert) {
@@ -329,7 +376,8 @@ export class ConversationService {
329
376
  await this.runService.stopRun(conversationId);
330
377
  // The stopping turn may have recorded the user's Pi entry while it drained.
331
378
  plan = this.store.planRevert(conversationId, messageId, withdraw);
332
- conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
379
+ context = this.store.turnContext(conversationId);
380
+ conversation = context?.conversation;
333
381
  if (!conversation)
334
382
  throw new Error("会话不存在");
335
383
  }
@@ -344,6 +392,12 @@ export class ConversationService {
344
392
  boundaryEntryId: plan.boundaryEntryId,
345
393
  beforeBoundary: withdraw && Boolean(conversation.messages.find(item => item.id === messageId)?.piEntryId),
346
394
  });
395
+ // Capture both facts before Store splices the messages array. `store.conversation()` is a shallow
396
+ // copy, so reading them afterwards would read the already-shortened array and lose both the
397
+ // withdrawn message and the first memory timestamp that has to be removed (TEST-051).
398
+ const firstRemovedAt = conversation.messages[plan.boundaryIndex + (withdraw ? 0 : 1)]?.createdAt;
399
+ const withdrawn = conversation.messages.find(item => item.id === messageId);
400
+ const withdrawnContent = withdrawn ? { content: withdrawn.content, images: withdrawn.images } : undefined;
347
401
  const applied = await this.store.revertMessages(conversationId, messageId, withdraw);
348
402
  // THE MEMORY HALF OF THE REWIND, and the half that used to be missing. Everything above removes the
349
403
  // turn from Pi's file and from state.json; the memory store is a second durable copy of what the turn
@@ -351,11 +405,10 @@ export class ConversationService {
351
405
  // had withdrawn - measured by scripts/revert-real-pi.test.mjs, which answers a question about the
352
406
  // withdrawn turn correctly only with `SUPER_PI_MEMORY_ENABLED=0`.
353
407
  //
354
- // The cut is the FIRST REMOVED message's timestamp, not the boundary's, because the boundary turn
355
- // SURVIVES and anything it taught memory is still true. `revertMessages` has already removed the rest,
408
+ // The cut is the FIRST REMOVED message's timestamp. Revert keeps the boundary; withdraw removes
409
+ // it too, including memory written before its assistant placeholder existed. `revertMessages` has already removed the rest,
356
410
  // so this runs on data that is settled and cannot be racing the store's own writes.
357
- const firstRemoved = conversation.messages[applied.boundaryIndex + 1];
358
- const forgotten = firstRemoved ? await this.memory?.forgetAfter(conversationId, firstRemoved.createdAt) ?? 0 : 0;
411
+ const forgotten = firstRemovedAt ? await this.memory?.forgetAfter(conversationId, firstRemovedAt) ?? 0 : 0;
359
412
  // The recall index is a cache of the store's messages, and the store just lost several: left alone it
360
413
  // keeps answering the memory extension's searches with the withdrawn turns, so the model is told about
361
414
  // history the person reverted away even though the transcript, Pi's file and the memory store all
@@ -368,22 +421,20 @@ export class ConversationService {
368
421
  `Pi's file ${outcome.sessionFile} kept ${outcome.linesKept} line(s) and lost ${outcome.entriesDropped}; ` +
369
422
  `${forgotten} memory entr${forgotten === 1 ? "y" : "ies"} withdrawn with the turn.`);
370
423
  this.broadcaster.broadcastState();
371
- const withdrawn = conversation.messages.find(item => item.id === messageId);
372
- return withdraw && withdrawn ? { content: withdrawn.content, images: withdrawn.images } : undefined;
424
+ return withdraw ? withdrawnContent : undefined;
373
425
  }
374
426
  /**
375
427
  * Forks an existing conversation into a new parallel branch, cloning the Pi session.
376
428
  */
377
429
  async forkConversation(conversationId) {
378
- const snapshot = this.store.snapshot();
379
- const conversation = snapshot.conversations.find((item) => item.id === conversationId);
430
+ const conversation = this.store.conversation(conversationId);
380
431
  if (!conversation) {
381
432
  return { ok: false, message: "会话不存在" };
382
433
  }
383
434
  if (conversation.archivedAt) {
384
435
  return { ok: false, message: "已归档的会话无法分叉,请先恢复会话" };
385
436
  }
386
- const workspace = snapshot.workspaces.find((item) => item.id === conversation.workspaceId);
437
+ const workspace = this.store.workspaceForConversation(conversationId);
387
438
  if (!workspace) {
388
439
  return { ok: false, message: `会话所属的工作区 ${conversation.workspaceId} 已不存在,拒绝分叉` };
389
440
  }
@@ -62,6 +62,8 @@ export declare class RunService {
62
62
  private agentsDir;
63
63
  private readonly workflows;
64
64
  private readonly questionWaiters;
65
+ /** A stop can race a late question callback; remember the stopped turn so it cannot park forever. */
66
+ private readonly cancelledQuestionConversations;
65
67
  private memory?;
66
68
  setMemory(memory: import("../active-memory.js").ActiveMemory): void;
67
69
  /**
@@ -83,6 +85,10 @@ export declare class RunService {
83
85
  setNameIndexes(indexes: Map<string, import("../file-name-index.js").FileNameIndex>): void;
84
86
  reviewMemoryBoundary(conversationId: string): void;
85
87
  private readonly activeRuns;
88
+ private readonly historyCutClaims;
89
+ holdHistoryCut(conversationId: string): void;
90
+ releaseHistoryCut(conversationId: string): void;
91
+ private assertHistoryAvailable;
86
92
  /**
87
93
  * The completion of the run currently holding a conversation's slot.
88
94
  *
@@ -182,6 +188,7 @@ export declare class RunService {
182
188
  private questionKey;
183
189
  private answerPayload;
184
190
  private settleQuestion;
191
+ private cancelQuestionWaiters;
185
192
  /**
186
193
  * THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
187
194
  *
@@ -223,6 +230,8 @@ export declare class RunService {
223
230
  * Checks whether a conversation has an active turn running.
224
231
  */
225
232
  isRunning(conversationId: string): boolean;
233
+ /** Returns true while a stop has been requested and the old producer is still unwinding. */
234
+ private isStopping;
226
235
  /**
227
236
  * Checks the session credential together with the application-level turn truth.
228
237
  *
@@ -250,6 +259,8 @@ export declare class RunService {
250
259
  * Stops an active turn run for a conversation.
251
260
  */
252
261
  stopRun(conversationId: string): Promise<void>;
262
+ /** Releases waits that can prevent Pi's abort from reaching its idle state. Idempotent by design. */
263
+ private cancelPendingRunInteractions;
253
264
  private stopRunUnsafe;
254
265
  /**
255
266
  * Handles queue snapshot updates from Pi RPC.
@@ -352,6 +363,8 @@ export declare class RunService {
352
363
  * stream it - which is why the two cases are expressed identically and cannot drift apart.
353
364
  */
354
365
  runConversationTurns(conversation: Conversation, workspace: Workspace, prompt: string, controller: AbortController, firstMessageId: string, initialImages?: MessageImage[]): Promise<void>;
366
+ /** Keep admission owned through final persistence and recheck the durable queue before releasing. */
367
+ private runUntilDrained;
355
368
  /**
356
369
  * Enqueues or starts execution for a user message.
357
370
  */