@astrosheep/pi-souls 0.0.0-stage → 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 +40 -2
- package/package.json +41 -4
- package/src/index.ts +119 -0
- package/src/prompt.ts +17 -0
- package/src/souls.ts +95 -0
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-souls
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Swap [Pi](https://github.com/earendil-works/pi)'s identity per session, per model, or by default. Only the identity changes; tools, rules, skills, and project context stay as they are.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pi install npm:@astrosheep/pi-souls
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
/soul # current soul and list
|
|
13
|
+
/soul faye # use faye for this session
|
|
14
|
+
/soul clear # back to defaults
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Names tab-complete.
|
|
18
|
+
|
|
19
|
+
## Souls
|
|
20
|
+
|
|
21
|
+
One Markdown file per soul; the body becomes the identity. Frontmatter is stripped, and edits apply on the next request.
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
~/.agents/souls/
|
|
25
|
+
├── faye.md
|
|
26
|
+
└── reviewer.md
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Settings
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"defaultSoul": "faye",
|
|
34
|
+
"modelSouls": { "anthropic/claude-sonnet-5": "reviewer" },
|
|
35
|
+
"soulsDir": "~/.agents/souls"
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Precedence: `/soul <name>` > `modelSouls` (exact `provider/modelId`) > `defaultSoul` > Pi default.
|
|
40
|
+
|
|
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,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-souls",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Session souls with model-specific defaults for Pi",
|
|
5
|
+
"type": "module",
|
|
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
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"check:load": "pi --no-extensions -e ./src/index.ts --list-models __load_check__"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"@earendil-works/pi-agent-core": "*"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
39
|
+
"@earendil-works/pi-agent-core": "1.1.0",
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
41
|
+
"typescript": "^6.0.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
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 } from "./prompt.ts";
|
|
5
|
+
import { ENTRY_TYPE, effectiveSoul, listSouls, loadSoul, readConfig, resolveSoul, sessionSoul, unmatchableModelKeys, type Selection } from "./souls.ts";
|
|
6
|
+
|
|
7
|
+
const home = homedir();
|
|
8
|
+
const tilde = (path: string) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
|
|
9
|
+
|
|
10
|
+
function describe(selection: Selection | undefined, model: ExtensionContext["model"], suppressed = false): string {
|
|
11
|
+
if (!selection) return suppressed ? "none (custom prompt)" : "none";
|
|
12
|
+
if (selection.source === "model") return `${selection.name} (${model?.provider}/${model?.id})`;
|
|
13
|
+
return `${selection.name} (${selection.source})`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function soulsExtension(pi: ExtensionAPI) {
|
|
17
|
+
// Argument completion has no context; remember where the session runs for relative soulsDir.
|
|
18
|
+
let cwd = process.cwd();
|
|
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; });
|
|
29
|
+
|
|
30
|
+
pi.registerCommand("soul", {
|
|
31
|
+
description: "Switch soul: /soul [name|clear]",
|
|
32
|
+
getArgumentCompletions: (prefix) => {
|
|
33
|
+
try {
|
|
34
|
+
const souls = listSouls(readConfig(pi.getSettings(), cwd).dir).map((name) => ({ value: name, label: name }));
|
|
35
|
+
const items = [...souls, { value: "clear", label: "clear", description: "Follow defaults" }];
|
|
36
|
+
const matches = items.filter((item) => item.value.startsWith(prefix.trim()));
|
|
37
|
+
return matches.length ? matches : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
handler: async (args, ctx) => {
|
|
43
|
+
const notify = (text: string) => ctx.ui.notify(text, "info");
|
|
44
|
+
if (!ctx.isIdle()) {
|
|
45
|
+
ctx.ui.notify("Busy. Switch souls after this run.", "warning");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const name = args.trim();
|
|
50
|
+
const config = readConfig(pi.getSettings(), ctx.cwd);
|
|
51
|
+
const prompt = ctx.getSystemPromptOptions().customPrompt;
|
|
52
|
+
const override = () => sessionSoul(ctx.sessionManager.getBranch());
|
|
53
|
+
const resolved = () => resolveSoul(config, override(), ctx.model);
|
|
54
|
+
const now = () => {
|
|
55
|
+
const configured = resolved();
|
|
56
|
+
const active = effectiveSoul(configured, prompt);
|
|
57
|
+
return describe(active, ctx.model, active === undefined && configured !== undefined);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (name === "clear") {
|
|
61
|
+
let selected = true; // A corrupted selection counts as something to clear.
|
|
62
|
+
try { selected = override() !== undefined; } catch {}
|
|
63
|
+
if (!selected) return notify(`No session soul. Soul: ${now()}`);
|
|
64
|
+
pi.appendEntry(ENTRY_TYPE, { version: 1, name: null });
|
|
65
|
+
return notify(`Cleared. Soul: ${now()}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const available = listSouls(config.dir);
|
|
69
|
+
if (name) {
|
|
70
|
+
if (override() === name) return notify(`Already ${name}.`);
|
|
71
|
+
if (!available.includes(name)) {
|
|
72
|
+
throw new Error(`No soul "${name}". Souls: ${available.join(", ") || "none"}`);
|
|
73
|
+
}
|
|
74
|
+
loadSoul(config.dir, name); // Validate content before changing durable session state.
|
|
75
|
+
pi.appendEntry(ENTRY_TYPE, { version: 1, name });
|
|
76
|
+
return notify(`Soul: ${name}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const configured = resolved();
|
|
80
|
+
const selection = effectiveSoul(configured, prompt);
|
|
81
|
+
const missing = selection && !available.includes(selection.name) ? " (missing)" : "";
|
|
82
|
+
const ignored = unmatchableModelKeys(config);
|
|
83
|
+
notify([
|
|
84
|
+
`Soul: ${describe(selection, ctx.model, selection === undefined && configured !== undefined)}${missing}`,
|
|
85
|
+
`Souls: ${available.join(", ") || "none"}`,
|
|
86
|
+
`Dir: ${tilde(config.dir)}`,
|
|
87
|
+
...(ignored.length ? [`Ignored keys: ${ignored.join(", ")}`] : []),
|
|
88
|
+
].join("\n"));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const project = (messages: AgentMessage[], ctx: ExtensionContext): AgentMessage[] | undefined => {
|
|
96
|
+
const config = readConfig(pi.getSettings(), ctx.cwd);
|
|
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
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
|
|
3
|
+
type SystemMessage = Extract<AgentMessage, { role: "system" }>;
|
|
4
|
+
|
|
5
|
+
function hasPreamble(message: AgentMessage): message is SystemMessage {
|
|
6
|
+
return message.role === "system" && typeof message.sections?.preamble === "string";
|
|
7
|
+
}
|
|
8
|
+
|
|
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
|
+
*/
|
|
14
|
+
export function applySoul(messages: AgentMessage[], body: string): AgentMessage[] {
|
|
15
|
+
if (!messages.some(hasPreamble)) throw new Error("Soul skipped: the prompt has no preamble");
|
|
16
|
+
return messages.map((message) => hasPreamble(message) ? { ...message, sections: { ...message.sections, preamble: body } } : message);
|
|
17
|
+
}
|
package/src/souls.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { stripFrontmatter, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export const ENTRY_TYPE = "pi-souls:selection";
|
|
7
|
+
export interface SoulConfig {
|
|
8
|
+
defaultSoul?: unknown;
|
|
9
|
+
modelSouls: Record<string, unknown>;
|
|
10
|
+
dir: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Selection {
|
|
13
|
+
name: string;
|
|
14
|
+
source: "session" | "model" | "default";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isSoulName(value: unknown): value is string {
|
|
18
|
+
return typeof value === "string" && /^[\p{L}\p{N}_-][\p{L}\p{N}_.-]*$/u.test(value) && value !== "clear";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only the shape is checked here; soul names are checked when used, so one bad entry never disables the rest. */
|
|
26
|
+
export function readConfig(settings: unknown, cwd: string): SoulConfig {
|
|
27
|
+
if (!object(settings)) throw new Error("Invalid Pi settings");
|
|
28
|
+
const { defaultSoul, modelSouls = {}, soulsDir = "~/.agents/souls" } = settings;
|
|
29
|
+
if (!object(modelSouls)) throw new Error("modelSouls must be an object");
|
|
30
|
+
if (typeof soulsDir !== "string" || !soulsDir.trim()) throw new Error("soulsDir must be a path");
|
|
31
|
+
const dir = soulsDir === "~" || soulsDir.startsWith("~/") ? join(homedir(), soulsDir.slice(2)) : resolve(cwd, soulsDir);
|
|
32
|
+
return { defaultSoul, modelSouls, dir };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Keys that can never match a model; surfaced by /soul instead of failing every request. */
|
|
36
|
+
export function unmatchableModelKeys(config: SoulConfig): string[] {
|
|
37
|
+
return Object.keys(config.modelSouls).filter((key) => !/^[^/]+\/.+$/.test(key));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read the active branch, never the whole session file or a second in-memory authority. */
|
|
41
|
+
export function sessionSoul(branch: readonly SessionEntry[]): string | undefined {
|
|
42
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
43
|
+
const entry = branch[i];
|
|
44
|
+
if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
|
|
45
|
+
if (!object(entry.data) || entry.data.version !== 1 || !(entry.data.name === null || isSoulName(entry.data.name))) {
|
|
46
|
+
throw new Error("Corrupted soul entry. Run /soul clear");
|
|
47
|
+
}
|
|
48
|
+
return entry.data.name ?? undefined;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function named(name: unknown, source: Selection["source"], setting: string): Selection {
|
|
54
|
+
if (!isSoulName(name)) throw new Error(`${setting}: invalid soul ${JSON.stringify(name)}`);
|
|
55
|
+
return { name, source };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveSoul(config: SoulConfig, override: string | undefined, model?: { provider: string; id: string }): Selection | undefined {
|
|
59
|
+
if (override) return { name: override, source: "session" };
|
|
60
|
+
const key = model && `${model.provider}/${model.id}`;
|
|
61
|
+
if (key && Object.hasOwn(config.modelSouls, key)) return named(config.modelSouls[key], "model", `modelSouls["${key}"]`);
|
|
62
|
+
if (config.defaultSoul !== undefined) return named(config.defaultSoul, "default", "defaultSoul");
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A settings soul is only a default: the user's explicit system prompt outranks it, a session choice does not. */
|
|
67
|
+
export function effectiveSoul(selection: Selection | undefined, customPrompt: string | undefined): Selection | undefined {
|
|
68
|
+
return selection?.source === "session" || !customPrompt ? selection : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listSouls(dir: string): string[] {
|
|
72
|
+
try {
|
|
73
|
+
return readdirSync(dir).filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -3)).filter(isSoulName).sort();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Small local files are read fresh; no cache invalidation protocol or watchers needed. */
|
|
81
|
+
export function loadSoul(dir: string, name: string): string {
|
|
82
|
+
if (!isSoulName(name)) throw new Error(`Invalid soul name: ${name}`);
|
|
83
|
+
const path = join(dir, `${name}.md`);
|
|
84
|
+
let text: string;
|
|
85
|
+
try {
|
|
86
|
+
text = readFileSync(path, "utf8");
|
|
87
|
+
} catch (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)})`);
|
|
91
|
+
}
|
|
92
|
+
const body = stripFrontmatter(text).trim();
|
|
93
|
+
if (!body) throw new Error(`Soul "${name}" is empty`);
|
|
94
|
+
return body;
|
|
95
|
+
}
|