@evandu/super-pi 0.1.21 → 0.1.23
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/README.md +58 -1
- package/agents/super-pi-memory/index.ts +34 -0
- package/dist/client/assets/index-BKKZnCis.css +1 -0
- package/dist/client/assets/index-LWO_0omC.js +50 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/favicon.svg +10 -0
- package/dist/client/index.html +5 -2
- package/dist/server/active-memory.d.ts +118 -0
- package/dist/server/active-memory.js +214 -0
- package/dist/server/application/conversation-service.js +1 -0
- package/dist/server/application/run-service.d.ts +12 -0
- package/dist/server/application/run-service.js +48 -4
- package/dist/server/command-handler.d.ts +16 -2
- package/dist/server/command-handler.js +24 -2
- package/dist/server/files.d.ts +1 -0
- package/dist/server/files.js +77 -46
- package/dist/server/http-server.d.ts +17 -1
- package/dist/server/http-server.js +185 -20
- package/dist/server/index.js +97 -29
- package/dist/server/memory.d.ts +25 -0
- package/dist/server/memory.js +88 -0
- package/dist/server/persistence/app-state-repository.d.ts +27 -0
- package/dist/server/persistence/app-state-repository.js +1 -0
- package/dist/server/persistence/json-state-repository.d.ts +12 -0
- package/dist/server/persistence/json-state-repository.js +24 -0
- package/dist/server/persistence/open-state-store.d.ts +3 -0
- package/dist/server/persistence/open-state-store.js +35 -0
- package/dist/server/persistence/rename.d.ts +22 -0
- package/dist/server/persistence/rename.js +44 -0
- package/dist/server/persistence/sqlite-state-repository.d.ts +13 -0
- package/dist/server/persistence/sqlite-state-repository.js +114 -0
- package/dist/server/runtime.d.ts +7 -1
- package/dist/server/runtime.js +23 -10
- package/dist/server/server-context.d.ts +12 -0
- package/dist/server/store.d.ts +12 -3
- package/dist/server/store.js +55 -82
- package/dist/server/subagent-runs.d.ts +2 -0
- package/dist/server/subagent-runs.js +6 -0
- package/dist/server/subagents.d.ts +2 -0
- package/dist/server/subagents.js +11 -3
- package/dist/server/terminal.d.ts +139 -0
- package/dist/server/terminal.js +366 -0
- package/dist/server/types.d.ts +69 -2
- package/dist/server/workflow-engine.d.ts +2 -0
- package/dist/server/workflow-engine.js +6 -0
- package/dist/server/ws-server.d.ts +11 -0
- package/dist/server/ws-server.js +42 -2
- package/package.json +2 -129
- package/dist/client/assets/index-B43FWHQ6.js +0 -43
- package/dist/client/assets/index-BY__2nV9.css +0 -1
package/dist/server/runtime.js
CHANGED
|
@@ -553,6 +553,8 @@ export class PiRpcRuntime {
|
|
|
553
553
|
sessionDir;
|
|
554
554
|
agents;
|
|
555
555
|
options;
|
|
556
|
+
memory;
|
|
557
|
+
setMemory(memory) { this.memory = memory; }
|
|
556
558
|
runs = new Map();
|
|
557
559
|
/**
|
|
558
560
|
* `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
|
|
@@ -698,7 +700,7 @@ export class PiRpcRuntime {
|
|
|
698
700
|
[TASK_CAPABILITY_ENV]: mintCapability(conversationId),
|
|
699
701
|
};
|
|
700
702
|
}
|
|
701
|
-
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images }) {
|
|
703
|
+
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images, memoryReview, memorySourceConversationId }) {
|
|
702
704
|
await mkdir(this.sessionDir, { recursive: true });
|
|
703
705
|
// Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
|
|
704
706
|
// turns both into one absolute native path. The directory must exist on disk before spawn - a
|
|
@@ -711,10 +713,11 @@ export class PiRpcRuntime {
|
|
|
711
713
|
// them: its RPC command union (rpc-types.ts:20-74) has no prompt and no tools command, so there is
|
|
712
714
|
// nothing to send after the process is already running. Fetched before the spawn so a definition
|
|
713
715
|
// error fails the turn loudly instead of starting a Pi that silently ignores it.
|
|
714
|
-
const agent = await this.agentFor(conversation);
|
|
716
|
+
const agent = memoryReview ? undefined : await this.agentFor(conversation);
|
|
717
|
+
const validationId = memorySourceConversationId ?? conversation.id;
|
|
715
718
|
if (agent?.model)
|
|
716
|
-
await this.modelValidator?.(
|
|
717
|
-
await this.modelValidator?.(
|
|
719
|
+
await this.modelValidator?.(validationId, { ...agent.model, label: agent.model.modelId });
|
|
720
|
+
await this.modelValidator?.(validationId, conversation.model);
|
|
718
721
|
// Whether the `task` tool EXISTS is decided first, because that is what decides whether it belongs
|
|
719
722
|
// on the allowlist. Naming a tool Pi has not registered is meaningless, and doing it anyway would
|
|
720
723
|
// change the spawn line of a runtime that has no task tool - which is what
|
|
@@ -727,20 +730,24 @@ export class PiRpcRuntime {
|
|
|
727
730
|
// declared - broken with no error. An agent that says `task: false` still wins: `toolFlags` treats an
|
|
728
731
|
// explicit false as outranking a host extra, which is what makes "this agent does not delegate" true
|
|
729
732
|
// rather than advisory.
|
|
730
|
-
const extensionArgs = await this.taskExtensionArgs();
|
|
733
|
+
const extensionArgs = memoryReview ? [] : await this.taskExtensionArgs();
|
|
734
|
+
const memoryEnabled = this.memory ? (await this.memory.configuration()).enabled : false;
|
|
735
|
+
const memoryToken = memoryEnabled ? this.memory.issue(validationId, { review: memoryReview }) : undefined;
|
|
736
|
+
const memoryExtension = resolve(import.meta.dirname, "../../agents/super-pi-memory/index.ts");
|
|
737
|
+
const memoryArgs = memoryEnabled ? ["--extension", memoryExtension] : [];
|
|
731
738
|
// BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
|
|
732
739
|
// different direction: Pi filters its registry through `--tools`, so an agent that declares
|
|
733
740
|
// `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
|
|
734
741
|
// other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
|
|
735
742
|
// `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
|
|
736
743
|
// the other is not.
|
|
737
|
-
const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME] : [] });
|
|
744
|
+
const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : [])] });
|
|
738
745
|
// Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
|
|
739
746
|
// "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
|
|
740
747
|
if (!this.pi.found)
|
|
741
748
|
throw new Error(this.pi.reason);
|
|
742
749
|
// npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
|
|
743
|
-
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...agentArgs], {
|
|
750
|
+
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...agentArgs], {
|
|
744
751
|
cwd: childCwd,
|
|
745
752
|
stdio: ["pipe", "pipe", "pipe"],
|
|
746
753
|
shell: process.platform === "win32",
|
|
@@ -748,7 +755,11 @@ export class PiRpcRuntime {
|
|
|
748
755
|
// every turn would silently replace the child's environment with whatever THIS process has at
|
|
749
756
|
// spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
|
|
750
757
|
// but the four documented additions.
|
|
751
|
-
...(extensionArgs.length ? { env: { ...process.env, ...await this.taskExtensionEnv(conversation.id)
|
|
758
|
+
...((extensionArgs.length || memoryEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.taskExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
|
|
759
|
+
SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
|
|
760
|
+
SUPER_PI_MEMORY_TOKEN: memoryToken,
|
|
761
|
+
SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
|
|
762
|
+
} : {}) } } : {}),
|
|
752
763
|
});
|
|
753
764
|
const channel = new RpcChannel(child);
|
|
754
765
|
this.runs.set(conversation.id, { child, channel });
|
|
@@ -794,7 +805,7 @@ export class PiRpcRuntime {
|
|
|
794
805
|
if (this.modelValidator) {
|
|
795
806
|
const actual = await channel.request({ id: "policy_state", type: "get_state" }, SESSION_COMMAND_TIMEOUT_MS);
|
|
796
807
|
const model = actual.data?.model;
|
|
797
|
-
await this.modelValidator(
|
|
808
|
+
await this.modelValidator(validationId, model?.provider && model.id ? { provider: model.provider, modelId: model.id, label: model.name ?? model.id } : undefined);
|
|
798
809
|
}
|
|
799
810
|
const imagePayloads = this.options.uploadsDir && images?.length
|
|
800
811
|
? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
|
|
@@ -957,12 +968,14 @@ export class PiRpcRuntime {
|
|
|
957
968
|
// exit - a turn that answered, a turn that threw, and a turn whose child was killed - because a
|
|
958
969
|
// credential whose turn has ended is a credential with no owner.
|
|
959
970
|
releaseCapability(conversation.id);
|
|
971
|
+
if (memoryToken)
|
|
972
|
+
this.memory?.release(memoryToken);
|
|
960
973
|
// A steer still unanswered when the turn ends is REJECTED, not left pending. The drain loop above
|
|
961
974
|
// is the only reader of this child's stdout and it has stopped, so nothing would ever settle that
|
|
962
975
|
// promise: the caller would wait for a timeout to learn something this process already knows. The
|
|
963
976
|
// message itself is not lost - Pi drains steering before its loop can end, so anything unanswered
|
|
964
977
|
// at this point was in practice not delivered, and the caller returns it to its queue.
|
|
965
|
-
if (this.pendingSteers.size) {
|
|
978
|
+
if (!memoryReview && this.pendingSteers.size) {
|
|
966
979
|
for (const [id, pending] of [...this.pendingSteers]) {
|
|
967
980
|
this.pendingSteers.delete(id);
|
|
968
981
|
pending.reject(new Error(`Pi never answered the steer: the turn ended first.`));
|
|
@@ -33,6 +33,7 @@ export interface EventBroadcaster {
|
|
|
33
33
|
* Central server context containing infrastructure dependencies and application services.
|
|
34
34
|
*/
|
|
35
35
|
export interface ServerContext {
|
|
36
|
+
memory?: import("./active-memory.js").ActiveMemory;
|
|
36
37
|
paths: ServerPaths;
|
|
37
38
|
store: Store;
|
|
38
39
|
runtime: AgentRuntime;
|
|
@@ -44,4 +45,15 @@ export interface ServerContext {
|
|
|
44
45
|
runService: RunService;
|
|
45
46
|
extensionUiService: ExtensionUiService;
|
|
46
47
|
catalogService: CatalogService;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the right rail's terminal tab should exist, and why not when it should not.
|
|
50
|
+
*
|
|
51
|
+
* TWO PREDICATES AND NOT A `TerminalService` HANDLE, and that is the load-bearing part. `/api/terminal`
|
|
52
|
+
* only ever needs to answer "is it on"; handing the HTTP layer the service itself would put a
|
|
53
|
+
* `spawn` within reach of a GET request, and the terminal is the one part of this server that is not
|
|
54
|
+
* confined to a workspace. Keeping the handle on `CommandHandler` alone means the only route to a
|
|
55
|
+
* shell is a WebSocket message that `TerminalService.assertUsable` has already vetted.
|
|
56
|
+
*/
|
|
57
|
+
terminalEnabled?: () => boolean;
|
|
58
|
+
terminalDisabledReason?: () => string;
|
|
47
59
|
}
|
package/dist/server/store.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AppStateRepository } from "./persistence/app-state-repository.js";
|
|
2
|
+
import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
|
|
1
3
|
import type { WorkflowRun } from "./workflow.js";
|
|
2
4
|
import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallBlock, Workspace } from "./types.js";
|
|
3
5
|
/** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
|
|
@@ -69,10 +71,12 @@ export type RevertPlan = {
|
|
|
69
71
|
doomedRunIds: string[];
|
|
70
72
|
};
|
|
71
73
|
export declare class Store {
|
|
72
|
-
private readonly
|
|
74
|
+
private readonly repository;
|
|
73
75
|
private state;
|
|
74
76
|
private constructor();
|
|
75
77
|
static open(filePath: string): Promise<Store>;
|
|
78
|
+
/** Opens an injected repository, retaining domain reconciliation here. */
|
|
79
|
+
static openRepository(repository: AppStateRepository): Promise<Store>;
|
|
76
80
|
setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
|
|
77
81
|
snapshot(): AppState;
|
|
78
82
|
createWorkspace(name: string, path?: string): Promise<Workspace>;
|
|
@@ -476,7 +480,12 @@ export declare class Store {
|
|
|
476
480
|
*
|
|
477
481
|
* A rejected write must not poison the queue, or one failure would reject every later persist as well.
|
|
478
482
|
*/
|
|
479
|
-
|
|
483
|
+
flush(): Promise<void>;
|
|
484
|
+
/** Releases persistence resources after all accepted writes settle. */
|
|
485
|
+
close(): Promise<void>;
|
|
486
|
+
private persistTransaction;
|
|
487
|
+
private persistCollections;
|
|
488
|
+
private persistMetadata;
|
|
480
489
|
private persist;
|
|
481
490
|
}
|
|
482
|
-
export declare
|
|
491
|
+
export declare const defaultStorePath: typeof defaultJsonStatePath;
|
package/dist/server/store.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { JsonStateRepository, defaultJsonStatePath } from "./persistence/json-state-repository.js";
|
|
3
2
|
import { normalizeTodos } from "./task-tool.js";
|
|
4
3
|
function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; }
|
|
5
4
|
/** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
|
|
@@ -427,15 +426,19 @@ function reconcileTodos(state) {
|
|
|
427
426
|
return { changed: before !== fingerprint(state.todos) };
|
|
428
427
|
}
|
|
429
428
|
export class Store {
|
|
430
|
-
|
|
429
|
+
repository;
|
|
431
430
|
state;
|
|
432
|
-
constructor(
|
|
433
|
-
this.
|
|
431
|
+
constructor(repository, state) {
|
|
432
|
+
this.repository = repository;
|
|
434
433
|
this.state = state;
|
|
435
434
|
}
|
|
436
435
|
static async open(filePath) {
|
|
436
|
+
return this.openRepository(new JsonStateRepository(filePath));
|
|
437
|
+
}
|
|
438
|
+
/** Opens an injected repository, retaining domain reconciliation here. */
|
|
439
|
+
static async openRepository(repository) {
|
|
437
440
|
try {
|
|
438
|
-
const state =
|
|
441
|
+
const state = await repository.load();
|
|
439
442
|
if (!Array.isArray(state.backgroundTasks))
|
|
440
443
|
state.backgroundTasks = [];
|
|
441
444
|
// Every sweep runs before the store is constructed, so a single persist below publishes them together.
|
|
@@ -446,7 +449,7 @@ export class Store {
|
|
|
446
449
|
const interrupted = reconcileConversations(state);
|
|
447
450
|
const requeued = reconcileQueuedMessages(state);
|
|
448
451
|
const todos = reconcileTodos(state);
|
|
449
|
-
const store = new Store(
|
|
452
|
+
const store = new Store(repository, normalizeMessageTimestamps(state));
|
|
450
453
|
// Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
|
|
451
454
|
// is only rewritten by a later mutation would still read `running` to anything that opened the file
|
|
452
455
|
// in the meantime, and would be reconciled a SECOND time on the next restart.
|
|
@@ -470,7 +473,7 @@ export class Store {
|
|
|
470
473
|
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
|
|
471
474
|
throw error;
|
|
472
475
|
const createdAt = now();
|
|
473
|
-
return new Store(
|
|
476
|
+
return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
|
|
474
477
|
}
|
|
475
478
|
}
|
|
476
479
|
async setModelFilterRules(rules) {
|
|
@@ -678,7 +681,7 @@ export class Store {
|
|
|
678
681
|
if (conversation.status === "running")
|
|
679
682
|
throw new Error("运行中的会话无法归档,请先停止运行");
|
|
680
683
|
conversation.archivedAt = now();
|
|
681
|
-
await this.
|
|
684
|
+
await this.persistMetadata(conversationId);
|
|
682
685
|
}
|
|
683
686
|
/** Restoring deletes the key rather than setting it to null, so a fully restored conversation serializes
|
|
684
687
|
* byte-identically to one that was never archived. `delete` is legal here because the field is optional. */
|
|
@@ -687,7 +690,7 @@ export class Store {
|
|
|
687
690
|
if (!conversation.archivedAt)
|
|
688
691
|
return;
|
|
689
692
|
delete conversation.archivedAt;
|
|
690
|
-
await this.
|
|
693
|
+
await this.persistMetadata(conversationId);
|
|
691
694
|
}
|
|
692
695
|
/**
|
|
693
696
|
* Rename edits the title and NOTHING else. In particular `updatedAt` is deliberately not touched,
|
|
@@ -709,7 +712,7 @@ export class Store {
|
|
|
709
712
|
throw new Error("Conversation name must be 1-80 characters");
|
|
710
713
|
const conversation = this.requireConversation(conversationId);
|
|
711
714
|
conversation.title = cleanName;
|
|
712
|
-
await this.
|
|
715
|
+
await this.persistMetadata(conversationId);
|
|
713
716
|
return structuredClone(conversation);
|
|
714
717
|
}
|
|
715
718
|
async addUserMessage(conversationId, content, images) {
|
|
@@ -767,7 +770,7 @@ export class Store {
|
|
|
767
770
|
// which is the exact failure this whole note is about, one level up. scripts/session-picker.test.mjs
|
|
768
771
|
// pins the real key set instead, and re-breaks whenever a field is added.
|
|
769
772
|
conversation.model = { provider: model.provider, modelId: model.modelId, label: model.label };
|
|
770
|
-
await this.
|
|
773
|
+
await this.persistMetadata(conversationId);
|
|
771
774
|
return structuredClone(conversation);
|
|
772
775
|
}
|
|
773
776
|
/** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
|
|
@@ -777,7 +780,7 @@ export class Store {
|
|
|
777
780
|
delete conversation.thinkingLevel;
|
|
778
781
|
else
|
|
779
782
|
conversation.thinkingLevel = level;
|
|
780
|
-
await this.
|
|
783
|
+
await this.persistMetadata(conversationId);
|
|
781
784
|
return structuredClone(conversation);
|
|
782
785
|
}
|
|
783
786
|
/**
|
|
@@ -792,7 +795,7 @@ export class Store {
|
|
|
792
795
|
delete conversation.agentId;
|
|
793
796
|
else
|
|
794
797
|
conversation.agentId = agentId;
|
|
795
|
-
await this.
|
|
798
|
+
await this.persistMetadata(conversationId);
|
|
796
799
|
return structuredClone(conversation);
|
|
797
800
|
}
|
|
798
801
|
async setConversationConfiguration(conversationId, settings) {
|
|
@@ -823,8 +826,15 @@ export class Store {
|
|
|
823
826
|
return structuredClone(conversation);
|
|
824
827
|
}
|
|
825
828
|
async beginAssistantMessage(conversationId, agentIdentity) { const conversation = this.requireConversation(conversationId); const message = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) }; conversation.messages.push(message); await this.persist(); return structuredClone(message); }
|
|
826
|
-
async appendAssistantDelta(conversationId, messageId, delta) {
|
|
827
|
-
|
|
829
|
+
async appendAssistantDelta(conversationId, messageId, delta) {
|
|
830
|
+
const conversation = this.requireConversation(conversationId);
|
|
831
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
832
|
+
if (!message)
|
|
833
|
+
throw new Error("Assistant message not found");
|
|
834
|
+
message.content += delta;
|
|
835
|
+
conversation.updatedAt = now();
|
|
836
|
+
await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
|
|
837
|
+
}
|
|
828
838
|
/**
|
|
829
839
|
* Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
|
|
830
840
|
* array so the caller can broadcast it as one snapshot.
|
|
@@ -894,7 +904,7 @@ export class Store {
|
|
|
894
904
|
await this.persist();
|
|
895
905
|
return structuredClone(calls);
|
|
896
906
|
}
|
|
897
|
-
async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.
|
|
907
|
+
async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persistMetadata(conversationId); }
|
|
898
908
|
/** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
|
|
899
909
|
planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
|
|
900
910
|
/**
|
|
@@ -977,7 +987,7 @@ export class Store {
|
|
|
977
987
|
if (images && images.length > 0)
|
|
978
988
|
message.images = structuredClone(images);
|
|
979
989
|
this.state.queuedMessages.push(message);
|
|
980
|
-
await this.
|
|
990
|
+
await this.persistCollections("queuedMessages");
|
|
981
991
|
return structuredClone(message);
|
|
982
992
|
}
|
|
983
993
|
/**
|
|
@@ -1001,7 +1011,7 @@ export class Store {
|
|
|
1001
1011
|
if (this.state.queuedMessages[index].state === "sending")
|
|
1002
1012
|
throw new Error("这条消息已经交给 Agent 了,取消不掉(Pi 没有撤回单条队列消息的能力)");
|
|
1003
1013
|
const [removed] = this.state.queuedMessages.splice(index, 1);
|
|
1004
|
-
await this.
|
|
1014
|
+
await this.persistCollections("queuedMessages");
|
|
1005
1015
|
return structuredClone(removed);
|
|
1006
1016
|
}
|
|
1007
1017
|
/**
|
|
@@ -1025,7 +1035,7 @@ export class Store {
|
|
|
1025
1035
|
if (message.state === "sending")
|
|
1026
1036
|
throw new Error(`排队消息已经交给 Agent 了:${queueId}`);
|
|
1027
1037
|
message.state = "sending";
|
|
1028
|
-
await this.
|
|
1038
|
+
await this.persistCollections("queuedMessages");
|
|
1029
1039
|
return structuredClone(message);
|
|
1030
1040
|
}
|
|
1031
1041
|
/**
|
|
@@ -1044,7 +1054,7 @@ export class Store {
|
|
|
1044
1054
|
if (!message || message.state !== "sending")
|
|
1045
1055
|
return;
|
|
1046
1056
|
message.state = "queued";
|
|
1047
|
-
await this.
|
|
1057
|
+
await this.persistCollections("queuedMessages");
|
|
1048
1058
|
}
|
|
1049
1059
|
/**
|
|
1050
1060
|
* Removes the OLDEST `count` messages this conversation has in `sending`, and returns them.
|
|
@@ -1067,7 +1077,7 @@ export class Store {
|
|
|
1067
1077
|
if (!doomed.length)
|
|
1068
1078
|
return [];
|
|
1069
1079
|
this.state.queuedMessages = this.state.queuedMessages.filter((item) => !doomed.includes(item.id));
|
|
1070
|
-
await this.
|
|
1080
|
+
await this.persistCollections("queuedMessages");
|
|
1071
1081
|
return structuredClone(sending.slice(0, doomed.length));
|
|
1072
1082
|
}
|
|
1073
1083
|
/**
|
|
@@ -1085,7 +1095,7 @@ export class Store {
|
|
|
1085
1095
|
if (index < 0)
|
|
1086
1096
|
return undefined;
|
|
1087
1097
|
const [taken] = this.state.queuedMessages.splice(index, 1);
|
|
1088
|
-
await this.
|
|
1098
|
+
await this.persistCollections("queuedMessages");
|
|
1089
1099
|
return structuredClone(taken);
|
|
1090
1100
|
}
|
|
1091
1101
|
/**
|
|
@@ -1106,7 +1116,7 @@ export class Store {
|
|
|
1106
1116
|
changed = true;
|
|
1107
1117
|
}
|
|
1108
1118
|
if (changed)
|
|
1109
|
-
await this.
|
|
1119
|
+
await this.persistCollections("queuedMessages");
|
|
1110
1120
|
}
|
|
1111
1121
|
// ------------------------------------------------------------- Sub-agent runs
|
|
1112
1122
|
// Records, not transcripts: what lands here is the dispatch, the last assistant message and the
|
|
@@ -1157,7 +1167,7 @@ export class Store {
|
|
|
1157
1167
|
throw new Error(`Workflow already recorded: ${plan.id}`);
|
|
1158
1168
|
this.state.workflows.unshift(plan);
|
|
1159
1169
|
trimWorkflows(this.state.workflows);
|
|
1160
|
-
await this.
|
|
1170
|
+
await this.persistCollections("workflows");
|
|
1161
1171
|
return structuredClone(plan);
|
|
1162
1172
|
}
|
|
1163
1173
|
/**
|
|
@@ -1182,7 +1192,7 @@ export class Store {
|
|
|
1182
1192
|
throw new Error(`Workflow not found: ${stored.id}`);
|
|
1183
1193
|
this.state.workflows[index] = stored;
|
|
1184
1194
|
trimWorkflows(this.state.workflows);
|
|
1185
|
-
await this.
|
|
1195
|
+
await this.persistCollections("workflows");
|
|
1186
1196
|
return structuredClone(stored);
|
|
1187
1197
|
}
|
|
1188
1198
|
async setBackgroundTask(summary) {
|
|
@@ -1191,11 +1201,11 @@ export class Store {
|
|
|
1191
1201
|
Object.assign(existing, summary);
|
|
1192
1202
|
else
|
|
1193
1203
|
this.state.backgroundTasks.unshift(summary);
|
|
1194
|
-
await this.
|
|
1204
|
+
await this.persistCollections("backgroundTasks");
|
|
1195
1205
|
}
|
|
1196
1206
|
async removeBackgroundTask(id) {
|
|
1197
1207
|
this.state.backgroundTasks = this.state.backgroundTasks.filter((item) => item.id !== id);
|
|
1198
|
-
await this.
|
|
1208
|
+
await this.persistCollections("backgroundTasks");
|
|
1199
1209
|
}
|
|
1200
1210
|
/**
|
|
1201
1211
|
* Applies the fields a run's life can change, and nothing else.
|
|
@@ -1215,7 +1225,7 @@ export class Store {
|
|
|
1215
1225
|
if (!run)
|
|
1216
1226
|
throw new Error(`Sub-agent run not found: ${runId}`);
|
|
1217
1227
|
Object.assign(run, changes);
|
|
1218
|
-
await this.
|
|
1228
|
+
await this.persistTransaction({ kind: "subagent.replace", run: structuredClone(run) });
|
|
1219
1229
|
return structuredClone(run);
|
|
1220
1230
|
}
|
|
1221
1231
|
/**
|
|
@@ -1276,59 +1286,22 @@ export class Store {
|
|
|
1276
1286
|
*
|
|
1277
1287
|
* A rejected write must not poison the queue, or one failure would reject every later persist as well.
|
|
1278
1288
|
*/
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
await
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
this.persistQueue = write.catch(() => { });
|
|
1288
|
-
await write;
|
|
1289
|
+
async flush() { await this.repository.flush(); }
|
|
1290
|
+
/** Releases persistence resources after all accepted writes settle. */
|
|
1291
|
+
async close() { await this.flush(); this.repository.close?.(); }
|
|
1292
|
+
async persistTransaction(transaction) {
|
|
1293
|
+
if (this.repository.commit)
|
|
1294
|
+
await this.repository.commit(transaction);
|
|
1295
|
+
else
|
|
1296
|
+
await this.persist();
|
|
1289
1297
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
const RENAME_RETRY_MS = 2_000;
|
|
1297
|
-
const RENAME_RETRY_STEP_MS = 5;
|
|
1298
|
-
/**
|
|
1299
|
-
* Renames `from` over `to`, retrying the WINDOWS SHARING VIOLATION.
|
|
1300
|
-
*
|
|
1301
|
-
* WHY THIS EXISTS, because the failure it fixes was a real defect and not a test artefact. On Windows a
|
|
1302
|
-
* rename onto a file that ANY handle has open fails with EPERM/EACCES: the destination must be held with
|
|
1303
|
-
* FILE_SHARE_DELETE, and a plain reader does not do that. Node's own `readFile` is such a reader, and so
|
|
1304
|
-
* is every indexer, virus scanner and sync client on the machine. So `persist()` threw EPERM, the caller's
|
|
1305
|
-
* mutation stayed only in memory, and the save was LOST - the settings dialog had already closed on send,
|
|
1306
|
-
* so the person who pressed 保存 was told nothing and kept a default that had never been saved.
|
|
1307
|
-
* scripts/workspace-settings-browser.test.mjs reproduced it at roughly 1 save in 5, and every occurrence
|
|
1308
|
-
* read as "the save never arrived" because the refusal travelled to the browser as an error event the
|
|
1309
|
-
* suite did not read, so a refused write and a slow write were indistinguishable for a whole day.
|
|
1310
|
-
*
|
|
1311
|
-
* MEASURED, so the bound is honest rather than hopeful: renaming onto a held-open `state.json` fails on
|
|
1312
|
-
* the first attempt 40 times out of 40 and clears within one 5ms tick of the reader letting go (worst
|
|
1313
|
-
* case 11 attempts). The lock lasts exactly as long as somebody is READING, which is why a bounded
|
|
1314
|
-
* retry is the fix and not a longer sleep: the transient case is short, and a genuinely stuck file is
|
|
1315
|
-
* not a sharing violation. A file that stays contended past the bound still throws, which is the honest
|
|
1316
|
-
* outcome - the store reports a failed write instead of pretending it saved.
|
|
1317
|
-
*/
|
|
1318
|
-
async function replaceFile(from, to) {
|
|
1319
|
-
const deadline = Date.now() + RENAME_RETRY_MS;
|
|
1320
|
-
for (;;) {
|
|
1321
|
-
try {
|
|
1322
|
-
await rename(from, to);
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
catch (error) {
|
|
1326
|
-
const code = error.code;
|
|
1327
|
-
const contended = code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
|
1328
|
-
if (!contended || Date.now() >= deadline)
|
|
1329
|
-
throw error;
|
|
1330
|
-
await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_STEP_MS));
|
|
1331
|
-
}
|
|
1298
|
+
async persistCollections(key) {
|
|
1299
|
+
await this.persistTransaction({ kind: "collections.replace", values: { [key]: structuredClone(this.state[key]) } });
|
|
1300
|
+
}
|
|
1301
|
+
async persistMetadata(conversationId) {
|
|
1302
|
+
const { messages: _messages, ...metadata } = this.requireConversation(conversationId);
|
|
1303
|
+
await this.persistTransaction({ kind: "conversation.metadata", conversationId, metadata: structuredClone(metadata) });
|
|
1332
1304
|
}
|
|
1305
|
+
async persist() { await this.repository.save(this.state); }
|
|
1333
1306
|
}
|
|
1334
|
-
export
|
|
1307
|
+
export const defaultStorePath = defaultJsonStatePath;
|
|
@@ -127,6 +127,8 @@ export declare class SubagentRunRecorder {
|
|
|
127
127
|
* reconciliation cleans up the RECORD of such a run; this stops the PROCESS from being one.
|
|
128
128
|
*/
|
|
129
129
|
abortAll(): void;
|
|
130
|
+
/** Waits for every child that was live at shutdown to settle and record its outcome. */
|
|
131
|
+
waitForIdle(): Promise<void>;
|
|
130
132
|
/**
|
|
131
133
|
* Runs one sub-agent, recording it from before the spawn to after the settle, and returns the record
|
|
132
134
|
* as it ended.
|
|
@@ -99,6 +99,12 @@ export class SubagentRunRecorder {
|
|
|
99
99
|
for (const entry of this.controllers.values())
|
|
100
100
|
entry.controller.abort();
|
|
101
101
|
}
|
|
102
|
+
/** Waits for every child that was live at shutdown to settle and record its outcome. */
|
|
103
|
+
async waitForIdle() {
|
|
104
|
+
while (this.controllers.size > 0) {
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
102
108
|
/**
|
|
103
109
|
* Runs one sub-agent, recording it from before the spawn to after the settle, and returns the record
|
|
104
110
|
* as it ended.
|
|
@@ -137,6 +137,8 @@ export declare class SubagentCancelled extends Error {
|
|
|
137
137
|
export declare class SubagentSupervisor {
|
|
138
138
|
private readonly sessionDir;
|
|
139
139
|
private agents;
|
|
140
|
+
private memory?;
|
|
141
|
+
setMemory(memory: import("./active-memory.js").ActiveMemory): void;
|
|
140
142
|
private readonly pi;
|
|
141
143
|
/** Live children by run id, so a caller can name the process it means. Cleared in the run's `finally`. */
|
|
142
144
|
private readonly children;
|
package/dist/server/subagents.js
CHANGED
|
@@ -168,6 +168,8 @@ const addUsage = (total, usage) => {
|
|
|
168
168
|
export class SubagentSupervisor {
|
|
169
169
|
sessionDir;
|
|
170
170
|
agents;
|
|
171
|
+
memory;
|
|
172
|
+
setMemory(memory) { this.memory = memory; }
|
|
171
173
|
pi;
|
|
172
174
|
/** Live children by run id, so a caller can name the process it means. Cleared in the run's `finally`. */
|
|
173
175
|
children = new Map();
|
|
@@ -244,10 +246,12 @@ export class SubagentSupervisor {
|
|
|
244
246
|
const agent = await this.subagentDefinition(agentId);
|
|
245
247
|
// The child's own `tools:` map, minus whatever the dispatching agent denied. Never the parent's
|
|
246
248
|
// ALLOWLIST - see `SubagentRunInput.denyTools` for why that asymmetry is the reference's too.
|
|
247
|
-
const
|
|
249
|
+
const memoryEnabled = this.memory ? (await this.memory.configuration()).enabled : false;
|
|
250
|
+
const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { denyTools: input.denyTools ?? [], extraAllowedTools: memoryEnabled ? ["memory", "skill_manage", "session_search"] : [] });
|
|
248
251
|
const sessionDirectory = join(this.sessionDir, "subagents");
|
|
249
252
|
await mkdir(sessionDirectory, { recursive: true });
|
|
250
|
-
const
|
|
253
|
+
const memoryToken = memoryEnabled ? this.memory.issue(conversationId, { readOnly: true }) : undefined;
|
|
254
|
+
const args = ["--mode", "rpc", "--session-dir", sessionDirectory, "--session-id", id, ...(memoryToken ? ["--extension", resolve(import.meta.dirname, "../../agents/super-pi-memory/index.ts")] : []), ...agentArgs];
|
|
251
255
|
// Model inheritance: the definition wins, otherwise the parent's current model, otherwise Pi's own
|
|
252
256
|
// default. "Omit the model and get what the parent is running on" is the default worth having, and it
|
|
253
257
|
// is a spawn-time flag because there is no RPC command to change it later.
|
|
@@ -260,7 +264,9 @@ export class SubagentSupervisor {
|
|
|
260
264
|
if (agent.thinkingLevel)
|
|
261
265
|
args.push("--thinking", agent.thinkingLevel);
|
|
262
266
|
// npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
|
|
263
|
-
const child = spawn(shellQuote(this.pi.command), args, { cwd: childCwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
267
|
+
const child = spawn(shellQuote(this.pi.command), args, { cwd: childCwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", ...(memoryToken ? { env: { ...process.env, SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT, SUPER_PI_MEMORY_TOKEN: memoryToken, SUPER_PI_MEMORY_READ_ONLY: "1" } } : {}) });
|
|
268
|
+
if (memoryToken)
|
|
269
|
+
child.once("close", () => this.memory?.release(memoryToken));
|
|
264
270
|
this.children.set(id, { conversationId, child });
|
|
265
271
|
const channel = new RpcChannel(child);
|
|
266
272
|
// Pi is asked to stop first and killed only if that is not enough, so a child that can unwind its own
|
|
@@ -508,6 +514,8 @@ export class SubagentSupervisor {
|
|
|
508
514
|
return { id, sessionId: id, finalText: finalText || streamed, usage };
|
|
509
515
|
}
|
|
510
516
|
finally {
|
|
517
|
+
if (memoryToken)
|
|
518
|
+
this.memory?.release(memoryToken);
|
|
511
519
|
signal?.removeEventListener("abort", abort);
|
|
512
520
|
this.children.delete(id);
|
|
513
521
|
// The run is not over until the child is actually gone: a process still tearing down still holds a
|