@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.
- package/agents/super-pi-knowledge/index.ts +11 -13
- package/agents/super-pi-memory/index.ts +11 -5
- package/bin/super-pi.js +7 -11
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/{CodeMirrorEditor-CkA_5D5r.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
- package/dist/client/assets/{dist-DcdzfM-n.js → dist-2XXLO-IZ.js} +1 -1
- package/dist/client/assets/{dist-ufRHdQ8G.js → dist-BEM6tttY.js} +1 -1
- package/dist/client/assets/{dist-9Fm8XKu4.js → dist-BfUeRJKr.js} +1 -1
- package/dist/client/assets/{dist-CrxmpY93.js → dist-BokzcFLE.js} +1 -1
- package/dist/client/assets/{dist-BfEmujEC.js → dist-BoucVzKA.js} +1 -1
- package/dist/client/assets/{dist-B02Erd0t.js → dist-BxTGjU84.js} +1 -1
- package/dist/client/assets/{dist-DGhdkud4.js → dist-C-MUXK_t.js} +1 -1
- package/dist/client/assets/{dist-k6bOz9RS.js → dist-C8NllalC.js} +1 -1
- package/dist/client/assets/{dist-C2tSd-AY.js → dist-CSdzvQnf.js} +1 -1
- package/dist/client/assets/{dist-BySjislr.js → dist-CUNctZ5v.js} +1 -1
- package/dist/client/assets/{dist-B3Mfuv1D.js → dist-CZ9Krsno.js} +1 -1
- package/dist/client/assets/{dist-DbovsYeH.js → dist-C_ftUQZN.js} +1 -1
- package/dist/client/assets/{dist-j9X7j381.js → dist-CuMnZOGE.js} +1 -1
- package/dist/client/assets/{dist-D-LtG92p.js → dist-CukPKrEn.js} +1 -1
- package/dist/client/assets/{dist-b9CfAgzG.js → dist-D3Ta0y4e.js} +1 -1
- package/dist/client/assets/{dist-BsqzrZ5R.js → dist-DJ8SwQl_.js} +1 -1
- package/dist/client/assets/{dist-BqGjijI6.js → dist-DLKVFZhO.js} +1 -1
- package/dist/client/assets/{dist-MC2XuZgq.js → dist-Jw-GQ-re.js} +1 -1
- package/dist/client/assets/{dist-D_umYm0o.js → dist-NlopeJ5R.js} +1 -1
- package/dist/client/assets/{dist-CsNytPug.js → dist-POoQ4j29.js} +1 -1
- package/dist/client/assets/{dist-DdVs8Y0X.js → dist-cP2vnTP_.js} +1 -1
- package/dist/client/assets/{dist-DL4Q05ts.js → dist-zUJqdbfo.js} +1 -1
- package/dist/client/assets/{index-DVmE8H4Y.js → index-B32VZVYx.js} +13 -13
- package/dist/client/assets/index-Bdym8BPo.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +13 -4
- package/dist/server/application/conversation-service.js +112 -61
- package/dist/server/application/run-service.d.ts +13 -0
- package/dist/server/application/run-service.js +255 -69
- package/dist/server/application/scheduled-task-service.js +8 -1
- package/dist/server/application/workspace-service.js +16 -1
- package/dist/server/feishu-cli.js +15 -4
- package/dist/server/file-index.d.ts +30 -0
- package/dist/server/file-index.js +49 -4
- package/dist/server/files.d.ts +21 -0
- package/dist/server/files.js +38 -0
- package/dist/server/http-server.js +1455 -1412
- package/dist/server/index.js +3 -4
- package/dist/server/logging.js +56 -3
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +1 -0
- package/dist/server/pi-session.d.ts +118 -0
- package/dist/server/pi-session.js +132 -10
- package/dist/server/runtime.d.ts +47 -0
- package/dist/server/runtime.js +183 -84
- package/dist/server/store.d.ts +44 -1
- package/dist/server/store.js +114 -12
- package/dist/server/subagent-runs.js +18 -7
- package/dist/server/subagents.js +7 -0
- package/dist/server/tool-runner.js +13 -4
- package/dist/server/types.d.ts +27 -0
- package/dist/server/ws-server.d.ts +34 -5
- package/dist/server/ws-server.js +170 -46
- package/package.json +15 -1
- package/scripts/diagnose.mjs +161 -58
- package/dist/client/assets/index-FxTrUapG.css +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertModelAllowed
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
|
100
|
-
const conversation =
|
|
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 =
|
|
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.
|
|
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.
|
|
149
|
+
const conversation = this.store.conversation(conversationId);
|
|
130
150
|
if (!conversation)
|
|
131
151
|
throw new Error("找不到这个会话。");
|
|
132
|
-
const
|
|
152
|
+
const workspace = this.store.workspaceForConversation(conversationId);
|
|
153
|
+
if (!workspace)
|
|
154
|
+
throw new Error("找不到这个工作区。");
|
|
133
155
|
const catalog = await this.runtime.catalog();
|
|
134
|
-
assertModelAllowed(
|
|
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.
|
|
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
|
|
180
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
* `
|
|
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
|
-
?
|
|
248
|
-
:
|
|
270
|
+
? this.store.workspace(workspaceId)
|
|
271
|
+
: this.store.workspaceForConversation(conversation.id);
|
|
249
272
|
if (!workspace)
|
|
250
273
|
throw new Error("找不到这个工作区。");
|
|
251
|
-
assertModelAllowed(
|
|
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
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
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.
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
355
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
*/
|