@evandu/super-pi 0.1.129 → 0.1.130
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/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +1 -1
- package/dist/server/active-memory.d.ts +12 -0
- package/dist/server/active-memory.js +25 -1
- package/dist/server/application/conversation-service.js +5 -0
- package/dist/server/pi-session.d.ts +16 -6
- package/dist/server/pi-session.js +32 -11
- package/package.json +3 -1
|
@@ -31,5 +31,5 @@
|
|
|
31
31
|
"src/client/types.ts": "7a99145ea7099ebf63a9009d855fbc1d432522b17e640a89c9596db40533dbd7",
|
|
32
32
|
"src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
|
|
33
33
|
},
|
|
34
|
-
"recordedAt": "2026-10-
|
|
34
|
+
"recordedAt": "2026-10-24T11:48:58.080Z"
|
|
35
35
|
}
|
|
@@ -70,6 +70,18 @@ export declare class ActiveMemory {
|
|
|
70
70
|
content: string;
|
|
71
71
|
}[];
|
|
72
72
|
private ensureIndex;
|
|
73
|
+
/**
|
|
74
|
+
* Drops a workspace's search index so the next search rebuilds it from the store.
|
|
75
|
+
*
|
|
76
|
+
* WHY THIS EXISTS. `ensureIndex` builds once per workspace and `addToIndex` only ever adds, so the
|
|
77
|
+
* index outlives the messages it indexed: a revert removes messages from the store while the index
|
|
78
|
+
* keeps their text, and the next recall then hands the model a turn the user has just withdrawn -
|
|
79
|
+
* measured by scripts/revert-real-pi.test.mjs, where the withdrawn codeword came back as "recalled
|
|
80
|
+
* history" after a revert every file-level assertion proved had really truncated. The store is the
|
|
81
|
+
* source of truth; the index is a cache of it, and a cache that survives an event that invalidated
|
|
82
|
+
* its source is wrong by definition.
|
|
83
|
+
*/
|
|
84
|
+
invalidateSearchIndex(workspaceId: string): void;
|
|
73
85
|
private addToIndex;
|
|
74
86
|
tool(token: string, body: {
|
|
75
87
|
kind?: string;
|
|
@@ -71,7 +71,16 @@ export class ActiveMemory {
|
|
|
71
71
|
// The live set is whatever SURVIVED, read back rather than derived from the dropped ids: deriving it
|
|
72
72
|
// as "everything not dropped" would be wrong the moment an entry was removed by someone else.
|
|
73
73
|
const live = new Set((await this.notes.apply(conversation.workspaceId, "", { target: "skills", action: "list" })).map((entry) => entry.id));
|
|
74
|
-
for
|
|
74
|
+
// The skills folder exists only once a skill has been written for this workspace, so its absence is
|
|
75
|
+
// the ordinary note-only case and not a failure. Throwing on it here used to discard the forget
|
|
76
|
+
// result above with it: the entries were already dropped, but the caller read the catch's zero as
|
|
77
|
+
// "nothing was withdrawn", and the log lied about the very rewind this method exists to do.
|
|
78
|
+
let skillFiles = [];
|
|
79
|
+
try {
|
|
80
|
+
skillFiles = await readdir(folder);
|
|
81
|
+
}
|
|
82
|
+
catch { /* no skill has ever been written for this workspace */ }
|
|
83
|
+
for (const name of skillFiles) {
|
|
75
84
|
if (name.endsWith(".md") && !live.has(name.slice(0, -3)))
|
|
76
85
|
await unlink(join(folder, name)).catch(() => { });
|
|
77
86
|
}
|
|
@@ -135,6 +144,21 @@ export class ActiveMemory {
|
|
|
135
144
|
this.inverted.set(workspaceId, index);
|
|
136
145
|
this.indexedWorkspaces.add(workspaceId);
|
|
137
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Drops a workspace's search index so the next search rebuilds it from the store.
|
|
149
|
+
*
|
|
150
|
+
* WHY THIS EXISTS. `ensureIndex` builds once per workspace and `addToIndex` only ever adds, so the
|
|
151
|
+
* index outlives the messages it indexed: a revert removes messages from the store while the index
|
|
152
|
+
* keeps their text, and the next recall then hands the model a turn the user has just withdrawn -
|
|
153
|
+
* measured by scripts/revert-real-pi.test.mjs, where the withdrawn codeword came back as "recalled
|
|
154
|
+
* history" after a revert every file-level assertion proved had really truncated. The store is the
|
|
155
|
+
* source of truth; the index is a cache of it, and a cache that survives an event that invalidated
|
|
156
|
+
* its source is wrong by definition.
|
|
157
|
+
*/
|
|
158
|
+
invalidateSearchIndex(workspaceId) {
|
|
159
|
+
this.indexedWorkspaces.delete(workspaceId);
|
|
160
|
+
this.inverted.delete(workspaceId);
|
|
161
|
+
}
|
|
138
162
|
addToIndex(conversation, message) {
|
|
139
163
|
if (!this.indexedWorkspaces.has(conversation.workspaceId) || (message.role !== "user" && message.role !== "assistant"))
|
|
140
164
|
return;
|
|
@@ -356,6 +356,11 @@ export class ConversationService {
|
|
|
356
356
|
// so this runs on data that is settled and cannot be racing the store's own writes.
|
|
357
357
|
const firstRemoved = conversation.messages[applied.boundaryIndex + 1];
|
|
358
358
|
const forgotten = firstRemoved ? await this.memory?.forgetAfter(conversationId, firstRemoved.createdAt) ?? 0 : 0;
|
|
359
|
+
// The recall index is a cache of the store's messages, and the store just lost several: left alone it
|
|
360
|
+
// keeps answering the memory extension's searches with the withdrawn turns, so the model is told about
|
|
361
|
+
// history the person reverted away even though the transcript, Pi's file and the memory store all
|
|
362
|
+
// agree it is gone. Invalidate it and the next search rebuilds from what actually survived.
|
|
363
|
+
this.memory?.invalidateSearchIndex(workspaceId);
|
|
359
364
|
console.log(`Super Pi: reverted conversation ${conversationId} to ${applied.boundaryIndex + 1} message(s): ` +
|
|
360
365
|
`${plan.removedMessageIds.length} message(s) and ${applied.doomedRunIds.length} sub-agent run(s) dropped from state.json; ` +
|
|
361
366
|
`Pi's file ${outcome.sessionFile} kept ${outcome.linesKept} line(s) and lost ${outcome.entriesDropped}; ` +
|
|
@@ -184,6 +184,13 @@ export interface ExtensionEnvironment {
|
|
|
184
184
|
* concurrent session's environment would otherwise land in the middle of this one.
|
|
185
185
|
*/
|
|
186
186
|
export declare function withEnvironment<T>(values: Record<string, string>, work: () => Promise<T>): Promise<T>;
|
|
187
|
+
/**
|
|
188
|
+
* `withEnvironment` for one session's extension load, serialized across all session creations.
|
|
189
|
+
*
|
|
190
|
+
* The extension factories run inside `work` and capture the values into their closures; after the
|
|
191
|
+
* window closes they never read `process.env` again, so only the WINDOW needs to be exclusive.
|
|
192
|
+
*/
|
|
193
|
+
export declare function withIsolatedSessionEnv<T>(values: Record<string, string>, work: () => Promise<T>): Promise<T>;
|
|
187
194
|
/**
|
|
188
195
|
* The live session for one conversation.
|
|
189
196
|
*
|
|
@@ -363,14 +370,17 @@ export interface SessionCreation {
|
|
|
363
370
|
*
|
|
364
371
|
* The order of operations here IS the isolation mechanism, and each step is load-bearing:
|
|
365
372
|
*
|
|
366
|
-
* 1. The
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
373
|
+
* 1. The loader is created with Super Pi's extension paths, for PI to load - through its jiti
|
|
374
|
+
* transpiler and virtual modules, the same machinery the spawned CLI used. Nothing here imports a
|
|
375
|
+
* Super Pi extension file directly; see the comment at the loader below for why that must never
|
|
376
|
+
* come back.
|
|
377
|
+
* 2. The reload runs inside this session's env window (`withIsolatedSessionEnv`), which is when Pi
|
|
378
|
+
* calls the extension factories. The factories CAPTURE the values; they do not read them later.
|
|
370
379
|
* 3. The session is created with that loader.
|
|
371
380
|
*
|
|
372
|
-
* Step 2 happening outside
|
|
373
|
-
*
|
|
381
|
+
* Step 2 happening outside its env window would give every session the same configuration, and the
|
|
382
|
+
* window would give every session no configuration at all if two creations could interleave - which
|
|
383
|
+
* the serialization in `withIsolatedSessionEnv` prevents.
|
|
374
384
|
*
|
|
375
385
|
* THE FACTORY IS INJECTABLE. A test needs to drive a session without a real Pi behind it - to check what
|
|
376
386
|
* a session was BUILT with (its working directory, its allowlist) rather than what it eventually
|
|
@@ -225,20 +225,39 @@ export async function withEnvironment(values, work) {
|
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* The env window of the session currently being built, so the next one queues behind it.
|
|
230
|
+
*
|
|
231
|
+
* `withEnvironment` alone is not safe here: its window is process-global, and two sessions created
|
|
232
|
+
* concurrently would interleave - A applies its capability, B applies its own, and A's extension
|
|
233
|
+
* factory captures B's credential. Chaining the windows means a concurrent creation waits for the
|
|
234
|
+
* other's `reload()` - milliseconds - instead of silently wearing its capability.
|
|
235
|
+
*/
|
|
236
|
+
let extensionEnvWindow = Promise.resolve();
|
|
237
|
+
/**
|
|
238
|
+
* `withEnvironment` for one session's extension load, serialized across all session creations.
|
|
239
|
+
*
|
|
240
|
+
* The extension factories run inside `work` and capture the values into their closures; after the
|
|
241
|
+
* window closes they never read `process.env` again, so only the WINDOW needs to be exclusive.
|
|
242
|
+
*/
|
|
243
|
+
export function withIsolatedSessionEnv(values, work) {
|
|
244
|
+
const run = extensionEnvWindow.then(() => withEnvironment(values, work));
|
|
245
|
+
extensionEnvWindow = run.then(() => undefined, () => undefined);
|
|
246
|
+
return run;
|
|
247
|
+
}
|
|
228
248
|
export async function createPiSession(creation) {
|
|
229
249
|
const startedAt = performance.now();
|
|
230
250
|
traceSession(creation.sessionId, "sdk.load.begin", startedAt);
|
|
231
251
|
const sdk = await loadSdk();
|
|
232
252
|
traceSession(creation.sessionId, "sdk.load.end", startedAt);
|
|
233
253
|
const agentDir = creation.agentDir ?? defaultAgentDir();
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}));
|
|
254
|
+
// THE EXTENSIONS ARE PI'S TO LOAD. Every path goes to Pi's own resource loader, which imports them
|
|
255
|
+
// through its jiti transpiler with its virtual modules - and both of those are load-bearing:
|
|
256
|
+
// `@sinclair/typebox` is a Pi virtual module that NO node_modules anywhere can resolve, and Node's
|
|
257
|
+
// type stripping refuses .ts under any `node_modules` segment (which is where a published package
|
|
258
|
+
// lives). A native `import()` here survived neither - it broke every installed layout the moment it
|
|
259
|
+
// shipped, invisibly, because nothing in `npm run check` runs from an installed layout.
|
|
260
|
+
// `scripts/installed-layout.test.mjs` exists to keep that class of breakage visible.
|
|
242
261
|
return (async () => {
|
|
243
262
|
// THE AGENT'S PROMPT. Pi's loader takes the same two shapes the command line took: a `systemPrompt`
|
|
244
263
|
// that REPLACES Pi's own, and an `appendSystemPrompt` list of files that are ADDED to it. An agent
|
|
@@ -249,15 +268,17 @@ export async function createPiSession(creation) {
|
|
|
249
268
|
const loader = new sdk.DefaultResourceLoader({
|
|
250
269
|
cwd: creation.cwd,
|
|
251
270
|
agentDir,
|
|
252
|
-
...(
|
|
253
|
-
...(extensionFactories.length ? { extensionFactories } : {}),
|
|
271
|
+
...(creation.extensionPaths.length ? { additionalExtensionPaths: creation.extensionPaths } : {}),
|
|
254
272
|
...(creation.systemPromptFile && creation.systemPromptMode === "replace"
|
|
255
273
|
? { systemPrompt: creation.systemPromptFile }
|
|
256
274
|
: creation.systemPromptFile
|
|
257
275
|
? { appendSystemPrompt: [creation.systemPromptFile] }
|
|
258
276
|
: {}),
|
|
259
277
|
});
|
|
260
|
-
|
|
278
|
+
// The extension factories run inside `reload()` and capture this session's env values into their
|
|
279
|
+
// closures; the window is serialized (`withIsolatedSessionEnv`) so a concurrent creation cannot
|
|
280
|
+
// land its own capability in the middle.
|
|
281
|
+
await withIsolatedSessionEnv(creation.extensionEnv, () => loader.reload());
|
|
261
282
|
traceSession(creation.sessionId, "sdk.loader.reload", startedAt, `extensions=${creation.extensionPaths.length}`);
|
|
262
283
|
const sessionManager = creation.persist === false
|
|
263
284
|
? sdk.SessionManager.inMemory(creation.cwd, { id: creation.sessionId })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.130",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"scripts/stream-checkpoint.test.mjs",
|
|
44
44
|
"scripts/stream-transport.test.mjs",
|
|
45
45
|
"scripts/pi-session.test.mjs",
|
|
46
|
+
"scripts/pi-session-isolation.test.mjs",
|
|
47
|
+
"scripts/installed-layout.test.mjs",
|
|
46
48
|
"scripts/pi-model-switch.test.mjs",
|
|
47
49
|
"scripts/pi-thinking-levels.test.mjs",
|
|
48
50
|
"scripts/diagnose-cli.test.mjs",
|