feat: add pi extension (#1)
This commit is contained in:
@@ -73,6 +73,12 @@ codex
|
|||||||
Open `/plugins`, select the Ponytail marketplace, and install Ponytail. Then
|
Open `/plugins`, select the Ponytail marketplace, and install Ponytail. Then
|
||||||
open `/hooks`, review and trust its two lifecycle hooks, and start a new thread.
|
open `/hooks`, review and trust its two lifecycle hooks, and start a new thread.
|
||||||
|
|
||||||
|
### Pi agent harness
|
||||||
|
|
||||||
|
```
|
||||||
|
pi install git:github.com/DietrichGebert/ponytail
|
||||||
|
```
|
||||||
|
|
||||||
That was it. He'd be proud. He won't say it.
|
That was it. He'd be proud. He won't say it.
|
||||||
|
|
||||||
Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest.
|
Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { getDefaultMode } = require('./ponytail-config');
|
const { getDefaultMode } = require('./ponytail-config');
|
||||||
|
const { getPonytailInstructions } = require('./ponytail-instructions');
|
||||||
const {
|
const {
|
||||||
clearMode,
|
clearMode,
|
||||||
isCodex,
|
isCodex,
|
||||||
@@ -37,99 +38,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
|
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
|
||||||
// A short summary is too weak — models drift back to over-building
|
let output = getPonytailInstructions(mode);
|
||||||
// mid-conversation, especially after context compression prunes it.
|
|
||||||
// Full rules with examples anchor behavior much more reliably.
|
|
||||||
//
|
|
||||||
// Reads SKILL.md at runtime so edits to the source of truth propagate
|
|
||||||
// automatically — no hardcoded duplication to go stale.
|
|
||||||
|
|
||||||
// Modes that have their own independent skill files — not intensity levels.
|
|
||||||
// For these, emit a short activation line; the skill itself handles behavior.
|
|
||||||
const INDEPENDENT_MODES = new Set(['review']);
|
|
||||||
|
|
||||||
if (INDEPENDENT_MODES.has(mode)) {
|
|
||||||
writeHookOutput(
|
|
||||||
'SessionStart',
|
|
||||||
mode,
|
|
||||||
'PONYTAIL MODE ACTIVE — level: ' + mode + '. Behavior defined by /ponytail-' + mode + ' skill.',
|
|
||||||
);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read SKILL.md — the single source of truth for ponytail behavior.
|
|
||||||
// Plugin installs: __dirname = <plugin_root>/hooks/, SKILL.md at <plugin_root>/skills/ponytail/SKILL.md
|
|
||||||
let skillContent = '';
|
|
||||||
try {
|
|
||||||
skillContent = fs.readFileSync(
|
|
||||||
path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8'
|
|
||||||
);
|
|
||||||
} catch (e) { /* standalone install — will use fallback below */ }
|
|
||||||
|
|
||||||
let output;
|
|
||||||
|
|
||||||
if (skillContent) {
|
|
||||||
// Strip YAML frontmatter
|
|
||||||
const body = skillContent.replace(/^---[\s\S]*?---\s*/, '');
|
|
||||||
|
|
||||||
// Filter intensity table: keep header rows + only the active level's row
|
|
||||||
const filtered = body.split('\n').reduce((acc, line) => {
|
|
||||||
// Intensity table rows start with | **level** |
|
|
||||||
const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
|
|
||||||
if (tableRowMatch) {
|
|
||||||
if (tableRowMatch[1] === mode) {
|
|
||||||
acc.push(line);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example lines start with "- level:" — keep only lines matching active level
|
|
||||||
const exampleMatch = line.match(/^- (\S+?):\s/);
|
|
||||||
if (exampleMatch) {
|
|
||||||
if (exampleMatch[1] === mode) {
|
|
||||||
acc.push(line);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.push(line);
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
output = 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' + filtered.join('\n');
|
|
||||||
} else {
|
|
||||||
// Fallback when SKILL.md is not found (hook installed without skills dir).
|
|
||||||
// Minimum viable ruleset — better than nothing.
|
|
||||||
output =
|
|
||||||
'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' +
|
|
||||||
'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' +
|
|
||||||
'## Persistence\n\n' +
|
|
||||||
'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' +
|
|
||||||
'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' +
|
|
||||||
'## The ladder\n\n' +
|
|
||||||
'Before any code, stop at the first rung that holds:\n' +
|
|
||||||
'1. Does this need to be built at all? (YAGNI)\n' +
|
|
||||||
'2. Does the standard library do this? Use it.\n' +
|
|
||||||
'3. Does a native platform feature cover it? Use it.\n' +
|
|
||||||
'4. Does an already-installed dependency solve it? Use it.\n' +
|
|
||||||
'5. Can this be one line? Make it one line.\n' +
|
|
||||||
'6. Only then: write the minimum code that works.\n\n' +
|
|
||||||
'## Rules\n\n' +
|
|
||||||
'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' +
|
|
||||||
'Deletion over addition. Boring over clever. Fewest files possible. ' +
|
|
||||||
'Ship the lazy version and question the complex request in the same response — never stall. ' +
|
|
||||||
'Between two same-size stdlib options, pick the one correct on edge cases. ' +
|
|
||||||
'Mark intentional simplifications with a `ponytail:` comment — a shortcut with a known ceiling names the ceiling and the upgrade path in the comment.\n\n' +
|
|
||||||
'## Output\n\n' +
|
|
||||||
'Code first. Then at most three short lines: what was skipped, when to add it. ' +
|
|
||||||
'If the explanation is longer than the code, delete the explanation.\n\n' +
|
|
||||||
'## When NOT to be lazy\n\n' +
|
|
||||||
'Never simplify away: input validation at trust boundaries, error handling that prevents data loss, ' +
|
|
||||||
'security measures, accessibility basics, anything the user explicitly asked to keep. ' +
|
|
||||||
'Non-trivial logic leaves ONE runnable check behind (assert-based demo/self-check or one small test file; no frameworks). Trivial one-liners need no test.\n\n' +
|
|
||||||
'## Boundaries\n\n' +
|
|
||||||
'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Detect missing statusline config — nudge Claude to help set it up
|
// 3. Detect missing statusline config — nudge Claude to help set it up
|
||||||
if (!isCodex) try {
|
if (!isCodex) try {
|
||||||
|
|||||||
@@ -13,7 +13,25 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
|
const DEFAULT_MODE = 'full';
|
||||||
const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
|
const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
|
||||||
|
const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra'];
|
||||||
|
|
||||||
|
function normalizeMode(mode) {
|
||||||
|
if (typeof mode !== 'string') return null;
|
||||||
|
const normalized = mode.trim().toLowerCase();
|
||||||
|
return RUNTIME_MODES.includes(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigMode(mode) {
|
||||||
|
if (typeof mode !== 'string') return null;
|
||||||
|
const normalized = mode.trim().toLowerCase();
|
||||||
|
return VALID_MODES.includes(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePersistedMode(mode) {
|
||||||
|
return normalizeMode(mode) || normalizeConfigMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
function getConfigDir() {
|
function getConfigDir() {
|
||||||
if (process.env.XDG_CONFIG_HOME) {
|
if (process.env.XDG_CONFIG_HOME) {
|
||||||
@@ -51,7 +69,28 @@ function getDefaultMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Default
|
// 3. Default
|
||||||
return 'full';
|
return DEFAULT_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES };
|
function writeDefaultMode(mode) {
|
||||||
|
const normalized = normalizeConfigMode(mode);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ defaultMode: normalized }, null, 2), 'utf8');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MODE,
|
||||||
|
VALID_MODES,
|
||||||
|
RUNTIME_MODES,
|
||||||
|
getDefaultMode,
|
||||||
|
getConfigDir,
|
||||||
|
getConfigPath,
|
||||||
|
normalizeMode,
|
||||||
|
normalizeConfigMode,
|
||||||
|
normalizePersistedMode,
|
||||||
|
writeDefaultMode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Shared Ponytail instruction builder for Claude hooks and Pi extension.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponytail-config');
|
||||||
|
|
||||||
|
const INDEPENDENT_MODES = new Set(['review']);
|
||||||
|
const SKILL_PATH = path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md');
|
||||||
|
|
||||||
|
function filterSkillBodyForMode(body, mode) {
|
||||||
|
const effectiveMode = normalizeMode(mode) || DEFAULT_MODE;
|
||||||
|
const withoutFrontmatter = String(body || '').replace(/^---[\s\S]*?---\s*/, '');
|
||||||
|
|
||||||
|
return withoutFrontmatter
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => {
|
||||||
|
const tableMatch = line.match(/^\|\s*\*\*(.+?)\*\*\s*\|/);
|
||||||
|
if (tableMatch) return tableMatch[1].trim() === effectiveMode;
|
||||||
|
|
||||||
|
const exampleMatch = line.match(/^-\s*([^:]+):\s*/);
|
||||||
|
if (exampleMatch) return exampleMatch[1].trim() === effectiveMode;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackInstructions(mode) {
|
||||||
|
return 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' +
|
||||||
|
'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' +
|
||||||
|
'## Persistence\n\n' +
|
||||||
|
'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' +
|
||||||
|
'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' +
|
||||||
|
'## The ladder\n\n' +
|
||||||
|
'Before any code, stop at the first rung that holds:\n' +
|
||||||
|
'1. Does this need to be built at all? (YAGNI)\n' +
|
||||||
|
'2. Does the standard library do this? Use it.\n' +
|
||||||
|
'3. Does a native platform feature cover it? Use it.\n' +
|
||||||
|
'4. Does an already-installed dependency solve it? Use it.\n' +
|
||||||
|
'5. Can this be one line? Make it one line.\n' +
|
||||||
|
'6. Only then: write the minimum code that works.\n\n' +
|
||||||
|
'## Rules\n\n' +
|
||||||
|
'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' +
|
||||||
|
'Deletion over addition. Boring over clever. Fewest files possible. ' +
|
||||||
|
'Ship the lazy version and question the complex request in the same response — never stall. ' +
|
||||||
|
'Between two same-size stdlib options, pick the one correct on edge cases. ' +
|
||||||
|
'Mark intentional simplifications with a `ponytail:` comment — a shortcut with a known ceiling names the ceiling and the upgrade path in the comment.\n\n' +
|
||||||
|
'## Output\n\n' +
|
||||||
|
'Code first. Then at most three short lines: what was skipped, when to add it. ' +
|
||||||
|
'If the explanation is longer than the code, delete the explanation.\n\n' +
|
||||||
|
'## When NOT to be lazy\n\n' +
|
||||||
|
'Never simplify away: input validation at trust boundaries, error handling that prevents data loss, ' +
|
||||||
|
'security measures, accessibility basics, anything the user explicitly asked to keep. ' +
|
||||||
|
'Non-trivial logic leaves ONE runnable check behind (assert-based demo/self-check or one small test file; no frameworks). Trivial one-liners need no test.\n\n' +
|
||||||
|
'## Boundaries\n\n' +
|
||||||
|
'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPonytailInstructions(mode) {
|
||||||
|
const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE;
|
||||||
|
|
||||||
|
if (INDEPENDENT_MODES.has(configuredMode)) {
|
||||||
|
return 'PONYTAIL MODE ACTIVE — level: ' + configuredMode + '. Behavior defined by /ponytail-' + configuredMode + ' skill.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMode = normalizeMode(configuredMode) || DEFAULT_MODE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return 'PONYTAIL MODE ACTIVE — level: ' + effectiveMode + '\n\n' +
|
||||||
|
filterSkillBodyForMode(fs.readFileSync(SKILL_PATH, 'utf8'), effectiveMode);
|
||||||
|
} catch (e) {
|
||||||
|
return getFallbackInstructions(effectiveMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
filterSkillBodyForMode,
|
||||||
|
getFallbackInstructions,
|
||||||
|
getPonytailInstructions,
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "ponytail",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
|
||||||
|
"keywords": ["pi-package", "pi", "skills", "ponytail"],
|
||||||
|
"license": "MIT",
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./pi-extension/index.js"],
|
||||||
|
"skills": ["./skills"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const {
|
||||||
|
DEFAULT_MODE,
|
||||||
|
getDefaultMode,
|
||||||
|
normalizeMode,
|
||||||
|
normalizeConfigMode,
|
||||||
|
normalizePersistedMode,
|
||||||
|
writeDefaultMode,
|
||||||
|
} = require("../hooks/ponytail-config.js");
|
||||||
|
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
|
||||||
|
|
||||||
|
export { filterSkillBodyForMode };
|
||||||
|
export const readDefaultMode = getDefaultMode;
|
||||||
|
|
||||||
|
export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
|
||||||
|
const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
|
||||||
|
if (!Array.isArray(entries)) return fallback;
|
||||||
|
|
||||||
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue;
|
||||||
|
|
||||||
|
const mode = normalizePersistedMode(entry?.data?.mode);
|
||||||
|
if (mode) return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE) {
|
||||||
|
const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE;
|
||||||
|
const normalizedText = String(text || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedText) {
|
||||||
|
return { type: "set-mode", mode: fallback === "off" ? "full" : fallback };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primary, secondary] = normalizedText.split(/\s+/);
|
||||||
|
|
||||||
|
if (primary === "status") return { type: "status" };
|
||||||
|
|
||||||
|
if (primary === "default") {
|
||||||
|
const mode = normalizeConfigMode(secondary);
|
||||||
|
return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = normalizeMode(primary);
|
||||||
|
return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { writeDefaultMode };
|
||||||
|
|
||||||
|
export default function ponytailExtension(pi) {
|
||||||
|
let currentMode = DEFAULT_MODE;
|
||||||
|
let configuredDefaultMode = getDefaultMode();
|
||||||
|
|
||||||
|
const setMode = (mode, ctx) => {
|
||||||
|
const normalized = normalizePersistedMode(mode);
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
currentMode = normalized;
|
||||||
|
pi.appendEntry("ponytail-mode", { mode: normalized });
|
||||||
|
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendAlias = (skillName, args, ctx) => {
|
||||||
|
const normalized = String(args || "").trim();
|
||||||
|
const message = normalized ? `${skillName} ${normalized}` : skillName;
|
||||||
|
|
||||||
|
if (ctx?.isIdle?.() === false) {
|
||||||
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
||||||
|
ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.sendUserMessage(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.registerCommand("ponytail", {
|
||||||
|
description: "Set or report Ponytail mode",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const parsed = parsePonytailCommand(args, configuredDefaultMode);
|
||||||
|
|
||||||
|
if (parsed.type === "status") {
|
||||||
|
ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "set-default") {
|
||||||
|
const written = writeDefaultMode(parsed.mode);
|
||||||
|
if (written) {
|
||||||
|
configuredDefaultMode = getDefaultMode();
|
||||||
|
const message = configuredDefaultMode === written
|
||||||
|
? `Default Ponytail mode set to ${written}.`
|
||||||
|
: `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
|
||||||
|
ctx?.ui?.notify?.(message, "info");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "set-mode") {
|
||||||
|
setMode(parsed.mode, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ponytail-review", {
|
||||||
|
description: "Run /skill:ponytail-review",
|
||||||
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-review", "", ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ponytail-help", {
|
||||||
|
description: "Run /skill:ponytail-help",
|
||||||
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-help", "", ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("input", async (event) => {
|
||||||
|
if (event?.source === "extension") return;
|
||||||
|
|
||||||
|
const text = String(event?.text || "");
|
||||||
|
if (currentMode !== "off" && /\b(stop ponytail|normal mode)\b/i.test(text)) {
|
||||||
|
setMode("off");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
|
||||||
|
configuredDefaultMode = getDefaultMode();
|
||||||
|
currentMode = resolveSessionMode(entries, configuredDefaultMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (event) => {
|
||||||
|
if (!currentMode || currentMode === "off") return;
|
||||||
|
return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode)}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ponytail-pi-extension-dev",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test ./test/*.test.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import ponytailExtension from "../index.js";
|
||||||
|
|
||||||
|
function createPiHarness() {
|
||||||
|
const events = new Map();
|
||||||
|
const commands = new Map();
|
||||||
|
const appendedEntries = [];
|
||||||
|
const sentUserMessages = [];
|
||||||
|
|
||||||
|
const pi = {
|
||||||
|
on(eventName, handler) {
|
||||||
|
events.set(eventName, handler);
|
||||||
|
},
|
||||||
|
registerCommand(name, options) {
|
||||||
|
commands.set(name, options);
|
||||||
|
},
|
||||||
|
appendEntry(customType, data) {
|
||||||
|
appendedEntries.push({ customType, data });
|
||||||
|
},
|
||||||
|
sendUserMessage(text, options) {
|
||||||
|
sentUserMessages.push({ text, options });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
ponytailExtension(pi);
|
||||||
|
return { events, commands, appendedEntries, sentUserMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommandContext(overrides = {}) {
|
||||||
|
return {
|
||||||
|
isIdle: () => true,
|
||||||
|
sessionManager: { getEntries: () => [] },
|
||||||
|
ui: { notify() {} },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTempConfig(fn) {
|
||||||
|
const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-"));
|
||||||
|
const previousXdg = process.env.XDG_CONFIG_HOME;
|
||||||
|
process.env.XDG_CONFIG_HOME = tempConfigHome;
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(fn)
|
||||||
|
.finally(() => {
|
||||||
|
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||||
|
else process.env.XDG_CONFIG_HOME = previousXdg;
|
||||||
|
rmSync(tempConfigHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("extension registers Ponytail commands", () => {
|
||||||
|
const { commands } = createPiHarness();
|
||||||
|
|
||||||
|
assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-help", "ponytail-review"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => {
|
||||||
|
const { commands, events, appendedEntries } = createPiHarness();
|
||||||
|
const ctx = createCommandContext();
|
||||||
|
|
||||||
|
await events.get("session_start")({ reason: "startup" }, ctx);
|
||||||
|
await commands.get("ponytail").handler("ultra", ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(appendedEntries.at(-1), {
|
||||||
|
customType: "ponytail-mode",
|
||||||
|
data: { mode: "ultra" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
|
assert.ok(result.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
|
||||||
|
assert.ok(result.systemPrompt.includes("ultra"));
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("session_start restores latest persisted mode", async () => withTempConfig(async () => {
|
||||||
|
const { events } = createPiHarness();
|
||||||
|
const ctx = createCommandContext({
|
||||||
|
sessionManager: {
|
||||||
|
getEntries: () => [
|
||||||
|
{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await events.get("session_start")({ reason: "resume" }, ctx);
|
||||||
|
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
|
|
||||||
|
assert.ok(result.systemPrompt.includes("lite"));
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("skill alias commands delegate to Pi skill commands", async () => {
|
||||||
|
const { commands, sentUserMessages } = createPiHarness();
|
||||||
|
const ctx = createCommandContext();
|
||||||
|
|
||||||
|
await commands.get("ponytail-review").handler("", ctx);
|
||||||
|
await commands.get("ponytail-help").handler("", ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
|
||||||
|
"/skill:ponytail-review",
|
||||||
|
"/skill:ponytail-help",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normal mode disables persistent instructions", async () => withTempConfig(async () => {
|
||||||
|
const { commands, events } = createPiHarness();
|
||||||
|
const ctx = createCommandContext();
|
||||||
|
|
||||||
|
await events.get("session_start")({ reason: "startup" }, ctx);
|
||||||
|
await commands.get("ponytail").handler("ultra", ctx);
|
||||||
|
await events.get("input")({ text: "normal mode", source: "interactive" }, ctx);
|
||||||
|
|
||||||
|
const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
|
assert.equal(disabled, undefined);
|
||||||
|
}));
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
filterSkillBodyForMode,
|
||||||
|
parsePonytailCommand,
|
||||||
|
readDefaultMode,
|
||||||
|
resolveSessionMode,
|
||||||
|
writeDefaultMode,
|
||||||
|
} from "../index.js";
|
||||||
|
|
||||||
|
test("parsePonytailCommand falls back to full when invoked bare and default is off", () => {
|
||||||
|
assert.deepEqual(parsePonytailCommand("", "off"), { type: "set-mode", mode: "full" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parsePonytailCommand parses modes, status, and default subcommand", () => {
|
||||||
|
assert.deepEqual(parsePonytailCommand("ultra", "full"), { type: "set-mode", mode: "ultra" });
|
||||||
|
assert.deepEqual(parsePonytailCommand("status", "full"), { type: "status" });
|
||||||
|
assert.deepEqual(parsePonytailCommand("default lite", "full"), { type: "set-default", mode: "lite" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveSessionMode prefers latest persisted session mode", () => {
|
||||||
|
const entries = [
|
||||||
|
{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
|
||||||
|
{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(resolveSessionMode(entries, "full"), "ultra");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readDefaultMode and writeDefaultMode use XDG config path", () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "ponytail-config-"));
|
||||||
|
const previousXdg = process.env.XDG_CONFIG_HOME;
|
||||||
|
const previousDefault = process.env.PONYTAIL_DEFAULT_MODE;
|
||||||
|
const configPath = join(tempDir, "ponytail", "config.json");
|
||||||
|
process.env.XDG_CONFIG_HOME = tempDir;
|
||||||
|
delete process.env.PONYTAIL_DEFAULT_MODE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(readDefaultMode(), "full");
|
||||||
|
assert.equal(writeDefaultMode("ultra"), "ultra");
|
||||||
|
assert.equal(readDefaultMode(), "ultra");
|
||||||
|
assert.ok(existsSync(configPath));
|
||||||
|
assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { defaultMode: "ultra" });
|
||||||
|
} finally {
|
||||||
|
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||||
|
else process.env.XDG_CONFIG_HOME = previousXdg;
|
||||||
|
if (previousDefault === undefined) delete process.env.PONYTAIL_DEFAULT_MODE;
|
||||||
|
else process.env.PONYTAIL_DEFAULT_MODE = previousDefault;
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterSkillBodyForMode keeps only requested intensity examples and rows", () => {
|
||||||
|
const body = `---\nname: ponytail\n---\n| **lite** | keep lite |\n| **full** | keep full |\n| **ultra** | keep ultra |\n- lite: Lite example\n- full: Full example\n- ultra: Ultra example\nOther line`;
|
||||||
|
|
||||||
|
const filtered = filterSkillBodyForMode(body, "ultra");
|
||||||
|
|
||||||
|
assert.ok(!filtered.includes("keep lite"));
|
||||||
|
assert.ok(!filtered.includes("keep full"));
|
||||||
|
assert.ok(filtered.includes("keep ultra"));
|
||||||
|
assert.ok(!filtered.includes("Lite example"));
|
||||||
|
assert.ok(filtered.includes("Ultra example"));
|
||||||
|
assert.ok(filtered.includes("Other line"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user