Detailed changes
@@ -6,50 +6,64 @@ SPDX-License-Identifier: CC0-1.0
# pi-extensions
-A collection of [Pi](https://github.com/badlogic/pi-mono) extensions. They're
-all quite rough currently, having just been imported to this repo from ad-hoc,
-vibed individual scripts created by saying "go change
-`~/.config/pi/extensions/foo.ts` so it bars".
+[](https://codeberg.org/Mutualism/Mutualist-License)
+[](https://api.reuse.software/info/git.secluded.site/pi-extensions)
+[](https://scratchanitch.dev)
+[](https://liberapay.com/Amolith/)
-## Packages
+A collection of [Pi] extensions.
-### `@amolith/pi-handoff`
+[Pi]: https://github.com/earendil-works/pi/
-[](https://www.npmjs.com/package/@amolith/pi-handoff)
+## `@amolith/pi-handoff`
-Get to the end of the thing you're doing, then use a lightweight subagent to
-extract particular bits from the current session and create a new one from those
-bits with `/handoff thorough goal description`. You can also tell Pi to hand
-something off (it has a tool).
+[](https://www.npmjs.com/package/@amolith/pi-handoff)
+
+Get to the end of the thing you're doing, then use a subagent to extract
+relevant bits from the current session and create a new one from them with
+`/handoff thorough goal description`. You can tell Pi to hand something off
+because it has a handoff tool too.
+
+After extraction, whether triggered by you or Pi, the draft handoff goes in the
+new session's prompt editor and an auto-submit countdown starts that's cancelled
+by pressing most any key.
```sh
pi install npm:@amolith/pi-handoff
```
-> Based on [handoff.ts](https://github.com/pasky/pi-amplike/blob/main/extensions/handoff.ts)
-> and [session-query.ts](https://github.com/pasky/pi-amplike/blob/main/extensions/session-query.ts)
-> by Petr Baudis, licensed MIT accordingly. Reworked so the subagent calls a
-> tool instead of expecting it to produce well-formed JSON, added an auto-submit
-> countdown, and make the extraction model configurable via `PI_HANDOFF_MODEL`.
+More info in the package's [README](packages/handoff/README.md)
+
+> Based on [handoff.ts] and [session-query.ts] by Petr Baudis, licensed MIT
+> accordingly. Reworked so the subagent calls a tool instead of expecting it to
+> produce well-formed JSON, added an auto-submit countdown, and made the
+> extraction model configurable.
+
+[handoff.ts]: https://github.com/pasky/pi-amplike/blob/main/extensions/handoff.ts
+[session-query.ts]: https://github.com/pasky/pi-amplike/blob/main/extensions/session-query.ts
-### `@amolith/pi-personas`
+## `@amolith/pi-personas`
-[](https://www.npmjs.com/package/@amolith/pi-personas)
+[](https://www.npmjs.com/package/@amolith/pi-personas)
-Drop markdown files in `$PI_CODING_AGENT_DIR/personas/` and switch between them
-with `/persona`. Lets your agent be a grumpy senior today and a patient tutor
-tomorrow. The active persona gets injected into the system prompt and persists
-across sessions until you change it.
+Code with a grumpy senior today and work with a patient tutor tomorrow. Switch
+between them with `/persona`. The active one persists across sessions until
+changed. Setting a persona prior to startup appends it to the system prompt.
+Changing it mid-session sends the persona content as a steering message.
```sh
pi install npm:@amolith/pi-personas
```
+More info in the package's [README](packages/personas/README.md)
+
> All mine :)
## Development
-Prerequisites: [mise](https://mise.jdx.dev/) (installs Node and npm automatically).
+Prerequisites: [mise] (installs Node and npm automatically).
+
+[mise]: https://mise.jdx.dev/
```sh
# Install dependencies
@@ -58,3 +72,32 @@ npm install
# Format, lint, and type-check everything
mise run check
```
+
+## Contributing
+
+Patch requests are in [amolith/llm-projects] on [pr.pico.sh]. You don't need a
+new account to contribute, you don't need to fork this repo, you don't need to
+fiddle with `git send-email`, you don't need to faff with your email client to
+get `git request-pull` working...
+
+You just need:
+
+- Git
+- SSH
+- An SSH key
+
+```bash
+# Clone this repo, make your changes, and commit them
+# Create a new patch request with
+git format-patch origin/main --stdout | ssh pr.pico.sh pr create amolith/llm-projects
+# After potential feedback, submit a revision to an existing patch request with
+git format-patch origin/main --stdout | ssh pr.pico.sh pr add {prID}
+# List patch requests
+ssh pr.pico.sh pr ls amolith/llm-projects
+```
+
+See "How do Patch Requests work?" on [pr.pico.sh]'s home page for a more
+complete example workflow.
+
+[amolith/llm-projects]: https://pr.pico.sh/r/amolith/llm-projects
+[pr.pico.sh]: https://pr.pico.sh
@@ -3347,7 +3347,7 @@
},
"packages/handoff": {
"name": "@amolith/pi-handoff",
- "version": "0.1.0-beta.0",
+ "version": "0.1.0-beta.1",
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
@@ -3357,7 +3357,7 @@
},
"packages/personas": {
"name": "@amolith/pi-personas",
- "version": "0.1.0-beta.0",
+ "version": "0.1.0-beta.1",
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
@@ -0,0 +1,86 @@
+<!--
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: CC0-1.0
+-->
+
+# `@amolith/pi-handoff`
+
+[](https://www.npmjs.com/package/@amolith/pi-handoff)
+[](https://codeberg.org/Mutualism/Mutualist-License)
+[](https://api.reuse.software/info/git.secluded.site/pi-extensions)
+[](https://scratchanitch.dev)
+[](https://liberapay.com/Amolith/)
+
+Get to the end of the thing you're doing, then use a subagent to extract
+relevant bits from the current session and create a new one from them with
+`/handoff thorough goal description`. You can tell Pi to hand something off
+because it has a handoff tool too.
+
+After extraction, whether triggered by you or Pi, the draft handoff goes in the
+new session's prompt editor and an auto-submit countdown starts that's cancelled
+by pressing most any key.
+
+## Install
+
+```sh
+pi install npm:@amolith/pi-handoff
+```
+
+## Do it yourself
+
+Run `/handoff` with whatever the next session should do:
+
+```text
+/handoff finish the release notes and publish both packages
+```
+
+If you omit the goal, Pi asks for one. Before the new session starts, the
+extension opens the generated handoff draft in the editor so you can review or
+tweak it. After the session switch, it starts an auto-submit countdown and
+pressing most any key cancels it.
+
+To use a different model in the new session, pass `-model provider/modelId`:
+
+```text
+/handoff -model neuralwatt/glm-5.1 finish the release notes
+```
+
+## Let the agent do it
+
+The package also registers a `handoff` tool. The agent should only use it when
+you explicitly ask for a handoff. It accepts:
+
+- `goal`: the goal for the new session
+- `model`: optional `provider/modelId` for the new session
+
+The handoff happens after the current agent turn completes and still opens the
+editor so you can tweak before starting the new session.
+
+## Configure the extraction model
+
+It uses the current session model by default. To use a cheaper/faster model, set
+a `provider/modelId` with one of these:
+
+1. `--handoff-model provider/modelId`
+2. `$PI_HANDOFF_MODEL`
+3. `handoffModel` in `$PI_CODING_AGENT_DIR/settings.json`
+
+If you set more than one, that list is the precedence order.
+
+Example settings:
+
+```json
+{
+ "handoffModel": "neuralwatt/qwen3.6-35b-fast"
+}
+```
+
+If the configured value is malformed or doesn't match a registered model, the
+extension falls back to the current session model.
+
+## Ask about the old session
+
+New handoff prompts include the parent session path when available. The package
+registers a `session_query` tool so the next agent can ask focused questions
+about that prior session when the handoff summary isn't enough.
@@ -1,6 +1,6 @@
{
"name": "@amolith/pi-handoff",
- "version": "0.1.0-beta.0",
+ "version": "0.1.0-beta.1",
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
@@ -63,7 +63,7 @@ export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: E
const currentSessionFile = ctx.sessionManager.getSessionFile();
const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
const loadedSkills = extractLoadedSkills(branch);
- const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;
+ const extractionModel = resolveExtractionModel(pi, ctx) ?? ctx.model;
const result = await ctx.ui.custom<HandoffExtraction | null>((tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, "Extracting handoff context...");
@@ -61,7 +61,7 @@ export function registerHandoffTool(pi: ExtensionAPI, setPendingHandoff: (h: Pen
const candidateFiles = [...extractCandidateFiles(branch, conversationText)];
const loadedSkills = extractLoadedSkills(branch);
- const extractionModel = resolveExtractionModel(ctx) ?? ctx.model;
+ const extractionModel = resolveExtractionModel(pi, ctx) ?? ctx.model;
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel);
if (!auth.ok) {
return {
@@ -9,6 +9,7 @@ import { Key, matchesKey, parseKey } from "@earendil-works/pi-tui";
import { registerHandoffCommand } from "./handoff-command.js";
import { registerHandoffTool, type PendingHandoff } from "./handoff-tool.js";
import { parseAndSetModelWithNotify } from "./model-utils.js";
+import { registerHandoffModelFlag } from "./session-analysis.js";
import { registerSessionQueryTool } from "./session-query-tool.js";
const STATUS_KEY = "handoff";
@@ -197,6 +198,7 @@ export default function (pi: ExtensionAPI) {
// --- Register tools and commands ---
+ registerHandoffModelFlag(pi);
registerSessionQueryTool(pi);
registerHandoffTool(pi, (h) => {
pendingHandoff = h;
@@ -3,15 +3,61 @@
//
// SPDX-License-Identifier: MIT
-import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
+import {
+ getAgentDir,
+ SettingsManager,
+ type ExtensionAPI,
+ type ExtensionContext,
+ type SessionEntry,
+} from "@earendil-works/pi-coding-agent";
import * as path from "node:path";
/**
- * Model used for handoff extraction calls. Set PI_HANDOFF_MODEL env var
- * as "provider/modelId" (e.g. "anthropic/claude-haiku-4-5") to use a
- * different model for extraction than the session's current model.
+ * Model override sources for handoff extraction calls. Use provider/modelId
+ * values (e.g. "anthropic/claude-haiku-4-5"). Precedence is:
+ * --handoff-model, PI_HANDOFF_MODEL, user settings handoffModel, session model.
*/
-const HANDOFF_MODEL_OVERRIDE = process.env.PI_HANDOFF_MODEL;
+const HANDOFF_MODEL_FLAG = "handoff-model";
+const HANDOFF_MODEL_ENV = "PI_HANDOFF_MODEL";
+const HANDOFF_MODEL_SETTING = "handoffModel";
+
+export function registerHandoffModelFlag(pi: ExtensionAPI): void {
+ pi.registerFlag(HANDOFF_MODEL_FLAG, {
+ description: "Model for handoff extraction as provider/modelId",
+ type: "string",
+ });
+}
+
+function nonEmptyString(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function stringProperty(source: object, key: string): string | undefined {
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
+ if (!descriptor || !("value" in descriptor)) return undefined;
+ return typeof descriptor.value === "string" ? nonEmptyString(descriptor.value) : undefined;
+}
+
+function userSettingsHandoffModel(cwd: string): string | undefined {
+ const settingsManager = SettingsManager.create(cwd, getAgentDir());
+ return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_MODEL_SETTING);
+}
+
+function configuredHandoffModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined {
+ const flagValue = pi.getFlag(HANDOFF_MODEL_FLAG);
+ const flagModel = typeof flagValue === "string" ? nonEmptyString(flagValue) : undefined;
+ return flagModel ?? nonEmptyString(process.env[HANDOFF_MODEL_ENV]) ?? userSettingsHandoffModel(ctx.cwd);
+}
+
+function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
+ const slashIdx = spec.indexOf("/");
+ if (slashIdx <= 0 || slashIdx === spec.length - 1) return undefined;
+ return {
+ provider: spec.slice(0, slashIdx),
+ modelId: spec.slice(slashIdx + 1),
+ };
+}
/**
* Build a candidate file set from two sources:
@@ -90,17 +136,21 @@ export function extractLoadedSkills(entries: SessionEntry[]): string[] {
}
/**
- * Resolve the model to use for handoff extraction calls. Uses the
- * PI_HANDOFF_MODEL env var if set, otherwise falls back to the session model.
+ * Resolve the model to use for handoff extraction calls. Uses --handoff-model,
+ * PI_HANDOFF_MODEL, then handoffModel in user settings, falling back to the
+ * session model if no valid override is configured.
*/
-export function resolveExtractionModel(ctx: {
- model: ExtensionContext["model"];
- modelRegistry: ExtensionContext["modelRegistry"];
-}): ExtensionContext["model"] {
- if (!HANDOFF_MODEL_OVERRIDE) return ctx.model;
- const slashIdx = HANDOFF_MODEL_OVERRIDE.indexOf("/");
- if (slashIdx <= 0) return ctx.model;
- const provider = HANDOFF_MODEL_OVERRIDE.slice(0, slashIdx);
- const modelId = HANDOFF_MODEL_OVERRIDE.slice(slashIdx + 1);
- return ctx.modelRegistry.find(provider, modelId) ?? ctx.model;
+export function resolveExtractionModel(
+ pi: ExtensionAPI,
+ ctx: {
+ cwd: string;
+ model: ExtensionContext["model"];
+ modelRegistry: ExtensionContext["modelRegistry"];
+ },
+): ExtensionContext["model"] {
+ const modelSpec = configuredHandoffModel(pi, ctx);
+ if (!modelSpec) return ctx.model;
+ const parsed = parseModelSpec(modelSpec);
+ if (!parsed) return ctx.model;
+ return ctx.modelRegistry.find(parsed.provider, parsed.modelId) ?? ctx.model;
}
@@ -0,0 +1,85 @@
+<!--
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: CC0-1.0
+-->
+
+# `@amolith/pi-personas`
+
+[](https://www.npmjs.com/package/@amolith/pi-personas)
+[](https://codeberg.org/Mutualism/Mutualist-License)
+[](https://api.reuse.software/info/git.secluded.site/pi-extensions)
+[](https://scratchanitch.dev)
+[](https://liberapay.com/Amolith/)
+
+Code with a grumpy senior today and work with a patient tutor tomorrow. Switch
+between them with `/persona`. The active one persists across sessions until
+changed. Setting a persona prior to startup appends it to the system prompt.
+Changing it mid-session sends the persona content as a steering message.
+
+Personas are loaded from `persona-name.md` files in
+`$PI_CODING_AGENT_DIR/personas/`. `/persona` is interactive and `/persona grug`
+sets it directly.
+
+## Install
+
+```sh
+pi install npm:@amolith/pi-personas
+```
+
+## Write personas
+
+Create one markdown file per persona in `$PI_CODING_AGENT_DIR/personas/`:
+
+```text
+$PI_CODING_AGENT_DIR/personas/grumpy-senior.md
+$PI_CODING_AGENT_DIR/personas/patient-tutor.md
+```
+
+The file name without `.md` is the persona name. That's what `/persona` and the
+`persona` setting use.
+
+## Switch personas
+
+Open an interactive picker:
+
+```text
+/persona
+```
+
+Switch directly:
+
+```text
+/persona patient-tutor
+```
+
+Clear the active persona:
+
+```text
+/persona none
+```
+
+`unset` and `clear` also clear the active persona.
+
+## Remember the active persona
+
+The active persona persists as the `persona` key in
+`$PI_CODING_AGENT_DIR/settings.json`:
+
+```json
+{
+ "persona": "patient-tutor"
+}
+```
+
+The extension only accepts persona names that match markdown files under
+`$PI_CODING_AGENT_DIR/personas/`.
+
+## What happens when it changes
+
+If a persona is set before startup, the extension reads it at session start and
+appends it to the system prompt for the lifetime of the session.
+
+When you set or clear a persona mid-session, the extension updates the setting
+immediately and sends a one-shot hidden reminder message. It does not rewrite
+the session's system prompt mid-stream.
@@ -1,6 +1,6 @@
{
"name": "@amolith/pi-personas",
- "version": "0.1.0-beta.0",
+ "version": "0.1.0-beta.1",
"description": "Switchable agent personas for Pi",
"keywords": [
"pi-package"
@@ -15,22 +15,26 @@
* /persona — Interactive picker to switch persona
* /persona none|unset|clear — Remove active persona
*
- * The active persona persists across sessions in a plain text file
- * at persona in the Pi agent directory.
+ * The active persona persists across sessions as the "persona" key
+ * in the Pi agent directory's settings.json.
*/
import * as fs from "node:fs";
import * as path from "node:path";
-import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { getAgentDir, type ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
const CLEAR_KEYWORDS = ["none", "unset", "clear"];
+const ACTIVE_PERSONA_SETTING = "persona";
+
+type JsonValue = JsonObject | JsonValue[] | string | number | boolean | null;
+type JsonObject = { [key: string]: JsonValue };
function getPersonasDir(): string {
return path.join(getAgentDir(), "personas");
}
-function getActivePersonaPath(): string {
- return path.join(getAgentDir(), "persona");
+function getSettingsPath(): string {
+ return path.join(getAgentDir(), "settings.json");
}
/** List available persona names (without .md extension). */
@@ -50,23 +54,61 @@ function listPersonas(): string[] {
}
}
+function parseSettingsJson(content: string): JsonObject {
+ if (!content.trim()) return {};
+ const parsed: JsonValue = JSON.parse(content);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw new Error("settings.json must contain a JSON object");
+ }
+ return parsed;
+}
+
+function readPersonaSetting(cwd: string): string | null {
+ const settingsManager = SettingsManager.create(cwd, getAgentDir());
+ const settings = settingsManager.getGlobalSettings();
+ const descriptor = Object.getOwnPropertyDescriptor(settings, ACTIVE_PERSONA_SETTING);
+ if (!descriptor || !("value" in descriptor)) return null;
+ if (typeof descriptor.value !== "string") return null;
+ const name = descriptor.value.trim();
+ return name ? name : null;
+}
+
+function writePersonaSetting(name: string | null): void {
+ const filePath = getSettingsPath();
+ try {
+ const settings = fs.existsSync(filePath) ? parseSettingsJson(fs.readFileSync(filePath, "utf-8")) : {};
+
+ if (name) {
+ settings[ACTIVE_PERSONA_SETTING] = name;
+ } else {
+ delete settings[ACTIVE_PERSONA_SETTING];
+ }
+
+ const dir = path.dirname(filePath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
+ } catch (err) {
+ console.error(`Failed to write active persona setting: ${err}`);
+ }
+}
+
/**
* Read the persisted active persona name, or null if none.
* Validates the name against available personas to prevent
- * path traversal via a tampered persistence file.
+ * path traversal via a tampered settings file.
*/
-function readActivePersona(): string | null {
- const filePath = getActivePersonaPath();
- if (!fs.existsSync(filePath)) return null;
-
+function readActivePersona(cwd: string): string | null {
try {
- const name = fs.readFileSync(filePath, "utf-8").trim();
+ const name = readPersonaSetting(cwd);
if (!name) return null;
// Reject names that could escape the personas directory
const available = listPersonas();
if (!available.includes(name)) {
- console.error(`Persisted persona "${name}" not in available list, ignoring`);
+ console.error(`Configured persona "${name}" not in available list, ignoring`);
return null;
}
@@ -77,18 +119,9 @@ function readActivePersona(): string | null {
}
}
-/** Persist the active persona name (or remove the file to clear). */
+/** Persist the active persona name (or remove the setting to clear). */
function writeActivePersona(name: string | null): void {
- const filePath = getActivePersonaPath();
- try {
- if (name) {
- fs.writeFileSync(filePath, `${name}\n`, "utf-8");
- } else if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- }
- } catch (err) {
- console.error(`Failed to write active persona: ${err}`);
- }
+ writePersonaSetting(name);
}
/** Read persona markdown content, or null if file missing. */
@@ -128,7 +161,7 @@ export default function (pi: ExtensionAPI) {
// Lock persona into the system prompt at session start.
// This content never changes for the lifetime of the session.
pi.on("session_start", async (_event, ctx) => {
- const persisted = readActivePersona();
+ const persisted = readActivePersona(ctx.cwd);
pendingReminder = null;
sessionPersonaContent = null;