@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 +1 -1
- package/package.json +27 -8
- package/src/index.ts +34 -9
- package/src/prompt.ts +8 -8
- package/src/souls.ts +3 -2
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
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
/**
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
throw
|
|
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`);
|