@astrosheep/pi-souls 0.0.0-stage → 0.1.0

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
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # pi-souls
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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. 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.
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-souls",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Session souls with model-specific defaults for Pi",
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" },
12
+ "scripts": {
13
+ "test": "node --experimental-strip-types --test test/*.test.ts",
14
+ "typecheck": "tsc --noEmit",
15
+ "check:load": "pi --no-extensions -e ./src/index.ts --list-models __load_check__"
16
+ },
17
+ "peerDependencies": { "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-agent-core": "*" },
18
+ "devDependencies": {
19
+ "@earendil-works/pi-coding-agent": "1.1.0",
20
+ "@earendil-works/pi-agent-core": "1.1.0",
21
+ "@types/node": "^22.0.0",
22
+ "typescript": "^6.0.3"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { homedir } from "node:os";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { applySoul } from "./prompt.ts";
4
+ import { ENTRY_TYPE, effectiveSoul, listSouls, loadSoul, readConfig, resolveSoul, sessionSoul, unmatchableModelKeys, type Selection } from "./souls.ts";
5
+
6
+ const home = homedir();
7
+ const tilde = (path: string) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
8
+
9
+ function describe(selection: Selection | undefined, model: ExtensionContext["model"], suppressed = false): string {
10
+ if (!selection) return suppressed ? "none (custom prompt)" : "none";
11
+ if (selection.source === "model") return `${selection.name} (${model?.provider}/${model?.id})`;
12
+ return `${selection.name} (${selection.source})`;
13
+ }
14
+
15
+ export default function soulsExtension(pi: ExtensionAPI) {
16
+ // Argument completion has no context; remember where the session runs for relative soulsDir.
17
+ 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; });
22
+
23
+ pi.registerCommand("soul", {
24
+ description: "Switch soul: /soul [name|clear]",
25
+ getArgumentCompletions: (prefix) => {
26
+ try {
27
+ const souls = listSouls(readConfig(pi.getSettings(), cwd).dir).map((name) => ({ value: name, label: name }));
28
+ const items = [...souls, { value: "clear", label: "clear", description: "Follow defaults" }];
29
+ const matches = items.filter((item) => item.value.startsWith(prefix.trim()));
30
+ return matches.length ? matches : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ },
35
+ handler: async (args, ctx) => {
36
+ const notify = (text: string) => ctx.ui.notify(text, "info");
37
+ if (!ctx.isIdle()) {
38
+ ctx.ui.notify("Busy. Switch souls after this run.", "warning");
39
+ return;
40
+ }
41
+ try {
42
+ const name = args.trim();
43
+ const config = readConfig(pi.getSettings(), ctx.cwd);
44
+ const prompt = ctx.getSystemPromptOptions().customPrompt;
45
+ const override = () => sessionSoul(ctx.sessionManager.getBranch());
46
+ const resolved = () => resolveSoul(config, override(), ctx.model);
47
+ const now = () => {
48
+ const configured = resolved();
49
+ const active = effectiveSoul(configured, prompt);
50
+ return describe(active, ctx.model, active === undefined && configured !== undefined);
51
+ };
52
+
53
+ if (name === "clear") {
54
+ let selected = true; // A corrupted selection counts as something to clear.
55
+ try { selected = override() !== undefined; } catch {}
56
+ if (!selected) return notify(`No session soul. Soul: ${now()}`);
57
+ pi.appendEntry(ENTRY_TYPE, { version: 1, name: null });
58
+ return notify(`Cleared. Soul: ${now()}`);
59
+ }
60
+
61
+ const available = listSouls(config.dir);
62
+ if (name) {
63
+ if (override() === name) return notify(`Already ${name}.`);
64
+ if (!available.includes(name)) {
65
+ throw new Error(`No soul "${name}". Souls: ${available.join(", ") || "none"}`);
66
+ }
67
+ loadSoul(config.dir, name); // Validate content before changing durable session state.
68
+ pi.appendEntry(ENTRY_TYPE, { version: 1, name });
69
+ return notify(`Soul: ${name}`);
70
+ }
71
+
72
+ const configured = resolved();
73
+ const selection = effectiveSoul(configured, prompt);
74
+ const missing = selection && !available.includes(selection.name) ? " (missing)" : "";
75
+ const ignored = unmatchableModelKeys(config);
76
+ notify([
77
+ `Soul: ${describe(selection, ctx.model, selection === undefined && configured !== undefined)}${missing}`,
78
+ `Souls: ${available.join(", ") || "none"}`,
79
+ `Dir: ${tilde(config.dir)}`,
80
+ ...(ignored.length ? [`Ignored keys: ${ignored.join(", ")}`] : []),
81
+ ].join("\n"));
82
+ } catch (error) {
83
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
84
+ }
85
+ },
86
+ });
87
+
88
+ pi.on("context_with_system", (event, ctx) => {
89
+ 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)) };
93
+ });
94
+ }
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 | undefined): message is SystemMessage {
6
+ return message?.role === "system" && typeof message.sections?.preamble === "string";
7
+ }
8
+
9
+ /** Request-only projection. Keep runtime sections, declaration deltas and conversation intact. */
10
+ 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.
16
+ return messages.map((message) => hasPreamble(message) ? { ...message, sections: { ...message.sections, preamble: body } } : message);
17
+ }
package/src/souls.ts ADDED
@@ -0,0 +1,94 @@
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
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Soul "${name}" not found: ${path}`);
89
+ throw error;
90
+ }
91
+ const body = stripFrontmatter(text).trim();
92
+ if (!body) throw new Error(`Soul "${name}" is empty`);
93
+ return body;
94
+ }