@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "hash": "0220ac9d17fac45029d0f99c48d3202e59d847f64cc3c311840886684351c635",
3
- "recordedAt": "2026-10-24T06:01:40.234Z"
2
+ "hash": "810acbfbefb53d8d64c63ed8b6bf194ce46c575d0cc9b1c259158e3a359b0bf8",
3
+ "recordedAt": "2026-10-24T11:40:43.712Z"
4
4
  }
@@ -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-24T08:11:35.652Z"
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 (const name of await readdir(folder)) {
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 environment is applied, so the extension factories that run during the reload read this
367
- * session's values rather than another session's.
368
- * 2. The loader is created and reloaded, which is when Pi calls the extension factories. The factories
369
- * CAPTURE the values; they do not read them later.
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 step 1 would give every session the same configuration, and step 1 happening
373
- * after step 2 would give every session no configuration at all. The window is the whole of the reload.
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
- // Extension credentials are per-session capabilities. Do not temporarily mutate process.env here:
235
- // two concurrent session creations otherwise race and can capture each other's capability.
236
- const inlinePaths = creation.extensionPaths.filter((path) => /super-pi-(task|memory|knowledge)/.test(path.replaceAll("\\", "/")));
237
- const externalPaths = creation.extensionPaths.filter((path) => !inlinePaths.includes(path));
238
- const extensionFactories = await Promise.all(inlinePaths.map(async (path) => {
239
- const mod = await import(pathToFileURL(path).href);
240
- return async (pi) => mod.default?.(pi, creation.extensionEnv);
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
- ...(externalPaths.length ? { additionalExtensionPaths: externalPaths } : {}),
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
- await loader.reload();
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.129",
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",