@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 +9 -2
- package/package.json +27 -8
- package/src/index.ts +64 -15
- package/src/prompt.ts +16 -8
- package/src/souls.ts +5 -3
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
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Session souls with model-specific defaults for Pi",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"keywords": [
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
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": {
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
return message
|
|
5
|
+
function hasPreamble(message: AgentMessage): message is SystemMessage {
|
|
6
|
+
return message.role === "system" && typeof message.sections?.preamble === "string";
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
throw
|
|
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`);
|