@evandu/super-pi 0.1.83 → 0.1.85
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 +40 -0
- package/assets/knowledge-model/README.md +37 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
- package/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +8 -7
- package/dist/client/assets/index-B_jOwjyp.js +72 -0
- package/dist/client/assets/index-CNNGl4eF.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +50 -11
- package/dist/server/application/run-service.js +206 -77
- package/dist/server/command-handler.js +16 -4
- package/dist/server/http-server.js +59 -1
- package/dist/server/index.js +7 -0
- package/dist/server/knowledge-vectors.d.ts +133 -0
- package/dist/server/knowledge-vectors.js +302 -0
- package/dist/server/knowledge.d.ts +263 -0
- package/dist/server/knowledge.js +743 -0
- package/dist/server/persistence/app-state-repository.d.ts +25 -1
- package/dist/server/persistence/sqlite-state-repository.js +47 -4
- package/dist/server/runtime.d.ts +48 -1
- package/dist/server/runtime.js +83 -7
- package/dist/server/server-context.d.ts +1 -0
- package/dist/server/store.d.ts +153 -68
- package/dist/server/store.js +405 -187
- package/dist/server/types.d.ts +97 -65
- package/package.json +163 -154
- package/dist/client/assets/index-DhOfFh0G.js +0 -72
- package/dist/client/assets/index-v2n_FYv_.css +0 -1
package/dist/server/store.js
CHANGED
|
@@ -297,53 +297,103 @@ function reconcileWorkflows(state) {
|
|
|
297
297
|
return orphaned;
|
|
298
298
|
}
|
|
299
299
|
/**
|
|
300
|
-
* Startup reconciliation for the
|
|
301
|
-
* back to
|
|
300
|
+
* Startup reconciliation for the waiting messages: one the previous process left `sending` goes back
|
|
301
|
+
* to `waiting`, and any legacy waiting list is folded back into the transcripts it belonged to.
|
|
302
302
|
*
|
|
303
303
|
* `sending` is one claim only: "this text has been written to a live Pi child's stdin as a `steer` and
|
|
304
304
|
* Pi has not confirmed delivering it". The claim dies with the process that made it, exactly like the
|
|
305
305
|
* `running` claims above - the Pi that would have delivered it was a child of the server that is no
|
|
306
306
|
* longer running, so it can never confirm anything now.
|
|
307
307
|
*
|
|
308
|
-
* RETURNING IT TO THE
|
|
309
|
-
* option:
|
|
308
|
+
* RETURNING IT TO THE WAITING SET, RATHER THAN DROPPING IT OR LEAVING IT SENDING, is the only honest
|
|
309
|
+
* third option:
|
|
310
310
|
*
|
|
311
|
-
* - Dropping it would lose a message the user is looking at in their
|
|
311
|
+
* - Dropping it would lose a message the user is looking at in their own transcript, silently and
|
|
312
|
+
* permanently - and unlike the old separate list, that message is not one entry among several, it is
|
|
313
|
+
* the only copy of those words there is.
|
|
312
314
|
* - Leaving it `sending` would show a message as "already handed to the agent, cannot be cancelled"
|
|
313
315
|
* that in fact never reached any model and can be cancelled - a UI claiming an irreversible fact
|
|
314
316
|
* about a reversible state.
|
|
315
317
|
* - Re-queueing is TRUE: Pi drains steering before its loop can end (`agent-loop.ts:295`), and it
|
|
316
|
-
*
|
|
317
|
-
* (`agent-session.ts:1116-1136`)
|
|
318
|
-
*
|
|
319
|
-
*
|
|
318
|
+
* announces the delivery with `message_start` before the turn settles
|
|
319
|
+
* (`agent-session.ts:1116-1136`), which this server consumes and applies. So a message still `sending`
|
|
320
|
+
* when the process died provably was not delivered. Re-queued, the user sees it again and can steer it
|
|
321
|
+
* into the next turn.
|
|
322
|
+
*
|
|
323
|
+
* THE LEGACY FOLD IS WHAT MAKES THIS AN UPGRADE RATHER THAN A RESET. A state file written by a build
|
|
324
|
+
* that kept waiting messages in their own array holds real words the user typed, in a shape this build
|
|
325
|
+
* no longer reads. Dropping that array would silently lose every message that was waiting at the moment
|
|
326
|
+
* of the upgrade - and that is the one moment where a reader is most likely to be looking at the queue.
|
|
327
|
+
* So each legacy entry is appended to its own conversation as a waiting MESSAGE, keeping its id, text
|
|
328
|
+
* and images, in the order the array had them.
|
|
329
|
+
*
|
|
330
|
+
* IT IS ONE-WAY, deliberately: the key is deleted after the fold, so a second boot cannot duplicate
|
|
331
|
+
* them, and a folded message keeps its `q_`-prefixed id so nothing can mistake one for a message this
|
|
332
|
+
* build wrote (`claimClientMessageId` only accepts `message_` ids, and a collision there falls back to
|
|
333
|
+
* a minted id rather than to a fold).
|
|
320
334
|
*
|
|
321
335
|
* Mutated in place and persisted by `open`, so a queue written by a build that predates it, or by a
|
|
322
336
|
* hand edit, is repaired on the first boot rather than read as permanently stuck.
|
|
323
337
|
*
|
|
324
338
|
* `changed` is reported separately from `recovered` because they are different repairs with different
|
|
325
|
-
* consequences: a `recovered`
|
|
339
|
+
* consequences: a `recovered` message is one the user can see and therefore needs announcing, while a
|
|
326
340
|
* DROPPED row is a broken file that has to be rewritten or it is re-dropped on every single boot. Neither
|
|
327
341
|
* is worth a persist on its own account - `open` persists once, for all the sweeps together.
|
|
328
342
|
*/
|
|
329
|
-
function
|
|
330
|
-
// `Array.isArray` rather than `?? []`, for the same reason the
|
|
331
|
-
//
|
|
332
|
-
const
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
343
|
+
function reconcileWaitingMessages(state) {
|
|
344
|
+
// The legacy array, read blind: `Array.isArray` rather than `?? []`, for the same reason the sweeps
|
|
345
|
+
// above do it, because a truthy non-array left by a hand edit would make every call below throw.
|
|
346
|
+
const legacy = state.queuedMessages;
|
|
347
|
+
const queue = Array.isArray(legacy) ? legacy : [];
|
|
348
|
+
const hadKey = Array.isArray(legacy);
|
|
349
|
+
// An entry that is not usable is dropped rather than repaired: every field is load-bearing (the id is
|
|
350
|
+
// what cancel/steer name, the content is what the user wrote), so a partial entry has no safe default
|
|
351
|
+
// and keeping it would put `undefined` text on the wire.
|
|
352
|
+
const usable = queue.filter((item) => Boolean(item) && typeof item.id === "string"
|
|
353
|
+
&& typeof item.conversationId === "string"
|
|
354
|
+
&& typeof item.content === "string");
|
|
355
|
+
// DELETED UNCONDITIONALLY, before anything else reads it. The fold below is the only consumer, and
|
|
356
|
+
// leaving the key in place would re-fold the same words on every single boot.
|
|
357
|
+
delete state.queuedMessages;
|
|
339
358
|
const recovered = [];
|
|
340
|
-
for (const
|
|
341
|
-
|
|
359
|
+
for (const conversation of state.conversations) {
|
|
360
|
+
for (const message of conversation.messages) {
|
|
361
|
+
// A `delivery` on anything but a user message is a broken file: no code path writes one, and
|
|
362
|
+
// rendering a waiting state on an assistant bubble would be a claim about nothing.
|
|
363
|
+
if (message.role !== "user") {
|
|
364
|
+
delete message.delivery;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (message.delivery !== "sending")
|
|
368
|
+
continue;
|
|
369
|
+
message.delivery = "waiting";
|
|
370
|
+
recovered.push({ conversationId: conversation.id, message });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const folded = [];
|
|
374
|
+
for (const entry of usable) {
|
|
375
|
+
const conversation = state.conversations.find((item) => item.id === entry.conversationId);
|
|
376
|
+
// An entry naming a conversation that is gone has nowhere to go. That is the one legacy case that is
|
|
377
|
+
// genuinely dropped, and it is reported through `changed` so the key's removal is written to disk.
|
|
378
|
+
if (!conversation)
|
|
342
379
|
continue;
|
|
343
|
-
message
|
|
344
|
-
|
|
380
|
+
const message = {
|
|
381
|
+
id: entry.id,
|
|
382
|
+
role: "user",
|
|
383
|
+
content: entry.content,
|
|
384
|
+
createdAt: typeof entry.createdAt === "string" ? entry.createdAt : now(),
|
|
385
|
+
// ALWAYS `waiting`, never the legacy `sending`: the claim died with the process that made it, which
|
|
386
|
+
// is the same argument the sweep above makes about a message that is genuinely `sending`.
|
|
387
|
+
delivery: "waiting",
|
|
388
|
+
};
|
|
389
|
+
if (entry.images && entry.images.length > 0)
|
|
390
|
+
message.images = structuredClone(entry.images);
|
|
391
|
+
if (entry.attachments && entry.attachments.length > 0)
|
|
392
|
+
message.attachments = structuredClone(entry.attachments);
|
|
393
|
+
conversation.messages.push(message);
|
|
394
|
+
folded.push({ conversationId: conversation.id, message });
|
|
345
395
|
}
|
|
346
|
-
return { recovered, changed:
|
|
396
|
+
return { recovered, folded, changed: hadKey || usable.length !== queue.length || recovered.length > 0 };
|
|
347
397
|
}
|
|
348
398
|
/**
|
|
349
399
|
* Which messages and which sub-agent runs a revert to `messageId` removes.
|
|
@@ -390,7 +440,7 @@ function planRevert(state, conversationId, messageId, withdraw = false) {
|
|
|
390
440
|
/**
|
|
391
441
|
* Brings `state.todosByConversation` back to a shape the browser can render, on boot.
|
|
392
442
|
*
|
|
393
|
-
* THE SAME REASON `
|
|
443
|
+
* THE SAME REASON `reconcileWaitingMessages` EXISTS, and the same three cases, but nothing here RECOVERS
|
|
394
444
|
* anything: there is no "still running" claim in a todo list to contradict, because the list is not a
|
|
395
445
|
* record of a running thing - it is a record of what a model last said it was doing. So there is nothing to
|
|
396
446
|
* reconcile against a fact and nothing to announce; the sweep exists purely because `state.json` is read
|
|
@@ -451,6 +501,18 @@ export class Store {
|
|
|
451
501
|
static async openRepository(repository) {
|
|
452
502
|
try {
|
|
453
503
|
const state = await repository.load();
|
|
504
|
+
// A COLLECTION THE FILE DID NOT HAVE IS A REPAIR TOO, and it is counted as one here rather than left
|
|
505
|
+
// to the sweeps below - which is what this branch used to do by accident. The follow-up queue sweep
|
|
506
|
+
// reported `changed` for any file without a `queuedMessages` key, so every state written before that
|
|
507
|
+
// feature shipped was rewritten on its first boot, and a hand-seeded file still is. Deleting the
|
|
508
|
+
// queue took that side effect with it, and with it the observable fact that a server which had just
|
|
509
|
+
// defaulted a collection says so on disk.
|
|
510
|
+
//
|
|
511
|
+
// The rule is the one already written above the queue sweep: a repaired file has to be rewritten, or
|
|
512
|
+
// it is repaired again on every single boot - and until it is, anything reading the file sees the
|
|
513
|
+
// un-normalised shape rather than the one the store is actually running on.
|
|
514
|
+
const defaulted = ["workspaces", "conversations", "subagentRuns"].filter((key) => !Array.isArray(state[key])).length > 0
|
|
515
|
+
|| !Array.isArray(state.backgroundTasks);
|
|
454
516
|
if (!Array.isArray(state.backgroundTasks))
|
|
455
517
|
state.backgroundTasks = [];
|
|
456
518
|
// Every sweep runs before the store is constructed, so a single persist below publishes them together.
|
|
@@ -459,13 +521,13 @@ export class Store {
|
|
|
459
521
|
const orphaned = reconcileSubagentRuns(state);
|
|
460
522
|
const orphanedWorkflows = reconcileWorkflows(state);
|
|
461
523
|
const interrupted = reconcileConversations(state);
|
|
462
|
-
const requeued =
|
|
524
|
+
const requeued = reconcileWaitingMessages(state);
|
|
463
525
|
const todos = reconcileTodos(state);
|
|
464
526
|
const store = new Store(repository, normalizeMessageTimestamps(state));
|
|
465
527
|
// Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
|
|
466
528
|
// is only rewritten by a later mutation would still read `running` to anything that opened the file
|
|
467
529
|
// in the meantime, and would be reconciled a SECOND time on the next restart.
|
|
468
|
-
if (orphaned.length || orphanedWorkflows.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
530
|
+
if (defaulted || orphaned.length || orphanedWorkflows.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
469
531
|
for (const run of orphaned)
|
|
470
532
|
console.warn(`Super Pi: sub-agent run ${run.id} was still marked running when the last server stopped - it is now failed, because no child process survived it.`);
|
|
471
533
|
for (const plan of orphanedWorkflows)
|
|
@@ -475,8 +537,10 @@ export class Store {
|
|
|
475
537
|
// their run is over rather than guessing from the fact that it stopped moving.
|
|
476
538
|
for (const conversation of interrupted)
|
|
477
539
|
console.warn(`Super Pi: conversation ${conversation.id} was still marked running when the last server stopped - it is now failed, because no turn was in flight any more.`);
|
|
478
|
-
for (const
|
|
479
|
-
console.warn(`Super Pi:
|
|
540
|
+
for (const entry of requeued.recovered)
|
|
541
|
+
console.warn(`Super Pi: message ${entry.message.id} of conversation ${entry.conversationId} was still marked sending when the last server stopped - it is waiting again and cancellable, because the Pi that would have delivered it is gone.`);
|
|
542
|
+
for (const entry of requeued.folded)
|
|
543
|
+
console.warn(`Super Pi: message ${entry.message.id} of conversation ${entry.conversationId} was waiting in the old queue format - it has been moved into that conversation's transcript.`);
|
|
480
544
|
await store.persist();
|
|
481
545
|
}
|
|
482
546
|
return store;
|
|
@@ -500,7 +564,12 @@ export class Store {
|
|
|
500
564
|
}
|
|
501
565
|
}
|
|
502
566
|
const createdAt = now();
|
|
503
|
-
|
|
567
|
+
// NO WRITE HERE, AND THAT IS AN INVARIANT RATHER THAN AN OMISSION: a store with no file behind it
|
|
568
|
+
// stays lazy, so opening one never creates a file and never has to un-create one. `persistence-
|
|
569
|
+
// repository.test.mjs` asserts it by reading the path and expecting ENOENT. Repairing an EXISTING
|
|
570
|
+
// file is the other case and is handled above; this branch invented its contents and says nothing
|
|
571
|
+
// until something actually happens.
|
|
572
|
+
return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], backgroundTasks: [] });
|
|
504
573
|
}
|
|
505
574
|
}
|
|
506
575
|
async setModelFilterRules(rules) {
|
|
@@ -585,14 +654,15 @@ export class Store {
|
|
|
585
654
|
throw new Error("Workspace not found");
|
|
586
655
|
if (this.state.workspaces.length <= 1)
|
|
587
656
|
throw new Error("Cannot delete the last workspace");
|
|
588
|
-
// Cascade: workspaces
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
657
|
+
// Cascade: workspaces and conversations live in one state.json, so this single atomic persist removes
|
|
658
|
+
// both together. A waiting message needs no sweep of its own any more - it is a message inside one of
|
|
659
|
+
// the conversations being removed, so it goes with its container, which is the whole reason it is a
|
|
660
|
+
// better shape than an entry keyed by conversation id in a separate array: that array needed this
|
|
661
|
+
// cascade to avoid naming a conversation that no longer exists, and could not have been trusted to
|
|
662
|
+
// get it right on its own.
|
|
592
663
|
const doomed = new Set(this.state.conversations.filter((item) => item.workspaceId === workspaceId).map((item) => item.id));
|
|
593
664
|
this.state.workspaces = this.state.workspaces.filter((item) => item.id !== workspaceId);
|
|
594
665
|
this.state.conversations = this.state.conversations.filter((item) => item.workspaceId !== workspaceId);
|
|
595
|
-
this.state.queuedMessages = this.state.queuedMessages.filter((item) => !doomed.has(item.conversationId));
|
|
596
666
|
await this.persist();
|
|
597
667
|
return [...doomed];
|
|
598
668
|
}
|
|
@@ -679,7 +749,15 @@ export class Store {
|
|
|
679
749
|
workspaceId: source.workspaceId,
|
|
680
750
|
updatedAt: now(),
|
|
681
751
|
status: "idle",
|
|
682
|
-
|
|
752
|
+
// A WAITING MESSAGE IS NOT COPIED, and this filter is why the fork still answers "no queued
|
|
753
|
+
// messages" after the waiting list stopped being a list of its own. Forking is refused while a turn
|
|
754
|
+
// is running, but a conversation can still be holding messages that were sent during that run and
|
|
755
|
+
// have not been started; they belong to the run that is in progress and to nothing else. Copying them
|
|
756
|
+
// would put words in the fork's transcript that Pi's session file - which the fork copies separately -
|
|
757
|
+
// has no record of, so the two halves of the new conversation would disagree from its first turn.
|
|
758
|
+
messages: structuredClone(source.messages)
|
|
759
|
+
.filter((message) => !message.delivery)
|
|
760
|
+
.map((message) => ({ ...message, id: id("message") })),
|
|
683
761
|
};
|
|
684
762
|
if (source.model)
|
|
685
763
|
conversation.model = { provider: source.model.provider, modelId: source.model.modelId, label: source.model.label };
|
|
@@ -743,19 +821,48 @@ export class Store {
|
|
|
743
821
|
await this.persistMetadata(conversationId);
|
|
744
822
|
return structuredClone(conversation);
|
|
745
823
|
}
|
|
746
|
-
|
|
824
|
+
/**
|
|
825
|
+
* Writes ONE user message into the transcript - the single place a user utterance comes into being.
|
|
826
|
+
*
|
|
827
|
+
* Two callers exist and they differ only in `delivery`, because whether a send starts a turn or waits
|
|
828
|
+
* for one is decided by the RUN GUARD in `run-service`, not here. This method records the words and,
|
|
829
|
+
* if they are waiting, records that they are waiting. Deciding anything about turns would put a second,
|
|
830
|
+
* contradictory answer next to the guard's.
|
|
831
|
+
*
|
|
832
|
+
* `clientId` is the browser's own id for the message it has already drawn. When it is well formed and
|
|
833
|
+
* not already taken, the message IS that id - which is what lets the optimistic bubble and the stored
|
|
834
|
+
* message be the same object rather than two that have to be matched up by content afterwards. A
|
|
835
|
+
* collision falls back to a minted id rather than throwing: the browser is asking for its words to be
|
|
836
|
+
* kept, and refusing the whole send because an id was already in use would lose them.
|
|
837
|
+
*
|
|
838
|
+
* `delivery: "waiting"` ALSO SKIPS EVERY SIDE EFFECT BELOW - no `running`, no title, no `updatedAt`,
|
|
839
|
+
* no clearing of a failure reason. Those all describe a turn, and a message waiting for one has not
|
|
840
|
+
* started anything. It is why a conversation holding only waiting messages still reads as idle in the
|
|
841
|
+
* sidebar, and why the title comes from the first message that was actually asked rather than from
|
|
842
|
+
* whatever was typed into a box that was not listening yet. `openTurnOnMessage` applies them at the
|
|
843
|
+
* moment a turn genuinely begins.
|
|
844
|
+
*/
|
|
845
|
+
async addUserMessage(conversationId, content, images, attachments, options) {
|
|
747
846
|
const conversation = this.requireConversation(conversationId);
|
|
748
847
|
// The browser cannot reach an archived conversation, so this guard exists for any other caller -
|
|
749
848
|
// a direct WebSocket client, or a future code path. Archiving is the promise that a conversation is
|
|
750
849
|
// closed, and a closed conversation must not accept a new turn.
|
|
751
850
|
if (conversation.archivedAt)
|
|
752
851
|
throw new Error("无法向已归档的会话发送消息,请先恢复该会话");
|
|
753
|
-
const message = { id:
|
|
852
|
+
const message = { id: this.claimClientMessageId(conversation, options?.clientId), role: "user", content, createdAt: now() };
|
|
754
853
|
if (images && images.length > 0)
|
|
755
854
|
message.images = structuredClone(images);
|
|
756
855
|
if (attachments && attachments.length > 0)
|
|
757
856
|
message.attachments = structuredClone(attachments);
|
|
857
|
+
if (options?.delivery)
|
|
858
|
+
message.delivery = options.delivery;
|
|
758
859
|
conversation.messages.push(message);
|
|
860
|
+
// Persisted as a transcript change and nothing more: this is the honest record of "the user said this
|
|
861
|
+
// and nothing has picked it up yet", and a refresh has to show it in the same place it was sent from.
|
|
862
|
+
if (message.delivery) {
|
|
863
|
+
await this.persistMessages(conversation);
|
|
864
|
+
return structuredClone(message);
|
|
865
|
+
}
|
|
759
866
|
const firstUser = conversation.messages.find((item) => item.role === "user");
|
|
760
867
|
const autoTitle = firstUser?.content.trim()
|
|
761
868
|
? firstUser.content.slice(0, 42)
|
|
@@ -774,6 +881,20 @@ export class Store {
|
|
|
774
881
|
await this.persist();
|
|
775
882
|
return structuredClone(message);
|
|
776
883
|
}
|
|
884
|
+
/**
|
|
885
|
+
* Takes the browser's id for its own optimistic message, when it is safe to.
|
|
886
|
+
*
|
|
887
|
+
* A client that names an id already in this conversation gets a minted one instead: two messages sharing
|
|
888
|
+
* an id would break `updateToolCall`, `revertMessage` and every per-message write that follows, so
|
|
889
|
+
* uniqueness within the conversation is worth more than the client getting its own id back in that case.
|
|
890
|
+
* The `message_` prefix is required so a folded legacy id (which is `q_`-prefixed) can never be adopted.
|
|
891
|
+
*/
|
|
892
|
+
claimClientMessageId(conversation, clientId) {
|
|
893
|
+
const usable = typeof clientId === "string" && /^message_[0-9a-f-]{36}$/.test(clientId);
|
|
894
|
+
if (usable && !conversation.messages.some((message) => message.id === clientId))
|
|
895
|
+
return clientId;
|
|
896
|
+
return id("message");
|
|
897
|
+
}
|
|
777
898
|
/**
|
|
778
899
|
* Appends one background-result message to a conversation, without starting a turn.
|
|
779
900
|
*
|
|
@@ -1190,199 +1311,296 @@ export class Store {
|
|
|
1190
1311
|
}
|
|
1191
1312
|
requireConversation(conversationId) { const conversation = this.state.conversations.find((item) => item.id === conversationId); if (!conversation)
|
|
1192
1313
|
throw new Error("Conversation not found"); return conversation; }
|
|
1193
|
-
// -------------------------------------------------------------
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1314
|
+
// ------------------------------------------------------------- Waiting messages
|
|
1315
|
+
//
|
|
1316
|
+
// A message the user sent while the agent was working WAITS HERE, and "here" is a field on the message
|
|
1317
|
+
// (`MessageDelivery`) rather than a list beside the conversation. Nothing in this section copies text
|
|
1318
|
+
// anywhere, because there is no second copy: the words a user typed live in exactly one place, in the
|
|
1319
|
+
// transcript, from the frame they pressed send until long after the agent answered them.
|
|
1320
|
+
//
|
|
1321
|
+
// ORDER IS THE QUEUE. The waiting messages are the `role: "user"` messages carrying a `delivery`, and
|
|
1322
|
+
// their array order is the order they will be started in - so "put this one first" is a move within
|
|
1323
|
+
// that group, and there is no `priority` flag to disagree with it.
|
|
1324
|
+
//
|
|
1325
|
+
// Every method persists on every call, for the reason `setSubagentRun` does: what a browser reads and
|
|
1326
|
+
// what the file holds must never differ, and a message waiting for a turn that has not started is
|
|
1327
|
+
// exactly as durable as the conversation around it.
|
|
1199
1328
|
/**
|
|
1200
|
-
* This conversation's waiting
|
|
1329
|
+
* This conversation's waiting messages, in the order they will be started.
|
|
1201
1330
|
*
|
|
1202
1331
|
* Cloned like every other value this store hands out, because the caller broadcasts it straight to
|
|
1203
|
-
* browsers and the array is mutated in place by the very next
|
|
1332
|
+
* browsers and the array is mutated in place by the very next send.
|
|
1204
1333
|
*/
|
|
1205
|
-
|
|
1206
|
-
return structuredClone(this.
|
|
1334
|
+
listWaitingMessages(conversationId) {
|
|
1335
|
+
return structuredClone(this.waitingMessages(this.requireConversation(conversationId)));
|
|
1336
|
+
}
|
|
1337
|
+
/** The same list by reference, for callers that only read a field and must not pay for a clone. */
|
|
1338
|
+
waitingMessages(conversation) {
|
|
1339
|
+
return conversation.messages.filter((message) => message.role === "user" && message.delivery);
|
|
1340
|
+
}
|
|
1341
|
+
requireWaitingMessage(conversationId, messageId) {
|
|
1342
|
+
const conversation = this.requireConversation(conversationId);
|
|
1343
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
1344
|
+
if (!message || message.role !== "user" || !message.delivery)
|
|
1345
|
+
throw new Error(`等待中的消息不存在或已被处理:${messageId}`);
|
|
1346
|
+
return { conversation, message };
|
|
1347
|
+
}
|
|
1348
|
+
async persistMessages(conversation) {
|
|
1349
|
+
await this.persistTransaction({ kind: "conversation.messages", conversationId: conversation.id, messages: structuredClone(conversation.messages), updatedAt: conversation.updatedAt });
|
|
1207
1350
|
}
|
|
1208
1351
|
/**
|
|
1209
|
-
*
|
|
1352
|
+
* Claims one waiting message for delivery to the live Pi process: `waiting` becomes `sending`, and the
|
|
1353
|
+
* message STAYS WHERE IT IS - still on screen, still in the transcript, still at the same index.
|
|
1210
1354
|
*
|
|
1211
|
-
* The
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1355
|
+
* The read and the write are adjacent statements with no `await` between them, which is what makes the
|
|
1356
|
+
* claim atomic here. This store has no mutex and does not need one: JavaScript runs this synchronous
|
|
1357
|
+
* run-to-completion, so a second caller arriving in the same tick observes the write above and cannot
|
|
1358
|
+
* claim the same message.
|
|
1215
1359
|
*/
|
|
1216
|
-
async
|
|
1217
|
-
this.
|
|
1218
|
-
if (
|
|
1219
|
-
throw new Error("
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
message.images = structuredClone(images);
|
|
1223
|
-
if (attachments && attachments.length > 0)
|
|
1224
|
-
message.attachments = structuredClone(attachments);
|
|
1225
|
-
this.state.queuedMessages.push(message);
|
|
1226
|
-
await this.persistCollections("queuedMessages");
|
|
1360
|
+
async claimWaitingMessage(conversationId, messageId) {
|
|
1361
|
+
const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
|
|
1362
|
+
if (message.delivery === "sending")
|
|
1363
|
+
throw new Error("这条消息已经交给 Agent 了(Pi 没有撤回单条队列消息的能力)");
|
|
1364
|
+
message.delivery = "sending";
|
|
1365
|
+
await this.persistMessages(conversation);
|
|
1227
1366
|
return structuredClone(message);
|
|
1228
1367
|
}
|
|
1229
1368
|
/**
|
|
1230
|
-
*
|
|
1369
|
+
* Puts ONE `sending` message back to `waiting`, for the one case that proves Pi never took it.
|
|
1370
|
+
*
|
|
1371
|
+
* The one legal use, and it is narrow on purpose: this is called ONLY when we can prove the message
|
|
1372
|
+
* never reached a model - the turn ended while Pi still held it, or the write to Pi's stdin failed
|
|
1373
|
+
* before it left. Pi drains steering before its loop can end and announces the delivery before the turn
|
|
1374
|
+
* settles, so a message still `sending` when the child is gone provably was not delivered, and
|
|
1375
|
+
* returning it is a fact rather than a guess. It is deliberately NOT used for "Pi has not answered yet",
|
|
1376
|
+
* where the outcome is genuinely unknown and the honest state is still `sending`.
|
|
1377
|
+
*/
|
|
1378
|
+
async releaseWaitingMessage(conversationId, messageId) {
|
|
1379
|
+
const conversation = this.requireConversation(conversationId);
|
|
1380
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
1381
|
+
if (!message || message.delivery !== "sending")
|
|
1382
|
+
return;
|
|
1383
|
+
message.delivery = "waiting";
|
|
1384
|
+
await this.persistMessages(conversation);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Returns every `sending` message of this conversation to `waiting`, for the end of a turn.
|
|
1388
|
+
*
|
|
1389
|
+
* See `releaseWaitingMessage` for why this is a fact and not a guess. Called both when a turn ends and
|
|
1390
|
+
* when the user stops it: in the stop case the child is killed mid-turn and a steer it was still holding
|
|
1391
|
+
* was never delivered either, so putting it back is what lets the user decide what to do with it instead
|
|
1392
|
+
* of silently dropping a message they typed - a message they can still see, because it never left the
|
|
1393
|
+
* transcript in the first place.
|
|
1394
|
+
*/
|
|
1395
|
+
async releaseSendingMessages(conversationId) {
|
|
1396
|
+
const conversation = this.requireConversation(conversationId);
|
|
1397
|
+
let changed = false;
|
|
1398
|
+
for (const message of this.waitingMessages(conversation)) {
|
|
1399
|
+
if (message.delivery !== "sending")
|
|
1400
|
+
continue;
|
|
1401
|
+
message.delivery = "waiting";
|
|
1402
|
+
changed = true;
|
|
1403
|
+
}
|
|
1404
|
+
if (changed)
|
|
1405
|
+
await this.persistMessages(conversation);
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Withdraws one waiting message, by id. Returns it, or `undefined` when it is already gone.
|
|
1231
1409
|
*
|
|
1232
|
-
* "Already gone" is a no-op rather than an error, and the reason is the same as `SubagentRun.stop`:
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1410
|
+
* "Already gone" is a no-op rather than an error, and the reason is the same as `SubagentRun.stop`: the
|
|
1411
|
+
* click that sent this was almost always triggered by a bubble the browser has not been told about
|
|
1412
|
+
* yet, so an id that has already been answered or cancelled is the ordinary case and must not surface
|
|
1413
|
+
* as a failure.
|
|
1236
1414
|
*
|
|
1237
|
-
* A `sending`
|
|
1415
|
+
* A `sending` message REFUSES, because the text is already in a live Pi child's stdin and Pi has no way
|
|
1238
1416
|
* to forget one item (its only queue command is `clear_queue`, which clears every one of them,
|
|
1239
|
-
* including follow-ups that have nothing to do with this click). Removing it
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1417
|
+
* including follow-ups that have nothing to do with this click). Removing it here would not stop Pi from
|
|
1418
|
+
* delivering it - it would make the model act on a message the user believed they had withdrawn, while
|
|
1419
|
+
* their own transcript quietly lost the words.
|
|
1242
1420
|
*/
|
|
1243
|
-
async
|
|
1244
|
-
const
|
|
1421
|
+
async cancelWaitingMessage(conversationId, messageId) {
|
|
1422
|
+
const conversation = this.requireConversation(conversationId);
|
|
1423
|
+
const index = conversation.messages.findIndex((item) => item.id === messageId);
|
|
1245
1424
|
if (index < 0)
|
|
1246
1425
|
return undefined;
|
|
1247
|
-
|
|
1426
|
+
const message = conversation.messages[index];
|
|
1427
|
+
if (message.role !== "user" || !message.delivery)
|
|
1428
|
+
return undefined;
|
|
1429
|
+
if (message.delivery === "sending")
|
|
1248
1430
|
throw new Error("这条消息已经交给 Agent 了,取消不掉(Pi 没有撤回单条队列消息的能力)");
|
|
1249
|
-
const [removed] =
|
|
1250
|
-
await this.
|
|
1431
|
+
const [removed] = conversation.messages.splice(index, 1);
|
|
1432
|
+
await this.persistMessages(conversation);
|
|
1251
1433
|
return structuredClone(removed);
|
|
1252
1434
|
}
|
|
1253
1435
|
/**
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
1259
|
-
*
|
|
1260
|
-
* and cannot claim the same entry. Anything that needed to await between reading and writing would
|
|
1261
|
-
* need real serialization instead.
|
|
1262
|
-
*
|
|
1263
|
-
* Throws for an unknown id and for one that is already `sending`, because both mean the caller is
|
|
1264
|
-
* acting on a state the store has already moved past - and the second one means a second steer of the
|
|
1265
|
-
* same message would put the text into Pi twice.
|
|
1436
|
+
* Rewrites the words of one waiting message.
|
|
1437
|
+
*
|
|
1438
|
+
* Only legal while nothing has read it. `waiting` means the text is still only in this transcript, so
|
|
1439
|
+
* changing it is changing the whole truth of the message - there is no second copy anywhere that could
|
|
1440
|
+
* fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
|
|
1441
|
+
* not one the user gets to rewrite after the fact.
|
|
1266
1442
|
*/
|
|
1267
|
-
async
|
|
1268
|
-
const
|
|
1269
|
-
if (!
|
|
1270
|
-
throw new Error(
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
message.
|
|
1275
|
-
await this.
|
|
1443
|
+
async editWaitingMessage(conversationId, messageId, content) {
|
|
1444
|
+
const trimmed = content.trim();
|
|
1445
|
+
if (!trimmed)
|
|
1446
|
+
throw new Error("消息不能为空");
|
|
1447
|
+
const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
|
|
1448
|
+
if (message.delivery !== "waiting")
|
|
1449
|
+
throw new Error("这条消息已经交给 Agent 了,改不了(Pi 已经收到了原文)");
|
|
1450
|
+
message.content = trimmed;
|
|
1451
|
+
await this.persistMessages(conversation);
|
|
1276
1452
|
return structuredClone(message);
|
|
1277
1453
|
}
|
|
1278
|
-
/**
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1454
|
+
/**
|
|
1455
|
+
* Moves one waiting message to a new position among the waiting messages.
|
|
1456
|
+
*
|
|
1457
|
+
* `toIndex` is counted over the WAITING messages only, so `0` is the next one the agent will start and
|
|
1458
|
+
* the caller never has to know how many answered messages sit above it. Moving to the front and moving
|
|
1459
|
+
* back are the same operation, which is the point: the old arrangement had a `priority` boolean for the
|
|
1460
|
+
* first and no way at all to express the second, so undoing a priority meant cancelling the message and
|
|
1461
|
+
* retyping it.
|
|
1462
|
+
*
|
|
1463
|
+
* NOTHING CAN GO IN FRONT OF A `sending` MESSAGE. It is already in Pi's stdin and Pi's steering queue
|
|
1464
|
+
* is FIFO, so it will be delivered before anything this server could do about the order. Accepting the
|
|
1465
|
+
* move and then delivering the other one first would be the UI promising an outcome the transport has
|
|
1466
|
+
* already decided, so it is refused instead.
|
|
1467
|
+
*/
|
|
1468
|
+
async reorderWaitingMessage(conversationId, messageId, toIndex) {
|
|
1469
|
+
const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
|
|
1470
|
+
const waiting = this.waitingMessages(conversation);
|
|
1471
|
+
const from = waiting.indexOf(message);
|
|
1472
|
+
const target = Math.max(0, Math.min(Math.trunc(toIndex), waiting.length - 1));
|
|
1473
|
+
if (target === from)
|
|
1285
1474
|
return structuredClone(message);
|
|
1286
|
-
|
|
1287
|
-
|
|
1475
|
+
// NOTHING MAY OCCUPY THE `sending` MESSAGE'S SLOT, in either direction. It is already in Pi's stdin and
|
|
1476
|
+
// Pi's steering queue is FIFO, so it is delivered next whatever this method does - meaning a different
|
|
1477
|
+
// message landing in front of it would be a move the UI performed and the transport then ignored, and
|
|
1478
|
+
// moving the `sending` message itself off that spot would be a lie of the same kind.
|
|
1479
|
+
const sendingIndex = waiting.findIndex((item) => item.delivery === "sending");
|
|
1480
|
+
if (sendingIndex >= 0 && (from === sendingIndex || target <= sendingIndex)) {
|
|
1481
|
+
throw new Error(message.delivery === "sending"
|
|
1482
|
+
? "这条消息已经交给 Agent 了,挪不动(Pi 会先回答已经收到的那条)"
|
|
1483
|
+
: "前面那条消息已经交给 Agent 了,插不过去(Pi 按收到的顺序送达)");
|
|
1484
|
+
}
|
|
1485
|
+
// Permute THE WAITING SLOTS rather than splicing a message to an index. The waiting messages are
|
|
1486
|
+
// usually a suffix of the array, but they are not guaranteed to be one - an assistant message sits
|
|
1487
|
+
// between two of them once a steer has been delivered mid-turn - and an offset derived from "where in
|
|
1488
|
+
// the waiting group am I" would then be computing a position that means nothing. Rewriting the slots in
|
|
1489
|
+
// place is total: it cannot land a message outside the group, and it moves nothing that is not waiting.
|
|
1490
|
+
const slots = waiting.map((item) => conversation.messages.indexOf(item));
|
|
1491
|
+
const reordered = [...waiting];
|
|
1492
|
+
reordered.splice(from, 1);
|
|
1493
|
+
reordered.splice(target, 0, message);
|
|
1494
|
+
slots.forEach((slot, order) => { conversation.messages[slot] = reordered[order]; });
|
|
1495
|
+
await this.persistMessages(conversation);
|
|
1288
1496
|
return structuredClone(message);
|
|
1289
1497
|
}
|
|
1290
1498
|
/**
|
|
1291
|
-
*
|
|
1499
|
+
* Pi has just delivered one steered message: unmark it and open the answer to it, in ONE write.
|
|
1292
1500
|
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1501
|
+
* Oldest rather than matched on text, and that is forced on us by Pi rather than chosen: Pi identifies
|
|
1502
|
+
* its own queued entry by `indexOf` on the message text (`agent-session.ts:1123`), so two identical
|
|
1503
|
+
* steered messages are ONE entry to it and no host-side matching could do better. Position is sufficient
|
|
1504
|
+
* anyway because Pi's steering queue is FIFO and Super Pi only ever steers from the front of the waiting
|
|
1505
|
+
* group.
|
|
1506
|
+
*
|
|
1507
|
+
* ONE WRITE, because the reader must never see the intermediate: a message that has stopped waiting but
|
|
1508
|
+
* has no answer under it yet is the exact broken state this refactor exists to remove, and a browser
|
|
1509
|
+
* that broadcast between the two halves would put it right back on screen.
|
|
1510
|
+
*
|
|
1511
|
+
* WHAT IS DIFFERENT FROM BEFORE, and is the whole point: the message used to be DELETED here. It is now
|
|
1512
|
+
* merely unmarked, with an assistant message spliced in after it. It was on screen the whole time - it
|
|
1513
|
+
* was written into the transcript when the user pressed send, long before Pi ever saw it - and it stays
|
|
1514
|
+
* exactly where it was, now with the agent answering it. Deleting it is what made a steered reply appear
|
|
1515
|
+
* with no question above it.
|
|
1300
1516
|
*/
|
|
1301
|
-
async
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1517
|
+
async deliverWaitingMessage(conversationId, agentIdentity) {
|
|
1518
|
+
const conversation = this.requireConversation(conversationId);
|
|
1519
|
+
const index = conversation.messages.findIndex((item) => item.role === "user" && item.delivery === "sending");
|
|
1520
|
+
if (index < 0)
|
|
1521
|
+
return undefined;
|
|
1522
|
+
const user = conversation.messages[index];
|
|
1523
|
+
delete user.delivery;
|
|
1524
|
+
const assistant = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) };
|
|
1525
|
+
conversation.messages.splice(index + 1, 0, assistant);
|
|
1526
|
+
await this.persistMessages(conversation);
|
|
1527
|
+
return { user: structuredClone(user), assistant: structuredClone(assistant) };
|
|
1307
1528
|
}
|
|
1308
1529
|
/**
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1530
|
+
* Opens a turn on a message that is ALREADY IN THE TRANSCRIPT: clears its delivery state, applies every
|
|
1531
|
+
* side effect that means "a turn started here", and splices the assistant placeholder in DIRECTLY AFTER
|
|
1532
|
+
* IT.
|
|
1533
|
+
*
|
|
1534
|
+
* THE INSERT POSITION IS THE WHOLE DESIGN. `push` was the old behaviour and it is only correct when the
|
|
1535
|
+
* message being answered is the last thing in the conversation. With two messages waiting, answering
|
|
1536
|
+
* the first by appending puts its reply BELOW the second one, so the reader sees the answer to a
|
|
1537
|
+
* question that is still waiting - a transcript lying about its own order. Splicing at
|
|
1538
|
+
* `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
|
|
1539
|
+
*
|
|
1540
|
+
* The side effects - title, `updatedAt`, `status: "running"`, clearing a stale failure - all describe a
|
|
1541
|
+
* TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
|
|
1542
|
+
* waiting. They belong here, at the moment the turn genuinely begins.
|
|
1318
1543
|
*/
|
|
1319
|
-
async
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1544
|
+
async openTurnOnMessage(conversationId, messageId, agentIdentity) {
|
|
1545
|
+
const conversation = this.requireConversation(conversationId);
|
|
1546
|
+
const index = conversation.messages.findIndex((item) => item.id === messageId);
|
|
1547
|
+
if (index < 0)
|
|
1548
|
+
throw new Error(`消息不存在或已被撤回:${messageId}`);
|
|
1549
|
+
const user = conversation.messages[index];
|
|
1550
|
+
delete user.delivery;
|
|
1551
|
+
const assistant = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) };
|
|
1552
|
+
conversation.messages.splice(index + 1, 0, assistant);
|
|
1553
|
+
const firstUser = conversation.messages.find((item) => item.role === "user");
|
|
1554
|
+
const autoTitle = firstUser?.content.trim()
|
|
1555
|
+
? firstUser.content.slice(0, 42)
|
|
1556
|
+
: (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
|
|
1557
|
+
if (!conversation.titleLocked)
|
|
1558
|
+
conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
|
|
1559
|
+
conversation.updatedAt = user.createdAt;
|
|
1560
|
+
conversation.status = "running";
|
|
1561
|
+
delete conversation.error;
|
|
1562
|
+
await this.persistMessages(conversation);
|
|
1563
|
+
return { user: structuredClone(user), assistant: structuredClone(assistant) };
|
|
1331
1564
|
}
|
|
1332
1565
|
/**
|
|
1333
|
-
* Takes the
|
|
1566
|
+
* Takes the OLDEST still-`waiting` message of this conversation, clearing its delivery state, for the
|
|
1334
1567
|
* next turn to send as its prompt.
|
|
1335
1568
|
*
|
|
1336
|
-
*
|
|
1337
|
-
* every pass
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1569
|
+
* Clearing the state and taking the message are the same write on purpose: it bounds the follow-up loop
|
|
1570
|
+
* (every pass strictly shrinks the set of waiting messages, so the loop cannot run forever even if a turn
|
|
1571
|
+
* succeeds without consuming anything) and it is what makes the crash honest - a server that dies
|
|
1572
|
+
* mid-turn has already taken the message out of the waiting set, so startup does not put it back, because
|
|
1573
|
+
* it may well have been delivered.
|
|
1341
1574
|
*/
|
|
1342
|
-
async
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
if (index < 0)
|
|
1575
|
+
async takeNextWaitingMessage(conversationId) {
|
|
1576
|
+
const conversation = this.requireConversation(conversationId);
|
|
1577
|
+
const next = this.waitingMessages(conversation).find((item) => item.delivery === "waiting");
|
|
1578
|
+
if (!next)
|
|
1347
1579
|
return undefined;
|
|
1348
|
-
|
|
1349
|
-
await this.
|
|
1350
|
-
return structuredClone(
|
|
1580
|
+
delete next.delivery;
|
|
1581
|
+
await this.persistMessages(conversation);
|
|
1582
|
+
return structuredClone(next);
|
|
1351
1583
|
}
|
|
1352
|
-
/**
|
|
1353
|
-
async
|
|
1354
|
-
const
|
|
1355
|
-
if (
|
|
1356
|
-
throw new Error("
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
await this.persistCollections("queuedMessages");
|
|
1361
|
-
return structuredClone(taken);
|
|
1584
|
+
/** Takes one specific waiting message by id, so an explicit user action can start it as a new turn. */
|
|
1585
|
+
async takeWaitingMessage(conversationId, messageId) {
|
|
1586
|
+
const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
|
|
1587
|
+
if (message.delivery !== "waiting")
|
|
1588
|
+
throw new Error("当前轮还没有结束,这条消息已经交给 Agent 了(Pi 没有撤回单条队列消息的能力)");
|
|
1589
|
+
delete message.delivery;
|
|
1590
|
+
await this.persistMessages(conversation);
|
|
1591
|
+
return structuredClone(message);
|
|
1362
1592
|
}
|
|
1363
1593
|
/**
|
|
1364
|
-
*
|
|
1594
|
+
* Opens a turn for a message the user chose to be answered now, when the conversation is idle.
|
|
1365
1595
|
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
* delivered either, so putting it back is what lets the user decide what to do with it instead of
|
|
1370
|
-
* silently dropping a message they typed.
|
|
1596
|
+
* The one place that both claims a specific message and opens its turn, so there is no window between
|
|
1597
|
+
* "the message stopped waiting" and "the turn began" in which a crash would leave a message that is
|
|
1598
|
+
* neither waiting nor answered.
|
|
1371
1599
|
*/
|
|
1372
|
-
async
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
if (message.conversationId !== conversationId || message.state !== "sending")
|
|
1376
|
-
continue;
|
|
1377
|
-
message.state = "queued";
|
|
1378
|
-
changed = true;
|
|
1379
|
-
}
|
|
1380
|
-
if (changed)
|
|
1381
|
-
await this.persistCollections("queuedMessages");
|
|
1600
|
+
async openTurnOnSpecificMessage(conversationId, messageId, agentIdentity) {
|
|
1601
|
+
await this.takeWaitingMessage(conversationId, messageId);
|
|
1602
|
+
return this.openTurnOnMessage(conversationId, messageId, agentIdentity);
|
|
1382
1603
|
}
|
|
1383
|
-
// ------------------------------------------------------------- Sub-agent runs
|
|
1384
|
-
// Records, not transcripts: what lands here is the dispatch, the last assistant message and the
|
|
1385
|
-
// usage - see `SubagentRun` for why the child's own conversation deliberately does not.
|
|
1386
1604
|
/**
|
|
1387
1605
|
* Records a sub-agent run as `running`, BEFORE the child is spawned.
|
|
1388
1606
|
*
|