@astrosheep/pi-souls 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,4 +38,4 @@ One Markdown file per soul; the body becomes the identity. Frontmatter is stripp
38
38
 
39
39
  Precedence: `/soul <name>` > `modelSouls` (exact `provider/modelId`) > `defaultSoul` > Pi default.
40
40
 
41
- The session choice follows resume, fork, and tree navigation. A bad entry only breaks the case that uses it; that request falls back to Pi's prompt with an error. `--system-prompt` and `SYSTEM.md` outrank a settings soul but not `/soul`. If another extension forces the whole prompt, souls are skipped.
41
+ The session choice follows resume, fork, and tree navigation. `--system-prompt` and `SYSTEM.md` outrank a settings soul but not `/soul`. A bad entry only breaks the case that uses it. When a soul can't apply (a bad entry, a missing file, or another extension forcing the whole prompt), Pi's prompt is used and a warning shows once, not on every request.
package/package.json CHANGED
@@ -1,20 +1,39 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-souls",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Session souls with model-specific defaults for Pi",
5
5
  "type": "module",
6
- "keywords": ["pi-package"],
7
- "repository": { "type": "git", "url": "https://github.com/astrosheep-zero/pi-souls.git" },
8
- "publishConfig": { "access": "public" },
9
- "files": ["src", "README.md"],
10
- "pi": { "extensions": ["./src/index.ts"] },
11
- "engines": { "node": ">=22.18" },
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/astrosheep-zero/pi-souls.git"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./src/index.ts"
23
+ ]
24
+ },
25
+ "engines": {
26
+ "node": ">=22.18"
27
+ },
12
28
  "scripts": {
13
29
  "test": "node --experimental-strip-types --test test/*.test.ts",
14
30
  "typecheck": "tsc --noEmit",
15
31
  "check:load": "pi --no-extensions -e ./src/index.ts --list-models __load_check__"
16
32
  },
17
- "peerDependencies": { "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-agent-core": "*" },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-agent-core": "*"
36
+ },
18
37
  "devDependencies": {
19
38
  "@earendil-works/pi-coding-agent": "1.1.0",
20
39
  "@earendil-works/pi-agent-core": "1.1.0",
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { homedir } from "node:os";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import { applySoul } from "./prompt.ts";
4
5
  import { ENTRY_TYPE, effectiveSoul, listSouls, loadSoul, readConfig, resolveSoul, sessionSoul, unmatchableModelKeys, type Selection } from "./souls.ts";
5
6
 
@@ -15,10 +16,16 @@ function describe(selection: Selection | undefined, model: ExtensionContext["mod
15
16
  export default function soulsExtension(pi: ExtensionAPI) {
16
17
  // Argument completion has no context; remember where the session runs for relative soulsDir.
17
18
  let cwd = process.cwd();
18
- pi.on("session_start", (_event, ctx) => { cwd = ctx.cwd; });
19
- // context_with_system cannot read prompt options; before_agent_start can, and runs first.
20
- let customPrompt: string | undefined;
21
- pi.on("before_agent_start", (event) => { customPrompt = event.systemPromptOptions.customPrompt; });
19
+ // A problem is reported when it appears or changes, not on every request of a tool loop.
20
+ let reported: string | undefined;
21
+ pi.on("session_start", (_event, ctx) => { cwd = ctx.cwd; reported = undefined; });
22
+ // context_with_system cannot read prompt options. Hold the last run's live options, not a copy:
23
+ // handlers after ours may still set customPrompt or forceSystemPrompt. customPrompt is session
24
+ // configuration and stays valid; a forced prompt only applies while its run is going.
25
+ let options: BuildSystemPromptOptions | undefined;
26
+ let running = false;
27
+ pi.on("before_agent_start", (event) => { options = event.systemPromptOptions; running = true; });
28
+ pi.on("agent_settled", () => { running = false; });
22
29
 
23
30
  pi.registerCommand("soul", {
24
31
  description: "Switch soul: /soul [name|clear]",
@@ -85,10 +92,28 @@ export default function soulsExtension(pi: ExtensionAPI) {
85
92
  },
86
93
  });
87
94
 
88
- pi.on("context_with_system", (event, ctx) => {
95
+ const project = (messages: AgentMessage[], ctx: ExtensionContext): AgentMessage[] | undefined => {
89
96
  const config = readConfig(pi.getSettings(), ctx.cwd);
90
- const selection = effectiveSoul(resolveSoul(config, sessionSoul(ctx.sessionManager.getBranch()), ctx.model), customPrompt);
91
- if (!selection) return;
92
- return { messages: applySoul(event.messages, loadSoul(config.dir, selection.name)) };
97
+ const selection = effectiveSoul(resolveSoul(config, sessionSoul(ctx.sessionManager.getBranch()), ctx.model), options?.customPrompt);
98
+ if (!selection) return undefined;
99
+ // Pi projects a forced prompt after this hook, over whatever it returns.
100
+ if (running && options?.forceSystemPrompt !== undefined) throw new Error("Soul skipped: another extension replaced the whole prompt");
101
+ return applySoul(messages, loadSoul(config.dir, selection.name));
102
+ };
103
+
104
+ pi.on("context_with_system", (event, ctx) => {
105
+ try {
106
+ const messages = project(event.messages, ctx);
107
+ reported = undefined;
108
+ return messages && { messages };
109
+ } catch (error) {
110
+ // The request goes out with Pi's own prompt; an unchanged problem skips quietly.
111
+ const problem = error instanceof Error ? error.message : String(error);
112
+ if (problem === reported) return;
113
+ reported = problem;
114
+ // Without a UI (print mode), only a thrown error reaches the user, on stderr.
115
+ if (!ctx.hasUI) throw error;
116
+ ctx.ui.notify(problem, "warning");
117
+ }
93
118
  });
94
119
  }
package/src/prompt.ts CHANGED
@@ -2,16 +2,16 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
 
3
3
  type SystemMessage = Extract<AgentMessage, { role: "system" }>;
4
4
 
5
- function hasPreamble(message: AgentMessage | undefined): message is SystemMessage {
6
- return message?.role === "system" && typeof message.sections?.preamble === "string";
5
+ function hasPreamble(message: AgentMessage): message is SystemMessage {
6
+ return message.role === "system" && typeof message.sections?.preamble === "string";
7
7
  }
8
8
 
9
- /** Request-only projection. Keep runtime sections, declaration deltas and conversation intact. */
9
+ /**
10
+ * Request-only projection. Keep runtime sections, declaration deltas and conversation intact.
11
+ * Pi replays every system message in order and the preamble can sit in a later one (sessions older
12
+ * than system messages), so every preamble patch carries the soul: an old identity must not win.
13
+ */
10
14
  export function applySoul(messages: AgentMessage[], body: string): AgentMessage[] {
11
- const head = messages[0];
12
- if (!hasPreamble(head) || head.content) {
13
- throw new Error("Soul skipped: system prompt fully replaced");
14
- }
15
- // Rewrite historical preamble deltas too: an old identity must not override today's soul.
15
+ if (!messages.some(hasPreamble)) throw new Error("Soul skipped: the prompt has no preamble");
16
16
  return messages.map((message) => hasPreamble(message) ? { ...message, sections: { ...message.sections, preamble: body } } : message);
17
17
  }
package/src/souls.ts CHANGED
@@ -85,8 +85,9 @@ export function loadSoul(dir: string, name: string): string {
85
85
  try {
86
86
  text = readFileSync(path, "utf8");
87
87
  } catch (error) {
88
- if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Soul "${name}" not found: ${path}`);
89
- throw error;
88
+ const code = (error as NodeJS.ErrnoException).code;
89
+ if (code === "ENOENT") throw new Error(`Soul "${name}" not found: ${path}`);
90
+ throw new Error(`Soul "${name}" unreadable: ${path} (${code ?? String(error)})`);
90
91
  }
91
92
  const body = stripFrontmatter(text).trim();
92
93
  if (!body) throw new Error(`Soul "${name}" is empty`);