@evandu/super-pi 0.1.33 → 0.1.34
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 +4 -0
- package/agents/super-pi-task/index.ts +4 -0
- package/dist/client/assets/index-BhsIL1Qa.js +50 -0
- package/dist/client/assets/index-Da9PkxxS.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/extension.js +7 -2
- package/dist/server/active-memory.d.ts +15 -0
- package/dist/server/active-memory.js +36 -1
- package/dist/server/application/conversation-service.d.ts +39 -0
- package/dist/server/application/conversation-service.js +65 -4
- package/dist/server/application/extension-ui-service.js +16 -0
- package/dist/server/application/run-service.d.ts +21 -0
- package/dist/server/application/run-service.js +130 -19
- package/dist/server/command-handler.d.ts +14 -0
- package/dist/server/command-handler.js +22 -0
- package/dist/server/git.d.ts +37 -0
- package/dist/server/git.js +30 -0
- package/dist/server/http-server.js +126 -8
- package/dist/server/index.js +7 -2
- package/dist/server/memory.d.ts +27 -0
- package/dist/server/memory.js +56 -0
- package/dist/server/persistence/json-state-repository.d.ts +2 -0
- package/dist/server/persistence/json-state-repository.js +24 -5
- package/dist/server/runtime.d.ts +4 -1
- package/dist/server/runtime.js +51 -14
- package/dist/server/store.d.ts +6 -0
- package/dist/server/store.js +46 -3
- package/dist/server/terminal.js +20 -7
- package/dist/server/types.d.ts +36 -2
- package/dist/server/upload.js +8 -19
- package/dist/server/ws-server.js +89 -28
- package/package.json +5 -3
- package/dist/client/assets/index-D6bdUvG_.css +0 -1
- package/dist/client/assets/index-dU9RhbHX.js +0 -50
package/dist/server/index.js
CHANGED
|
@@ -25,7 +25,10 @@ import { initRunningVersion } from "./version.js";
|
|
|
25
25
|
const rootDir = resolve(import.meta.dirname, "../..");
|
|
26
26
|
initRunningVersion(rootDir);
|
|
27
27
|
const cliArgs = parseServerArgs(process.argv);
|
|
28
|
-
|
|
28
|
+
// A non-numeric SUPER_PI_PORT would parse to NaN and die later in `listen` with a confusing error;
|
|
29
|
+
// like the other environment variables here, an unusable value falls back to the default port.
|
|
30
|
+
const envPort = Number.parseInt(process.env.SUPER_PI_PORT ?? "3080", 10);
|
|
31
|
+
const port = cliArgs.port ?? (Number.isNaN(envPort) ? 3080 : envPort);
|
|
29
32
|
const host = cliArgs.host ?? process.env.SUPER_PI_HOST ?? "0.0.0.0";
|
|
30
33
|
const internalHost = (host === "0.0.0.0" || host === "::" || !host) ? "127.0.0.1" : host;
|
|
31
34
|
const defaultDataDir = !rootDir.includes("node_modules") && existsSync(join(rootDir, ".super-pi"))
|
|
@@ -40,6 +43,7 @@ await mkdir(uploadsDir, { recursive: true }).catch(() => { });
|
|
|
40
43
|
const taskEndpoint = `http://${internalHost}:${port}/api/subagent/dispatch`;
|
|
41
44
|
const todoEndpoint = `http://${internalHost}:${port}/api/todos`;
|
|
42
45
|
const workflowEndpoint = `http://${internalHost}:${port}/api/workflow/run`;
|
|
46
|
+
const questionEndpoint = `http://${internalHost}:${port}/api/question`;
|
|
43
47
|
// ------------------------------------------------------------- Core singletons
|
|
44
48
|
const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
|
|
45
49
|
const initialAgentDirs = resolveAgentDirectories({
|
|
@@ -58,7 +62,7 @@ const paths = {
|
|
|
58
62
|
};
|
|
59
63
|
const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
|
|
60
64
|
? new WelcomeRuntime()
|
|
61
|
-
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, workflowEndpoint, uploadsDir });
|
|
65
|
+
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, uploadsDir });
|
|
62
66
|
const broadcaster = new WsBroadcaster(() => store.snapshot());
|
|
63
67
|
const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
|
|
64
68
|
process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
|
|
@@ -114,6 +118,7 @@ const catalogService = new CatalogService(runtime, broadcaster, initialAgentDirs
|
|
|
114
118
|
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
|
|
115
119
|
runService.setMemory(memory);
|
|
116
120
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
121
|
+
conversationService.setMemory(memory);
|
|
117
122
|
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
|
|
118
123
|
/**
|
|
119
124
|
* The terminal, constructed before `serverContext` because that object carries the two predicates
|
package/dist/server/memory.d.ts
CHANGED
|
@@ -18,6 +18,33 @@ export declare class MemoryStore {
|
|
|
18
18
|
private path;
|
|
19
19
|
private read;
|
|
20
20
|
apply(workspaceId: string, conversationId: string, operation: MemoryOperation, review?: boolean): Promise<MemoryEntry[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Drops every entry this conversation contributed at or after `sinceIso`, in all three targets.
|
|
23
|
+
*
|
|
24
|
+
* WHY THIS EXISTS. A revert rewinds the conversation - Pi's session file is truncated and the messages
|
|
25
|
+
* after the boundary are gone from `state.json` - but the memory store is a SECOND, durable copy of what
|
|
26
|
+
* those turns said, and `snapshot` below splices it into every later prompt. So a person who withdrew a
|
|
27
|
+
* message had not actually withdrawn it: the turn was gone from the transcript and the app went on
|
|
28
|
+
* handing its contents to the model. Measured rather than argued: with `SUPER_PI_MEMORY_ENABLED=0` the
|
|
29
|
+
* same revert test passes, and with memory on the model still answered with a codeword from a turn the
|
|
30
|
+
* user had withdrawn.
|
|
31
|
+
*
|
|
32
|
+
* WHY A TIMESTAMP IS THE RIGHT CUT. Entries carry a `conversationId` and a `createdAt`, and reverting to
|
|
33
|
+
* a message removes EVERY message after it - so any entry this conversation created at or after the first
|
|
34
|
+
* removed message can only have come from content that no longer exists. The predicate therefore cannot
|
|
35
|
+
* delete a memory that is still true of the conversation, which is what makes deleting on a timestamp
|
|
36
|
+
* acceptable at all.
|
|
37
|
+
*
|
|
38
|
+
* WHAT IT COSTS, STATED PLAINLY. An entry does not record which message taught it, so an entry learned in
|
|
39
|
+
* a removed turn goes even when it states something that would still be true - a user's name first
|
|
40
|
+
* mentioned in the withdrawn turn. Narrowing that to provenance means stamping every write with its source
|
|
41
|
+
* and reworking consolidation: a larger change than the defect warrants, and not taken here. The
|
|
42
|
+
* alternative is the behaviour this exists to stop.
|
|
43
|
+
*
|
|
44
|
+
* Returns the dropped ids per target so the caller can unlink the mirrored skill files; the JSON alone
|
|
45
|
+
* would leave a skill on disk that no manifest advertises.
|
|
46
|
+
*/
|
|
47
|
+
forget(workspaceId: string, conversationId: string, sinceIso: string): Promise<Record<MemoryTarget, string[]>>;
|
|
21
48
|
snapshot(workspaceId: string): Promise<string>;
|
|
22
49
|
}
|
|
23
50
|
/** A separate review session must never reuse or truncate the user's Pi session. */
|
package/dist/server/memory.js
CHANGED
|
@@ -72,6 +72,62 @@ export class MemoryStore {
|
|
|
72
72
|
locks.delete(path);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Drops every entry this conversation contributed at or after `sinceIso`, in all three targets.
|
|
77
|
+
*
|
|
78
|
+
* WHY THIS EXISTS. A revert rewinds the conversation - Pi's session file is truncated and the messages
|
|
79
|
+
* after the boundary are gone from `state.json` - but the memory store is a SECOND, durable copy of what
|
|
80
|
+
* those turns said, and `snapshot` below splices it into every later prompt. So a person who withdrew a
|
|
81
|
+
* message had not actually withdrawn it: the turn was gone from the transcript and the app went on
|
|
82
|
+
* handing its contents to the model. Measured rather than argued: with `SUPER_PI_MEMORY_ENABLED=0` the
|
|
83
|
+
* same revert test passes, and with memory on the model still answered with a codeword from a turn the
|
|
84
|
+
* user had withdrawn.
|
|
85
|
+
*
|
|
86
|
+
* WHY A TIMESTAMP IS THE RIGHT CUT. Entries carry a `conversationId` and a `createdAt`, and reverting to
|
|
87
|
+
* a message removes EVERY message after it - so any entry this conversation created at or after the first
|
|
88
|
+
* removed message can only have come from content that no longer exists. The predicate therefore cannot
|
|
89
|
+
* delete a memory that is still true of the conversation, which is what makes deleting on a timestamp
|
|
90
|
+
* acceptable at all.
|
|
91
|
+
*
|
|
92
|
+
* WHAT IT COSTS, STATED PLAINLY. An entry does not record which message taught it, so an entry learned in
|
|
93
|
+
* a removed turn goes even when it states something that would still be true - a user's name first
|
|
94
|
+
* mentioned in the withdrawn turn. Narrowing that to provenance means stamping every write with its source
|
|
95
|
+
* and reworking consolidation: a larger change than the defect warrants, and not taken here. The
|
|
96
|
+
* alternative is the behaviour this exists to stop.
|
|
97
|
+
*
|
|
98
|
+
* Returns the dropped ids per target so the caller can unlink the mirrored skill files; the JSON alone
|
|
99
|
+
* would leave a skill on disk that no manifest advertises.
|
|
100
|
+
*/
|
|
101
|
+
async forget(workspaceId, conversationId, sinceIso) {
|
|
102
|
+
const dropped = { user: [], memory: [], skills: [] };
|
|
103
|
+
const since = Date.parse(sinceIso);
|
|
104
|
+
if (!Number.isFinite(since))
|
|
105
|
+
throw new Error("记忆回滚需要一条可解析的时间戳");
|
|
106
|
+
for (const target of Object.keys(limits)) {
|
|
107
|
+
const path = this.path(workspaceId, target);
|
|
108
|
+
const task = (locks.get(path) ?? Promise.resolve()).catch(() => { }).then(async () => {
|
|
109
|
+
const entries = await this.read(path);
|
|
110
|
+
const kept = entries.filter((entry) => !(entry.conversationId === conversationId && Date.parse(entry.createdAt) >= since));
|
|
111
|
+
dropped[target] = entries.filter((entry) => !kept.includes(entry)).map((entry) => entry.id);
|
|
112
|
+
if (kept.length === entries.length)
|
|
113
|
+
return entries;
|
|
114
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
115
|
+
await mkdir(this.directory, { recursive: true });
|
|
116
|
+
await writeFile(temporary, JSON.stringify(kept, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
117
|
+
await rename(temporary, path);
|
|
118
|
+
return kept;
|
|
119
|
+
});
|
|
120
|
+
locks.set(path, task);
|
|
121
|
+
try {
|
|
122
|
+
await task;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
if (locks.get(path) === task)
|
|
126
|
+
locks.delete(path);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return dropped;
|
|
130
|
+
}
|
|
75
131
|
async snapshot(workspaceId) {
|
|
76
132
|
const blocks = await Promise.all(Object.keys(limits).map(async (target) => {
|
|
77
133
|
const entries = await this.apply(workspaceId, "", { target, action: "list" });
|
|
@@ -4,6 +4,8 @@ import type { AppStateRepository } from "./app-state-repository.js";
|
|
|
4
4
|
export declare class JsonStateRepository implements AppStateRepository {
|
|
5
5
|
readonly filePath: string;
|
|
6
6
|
private writeQueue;
|
|
7
|
+
/** The error of the most recent FAILED write, cleared by the next successful one. Read by `flush`. */
|
|
8
|
+
private lastWriteError;
|
|
7
9
|
constructor(filePath: string);
|
|
8
10
|
load(): Promise<AppState>;
|
|
9
11
|
save(state: AppState): Promise<void>;
|
|
@@ -5,20 +5,39 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
export class JsonStateRepository {
|
|
6
6
|
filePath;
|
|
7
7
|
writeQueue = Promise.resolve();
|
|
8
|
+
/** The error of the most recent FAILED write, cleared by the next successful one. Read by `flush`. */
|
|
9
|
+
lastWriteError;
|
|
8
10
|
constructor(filePath) {
|
|
9
11
|
this.filePath = filePath;
|
|
10
12
|
}
|
|
11
13
|
async load() { return JSON.parse(await readFile(this.filePath, "utf8")); }
|
|
12
14
|
async save(state) {
|
|
13
15
|
const write = this.writeQueue.then(async () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
18
|
+
const temporary = `${this.filePath}.tmp`;
|
|
19
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
20
|
+
await replaceFile(temporary, this.filePath);
|
|
21
|
+
this.lastWriteError = undefined;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
this.lastWriteError = error;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
18
27
|
});
|
|
28
|
+
// The queue keeps swallowing: one failed write must not poison every write queued behind it.
|
|
29
|
+
// The failure is remembered above, and `flush` is what reports it.
|
|
19
30
|
this.writeQueue = write.catch(() => { });
|
|
20
31
|
await write;
|
|
21
32
|
}
|
|
22
|
-
async flush() {
|
|
33
|
+
async flush() {
|
|
34
|
+
await this.writeQueue;
|
|
35
|
+
if (this.lastWriteError !== undefined) {
|
|
36
|
+
const error = this.lastWriteError;
|
|
37
|
+
this.lastWriteError = undefined;
|
|
38
|
+
console.error(`Super Pi: the last state write to ${this.filePath} failed (${error instanceof Error ? error.message : String(error)}) - the state on disk may be older than the state in memory.`);
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
23
42
|
}
|
|
24
43
|
export const defaultJsonStatePath = (dataDir) => join(dataDir, "state.json");
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface AnswerInput {
|
|
|
96
96
|
* replacement text, and one that shows the call arriving.
|
|
97
97
|
*/
|
|
98
98
|
onToolPreparing?(call: ToolCallPreparing): void;
|
|
99
|
+
/** Called while the primary model is emitting reasoning, so the UI can show live progress. */
|
|
100
|
+
onThinking?(delta: string): void;
|
|
99
101
|
}
|
|
100
102
|
/**
|
|
101
103
|
* What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
|
|
@@ -662,6 +664,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
662
664
|
endpoint?: string;
|
|
663
665
|
todoEndpoint?: string;
|
|
664
666
|
workflowEndpoint?: string;
|
|
667
|
+
questionEndpoint?: string;
|
|
665
668
|
uploadsDir?: string;
|
|
666
669
|
});
|
|
667
670
|
/**
|
|
@@ -703,7 +706,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
703
706
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
704
707
|
*/
|
|
705
708
|
private taskExtensionEnv;
|
|
706
|
-
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
|
|
709
|
+
answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
|
|
707
710
|
/**
|
|
708
711
|
* Reads back the id of the user message this turn just created, and hands it to the caller.
|
|
709
712
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
4
5
|
import { resolve, join } from "node:path";
|
|
@@ -343,6 +344,11 @@ export async function truncateSessionFile(path, boundaryEntryId, expectedSession
|
|
|
343
344
|
const expectedLastId = beforeBoundary ? parsed[boundary - 1]?.id : parsed[boundary]?.id;
|
|
344
345
|
if (beforeBoundary)
|
|
345
346
|
boundary -= 1;
|
|
347
|
+
// Fail closed: a boundary of -1 here means "revert to before the FIRST entry", which would write an
|
|
348
|
+
// empty session file. The verification below would pass vacuously (0 !== 0 is false), so the refusal
|
|
349
|
+
// must happen here rather than be trusted to a check that cannot fire.
|
|
350
|
+
if (boundary < 0)
|
|
351
|
+
throw new Error(`回退点在第一条消息之前,截断会得到一个空会话文件,拒绝回退:${path}`);
|
|
346
352
|
// The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
|
|
347
353
|
// being KEPT must point at something also being kept. A prefix that violates this is not a session,
|
|
348
354
|
// and writing it would produce a file Pi either rejects or silently misreads.
|
|
@@ -371,7 +377,7 @@ export async function truncateSessionFile(path, boundaryEntryId, expectedSession
|
|
|
371
377
|
catch {
|
|
372
378
|
lastEntry = undefined;
|
|
373
379
|
}
|
|
374
|
-
if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId) {
|
|
380
|
+
if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId || boundary + 1 < 1) {
|
|
375
381
|
throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
|
|
376
382
|
}
|
|
377
383
|
return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
|
|
@@ -703,6 +709,7 @@ export class PiRpcRuntime {
|
|
|
703
709
|
[TASK_DEPTH_ENV]: "0",
|
|
704
710
|
[TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
|
|
705
711
|
[WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
|
|
712
|
+
SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
|
|
706
713
|
// MINTED HERE, AT SPAWN, rather than read from anywhere: this is the only moment the value is
|
|
707
714
|
// decided, so it cannot change underneath a running child. `SubagentSupervisor` never calls this
|
|
708
715
|
// function, so no sub-agent process is ever handed a capability - which is what makes a sub-agent
|
|
@@ -710,7 +717,7 @@ export class PiRpcRuntime {
|
|
|
710
717
|
[TASK_CAPABILITY_ENV]: mintCapability(conversationId),
|
|
711
718
|
};
|
|
712
719
|
}
|
|
713
|
-
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, images, memoryReview, memorySourceConversationId }) {
|
|
720
|
+
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
|
|
714
721
|
await mkdir(this.sessionDir, { recursive: true });
|
|
715
722
|
// Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
|
|
716
723
|
// turns both into one absolute native path. The directory must exist on disk before spawn - a
|
|
@@ -751,7 +758,7 @@ export class PiRpcRuntime {
|
|
|
751
758
|
// other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
|
|
752
759
|
// `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
|
|
753
760
|
// the other is not.
|
|
754
|
-
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"] : [])] });
|
|
761
|
+
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, "question"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : [])] });
|
|
755
762
|
// Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
|
|
756
763
|
// "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
|
|
757
764
|
if (!this.pi.found)
|
|
@@ -773,8 +780,16 @@ export class PiRpcRuntime {
|
|
|
773
780
|
});
|
|
774
781
|
const channel = new RpcChannel(child);
|
|
775
782
|
this.runs.set(conversation.id, { child, channel });
|
|
776
|
-
|
|
777
|
-
|
|
783
|
+
// Same guard as subagents.ts: a destroyed stdin throws synchronously on write, and an abort
|
|
784
|
+
// listener that throws inside a listener would surface as an unhandled error instead of a cancel.
|
|
785
|
+
const abort = () => {
|
|
786
|
+
if (child.exitCode === null && !child.killed && !child.stdin.destroyed) {
|
|
787
|
+
try {
|
|
788
|
+
child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`);
|
|
789
|
+
}
|
|
790
|
+
catch { /* the child died between the check and the write; the abort is moot */ }
|
|
791
|
+
}
|
|
792
|
+
};
|
|
778
793
|
signal.addEventListener("abort", abort, { once: true });
|
|
779
794
|
// A blocking request is awaited inside the drain loop, so the loop cannot observe an abort or a
|
|
780
795
|
// dead child on its own. Both are raced into the wait so a pending question can never outlive the run.
|
|
@@ -1006,6 +1021,8 @@ export class PiRpcRuntime {
|
|
|
1006
1021
|
const detail = describeToolCallInProgress(name, argumentText.get(event.contentIndex) ?? "");
|
|
1007
1022
|
onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(delta ? { delta } : {}), ...(detail ? { detail } : {}) });
|
|
1008
1023
|
}
|
|
1024
|
+
else if (event?.type === "thinking_delta")
|
|
1025
|
+
onThinking?.(typeof event.delta === "string" ? event.delta : "");
|
|
1009
1026
|
else
|
|
1010
1027
|
noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
|
|
1011
1028
|
continue;
|
|
@@ -1335,8 +1352,14 @@ export class PiRpcRuntime {
|
|
|
1335
1352
|
console.warn(`PiRpcRuntime: extension_ui_request method=${JSON.stringify(record.method ?? null)} id=${JSON.stringify(record.id ?? null)} is not one of Pi's nine Extension UI methods - consumed without reply.`);
|
|
1336
1353
|
return;
|
|
1337
1354
|
}
|
|
1338
|
-
const reply = (response) => {
|
|
1339
|
-
|
|
1355
|
+
const reply = (response) => {
|
|
1356
|
+
if (child.exitCode === null && !child.killed && !child.stdin.destroyed) {
|
|
1357
|
+
try {
|
|
1358
|
+
child.stdin.write(`${JSON.stringify(response)}\n`);
|
|
1359
|
+
}
|
|
1360
|
+
catch { /* the child died between the check and the write; the reply is moot */ }
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1340
1363
|
if (!request.blocking) {
|
|
1341
1364
|
// Deliberately not awaited: these five are fire-and-forget in Pi, and awaiting would stall the
|
|
1342
1365
|
// drain loop on a UI that already told us it wants no reply.
|
|
@@ -1372,8 +1395,18 @@ export class PiRpcRuntime {
|
|
|
1372
1395
|
}
|
|
1373
1396
|
reply(buildExtensionUiResponse(request, answer));
|
|
1374
1397
|
}
|
|
1375
|
-
async stop(conversationId) {
|
|
1376
|
-
run.
|
|
1398
|
+
async stop(conversationId) {
|
|
1399
|
+
const run = this.runs.get(conversationId);
|
|
1400
|
+
if (!run)
|
|
1401
|
+
return;
|
|
1402
|
+
// Same guard as the abort listener in `answer`: a destroyed stdin throws synchronously on write.
|
|
1403
|
+
if (run.child.exitCode === null && !run.child.killed && !run.child.stdin.destroyed) {
|
|
1404
|
+
try {
|
|
1405
|
+
run.child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`);
|
|
1406
|
+
}
|
|
1407
|
+
catch { /* the child died between the check and the write; the abort is moot */ }
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1377
1410
|
/**
|
|
1378
1411
|
* Injects one message into the Pi process that is running right now, at its next turn boundary.
|
|
1379
1412
|
*
|
|
@@ -1404,10 +1437,12 @@ export class PiRpcRuntime {
|
|
|
1404
1437
|
if (!this.pi.found)
|
|
1405
1438
|
throw new Error(this.pi.reason);
|
|
1406
1439
|
const TIMEOUT_MS = 30_000;
|
|
1407
|
-
|
|
1440
|
+
// Session names live in a narrow sidebar row. Keep the model's budget well below the storage
|
|
1441
|
+
// limit so the result remains a concise label instead of becoming a sentence.
|
|
1442
|
+
const MAX_TITLE_CHARS = 18;
|
|
1408
1443
|
// A throwaway session id, not the conversation's: this child reads nothing else and its history must
|
|
1409
1444
|
// not be filed under the conversation whose title it is producing.
|
|
1410
|
-
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `autotitle-${
|
|
1445
|
+
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `autotitle-${randomUUID()}`], {
|
|
1411
1446
|
cwd: resolve(cwd),
|
|
1412
1447
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1413
1448
|
shell: process.platform === "win32",
|
|
@@ -1427,7 +1462,7 @@ export class PiRpcRuntime {
|
|
|
1427
1462
|
message: [
|
|
1428
1463
|
"为下面这段对话起一个简短的标题,用作会话列表里的一行字。",
|
|
1429
1464
|
"只输出标题本身:不要引号,不要句末标点,不要「标题:」之类的前缀,不要解释,不要换行。",
|
|
1430
|
-
|
|
1465
|
+
`只生成一个简短的会话标签,长度严格不超过 ${MAX_TITLE_CHARS} 个字符(中文按一个字计数)。优先控制在 8-12 个字符;不要写成完整句子,不要使用副标题、解释、列表或多个候选标题。用与对话内容相同的语言。`,
|
|
1431
1466
|
"",
|
|
1432
1467
|
transcript,
|
|
1433
1468
|
].join("\n"),
|
|
@@ -1460,8 +1495,10 @@ export class PiRpcRuntime {
|
|
|
1460
1495
|
.replace(/[。.!!,,、;;\s]+$/u, "")
|
|
1461
1496
|
.trim()
|
|
1462
1497
|
.slice(0, MAX_TITLE_CHARS);
|
|
1463
|
-
if (!title)
|
|
1464
|
-
|
|
1498
|
+
if (!title) {
|
|
1499
|
+
const fallback = conversation.messages.find((message) => message.role === "user" && message.content.trim())?.content.trim();
|
|
1500
|
+
return (fallback || "新会话").slice(0, MAX_TITLE_CHARS);
|
|
1501
|
+
}
|
|
1465
1502
|
return title;
|
|
1466
1503
|
}
|
|
1467
1504
|
finally {
|
package/dist/server/store.d.ts
CHANGED
|
@@ -232,6 +232,12 @@ export declare class Store {
|
|
|
232
232
|
* instead of as a field it does not understand.
|
|
233
233
|
*/
|
|
234
234
|
appendToolCall(conversationId: string, messageId: string, call: ToolCallBlock): Promise<ToolCallBlock[]>;
|
|
235
|
+
appendQuestion(conversationId: string, messageId: string, question: Omit<import("./types.js").QuestionBlock, "id" | "status">): Promise<import("./types.js").QuestionBlock>;
|
|
236
|
+
answerQuestion(conversationId: string, messageId: string, questionId: string, answer: {
|
|
237
|
+
selected?: string[];
|
|
238
|
+
extraNote?: string;
|
|
239
|
+
cancelled?: boolean;
|
|
240
|
+
}): Promise<import("./types.js").QuestionBlock[]>;
|
|
235
241
|
/**
|
|
236
242
|
* Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
|
|
237
243
|
* index.
|
package/dist/server/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rename } from "node:fs/promises";
|
|
1
2
|
import { JsonStateRepository, defaultJsonStatePath } from "./persistence/json-state-repository.js";
|
|
2
3
|
import { normalizeTodos } from "./task-tool.js";
|
|
3
4
|
import { TOOL_ARG_CAP } from "./tool-calls.js";
|
|
@@ -481,8 +482,23 @@ export class Store {
|
|
|
481
482
|
return store;
|
|
482
483
|
}
|
|
483
484
|
catch (error) {
|
|
484
|
-
|
|
485
|
-
|
|
485
|
+
const code = error instanceof Error ? error.code : undefined;
|
|
486
|
+
if (code !== "ENOENT") {
|
|
487
|
+
// A file-backed JSON state that cannot be parsed (or read) must not crash startup: the bad file is
|
|
488
|
+
// moved aside so a later run can still inspect it, and the server starts from a fresh state.
|
|
489
|
+
// Repositories without a file behind them keep failing loudly - there is nothing to quarantine.
|
|
490
|
+
if (!(repository instanceof JsonStateRepository))
|
|
491
|
+
throw error;
|
|
492
|
+
const quarantine = `${repository.filePath}.corrupt-${Date.now()}`;
|
|
493
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
494
|
+
try {
|
|
495
|
+
await rename(repository.filePath, quarantine);
|
|
496
|
+
console.warn(`Super Pi: state file ${repository.filePath} could not be loaded (${reason}) - it was moved to ${quarantine} and the server starts with a fresh state.`);
|
|
497
|
+
}
|
|
498
|
+
catch (moveError) {
|
|
499
|
+
console.warn(`Super Pi: state file ${repository.filePath} could not be loaded (${reason}) and could not be moved aside either (${moveError instanceof Error ? moveError.message : String(moveError)}) - the server starts with a fresh state; the unreadable file is still at ${repository.filePath}.`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
486
502
|
const createdAt = now();
|
|
487
503
|
return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
|
|
488
504
|
}
|
|
@@ -723,6 +739,7 @@ export class Store {
|
|
|
723
739
|
throw new Error("Conversation name must be 1-80 characters");
|
|
724
740
|
const conversation = this.requireConversation(conversationId);
|
|
725
741
|
conversation.title = cleanName;
|
|
742
|
+
conversation.titleLocked = true;
|
|
726
743
|
await this.persistMetadata(conversationId);
|
|
727
744
|
return structuredClone(conversation);
|
|
728
745
|
}
|
|
@@ -741,7 +758,8 @@ export class Store {
|
|
|
741
758
|
const autoTitle = firstUser?.content.trim()
|
|
742
759
|
? firstUser.content.slice(0, 42)
|
|
743
760
|
: (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
|
|
744
|
-
conversation.
|
|
761
|
+
if (!conversation.titleLocked)
|
|
762
|
+
conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
|
|
745
763
|
conversation.updatedAt = message.createdAt;
|
|
746
764
|
conversation.status = "running";
|
|
747
765
|
// The reason this conversation was failed - startup reconciliation's note that a previous run was cut
|
|
@@ -881,6 +899,31 @@ export class Store {
|
|
|
881
899
|
await this.persist();
|
|
882
900
|
return structuredClone(calls);
|
|
883
901
|
}
|
|
902
|
+
async appendQuestion(conversationId, messageId, question) {
|
|
903
|
+
const message = this.requireConversation(conversationId).messages.find((item) => item.id === messageId);
|
|
904
|
+
if (!message)
|
|
905
|
+
throw new Error("Assistant message not found");
|
|
906
|
+
const block = { ...structuredClone(question), id: id("question"), status: "pending" };
|
|
907
|
+
(message.questionBlocks ??= []).push(block);
|
|
908
|
+
await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: now() });
|
|
909
|
+
return structuredClone(block);
|
|
910
|
+
}
|
|
911
|
+
async answerQuestion(conversationId, messageId, questionId, answer) {
|
|
912
|
+
const conversation = this.requireConversation(conversationId);
|
|
913
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
914
|
+
if (!message?.questionBlocks)
|
|
915
|
+
throw new Error("Question not found");
|
|
916
|
+
const block = message.questionBlocks.find((item) => item.id === questionId);
|
|
917
|
+
if (!block || block.status !== "pending")
|
|
918
|
+
throw new Error("Question is no longer pending");
|
|
919
|
+
block.status = answer.cancelled ? "cancelled" : "answered";
|
|
920
|
+
if (!answer.cancelled) {
|
|
921
|
+
block.selected = [...(answer.selected ?? [])];
|
|
922
|
+
block.extraNote = answer.extraNote ?? "";
|
|
923
|
+
}
|
|
924
|
+
await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: now() });
|
|
925
|
+
return structuredClone(message.questionBlocks);
|
|
926
|
+
}
|
|
884
927
|
/**
|
|
885
928
|
* Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
|
|
886
929
|
* index.
|
package/dist/server/terminal.js
CHANGED
|
@@ -156,8 +156,10 @@ const MAX_ROWS = 200;
|
|
|
156
156
|
* event loop. Chunks are queued and flushed on a timer, with a byte ceiling per frame so one flush
|
|
157
157
|
* cannot build a megabyte-long string.
|
|
158
158
|
*
|
|
159
|
-
* The ceiling
|
|
160
|
-
*
|
|
159
|
+
* The per-frame ceiling never DISCARDS data: a flush larger than MAX_CHUNK_BYTES sends its first
|
|
160
|
+
* MAX_CHUNK_BYTES and puts the remainder back at the FRONT of the queue - it is older than anything
|
|
161
|
+
* enqueued since - with the flush timer re-armed, so the split lands at the START of the next flush
|
|
162
|
+
* rather than mid-escape, where it could cut a partial ANSI escape sequence.
|
|
161
163
|
*/
|
|
162
164
|
const FLUSH_INTERVAL_MS = 16;
|
|
163
165
|
const MAX_CHUNK_BYTES = 64 * 1024;
|
|
@@ -257,7 +259,11 @@ export class TerminalService {
|
|
|
257
259
|
this.sessions.set(command.sessionId, session);
|
|
258
260
|
child.onData((data) => this.enqueue(session, command.sessionId, data, send));
|
|
259
261
|
child.onExit(({ exitCode }) => {
|
|
260
|
-
|
|
262
|
+
// Drain everything still queued before reporting the exit: each flush sends at most
|
|
263
|
+
// MAX_CHUNK_BYTES and re-queues the remainder, so a large final burst takes several
|
|
264
|
+
// passes. Without the loop, the exit would cut off output the shell already produced.
|
|
265
|
+
while (session.pending)
|
|
266
|
+
this.flush(session, command.sessionId, send);
|
|
261
267
|
this.sessions.delete(command.sessionId);
|
|
262
268
|
// A killed shell on Windows reports 0xC0000139 (STATUS_ENTRYPOINT_NOT_FOUND) because that is how
|
|
263
269
|
// ConPTY tears down, not because a command failed. Reporting it verbatim would tell the user a
|
|
@@ -265,8 +271,6 @@ export class TerminalService {
|
|
|
265
271
|
if (session.closed && session.killed)
|
|
266
272
|
return;
|
|
267
273
|
session.closed = true;
|
|
268
|
-
if (session.timer)
|
|
269
|
-
clearTimeout(session.timer);
|
|
270
274
|
send({ type: "terminal.exit", sessionId: command.sessionId, exitCode: session.killed ? 0 : exitCode });
|
|
271
275
|
});
|
|
272
276
|
return;
|
|
@@ -319,12 +323,21 @@ export class TerminalService {
|
|
|
319
323
|
}
|
|
320
324
|
let data = session.pending;
|
|
321
325
|
session.pending = "";
|
|
322
|
-
if (data.length > MAX_CHUNK_BYTES)
|
|
326
|
+
if (data.length > MAX_CHUNK_BYTES) {
|
|
327
|
+
// The ceiling bounds ONE FRAME, not the queue: the remainder is older data, so it goes back to
|
|
328
|
+
// the FRONT of the pending buffer and the timer is re-armed so the next flush sends it. Dropping
|
|
329
|
+
// it here would silently lose terminal output - the exact corruption the queue exists to avoid.
|
|
330
|
+
session.pending = data.slice(MAX_CHUNK_BYTES) + session.pending;
|
|
323
331
|
data = data.slice(0, MAX_CHUNK_BYTES);
|
|
332
|
+
session.timer = setTimeout(() => this.flush(session, sessionId, send), FLUSH_INTERVAL_MS);
|
|
333
|
+
}
|
|
324
334
|
send({ type: "terminal.output", sessionId, data });
|
|
325
335
|
}
|
|
326
336
|
kill(sessionId, session, send) {
|
|
327
|
-
|
|
337
|
+
// Drain the backlog before teardown so closing a terminal does not discard output the shell
|
|
338
|
+
// already produced; each flush sends at most MAX_CHUNK_BYTES per frame.
|
|
339
|
+
while (session.pending)
|
|
340
|
+
this.flush(session, sessionId, send);
|
|
328
341
|
session.closed = true;
|
|
329
342
|
session.killed = true;
|
|
330
343
|
if (session.timer) {
|
package/dist/server/types.d.ts
CHANGED
|
@@ -55,6 +55,15 @@ export type MessageImage = {
|
|
|
55
55
|
width?: number;
|
|
56
56
|
height?: number;
|
|
57
57
|
};
|
|
58
|
+
export type QuestionBlock = {
|
|
59
|
+
id: string;
|
|
60
|
+
question: string;
|
|
61
|
+
options: string[];
|
|
62
|
+
multiple: boolean;
|
|
63
|
+
status: "pending" | "answered" | "cancelled";
|
|
64
|
+
selected?: string[];
|
|
65
|
+
extraNote?: string;
|
|
66
|
+
};
|
|
58
67
|
export type Message = {
|
|
59
68
|
id: string;
|
|
60
69
|
role: MessageRole;
|
|
@@ -62,6 +71,8 @@ export type Message = {
|
|
|
62
71
|
createdAt: string;
|
|
63
72
|
piEntryId?: string;
|
|
64
73
|
toolCalls?: ToolCallBlock[];
|
|
74
|
+
questionBlocks?: QuestionBlock[];
|
|
75
|
+
thinking?: string;
|
|
65
76
|
images?: MessageImage[];
|
|
66
77
|
agentIdentity?: {
|
|
67
78
|
id?: string;
|
|
@@ -250,6 +261,8 @@ export type Conversation = {
|
|
|
250
261
|
status: RunStatus;
|
|
251
262
|
messages: Message[];
|
|
252
263
|
archivedAt?: string;
|
|
264
|
+
/** Set when the user explicitly names the session; automatic first-message titles must not overwrite it. */
|
|
265
|
+
titleLocked?: boolean;
|
|
253
266
|
/**
|
|
254
267
|
* Why this conversation is not `running`, in WORDS A PERSON READS - the same role `SubagentRun.error`
|
|
255
268
|
* plays for a card, one level up.
|
|
@@ -678,7 +691,15 @@ export type ExtensionUiAnswer = {
|
|
|
678
691
|
export type ClientCommand = ClientCommandBody & {
|
|
679
692
|
operationRequestId?: string;
|
|
680
693
|
};
|
|
681
|
-
export type ClientCommandBody =
|
|
694
|
+
export type ClientCommandBody = {
|
|
695
|
+
type: "question.answer";
|
|
696
|
+
conversationId: string;
|
|
697
|
+
messageId: string;
|
|
698
|
+
questionId: string;
|
|
699
|
+
selected?: string[];
|
|
700
|
+
extraNote?: string;
|
|
701
|
+
cancelled?: boolean;
|
|
702
|
+
}
|
|
682
703
|
/**
|
|
683
704
|
* THE TERMINAL, and the reason `terminal.open` CARRIES NO PATH.
|
|
684
705
|
*
|
|
@@ -689,7 +710,7 @@ export type ClientCommandBody =
|
|
|
689
710
|
* the user already configured and a shell scoped to the whole disk, and it is structural rather than a
|
|
690
711
|
* check that a later edit could forget.
|
|
691
712
|
*/
|
|
692
|
-
{
|
|
713
|
+
| {
|
|
693
714
|
type: "terminal.open";
|
|
694
715
|
sessionId: string;
|
|
695
716
|
workspaceId: string;
|
|
@@ -948,6 +969,8 @@ export type ServerVersionInfo = {
|
|
|
948
969
|
runningVersion: string;
|
|
949
970
|
diskVersion: string;
|
|
950
971
|
needsRestart: boolean;
|
|
972
|
+
latestVersion?: string;
|
|
973
|
+
updateAvailable?: boolean;
|
|
951
974
|
};
|
|
952
975
|
export type ServerEvent = {
|
|
953
976
|
type: "operation.result";
|
|
@@ -966,6 +989,12 @@ export type ServerEvent = {
|
|
|
966
989
|
conversationId: string;
|
|
967
990
|
messageId: string;
|
|
968
991
|
delta: string;
|
|
992
|
+
} | {
|
|
993
|
+
type: "message.thinking";
|
|
994
|
+
conversationId: string;
|
|
995
|
+
messageId: string;
|
|
996
|
+
delta: string;
|
|
997
|
+
active: boolean;
|
|
969
998
|
}
|
|
970
999
|
/**
|
|
971
1000
|
* THE ANSWER TO `conversation.fork`, as ONE event with an `ok` discriminant rather than two events or a
|
|
@@ -1021,6 +1050,11 @@ export type ServerEvent = {
|
|
|
1021
1050
|
conversationId: string;
|
|
1022
1051
|
messageId: string;
|
|
1023
1052
|
toolCalls: ToolCallBlock[];
|
|
1053
|
+
} | {
|
|
1054
|
+
type: "message.questions";
|
|
1055
|
+
conversationId: string;
|
|
1056
|
+
messageId: string;
|
|
1057
|
+
questionBlocks: QuestionBlock[];
|
|
1024
1058
|
} | {
|
|
1025
1059
|
type: "error";
|
|
1026
1060
|
message: string;
|