@astrosheep/pi-souls 0.1.1 → 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. `--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.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-souls",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Session souls with model-specific defaults for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -1,30 +1,38 @@
1
1
  import { homedir } from "node:os";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { applySoul } from "./prompt.ts";
4
+ import { applySoul, replaceForcedPreamble } from "./prompt.ts";
5
5
  import { ENTRY_TYPE, effectiveSoul, listSouls, loadSoul, readConfig, resolveSoul, sessionSoul, unmatchableModelKeys, type Selection } from "./souls.ts";
6
6
 
7
7
  const home = homedir();
8
8
  const tilde = (path: string) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
9
9
 
10
- function describe(selection: Selection | undefined, model: ExtensionContext["model"], suppressed = false): string {
11
- 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";
12
12
  if (selection.source === "model") return `${selection.name} (${model?.provider}/${model?.id})`;
13
13
  return `${selection.name} (${selection.source})`;
14
14
  }
15
15
 
16
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 ?? "");
17
19
  // Argument completion has no context; remember where the session runs for relative soulsDir.
18
20
  let cwd = process.cwd();
19
21
  // A problem is reported when it appears or changes, not on every request of a tool loop.
20
22
  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.
23
+ // Hold live options: handlers after ours can still set customPrompt or forceSystemPrompt.
25
24
  let options: BuildSystemPromptOptions | undefined;
26
25
  let running = false;
27
- pi.on("before_agent_start", (event) => { options = event.systemPromptOptions; running = true; });
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
+ });
28
36
  pi.on("agent_settled", () => { running = false; });
29
37
 
30
38
  pi.registerCommand("soul", {
@@ -50,11 +58,11 @@ export default function soulsExtension(pi: ExtensionAPI) {
50
58
  const config = readConfig(pi.getSettings(), ctx.cwd);
51
59
  const prompt = ctx.getSystemPromptOptions().customPrompt;
52
60
  const override = () => sessionSoul(ctx.sessionManager.getBranch());
53
- const resolved = () => resolveSoul(config, override(), ctx.model);
61
+ const resolved = () => resolveSoul(config, override(), ctx.model, soulless());
54
62
  const now = () => {
55
63
  const configured = resolved();
56
64
  const active = effectiveSoul(configured, prompt);
57
- return describe(active, ctx.model, active === undefined && configured !== undefined);
65
+ return describe(active, ctx.model, active === undefined && configured !== undefined, soulless());
58
66
  };
59
67
 
60
68
  if (name === "clear") {
@@ -81,7 +89,7 @@ export default function soulsExtension(pi: ExtensionAPI) {
81
89
  const missing = selection && !available.includes(selection.name) ? " (missing)" : "";
82
90
  const ignored = unmatchableModelKeys(config);
83
91
  notify([
84
- `Soul: ${describe(selection, ctx.model, selection === undefined && configured !== undefined)}${missing}`,
92
+ `Soul: ${describe(selection, ctx.model, selection === undefined && configured !== undefined, soulless())}${missing}`,
85
93
  `Souls: ${available.join(", ") || "none"}`,
86
94
  `Dir: ${tilde(config.dir)}`,
87
95
  ...(ignored.length ? [`Ignored keys: ${ignored.join(", ")}`] : []),
@@ -94,11 +102,27 @@ export default function soulsExtension(pi: ExtensionAPI) {
94
102
 
95
103
  const project = (messages: AgentMessage[], ctx: ExtensionContext): AgentMessage[] | undefined => {
96
104
  const config = readConfig(pi.getSettings(), ctx.cwd);
97
- const selection = effectiveSoul(resolveSoul(config, sessionSoul(ctx.sessionManager.getBranch()), ctx.model), options?.customPrompt);
105
+ const selection = effectiveSoul(resolveSoul(config, sessionSoul(ctx.sessionManager.getBranch()), ctx.model, soulless()), options?.customPrompt);
98
106
  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));
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;
102
126
  };
103
127
 
104
128
  pi.on("context_with_system", (event, ctx) => {
package/src/prompt.ts CHANGED
@@ -6,6 +6,14 @@ function hasPreamble(message: AgentMessage): message is SystemMessage {
6
6
  return message.role === "system" && typeof message.sections?.preamble === "string";
7
7
  }
8
8
 
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
+
9
17
  /**
10
18
  * Request-only projection. Keep runtime sections, declaration deltas and conversation intact.
11
19
  * Pi replays every system message in order and the preamble can sit in a later one (sessions older
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");