@evandu/super-pi 0.1.138 → 0.1.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/super-pi-knowledge/index.ts +11 -13
- package/agents/super-pi-memory/index.ts +11 -5
- package/agents/super-pi-task/core.ts +418 -1
- package/agents/super-pi-task/index.ts +115 -0
- package/bin/super-pi.js +7 -11
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -6
- package/dist/client/assets/{CodeMirrorEditor-CVImD0nh.js → CodeMirrorEditor-DvZeOLny.js} +2 -2
- package/dist/client/assets/{dist-CiEW7LQW.js → dist-3BuyVG3H.js} +1 -1
- package/dist/client/assets/{dist-faxwvvOU.js → dist-BCUfH4nP.js} +1 -1
- package/dist/client/assets/{dist-yONALz0V.js → dist-BIIMtU9W.js} +1 -1
- package/dist/client/assets/{dist-CKI1xVeE.js → dist-C-QmRwbd.js} +1 -1
- package/dist/client/assets/{dist-4uPxkRdw.js → dist-CTkzLBdb.js} +1 -1
- package/dist/client/assets/{dist-CUACXe1Y.js → dist-C_VuSpx8.js} +1 -1
- package/dist/client/assets/{dist-BwQM8_Mk.js → dist-CaUsvSz1.js} +1 -1
- package/dist/client/assets/{dist-Clf26yld.js → dist-Cb1gxS57.js} +1 -1
- package/dist/client/assets/{dist-YB4GJD3u.js → dist-CqDSeoUk.js} +1 -1
- package/dist/client/assets/{dist-DozjvPAZ.js → dist-D7yfnKNC.js} +1 -1
- package/dist/client/assets/{dist-Cocw5XJ_.js → dist-DA8yW4lK.js} +1 -1
- package/dist/client/assets/{dist-amySbADh.js → dist-DAcS4Kj8.js} +1 -1
- package/dist/client/assets/{dist-B75OT6A9.js → dist-DN0M4DAX.js} +1 -1
- package/dist/client/assets/{dist-At94VK1P.js → dist-DRgWZqFH.js} +1 -1
- package/dist/client/assets/{dist-BIgRwYsI.js → dist-DdfW8Gjv.js} +1 -1
- package/dist/client/assets/{dist-DhEa5hHR.js → dist-DlQ8PMvv.js} +1 -1
- package/dist/client/assets/{dist-quQ6u6Rw.js → dist-Dvca0LeR.js} +1 -1
- package/dist/client/assets/{dist-BqblSnWL.js → dist-HJdP1yxN.js} +1 -1
- package/dist/client/assets/{dist-pnGHl3nq.js → dist-gBAr9yXF.js} +1 -1
- package/dist/client/assets/{dist-1H_gIENV.js → dist-gyzurUDR.js} +1 -1
- package/dist/client/assets/{dist-DSCjkO-y.js → dist-qG53hT1b.js} +1 -1
- package/dist/client/assets/{dist-CNP-Q5Gs.js → dist-r4Kq1ABA.js} +1 -1
- package/dist/client/assets/{index-CNoiRdJC.js → index-CNbh1fL2.js} +14 -14
- package/dist/client/assets/index-DCSFD4VV.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +13 -4
- package/dist/server/application/conversation-service.js +112 -61
- package/dist/server/application/run-service.d.ts +13 -0
- package/dist/server/application/run-service.js +255 -69
- package/dist/server/application/scheduled-task-service.js +8 -1
- package/dist/server/application/session-orchestrator-service.d.ts +113 -0
- package/dist/server/application/session-orchestrator-service.js +234 -0
- package/dist/server/application/workspace-service.js +16 -1
- package/dist/server/feishu-cli.js +15 -4
- package/dist/server/file-index.d.ts +30 -0
- package/dist/server/file-index.js +49 -4
- package/dist/server/files.d.ts +21 -0
- package/dist/server/files.js +38 -0
- package/dist/server/http-server.js +1580 -1402
- package/dist/server/index.js +23 -6
- package/dist/server/logging.js +56 -3
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +1 -0
- package/dist/server/pi-session.d.ts +118 -0
- package/dist/server/pi-session.js +132 -10
- package/dist/server/runtime.d.ts +48 -0
- package/dist/server/runtime.js +189 -86
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/session-ledger.d.ts +60 -0
- package/dist/server/session-ledger.js +76 -0
- package/dist/server/store.d.ts +44 -1
- package/dist/server/store.js +114 -12
- package/dist/server/subagent-runs.d.ts +31 -1
- package/dist/server/subagent-runs.js +48 -20
- package/dist/server/subagents.js +7 -0
- package/dist/server/task-tool.d.ts +18 -0
- package/dist/server/task-tool.js +85 -0
- package/dist/server/tool-runner.js +13 -4
- package/dist/server/types.d.ts +50 -0
- package/dist/server/ws-server.d.ts +86 -6
- package/dist/server/ws-server.js +291 -51
- package/package.json +16 -2
- package/scripts/diagnose.mjs +161 -58
- package/dist/client/assets/index-FxTrUapG.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -24,6 +24,8 @@ import { RunService } from "./application/run-service.js";
|
|
|
24
24
|
import { FileChangeTracker } from "./file-changes.js";
|
|
25
25
|
import { ConversationService } from "./application/conversation-service.js";
|
|
26
26
|
import { ScheduledTaskService } from "./application/scheduled-task-service.js";
|
|
27
|
+
import { SessionLedger } from "./session-ledger.js";
|
|
28
|
+
import { SessionOrchestratorService } from "./application/session-orchestrator-service.js";
|
|
27
29
|
import { WorkspaceService } from "./application/workspace-service.js";
|
|
28
30
|
import { CommandHandler } from "./command-handler.js";
|
|
29
31
|
import { TerminalService, terminalDisabledReason } from "./terminal.js";
|
|
@@ -114,6 +116,7 @@ const workflowEndpoint = `http://${internalHost}:${port}/api/workflow/run`;
|
|
|
114
116
|
const questionEndpoint = `http://${internalHost}:${port}/api/question`;
|
|
115
117
|
const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
|
|
116
118
|
const documentsEndpoint = `http://${internalHost}:${port}/api/documents/tool`;
|
|
119
|
+
const orchestratorEndpoint = `http://${internalHost}:${port}/api/orchestrator`;
|
|
117
120
|
// ------------------------------------------------------------- Core singletons
|
|
118
121
|
const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
|
|
119
122
|
const toolRegistry = new ToolRegistry(dataDir);
|
|
@@ -136,11 +139,10 @@ const paths = {
|
|
|
136
139
|
};
|
|
137
140
|
const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
|
|
138
141
|
? new WelcomeRuntime()
|
|
139
|
-
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, uploadsDir });
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
const broadcaster = new WsBroadcaster(() => store.clientState());
|
|
142
|
+
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, orchestratorEndpoint, uploadsDir });
|
|
143
|
+
// Closed workspaces stay in the store for reopening. Focused sockets receive all visible sidebar
|
|
144
|
+
// summaries and only their selected transcript; legacy sockets retain the complete visible state.
|
|
145
|
+
const broadcaster = new WsBroadcaster((scope) => store.clientState(scope));
|
|
144
146
|
const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
|
|
145
147
|
process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
|
|
146
148
|
if (runtime instanceof PiRpcRuntime)
|
|
@@ -174,13 +176,24 @@ supervisor.setMemory(memory);
|
|
|
174
176
|
const validateModel = (conversationId, selection) => validateConversationModel(store, runtime, conversationId, selection);
|
|
175
177
|
runtime.setModelValidator?.(validateModel);
|
|
176
178
|
supervisor.setModelValidator(validateModel);
|
|
179
|
+
/**
|
|
180
|
+
* DIAGNOSTIC ONLY - reproduces the pre-fix flood wire-for-wire (a full `state` broadcast per
|
|
181
|
+
* `subagent.event`, uncapped live output) so probes and regression tests can A/B the fix. Never set
|
|
182
|
+
* outside a probe; see docs/SUBAGENT-WS-FLOOD-FIX-2026-10-29.md.
|
|
183
|
+
*/
|
|
184
|
+
const legacySubagentFlood = process.env.SUPER_PI_LEGACY_SUBAGENT_FLOOD === "1";
|
|
177
185
|
const subagents = new SubagentRunRecorder(supervisor, {
|
|
178
186
|
store,
|
|
179
187
|
broadcast: (event) => {
|
|
180
188
|
broadcaster.broadcast(event);
|
|
181
|
-
if (event.type === "subagent.event")
|
|
189
|
+
if (legacySubagentFlood && event.type === "subagent.event")
|
|
182
190
|
broadcaster.broadcastState();
|
|
183
191
|
},
|
|
192
|
+
// The full state broadcast moved from "every delta" to "on settle": a browser that reloads mid-run
|
|
193
|
+
// rebuilds the card from the store (written per delta); the settle frame is what puts the FINAL
|
|
194
|
+
// record into `state` so a reload after the finish sees it too.
|
|
195
|
+
onSettled: legacySubagentFlood ? undefined : () => broadcaster.broadcastState(),
|
|
196
|
+
liveTailCap: legacySubagentFlood ? Number.POSITIVE_INFINITY : undefined,
|
|
184
197
|
host: (conversationId, runId) => extensionUiService.createHost(conversationId, runId),
|
|
185
198
|
});
|
|
186
199
|
/**
|
|
@@ -276,6 +289,8 @@ runService.setExecService(execService);
|
|
|
276
289
|
* message takes.
|
|
277
290
|
*/
|
|
278
291
|
const scheduledTaskService = new ScheduledTaskService(store, runService, conversationService, broadcaster);
|
|
292
|
+
const sessionLedger = new SessionLedger();
|
|
293
|
+
const sessionOrchestratorService = new SessionOrchestratorService(store, conversationService, runService, sessionLedger, broadcaster);
|
|
279
294
|
const serverContext = {
|
|
280
295
|
memory,
|
|
281
296
|
knowledge,
|
|
@@ -295,6 +310,8 @@ const serverContext = {
|
|
|
295
310
|
catalogService,
|
|
296
311
|
providerService,
|
|
297
312
|
scheduledTaskService,
|
|
313
|
+
sessionLedger,
|
|
314
|
+
sessionOrchestratorService,
|
|
298
315
|
execService,
|
|
299
316
|
toolRegistry,
|
|
300
317
|
toolRunner,
|
package/dist/server/logging.js
CHANGED
|
@@ -69,11 +69,22 @@ export function installServerLogging(dataDir) {
|
|
|
69
69
|
* server cannot even write "I died". This file answers the other half of the question: it is
|
|
70
70
|
* touched every minute while the server lives, so its timestamp says "the server was alive until
|
|
71
71
|
* this moment" - which is how a silent death gets a time on it.
|
|
72
|
+
*
|
|
73
|
+
* IT ALSO CARRIES THE MEMORY FINGERPRINT, because "the server died without a word" has two very
|
|
74
|
+
* different causes and they need different answers. A server killed by the operating system's OOM
|
|
75
|
+
* killer leaves a log that ends mid-sentence and nothing else - the kernel does not run JavaScript,
|
|
76
|
+
* and the message Node prints about a heap it could not grow goes to a standard error stream this
|
|
77
|
+
* process was started without. The last heartbeat before the silence is the only record of how much
|
|
78
|
+
* memory the process was holding when it went, so it says: the resident size, the heap in use and
|
|
79
|
+
* the heap that had been reserved. A death at 2 GB of resident size is a memory story; a death at
|
|
80
|
+
* 90 MB is something else.
|
|
72
81
|
*/
|
|
73
82
|
const heartbeatFile = join(logDir, "heartbeat.txt");
|
|
74
83
|
const touch = () => {
|
|
75
84
|
try {
|
|
76
|
-
|
|
85
|
+
const memory = process.memoryUsage();
|
|
86
|
+
const mb = (bytes) => Math.round(bytes / 1_048_576);
|
|
87
|
+
writeFileSync(heartbeatFile, `${Date.now()} rss=${mb(memory.rss)}MB heapUsed=${mb(memory.heapUsed)}MB heapTotal=${mb(memory.heapTotal)}MB external=${mb(memory.external)}MB\n`, "utf8");
|
|
77
88
|
}
|
|
78
89
|
catch { /* best effort */ }
|
|
79
90
|
};
|
|
@@ -114,8 +125,21 @@ export function installServerLogging(dataDir) {
|
|
|
114
125
|
write("fatal", `uncaughtException: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
115
126
|
process.exit(1);
|
|
116
127
|
});
|
|
128
|
+
/**
|
|
129
|
+
* AN UNHANDLED REJECTION IS NOT FATAL, AND THE WORD MATTERS.
|
|
130
|
+
*
|
|
131
|
+
* Node hands an unhandled rejection to this hook and KEEPS RUNNING - the process does not die here.
|
|
132
|
+
* It was logged as `fatal` anyway, which made every one of them read like a crash report. One user
|
|
133
|
+
* report of "Super Pi crashed" turned out to be exactly this: a promise nobody was awaiting,
|
|
134
|
+
* refusing a path, once per agent write - and the log's `fatal` line is what made it look like the
|
|
135
|
+
* server had died.
|
|
136
|
+
*
|
|
137
|
+
* The line stays loud, because an unhandled rejection IS a bug - a promise that rejected with nobody
|
|
138
|
+
* listening - but it now says what actually happened, so the next reader is not sent looking for a
|
|
139
|
+
* dead process that is still alive.
|
|
140
|
+
*/
|
|
117
141
|
process.on("unhandledRejection", (reason) => {
|
|
118
|
-
write("
|
|
142
|
+
write("error", `unhandledRejection (the process is still running; a promise rejected with nobody listening): ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`);
|
|
119
143
|
});
|
|
120
144
|
process.on("exit", (code) => {
|
|
121
145
|
write("info", `server process exiting with code ${code}`);
|
|
@@ -123,8 +147,37 @@ export function installServerLogging(dataDir) {
|
|
|
123
147
|
return { logFile, heartbeatFile, write };
|
|
124
148
|
}
|
|
125
149
|
function safeStringify(value) {
|
|
150
|
+
// JSON.stringify(new Error("boom")) is `{}` because Error's useful fields are
|
|
151
|
+
// non-enumerable. That used to erase the only clue in a detached server's log
|
|
152
|
+
// whenever a service did `console.error(error)`. Keep the diagnostic shape
|
|
153
|
+
// explicit and handle the other values that commonly make logging itself throw
|
|
154
|
+
// (cycles and BigInt).
|
|
155
|
+
if (value instanceof Error)
|
|
156
|
+
return value.stack ?? `${value.name}: ${value.message}`;
|
|
157
|
+
if (value === undefined)
|
|
158
|
+
return "undefined";
|
|
159
|
+
if (value === null)
|
|
160
|
+
return "null";
|
|
161
|
+
if (typeof value === "string")
|
|
162
|
+
return value;
|
|
163
|
+
if (typeof value === "bigint")
|
|
164
|
+
return `${value}n`;
|
|
126
165
|
try {
|
|
127
|
-
|
|
166
|
+
const seen = new WeakSet();
|
|
167
|
+
const text = JSON.stringify(value, (_key, nested) => {
|
|
168
|
+
if (nested instanceof Error) {
|
|
169
|
+
return { name: nested.name, message: nested.message, stack: nested.stack, cause: nested.cause };
|
|
170
|
+
}
|
|
171
|
+
if (typeof nested === "bigint")
|
|
172
|
+
return `${nested}n`;
|
|
173
|
+
if (nested && typeof nested === "object") {
|
|
174
|
+
if (seen.has(nested))
|
|
175
|
+
return "[Circular]";
|
|
176
|
+
seen.add(nested);
|
|
177
|
+
}
|
|
178
|
+
return nested;
|
|
179
|
+
});
|
|
180
|
+
return text ?? String(value);
|
|
128
181
|
}
|
|
129
182
|
catch {
|
|
130
183
|
return String(value);
|
|
@@ -25,6 +25,8 @@ import { type PiSessionFactory, type PiSessionLike } from "./pi-session.js";
|
|
|
25
25
|
export interface SessionRequest {
|
|
26
26
|
conversationId: string;
|
|
27
27
|
cwd: string;
|
|
28
|
+
/** The recorded transcript locator, verified against the real header before use. */
|
|
29
|
+
sessionFile?: string;
|
|
28
30
|
/** The agent's id, or null. */
|
|
29
31
|
agentId: string | null;
|
|
30
32
|
/** The environment Super Pi's extensions read when they are loaded. */
|
|
@@ -187,6 +187,7 @@ export class PiSessionPool {
|
|
|
187
187
|
cwd: request.cwd,
|
|
188
188
|
sessionId: request.conversationId,
|
|
189
189
|
sessionDir: this.sessionDir,
|
|
190
|
+
...(request.sessionFile ? { sessionFile: request.sessionFile } : {}),
|
|
190
191
|
extensionPaths: superPiExtensionPaths(this.repositoryRoot),
|
|
191
192
|
skillPaths: superPiSkillPaths(this.repositoryRoot),
|
|
192
193
|
extensionEnv: request.extensionEnv,
|
|
@@ -23,6 +23,85 @@
|
|
|
23
23
|
* the transcript on disk while the live session still holds the old context in memory, and nothing in Pi
|
|
24
24
|
* would notice the difference.
|
|
25
25
|
*/
|
|
26
|
+
/**
|
|
27
|
+
* The Pi SDK, imported lazily.
|
|
28
|
+
*
|
|
29
|
+
* LAZY, and not for startup speed: Super Pi is also shipped as a package that runs against a Pi
|
|
30
|
+
* INSTALLED ON THE USER'S MACHINE, resolved by `pi-command.ts` (override -> PATH -> npm's global bin).
|
|
31
|
+
* That resolution finds a COMMAND; the SDK is a LIBRARY, and it has to come from somewhere specific.
|
|
32
|
+
* A static import would make the whole server fail to start when the library is absent, taking the
|
|
33
|
+
* terminal, the file panel and the git panel down with it - none of which need Pi.
|
|
34
|
+
*
|
|
35
|
+
* So the library is optional, and its absence is a REPORTED FACT rather than a crash. A Super Pi
|
|
36
|
+
* without the SDK cannot run a conversation, and says so in words; a Super Pi whose import throws at
|
|
37
|
+
* module scope cannot even start.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* The parts of Pi's SDK this file uses, described structurally.
|
|
41
|
+
*
|
|
42
|
+
* NOT `typeof import("@earendil-works/pi-coding-agent")`, and that is deliberate. The SDK is an
|
|
43
|
+
* OPTIONAL dependency: a Super Pi installed without it still has to COMPILE, because the compiler runs
|
|
44
|
+
* before anything can report that the library is missing. A type-only import of an absent package is a
|
|
45
|
+
* compile error, so the shapes are declared here instead, and the compiler checks them against the
|
|
46
|
+
* installed SDK's own declarations when it is present (see `scripts/pi-session.test.mjs`).
|
|
47
|
+
*/
|
|
48
|
+
interface PiSdk {
|
|
49
|
+
DefaultResourceLoader: new (options: {
|
|
50
|
+
cwd: string;
|
|
51
|
+
agentDir: string;
|
|
52
|
+
additionalExtensionPaths?: string[];
|
|
53
|
+
additionalSkillPaths?: string[];
|
|
54
|
+
systemPrompt?: string;
|
|
55
|
+
appendSystemPrompt?: string[];
|
|
56
|
+
extensionFactories?: unknown[];
|
|
57
|
+
}) => {
|
|
58
|
+
reload(): Promise<void>;
|
|
59
|
+
getPrompts?: () => {
|
|
60
|
+
prompts: Array<{
|
|
61
|
+
name: string;
|
|
62
|
+
description: string;
|
|
63
|
+
argumentHint?: string;
|
|
64
|
+
sourceInfo: {
|
|
65
|
+
scope: string;
|
|
66
|
+
};
|
|
67
|
+
content: string;
|
|
68
|
+
filePath: string;
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
SessionManager: {
|
|
73
|
+
create(cwd: string, sessionDir?: string, options?: {
|
|
74
|
+
id?: string;
|
|
75
|
+
}): unknown;
|
|
76
|
+
open(path: string, sessionDir?: string, cwdOverride?: string): unknown;
|
|
77
|
+
inMemory(cwd?: string, options?: {
|
|
78
|
+
id?: string;
|
|
79
|
+
}): unknown;
|
|
80
|
+
findById(cwd: string, id: string, sessionDir?: string): string | undefined;
|
|
81
|
+
};
|
|
82
|
+
ModelRuntime: {
|
|
83
|
+
create(): Promise<{
|
|
84
|
+
getModel?: (provider: string, modelId: string) => unknown;
|
|
85
|
+
getAvailable?: () => Promise<unknown[]>;
|
|
86
|
+
getModels?: () => unknown[];
|
|
87
|
+
/**
|
|
88
|
+
* Pi's own verdict on its model configuration - the schema error that empties every picker
|
|
89
|
+
* when one entry in `models.json` is refused.
|
|
90
|
+
*/
|
|
91
|
+
getError?: () => string | undefined;
|
|
92
|
+
}>;
|
|
93
|
+
};
|
|
94
|
+
SettingsManager: {
|
|
95
|
+
create(cwd: string, agentDir: string): {
|
|
96
|
+
getDefaultProvider?: () => string | undefined;
|
|
97
|
+
getDefaultModel?: () => string | undefined;
|
|
98
|
+
getDefaultThinkingLevel?: () => string | undefined;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
createAgentSession(options: Record<string, unknown>): Promise<{
|
|
102
|
+
session: unknown;
|
|
103
|
+
}>;
|
|
104
|
+
}
|
|
26
105
|
/**
|
|
27
106
|
* Loads Pi's SDK while the server is still starting.
|
|
28
107
|
*
|
|
@@ -203,7 +282,10 @@ export interface PiSessionLike {
|
|
|
203
282
|
prompt(message: string, options?: {
|
|
204
283
|
images?: unknown[];
|
|
205
284
|
streamingBehavior?: "steer" | "followUp";
|
|
285
|
+
preflightResult?: (success: boolean) => void;
|
|
206
286
|
}): Promise<void>;
|
|
287
|
+
/** True when no agent run or compaction remains; also true when an input hook handled the prompt. */
|
|
288
|
+
readonly isIdle?: boolean;
|
|
207
289
|
/**
|
|
208
290
|
* Queues a message to be answered AFTER the current run finishes its pending work.
|
|
209
291
|
*
|
|
@@ -341,6 +423,8 @@ export interface SessionCreation {
|
|
|
341
423
|
sessionId: string;
|
|
342
424
|
/** The directory Pi keeps its session files in. */
|
|
343
425
|
sessionDir: string;
|
|
426
|
+
/** Previously recorded locator. Its actual header must still match this id and workspace. */
|
|
427
|
+
sessionFile?: string;
|
|
344
428
|
/** Super Pi's extensions to mount, as absolute paths. */
|
|
345
429
|
extensionPaths: string[];
|
|
346
430
|
/** Super Pi's built-in skills to expose beside the user's and workspace's skills. */
|
|
@@ -391,6 +475,39 @@ export interface SessionCreation {
|
|
|
391
475
|
*/
|
|
392
476
|
export type PiSessionFactory = (creation: SessionCreation) => Promise<PiSessionLike>;
|
|
393
477
|
export declare function createPiSession(creation: SessionCreation): Promise<PiSessionLike>;
|
|
478
|
+
/**
|
|
479
|
+
* Resolves the session manager for a conversation: the EXISTING transcript if there is one, a new one
|
|
480
|
+
* otherwise.
|
|
481
|
+
*
|
|
482
|
+
* WHY THIS IS NOT JUST `SessionManager.create`. A conversation's transcript outlives its session - Pi
|
|
483
|
+
* writes it to a file, and a session that was evicted (idle, or thrown away by a revert) is rebuilt from
|
|
484
|
+
* that file. So building a session is "reopen the conversation's transcript", and the id is what says
|
|
485
|
+
* which transcript: Super Pi's conversation id IS the session id, which is what makes `revert` and
|
|
486
|
+
* `fork` able to find the file without guessing at a filename.
|
|
487
|
+
*
|
|
488
|
+
* The recorded locator avoids directory discovery altogether. Both paths verify the actual header's
|
|
489
|
+
* exact id and cwd; a stale path or a workspace change must never reopen somebody else's transcript.
|
|
490
|
+
*/
|
|
491
|
+
export declare function resolveSessionManager(sdk: Pick<PiSdk, "SessionManager">, creation: SessionCreation): Promise<unknown>;
|
|
492
|
+
type TranscriptHeader = {
|
|
493
|
+
type: "session";
|
|
494
|
+
id: string;
|
|
495
|
+
cwd?: string;
|
|
496
|
+
};
|
|
497
|
+
/** Pi's bounded header discovery contract, using async reads instead of synchronous disk I/O. */
|
|
498
|
+
export declare function readTranscriptHeader(path: string): Promise<TranscriptHeader | undefined>;
|
|
499
|
+
/**
|
|
500
|
+
* Session headers are immutable under Pi's append/rewrite contract. Cache their identities, refresh
|
|
501
|
+
* filenames every lookup, and recheck the selected header before opening. A newly created file is
|
|
502
|
+
* discovered even if stopping/crashing prevented Super Pi from persisting its locator. Arbitrary
|
|
503
|
+
* legacy filenames still work; filenames are never treated as proof of session identity.
|
|
504
|
+
*/
|
|
505
|
+
export declare class TranscriptFileIndex {
|
|
506
|
+
private readonly readHeader;
|
|
507
|
+
private readonly directories;
|
|
508
|
+
constructor(readHeader?: (path: string) => Promise<TranscriptHeader | undefined>);
|
|
509
|
+
find(cwd: string, id: string, sessionDir: string): Promise<string | undefined>;
|
|
510
|
+
}
|
|
394
511
|
/**
|
|
395
512
|
* Pi's own global configuration directory, when it can be worked out without importing the SDK.
|
|
396
513
|
*
|
|
@@ -424,3 +541,4 @@ export declare function loadPromptTemplateSummaries(cwd: string): Promise<Prompt
|
|
|
424
541
|
export declare function superPiExtensionPaths(repositoryRoot: string): string[];
|
|
425
542
|
/** Skills shipped by Super Pi itself. Paths are explicit so installed and development layouts agree. */
|
|
426
543
|
export declare function superPiSkillPaths(repositoryRoot: string): string[];
|
|
544
|
+
export {};
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
* would notice the difference.
|
|
25
25
|
*/
|
|
26
26
|
import { existsSync, statSync } from "node:fs";
|
|
27
|
-
import {
|
|
27
|
+
import { open, readdir } from "node:fs/promises";
|
|
28
|
+
import { StringDecoder } from "node:string_decoder";
|
|
29
|
+
import { dirname, join, resolve } from "node:path";
|
|
28
30
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
31
|
const traceSession = (conversationId, phase, startedAt, extra = "") => {
|
|
30
32
|
if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
|
|
@@ -153,8 +155,10 @@ function sessionLevels(session) {
|
|
|
153
155
|
* to guess: a wrong path here would truncate somebody else's conversation.
|
|
154
156
|
*/
|
|
155
157
|
export async function resolveTranscriptFile(cwd, conversationId, sessionDir) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
// The caller still falls back to RPC when the SDK is unavailable. Discovery itself is async so
|
|
159
|
+
// a cold directory on a cloud filesystem does not block stop/send handling on the event loop.
|
|
160
|
+
await loadSdk();
|
|
161
|
+
return transcriptFiles.find(cwd, conversationId, sessionDir);
|
|
158
162
|
}
|
|
159
163
|
/** Whether the SDK could be loaded. Used by the server to report the reason instead of failing a turn. */
|
|
160
164
|
export async function piSdkAvailable() {
|
|
@@ -352,16 +356,21 @@ function transcriptBytes(sessionManager) {
|
|
|
352
356
|
* which transcript: Super Pi's conversation id IS the session id, which is what makes `revert` and
|
|
353
357
|
* `fork` able to find the file without guessing at a filename.
|
|
354
358
|
*
|
|
355
|
-
* The
|
|
356
|
-
*
|
|
357
|
-
* matters here: a session is only found when it was recorded for the SAME working directory. A
|
|
358
|
-
* conversation whose workspace changed therefore gets a fresh transcript rather than somebody else's.
|
|
359
|
+
* The recorded locator avoids directory discovery altogether. Both paths verify the actual header's
|
|
360
|
+
* exact id and cwd; a stale path or a workspace change must never reopen somebody else's transcript.
|
|
359
361
|
*/
|
|
360
|
-
async function resolveSessionManager(sdk, creation) {
|
|
361
|
-
const
|
|
362
|
+
export async function resolveSessionManager(sdk, creation) {
|
|
363
|
+
const cachedHeader = creation.sessionFile ? await readTranscriptHeader(creation.sessionFile) : undefined;
|
|
364
|
+
const cachedMatches = transcriptMatches(cachedHeader, creation.cwd, creation.sessionId);
|
|
365
|
+
const existing = cachedMatches ? creation.sessionFile
|
|
366
|
+
: await transcriptFiles.find(creation.cwd, creation.sessionId, creation.sessionDir);
|
|
362
367
|
if (existing) {
|
|
363
368
|
try {
|
|
364
|
-
|
|
369
|
+
const manager = sdk.SessionManager.open(existing, creation.sessionDir, creation.cwd);
|
|
370
|
+
// Check Pi's loaded header too: the file may have changed between async discovery and open.
|
|
371
|
+
const header = manager.getHeader?.();
|
|
372
|
+
if (transcriptMatches(header ?? undefined, creation.cwd, creation.sessionId))
|
|
373
|
+
return manager;
|
|
365
374
|
}
|
|
366
375
|
catch {
|
|
367
376
|
// A file that exists but cannot be opened is not a reason to fail the turn: falling through
|
|
@@ -371,6 +380,119 @@ async function resolveSessionManager(sdk, creation) {
|
|
|
371
380
|
}
|
|
372
381
|
return sdk.SessionManager.create(creation.cwd, creation.sessionDir, { id: creation.sessionId });
|
|
373
382
|
}
|
|
383
|
+
function transcriptMatches(header, cwd, id) {
|
|
384
|
+
return header?.id === id && typeof header.cwd === "string" && header.cwd !== "" && resolve(header.cwd) === resolve(cwd);
|
|
385
|
+
}
|
|
386
|
+
/** Pi's bounded header discovery contract, using async reads instead of synchronous disk I/O. */
|
|
387
|
+
export async function readTranscriptHeader(path) {
|
|
388
|
+
const file = await open(path, "r").catch(() => undefined);
|
|
389
|
+
if (!file)
|
|
390
|
+
return undefined;
|
|
391
|
+
try {
|
|
392
|
+
const decoder = new StringDecoder("utf8");
|
|
393
|
+
const buffer = Buffer.allocUnsafe(4096);
|
|
394
|
+
let pending = "";
|
|
395
|
+
const parse = (line) => {
|
|
396
|
+
if (!line.trim())
|
|
397
|
+
return undefined;
|
|
398
|
+
let value;
|
|
399
|
+
try {
|
|
400
|
+
value = JSON.parse(line);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
if (value && typeof value === "object" && value.type === "session"
|
|
406
|
+
&& typeof value.id === "string")
|
|
407
|
+
return value;
|
|
408
|
+
return null;
|
|
409
|
+
};
|
|
410
|
+
let scanned = 0;
|
|
411
|
+
while (scanned < 1024 * 1024) {
|
|
412
|
+
const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, 1024 * 1024 - scanned), null);
|
|
413
|
+
if (!bytesRead)
|
|
414
|
+
return parse(pending + decoder.end()) ?? undefined;
|
|
415
|
+
scanned += bytesRead;
|
|
416
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
417
|
+
let newline;
|
|
418
|
+
while ((newline = pending.indexOf("\n")) !== -1) {
|
|
419
|
+
const header = parse(pending.slice(0, newline));
|
|
420
|
+
if (header !== undefined)
|
|
421
|
+
return header ?? undefined;
|
|
422
|
+
pending = pending.slice(newline + 1);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Match Pi's exact-boundary EOF case without reading an unbounded prefix or transcript body.
|
|
426
|
+
const probe = await file.read(buffer, 0, 1, null);
|
|
427
|
+
return probe.bytesRead ? undefined : parse(pending + decoder.end()) ?? undefined;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Discovery is best-effort, like Pi's own readSessionHeaderForDiscovery.
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
await file.close();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Session headers are immutable under Pi's append/rewrite contract. Cache their identities, refresh
|
|
439
|
+
* filenames every lookup, and recheck the selected header before opening. A newly created file is
|
|
440
|
+
* discovered even if stopping/crashing prevented Super Pi from persisting its locator. Arbitrary
|
|
441
|
+
* legacy filenames still work; filenames are never treated as proof of session identity.
|
|
442
|
+
*/
|
|
443
|
+
export class TranscriptFileIndex {
|
|
444
|
+
readHeader;
|
|
445
|
+
directories = new Map();
|
|
446
|
+
constructor(readHeader = readTranscriptHeader) {
|
|
447
|
+
this.readHeader = readHeader;
|
|
448
|
+
}
|
|
449
|
+
async find(cwd, id, sessionDir) {
|
|
450
|
+
const dir = resolve(sessionDir);
|
|
451
|
+
let state = this.directories.get(dir);
|
|
452
|
+
if (!state) {
|
|
453
|
+
state = { headers: new Map(), lane: Promise.resolve() };
|
|
454
|
+
this.directories.set(dir, state);
|
|
455
|
+
}
|
|
456
|
+
const current = state;
|
|
457
|
+
let found;
|
|
458
|
+
const lookup = async () => {
|
|
459
|
+
const names = (await readdir(dir).catch(() => [])).filter(name => name.endsWith(".jsonl"));
|
|
460
|
+
const paths = new Set(names.map(name => join(dir, name)));
|
|
461
|
+
for (const path of current.headers.keys())
|
|
462
|
+
if (!paths.has(path))
|
|
463
|
+
current.headers.delete(path);
|
|
464
|
+
const pending = [...paths].filter(path => !current.headers.has(path));
|
|
465
|
+
let next = 0;
|
|
466
|
+
await Promise.all(Array.from({ length: Math.min(8, pending.length) }, async () => {
|
|
467
|
+
while (next < pending.length) {
|
|
468
|
+
const path = pending[next++];
|
|
469
|
+
const header = await this.readHeader(path);
|
|
470
|
+
// Empty/unreadable files may be in the middle of their first write; retry next time.
|
|
471
|
+
if (header)
|
|
472
|
+
current.headers.set(path, header);
|
|
473
|
+
}
|
|
474
|
+
}));
|
|
475
|
+
for (const path of paths) {
|
|
476
|
+
if (!transcriptMatches(current.headers.get(path), cwd, id))
|
|
477
|
+
continue;
|
|
478
|
+
const header = await this.readHeader(path);
|
|
479
|
+
if (header)
|
|
480
|
+
current.headers.set(path, header);
|
|
481
|
+
else
|
|
482
|
+
current.headers.delete(path);
|
|
483
|
+
if (transcriptMatches(header, cwd, id)) {
|
|
484
|
+
found = path;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const run = current.lane.then(lookup, lookup);
|
|
490
|
+
current.lane = run.then(() => { }, () => { });
|
|
491
|
+
await run;
|
|
492
|
+
return found;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const transcriptFiles = new TranscriptFileIndex();
|
|
374
496
|
/**
|
|
375
497
|
* Pi's own global configuration directory, when it can be worked out without importing the SDK.
|
|
376
498
|
*
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -686,6 +686,18 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
686
686
|
* it was started.
|
|
687
687
|
*/
|
|
688
688
|
private readonly pi;
|
|
689
|
+
/**
|
|
690
|
+
* HOW MANY LIVE SESSIONS THIS CONVERSATION HAS, and whether it has one at all.
|
|
691
|
+
*
|
|
692
|
+
* A reader of this number learns whether a conversation's context is still in Pi's memory. That is
|
|
693
|
+
* the fact a revert has to change: it rewrites the transcript on disk, and a live session keeps the
|
|
694
|
+
* OLD context in memory, so a session that outlived the revert is a model that answers from
|
|
695
|
+
* history the person withdrew - with nothing saying so.
|
|
696
|
+
*
|
|
697
|
+
* It is here because that failure is invisible from the outside: the file is shorter, the state is
|
|
698
|
+
* clean, and only the model knows something the reader cannot see.
|
|
699
|
+
*/
|
|
700
|
+
liveSessionCount(conversationId: string): number;
|
|
689
701
|
canSteer(conversationId: string): boolean;
|
|
690
702
|
constructor(sessionDir: string, command?: string,
|
|
691
703
|
/**
|
|
@@ -730,6 +742,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
730
742
|
toolsEndpoint?: string;
|
|
731
743
|
documentsEndpoint?: string;
|
|
732
744
|
scheduledTaskEndpoint?: string;
|
|
745
|
+
orchestratorEndpoint?: string;
|
|
733
746
|
uploadsDir?: string;
|
|
734
747
|
sessionFactory?: PiSessionFactory;
|
|
735
748
|
sessionMode?: "sdk" | "oneshot";
|
|
@@ -999,6 +1012,26 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
999
1012
|
* separated at.
|
|
1000
1013
|
*/
|
|
1001
1014
|
private answerTurn;
|
|
1015
|
+
/**
|
|
1016
|
+
* THE ONE-SHOT PATH, WITH THE INLINE THINKING SPLIT OUT OF IT.
|
|
1017
|
+
*
|
|
1018
|
+
* WHY THIS IS A SEPARATE FUNCTION AND NOT A LINE IN `answerTurn`. The reasoning of an inline-thinking
|
|
1019
|
+
* model is a `<think>…</think>` region INSIDE the answer text, so splitting it is not a formatting
|
|
1020
|
+
* step - it is a reading of the text, and the facts it produces ("the thinking started", "the thinking
|
|
1021
|
+
* ended") belong where the text is read.
|
|
1022
|
+
*
|
|
1023
|
+
* WHEN IT WAS FORGOTTEN, IT COST THE PRODUCT A BUG. The splitter used to live one layer up, in
|
|
1024
|
+
* `answer()`, where it covered EVERY path. It was moved down into the SDK session path to fix the
|
|
1025
|
+
* ordering of "the thinking ended" against a tool call (UX-008), and in that move the ONE-SHOT
|
|
1026
|
+
* CONVERSATION path - the path a server without the Pi SDK falls back to, and the path the whole test
|
|
1027
|
+
* suite drives - lost the splitting altogether: the `<think>` tags reached the answer verbatim.
|
|
1028
|
+
* `scripts/inline-thinking.test.mjs` caught it; the SDK path was fine, which is why it took a test
|
|
1029
|
+
* suite running the other path to notice.
|
|
1030
|
+
*
|
|
1031
|
+
* SO EVERY ONE-SHOT PATH GOES THROUGH HERE, and the session path keeps its own splitter inside the
|
|
1032
|
+
* queue's consumer, where the ORDER of "thinking ended" and a tool call is decided.
|
|
1033
|
+
*/
|
|
1034
|
+
private answerOneShotWithInlineThinking;
|
|
1002
1035
|
/**
|
|
1003
1036
|
* The one-shot path: a fresh child process for a single turn.
|
|
1004
1037
|
*
|
|
@@ -1062,6 +1095,21 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
1062
1095
|
* revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
|
|
1063
1096
|
* elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
|
|
1064
1097
|
*/
|
|
1098
|
+
/**
|
|
1099
|
+
* THE LIVE SESSION MUST GO WHEN A REVERT REWRITES THE TRANSCRIPT.
|
|
1100
|
+
*
|
|
1101
|
+
* A revert rewrites Pi's transcript on disk. A live Pi session holds the OLD context in memory, and
|
|
1102
|
+
* nothing in Pi would notice the difference: the model would keep answering from history the user
|
|
1103
|
+
* has just withdrawn, while `state.json` and the file on disk both said it was gone. Three views of
|
|
1104
|
+
* the conversation, two of them wrong, and no error anywhere. So the session is destroyed and the
|
|
1105
|
+
* next turn rebuilds it from the truncated file.
|
|
1106
|
+
*
|
|
1107
|
+
* THIS IS A CORRECTNESS REQUIREMENT, NOT AN OPTIMIZATION, and it is why it lives in one place
|
|
1108
|
+
* called by EVERY branch of `revert`. It used to be inline in two of the three, and the third -
|
|
1109
|
+
* the one taken when the transcript path was not cached - missed it, so a session reverted before
|
|
1110
|
+
* its path was ever learned kept talking about the withdrawn turn with nothing saying so.
|
|
1111
|
+
*/
|
|
1112
|
+
private evictAfterRevert;
|
|
1065
1113
|
revert({ conversation, cwd, boundaryEntryId, beforeBoundary }: RevertRequest): Promise<RevertOutcome>;
|
|
1066
1114
|
/**
|
|
1067
1115
|
* Copies Pi's own transcript for one conversation into a second, independent file under a new id.
|