@astrosheep/pi-souls

Session souls with model-specific defaults for Pi

Packages

Package details

extension

Install @astrosheep/pi-souls from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@astrosheep/pi-souls
Package
@astrosheep/pi-souls
Version
0.4.1
Published
Nov 10, 2026
Downloads
961/mo · 58/wk
Author
astrosheep
License
unknown
Types
extension
Size
60.7 KB
Dependencies
2 dependencies · 3 peers
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-souls

Swap Pi's identity per session, per model, or by default. Only the identity changes; tools, rules, skills, and project context stay as they are.

pi install npm:@astrosheep/pi-souls

Usage

/soul          # current soul and list
/soul @faye    # use faye for this session
/soul clear    # disable souls for this session
/soul reset    # follow defaults again
/soul reload   # re-read the active SOUL and its perception

Tab-complete offers @name, clear, reset, and reload. Soul names matching commands are selected with @, such as @clear or @reset.

/soul clear keeps souls off for this session, including model changes, resume, and fork. /soul reset removes that override and any manual soul selection, restoring model and global defaults. /soul @<name> selects a soul again after clearing.

/soul reload validates the active body and Liquid fragments, refreshes the footer, and reloads its perception on the next moment. It changes no selection and starts no model request; the next request uses the current soul body.

pi --soulless
SOULLESS=1 pi

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 disables souls for the session; /soul reset follows defaults, which remain off while this flag is set.

Souls

A soul is one Markdown file, or a directory holding SOUL.md beside whatever it keeps with it. The body becomes the identity; frontmatter is stripped, and edits apply on the next request. An optional nonempty icon string is shown alone in the footer; without one, the footer shows the soul's name.

~/.agents/souls/
├── faye/
│   ├── SOUL.md
│   ├── parts/shared.md
│   └── perception.ts
├── reviewer.md
└── parts/
    └── shared.md

<dir>/<name>/SOUL.md wins when both forms exist. A directory soul renders templates from its own folder first and soulsDir second, so parts/ next to it beats the shared one. <name>/perception.ts is optional; see Perception.

---
icon: 🦈
---
Faye's identity body

Presence

The footer shows the effective soul, and is removed when no soul applies. Before the first request after startup, Pi does not expose the explicit system prompt to extensions, so a session /soul choice is shown but a settings or model soul waits until the first request confirms it applies. /soul @<name>, /soul clear, and /soul reset leave a compact durable divider in the transcript only when the effective identity actually changes: ── soul: faye ──, or ── soulless ── when nothing applies. Dividers keep the identity they recorded when replayed, and never enter model context. No divider appears for no-ops, failures, /soul reload, or a bare /soul status check.

Templates (Pi)

Soul bodies support Liquid without extra configuration:

You are Faye.
{% if model.provider == "openai" %}
Instructions for GPT.
{% elsif model.provider == "anthropic" %}
Instructions for Claude.
{% endif %}
Working in {{ cwd }} with {{ model.id }}.

{% render 'parts/shared' %}

Available variables: model.provider, model.id, and cwd (the session working directory). Model fields are empty strings when no model is selected. Output is plain text, without HTML escaping. Undefined variables, unknown filters, invalid templates, and empty output use the normal warning/fallback behavior. /soul @<name> validates the rendered result before saving the choice.

Put reusable Markdown fragments in soulsDir/parts/; both {% include 'parts/shared' %} and {% render 'parts/shared' %} load parts/shared.md and can access the host variables above. Fragments should have no frontmatter. Subdirectories don't appear in /soul completion. Soul files and fragments are read fresh on each request.

Template syntax is processed even inside Markdown code fences. Wrap literal examples in {% raw %}...{% endraw %}. The Claude Code mod below still reads souls as literal Markdown; Liquid rendering is Pi-only.

Perception

A directory soul may answer lifecycle moments with code. <soulsDir>/<name>/perception.ts (or perception.mjs, perception.js) exports perception(moment, world), by name or as the default:

export function perception(moment, world) {
  if (moment.kind === "human") return { text: "……" };
  if (moment.kind === "tool" && moment.phase === "after") return { text: `${moment.toolName} ${moment.ok ? "ok" : "failed"}` };
  return undefined; // silence is always allowed
}

Moments: wake (source: startup, resume, fork, clear, compact), human (text), and tool (phase before/after, category reading/running/writing/other, toolName, filePath, input, output, ok).

world is { now, sessionId, cwd, absenceMs?, storeDir, facts }. facts carries hour, morning, afternoon, evening, night, weekday (0 is Sunday) and weekend. absenceMs is the time since the human's previous message when that is known; storeDir defaults to ~/.local/share/pi-souls/<name>.

timeaware (⌚️) is a builtin perception on "human" moments, for file and directory souls alike. Pi wraps each utterance in <system-reminder>...</system-reminder> and displays it in the transcript; Claude delivers it as model context. A module that fails to import, throws, or returns no text is ignored: a perception never interrupts the host. Pi loads it once; /soul reload drops the cached module so the next moment imports the file again.

Pi imports the entry module in-process; dependencies follow their loader's normal caching. The Claude Code mod runs it in a node child process (--experimental-strip-types), because its sandbox has no import() and no Node. node must be on the mod's PATH, and its TypeScript perceptions must use syntax Node can erase.

Settings

{
  "defaultSoul": "faye",
  "modelSouls": { "*/claude-*": "reviewer" },
  "soulsDir": "~/.agents/souls",
  "timeaware": true
}

Precedence: session choice (/soul @<name> or /soul clear) > --soulless / SOULLESS / explicit system prompt > exact modelSouls entry > first matching modelSouls wildcard > defaultSoul > Pi default. /soul reset removes the session choice. Keys are provider/modelId patterns: * matches any text, including /; all other characters are literal. For example, */gemini-* applies across providers, and kimi-coding/* applies to that provider. Exact entries always win; overlapping wildcard entries use JSON order.

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. Time awareness is on by default (timeaware: false disables it). When a soul is active, user messages refresh a visible watch-style <system-reminder> on the first prompt and whenever at least 1 hour has passed since the previous time reminder. A gap of at least 1 hour since the previous user message adds the parenthetical; continuous conversation just refreshes the watch. This is checked when a user message arrives, not by a background timer.

Transition reminders

Native model changes and successfully applied soul changes append hidden system reminders to the transcript:

new brain: gpt-5.4
new soul: faye

Only changed lines appear, brain first, each as its own persisted message. Reminders are separate from clock/time messages. A notice is written once, when it is observed: one that arrives during a run settles after that run's tool results, so later requests carry it unchanged in the same place. Earlier messages are never edited, reordered, or retroactively given a line they did not have; the initial model selection establishes a silent baseline.

Soul changes require evidence from a successful projection using the final live prompt options of a user run. A missing or failed soul is never announced, and a later success is announced at that later point rather than backdated to the earlier attempt. Switching souls still replaces the system preamble for the next request, so that switch remains a cache miss on its own; the notices around it are not.

Claude Code mod

From a checkout, with Claude Code 2.1.299 or later:

claude --plugin-dir /path/to/pi-souls/plugins/claude

Uses the same ~/.agents/souls/ souls (<name>.md, or <name>/SOUL.md with an optional perception.ts) and strips frontmatter. Configure the plugin's optional userConfig values in Claude's user settings.json:

{
  "pluginConfigs": {
    "pi-souls@inline": {
      "options": {
        "defaultSoul": "faye",
        "modelSouls": ["claude-opus-5-5=reviewer"],
        "timeaware": true
      }
    }
  }
}

pi-souls@inline is the key for --plugin-dir; a marketplace installation uses pi-souls@<marketplace> instead. Values come directly from Claude's official plugin configuration, with no separate souls settings file. modelSouls is a string list of exact model=soul entries; malformed entries warn once per session and are ignored individually. If a model appears more than once, the last valid entry wins.

Precedence: SOULLESS=1 / true > exact requested Claude model ID in modelSouls > defaultSoul > Claude default. Model IDs have no Pi provider prefix. Pi's own settings remain unchanged. File reads use the mods API, without a helper process or build step.

The mod sends the same hidden transition reminder through prompt.submit context, which the engine wraps, and keeps a transient per-session baseline. A soul line appears only after prompt.compose actually replaced the opening for that model; a frozen prompt, a skipped compose, or --system-prompt leaves the last evidenced soul in place. The brain line still reports. Command/meta prompts, dropped prompts, and downstream-stripped context do not consume it.

Only the first You are… paragraph of the normal or lean opening is replaced. Leading whitespace, the remaining text, and all other sections stay unchanged. A missing or unexpected opening leaves the prompt intact and warns once per session; unreadable souls also fall back to Claude's prompt. Time awareness (timeaware) is on by default (false disables it). When a soul is active, prompt.submit attaches the same watch-style time reminder on the first user prompt and when at least 1 hour has passed since the previous reminder. The parenthetical appears only after a gap of at least 1 hour between user prompts.

A directory soul's perception.ts answers wake on SessionStart, human on prompt.submit, and tool on PreToolUse/PostToolUse/PostToolUseFailure. Utterance text joins the event's context. absenceMs comes from seconds_since_last_response on resume/fork and from the session's previous prompt otherwise.

Limitations:

  • The separate official identity, You are Claude Code, Anthropic's official CLI…, cannot be removed by this hook; it coexists with the soul.
  • The default system-prompt snapshot freezes the first result; start a new session after changing soul files or configuration.
  • --system-prompt overrides the soul. --append-system-prompt still appends normally.
  • An organization's managed settings can prevent installed mods from handling prompt events. --bare doesn't load this mod.

Checks: npm test, npm run typecheck, and claude plugin validate plugins/claude --strict --json. The vendored types/claude-code.d.ts is the declaration generated by Claude Code 2.1.299.