@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.
Files changed (33) hide show
  1. package/agents/super-pi-knowledge/index.ts +40 -0
  2. package/assets/knowledge-model/README.md +37 -0
  3. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
  4. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  5. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  6. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  7. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  8. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
  9. package/dist/.super-pi-check-inputs.json +4 -0
  10. package/dist/client/.super-pi-client-sources.json +8 -7
  11. package/dist/client/assets/index-B_jOwjyp.js +72 -0
  12. package/dist/client/assets/index-CNNGl4eF.css +1 -0
  13. package/dist/client/index.html +2 -2
  14. package/dist/server/application/run-service.d.ts +50 -11
  15. package/dist/server/application/run-service.js +206 -77
  16. package/dist/server/command-handler.js +16 -4
  17. package/dist/server/http-server.js +59 -1
  18. package/dist/server/index.js +7 -0
  19. package/dist/server/knowledge-vectors.d.ts +133 -0
  20. package/dist/server/knowledge-vectors.js +302 -0
  21. package/dist/server/knowledge.d.ts +263 -0
  22. package/dist/server/knowledge.js +743 -0
  23. package/dist/server/persistence/app-state-repository.d.ts +25 -1
  24. package/dist/server/persistence/sqlite-state-repository.js +47 -4
  25. package/dist/server/runtime.d.ts +48 -1
  26. package/dist/server/runtime.js +83 -7
  27. package/dist/server/server-context.d.ts +1 -0
  28. package/dist/server/store.d.ts +153 -68
  29. package/dist/server/store.js +405 -187
  30. package/dist/server/types.d.ts +97 -65
  31. package/package.json +163 -154
  32. package/dist/client/assets/index-DhOfFh0G.js +0 -72
  33. package/dist/client/assets/index-v2n_FYv_.css +0 -1
@@ -377,34 +377,55 @@ export class RunService {
377
377
  }
378
378
  /**
379
379
  * Handles queue snapshot updates from Pi RPC.
380
+ *
381
+ * NOW PURELY OBSERVATIONAL, and that is the fix rather than a simplification. It used to INFER a
382
+ * delivery from a drop in `steering.length` and act on it by deleting the oldest `sending` row - which is
383
+ * how a steered message could reach the model, be answered at length, and leave no trace in the
384
+ * transcript the user was reading. A count also cannot say WHICH row, so a snapshot arriving out of
385
+ * order could delete a message that had not been delivered at all.
386
+ *
387
+ * Delivery is now announced by Pi itself, through `onSteerDelivered`, and applied by `streamTurn`. What
388
+ * is left here is the one thing this event is genuinely good for: telling the browser what Pi holds, at
389
+ * the moment Pi holds it.
380
390
  */
381
391
  onPiQueue(conversationId, snapshot) {
382
392
  this.broadcaster.broadcast({ type: "pi.queue", conversationId, ...snapshot });
383
- const held = this.store.listQueuedMessages(conversationId).filter((message) => message.state === "sending").length;
384
- const delivered = held - snapshot.steering.length;
385
- if (delivered > 0) {
386
- void this.store.dropSendingMessages(conversationId, delivered).then(() => this.broadcaster.broadcastState());
387
- }
388
393
  }
389
394
  /**
390
- * Opens a turn by creating user and assistant messages in store.
395
+ * Opens a turn on a message that is ALREADY IN THE TRANSCRIPT.
396
+ *
397
+ * It creates no user message. The words were written when the user pressed send - that is the whole
398
+ * promise the client is making - so opening a turn can only mark that message as no longer waiting and
399
+ * put the answer beneath it. `store.openTurnOnMessage` does both in one write, and splices the assistant
400
+ * message in directly AFTER rather than appending it, which is what keeps a second waiting message below
401
+ * the reply to the first.
391
402
  */
392
- async openTurn(conversationId, prompt, images, attachments) {
403
+ async openTurn(conversationId, messageId) {
393
404
  const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
394
405
  if (!conversation)
395
406
  throw new Error("Conversation not found");
396
407
  const agent = await this.runtime.agentFor?.(conversation);
397
408
  const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
398
- const user = await this.store.addUserMessage(conversationId, prompt ?? "", images, attachments);
399
- const assistant = await this.store.beginAssistantMessage(conversationId, identity);
409
+ const opened = await this.store.openTurnOnMessage(conversationId, messageId, identity);
400
410
  this.broadcaster.broadcastState();
401
- return { user, assistant };
411
+ return opened;
402
412
  }
403
413
  /**
404
414
  * Streams a turn via AgentRuntime.
415
+ *
416
+ * A TURN CAN CONTAIN MORE THAN ONE ANSWER. When Pi delivers a steered message it announces the
417
+ * boundary (`message_start`), and everything streamed after it is the answer to THAT message rather than
418
+ * more of the same one. So this function can be handed a different `assistant` mid-flight: `assistant`
419
+ * is a parameter rather than a const precisely so one assignment redirects every handler below, each of
420
+ * which closes over the binding rather than over the value it saw when it was made.
421
+ *
422
+ * Before this, a delivered steer's answer was appended to the message that was already streaming. The
423
+ * model was answering words that existed in no message at all, and the transcript showed a reply with
424
+ * nothing above it - the user could not find what they had typed, while the agent quoted it back.
405
425
  */
406
- async streamTurn(conversation, workspace, prompt, assistant, controller, userMessageId, images) {
426
+ async streamTurn(conversation, workspace, prompt, assistantMessage, controller, userMessageId, images, agentIdentity) {
407
427
  this.extensionUiService.cancelPendingUi(conversation.id, "expired");
428
+ let assistant = assistantMessage;
408
429
  let toolCalls = [];
409
430
  let pendingTools = Promise.resolve();
410
431
  // Live events are independent from durable checkpoints. Persisting every token made the stdout
@@ -615,6 +636,51 @@ export class RunService {
615
636
  return;
616
637
  enqueueTools("a tool call outcome", () => this.store.settleToolCall(conversation.id, assistant.id, index, { status: call.status, durationMs: call.durationMs, ...(call.result ? { result: call.result } : {}) }));
617
638
  };
639
+ /**
640
+ * PI DELIVERED A STEERED MESSAGE. Everything it streams from here belongs to that message, not to the
641
+ * one this turn opened with.
642
+ *
643
+ * The work goes on the SAME lane as tool writes, which is what makes it safe: the delta handler above
644
+ * awaits that lane before its first line, so by the time another token is handled the reassignment
645
+ * below has already happened. Running it inline instead would put a `store` round trip in the middle
646
+ * of the stdout drain, which is precisely the latency the streaming lane exists to avoid.
647
+ *
648
+ * EVERY PER-MESSAGE COUNTER IS RESET, and each for its own reason. `spoken` and `thought` are OFFSETS
649
+ * WITHIN ONE MESSAGE - the `at` a tool call anchors at, and where a thinking block begins - so
650
+ * carrying them across would anchor this message's first tool call halfway down the previous answer.
651
+ * `rows` numbers tool blocks within a message, so it must restart at zero or the new message's first
652
+ * block is numbered after the old one's. The two maps bind rows and call ids to those numbers and are
653
+ * cleared rather than reassigned for the same reason. `thinkingBlock` continues numbering on purpose:
654
+ * it is a global fold sequence, and restarting it would make the browser render two folds numbered 0.
655
+ */
656
+ const onSteerDelivered = () => {
657
+ enqueueTools("a steer being delivered", async () => {
658
+ const previous = assistant;
659
+ // Checkpoint first: the message being left behind is complete - Pi drains steering at a turn
660
+ // boundary - and this is the last chance to write it durably with the lane it belongs to.
661
+ await this.store.checkpointMessage(conversation.id, previous.id);
662
+ const delivered = await this.store.deliverWaitingMessage(conversation.id, agentIdentity);
663
+ // NOTHING TO DELIVER MEANS NOTHING TO SPLIT. Pi may announce a user `message_start` that this
664
+ // server never handed it - the prompt that opened the turn is the obvious case - and inventing an
665
+ // assistant message for one of those would open an empty bubble before every single turn. The
666
+ // question "was anything `sending`?" is the server's own state, not something the event decides,
667
+ // which is the same reason the delivery is matched by position rather than by text.
668
+ if (!delivered)
669
+ return [];
670
+ const next = delivered.assistant;
671
+ this.broadcaster.broadcastState();
672
+ assistant = next;
673
+ spoken = 0;
674
+ thought = 0;
675
+ thinkingActive = false;
676
+ thinkingInterrupted = true;
677
+ rows = 0;
678
+ rowByToolCallId.clear();
679
+ preparingRows.clear();
680
+ toolCalls = [];
681
+ return [];
682
+ });
683
+ };
618
684
  try {
619
685
  for await (const delta of this.runtime.answer({
620
686
  conversation,
@@ -623,6 +689,7 @@ export class RunService {
623
689
  cwd: workspace.path,
624
690
  host: this.extensionUiService.createHost(conversation.id),
625
691
  onPiQueue: (snapshot) => this.onPiQueue(conversation.id, snapshot),
692
+ onSteerDelivered,
626
693
  onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
627
694
  onSessionFile: (path) => this.store.setConversationPiSessionFile(conversation.id, path),
628
695
  onToolCall,
@@ -664,12 +731,19 @@ export class RunService {
664
731
  },
665
732
  images,
666
733
  })) {
734
+ // AWAITED FIRST, BEFORE THE LIVE WRITE, because `assistant` may be reassigned by a steer
735
+ // delivery that is still in flight on this same lane. It used to be awaited after
736
+ // `appendAssistantDeltaLive`, which is harmless for tool writes - a tool write never changes
737
+ // which message text belongs to - and is NOT harmless for a split: a token would land in the
738
+ // message that just stopped being answered, in front of the reader, and then the split would
739
+ // publish a new message underneath it. One `await` at the top covers both, and costs nothing:
740
+ // the lane is empty most of the time.
741
+ await pendingTools;
667
742
  this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
668
743
  spoken += delta.length;
669
744
  // Tool events are structural transcript boundaries. Publish any boundary Pi announced before
670
745
  // this text before publishing the text itself; this is a handful of writes per call, never one
671
746
  // write per token, and preserves the event order a reconnecting client reconstructs.
672
- await pendingTools;
673
747
  this.broadcaster.broadcast({ type: "message.delta", conversationId: conversation.id, messageId: assistant.id, delta });
674
748
  scheduleCheckpoint();
675
749
  }
@@ -710,29 +784,33 @@ export class RunService {
710
784
  }
711
785
  }
712
786
  /**
713
- * Loops running turns for as long as queued follow-ups exist.
787
+ * Loops running turns for as long as waiting messages exist.
788
+ *
789
+ * The FIRST message is passed in rather than opened here, because it was written into the transcript
790
+ * before this method was called: the user pressed send, and their words were on screen in that frame. The
791
+ * loop then does the same thing for each message after it - take the next waiting one, open its turn,
792
+ * stream it - which is why the two cases are expressed identically and cannot drift apart.
714
793
  */
715
- async runConversationTurns(conversation, workspace, prompt, controller, initialImages, initialAttachments) {
794
+ async runConversationTurns(conversation, workspace, prompt, controller, firstMessageId, initialImages) {
716
795
  try {
717
- const opened = await this.openTurn(conversation.id, prompt, initialImages, initialAttachments);
718
- await this.streamTurn(conversation, workspace, prompt, opened.assistant, controller, opened.user.id, initialImages);
796
+ const agent = await this.runtime.agentFor?.(conversation);
797
+ const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
798
+ const opened = await this.openTurn(conversation.id, firstMessageId);
799
+ await this.streamTurn(conversation, workspace, prompt, opened.assistant, controller, opened.user.id, initialImages, identity);
719
800
  for (;;) {
720
801
  if (controller.signal.aborted)
721
802
  break;
722
- const next = await this.store.takeNextQueuedMessage(conversation.id);
803
+ // Taken AND opened, in that order, by two separate writes. The take is what bounds this loop -
804
+ // every pass strictly shrinks the waiting set - and it is what makes a crash honest, because a
805
+ // server that dies between the two has a message that is neither waiting nor answered, and the
806
+ // next boot leaves it alone rather than re-sending words Pi may already have seen.
807
+ const next = await this.store.takeNextWaitingMessage(conversation.id);
723
808
  if (!next)
724
809
  break;
810
+ const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
725
811
  this.broadcaster.broadcastState();
726
- let followUp;
727
- try {
728
- followUp = await this.openTurn(conversation.id, next.content, next.images, next.attachments);
729
- }
730
- catch (error) {
731
- await this.store.enqueueMessage(conversation.id, next.content, next.images, next.attachments);
732
- throw error;
733
- }
734
812
  const current = this.turnContext(conversation.id).conversation;
735
- await this.streamTurn(current, workspace, next.content, followUp.assistant, controller, followUp.user.id, next.images);
813
+ await this.streamTurn(current, workspace, next.content, followUp.assistant, controller, followUp.user.id, next.images, identity);
736
814
  }
737
815
  }
738
816
  finally {
@@ -742,22 +820,30 @@ export class RunService {
742
820
  /**
743
821
  * Enqueues or starts execution for a user message.
744
822
  */
745
- async enqueueAndRun(conversationId, content, images, attachments) {
746
- // The run itself must not occupy the mutation lane: a second send needs to observe the active run
747
- // and enqueue immediately while the first model turn is still streaming.
823
+ async enqueueAndRun(conversationId, content, images, attachments, clientId) {
748
824
  this.memory?.cancel(conversationId);
749
825
  const trimmed = content.trim();
750
826
  const validImages = Array.isArray(images) && images.length > 0 ? images : undefined;
751
827
  if (!trimmed && !validImages?.length)
752
828
  return;
753
829
  const { conversation, workspace } = this.turnContext(conversationId);
830
+ // THE MESSAGE IS WRITTEN FIRST, ALWAYS, AND THE RUN GUARD IS ASKED SECOND.
831
+ //
832
+ // This ordering is the fix for a bug that was not a slip but a consequence: the words used to be
833
+ // written by `openTurn`, at the moment the turn started, so a send into a running turn had nowhere to
834
+ // put them until a turn began. The browser was therefore told to draw nothing on a send-while-busy,
835
+ // and the composer emptied into silence for as long as the round trip took - a state the reader reads
836
+ // as "it did not send", not as "it is queued".
837
+ //
838
+ // Written first, the message is in the transcript in the same frame, whether it starts a turn or
839
+ // waits for one, and the guard below only decides WHICH. It also means the client-supplied `clientId`
840
+ // is attached to a real message either way, so there is nothing to reconcile afterwards.
841
+ const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, delivery: "waiting" });
842
+ this.broadcaster.broadcastState();
754
843
  // Claim the run slot synchronously (no await between has() and set()) so two
755
844
  // rapid sends can never both pass the guard and start concurrent turns.
756
- if (this.activeRuns.has(conversation.id)) {
757
- await this.store.enqueueMessage(conversation.id, trimmed, validImages, attachments);
758
- this.broadcaster.broadcastState();
845
+ if (this.activeRuns.has(conversation.id))
759
846
  return;
760
- }
761
847
  const controller = new AbortController();
762
848
  this.activeRuns.set(conversation.id, controller);
763
849
  try {
@@ -770,7 +856,7 @@ export class RunService {
770
856
  }
771
857
  }
772
858
  const expanded = await expandAtReferences(this.store, workspace, trimmed);
773
- await this.runConversationTurns(conversation, workspace, expanded, controller, validImages, attachments);
859
+ await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
774
860
  await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
775
861
  }
776
862
  catch (error) {
@@ -786,11 +872,11 @@ export class RunService {
786
872
  /**
787
873
  * Steers a queued message into the in-flight turn.
788
874
  */
789
- async steer(conversationId, queueId) {
790
- return this.serializeConversation(conversationId, () => this.steerUnsafe(conversationId, queueId));
875
+ async steer(conversationId, messageId) {
876
+ return this.serializeConversation(conversationId, () => this.steerUnsafe(conversationId, messageId));
791
877
  }
792
- /** Starts a chosen saved message only when the conversation is idle. */
793
- async sendQueued(conversationId, queueId) {
878
+ /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
879
+ async sendQueued(conversationId, messageId) {
794
880
  const waitForStop = async () => {
795
881
  // A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
796
882
  // short cleanup window; a click on the newly visible button must not race the old run slot.
@@ -803,64 +889,107 @@ export class RunService {
803
889
  return this.serializeConversation(conversationId, async () => {
804
890
  if (this.activeRuns.has(conversationId))
805
891
  throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
806
- const queued = await this.store.takeQueuedMessage(conversationId, queueId);
807
- const userMessageCount = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.filter((item) => item.role === "user").length ?? 0;
808
- // Claim the run immediately, then release the command lane. Waiting for the whole model turn here
809
- // would block every later queue action from this socket until the answer completes.
810
- void this.enqueueAndRun(conversationId, queued.content, queued.images).catch(async (error) => {
811
- // Once the turn has a user message it is already in the transcript. Requeue only a preflight
812
- // failure, or the same prompt would be shown as both sent and waiting to be sent again.
813
- const sent = (this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.filter((item) => item.role === "user").length ?? 0) > userMessageCount;
814
- if (!sent)
815
- await this.store.enqueueMessage(conversationId, queued.content, queued.images);
892
+ // Claim the run slot BEFORE releasing the command lane, then start the turn from the message that
893
+ // is already in the transcript. It is not re-sent through `enqueueAndRun`, which would write a
894
+ // SECOND copy of these words: the whole point of this action is that the user is sending a message
895
+ // they can already see sitting in their conversation.
896
+ const controller = new AbortController();
897
+ this.activeRuns.set(conversationId, controller);
898
+ try {
899
+ const { conversation, workspace } = this.turnContext(conversationId);
900
+ const agent = await this.runtime.agentFor?.(conversation);
901
+ const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
902
+ const opened = await this.store.openTurnOnSpecificMessage(conversationId, messageId, identity);
816
903
  this.broadcaster.broadcastState();
817
- console.warn(`Super Pi: explicit queued send failed: ${error instanceof Error ? error.message : String(error)}`);
818
- });
819
- this.broadcaster.broadcastState();
904
+ await this.runConversationTurns(conversation, workspace, opened.user.content, controller, opened.user.id, opened.user.images);
905
+ await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
906
+ }
907
+ catch (error) {
908
+ await this.store.setStatus(conversationId, "failed");
909
+ // A preflight failure must put the words BACK where the user can see them. Re-adding them would
910
+ // duplicate the message that is still in the transcript, so this only re-marks the one that is
911
+ // there - which is what makes "the send failed, your message is still waiting" a true statement.
912
+ await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
913
+ throw error;
914
+ }
915
+ finally {
916
+ this.activeRuns.delete(conversationId);
917
+ this.extensionUiService.cancelPendingUi(conversationId, "expired");
918
+ this.broadcaster.broadcastState();
919
+ }
820
920
  });
821
921
  }
822
- async steerUnsafe(conversationId, queueId) {
823
- if (!this.runtime.steer) {
824
- throw new Error("当前运行时没有插队能力,无法把排队消息插到正在运行的这轮里");
922
+ async steerUnsafe(conversationId, messageId) {
923
+ // MOVED TO THE FRONT FIRST, ALWAYS - AND THE MOVE IS BEST-EFFORT.
924
+ //
925
+ // The move is what makes "插到最前" mean what it says rather than leaving a flag nobody can explain.
926
+ // But the move CAN fail, and the case where it fails is a real one a person causes on purpose: two
927
+ // steers in a row. The first message is already in Pi's stdin, nothing can be placed in front of it,
928
+ // and the second click means "and this one too". Refusing it would break a capability the old
929
+ // implementation had and would leave the person with a message that cannot be sent at all until the
930
+ // turn ends - which is the thing they were trying to avoid by clicking twice.
931
+ //
932
+ // So the refusal is swallowed HERE and only here: being second in Pi's own FIFO queue is still a jump
933
+ // past everything else that is merely waiting, which is what the second click was asking for. The
934
+ // `message.reorder` command does not swallow it, because a button that says 「插到最前」 and silently
935
+ // did not move anything is the promise this refactor exists to stop making.
936
+ try {
937
+ await this.store.reorderWaitingMessage(conversationId, messageId, 0);
825
938
  }
939
+ catch {
940
+ // Nothing to do: the claim below is what actually delivers it, and Pi takes steers in order.
941
+ }
942
+ this.broadcaster.broadcastState();
943
+ if (!this.runtime.steer)
944
+ return;
826
945
  // A running conversation may be in the tiny hand-off window after Pi ended but before this service
827
- // opens the next queued turn. Priority delivery has the same user meaning in both cases, so reserve
828
- // the message for the next turn instead of turning a successful click into an implementation error.
829
- if (this.runtime.canSteer && !this.runtime.canSteer(conversationId)) {
830
- await this.store.prioritizeQueuedMessage(conversationId, queueId);
831
- this.broadcaster.broadcastState();
946
+ // opens the next waiting message. The message is at the front of the waiting set, so there is nothing
947
+ // left to promise and nothing to report as an error - the click did what it was for either way.
948
+ if (this.runtime.canSteer && !this.runtime.canSteer(conversationId))
832
949
  return;
833
- }
834
- const claimed = await this.store.claimQueuedMessage(conversationId, queueId);
950
+ const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
835
951
  this.broadcaster.broadcastState();
836
952
  try {
837
- const disposition = await this.runtime.steer(conversationId, claimed.content, claimed.images);
838
- if (disposition === "handled") {
839
- await this.store.dropSendingMessages(conversationId, 1);
840
- }
953
+ await this.runtime.steer(conversationId, claimed.content, claimed.images);
841
954
  }
842
955
  catch (error) {
843
- if (error instanceof SteerUnavailableError) {
844
- await this.store.releaseQueuedMessage(claimed.id);
845
- await this.store.prioritizeQueuedMessage(conversationId, claimed.id);
846
- this.broadcaster.broadcastState();
956
+ // Back to waiting in every failure case, including the "Pi is gone" one. It is still at the front of
957
+ // whatever is waiting, which is the most this could promise once the transport has refused.
958
+ await this.store.releaseWaitingMessage(conversationId, claimed.id);
959
+ this.broadcaster.broadcastState();
960
+ if (error instanceof SteerUnavailableError)
847
961
  return;
848
- }
849
- await this.store.releaseQueuedMessage(claimed.id);
850
962
  throw error;
851
963
  }
852
- this.broadcaster.broadcastState();
853
964
  }
854
965
  /**
855
- * Cancels a queued message.
966
+ * Withdraws a waiting message.
856
967
  */
857
- async cancelQueued(conversationId, queueId) {
858
- return this.serializeConversation(conversationId, () => this.cancelQueuedUnsafe(conversationId, queueId));
968
+ async cancelQueued(conversationId, messageId) {
969
+ return this.serializeConversation(conversationId, () => this.cancelQueuedUnsafe(conversationId, messageId));
859
970
  }
860
- async cancelQueuedUnsafe(conversationId, queueId) {
861
- await this.store.cancelQueuedMessage(conversationId, queueId);
971
+ async cancelQueuedUnsafe(conversationId, messageId) {
972
+ await this.store.cancelWaitingMessage(conversationId, messageId);
862
973
  this.broadcaster.broadcastState();
863
974
  }
975
+ /**
976
+ * Rewrites a waiting message, in place, where the user can see it change.
977
+ */
978
+ async editQueued(conversationId, messageId, content) {
979
+ return this.serializeConversation(conversationId, async () => {
980
+ await this.store.editWaitingMessage(conversationId, messageId, content);
981
+ this.broadcaster.broadcastState();
982
+ });
983
+ }
984
+ /**
985
+ * Moves a waiting message within the waiting set.
986
+ */
987
+ async reorderQueued(conversationId, messageId, toIndex) {
988
+ return this.serializeConversation(conversationId, async () => {
989
+ await this.store.reorderWaitingMessage(conversationId, messageId, toIndex);
990
+ this.broadcaster.broadcastState();
991
+ });
992
+ }
864
993
  /**
865
994
  * Dispatches a subagent from client command (human dispatch).
866
995
  */
@@ -158,19 +158,31 @@ export class CommandHandler {
158
158
  if (this.conversationService.isReverting(command.conversationId)) {
159
159
  throw new Error("正在撤回上一条消息,Pi 的历史还没截断完成,请等撤回结束后再发送。");
160
160
  }
161
- await this.runService.enqueueAndRun(command.conversationId, command.content, command.images);
161
+ // `attachments` was dropped here while the send path grew it, so a message carrying a skill
162
+ // attachment lost it on arrival - the browser drew the attachment, the transcript never had it, and
163
+ // the model was handed the bare prompt. Forwarded now because this is the one line that carries
164
+ // every field of the send.
165
+ await this.runService.enqueueAndRun(command.conversationId, command.content, command.images, command.attachments, command.clientId);
162
166
  return;
163
167
  }
164
168
  if (command.type === "message.steer") {
165
- await this.runService.steer(command.conversationId, command.queueId);
169
+ await this.runService.steer(command.conversationId, command.messageId);
166
170
  return;
167
171
  }
168
172
  if (command.type === "message.sendQueued") {
169
- await this.runService.sendQueued(command.conversationId, command.queueId);
173
+ await this.runService.sendQueued(command.conversationId, command.messageId);
170
174
  return;
171
175
  }
172
176
  if (command.type === "message.cancel") {
173
- await this.runService.cancelQueued(command.conversationId, command.queueId);
177
+ await this.runService.cancelQueued(command.conversationId, command.messageId);
178
+ return;
179
+ }
180
+ if (command.type === "message.edit") {
181
+ await this.runService.editQueued(command.conversationId, command.messageId, command.content);
182
+ return;
183
+ }
184
+ if (command.type === "message.reorder") {
185
+ await this.runService.reorderQueued(command.conversationId, command.messageId, command.toIndex);
174
186
  return;
175
187
  }
176
188
  if (command.type === "message.revert") {
@@ -9,6 +9,7 @@ import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
9
  import { checkDiskVersion } from "./version.js";
10
10
  import { gitDiff, gitLog, gitStatus } from "./git.js";
11
11
  import { verifyCapability } from "./capability.js";
12
+ import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
12
13
  import { packageUpdateArgs } from "./update.js";
13
14
  const MIME_TYPES = {
14
15
  ".css": "text/css; charset=utf-8",
@@ -164,6 +165,20 @@ export function parseServerArgs(argv) {
164
165
  export function isAuthorized(request, accessToken) {
165
166
  return !accessToken || request.headers.authorization === `Bearer ${accessToken}`;
166
167
  }
168
+ /**
169
+ * A signal that fires when the caller goes away, so work started on its behalf can stop.
170
+ *
171
+ * WHY IT EXISTS HERE. A knowledge search may spawn a short-lived model call to re-rank its own
172
+ * candidates. If the agent aborted the turn that made the search, that work has no consumer left, and
173
+ * without this it would run to its timeout inside a server that is already shutting the turn down.
174
+ */
175
+ function abortedAs(request) {
176
+ const controller = new AbortController();
177
+ request.once("aborted", () => controller.abort());
178
+ request.once("close", () => { if (!request.readableEnded)
179
+ controller.abort(); });
180
+ return controller.signal;
181
+ }
167
182
  /**
168
183
  * Creates the HTTP server providing static client assets and all `/api/*` endpoints.
169
184
  */
@@ -179,7 +194,12 @@ export function createHttpServer(options) {
179
194
  // the registry have no such second channel, so they are NOT exempted here and fall through to the
180
195
  // ordinary token check below - exempting the whole path is what let any local process list and
181
196
  // delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
182
- if (requestPath !== "/api/memory/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
197
+ //
198
+ // `/api/knowledge/tool` is the same shape as `/api/memory/tool` and carries the same exemption for
199
+ // the same reason: it is reached only by the Pi process of one turn, carrying that turn's
200
+ // capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
201
+ // imports and deletes documents, so it stays behind the ordinary token.
202
+ if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
183
203
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
184
204
  return;
185
205
  }
@@ -409,6 +429,44 @@ export function createHttpServer(options) {
409
429
  }
410
430
  return;
411
431
  }
432
+ if (url.pathname === "/api/knowledge/tool" || url.pathname === "/api/knowledge") {
433
+ if (!context.knowledge) {
434
+ response.writeHead(503).end();
435
+ return;
436
+ }
437
+ if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
438
+ response.writeHead(405).end();
439
+ return;
440
+ }
441
+ const origin = request.headers.origin;
442
+ if (origin && origin !== url.origin) {
443
+ response.writeHead(403).end("Origin not allowed");
444
+ return;
445
+ }
446
+ try {
447
+ let raw = "";
448
+ let totalBytes = 0;
449
+ // A pasted document rides in the body, so the cap is the document cap - past it the request is
450
+ // refused outright rather than truncated into a document that silently lost its tail.
451
+ for await (const chunk of request) {
452
+ totalBytes += chunk.length;
453
+ if (totalBytes > MAX_KNOWLEDGE_REQUEST_BYTES) {
454
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
455
+ return;
456
+ }
457
+ raw += chunk;
458
+ }
459
+ const body = raw ? JSON.parse(raw) : {};
460
+ const result = url.pathname.endsWith("/tool")
461
+ ? await context.knowledge.tool(String(request.headers["x-knowledge-capability"] ?? ""), body, abortedAs(request))
462
+ : await context.knowledge.manage(url.searchParams.get("workspaceId") ?? "", body);
463
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
464
+ }
465
+ catch (error) {
466
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
467
+ }
468
+ return;
469
+ }
412
470
  if (url.pathname === "/api/shutdown") {
413
471
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
414
472
  response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
@@ -1,5 +1,7 @@
1
1
  import { validateConversationModel } from "./model-filter.js";
2
2
  import { ActiveMemory } from "./active-memory.js";
3
+ import { KnowledgeBase } from "./knowledge.js";
4
+ import { VectorIndex } from "./knowledge-vectors.js";
3
5
  import { existsSync } from "node:fs";
4
6
  import { mkdir } from "node:fs/promises";
5
7
  import { homedir } from "node:os";
@@ -75,6 +77,10 @@ const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
75
77
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
76
78
  if (runtime instanceof PiRpcRuntime)
77
79
  runtime.setMemory(memory);
80
+ const knowledge = new KnowledgeBase(join(dataDir, "knowledge"), store, runtime, new VectorIndex(join(dataDir, "knowledge")));
81
+ process.env.SUPER_PI_KNOWLEDGE_ENDPOINT = `http://${internalHost}:${port}/api/knowledge/tool`;
82
+ if (runtime instanceof PiRpcRuntime)
83
+ runtime.setKnowledge(knowledge);
78
84
  const extensionUiService = new ExtensionUiService(broadcaster);
79
85
  const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefined, {
80
86
  dir: initialAgentDirs,
@@ -148,6 +154,7 @@ const execService = new ExecService((event) => broadcaster.broadcast(event), (se
148
154
  runService.setExecService(execService);
149
155
  const serverContext = {
150
156
  memory,
157
+ knowledge,
151
158
  paths,
152
159
  store,
153
160
  runtime,