@evandu/super-pi 0.1.129 → 0.1.131

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.
Files changed (70) hide show
  1. package/agents/super-pi-documents/index.ts +92 -0
  2. package/agents/super-pi-knowledge/index.ts +32 -1
  3. package/agents/super-pi-task/core.ts +40 -0
  4. package/agents/super-pi-task/index.ts +15 -1
  5. package/agents/super-pi-web/index.ts +32 -0
  6. package/assets/fonts/LICENSE +92 -0
  7. package/assets/fonts/NotoSansSC-Bold.otf +0 -0
  8. package/assets/fonts/NotoSansSC-Regular.otf +0 -0
  9. package/dist/.super-pi-check-inputs.json +2 -2
  10. package/dist/client/.super-pi-client-sources.json +5 -5
  11. package/dist/client/assets/{CodeMirrorEditor-BNVBZZSS.js → CodeMirrorEditor-BlQhH3vs.js} +2 -2
  12. package/dist/client/assets/{dist-DmAOqr0M.js → dist-B1_1Ui9E.js} +1 -1
  13. package/dist/client/assets/{dist-GkIV1LTX.js → dist-B448EWTM.js} +1 -1
  14. package/dist/client/assets/{dist-CiSGSVBZ.js → dist-B68Q8uXX.js} +1 -1
  15. package/dist/client/assets/{dist-DB5M5fZG.js → dist-BFlq-kiF.js} +1 -1
  16. package/dist/client/assets/{dist-BykVFExd.js → dist-BXVFgriA.js} +1 -1
  17. package/dist/client/assets/{dist-CZ9I2JCd.js → dist-BcE6tJvc.js} +1 -1
  18. package/dist/client/assets/{dist-BhJGK8-b.js → dist-ByIqsb5-.js} +1 -1
  19. package/dist/client/assets/{dist-DZnUERoW.js → dist-C6qSkkeN.js} +1 -1
  20. package/dist/client/assets/{dist-BKp4AWEJ.js → dist-C9AkgNbt.js} +1 -1
  21. package/dist/client/assets/{dist-CRzRB9k3.js → dist-COp25nhe.js} +1 -1
  22. package/dist/client/assets/{dist-CMnvChDX.js → dist-CpuexUlh.js} +1 -1
  23. package/dist/client/assets/{dist-ChszWGDv.js → dist-CtdlYgGc.js} +1 -1
  24. package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-Cybsoapc.js} +1 -1
  25. package/dist/client/assets/{dist-Ur37340K.js → dist-D6PoSxR5.js} +1 -1
  26. package/dist/client/assets/{dist-C90MPTnk.js → dist-D7bj81KQ.js} +1 -1
  27. package/dist/client/assets/{dist-BBW2SE_R.js → dist-DP2ygAFF.js} +1 -1
  28. package/dist/client/assets/{dist-B61RSa-K.js → dist-DXNDtyHP.js} +1 -1
  29. package/dist/client/assets/{dist-CWYRjnj-.js → dist-DxoWS5MH.js} +1 -1
  30. package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Fhkmvjdx.js} +1 -1
  31. package/dist/client/assets/{dist-BDygQgZD.js → dist-hfpd-aWa.js} +1 -1
  32. package/dist/client/assets/{dist-B2nPVdKh.js → dist-lQWWWxh9.js} +1 -1
  33. package/dist/client/assets/{dist-HdeIBl2J.js → dist-r5Af94tH.js} +1 -1
  34. package/dist/client/assets/{index-D_c-CYAr.js → index-BApJ3Zbq.js} +3 -3
  35. package/dist/client/index.html +1 -1
  36. package/dist/server/active-memory.d.ts +12 -0
  37. package/dist/server/active-memory.js +25 -1
  38. package/dist/server/application/conversation-service.js +5 -0
  39. package/dist/server/application/document-service.d.ts +68 -0
  40. package/dist/server/application/document-service.js +120 -0
  41. package/dist/server/application/run-service.js +49 -7
  42. package/dist/server/documents.d.ts +152 -0
  43. package/dist/server/documents.js +998 -0
  44. package/dist/server/file-changes.js +6 -4
  45. package/dist/server/files.d.ts +32 -0
  46. package/dist/server/files.js +28 -0
  47. package/dist/server/http-server.js +113 -2
  48. package/dist/server/index.js +13 -1
  49. package/dist/server/inline-thinking.d.ts +90 -0
  50. package/dist/server/inline-thinking.js +181 -0
  51. package/dist/server/knowledge.d.ts +24 -14
  52. package/dist/server/knowledge.js +47 -32
  53. package/dist/server/pi-session-pool.js +2 -1
  54. package/dist/server/pi-session.d.ts +20 -6
  55. package/dist/server/pi-session.js +43 -11
  56. package/dist/server/presentations/schema.d.ts +120 -0
  57. package/dist/server/presentations/schema.js +128 -0
  58. package/dist/server/runtime.d.ts +46 -0
  59. package/dist/server/runtime.js +150 -16
  60. package/dist/server/server-context.d.ts +3 -0
  61. package/dist/server/subagents.js +33 -6
  62. package/dist/server/web.d.ts +24 -0
  63. package/dist/server/web.js +180 -0
  64. package/package.json +33 -5
  65. package/skills/presentation/SKILL.md +30 -0
  66. package/skills/presentation/references/data.md +10 -0
  67. package/skills/presentation/references/quality.md +12 -0
  68. package/skills/presentation/references/story.md +12 -0
  69. package/skills/presentation/references/templates-and-editing.md +11 -0
  70. package/skills/presentation/references/visual-system.md +10 -0
@@ -82,6 +82,8 @@ export type KnowledgeRuntime = {
82
82
  export declare function parseRanking(output: string): number[] | null;
83
83
  /** Duplicated as a literal in `agents/super-pi-knowledge/index.ts`, the way `TASK_TOOL_NAME` is. */
84
84
  export declare const KNOWLEDGE_TOOL_NAME = "knowledge_search";
85
+ /** The import half of the same extension. Duplicated as a literal there, for the same reason. */
86
+ export declare const KNOWLEDGE_IMPORT_TOOL_NAME = "knowledge_import";
85
87
  /**
86
88
  * Index terms for one string: CJK bigrams plus Latin words.
87
89
  *
@@ -152,17 +154,6 @@ export declare class KnowledgeBase {
152
154
  private load;
153
155
  private invalidate;
154
156
  list(workspaceId: string): Promise<Array<Omit<KnowledgeDocument, "text">>>;
155
- /**
156
- * Whether this workspace has any document at all, which is what decides whether the turn mounts the
157
- * extension.
158
- *
159
- * WHY A DIRECTORY LISTING AND NOT `list()`. This runs once per turn, before every spawn. `list()`
160
- * would read and parse every document's full text to answer a yes/no question, so a workspace with
161
- * a dozen real documents would pay for parsing all of them on every message - including the
162
- * catalogue fetch the extension makes the moment it loads. `readdir` answers the same question for
163
- * the price of one directory entry, and a name is all this needs.
164
- */
165
- hasDocuments(workspaceId: string): Promise<boolean>;
166
157
  /**
167
158
  * Adds a document, either from a workspace file or from pasted text.
168
159
  *
@@ -171,6 +162,10 @@ export declare class KnowledgeBase {
171
162
  * newer version that diverges from the one the workspace serves. The path therefore goes through
172
163
  * the same `resolveInsideRoot` guard the file editor uses, so symlink and `..` escapes are refused
173
164
  * by construction instead of by a check someone has to remember.
165
+ *
166
+ * WHO CALLS THIS. The settings window (manage, action "add") AND, since the import tool exists,
167
+ * the primary agent mid-turn on the user's say-so - through the same guard, the same whitelist and
168
+ * the same caps, so an agent import can never do more than a person's import could.
174
169
  */
175
170
  add(workspaceId: string, body: {
176
171
  path?: string;
@@ -213,27 +208,42 @@ export declare class KnowledgeBase {
213
208
  */
214
209
  private rerank;
215
210
  /**
216
- * Mints a read-only token for one turn. The agent can retrieve, never write: everything a person
217
- * puts in a knowledge base is theirs to curate from the settings window.
211
+ * Mints a token for one turn. The agent can retrieve and IMPORT (a document the user just asked it
212
+ * to add rides through the same `add` guard the settings window uses), but never remove: curation
213
+ * that deletes a person's documents stays a settings-window act.
218
214
  */
219
215
  issue(conversationId: string): string;
220
216
  release(token: string): void;
221
217
  private identity;
222
- /** The agent-facing endpoint: a title catalogue and a search. Nothing that writes. */
218
+ /**
219
+ * The agent-facing endpoint: a title catalogue, a search, and an import. The import is the SAME
220
+ * `add` the settings window calls - path guard, format whitelist, size caps, dedupe - so an agent
221
+ * cannot do anything a person could not, and the one thing it cannot do is `remove`.
222
+ */
223
223
  tool(token: string, body: {
224
224
  kind?: string;
225
225
  query?: string;
226
226
  limit?: number;
227
+ path?: string;
228
+ title?: string;
229
+ text?: string;
227
230
  }, signal?: AbortSignal): Promise<{
228
231
  documents: {
229
232
  title: string;
230
233
  source: string;
231
234
  characters: number;
232
235
  }[];
236
+ imported?: undefined;
237
+ hits?: undefined;
238
+ reranked?: undefined;
239
+ } | {
240
+ documents?: undefined;
241
+ imported: Omit<KnowledgeDocument, "text">;
233
242
  hits?: undefined;
234
243
  reranked?: undefined;
235
244
  } | {
236
245
  documents?: undefined;
246
+ imported?: undefined;
237
247
  hits: KnowledgeHit[];
238
248
  reranked: boolean;
239
249
  }>;
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, join } from "node:path";
4
4
  import { looksBinary, resolveInsideRoot } from "./files.js";
5
+ import { documentFormatOf, DocumentError, extractText } from "./documents.js";
5
6
  /** 1 MB. Big enough for a design doc or a spec, small enough that chunking stays instant. */
6
7
  export const MAX_DOCUMENT_BYTES = 1024 * 1024;
7
8
  /** A pasted document rides inside a JSON body, so the request cap leaves room for escaping overhead. */
@@ -53,6 +54,8 @@ const HEADING = /^#{1,6}\s+(.*\S)\s*$/;
53
54
  const PASTED_SOURCE = "pasted";
54
55
  /** Duplicated as a literal in `agents/super-pi-knowledge/index.ts`, the way `TASK_TOOL_NAME` is. */
55
56
  export const KNOWLEDGE_TOOL_NAME = "knowledge_search";
57
+ /** The import half of the same extension. Duplicated as a literal there, for the same reason. */
58
+ export const KNOWLEDGE_IMPORT_TOOL_NAME = "knowledge_import";
56
59
  /** Extensions a document may be imported from. Mirrors `files.TEXT_EXTENSIONS`, minus the source languages. */
57
60
  const IMPORTABLE = new Set([
58
61
  ".md", ".markdown", ".mdx", ".txt", ".text", ".log", ".rst", ".org",
@@ -369,30 +372,6 @@ export class KnowledgeBase {
369
372
  const documents = (await this.readAll(workspaceId)).map(document => this.summarise(document));
370
373
  return documents.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
371
374
  }
372
- /**
373
- * Whether this workspace has any document at all, which is what decides whether the turn mounts the
374
- * extension.
375
- *
376
- * WHY A DIRECTORY LISTING AND NOT `list()`. This runs once per turn, before every spawn. `list()`
377
- * would read and parse every document's full text to answer a yes/no question, so a workspace with
378
- * a dozen real documents would pay for parsing all of them on every message - including the
379
- * catalogue fetch the extension makes the moment it loads. `readdir` answers the same question for
380
- * the price of one directory entry, and a name is all this needs.
381
- */
382
- async hasDocuments(workspaceId) {
383
- if (!this.enabled())
384
- return false;
385
- if (!this.store.snapshot().workspaces.some(workspace => workspace.id === workspaceId))
386
- return false;
387
- try {
388
- return (await readdir(this.folder(workspaceId))).some(name => name.endsWith(".json"));
389
- }
390
- catch (error) {
391
- if (error.code === "ENOENT")
392
- return false;
393
- throw error;
394
- }
395
- }
396
375
  /**
397
376
  * Adds a document, either from a workspace file or from pasted text.
398
377
  *
@@ -401,6 +380,10 @@ export class KnowledgeBase {
401
380
  * newer version that diverges from the one the workspace serves. The path therefore goes through
402
381
  * the same `resolveInsideRoot` guard the file editor uses, so symlink and `..` escapes are refused
403
382
  * by construction instead of by a check someone has to remember.
383
+ *
384
+ * WHO CALLS THIS. The settings window (manage, action "add") AND, since the import tool exists,
385
+ * the primary agent mid-turn on the user's say-so - through the same guard, the same whitelist and
386
+ * the same caps, so an agent import can never do more than a person's import could.
404
387
  */
405
388
  async add(workspaceId, body) {
406
389
  this.requireWorkspace(workspaceId);
@@ -417,17 +400,42 @@ export class KnowledgeBase {
417
400
  if (!info.isFile())
418
401
  throw new Error("只能导入文件");
419
402
  format = extname(absolute).toLocaleLowerCase();
420
- if (!IMPORTABLE.has(format))
403
+ const documentFormat = documentFormatOf(format);
404
+ if (!IMPORTABLE.has(format) && !documentFormat) {
421
405
  throw new Error(`不支持的文件类型 ${format || "(无扩展名)"};请先转换为 Markdown 或纯文本`);
406
+ }
422
407
  if (info.size > MAX_DOCUMENT_BYTES)
423
408
  throw new Error(`文件过大:${info.size} 字节超过 ${MAX_DOCUMENT_BYTES} 的上限`);
424
- const buffer = await readFile(absolute);
425
- if (looksBinary(buffer.subarray(0, 4096)))
426
- throw new Error("看起来是二进制文件,无法索引");
427
- text = buffer.toString("utf8");
428
409
  source = body.path.trim();
429
410
  if (!title)
430
411
  title = basename(absolute);
412
+ if (documentFormat) {
413
+ // A DOCUMENT IS PARSED, NOT DECODED. The bytes on disk are a container - a PDF's or a
414
+ // docx's - and what goes into the knowledge base is the TEXT they contain. The format
415
+ // recorded on the document stays the SOURCE format (`.pdf`), because that is what the next
416
+ // re-import of this path has to parse it as: the extracted text is not a PDF, and treating
417
+ // it as one would make an update fail to parse at all.
418
+ //
419
+ // A DOCUMENT THAT COULD NOT BE READ IS A REFUSAL, NOT AN EMPTY DOCUMENT. The parser's own
420
+ // reasons are already written for a person - a scan with no text layer, an encrypted file,
421
+ // a missing package - and they are passed through unchanged, because a person who is told
422
+ // "读取失败" learns nothing about what to do next.
423
+ try {
424
+ const extracted = await extractText(absolute, documentFormat);
425
+ text = extracted.text;
426
+ }
427
+ catch (error) {
428
+ if (error instanceof DocumentError)
429
+ throw new Error(error.message);
430
+ throw error;
431
+ }
432
+ }
433
+ else {
434
+ const buffer = await readFile(absolute);
435
+ if (looksBinary(buffer.subarray(0, 4096)))
436
+ throw new Error("看起来是二进制文件,无法索引");
437
+ text = buffer.toString("utf8");
438
+ }
431
439
  }
432
440
  const trimmed = text.trim();
433
441
  if (!trimmed)
@@ -677,8 +685,9 @@ export class KnowledgeBase {
677
685
  }
678
686
  }
679
687
  /**
680
- * Mints a read-only token for one turn. The agent can retrieve, never write: everything a person
681
- * puts in a knowledge base is theirs to curate from the settings window.
688
+ * Mints a token for one turn. The agent can retrieve and IMPORT (a document the user just asked it
689
+ * to add rides through the same `add` guard the settings window uses), but never remove: curation
690
+ * that deletes a person's documents stays a settings-window act.
682
691
  */
683
692
  issue(conversationId) {
684
693
  const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
@@ -697,13 +706,19 @@ export class KnowledgeBase {
697
706
  throw new Error("知识库授权已失效");
698
707
  return identity;
699
708
  }
700
- /** The agent-facing endpoint: a title catalogue and a search. Nothing that writes. */
709
+ /**
710
+ * The agent-facing endpoint: a title catalogue, a search, and an import. The import is the SAME
711
+ * `add` the settings window calls - path guard, format whitelist, size caps, dedupe - so an agent
712
+ * cannot do anything a person could not, and the one thing it cannot do is `remove`.
713
+ */
701
714
  async tool(token, body, signal) {
702
715
  const identity = this.identity(token);
703
716
  if (!this.enabled())
704
717
  throw new Error("知识库已关闭");
705
718
  if (body.kind === "catalogue")
706
719
  return { documents: (await this.list(identity.workspaceId)).map(document => ({ title: document.title, source: document.source, characters: document.characters })) };
720
+ if (body.kind === "import")
721
+ return { imported: await this.add(identity.workspaceId, { path: body.path, title: body.title, text: body.text }) };
707
722
  if (body.kind !== "search")
708
723
  throw new Error("未知知识库操作");
709
724
  // The reranker needs the conversation it is ranking FOR: that is whose model answers, whose
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
24
  import { join } from "node:path";
25
- import { createPiSession, sameFingerprint, superPiExtensionPaths, } from "./pi-session.js";
25
+ import { createPiSession, sameFingerprint, superPiExtensionPaths, superPiSkillPaths, } from "./pi-session.js";
26
26
  /** How long an idle session survives before it is evicted. */
27
27
  const IDLE_EVICTION_MS = 10 * 60_000;
28
28
  /**
@@ -170,6 +170,7 @@ export class PiSessionPool {
170
170
  sessionId: request.conversationId,
171
171
  sessionDir: this.sessionDir,
172
172
  extensionPaths: superPiExtensionPaths(this.repositoryRoot),
173
+ skillPaths: superPiSkillPaths(this.repositoryRoot),
173
174
  extensionEnv: request.extensionEnv,
174
175
  ...(request.systemPromptFile ? { systemPromptFile: request.systemPromptFile } : {}),
175
176
  ...(request.systemPromptMode ? { systemPromptMode: request.systemPromptMode } : {}),
@@ -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
  *
@@ -336,6 +343,8 @@ export interface SessionCreation {
336
343
  sessionDir: string;
337
344
  /** Super Pi's extensions to mount, as absolute paths. */
338
345
  extensionPaths: string[];
346
+ /** Super Pi's built-in skills to expose beside the user's and workspace's skills. */
347
+ skillPaths?: string[];
339
348
  /** The environment those extensions read when they are loaded. */
340
349
  extensionEnv: Record<string, string>;
341
350
  /** The agent's system prompt file, or undefined for Pi's own defaults. */
@@ -363,14 +372,17 @@ export interface SessionCreation {
363
372
  *
364
373
  * The order of operations here IS the isolation mechanism, and each step is load-bearing:
365
374
  *
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.
375
+ * 1. The loader is created with Super Pi's extension paths, for PI to load - through its jiti
376
+ * transpiler and virtual modules, the same machinery the spawned CLI used. Nothing here imports a
377
+ * Super Pi extension file directly; see the comment at the loader below for why that must never
378
+ * come back.
379
+ * 2. The reload runs inside this session's env window (`withIsolatedSessionEnv`), which is when Pi
380
+ * calls the extension factories. The factories CAPTURE the values; they do not read them later.
370
381
  * 3. The session is created with that loader.
371
382
  *
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.
383
+ * Step 2 happening outside its env window would give every session the same configuration, and the
384
+ * window would give every session no configuration at all if two creations could interleave - which
385
+ * the serialization in `withIsolatedSessionEnv` prevents.
374
386
  *
375
387
  * THE FACTORY IS INJECTABLE. A test needs to drive a session without a real Pi behind it - to check what
376
388
  * a session was BUILT with (its working directory, its allowlist) rather than what it eventually
@@ -410,3 +422,5 @@ export interface PromptTemplateSummary {
410
422
  }
411
423
  export declare function loadPromptTemplateSummaries(cwd: string): Promise<PromptTemplateSummary[]>;
412
424
  export declare function superPiExtensionPaths(repositoryRoot: string): string[];
425
+ /** Skills shipped by Super Pi itself. Paths are explicit so installed and development layouts agree. */
426
+ export declare function superPiSkillPaths(repositoryRoot: string): string[];
@@ -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,18 @@ 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 } : {}),
272
+ ...(creation.skillPaths?.length ? { additionalSkillPaths: creation.skillPaths } : {}),
254
273
  ...(creation.systemPromptFile && creation.systemPromptMode === "replace"
255
274
  ? { systemPrompt: creation.systemPromptFile }
256
275
  : creation.systemPromptFile
257
276
  ? { appendSystemPrompt: [creation.systemPromptFile] }
258
277
  : {}),
259
278
  });
260
- await loader.reload();
279
+ // The extension factories run inside `reload()` and capture this session's env values into their
280
+ // closures; the window is serialized (`withIsolatedSessionEnv`) so a concurrent creation cannot
281
+ // land its own capability in the middle.
282
+ await withIsolatedSessionEnv(creation.extensionEnv, () => loader.reload());
261
283
  traceSession(creation.sessionId, "sdk.loader.reload", startedAt, `extensions=${creation.extensionPaths.length}`);
262
284
  const sessionManager = creation.persist === false
263
285
  ? sdk.SessionManager.inMemory(creation.cwd, { id: creation.sessionId })
@@ -392,6 +414,16 @@ export function superPiExtensionPaths(repositoryRoot) {
392
414
  join(repositoryRoot, "agents", "super-pi-task", "index.ts"),
393
415
  join(repositoryRoot, "agents", "super-pi-memory", "index.ts"),
394
416
  join(repositoryRoot, "agents", "super-pi-knowledge", "index.ts"),
417
+ join(repositoryRoot, "agents", "super-pi-web", "index.ts"),
418
+ // The document tools. They are mounted for every session, and the extension itself decides
419
+ // whether to register anything: with no endpoint in its environment it returns without
420
+ // registering a tool, so a runtime without a document endpoint mounts nothing harmful.
421
+ join(repositoryRoot, "agents", "super-pi-documents", "index.ts"),
395
422
  ];
396
423
  return paths.filter((path) => existsSync(path));
397
424
  }
425
+ /** Skills shipped by Super Pi itself. Paths are explicit so installed and development layouts agree. */
426
+ export function superPiSkillPaths(repositoryRoot) {
427
+ const paths = [join(repositoryRoot, "skills", "presentation")];
428
+ return paths.filter((path) => existsSync(join(path, "SKILL.md")));
429
+ }
@@ -0,0 +1,120 @@
1
+ /** Versioned, renderer-independent contract for structured presentation tools. */
2
+ export declare const PRESENTATION_SPEC_VERSION: 1;
3
+ export declare const PRESENTATION_LIMITS: {
4
+ readonly maxSlides: 200;
5
+ readonly maxElementsPerSlide: 500;
6
+ readonly maxTextCharacters: 100000;
7
+ readonly maxTableCells: 10000;
8
+ readonly maxChartPoints: 20000;
9
+ };
10
+ export type PresentationSpec = {
11
+ version: typeof PRESENTATION_SPEC_VERSION;
12
+ metadata?: {
13
+ title?: string;
14
+ subject?: string;
15
+ author?: string;
16
+ company?: string;
17
+ language?: string;
18
+ };
19
+ canvas: {
20
+ width: number;
21
+ height: number;
22
+ };
23
+ theme?: PresentationTheme;
24
+ slides: PresentationSlide[];
25
+ };
26
+ export type PresentationTheme = {
27
+ colors?: Record<string, string>;
28
+ fonts?: {
29
+ heading?: string;
30
+ body?: string;
31
+ mono?: string;
32
+ };
33
+ };
34
+ export type PresentationSlide = {
35
+ id: string;
36
+ title?: string;
37
+ notes?: string;
38
+ background?: string;
39
+ elements: PresentationElement[];
40
+ };
41
+ type Frame = {
42
+ x: number;
43
+ y: number;
44
+ width: number;
45
+ height: number;
46
+ };
47
+ type BaseElement = Frame & {
48
+ id: string;
49
+ altText?: string;
50
+ name?: string;
51
+ hidden?: boolean;
52
+ rotation?: number;
53
+ };
54
+ export type TextElement = BaseElement & {
55
+ type: "text";
56
+ text: string;
57
+ fontFamily?: string;
58
+ fontSize?: number;
59
+ color?: string;
60
+ bold?: boolean;
61
+ italic?: boolean;
62
+ align?: "left" | "center" | "right" | "justify";
63
+ verticalAlign?: "top" | "middle" | "bottom";
64
+ link?: string;
65
+ };
66
+ export type ShapeElement = BaseElement & {
67
+ type: "shape";
68
+ shape: "rectangle" | "roundRectangle" | "ellipse" | "triangle" | "chevron";
69
+ fill?: string;
70
+ stroke?: string;
71
+ strokeWidth?: number;
72
+ };
73
+ export type LineElement = BaseElement & {
74
+ type: "line";
75
+ stroke?: string;
76
+ strokeWidth?: number;
77
+ dash?: "solid" | "dash" | "dot";
78
+ beginArrow?: boolean;
79
+ endArrow?: boolean;
80
+ };
81
+ export type ImageElement = BaseElement & {
82
+ type: "image";
83
+ source: string;
84
+ fit?: "contain" | "cover" | "stretch";
85
+ link?: string;
86
+ };
87
+ export type TableElement = BaseElement & {
88
+ type: "table";
89
+ rows: Array<Array<string | number | boolean | null>>;
90
+ headerRows?: number;
91
+ };
92
+ export type ChartElement = BaseElement & {
93
+ type: "chart";
94
+ chart: "bar" | "column" | "line" | "area" | "pie" | "doughnut" | "scatter";
95
+ categories?: string[];
96
+ series: Array<{
97
+ name: string;
98
+ values: Array<number | null>;
99
+ xValues?: Array<number | null>;
100
+ }>;
101
+ showLegend?: boolean;
102
+ showValues?: boolean;
103
+ };
104
+ export type PresentationElement = TextElement | ShapeElement | LineElement | ImageElement | TableElement | ChartElement;
105
+ export type PresentationDiagnostic = {
106
+ code: "invalid_type" | "invalid_value" | "unsupported_version" | "duplicate_id" | "limit_exceeded" | "out_of_bounds";
107
+ severity: "error" | "warning";
108
+ path: string;
109
+ message: string;
110
+ };
111
+ export type PresentationValidationResult = {
112
+ valid: true;
113
+ spec: PresentationSpec;
114
+ diagnostics: PresentationDiagnostic[];
115
+ } | {
116
+ valid: false;
117
+ diagnostics: PresentationDiagnostic[];
118
+ };
119
+ export declare function validatePresentationSpec(input: unknown): PresentationValidationResult;
120
+ export {};
@@ -0,0 +1,128 @@
1
+ /** Versioned, renderer-independent contract for structured presentation tools. */
2
+ export const PRESENTATION_SPEC_VERSION = 1;
3
+ export const PRESENTATION_LIMITS = {
4
+ maxSlides: 200,
5
+ maxElementsPerSlide: 500,
6
+ maxTextCharacters: 100_000,
7
+ maxTableCells: 10_000,
8
+ maxChartPoints: 20_000,
9
+ };
10
+ const ELEMENT_TYPES = new Set(["text", "shape", "line", "image", "table", "chart"]);
11
+ const ID_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
12
+ export function validatePresentationSpec(input) {
13
+ const diagnostics = [];
14
+ const error = (code, path, message) => diagnostics.push({ code, severity: "error", path, message });
15
+ if (!isObject(input)) {
16
+ error("invalid_type", "$", "Presentation spec must be an object.");
17
+ return { valid: false, diagnostics };
18
+ }
19
+ if (input.version !== PRESENTATION_SPEC_VERSION) {
20
+ error("unsupported_version", "$.version", `Only presentation spec version ${PRESENTATION_SPEC_VERSION} is supported.`);
21
+ }
22
+ const canvas = input.canvas;
23
+ if (!isObject(canvas))
24
+ error("invalid_type", "$.canvas", "Canvas must be an object.");
25
+ else {
26
+ positiveNumber(canvas.width, "$.canvas.width", error);
27
+ positiveNumber(canvas.height, "$.canvas.height", error);
28
+ }
29
+ if (!Array.isArray(input.slides)) {
30
+ error("invalid_type", "$.slides", "Slides must be an array.");
31
+ }
32
+ else {
33
+ if (input.slides.length > PRESENTATION_LIMITS.maxSlides) {
34
+ error("limit_exceeded", "$.slides", `A deck may contain at most ${PRESENTATION_LIMITS.maxSlides} slides.`);
35
+ }
36
+ const ids = new Set();
37
+ input.slides.forEach((slide, slideIndex) => validateSlide(slide, slideIndex, canvas, ids, error));
38
+ }
39
+ if (input.theme !== undefined)
40
+ validateTheme(input.theme, error);
41
+ return diagnostics.some((item) => item.severity === "error")
42
+ ? { valid: false, diagnostics }
43
+ : { valid: true, spec: input, diagnostics };
44
+ }
45
+ function validateSlide(slide, index, canvas, ids, error) {
46
+ const path = `$.slides[${index}]`;
47
+ if (!isObject(slide))
48
+ return error("invalid_type", path, "Slide must be an object.");
49
+ validateId(slide.id, `${path}.id`, ids, error);
50
+ if (!Array.isArray(slide.elements))
51
+ return error("invalid_type", `${path}.elements`, "Elements must be an array.");
52
+ if (slide.elements.length > PRESENTATION_LIMITS.maxElementsPerSlide) {
53
+ error("limit_exceeded", `${path}.elements`, `A slide may contain at most ${PRESENTATION_LIMITS.maxElementsPerSlide} elements.`);
54
+ }
55
+ slide.elements.forEach((element, elementIndex) => validateElement(element, `${path}.elements[${elementIndex}]`, canvas, ids, error));
56
+ }
57
+ function validateElement(element, path, canvas, ids, error) {
58
+ if (!isObject(element))
59
+ return error("invalid_type", path, "Element must be an object.");
60
+ validateId(element.id, `${path}.id`, ids, error);
61
+ if (typeof element.type !== "string" || !ELEMENT_TYPES.has(element.type)) {
62
+ error("invalid_value", `${path}.type`, "Element type is not supported.");
63
+ return;
64
+ }
65
+ for (const key of ["x", "y", "width", "height"])
66
+ positiveNumber(element[key], `${path}.${key}`, error, key === "x" || key === "y");
67
+ if (isObject(canvas) && finite(element.x) && finite(element.y) && finite(element.width) && finite(element.height)
68
+ && finite(canvas.width) && finite(canvas.height)
69
+ && (element.x < 0 || element.y < 0 || element.x + element.width > canvas.width || element.y + element.height > canvas.height)) {
70
+ error("out_of_bounds", path, "Element frame must fit within the canvas.");
71
+ }
72
+ if (element.type === "text") {
73
+ if (typeof element.text !== "string")
74
+ error("invalid_type", `${path}.text`, "Text must be a string.");
75
+ else if (element.text.length > PRESENTATION_LIMITS.maxTextCharacters)
76
+ error("limit_exceeded", `${path}.text`, "Text is too long.");
77
+ }
78
+ else if (element.type === "image") {
79
+ if (typeof element.source !== "string" || !element.source.trim())
80
+ error("invalid_value", `${path}.source`, "Image source is required.");
81
+ if (!element.altText)
82
+ error("invalid_value", `${path}.altText`, "Images require alt text.");
83
+ }
84
+ else if (element.type === "table") {
85
+ if (!Array.isArray(element.rows))
86
+ error("invalid_type", `${path}.rows`, "Table rows must be an array.");
87
+ else if (element.rows.reduce((sum, row) => sum + (Array.isArray(row) ? row.length : 0), 0) > PRESENTATION_LIMITS.maxTableCells)
88
+ error("limit_exceeded", `${path}.rows`, "Table contains too many cells.");
89
+ }
90
+ else if (element.type === "chart")
91
+ validateChart(element, path, error);
92
+ }
93
+ function validateChart(chart, path, error) {
94
+ if (!Array.isArray(chart.series) || chart.series.length === 0)
95
+ return error("invalid_value", `${path}.series`, "Chart requires at least one series.");
96
+ let points = 0;
97
+ for (const [index, series] of chart.series.entries()) {
98
+ if (!isObject(series) || typeof series.name !== "string" || !Array.isArray(series.values)) {
99
+ error("invalid_type", `${path}.series[${index}]`, "Series requires a name and numeric values.");
100
+ continue;
101
+ }
102
+ if (series.values.some((value) => value !== null && !finite(value)))
103
+ error("invalid_value", `${path}.series[${index}].values`, "Chart values must be finite numbers or null.");
104
+ points += series.values.length;
105
+ }
106
+ if (points > PRESENTATION_LIMITS.maxChartPoints)
107
+ error("limit_exceeded", `${path}.series`, "Chart contains too many data points.");
108
+ }
109
+ function validateTheme(theme, error) {
110
+ if (!isObject(theme))
111
+ return error("invalid_type", "$.theme", "Theme must be an object.");
112
+ if (theme.colors !== undefined && (!isObject(theme.colors) || Object.values(theme.colors).some((value) => typeof value !== "string"))) {
113
+ error("invalid_type", "$.theme.colors", "Theme colors must map token names to color strings.");
114
+ }
115
+ }
116
+ function validateId(value, path, ids, error) {
117
+ if (typeof value !== "string" || !ID_PATTERN.test(value))
118
+ return error("invalid_value", path, "ID must start with a letter and contain only letters, digits, dot, underscore, or hyphen.");
119
+ if (ids.has(value))
120
+ error("duplicate_id", path, `ID '${value}' is already used in this deck.`);
121
+ ids.add(value);
122
+ }
123
+ function positiveNumber(value, path, error, allowZero = false) {
124
+ if (!finite(value) || (allowZero ? value < 0 : value <= 0))
125
+ error("invalid_value", path, allowZero ? "Value must be a non-negative finite number." : "Value must be a positive finite number.");
126
+ }
127
+ function finite(value) { return typeof value === "number" && Number.isFinite(value); }
128
+ function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }