import { ExtensionAPI, SessionEntry } from '@mariozechner/pi-coding-agent'; import { AutocompleteItem } from '@mariozechner/pi-tui'; import { Static, Type } from 'typebox'; /** * TypeBox schemas for pi-roles. * * These define: * - The role frontmatter contract (parsed from YAML in `.md` files). * - The pi-roles settings contract (read from `settings.json`). * - The persisted "active role" state (stored via `pi.appendEntry` so it * survives `/reload`). * * Everything that touches role data downstream imports from this file. If you * need to extend a contract, change it here first. * * We use `typebox` (1.x), not `@sinclair/typebox` 0.34 — pi-mono migrated and * its docs explicitly tell new extensions to depend on the `typebox` root * package. See pi-mono CHANGELOG for the migration notes. */ /** * Pi's thinking levels, mirrored from `ThinkingLevel` in @mariozechner/pi-ai. * * We don't import the type directly because we want to validate user-supplied * frontmatter and produce friendly error messages instead of letting Pi * complain later. */ declare const ThinkingLevelSchema: Type.TUnion<[Type.TLiteral<"off">, Type.TLiteral<"minimal">, Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">, Type.TLiteral<"xhigh">]>; type ThinkingLevelValue = Static; /** * Intercom integration mode for a role or for the global default. * * - off: no intercom tool, no prompt addendum. * - receive: targetable by other sessions, no proactive sends. * - send: can send to other sessions, no inbound coordination expected. * - both: full bidirectional coordination. */ declare const IntercomModeSchema: Type.TUnion<[Type.TLiteral<"off">, Type.TLiteral<"receive">, Type.TLiteral<"send">, Type.TLiteral<"both">]>; type IntercomMode = Static; /** * Where a discovered role came from. Used in /role list output and for * shadowing detection. 'built-in' refers to roles bundled with the pi-roles * package (currently only `role-assistant`). */ declare const RoleSourceSchema: Type.TUnion<[Type.TLiteral<"project">, Type.TLiteral<"user">, Type.TLiteral<"built-in">]>; type RoleSource = Static; /** * Role frontmatter as it appears at the top of a role .md file. * * Notes on the design: * * - `tools` is intentionally kept as a raw string here. We need to distinguish * three states downstream: * - field absent in YAML -> inherit from parent or keep current * - field present but "" -> explicitly disable all tools * - field present with -> override exactly these tools * a value * YAML parsers collapse `tools:` (no value) and `tools: ~` to `null`, and * we get `undefined` when the field is missing from the object entirely. * The schema accepts `string | null`, and `roles.ts` handles the tri-state * semantics. Don't try to encode "absent" inside this schema — JSON Schema * can't distinguish `undefined` from "not validated", which is why we * handle this in code rather than in the type. * * - `model` is a free string here; resolution against * `ctx.modelRegistry.find(provider, id)` happens in `apply.ts`. Keeping it * loose lets users use either `provider/id` or just `id` syntax, matching * Pi's `--model` flag. * * - `name` is required and must equal the filename without extension. We * enforce that in `roles.ts` after parsing, not at the schema level. */ declare const RoleFrontmatterSchema: Type.TObject<{ /** Unique identifier; must match the filename without `.md`. */ name: Type.TString; /** One-line description shown in /role list and pickers. */ description: Type.TString; /** * Model identifier in `provider/id` or `id` form. Resolved against Pi's * model registry at apply time. If the model isn't available, we warn * and keep the session's current model. */ model: Type.TOptional; /** Reasoning level. Clamped to model capabilities by Pi. */ thinking: Type.TOptional, Type.TLiteral<"minimal">, Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">, Type.TLiteral<"xhigh">]>>; /** * Tool list as a raw, comma-separated string. Empty string means "no * tools". Use the `mcp:server-name` syntax for MCP tools (requires * pi-mcp-adapter at runtime). Parse and tri-state semantics live in * `roles.ts`. We accept `null` because YAML's `tools:` (no value) * deserializes to that. */ tools: Type.TOptional>; /** Per-role intercom mode override. Falls back to global `intercomMode`. */ intercom: Type.TOptional, Type.TLiteral<"receive">, Type.TLiteral<"send">, Type.TLiteral<"both">]>>; /** * Name of a parent role to inherit from. Resolved against the same scope * the child was loaded from, then user, then built-in. Cycles are a hard * error. */ extends: Type.TOptional; }>; type RoleFrontmatter = Static; /** * The `tools` field after tri-state normalization. This is what apply.ts * actually consumes. * * - `{ kind: "inherit" }` -> field absent in frontmatter; do nothing on * apply unless an `extends` chain provides a different value. * - `{ kind: "set", names: [] }` -> explicitly empty; pass [] to setActiveTools. * - `{ kind: "set", names: [...] }` -> explicit list; pass through to * setActiveTools (after stripping mcp:* entries when pi-mcp-adapter is not * installed). */ type ToolsDirective = { kind: "inherit"; } | { kind: "set"; names: string[]; }; /** * A fully-resolved role: frontmatter + body, with `extends` chain merged in. * * `RawRole` is what we get from disk before merging. `ResolvedRole` is what * apply.ts consumes. The conversion happens in `roles.ts`. */ interface RawRole { /** Where the role was loaded from. */ source: RoleSource; /** Absolute path to the .md file (or a synthetic path for built-in roles). */ path: string; /** Parsed frontmatter, validated against RoleFrontmatterSchema. */ frontmatter: RoleFrontmatter; /** Markdown body — the system prompt. */ body: string; } interface ResolvedRole { /** Final name (always equals frontmatter.name). */ name: string; /** Final description. */ description: string; /** Final model id, or undefined to keep current. */ model?: string; /** Final thinking level, or undefined to keep current. */ thinking?: ThinkingLevelValue; /** Final tools directive after merging the extends chain. */ tools: ToolsDirective; /** Final intercom mode, or undefined to fall back to global. */ intercom?: IntercomMode; /** Final system prompt body (parent body prepended to child body when extending). */ body: string; /** Source of the leaf role file (always the file the user requested by name). */ source: RoleSource; /** Path of the leaf role file. */ path: string; /** Names of all parent roles in resolution order, leaf-first. Useful for diagnostics. */ extendsChain: string[]; } /** * The pi-roles section of Pi's settings.json. Project settings beat global * per Pi's standard precedence. * * All fields are optional; unset fields fall back to the documented defaults * (see README "Settings reference"). The schema is permissive so that * settings written by future versions don't break older code. */ declare const PiRolesSettingsSchema: Type.TObject<{ /** "user" | "project" | "both". Default: "both". */ roleScope: Type.TOptional, Type.TLiteral<"project">, Type.TLiteral<"both">]>>; /** * Default role name applied when no --role / PI_ROLE is supplied. * Default: "role-assistant" (the built-in fallback). * If set to a missing role, we warn and use the built-in role-assistant. */ defaultRole: Type.TOptional; /** * Keep the currently active role when creating a normal new conversation. * Default: false, so new conversations resolve defaultRole as usual. * Does not apply to --reset, reload, resume, or process restarts. */ preserveRoleOnNewSession: Type.TOptional; /** Default intercom mode for roles that don't set `intercom:`. Default: "off". */ intercomMode: Type.TOptional, Type.TLiteral<"receive">, Type.TLiteral<"send">, Type.TLiteral<"both">]>>; /** * Model used to summarize the first user message into the session-name * intent. Default: a small/cheap model when one is available, falling * back to the session's current model. */ titleModel: Type.TOptional; /** Whether to surface a warning when an mcp:* tool can't be resolved. Default: true. */ warnOnMissingMcp: Type.TOptional; }>; type PiRolesSettings = Static; /** * State persisted via `pi.appendEntry("pi-roles:active-role", ...)`. Read on * `session_start` (with reason="reload" or "resume") to restore the active * role after a reload, since extension memory is wiped on /reload. * * `appliedAt` is the leaf role's source/path so we can re-resolve the chain * fresh on restore — the extends parents may have been edited in the * meantime. * * `intent` carries the session-intent summary we generated for the title, so * a /role swap mid-session can keep the intent and just replace the role * prefix in the session name without re-summarizing. */ declare const ActiveRoleStateSchema: Type.TObject<{ name: Type.TString; /** Source of the LEAF role file when it was applied. */ source: Type.TUnion<[Type.TLiteral<"project">, Type.TLiteral<"user">, Type.TLiteral<"built-in">]>; /** Path of the LEAF role file. */ path: Type.TString; /** Cached session-intent summary for the title. May be empty pre-first-message. */ intent: Type.TOptional; /** Unix ms timestamp; for diagnostics only. */ appliedAt: Type.TNumber; }>; type ActiveRoleState = Static; interface ResetRoleRequest { /** Name explicitly requested by `/role --reset`. */ name: string; /** Unix ms timestamp, retained for diagnostics. */ requestedAt: number; } /** * State a replacement session may inherit from the previous session. * * Persisted session entries are authoritative. This process-local state only * bridges Pi's pre-first-assistant-response gap, during which the old session * has entries in memory but no session file on disk yet. */ interface PreviousSessionRoleState { activeRole: ActiveRoleState | undefined; /** Present only when the final reset lifecycle event is a valid request. */ pendingResetRole: ResetRoleRequest | undefined; } /** * pi-roles extension entry point. * * Wires together discovery (roles.ts), application (apply.ts), and settings * (settings.ts) into the three Pi integration points the role lifecycle * actually needs: * * - `session_start` — restore the current session's persisted role on * reload/resume. For a new session, resolve an explicit persisted reset * request first, then (when configured) preserve the previous session's * active role, otherwise use --role > PI_ROLE > defaultRole > built-in. * New-session transfer reads `previousSessionFile`; Pi recreates the * extension instance during session replacement, so in-memory state is * intentionally never used as a cross-session handoff. * - `before_agent_start` — re-inject the active role's body as the system * prompt every turn (Pi rebuilds the prompt per turn; this is the * stable hook). * - `/role` command — list, current, reload, switch (with optional * --reset to clear history first). * * The module-scoped state below is the source of truth for "what role is * live in this extension instance". Pi reloads spin up a fresh module, at * which point we restore from the most recent `pi-roles:active-role` entry * in the session log. */ interface RuntimeState { /** Live role applied to this session, or null before first apply. */ activeRole: ResolvedRole | null; /** * Explicit reset requested in this still-live instance. It is copied into * the process bridge by session_before_switch, then read by the replacement * instance if Pi has not yet created the old session file. */ pendingResetRequest: ResetRoleRequest | undefined; /** Cached discovery result; refreshed on session_start, every `/role` invocation, and `/role reload`. */ roles: RawRole[]; /** Shadowed roles found at lower-precedence scopes; shown in `/role list`. */ shadowed: { name: string; source: string; path: string; }[]; /** Cached settings for the current cwd; refreshed on session_start. */ settings: PiRolesSettings; /** Carried across role swaps so the session-name intent survives a role change. */ intent: string | undefined; /** * True while a title-generation request is in flight. Prevents * `before_agent_start` from spawning a second concurrent summarization * if it fires again before the first resolves. Reset to false in * `generateAndApplyTitle`'s finally block. */ titleInFlight: boolean; /** * True after we've shown the one-time title-generation error hint. * Prevents spamming the user on every prompt when the title model * is misconfigured or lacks credentials. */ titleErrorShown: boolean; } declare function export_default(pi: ExtensionAPI): void; /** * Build the replacement system prompt for the current active role. * * Returns `undefined` when there's no active role (Pi keeps its default for * that turn). Otherwise returns `{ systemPrompt }` with the role body — and, * when intercom is requested AND the intercom tool is registered, a small * mode-specific addendum appended to the body. * * Exported for unit tests; the handler in `before_agent_start` is a one-line * delegation. */ declare function composeSystemPrompt(state: Pick, pi: Pick): { systemPrompt: string; } | undefined; /** * Pick the role for an ordinary new conversation. `activeRole` must come * from the previous session's persisted state, not the current extension * instance: Pi recreates extensions for `/new`. An explicit --reset role is * resolved before this helper is reached. */ declare function pickNewSessionRoleName(activeRole: Pick | null, pi: ExtensionAPI, settings: PiRolesSettings, roles: RawRole[]): string; /** * Pick the role to launch with on a fresh session_start (no persisted * previous-session override). Precedence per BUILD-STATUS.md: * * --role flag > PI_ROLE env > settings.defaultRole > built-in role-assistant * * If a configured `defaultRole` doesn't exist, we fall through to the * built-in rather than failing — a missing role shouldn't lock the user out * of the session. */ declare function pickInitialRoleName(pi: ExtensionAPI, settings: PiRolesSettings, roles: RawRole[]): string; /** * Prefer durable state from the previous session log. The process-local * transfer is only a fallback for Pi's pre-first-response persistence gap. */ declare function pickPreviousSessionRoleState(diskState: PreviousSessionRoleState | undefined, processState: PreviousSessionRoleState | undefined): PreviousSessionRoleState | undefined; /** * Read transferable state from an earlier session file. Session files may be * missing, corrupted, or unavailable in ephemeral modes; those cases simply * disable inheritance and fall back to ordinary initial resolution. */ declare function findPreviousSessionRoleState(previousSessionFile: string | undefined): PreviousSessionRoleState | undefined; /** * Interpret entries from a previous session for a fresh `reason="new"` * session. Exported as a pure test seam: the runtime reader above only opens * the file, while this function contains the lifecycle semantics. * * Reset events are intentionally an ordered two-event protocol without a * requestId. The final reset lifecycle event is authoritative: cancellation * suppresses every earlier request; a valid final request overrides ordinary * role preservation. */ declare function resolvePreviousSessionRoleState(entries: readonly SessionEntry[]): PreviousSessionRoleState; /** * Provide tab completions for `/role `. Combines built-in subcommands * with discovered role names; case-insensitive prefix match. */ declare function roleCompletions(prefix: string, roles: RawRole[]): AutocompleteItem[] | null; export { composeSystemPrompt, export_default as default, findPreviousSessionRoleState, pickInitialRoleName, pickNewSessionRoleName, pickPreviousSessionRoleState, resolvePreviousSessionRoleState, roleCompletions };