feat(pi): 3 new Pi Dev extensions — read-tweet, auto-context, task-lifecycle tools
CI / build (push) Has been cancelled
CI / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import type { ExtensionAPI, ExtensionContext, BeforeAgentStartEventResult } from "@earendil-works/pi-coding-agent";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
||||
import { basename, join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
const AUTOMATON_HOME = join(homedir(), ".automaton");
|
||||
const STALE_MINUTES = 30;
|
||||
|
||||
interface TaskInfo {
|
||||
name: string;
|
||||
phase: string;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
function getTasks(autoDir: string): TaskInfo[] {
|
||||
const tasksDir = join(autoDir, ".automaton", "tasks");
|
||||
if (!existsSync(tasksDir)) return [];
|
||||
try {
|
||||
const entries = readdirSync(tasksDir);
|
||||
const tasks: TaskInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
const stateFile = join(tasksDir, entry, ".state");
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const phase = readFileSync(stateFile, "utf-8").trim();
|
||||
if (!phase) continue;
|
||||
const stat = statSync(stateFile);
|
||||
tasks.push({ name: entry, phase, mtime: stat.mtime });
|
||||
} catch {
|
||||
// skip unreadable
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
return tasks;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getLoopInfo(autoDir: string): string[] {
|
||||
const loopsDir = join(autoDir, ".automaton", "loops");
|
||||
if (!existsSync(loopsDir)) return [];
|
||||
try {
|
||||
const entries = readdirSync(loopsDir);
|
||||
const lines: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const stateFile = join(loopsDir, entry, ".state.loop");
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const content = readFileSync(stateFile, "utf-8").trim();
|
||||
const state = JSON.parse(content);
|
||||
const taskRef = state.current_task ? `, active task: ${state.current_task}` : "";
|
||||
lines.push(`Loop "${entry}": ${state.status || "unknown"}${taskRef}`);
|
||||
} catch {
|
||||
// skip unparseable
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("before_agent_start", async (_event, ctx): Promise<BeforeAgentStartEventResult | undefined> => {
|
||||
const cwd = ctx.cwd;
|
||||
const inFramework = cwd === AUTOMATON_HOME || cwd.startsWith(AUTOMATON_HOME + "/");
|
||||
|
||||
let baseDir: string | null = null;
|
||||
let scopeLabel: string;
|
||||
|
||||
if (inFramework) {
|
||||
baseDir = AUTOMATON_HOME;
|
||||
scopeLabel = "framework (~/.automaton/)";
|
||||
} else {
|
||||
const projectAuto = join(cwd, ".automaton");
|
||||
if (existsSync(projectAuto)) {
|
||||
baseDir = cwd;
|
||||
const projectName = basename(cwd) || "project";
|
||||
scopeLabel = `project (${projectName}/.automaton/)`;
|
||||
} else {
|
||||
return; // not in automaton context
|
||||
}
|
||||
}
|
||||
|
||||
const tasks = getTasks(baseDir);
|
||||
const editTasks = tasks.filter((t) => t.phase === "implement" || t.phase === "doc_review");
|
||||
const currentTask = editTasks.length > 0 ? editTasks[0] : tasks.length > 0 ? tasks[0] : null;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Automaton scope: ${scopeLabel}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("How Automaton works:");
|
||||
lines.push("- Automaton enforces a task phase state machine. File edits are ONLY allowed when a task is in 'implement' or 'doc_review' phase.");
|
||||
lines.push("- When the user asks you to build, fix, or change something, you MUST first create a task (use automaton_create_task) and transition it to 'implement' before you can edit any files.");
|
||||
lines.push("- Without a task in 'implement', the automaton-guard-pi extension will BLOCK all file edits.");
|
||||
lines.push(`- Available tools: automaton_create_task (create+transition), automaton_transition (change phase), automaton_status (check state).`);
|
||||
lines.push("");
|
||||
|
||||
if (currentTask) {
|
||||
lines.push(`State: Active task "${currentTask.name}" is in "${currentTask.phase}" phase.`);
|
||||
|
||||
if (currentTask.phase === "implement" || currentTask.phase === "doc_review") {
|
||||
lines.push(`You CAN edit files under this task.`);
|
||||
const ageMinutes = Math.round((Date.now() - currentTask.mtime.getTime()) / 60000);
|
||||
if (ageMinutes > STALE_MINUTES) {
|
||||
lines.push(
|
||||
`Note: task has been in ${currentTask.phase} for ${ageMinutes} min and is considered stale. ` +
|
||||
`Run automaton_transition (or touch via status.py) if still active.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push(`File edits are BLOCKED. Call automaton_transition to move it to 'implement' before editing.`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`State: No active tasks. When the user makes a work request, call automaton_create_task first.`);
|
||||
}
|
||||
|
||||
const byPhase = new Map<string, number>();
|
||||
for (const t of tasks) {
|
||||
byPhase.set(t.phase, (byPhase.get(t.phase) || 0) + 1);
|
||||
}
|
||||
const summary = Array.from(byPhase.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([p, c]) => `${p} (${c})`)
|
||||
.join(", ");
|
||||
lines.push(`All tasks: ${summary || "none"}`);
|
||||
|
||||
const loopInfo = getLoopInfo(baseDir);
|
||||
for (const l of loopInfo) {
|
||||
lines.push(l);
|
||||
}
|
||||
|
||||
return {
|
||||
systemPrompt: `${_event.systemPrompt}\n\n## Automaton Context\n\n${lines.join("\n")}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "pi-automaton-context",
|
||||
"version": "1.0.0",
|
||||
"description": "Auto-injects Automaton framework context into Pi Dev system prompt — no more manual copy-paste of pi-automaton.sh output",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync, readFileSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
const STATUS_SCRIPT = join(homedir(), ".automaton", "scripts", "status.py");
|
||||
|
||||
function resolveProjectDir(cwd: string): string | null {
|
||||
const inFramework = cwd === join(homedir(), ".automaton") || cwd.startsWith(join(homedir(), ".automaton") + "/");
|
||||
if (inFramework) return homedir() + "/.automaton";
|
||||
if (existsSync(join(cwd, ".automaton"))) return cwd;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTasks(projectDir: string) {
|
||||
const tasksDir = join(projectDir, ".automaton", "tasks");
|
||||
if (!existsSync(tasksDir)) return [];
|
||||
const tasks: { name: string; phase: string }[] = [];
|
||||
for (const entry of readdirSync(tasksDir)) {
|
||||
const stateFile = join(tasksDir, entry, ".state");
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const phase = readFileSync(stateFile, "utf-8").trim();
|
||||
if (phase) tasks.push({ name: entry, phase });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
tasks.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "automaton_create_task",
|
||||
label: "Create Automaton Task",
|
||||
description:
|
||||
"Create a new automaton task and transition it to the specified phase. " +
|
||||
"Use this when the user asks you to do work that requires file edits — you need a task in 'implement' or 'doc_review' phase before you can edit files.",
|
||||
promptSnippet: "Create automaton tasks for work management",
|
||||
promptGuidelines: [
|
||||
"When the user asks you to build, implement, fix, or change something, first call automaton_create_task to create a task and transition it to 'implement' phase",
|
||||
"Only after the task is in 'implement' can you edit files — the guard will block edits otherwise",
|
||||
"Use a descriptive task name based on what the user wants (e.g., 'add-login-page', 'fix-api-timeout')",
|
||||
"Default phase is 'implement' — omit phase for most cases",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ minLength: 1, description: "Task name (kebab-case, e.g. 'add-login-page')" }),
|
||||
phase: Type.Optional(Type.String({ default: "implement", description: "Phase to transition to after creation" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const { name, phase = "implement" } = params;
|
||||
const projectDir = resolveProjectDir(process.cwd());
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Not in an automaton-managed project. No .automaton/ directory found." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const createOut = execSync(
|
||||
`python3 ${STATUS_SCRIPT} --create-task ${JSON.stringify(name)} --project ${JSON.stringify(projectDir)}`,
|
||||
{ encoding: "utf-8", timeout: 10000 },
|
||||
);
|
||||
const transitionOut = execSync(
|
||||
`python3 ${STATUS_SCRIPT} --transition ${phase} --task ${JSON.stringify(name)} --project ${JSON.stringify(projectDir)}`,
|
||||
{ encoding: "utf-8", timeout: 10000 },
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Created task "${name}" and transitioned to "${phase}".` }],
|
||||
details: { task: name, phase },
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Failed to create task: ${e.stderr || e.message || String(e)}`,
|
||||
}],
|
||||
details: { error: e.stderr || e.message, task: name },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "automaton_transition",
|
||||
label: "Transition Automaton Task",
|
||||
description:
|
||||
"Transition an existing automaton task to a new phase. " +
|
||||
"Valid phases: research, decomposition, design, implement, test_design, testing, doc_review, complete. " +
|
||||
"Use this to move a task forward (e.g. from research to implement).",
|
||||
promptSnippet: "Transition automaton tasks between phases",
|
||||
parameters: Type.Object({
|
||||
task: Type.String({ minLength: 1, description: "Task name" }),
|
||||
phase: Type.String({ minLength: 1, description: "Target phase" }),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const { task, phase } = params;
|
||||
const projectDir = resolveProjectDir(process.cwd());
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Not in an automaton-managed project." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`python3 ${STATUS_SCRIPT} --transition ${phase} --task ${JSON.stringify(task)} --project ${JSON.stringify(projectDir)}`,
|
||||
{ encoding: "utf-8", timeout: 10000 },
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: `Task "${task}" transitioned to "${phase}".` }],
|
||||
details: { task, phase },
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Failed to transition task: ${e.stderr || e.message || String(e)}`,
|
||||
}],
|
||||
details: { error: e.stderr || e.message, task, phase },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "automaton_status",
|
||||
label: "Automaton Status",
|
||||
description:
|
||||
"Show current automaton project status — all tasks, their phases, and any running loops. " +
|
||||
"Call this to check what state things are in before deciding what to do.",
|
||||
promptSnippet: "Check automaton project status",
|
||||
parameters: Type.Object({}),
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
||||
const projectDir = resolveProjectDir(process.cwd());
|
||||
if (!projectDir) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Not in an automaton-managed project." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const tasks = getTasks(projectDir);
|
||||
const editTasks = tasks.filter((t) => t.phase === "implement" || t.phase === "doc_review");
|
||||
const currentTask = editTasks.length > 0 ? editTasks[0] : tasks.length > 0 ? tasks[0] : null;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Project: ${projectDir.split("/").pop()}`);
|
||||
if (currentTask) {
|
||||
lines.push(`Current: "${currentTask.name}" (${currentTask.phase})`);
|
||||
} else {
|
||||
lines.push("No tasks. Create one with automaton_create_task.");
|
||||
}
|
||||
lines.push("");
|
||||
for (const t of tasks) {
|
||||
const marker = currentTask && t.name === currentTask.name ? ">" : " ";
|
||||
lines.push(`${marker} ${t.name.padEnd(35)} ${t.phase}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { tasks: tasks.length, current: currentTask?.name || null },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "pi-automaton-tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Registers automaton_create_task and automaton_transition tools so the agent can manage task lifecycle automatically",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
const BIRD_BIN = "/opt/homebrew/bin/bird";
|
||||
|
||||
const TweetParams = Type.Object({
|
||||
url: Type.String({ minLength: 1, description: "Tweet URL or numeric ID" }),
|
||||
mode: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("read"),
|
||||
Type.Literal("thread"),
|
||||
Type.Literal("replies"),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "read_tweet",
|
||||
label: "Read Tweet",
|
||||
description:
|
||||
"Read an X/Twitter tweet or thread using the local bird CLI. " +
|
||||
"Returns tweet author, text, media, metrics, and timestamps. " +
|
||||
"Use this instead of webfetch for x.com/twitter.com URLs.",
|
||||
promptSnippet: "Read X/Twitter tweets with bird CLI",
|
||||
promptGuidelines: [
|
||||
"Use read_tweet when the user shares an x.com or twitter.com URL and asks what it says",
|
||||
"Use mode='thread' for full conversation threads",
|
||||
"Use mode='replies' to fetch replies to a tweet",
|
||||
"If fetch fails with auth errors, ask the user to sign in to x.com in their browser and retry",
|
||||
],
|
||||
parameters: TweetParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const { url, mode = "read" } = params;
|
||||
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
const cmd = `${BIRD_BIN} ${mode} ${JSON.stringify(url)} --json`;
|
||||
const stdout = execSync(cmd, { encoding: "utf-8", timeout: 15000 });
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
const author = parsed.author?.name || parsed.author?.screen_name || "unknown";
|
||||
const text = parsed.text || parsed.content || "";
|
||||
const createdAt = parsed.created_at || "";
|
||||
const retweetCount = parsed.metrics?.retweet_count ?? parsed.metrics?.retweets ?? 0;
|
||||
const likeCount = parsed.metrics?.like_count ?? parsed.metrics?.likes ?? 0;
|
||||
const replyCount = parsed.metrics?.reply_count ?? parsed.metrics?.replies ?? 0;
|
||||
const mediaCount = parsed.media?.length ?? 0;
|
||||
const threadCount = parsed.thread?.tweets?.length ?? 0;
|
||||
const repliesCount = parsed.replies?.length ?? 0;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Author: ${author}`);
|
||||
if (createdAt) lines.push(`Posted: ${createdAt}`);
|
||||
lines.push("");
|
||||
lines.push(text);
|
||||
if (retweetCount || likeCount || replyCount) {
|
||||
lines.push("");
|
||||
lines.push(`Retweets: ${retweetCount} Likes: ${likeCount} Replies: ${replyCount}`);
|
||||
}
|
||||
if (mediaCount) lines.push(`Media: ${mediaCount} attachment(s)`);
|
||||
if (threadCount) lines.push(`Thread: ${threadCount} tweets total`);
|
||||
if (repliesCount) lines.push(`Replies fetched: ${repliesCount}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: lines.join("\n") },
|
||||
{ type: "text", text: `\n--- raw ---\n${JSON.stringify(parsed, null, 2)}` },
|
||||
],
|
||||
details: {
|
||||
author,
|
||||
text: text.slice(0, 500),
|
||||
url,
|
||||
mode,
|
||||
tweetCount: threadCount || repliesCount || 1,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
const errMsg = e.stderr || e.message || String(e);
|
||||
const hint = errMsg.includes("auth")
|
||||
? " Sign in to x.com in your browser and retry."
|
||||
: "";
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Failed to fetch tweet: ${errMsg}${hint}` }],
|
||||
details: { error: errMsg, url, mode },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "pi-read-tweet",
|
||||
"version": "1.0.0",
|
||||
"description": "Read X/Twitter tweets using the local `bird` CLI — registered as a read_tweet tool for Pi Dev",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -54,18 +54,26 @@ else
|
||||
echo "OpenCode: not detected (no ~/.config/opencode/opencode.json or .jsonc)"
|
||||
fi
|
||||
|
||||
# Pi Dev guard
|
||||
PI_SOURCE="$FRAMEWORK_DIR/plugins/automaton-guard-pi"
|
||||
# Pi Dev extensions
|
||||
PI_EXTENSIONS=(
|
||||
"$FRAMEWORK_DIR/plugins/automaton-guard-pi"
|
||||
"$FRAMEWORK_DIR/plugins/pi-read-tweet"
|
||||
"$FRAMEWORK_DIR/plugins/pi-automaton-context"
|
||||
"$FRAMEWORK_DIR/plugins/pi-automaton-tools"
|
||||
)
|
||||
if command -v pi &>/dev/null; then
|
||||
INSTALLED=$(pi list 2>/dev/null | grep -c "automaton-guard-pi" || true)
|
||||
if [ "$INSTALLED" -gt 0 ]; then
|
||||
echo "Pi Dev: already installed"
|
||||
else
|
||||
echo "Pi Dev: installing guard extension..."
|
||||
pi install "$PI_SOURCE" 2>&1 | sed 's/^/ /'
|
||||
INSTALLED_PI=true
|
||||
echo "Pi Dev: installed"
|
||||
fi
|
||||
for ext in "${PI_EXTENSIONS[@]}"; do
|
||||
ext_name=$(basename "$ext")
|
||||
INSTALLED=$(pi list 2>/dev/null | grep -c "$ext_name" || true)
|
||||
if [ "$INSTALLED" -gt 0 ]; then
|
||||
echo "Pi Dev: $ext_name already installed"
|
||||
else
|
||||
echo "Pi Dev: installing $ext_name..."
|
||||
pi install "$ext" 2>&1 | sed 's/^/ /'
|
||||
INSTALLED_PI=true
|
||||
echo "Pi Dev: $ext_name installed"
|
||||
fi
|
||||
done
|
||||
# Offer pi-automaton startup wrapper
|
||||
PI_WRAPPER="$FRAMEWORK_DIR/scripts/pi-automaton.sh"
|
||||
if [ -f "$PI_WRAPPER" ]; then
|
||||
|
||||
Reference in New Issue
Block a user