Add Pi backend, secretary system prompt, and workspace templates

Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
EugeneTes
2026-09-06 16:54:56 +02:00
parent 4b7348a704
commit 52785df229
6 changed files with 281 additions and 0 deletions

View 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
View 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;
},
};
}

View 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 });
}
});
});

View 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
View 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
View 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;
}
}