Add Pi backend, secretary system prompt, and workspace templates
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
30
src/agent/pi-backend.smoke.test.ts
Normal file
30
src/agent/pi-backend.smoke.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLogger } from "../log.js";
|
||||
import type { AgentHostEvent } from "./events.js";
|
||||
import { AgentHost } from "./host.js";
|
||||
import { createPiBackend } from "./pi-backend.js";
|
||||
|
||||
// Runs a real model call through Pi. Opt in with SECRETARY_SMOKE=1; needs auth in ~/.pi/agent.
|
||||
describe.skipIf(process.env.SECRETARY_SMOKE !== "1")("pi backend smoke", () => {
|
||||
it("answers a prompt end to end", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "secretary-smoke-"));
|
||||
const host = new AgentHost(
|
||||
createPiBackend({ workspaceDir: dir, timezone: "UTC", customTools: [], logger: createLogger("debug") }),
|
||||
createLogger("debug"),
|
||||
);
|
||||
const events: AgentHostEvent[] = [];
|
||||
try {
|
||||
await host.start();
|
||||
host.subscribe((e) => events.push(e));
|
||||
await host.send({ text: "Reply with exactly the word pong and nothing else." });
|
||||
const settled = events.find((e) => e.type === "run_settled");
|
||||
expect(settled && "finalText" in settled && settled.finalText.toLowerCase()).toContain("pong");
|
||||
} finally {
|
||||
await host.dispose();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
117
src/agent/pi-backend.ts
Normal file
117
src/agent/pi-backend.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
type AgentSession,
|
||||
type AgentSessionRuntime,
|
||||
type CreateAgentSessionFromServicesOptions,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
type ExtensionAPI,
|
||||
getAgentDir,
|
||||
type InlineExtension,
|
||||
resolveCliModel,
|
||||
SessionManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { Logger } from "../log.js";
|
||||
import type { AgentBackend, SessionLike } from "./host.js";
|
||||
import { memorySection, secretarySystemPrompt } from "./system-prompt.js";
|
||||
|
||||
export type CustomTools = NonNullable<CreateAgentSessionFromServicesOptions["customTools"]>;
|
||||
|
||||
export interface PiBackendOptions {
|
||||
workspaceDir: string;
|
||||
piModel?: string;
|
||||
timezone: string;
|
||||
customTools: CustomTools;
|
||||
logger: Logger;
|
||||
onShutdownRequest?: () => void;
|
||||
}
|
||||
|
||||
export const ENABLED_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "schedule"];
|
||||
|
||||
export function createPiBackend(opts: PiBackendOptions): AgentBackend {
|
||||
const { workspaceDir, logger } = opts;
|
||||
const agentDir = getAgentDir();
|
||||
let runtime: AgentSessionRuntime | undefined;
|
||||
|
||||
const memoryExtension: InlineExtension = {
|
||||
name: "secretary",
|
||||
factory: (pi: ExtensionAPI) => {
|
||||
pi.on("before_agent_start", async (event) => ({ systemPrompt: event.systemPrompt + memorySection(workspaceDir) }));
|
||||
},
|
||||
};
|
||||
|
||||
const factory: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
agentDir,
|
||||
resourceLoaderOptions: {
|
||||
appendSystemPrompt: [secretarySystemPrompt(opts.timezone)],
|
||||
extensionFactories: [memoryExtension],
|
||||
},
|
||||
});
|
||||
for (const d of services.diagnostics) {
|
||||
const level = d.type === "error" ? "error" : d.type === "warning" ? "warn" : "info";
|
||||
logger[level](`pi: ${d.message}`);
|
||||
}
|
||||
|
||||
let model: CreateAgentSessionFromServicesOptions["model"];
|
||||
let thinkingLevel: CreateAgentSessionFromServicesOptions["thinkingLevel"];
|
||||
if (opts.piModel) {
|
||||
const resolved = resolveCliModel({ cliModel: opts.piModel, modelRuntime: services.modelRuntime });
|
||||
if (resolved.error) throw new Error(resolved.error);
|
||||
if (resolved.warning) logger.warn(resolved.warning);
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model,
|
||||
thinkingLevel,
|
||||
tools: ENABLED_TOOLS,
|
||||
customTools: opts.customTools,
|
||||
});
|
||||
return { ...result, services, diagnostics: services.diagnostics };
|
||||
};
|
||||
|
||||
const bind = async (session: AgentSession): Promise<void> => {
|
||||
await session.bindExtensions({
|
||||
mode: "print",
|
||||
shutdownHandler: () => opts.onShutdownRequest?.(),
|
||||
onError: (error) => logger.error("pi extension error", error),
|
||||
});
|
||||
};
|
||||
|
||||
const asSessionLike = (session: AgentSession): SessionLike => session;
|
||||
|
||||
return {
|
||||
async start() {
|
||||
runtime = await createAgentSessionRuntime(factory, {
|
||||
cwd: workspaceDir,
|
||||
agentDir,
|
||||
sessionManager: SessionManager.continueRecent(workspaceDir),
|
||||
sessionStartEvent: { type: "session_start", reason: "startup" },
|
||||
});
|
||||
runtime.setRebindSession(bind);
|
||||
await bind(runtime.session);
|
||||
if (runtime.modelFallbackMessage) logger.warn(runtime.modelFallbackMessage);
|
||||
if (!runtime.session.model) {
|
||||
throw new Error("No model available. Log in with `pi` (or put auth.json under ~/.pi/agent), or set PI_MODEL and a provider API key.");
|
||||
}
|
||||
return asSessionLike(runtime.session);
|
||||
},
|
||||
async newSession() {
|
||||
if (!runtime) throw new Error("backend not started");
|
||||
const result = await runtime.newSession();
|
||||
if (result.cancelled) throw new Error("new session was cancelled by an extension");
|
||||
return asSessionLike(runtime.session);
|
||||
},
|
||||
async dispose() {
|
||||
await runtime?.dispose();
|
||||
runtime = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
31
src/agent/system-prompt.test.ts
Normal file
31
src/agent/system-prompt.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { memorySection, secretarySystemPrompt } from "./system-prompt.js";
|
||||
|
||||
describe("secretarySystemPrompt", () => {
|
||||
it("mentions the timezone, the memory file, the inbox, and the script convention", () => {
|
||||
const p = secretarySystemPrompt("Europe/Berlin");
|
||||
expect(p).toContain("Europe/Berlin");
|
||||
expect(p).toContain("MEMORY.md");
|
||||
expect(p).toContain("inbox/");
|
||||
expect(p).toContain("Print nothing and exit 0");
|
||||
expect(p).toContain("schedule");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memorySection", () => {
|
||||
it("returns an empty string when MEMORY.md is missing or blank, else a headed section", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "secretary-mem-"));
|
||||
try {
|
||||
expect(memorySection(dir)).toBe("");
|
||||
await writeFile(path.join(dir, "MEMORY.md"), " \n");
|
||||
expect(memorySection(dir)).toBe("");
|
||||
await writeFile(path.join(dir, "MEMORY.md"), "# Memory\n- likes tea\n");
|
||||
expect(memorySection(dir)).toBe("\n\n# Current contents of MEMORY.md\n\n# Memory\n- likes tea");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
41
src/agent/system-prompt.ts
Normal file
41
src/agent/system-prompt.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function secretarySystemPrompt(timezone: string): string {
|
||||
return `# Volcanic Secretary
|
||||
|
||||
You are a personal assistant for a single owner who talks to you over Telegram. Your working directory is your workspace and it is yours to organize.
|
||||
|
||||
## Conversation style
|
||||
- Replies are read on a phone. Keep them short and lead with the answer.
|
||||
- Use plain Markdown: bold, lists, inline code, fenced code. No tables.
|
||||
- Do not narrate tool use unless the owner asks.
|
||||
|
||||
## Memory
|
||||
- MEMORY.md in the workspace holds durable facts about the owner, their preferences, and ongoing matters. Its current contents are shown to you every turn.
|
||||
- When you learn something worth keeping, update MEMORY.md with the edit tool. Keep it short and tidy.
|
||||
|
||||
## Files
|
||||
- Files the owner sends arrive in inbox/. Read them from there.
|
||||
- Put scripts you write in scripts/.
|
||||
|
||||
## Scheduled jobs (the schedule tool)
|
||||
- Use it for reminders ("remind me at 9"), recurring prompts, and watchers.
|
||||
- A watcher is a script job: write a script in scripts/, then create a job with a command. The command runs from the workspace with bash -c. Run Python with \`python\`.
|
||||
- Script convention: Print nothing and exit 0 when there is nothing to report. Print a message and exit 0 when there is. Exit non-zero only on real errors.
|
||||
- deliver "agent" hands the output to you to act on and reply. deliver "direct" sends it to the owner verbatim with no model call.
|
||||
- The owner's timezone is ${timezone}. Cron expressions are evaluated in it. For "at", use ISO 8601 with an offset.
|
||||
- After creating a job, confirm it to the owner in one line including the next fire time.`;
|
||||
}
|
||||
|
||||
/** Text appended to the system prompt each turn. Empty when MEMORY.md is missing or blank. */
|
||||
export function memorySection(workspaceDir: string): string {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(path.join(workspaceDir, "MEMORY.md"), "utf8").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (!content) return "";
|
||||
return `\n\n# Current contents of MEMORY.md\n\n${content}`;
|
||||
}
|
||||
23
src/workspace.test.ts
Normal file
23
src/workspace.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AGENTS_TEMPLATE, ensureWorkspace, MEMORY_TEMPLATE } from "./workspace.js";
|
||||
|
||||
describe("ensureWorkspace", () => {
|
||||
it("creates folders and template files, and never overwrites existing files", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "secretary-ws-"));
|
||||
try {
|
||||
await ensureWorkspace(dir);
|
||||
for (const sub of ["scripts", "inbox", ".secretary"]) expect((await stat(path.join(dir, sub))).isDirectory()).toBe(true);
|
||||
expect(await readFile(path.join(dir, "AGENTS.md"), "utf8")).toBe(AGENTS_TEMPLATE);
|
||||
expect(await readFile(path.join(dir, "MEMORY.md"), "utf8")).toBe(MEMORY_TEMPLATE);
|
||||
|
||||
await writeFile(path.join(dir, "MEMORY.md"), "custom");
|
||||
await ensureWorkspace(dir);
|
||||
expect(await readFile(path.join(dir, "MEMORY.md"), "utf8")).toBe("custom");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
39
src/workspace.ts
Normal file
39
src/workspace.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const AGENTS_TEMPLATE = `# Workspace
|
||||
|
||||
This directory belongs to Volcanic Secretary, a personal assistant reached over Telegram.
|
||||
|
||||
- MEMORY.md: durable facts about the owner. Keep it current.
|
||||
- scripts/: scripts for scheduled jobs.
|
||||
- inbox/: files the owner sent.
|
||||
- .secretary/: runtime state. Do not edit by hand.
|
||||
|
||||
Standing instructions for the assistant go below this line.
|
||||
`;
|
||||
|
||||
export const MEMORY_TEMPLATE = `# Memory
|
||||
|
||||
Durable facts the assistant should remember. The assistant edits this file itself.
|
||||
|
||||
## Owner
|
||||
|
||||
## Preferences
|
||||
|
||||
## Ongoing
|
||||
`;
|
||||
|
||||
export async function ensureWorkspace(dir: string): Promise<void> {
|
||||
for (const sub of ["scripts", "inbox", ".secretary"]) await mkdir(path.join(dir, sub), { recursive: true });
|
||||
await writeIfMissing(path.join(dir, "AGENTS.md"), AGENTS_TEMPLATE);
|
||||
await writeIfMissing(path.join(dir, "MEMORY.md"), MEMORY_TEMPLATE);
|
||||
}
|
||||
|
||||
async function writeIfMissing(file: string, content: string): Promise<void> {
|
||||
try {
|
||||
await writeFile(file, content, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user