@astrosheep/pi-souls 0.1.0 → 0.1.2

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
@@ -16,6 +16,13 @@ pi install npm:@astrosheep/pi-souls
16
16
 
17
17
  Names tab-complete.
18
18
 
19
+ ```bash
20
+ pi --soulless
21
+ SOULLESS=1 pi
22
+ ```
23
+
24
+ Skip settings souls for this process. `SOULLESS=1` or `true` enables it; `0`, `false`, or unset does not. A session `/soul <name>` still wins, including resumed selections; `/soul clear` returns to soulless.
25
+
19
26
  ## Souls
20
27
 
21
28
  One Markdown file per soul; the body becomes the identity. Frontmatter is stripped, and edits apply on the next request.
@@ -36,6 +43,6 @@ One Markdown file per soul; the body becomes the identity. Frontmatter is stripp
36
43
  }
37
44
  ```
38
45
 
39
- Precedence: `/soul <name>` > `modelSouls` (exact `provider/modelId`) > `defaultSoul` > Pi default.
46
+ Precedence: `/soul <name>` > `--soulless` / `SOULLESS` / explicit system prompt > `modelSouls` (exact `provider/modelId`) > `defaultSoul` > Pi default.
40
47
 
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.
48
+ 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 another extension forces the whole prompt, souls warns but still replaces the latest preamble if its exact text remains in the forced prompt, preserving surrounding instructions. When a soul can't apply (a bad entry, a missing file, or no matching preamble), 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.2",
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,24 +1,39 @@
1
1
  import { homedir } from "node:os";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { applySoul } from "./prompt.ts";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { applySoul, replaceForcedPreamble } from "./prompt.ts";
4
5
  import { ENTRY_TYPE, effectiveSoul, listSouls, loadSoul, readConfig, resolveSoul, sessionSoul, unmatchableModelKeys, type Selection } from "./souls.ts";
5
6
 
6
7
  const home = homedir();
7
8
  const tilde = (path: string) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
8
9
 
9
- function describe(selection: Selection | undefined, model: ExtensionContext["model"], suppressed = false): string {
10
- if (!selection) return suppressed ? "none (custom prompt)" : "none";
10
+ function describe(selection: Selection | undefined, model: ExtensionContext["model"], suppressed = false, soulless = false): string {
11
+ if (!selection) return soulless ? "none (soulless)" : suppressed ? "none (custom prompt)" : "none";
11
12
  if (selection.source === "model") return `${selection.name} (${model?.provider}/${model?.id})`;
12
13
  return `${selection.name} (${selection.source})`;
13
14
  }
14
15
 
15
16
  export default function soulsExtension(pi: ExtensionAPI) {
17
+ pi.registerFlag("soulless", { description: "Skip default souls (session /soul still wins)", type: "boolean", default: false });
18
+ const soulless = () => pi.getFlag("soulless") === true || /^(1|true)$/i.test(process.env.SOULLESS ?? "");
16
19
  // Argument completion has no context; remember where the session runs for relative soulsDir.
17
20
  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; });
21
+ // A problem is reported when it appears or changes, not on every request of a tool loop.
22
+ let reported: string | undefined;
23
+ // Hold live options: handlers after ours can still set customPrompt or forceSystemPrompt.
24
+ let options: BuildSystemPromptOptions | undefined;
25
+ let running = false;
26
+ let forceReported = false;
27
+ // Retain the original text so tool-loop requests and soul edits do not patch our own output.
28
+ let forcedProjection: { original: string; projected: string } | undefined;
29
+ pi.on("session_start", (_event, ctx) => {
30
+ cwd = ctx.cwd; reported = undefined; forceReported = false;
31
+ options = undefined; running = false; forcedProjection = undefined;
32
+ });
33
+ pi.on("before_agent_start", (event) => {
34
+ options = event.systemPromptOptions; running = true; forcedProjection = undefined;
35
+ });
36
+ pi.on("agent_settled", () => { running = false; });
22
37
 
23
38
  pi.registerCommand("soul", {
24
39
  description: "Switch soul: /soul [name|clear]",
@@ -43,11 +58,11 @@ export default function soulsExtension(pi: ExtensionAPI) {
43
58
  const config = readConfig(pi.getSettings(), ctx.cwd);
44
59
  const prompt = ctx.getSystemPromptOptions().customPrompt;
45
60
  const override = () => sessionSoul(ctx.sessionManager.getBranch());
46
- const resolved = () => resolveSoul(config, override(), ctx.model);
61
+ const resolved = () => resolveSoul(config, override(), ctx.model, soulless());
47
62
  const now = () => {
48
63
  const configured = resolved();
49
64
  const active = effectiveSoul(configured, prompt);
50
- return describe(active, ctx.model, active === undefined && configured !== undefined);
65
+ return describe(active, ctx.model, active === undefined && configured !== undefined, soulless());
51
66
  };
52
67
 
53
68
  if (name === "clear") {
@@ -74,7 +89,7 @@ export default function soulsExtension(pi: ExtensionAPI) {
74
89
  const missing = selection && !available.includes(selection.name) ? " (missing)" : "";
75
90
  const ignored = unmatchableModelKeys(config);
76
91
  notify([
77
- `Soul: ${describe(selection, ctx.model, selection === undefined && configured !== undefined)}${missing}`,
92
+ `Soul: ${describe(selection, ctx.model, selection === undefined && configured !== undefined, soulless())}${missing}`,
78
93
  `Souls: ${available.join(", ") || "none"}`,
79
94
  `Dir: ${tilde(config.dir)}`,
80
95
  ...(ignored.length ? [`Ignored keys: ${ignored.join(", ")}`] : []),
@@ -85,10 +100,44 @@ export default function soulsExtension(pi: ExtensionAPI) {
85
100
  },
86
101
  });
87
102
 
88
- pi.on("context_with_system", (event, ctx) => {
103
+ const project = (messages: AgentMessage[], ctx: ExtensionContext): AgentMessage[] | undefined => {
89
104
  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)) };
105
+ const selection = effectiveSoul(resolveSoul(config, sessionSoul(ctx.sessionManager.getBranch()), ctx.model, soulless()), options?.customPrompt);
106
+ if (!selection) return undefined;
107
+ const body = loadSoul(config.dir, selection.name);
108
+ const projected = applySoul(messages, body);
109
+ // Pi projects forced text after this hook; patch that too, not just its transcript sections.
110
+ if (running && options?.forceSystemPrompt !== undefined) {
111
+ if (!forceReported) {
112
+ const warning = "Soul: another extension forced the prompt; attempting preamble replacement";
113
+ if (ctx.hasUI) ctx.ui.notify(warning, "warning");
114
+ else console.warn(warning);
115
+ forceReported = true;
116
+ }
117
+ const forced = options.forceSystemPrompt;
118
+ const original = forced === forcedProjection?.projected ? forcedProjection.original : forced;
119
+ const patched = replaceForcedPreamble(messages, original, body);
120
+ forcedProjection = { original, projected: patched };
121
+ options.forceSystemPrompt = patched;
122
+ } else {
123
+ forceReported = false;
124
+ }
125
+ return projected;
126
+ };
127
+
128
+ pi.on("context_with_system", (event, ctx) => {
129
+ try {
130
+ const messages = project(event.messages, ctx);
131
+ reported = undefined;
132
+ return messages && { messages };
133
+ } catch (error) {
134
+ // The request goes out with Pi's own prompt; an unchanged problem skips quietly.
135
+ const problem = error instanceof Error ? error.message : String(error);
136
+ if (problem === reported) return;
137
+ reported = problem;
138
+ // Without a UI (print mode), only a thrown error reaches the user, on stderr.
139
+ if (!ctx.hasUI) throw error;
140
+ ctx.ui.notify(problem, "warning");
141
+ }
93
142
  });
94
143
  }
package/src/prompt.ts CHANGED
@@ -2,16 +2,24 @@ 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
+ /** Forced prompts render the latest replayed preamble; leave appended instructions intact. */
10
+ export function replaceForcedPreamble(messages: AgentMessage[], prompt: string, body: string): string {
11
+ const preamble = messages.findLast(hasPreamble)?.sections?.preamble;
12
+ if (!preamble || !prompt.includes(preamble)) throw new Error("Soul skipped: the forced prompt has no preamble");
13
+ // Use a callback so $ sequences in a soul are literal, not replacement patterns.
14
+ return prompt.replace(preamble, () => body);
15
+ }
16
+
17
+ /**
18
+ * Request-only projection. Keep runtime sections, declaration deltas and conversation intact.
19
+ * Pi replays every system message in order and the preamble can sit in a later one (sessions older
20
+ * than system messages), so every preamble patch carries the soul: an old identity must not win.
21
+ */
10
22
  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.
23
+ if (!messages.some(hasPreamble)) throw new Error("Soul skipped: the prompt has no preamble");
16
24
  return messages.map((message) => hasPreamble(message) ? { ...message, sections: { ...message.sections, preamble: body } } : message);
17
25
  }
package/src/souls.ts CHANGED
@@ -55,8 +55,9 @@ function named(name: unknown, source: Selection["source"], setting: string): Sel
55
55
  return { name, source };
56
56
  }
57
57
 
58
- export function resolveSoul(config: SoulConfig, override: string | undefined, model?: { provider: string; id: string }): Selection | undefined {
58
+ export function resolveSoul(config: SoulConfig, override: string | undefined, model?: { provider: string; id: string }, soulless = false): Selection | undefined {
59
59
  if (override) return { name: override, source: "session" };
60
+ if (soulless) return undefined;
60
61
  const key = model && `${model.provider}/${model.id}`;
61
62
  if (key && Object.hasOwn(config.modelSouls, key)) return named(config.modelSouls[key], "model", `modelSouls["${key}"]`);
62
63
  if (config.defaultSoul !== undefined) return named(config.defaultSoul, "default", "defaultSoul");
@@ -85,8 +86,9 @@ export function loadSoul(dir: string, name: string): string {
85
86
  try {
86
87
  text = readFileSync(path, "utf8");
87
88
  } catch (error) {
88
- if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Soul "${name}" not found: ${path}`);
89
- throw error;
89
+ const code = (error as NodeJS.ErrnoException).code;
90
+ if (code === "ENOENT") throw new Error(`Soul "${name}" not found: ${path}`);
91
+ throw new Error(`Soul "${name}" unreadable: ${path} (${code ?? String(error)})`);
90
92
  }
91
93
  const body = stripFrontmatter(text).trim();
92
94
  if (!body) throw new Error(`Soul "${name}" is empty`);