160 KiB
Volcanic Secretary Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A headless Node daemon that lets one owner talk to a Pi-driven personal assistant over Telegram, with media input, a memory file, and a scheduler that can run conditional scripts.
Architecture: One Node 24 process embeds Pi's SDK (createAgentSessionRuntime) behind a narrow AgentHost interface, a grammY long-polling bridge that turns Telegram messages into agent input and agent events into Telegram messages, and a JSON-backed scheduler whose jobs deliver either straight to Telegram or into the agent. All three are wired in main.ts and talk only through the interfaces defined in this plan.
Tech Stack: TypeScript 5.9 (ESM, NodeNext), Node 24, @earendil-works/pi-coding-agent 0.85.x, grammy 1.46 + @grammyjs/auto-retry, croner 10, typebox 1.3, vitest 5, tsx.
Spec: docs/superpowers/specs/2026-09-06-volcanic-secretary-design.md.
Global Constraints
- Node
>=24. ESM only ("type": "module"). TypeScriptstrict,module/moduleResolutionNodeNext. Relative imports insidesrc/use the.jsextension. - Pi version:
@earendil-works/pi-coding-agent^0.85.1. The daemon inherits~/.pi/agent(auth, settings, packages, skills) through Pi's default resource loader. Pi's ownPI_CODING_AGENT_DIRenv var relocates that directory. - Single owner: every update whose
from.idis notTELEGRAM_OWNER_IDor whose chat is notprivateis ignored. No approval prompts of any kind. - Tools enabled for the agent, exactly:
read,bash,edit,write,grep,find,ls,schedule. - Telegram: chunk outgoing Markdown at
3800characters (limit is 4096 after parsing). Progress message edits throttled to one per2000ms. Typing action refreshed every4000ms. Progress message shows the last5tool lines. - Scheduler: script timeout default
300s. Failure report carries the last20stderr lines. Scripts run withbash -cfrom the workspace. Job file:<workspace>/.secretary/jobs.json, written atomically. - Workspace layout:
AGENTS.md,MEMORY.md,scripts/,inbox/,.secretary/. Documents are saved asinbox/<yyyy-mm-dd>-<name>with-Nsuffix on collision. - Strings the user sees, verbatim: progress header
βοΈ Workingβ¦;/stoprepliesStopped.orNothing running.; empty reply fallbackDone, no text reply.; transcript echoπ€ <transcript>; job direct delivery<job name>: <message>; job agent delivery starts withScheduled job "<name>" fired with this output:. - Every commit message ends with the trailer line
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT. - Tests:
vitest, colocatedsrc/**/*.test.ts. Run all withnpm test. Typecheck withnpm run typecheck.
File Structure
| File | Responsibility |
|---|---|
package.json, tsconfig.json, tsconfig.build.json, vitest.config.ts, .gitignore, .env.example |
Project scaffold |
src/log.ts |
Leveled logger |
src/config.ts |
Env var parsing and validation into Config |
src/workspace.ts |
Workspace folder and template file creation |
src/telegram/markdown.ts |
Markdown to Telegram HTML |
src/telegram/chunk.ts |
Split long Markdown at safe boundaries, reopening code fences |
src/scheduler/types.ts |
Job and related types |
src/scheduler/store.ts |
jobs.json persistence, next fire time, id generation, schedule validation |
src/scheduler/runner.ts |
Run a shell command with timeout, apply the stdout condition rule |
src/scheduler/scheduler.ts |
Timer loop, firing, failure dedupe, delivery |
src/tools/schedule.ts |
Pi custom tool wrapping the scheduler |
src/stt/openai.ts |
OpenAI transcription client |
src/agent/events.ts |
AgentInput, AgentStatus, AgentHostEvent, tool call summaries |
src/agent/host.ts |
AgentHost: session lifecycle, busy routing, event mapping |
src/agent/system-prompt.ts |
Secretary system prompt and memory section |
src/agent/pi-backend.ts |
Real Pi runtime behind AgentBackend |
src/telegram/inbound.ts |
Telegram message to AgentInput (photos, voice, documents) |
src/telegram/outbound.ts |
Progress message, typing action, chunked sends with HTML fallback |
src/telegram/bridge.ts |
Bot wiring: owner guard, commands, message handler, host event rendering |
src/main.ts |
Startup sequence, wiring, signals |
Dockerfile, docker-compose.yml, README.md |
Packaging and docs |
Task 1: Project scaffold, logger, config
Files:
- Create:
package.json,tsconfig.json,tsconfig.build.json,vitest.config.ts,.gitignore,.env.example - Create:
src/log.ts,src/config.ts - Test:
src/config.test.ts,src/log.test.ts
Interfaces:
-
Produces:
createLogger(level: LogLevel, out?: (line: string) => void): LoggerwithLogger = { debug, info, warn, error }(message: string, meta?: unknown);LogLevel = "debug" | "info" | "warn" | "error". -
Produces:
loadConfig(env?: NodeJS.ProcessEnv, cwd?: string): Config,class ConfigError extends Error, andinterface Config { telegramBotToken: string; telegramOwnerId: number; workspaceDir: string; openaiApiKey?: string; sttModel: string; piModel?: string; timezone: string; shellPath?: string; logLevel: LogLevel; } -
Step 1: Create package.json and install
{
"name": "volcanic-secretary",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"dev": "node --env-file-if-exists=.env --import tsx src/main.ts",
"start": "node --env-file-if-exists=.env dist/main.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.85.1",
"@grammyjs/auto-retry": "^2.0.2",
"croner": "^10.0.1",
"grammy": "^1.46.0",
"typebox": "^1.3.27"
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.23.13",
"typescript": "^5.9.0",
"vitest": "^5.0.0"
}
}
Run: npm install
Expected: node_modules/ created, package-lock.json written, no errors.
- Step 2: Create tsconfig.json, tsconfig.build.json, vitest.config.ts
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
tsconfig.build.json:
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts", "src/test-support/**"]
}
vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
testTimeout: 20_000,
},
});
- Step 3: Create .gitignore and .env.example
.gitignore:
node_modules/
dist/
.env
.superpowers/
workspace/inbox/*
!workspace/inbox/.gitkeep
workspace/scripts/*
!workspace/scripts/.gitkeep
workspace/.secretary/
.env.example:
# Required
TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
TELEGRAM_OWNER_ID=123456789
# Optional
WORKSPACE_DIR=./workspace
OPENAI_API_KEY=
STT_MODEL=gpt-4o-transcribe
PI_MODEL=
SECRETARY_TIMEZONE=
SECRETARY_SHELL=
LOG_LEVEL=info
# PI_CODING_AGENT_DIR= (Pi's own override for ~/.pi/agent, mainly for Docker)
- Step 4: Write the failing logger test
src/log.test.ts:
import { describe, expect, it } from "vitest";
import { createLogger } from "./log.js";
describe("createLogger", () => {
it("drops messages below the configured level", () => {
const lines: string[] = [];
const log = createLogger("warn", (l) => lines.push(l));
log.debug("d");
log.info("i");
log.warn("w");
log.error("e");
expect(lines).toHaveLength(2);
expect(lines[0]).toMatch(/ WARN w$/);
expect(lines[1]).toMatch(/ ERROR e$/);
});
it("appends serialized meta and formats errors by name and message", () => {
const lines: string[] = [];
const log = createLogger("debug", (l) => lines.push(l));
log.info("with meta", { a: 1 });
log.error("boom", new Error("bad"));
expect(lines[0]).toMatch(/with meta \{"a":1\}$/);
expect(lines[1]).toMatch(/boom Error: bad$/);
});
});
- Step 5: Run it to verify it fails
Run: npx vitest run src/log.test.ts
Expected: FAIL, cannot find module ./log.js.
- Step 6: Implement src/log.ts
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface Logger {
debug(message: string, meta?: unknown): void;
info(message: string, meta?: unknown): void;
warn(message: string, meta?: unknown): void;
error(message: string, meta?: unknown): void;
}
const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
export function createLogger(
level: LogLevel,
out: (line: string) => void = (line) => process.stdout.write(line + "\n"),
): Logger {
const emit = (lvl: LogLevel, message: string, meta?: unknown) => {
if (ORDER[lvl] < ORDER[level]) return;
const suffix = meta === undefined ? "" : " " + formatMeta(meta);
out(`${new Date().toISOString()} ${lvl.toUpperCase().padEnd(5)} ${message}${suffix}`);
};
return {
debug: (m, x) => emit("debug", m, x),
info: (m, x) => emit("info", m, x),
warn: (m, x) => emit("warn", m, x),
error: (m, x) => emit("error", m, x),
};
}
function formatMeta(meta: unknown): string {
if (meta instanceof Error) return `${meta.name}: ${meta.message}`;
try {
return JSON.stringify(meta);
} catch {
return String(meta);
}
}
- Step 7: Run logger test to verify it passes
Run: npx vitest run src/log.test.ts
Expected: PASS (2 tests).
- Step 8: Write the failing config test
src/config.test.ts:
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ConfigError, loadConfig } from "./config.js";
const base = { TELEGRAM_BOT_TOKEN: "123:abc", TELEGRAM_OWNER_ID: "42" };
describe("loadConfig", () => {
it("applies defaults and resolves the workspace against cwd", () => {
const cfg = loadConfig(base, "/proj");
expect(cfg.telegramBotToken).toBe("123:abc");
expect(cfg.telegramOwnerId).toBe(42);
expect(cfg.workspaceDir).toBe(path.resolve("/proj", "workspace"));
expect(cfg.sttModel).toBe("gpt-4o-transcribe");
expect(cfg.logLevel).toBe("info");
expect(cfg.openaiApiKey).toBeUndefined();
expect(cfg.piModel).toBeUndefined();
expect(typeof cfg.timezone).toBe("string");
});
it("reads optional values and treats blank strings as unset", () => {
const cfg = loadConfig(
{ ...base, OPENAI_API_KEY: "sk", PI_MODEL: " ", SECRETARY_TIMEZONE: "Europe/Berlin", LOG_LEVEL: "debug", WORKSPACE_DIR: "/abs/ws", SECRETARY_SHELL: "/bin/bash" },
"/proj",
);
expect(cfg.openaiApiKey).toBe("sk");
expect(cfg.piModel).toBeUndefined();
expect(cfg.timezone).toBe("Europe/Berlin");
expect(cfg.logLevel).toBe("debug");
expect(cfg.workspaceDir).toBe(path.resolve("/abs/ws"));
expect(cfg.shellPath).toBe("/bin/bash");
});
it("rejects missing required variables by name", () => {
expect(() => loadConfig({ TELEGRAM_OWNER_ID: "42" }, "/p")).toThrow(ConfigError);
expect(() => loadConfig({ TELEGRAM_OWNER_ID: "42" }, "/p")).toThrow(/TELEGRAM_BOT_TOKEN/);
expect(() => loadConfig({ TELEGRAM_BOT_TOKEN: "x" }, "/p")).toThrow(/TELEGRAM_OWNER_ID/);
});
it("rejects a non-numeric owner id, bad log level, and bad timezone", () => {
expect(() => loadConfig({ ...base, TELEGRAM_OWNER_ID: "bob" }, "/p")).toThrow(/positive integer/);
expect(() => loadConfig({ ...base, LOG_LEVEL: "loud" }, "/p")).toThrow(/LOG_LEVEL/);
expect(() => loadConfig({ ...base, SECRETARY_TIMEZONE: "Mars/Olympus" }, "/p")).toThrow(/timezone/);
});
});
- Step 9: Run it to verify it fails
Run: npx vitest run src/config.test.ts
Expected: FAIL, cannot find module ./config.js.
- Step 10: Implement src/config.ts
import path from "node:path";
import type { LogLevel } from "./log.js";
export interface Config {
telegramBotToken: string;
telegramOwnerId: number;
workspaceDir: string;
openaiApiKey?: string;
sttModel: string;
piModel?: string;
timezone: string;
shellPath?: string;
logLevel: LogLevel;
}
export class ConfigError extends Error {}
const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
export function loadConfig(env: NodeJS.ProcessEnv = process.env, cwd: string = process.cwd()): Config {
const telegramBotToken = required(env, "TELEGRAM_BOT_TOKEN");
const ownerRaw = required(env, "TELEGRAM_OWNER_ID");
const telegramOwnerId = Number(ownerRaw);
if (!Number.isInteger(telegramOwnerId) || telegramOwnerId <= 0) {
throw new ConfigError(`TELEGRAM_OWNER_ID must be a positive integer, got "${ownerRaw}"`);
}
const logLevel = (env.LOG_LEVEL?.trim() || "info") as LogLevel;
if (!LOG_LEVELS.includes(logLevel)) {
throw new ConfigError(`LOG_LEVEL must be one of ${LOG_LEVELS.join(", ")}, got "${logLevel}"`);
}
const timezone = env.SECRETARY_TIMEZONE?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone;
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
} catch {
throw new ConfigError(`SECRETARY_TIMEZONE is not a valid IANA timezone: "${timezone}"`);
}
return {
telegramBotToken,
telegramOwnerId,
workspaceDir: path.resolve(cwd, env.WORKSPACE_DIR?.trim() || "workspace"),
openaiApiKey: blankToUndefined(env.OPENAI_API_KEY),
sttModel: env.STT_MODEL?.trim() || "gpt-4o-transcribe",
piModel: blankToUndefined(env.PI_MODEL),
timezone,
shellPath: blankToUndefined(env.SECRETARY_SHELL),
logLevel,
};
}
function required(env: NodeJS.ProcessEnv, key: string): string {
const value = env[key]?.trim();
if (!value) throw new ConfigError(`${key} is required`);
return value;
}
function blankToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
- Step 11: Run all tests and typecheck
Run: npm test && npm run typecheck
Expected: PASS (6 tests), typecheck clean.
- Step 12: Commit
git add package.json package-lock.json tsconfig.json tsconfig.build.json vitest.config.ts .gitignore .env.example src/log.ts src/log.test.ts src/config.ts src/config.test.ts
git commit -m "Scaffold project with logger and config
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 2: Markdown to Telegram HTML
Files:
- Create:
src/telegram/markdown.ts - Test:
src/telegram/markdown.test.ts
Interfaces:
- Produces:
escapeHtml(text: string): string,markdownToTelegramHtml(markdown: string): string.
Telegram HTML supports <b> <i> <s> <code> <pre> <a href> <blockquote> and requires & < > escaped everywhere else. This converter is hand rolled for that subset; no Markdown library.
- Step 1: Write the failing test
src/telegram/markdown.test.ts:
import { describe, expect, it } from "vitest";
import { escapeHtml, markdownToTelegramHtml } from "./markdown.js";
describe("escapeHtml", () => {
it("escapes the three HTML specials", () => {
expect(escapeHtml("a < b && c > d")).toBe("a < b && c > d");
});
});
describe("markdownToTelegramHtml", () => {
it("escapes plain text", () => {
expect(markdownToTelegramHtml("1 < 2 & 3 > 2")).toBe("1 < 2 & 3 > 2");
});
it("renders inline formatting", () => {
expect(markdownToTelegramHtml("**bold** __bold2__ *it* _it2_ ~~gone~~ `co<de>`")).toBe(
"<b>bold</b> <b>bold2</b> <i>it</i> <i>it2</i> <s>gone</s> <code>co<de></code>",
);
});
it("leaves snake_case words and lone asterisks alone", () => {
expect(markdownToTelegramHtml("use snake_case_names and 2 * 3")).toBe("use snake_case_names and 2 * 3");
});
it("does not format inside inline code", () => {
expect(markdownToTelegramHtml("run `**not bold**`")).toBe("run <code>**not bold**</code>");
});
it("renders links", () => {
expect(markdownToTelegramHtml("see [docs](https://x.y/a?b=1&c=2)")).toBe(
'see <a href="https://x.y/a?b=1&c=2">docs</a>',
);
});
it("renders headings as bold lines", () => {
expect(markdownToTelegramHtml("# Title\n\ntext")).toBe("<b>Title</b>\n\ntext");
});
it("renders bullet and ordered lists", () => {
expect(markdownToTelegramHtml("- one\n* two\n1. three\n - nested")).toBe("β’ one\nβ’ two\n1. three\n β’ nested");
});
it("renders fenced code with and without language, escaped and unformatted", () => {
const md = "```ts\nconst a = 1 < 2; // **x**\n```\n\n```\nplain\n```";
expect(markdownToTelegramHtml(md)).toBe(
'<pre><code class="language-ts">const a = 1 < 2; // **x**</code></pre>\n\n<pre>plain</pre>',
);
});
it("merges consecutive blockquote lines and renders rules", () => {
expect(markdownToTelegramHtml("> a\n> b\n\n---\n\nc")).toBe("<blockquote>a\nb</blockquote>\n\nβββ\n\nc");
});
it("collapses runs of blank lines and trims", () => {
expect(markdownToTelegramHtml("\n\na\n\n\n\nb\n\n")).toBe("a\n\nb");
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/telegram/markdown.test.ts
Expected: FAIL, cannot find module ./markdown.js.
- Step 3: Implement src/telegram/markdown.ts
export function escapeHtml(text: string): string {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
const FENCE = /```([\w+-]*)[ \t]*\n([\s\S]*?)\n?```/g;
/** Convert a Markdown subset to Telegram HTML. Unknown constructs are passed through escaped. */
export function markdownToTelegramHtml(markdown: string): string {
const out: string[] = [];
let last = 0;
for (const m of markdown.matchAll(FENCE)) {
out.push(renderBlocks(markdown.slice(last, m.index)));
const lang = m[1];
const code = escapeHtml(m[2]);
out.push(lang ? `<pre><code class="language-${lang}">${code}</code></pre>` : `<pre>${code}</pre>`);
last = m.index + m[0].length;
}
out.push(renderBlocks(markdown.slice(last)));
return out.join("").replace(/\n{3,}/g, "\n\n").trim();
}
function renderBlocks(text: string): string {
const lines = text.split("\n");
const out: string[] = [];
let quote: string[] | null = null;
const flushQuote = () => {
if (quote) out.push(`<blockquote>${quote.join("\n")}</blockquote>`);
quote = null;
};
for (const line of lines) {
const q = /^\s{0,3}>\s?(.*)$/.exec(line);
if (q) {
(quote ??= []).push(renderInline(q[1]));
continue;
}
flushQuote();
const heading = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
if (heading) {
out.push(`<b>${renderInline(heading[1])}</b>`);
continue;
}
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) {
out.push("βββ");
continue;
}
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (bullet) {
out.push(`${bullet[1]}β’ ${renderInline(bullet[2])}`);
continue;
}
const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line);
if (ordered) {
out.push(`${ordered[1]}${ordered[2]}. ${renderInline(ordered[3])}`);
continue;
}
out.push(renderInline(line));
}
flushQuote();
return out.join("\n");
}
function renderInline(text: string): string {
const codes: string[] = [];
let s = text.replace(/`([^`\n]+)`/g, (_m, code: string) => {
codes.push(`<code>${escapeHtml(code)}</code>`);
return `\u0000${codes.length - 1}\u0000`;
});
s = escapeHtml(s);
s = s.replace(
/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g,
(_m, label: string, url: string) => `<a href="${url.replace(/"/g, """)}">${label}</a>`,
);
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
s = s.replace(/__(.+?)__/g, "<b>$1</b>");
s = s.replace(/~~(.+?)~~/g, "<s>$1</s>");
s = s.replace(/(^|[^*\w])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\w)/g, "$1<i>$2</i>");
s = s.replace(/(^|[^_\w])_(?!\s)([^_\n]+?)(?<!\s)_(?!\w)/g, "$1<i>$2</i>");
return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => codes[Number(i)]);
}
Note on the link test: the URL is escaped by escapeHtml in step 2 before the link regex runs, so & inside the href becomes &, which is what Telegram expects.
- Step 4: Run test to verify it passes
Run: npx vitest run src/telegram/markdown.test.ts
Expected: PASS (11 tests). If the link test fails because [^\s)]+ stopped at &, it did not: & contains no whitespace or ). If the italic test fails on *it* after __bold2__, check the preceding character class allows a space.
- Step 5: Commit
git add src/telegram/markdown.ts src/telegram/markdown.test.ts
git commit -m "Add Markdown to Telegram HTML converter
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 3: Chunking long messages
Files:
- Create:
src/telegram/chunk.ts - Test:
src/telegram/chunk.test.ts
Interfaces:
-
Produces:
TELEGRAM_CHUNK_LIMIT = 3800,splitMarkdown(text: string, limit?: number): string[]. Chunks are Markdown, meant to be converted individually bymarkdownToTelegramHtml. A code fence cut by a split is closed at the end of one chunk and reopened with the same opener at the start of the next. -
Step 1: Write the failing test
src/telegram/chunk.test.ts:
import { describe, expect, it } from "vitest";
import { splitMarkdown, TELEGRAM_CHUNK_LIMIT } from "./chunk.js";
describe("splitMarkdown", () => {
it("returns short text as a single chunk", () => {
expect(splitMarkdown("hello")).toEqual(["hello"]);
expect(TELEGRAM_CHUNK_LIMIT).toBe(3800);
});
it("prefers paragraph boundaries", () => {
const text = "a".repeat(30) + "\n\n" + "b".repeat(30) + "\n\n" + "c".repeat(30);
expect(splitMarkdown(text, 70)).toEqual(["a".repeat(30) + "\n\n" + "b".repeat(30), "c".repeat(30)]);
});
it("falls back to line boundaries", () => {
const text = "a".repeat(30) + "\n" + "b".repeat(30) + "\n" + "c".repeat(30);
expect(splitMarkdown(text, 65)).toEqual(["a".repeat(30) + "\n" + "b".repeat(30), "c".repeat(30)]);
});
it("hard cuts a single long line", () => {
const text = "x".repeat(100);
expect(splitMarkdown(text, 40)).toEqual(["x".repeat(40), "x".repeat(40), "x".repeat(20)]);
});
it("closes and reopens a code fence across the split", () => {
const text = "```py\n" + "print(1)\n".repeat(10) + "```\nafter";
const chunks = splitMarkdown(text, 50);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks[0].endsWith("\n```")).toBe(true);
expect(chunks[1].startsWith("```py\n")).toBe(true);
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(50 + 4);
const rejoined = chunks.join("\n").replace(/\n```\n```py\n/g, "\n");
expect(rejoined).toBe(text);
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/telegram/chunk.test.ts
Expected: FAIL, cannot find module ./chunk.js.
- Step 3: Implement src/telegram/chunk.ts
export const TELEGRAM_CHUNK_LIMIT = 3800;
/** Split Markdown into chunks of at most `limit` characters (plus a closing fence when needed). */
export function splitMarkdown(text: string, limit: number = TELEGRAM_CHUNK_LIMIT): string[] {
const chunks: string[] = [];
let rest = text;
while (rest.length > limit) {
const cut = findCut(rest, limit);
let head = rest.slice(0, cut);
let tail = rest.slice(cut).replace(/^\n+/, "");
const openFence = unclosedFence(head);
if (openFence !== null) {
head = head.replace(/\n+$/, "") + "\n```";
tail = `${openFence}\n${tail}`;
}
chunks.push(head);
rest = tail;
}
if (rest.length > 0 || chunks.length === 0) chunks.push(rest);
return chunks;
}
function findCut(text: string, limit: number): number {
const paragraph = text.lastIndexOf("\n\n", limit);
if (paragraph > 0) return paragraph;
const line = text.lastIndexOf("\n", limit);
if (line > 0) return line;
return limit;
}
/** Returns the opener line (e.g. "```py") if `text` ends inside a fenced block, else null. */
function unclosedFence(text: string): string | null {
let open: string | null = null;
for (const line of text.split("\n")) {
if (/^\s{0,3}```/.test(line)) open = open === null ? line.trim() : null;
}
return open;
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/telegram/chunk.test.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add src/telegram/chunk.ts src/telegram/chunk.test.ts
git commit -m "Add Markdown chunking with fence reopening
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 4: Scheduler types and job store
Files:
- Create:
src/scheduler/types.ts,src/scheduler/store.ts - Test:
src/scheduler/store.test.ts
Interfaces:
-
Produces (
types.ts):type JobSchedule = { at: string } | { cron: string }; type JobSource = { kind: "text"; text: string } | { kind: "script"; command: string; timeoutSec?: number }; type JobDelivery = "agent" | "direct"; interface JobResult { ok: boolean; exitCode?: number | null; stderrTail?: string; at: string } interface Job { id: string; name: string; schedule: JobSchedule; source: JobSource; deliver: JobDelivery; createdAt: string; lastRunAt?: string; lastResult?: JobResult } interface NewJobInput { name: string; schedule: JobSchedule; source: JobSource; deliver: JobDelivery } const DEFAULT_SCRIPT_TIMEOUT_SEC = 300; -
Produces (
store.ts):class JobStore { constructor(filePath: string); load(): Promise<Job[]>; save(jobs: Job[]): Promise<void> },nextFireTime(job: Job, from: Date, timezone: string): Date | null,validateSchedule(schedule: JobSchedule, timezone: string): string | null,generateJobId(random?: () => number): string. -
Step 1: Create src/scheduler/types.ts
export type JobSchedule = { at: string } | { cron: string };
export type JobSource =
| { kind: "text"; text: string }
| { kind: "script"; command: string; timeoutSec?: number };
export type JobDelivery = "agent" | "direct";
export interface JobResult {
ok: boolean;
exitCode?: number | null;
stderrTail?: string;
at: string;
}
export interface Job {
id: string;
name: string;
schedule: JobSchedule;
source: JobSource;
deliver: JobDelivery;
createdAt: string;
lastRunAt?: string;
lastResult?: JobResult;
}
export interface NewJobInput {
name: string;
schedule: JobSchedule;
source: JobSource;
deliver: JobDelivery;
}
export const DEFAULT_SCRIPT_TIMEOUT_SEC = 300;
- Step 2: Write the failing store test
src/scheduler/store.test.ts:
import { mkdtemp, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { generateJobId, JobStore, nextFireTime, validateSchedule } from "./store.js";
import type { Job } from "./types.js";
const baseJob: Job = {
id: "abc12",
name: "test",
schedule: { cron: "0 9 * * *" },
source: { kind: "text", text: "hi" },
deliver: "direct",
createdAt: "2026-01-01T00:00:00.000Z",
};
describe("JobStore", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "secretary-store-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("returns an empty list when the file does not exist", async () => {
const store = new JobStore(path.join(dir, "nested", "jobs.json"));
expect(await store.load()).toEqual([]);
});
it("round trips jobs and leaves no temp file behind", async () => {
const file = path.join(dir, "nested", "jobs.json");
const store = new JobStore(file);
await store.save([baseJob]);
expect(await store.load()).toEqual([baseJob]);
expect(await readdir(path.dirname(file))).toEqual(["jobs.json"]);
});
});
describe("nextFireTime", () => {
it("evaluates cron in the given timezone, strictly after from", () => {
const from = new Date("2026-01-15T10:00:00Z");
const next = nextFireTime(baseJob, from, "Europe/Berlin");
expect(next?.toISOString()).toBe("2026-01-16T08:00:00.000Z");
});
it("returns the at time even when it is in the past", () => {
const job: Job = { ...baseJob, schedule: { at: "2020-01-01T00:00:00Z" } };
expect(nextFireTime(job, new Date("2026-01-01T00:00:00Z"), "UTC")?.toISOString()).toBe("2020-01-01T00:00:00.000Z");
});
it("returns null for an unparseable at time", () => {
const job: Job = { ...baseJob, schedule: { at: "yesterday-ish" } };
expect(nextFireTime(job, new Date(), "UTC")).toBeNull();
});
});
describe("validateSchedule", () => {
it("accepts valid cron and at values", () => {
expect(validateSchedule({ cron: "*/5 * * * *" }, "UTC")).toBeNull();
expect(validateSchedule({ at: "2026-09-07T09:00:00+02:00" }, "UTC")).toBeNull();
});
it("describes invalid values", () => {
expect(validateSchedule({ cron: "99 99 * * *" }, "UTC")).toMatch(/invalid cron/);
expect(validateSchedule({ at: "soon" }, "UTC")).toMatch(/ISO 8601/);
});
});
describe("generateJobId", () => {
it("makes five character ids from the safe alphabet", () => {
const id = generateJobId();
expect(id).toMatch(/^[abcdefghjkmnpqrstuvwxyz23456789]{5}$/);
expect(generateJobId(() => 0)).toBe("aaaaa");
});
});
- Step 3: Run it to verify it fails
Run: npx vitest run src/scheduler/store.test.ts
Expected: FAIL, cannot find module ./store.js.
- Step 4: Implement src/scheduler/store.ts
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { Cron } from "croner";
import type { Job, JobSchedule } from "./types.js";
export class JobStore {
constructor(private readonly filePath: string) {}
async load(): Promise<Job[]> {
try {
const parsed: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
return Array.isArray(parsed) ? (parsed as Job[]) : [];
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
throw err;
}
}
/** Atomic write: temp file in the same directory, then rename over the target. */
async save(jobs: Job[]): Promise<void> {
await mkdir(path.dirname(this.filePath), { recursive: true });
const tmp = `${this.filePath}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(jobs, null, 2) + "\n", "utf8");
await rename(tmp, this.filePath);
}
}
/**
* Next time a job should fire, strictly after `from` for cron jobs.
* One-shot jobs return their `at` time even when it is already past, so overdue jobs fire immediately.
*/
export function nextFireTime(job: Job, from: Date, timezone: string): Date | null {
if ("at" in job.schedule) {
const t = new Date(job.schedule.at);
return Number.isNaN(t.getTime()) ? null : t;
}
return new Cron(job.schedule.cron, { timezone }).nextRun(from);
}
export function validateSchedule(schedule: JobSchedule, timezone: string): string | null {
if ("at" in schedule) {
return Number.isNaN(new Date(schedule.at).getTime())
? `"at" is not a valid ISO 8601 time: ${schedule.at}`
: null;
}
try {
new Cron(schedule.cron, { timezone });
return null;
} catch (err) {
return `invalid cron expression "${schedule.cron}": ${(err as Error).message}`;
}
}
const ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
export function generateJobId(random: () => number = Math.random): string {
let id = "";
for (let i = 0; i < 5; i++) id += ID_ALPHABET[Math.floor(random() * ID_ALPHABET.length)];
return id;
}
- Step 5: Run test to verify it passes
Run: npx vitest run src/scheduler/store.test.ts
Expected: PASS (8 tests). If the cron test is off by an hour, confirm croner is ^10 and the timezone option name is exactly timezone.
- Step 6: Commit
git add src/scheduler/types.ts src/scheduler/store.ts src/scheduler/store.test.ts
git commit -m "Add scheduler job types and JSON job store
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 5: Script runner with the stdout condition rule
Files:
- Create:
src/scheduler/runner.ts - Test:
src/scheduler/runner.test.ts
Interfaces:
- Produces:
type ScriptOutcome = | { kind: "silent" } | { kind: "output"; text: string } | { kind: "failed"; exitCode: number | null; stderrTail: string; timedOut: boolean }; interface RunScriptOptions { cwd: string; timeoutSec: number; shellPath?: string; env?: NodeJS.ProcessEnv } function resolveShell(override?: string, platform?: NodeJS.Platform): string; function tailLines(text: string, n: number): string; function runScript(command: string, opts: RunScriptOptions): Promise<ScriptOutcome>;
Rule: exit 0 and empty trimmed stdout is silent; exit 0 with stdout is output; anything else is failed. On Windows, Git Bash is looked up at C:\Program Files\Git\bin\bash.exe first, mirroring Pi.
- Step 1: Write the failing test
src/scheduler/runner.test.ts:
import { describe, expect, it } from "vitest";
import { resolveShell, runScript, tailLines } from "./runner.js";
const cwd = process.cwd();
describe("runScript", () => {
it("reports output when the script prints", async () => {
expect(await runScript("echo hello", { cwd, timeoutSec: 10 })).toEqual({ kind: "output", text: "hello" });
});
it("reports silent on exit 0 with no output", async () => {
expect(await runScript("exit 0", { cwd, timeoutSec: 10 })).toEqual({ kind: "silent" });
});
it("reports failure with exit code and stderr tail", async () => {
const r = await runScript("echo boom >&2; exit 3", { cwd, timeoutSec: 10 });
expect(r).toEqual({ kind: "failed", exitCode: 3, stderrTail: "boom", timedOut: false });
});
it("kills a script that exceeds the timeout", async () => {
const r = await runScript("sleep 5; echo late", { cwd, timeoutSec: 1 });
expect(r.kind).toBe("failed");
if (r.kind === "failed") {
expect(r.timedOut).toBe(true);
expect(r.stderrTail).toMatch(/timed out after 1s/);
}
});
it("reports a missing shell as a failure instead of throwing", async () => {
const r = await runScript("echo hi", { cwd, timeoutSec: 5, shellPath: "/definitely/not/a/shell" });
expect(r.kind).toBe("failed");
if (r.kind === "failed") expect(r.stderrTail).toMatch(/failed to start/);
});
});
describe("resolveShell", () => {
it("prefers the override", () => {
expect(resolveShell("/x/bash", "win32")).toBe("/x/bash");
});
it("uses bash from PATH on non-Windows", () => {
expect(resolveShell(undefined, "linux")).toBe("bash");
});
});
describe("tailLines", () => {
it("keeps the last n lines", () => {
expect(tailLines("a\nb\nc\nd\n", 2)).toBe("c\nd");
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/scheduler/runner.test.ts
Expected: FAIL, cannot find module ./runner.js.
- Step 3: Implement src/scheduler/runner.ts
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
export type ScriptOutcome =
| { kind: "silent" }
| { kind: "output"; text: string }
| { kind: "failed"; exitCode: number | null; stderrTail: string; timedOut: boolean };
export interface RunScriptOptions {
cwd: string;
timeoutSec: number;
shellPath?: string;
env?: NodeJS.ProcessEnv;
}
const STDERR_TAIL_LINES = 20;
const WINDOWS_BASH_CANDIDATES = [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
];
export function resolveShell(override?: string, platform: NodeJS.Platform = process.platform): string {
if (override) return override;
if (platform === "win32") {
for (const candidate of WINDOWS_BASH_CANDIDATES) if (existsSync(candidate)) return candidate;
}
return "bash";
}
export function tailLines(text: string, n: number): string {
return text.trimEnd().split("\n").slice(-n).join("\n");
}
export function runScript(command: string, opts: RunScriptOptions): Promise<ScriptOutcome> {
return new Promise((resolve) => {
const shell = resolveShell(opts.shellPath);
const child = spawn(shell, ["-c", command], {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
let timedOut = false;
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
const timer = setTimeout(() => {
timedOut = true;
child.kill();
}, opts.timeoutSec * 1000);
child.on("error", (err) => {
clearTimeout(timer);
resolve({ kind: "failed", exitCode: null, stderrTail: `failed to start ${shell}: ${err.message}`, timedOut: false });
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
const note = `(timed out after ${opts.timeoutSec}s)`;
resolve({ kind: "failed", exitCode: code, stderrTail: tailLines(`${stderr}\n${note}`, STDERR_TAIL_LINES), timedOut: true });
return;
}
if (code !== 0) {
resolve({ kind: "failed", exitCode: code, stderrTail: tailLines(stderr, STDERR_TAIL_LINES), timedOut: false });
return;
}
const text = stdout.trim();
resolve(text ? { kind: "output", text } : { kind: "silent" });
});
});
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/scheduler/runner.test.ts
Expected: PASS (8 tests). On Windows this needs Git for Windows installed at the default path. On Linux it needs bash on PATH.
- Step 5: Commit
git add src/scheduler/runner.ts src/scheduler/runner.test.ts
git commit -m "Add script runner with stdout condition rule
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 6: Scheduler core
Files:
- Create:
src/scheduler/scheduler.ts - Test:
src/scheduler/scheduler.test.ts
Interfaces:
- Consumes:
JobStore,nextFireTime,validateSchedule,generateJobId(Task 4);runScript,RunScriptOptions,ScriptOutcome(Task 5);Logger(Task 1). - Produces:
interface SchedulerDeps { store: JobStore; workspaceDir: string; timezone: string; logger: Logger; deliverDirect: (text: string) => Promise<void>; deliverAgent: (text: string) => Promise<void>; runScript?: (command: string, opts: RunScriptOptions) => Promise<ScriptOutcome>; shellPath?: string; now?: () => Date; } interface JobView extends Job { nextRunAt: string | null } class Scheduler { constructor(deps: SchedulerDeps); start(): Promise<void>; stop(): Promise<void>; list(): JobView[]; get(id: string): JobView | undefined; create(input: NewJobInput): Promise<JobView>; // throws Error(problem) on invalid input delete(id: string): Promise<boolean>; tick(): Promise<void>; drain(): Promise<void>; } function validateJobInput(input: NewJobInput, timezone: string): string | null; function formatFailure(job: Job, outcome: Extract<ScriptOutcome, { kind: "failed" }>): string;
Firing rules implemented here: text source is the message; script source uses the runner outcome; direct sends <name>: <message>; agent sends Scheduled job "<name>" fired with this output: + blank line + message; failures are reported once and stay silent while consecutive runs fail with the same exit code; one-shot jobs are removed after they run; cron jobs never catch up; a job still running when due again is skipped.
- Step 1: Write the failing test
src/scheduler/scheduler.test.ts:
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createLogger } from "../log.js";
import type { ScriptOutcome } from "./runner.js";
import { Scheduler, validateJobInput } from "./scheduler.js";
import { JobStore } from "./store.js";
const logger = createLogger("error", () => {});
const PAST = "2020-01-01T00:00:00.000Z";
function harness(runScript?: (command: string) => Promise<ScriptOutcome>) {
const direct: string[] = [];
const agent: string[] = [];
const build = (dir: string) =>
new Scheduler({
store: new JobStore(path.join(dir, "jobs.json")),
workspaceDir: dir,
timezone: "UTC",
logger,
deliverDirect: async (t) => void direct.push(t),
deliverAgent: async (t) => void agent.push(t),
runScript: runScript ? (cmd) => runScript(cmd) : undefined,
});
return { direct, agent, build };
}
describe("Scheduler", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "secretary-sched-"));
});
afterEach(async () => {
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
});
it("fires an overdue one-shot text job directly and removes it", async () => {
const h = harness();
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "dentist", schedule: { at: PAST }, source: { kind: "text", text: "call them" }, deliver: "direct" });
await s.drain();
expect(h.direct).toEqual(["dentist: call them"]);
expect(s.get(job.id)).toBeUndefined();
expect(await new JobStore(path.join(dir, "jobs.json")).load()).toEqual([]);
await s.stop();
});
it("delivers to the agent with the job prefix", async () => {
const h = harness();
const s = h.build(dir);
await s.start();
await s.create({ name: "news", schedule: { at: PAST }, source: { kind: "text", text: "summarize the news" }, deliver: "agent" });
await s.drain();
expect(h.agent).toEqual(['Scheduled job "news" fired with this output:\n\nsummarize the news']);
await s.stop();
});
it("stays silent when a script prints nothing, but records the run", async () => {
vi.useFakeTimers({ now: new Date("2026-01-01T12:00:00Z") });
const h = harness(async () => ({ kind: "silent" }));
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "watch", schedule: { cron: "* * * * *" }, source: { kind: "script", command: "python w.py" }, deliver: "agent" });
await vi.advanceTimersByTimeAsync(60_000);
await s.drain();
expect(h.agent).toEqual([]);
expect(h.direct).toEqual([]);
expect(s.get(job.id)?.lastRunAt).toBeDefined();
expect(s.get(job.id)?.lastResult?.ok).toBe(true);
await s.stop();
});
it("delivers script output to the agent", async () => {
const h = harness(async () => ({ kind: "output", text: "price dropped to 9" }));
const s = h.build(dir);
await s.start();
await s.create({ name: "price", schedule: { at: PAST }, source: { kind: "script", command: "python p.py" }, deliver: "agent" });
await s.drain();
expect(h.agent).toEqual(['Scheduled job "price" fired with this output:\n\nprice dropped to 9']);
await s.stop();
});
it("reports a failure once and again only after a success or a different exit code", async () => {
const outcomes: ScriptOutcome[] = [
{ kind: "failed", exitCode: 1, stderrTail: "no module", timedOut: false },
{ kind: "failed", exitCode: 1, stderrTail: "no module", timedOut: false },
{ kind: "failed", exitCode: 2, stderrTail: "worse", timedOut: false },
{ kind: "silent" },
{ kind: "failed", exitCode: 2, stderrTail: "worse again", timedOut: false },
];
let i = 0;
const h = harness(async () => outcomes[i++]);
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "flaky", schedule: { cron: "* * * * *" }, source: { kind: "script", command: "x" }, deliver: "direct" });
for (let n = 0; n < outcomes.length; n++) {
await s.fireNow(job.id);
}
expect(h.direct).toHaveLength(3);
expect(h.direct[0]).toMatch(/β οΈ Job "flaky" failed \(exit 1\):\nno module/);
expect(h.direct[1]).toMatch(/exit 2/);
expect(h.direct[2]).toMatch(/worse again/);
await s.stop();
});
it("computes the next cron occurrence strictly in the future and fires when the timer elapses", async () => {
vi.useFakeTimers({ now: new Date("2026-01-01T12:00:00Z") });
const h = harness();
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "tick", schedule: { cron: "*/5 * * * *" }, source: { kind: "text", text: "5 min passed" }, deliver: "direct" });
expect(s.get(job.id)?.nextRunAt).toBe("2026-01-01T12:05:00.000Z");
await vi.advanceTimersByTimeAsync(5 * 60_000);
await s.drain();
expect(h.direct).toEqual(["tick: 5 min passed"]);
expect(s.get(job.id)?.nextRunAt).toBe("2026-01-01T12:10:00.000Z");
await s.stop();
});
it("skips an occurrence while the previous run is still going", async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
let runs = 0;
const h = harness(async () => {
runs++;
await gate;
return { kind: "silent" };
});
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "slow", schedule: { cron: "* * * * *" }, source: { kind: "script", command: "x" }, deliver: "direct" });
const first = s.fireNow(job.id);
await s.fireNow(job.id);
expect(runs).toBe(1);
release();
await first;
await s.stop();
});
it("delete returns false for unknown ids", async () => {
const s = harness().build(dir);
await s.start();
expect(await s.delete("nope1")).toBe(false);
await s.stop();
});
});
describe("validateJobInput", () => {
const ok = { name: "n", schedule: { at: "2030-01-01T00:00:00Z" }, source: { kind: "text" as const, text: "t" }, deliver: "agent" as const };
it("accepts a valid job", () => {
expect(validateJobInput(ok, "UTC")).toBeNull();
});
it("rejects missing name, bad schedule, empty text or command, bad deliver", () => {
expect(validateJobInput({ ...ok, name: " " }, "UTC")).toMatch(/name/);
expect(validateJobInput({ ...ok, schedule: { cron: "bad" } }, "UTC")).toMatch(/cron/);
expect(validateJobInput({ ...ok, source: { kind: "text", text: "" } }, "UTC")).toMatch(/text/);
expect(validateJobInput({ ...ok, source: { kind: "script", command: " " } }, "UTC")).toMatch(/command/);
expect(validateJobInput({ ...ok, source: { kind: "script", command: "x", timeoutSec: 0 } }, "UTC")).toMatch(/timeoutSec/);
expect(validateJobInput({ ...ok, deliver: "email" as never }, "UTC")).toMatch(/deliver/);
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/scheduler/scheduler.test.ts
Expected: FAIL, cannot find module ./scheduler.js.
- Step 3: Implement src/scheduler/scheduler.ts
import type { Logger } from "../log.js";
import { runScript as defaultRunScript, type RunScriptOptions, type ScriptOutcome } from "./runner.js";
import { generateJobId, type JobStore, nextFireTime, validateSchedule } from "./store.js";
import { DEFAULT_SCRIPT_TIMEOUT_SEC, type Job, type NewJobInput } from "./types.js";
export interface SchedulerDeps {
store: JobStore;
workspaceDir: string;
timezone: string;
logger: Logger;
deliverDirect: (text: string) => Promise<void>;
deliverAgent: (text: string) => Promise<void>;
runScript?: (command: string, opts: RunScriptOptions) => Promise<ScriptOutcome>;
shellPath?: string;
now?: () => Date;
}
export interface JobView extends Job {
nextRunAt: string | null;
}
type FailedOutcome = Extract<ScriptOutcome, { kind: "failed" }>;
const MAX_TIMER_MS = 60 * 60 * 1000;
const STOP_GRACE_MS = 10_000;
export class Scheduler {
private jobs: Job[] = [];
private nextTimes = new Map<string, Date>();
private timer: NodeJS.Timeout | undefined;
private running = new Set<string>();
private inflight = new Set<Promise<void>>();
private stopped = false;
private readonly runScript: (command: string, opts: RunScriptOptions) => Promise<ScriptOutcome>;
private readonly now: () => Date;
constructor(private readonly deps: SchedulerDeps) {
this.runScript = deps.runScript ?? defaultRunScript;
this.now = deps.now ?? (() => new Date());
}
async start(): Promise<void> {
this.jobs = await this.deps.store.load();
this.stopped = false;
await this.tick();
}
async stop(): Promise<void> {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
await Promise.race([this.drain(), new Promise((r) => setTimeout(r, STOP_GRACE_MS))]);
}
/** Resolves when every in-flight job run has finished. */
async drain(): Promise<void> {
while (this.inflight.size > 0) await Promise.all([...this.inflight]);
}
list(): JobView[] {
const now = this.now();
return this.jobs.map((job) => ({
...job,
nextRunAt: (this.nextTimes.get(job.id) ?? nextFireTime(job, now, this.deps.timezone))?.toISOString() ?? null,
}));
}
get(id: string): JobView | undefined {
return this.list().find((j) => j.id === id);
}
async create(input: NewJobInput): Promise<JobView> {
const problem = validateJobInput(input, this.deps.timezone);
if (problem) throw new Error(problem);
let id = generateJobId();
while (this.jobs.some((j) => j.id === id)) id = generateJobId();
const job: Job = {
id,
name: input.name.trim(),
schedule: input.schedule,
source: input.source,
deliver: input.deliver,
createdAt: this.now().toISOString(),
};
this.jobs.push(job);
await this.persist();
await this.tick();
const view = this.get(id);
if (!view) throw new Error(`job ${id} vanished after creation`);
return view;
}
async delete(id: string): Promise<boolean> {
const before = this.jobs.length;
this.jobs = this.jobs.filter((j) => j.id !== id);
if (this.jobs.length === before) return false;
this.nextTimes.delete(id);
await this.persist();
await this.tick();
return true;
}
/** Fires every due job (without waiting for them) and arms the timer for the next one. */
async tick(): Promise<void> {
if (this.stopped) return;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
const now = this.now();
for (const job of this.jobs) {
if (!this.nextTimes.has(job.id)) {
const t = nextFireTime(job, now, this.deps.timezone);
if (t) this.nextTimes.set(job.id, t);
}
}
for (const id of [...this.nextTimes.keys()]) {
if (!this.jobs.some((j) => j.id === id)) this.nextTimes.delete(id);
}
for (const job of this.jobs) {
const due = this.nextTimes.get(job.id);
if (!due || due.getTime() > now.getTime()) continue;
if ("cron" in job.schedule) {
const next = nextFireTime(job, now, this.deps.timezone);
if (next) this.nextTimes.set(job.id, next);
else this.nextTimes.delete(job.id);
} else {
this.nextTimes.delete(job.id);
}
this.track(this.fire(job));
}
this.arm();
}
/** Runs one job now, waiting for it. Used by tests and by tick(). */
async fireNow(id: string): Promise<void> {
const job = this.jobs.find((j) => j.id === id);
if (!job) throw new Error(`no job with id ${id}`);
await this.fire(job);
}
private track(p: Promise<void>): void {
const tracked: Promise<void> = p.finally(() => this.inflight.delete(tracked));
this.inflight.add(tracked);
}
private arm(): void {
if (this.stopped) return;
let soonest: Date | undefined;
for (const t of this.nextTimes.values()) if (!soonest || t < soonest) soonest = t;
if (!soonest) return;
const delay = Math.min(Math.max(soonest.getTime() - this.now().getTime(), 0), MAX_TIMER_MS);
this.timer = setTimeout(() => void this.tick(), delay);
}
private async fire(job: Job): Promise<void> {
if (this.running.has(job.id)) {
this.deps.logger.warn(`job ${job.id} (${job.name}) is still running, skipping this occurrence`);
return;
}
this.running.add(job.id);
try {
const startedAt = this.now().toISOString();
const outcome = await this.evaluate(job);
const previous = job.lastResult;
job.lastRunAt = startedAt;
if (outcome.kind === "failed") {
job.lastResult = { ok: false, exitCode: outcome.exitCode, stderrTail: outcome.stderrTail, at: startedAt };
const repeat = previous !== undefined && !previous.ok && previous.exitCode === outcome.exitCode;
if (!repeat) await this.safeDeliver(() => this.deps.deliverDirect(formatFailure(job, outcome)));
} else {
job.lastResult = { ok: true, at: startedAt };
if (outcome.kind === "output") {
if (job.deliver === "direct") {
await this.safeDeliver(() => this.deps.deliverDirect(`${job.name}: ${outcome.text}`));
} else {
await this.safeDeliver(() =>
this.deps.deliverAgent(`Scheduled job "${job.name}" fired with this output:\n\n${outcome.text}`),
);
}
}
}
if ("at" in job.schedule) this.jobs = this.jobs.filter((j) => j.id !== job.id);
await this.persist();
} catch (err) {
this.deps.logger.error(`job ${job.id} (${job.name}) crashed`, err);
} finally {
this.running.delete(job.id);
}
}
private async evaluate(job: Job): Promise<ScriptOutcome> {
if (job.source.kind === "text") return { kind: "output", text: job.source.text };
return this.runScript(job.source.command, {
cwd: this.deps.workspaceDir,
timeoutSec: job.source.timeoutSec ?? DEFAULT_SCRIPT_TIMEOUT_SEC,
shellPath: this.deps.shellPath,
});
}
private async safeDeliver(deliver: () => Promise<void>): Promise<void> {
try {
await deliver();
} catch (err) {
this.deps.logger.error("job delivery failed", err);
}
}
private async persist(): Promise<void> {
await this.deps.store.save(this.jobs);
}
}
export function validateJobInput(input: NewJobInput, timezone: string): string | null {
if (!input.name || !input.name.trim()) return "name is required";
if (!input.schedule || !("at" in input.schedule || "cron" in input.schedule)) return "schedule must have at or cron";
const scheduleProblem = validateSchedule(input.schedule, timezone);
if (scheduleProblem) return scheduleProblem;
if (!input.source) return "source is required";
if (input.source.kind === "text" && !input.source.text.trim()) return "text is required";
if (input.source.kind === "script") {
if (!input.source.command.trim()) return "command is required";
if (input.source.timeoutSec !== undefined && !(input.source.timeoutSec > 0)) return "timeoutSec must be a positive number";
}
if (input.deliver !== "agent" && input.deliver !== "direct") return "deliver must be agent or direct";
return null;
}
export function formatFailure(job: Job, outcome: FailedOutcome): string {
const code = outcome.exitCode ?? "?";
const suffix = outcome.timedOut ? ", timed out" : "";
return `β οΈ Job "${job.name}" failed (exit ${code}${suffix}):\n${outcome.stderrTail}`;
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/scheduler/scheduler.test.ts
Expected: PASS (10 tests). The "silent" cron test relies on vi.useFakeTimers faking Date, which vitest 5 does by default.
- Step 5: Commit
git add src/scheduler/scheduler.ts src/scheduler/scheduler.test.ts
git commit -m "Add scheduler with firing rules and failure dedupe
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 7: The schedule tool
Files:
- Create:
src/tools/schedule.ts - Test:
src/tools/schedule.test.ts
Interfaces:
- Consumes:
Scheduler,JobView(Task 6). - Produces:
scheduleParams(typebox schema),ScheduleParams,createScheduleTool(scheduler: Scheduler, timezone: string)returning a PiToolDefinitionnamedschedule,handleSchedule(params: ScheduleParams, scheduler: Scheduler, timezone: string): Promise<string>,describeJob(job: JobView, timezone: string): string,formatTime(iso: string, timezone: string): string.
Errors are thrown; Pi turns a thrown error into an isError result the model can read.
- Step 1: Write the failing test
src/tools/schedule.test.ts:
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createLogger } from "../log.js";
import { Scheduler } from "../scheduler/scheduler.js";
import { JobStore } from "../scheduler/store.js";
import { createScheduleTool, describeJob, formatTime, handleSchedule } from "./schedule.js";
describe("schedule tool", () => {
let dir: string;
let scheduler: Scheduler;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "secretary-tool-"));
scheduler = new Scheduler({
store: new JobStore(path.join(dir, "jobs.json")),
workspaceDir: dir,
timezone: "UTC",
logger: createLogger("error", () => {}),
deliverDirect: async () => {},
deliverAgent: async () => {},
});
await scheduler.start();
});
afterEach(async () => {
await scheduler.stop();
await rm(dir, { recursive: true, force: true });
});
it("creates, lists, and deletes a job", async () => {
const created = await handleSchedule(
{ action: "create", name: "Dentist", at: "2030-05-01T09:00:00Z", text: "call the dentist", deliver: "direct" },
scheduler,
"UTC",
);
expect(created).toMatch(/^Created job:\n[a-z2-9]{5} Β· Dentist Β· once at 1 May 2030, 09:00 Β· text: call the dentist Β· deliver direct Β· next 1 May 2030, 09:00$/);
const id = created.split("\n")[1].slice(0, 5);
expect(await handleSchedule({ action: "list" }, scheduler, "UTC")).toContain("Dentist");
expect(await handleSchedule({ action: "delete", id }, scheduler, "UTC")).toBe(`Deleted job ${id}.`);
expect(await handleSchedule({ action: "list" }, scheduler, "UTC")).toBe("No scheduled jobs.");
});
it("defaults deliver to agent and supports script jobs on cron", async () => {
const out = await handleSchedule(
{ action: "create", name: "watch", cron: "*/10 * * * *", command: "python scripts/watch.py", timeoutSec: 30 },
scheduler,
"UTC",
);
expect(out).toMatch(/cron "\*\/10 \* \* \* \*" Β· script: python scripts\/watch.py Β· deliver agent/);
});
it("rejects ambiguous or incomplete create calls", async () => {
await expect(handleSchedule({ action: "create", name: "x", text: "t" }, scheduler, "UTC")).rejects.toThrow(/exactly one of at or cron/);
await expect(handleSchedule({ action: "create", name: "x", at: "2030-01-01T00:00:00Z" }, scheduler, "UTC")).rejects.toThrow(/exactly one of text or command/);
await expect(handleSchedule({ action: "create", name: "x", at: "2030-01-01T00:00:00Z", cron: "* * * * *", text: "t" }, scheduler, "UTC")).rejects.toThrow(/exactly one of at or cron/);
await expect(handleSchedule({ action: "create", at: "2030-01-01T00:00:00Z", text: "t" }, scheduler, "UTC")).rejects.toThrow(/name/);
await expect(handleSchedule({ action: "delete" }, scheduler, "UTC")).rejects.toThrow(/id/);
await expect(handleSchedule({ action: "delete", id: "zzzzz" }, scheduler, "UTC")).rejects.toThrow(/no job/);
});
it("exposes a Pi tool definition that returns text content", async () => {
const tool = createScheduleTool(scheduler, "UTC");
expect(tool.name).toBe("schedule");
const result = await tool.execute("call-1", { action: "list" }, undefined, undefined, undefined as never);
expect(result.content).toEqual([{ type: "text", text: "No scheduled jobs." }]);
});
it("formats times in the owner's timezone", () => {
expect(formatTime("2030-05-01T09:00:00Z", "Europe/Berlin")).toBe("1 May 2030, 11:00");
expect(describeJob({ id: "ab123", name: "n", schedule: { cron: "0 9 * * 1-5" }, source: { kind: "text", text: "hello" }, deliver: "agent", createdAt: "", nextRunAt: null }, "UTC")).toBe(
'ab123 Β· n Β· cron "0 9 * * 1-5" Β· text: hello Β· deliver agent Β· next never',
);
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/tools/schedule.test.ts
Expected: FAIL, cannot find module ./schedule.js.
- Step 3: Implement src/tools/schedule.ts
import { defineTool } from "@earendil-works/pi-coding-agent";
import { type Static, Type } from "typebox";
import type { JobView, Scheduler } from "../scheduler/scheduler.js";
export const scheduleParams = Type.Object({
action: Type.Union([Type.Literal("create"), Type.Literal("list"), Type.Literal("delete")], {
description: "create a job, list all jobs, or delete one by id",
}),
id: Type.Optional(Type.String({ description: "Job id. Required for delete." })),
name: Type.Optional(Type.String({ description: "Short human name for the job. Required for create." })),
at: Type.Optional(
Type.String({ description: "ISO 8601 time with offset to fire once, e.g. 2026-09-07T09:00:00+02:00. Use either at or cron." }),
),
cron: Type.Optional(
Type.String({ description: "5 field cron expression evaluated in the owner's timezone, e.g. '0 9 * * 1-5'. Use either at or cron." }),
),
text: Type.Optional(Type.String({ description: "Fixed message text. Use either text or command." })),
command: Type.Optional(
Type.String({
description:
"Shell command run from the workspace with bash -c. Its stdout is the message. Print nothing and exit 0 to stay silent. Use either text or command.",
}),
),
deliver: Type.Optional(
Type.Union([Type.Literal("agent"), Type.Literal("direct")], {
description: "agent: the message is handed to you to act on and reply. direct: sent to the owner verbatim without a model call. Default agent.",
}),
),
timeoutSec: Type.Optional(Type.Number({ description: "Script timeout in seconds. Default 300." })),
});
export type ScheduleParams = Static<typeof scheduleParams>;
export function createScheduleTool(scheduler: Scheduler, timezone: string) {
return defineTool({
name: "schedule",
label: "Schedule",
description:
"Create, list, or delete scheduled jobs: one-off reminders, recurring prompts, and watcher scripts that only message the owner when they print output.",
parameters: scheduleParams,
execute: async (_toolCallId, params) => {
const text = await handleSchedule(params, scheduler, timezone);
return { content: [{ type: "text", text }], details: {} };
},
});
}
export async function handleSchedule(params: ScheduleParams, scheduler: Scheduler, timezone: string): Promise<string> {
switch (params.action) {
case "list": {
const jobs = scheduler.list();
return jobs.length ? jobs.map((j) => describeJob(j, timezone)).join("\n") : "No scheduled jobs.";
}
case "delete": {
if (!params.id) throw new Error("delete requires id");
if (!(await scheduler.delete(params.id))) throw new Error(`no job with id ${params.id}`);
return `Deleted job ${params.id}.`;
}
case "create": {
if (!params.name) throw new Error("create requires name");
if (Boolean(params.at) === Boolean(params.cron)) throw new Error("create requires exactly one of at or cron");
if (Boolean(params.text) === Boolean(params.command)) throw new Error("create requires exactly one of text or command");
const job = await scheduler.create({
name: params.name,
schedule: params.at ? { at: params.at } : { cron: params.cron as string },
source: params.text
? { kind: "text", text: params.text }
: { kind: "script", command: params.command as string, timeoutSec: params.timeoutSec },
deliver: params.deliver ?? "agent",
});
return `Created job:\n${describeJob(job, timezone)}`;
}
}
}
export function describeJob(job: JobView, timezone: string): string {
const when = "at" in job.schedule ? `once at ${formatTime(job.schedule.at, timezone)}` : `cron "${job.schedule.cron}"`;
const what = job.source.kind === "text" ? `text: ${truncate(job.source.text, 60)}` : `script: ${truncate(job.source.command, 60)}`;
const next = job.nextRunAt ? formatTime(job.nextRunAt, timezone) : "never";
return `${job.id} Β· ${job.name} Β· ${when} Β· ${what} Β· deliver ${job.deliver} Β· next ${next}`;
}
export function formatTime(iso: string, timezone: string): string {
return new Intl.DateTimeFormat("en-GB", { timeZone: timezone, dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
}
function truncate(text: string, max: number): string {
const single = text.replace(/\s+/g, " ").trim();
return single.length > max ? `${single.slice(0, max - 1)}β¦` : single;
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/tools/schedule.test.ts && npm run typecheck
Expected: PASS (5 tests), typecheck clean. If en-GB medium date renders as 1 May 2030 on your Node ICU (full-icu is standard in Node 24), the regex matches. If tool.execute complains about argument count, Pi's ToolDefinition.execute takes (toolCallId, params, signal, onUpdate, ctx); pass the five arguments as shown.
- Step 5: Commit
git add src/tools/schedule.ts src/tools/schedule.test.ts
git commit -m "Add schedule tool for the agent
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 8: OpenAI transcription client
Files:
- Create:
src/stt/openai.ts - Test:
src/stt/openai.test.ts
Interfaces:
-
Produces:
interface Transcriber { transcribe(audio: Buffer, filename: string, mimeType: string): Promise<string> } interface OpenAiTranscriberOptions { apiKey: string; model: string; fetchFn?: typeof fetch; baseUrl?: string } function createOpenAiTranscriber(opts: OpenAiTranscriberOptions): Transcriber; -
Step 1: Write the failing test
src/stt/openai.test.ts:
import { describe, expect, it } from "vitest";
import { createOpenAiTranscriber } from "./openai.js";
describe("createOpenAiTranscriber", () => {
it("posts multipart audio with the bearer token and returns the text", async () => {
let captured: { url: string; init: RequestInit } | undefined;
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured = { url: String(url), init: init ?? {} };
return new Response(JSON.stringify({ text: " hello there " }), { status: 200 });
}) as typeof fetch;
const t = createOpenAiTranscriber({ apiKey: "sk-test", model: "gpt-4o-transcribe", fetchFn });
const text = await t.transcribe(Buffer.from("OggS"), "voice.oga", "audio/ogg");
expect(text).toBe("hello there");
expect(captured?.url).toBe("https://api.openai.com/v1/audio/transcriptions");
expect(captured?.init.method).toBe("POST");
expect((captured?.init.headers as Record<string, string>).Authorization).toBe("Bearer sk-test");
const body = captured?.init.body as FormData;
expect(body).toBeInstanceOf(FormData);
expect(body.get("model")).toBe("gpt-4o-transcribe");
const file = body.get("file") as File;
expect(file.name).toBe("voice.oga");
expect(file.type).toBe("audio/ogg");
});
it("throws with the HTTP status on failure", async () => {
const fetchFn = (async () => new Response("nope", { status: 401 })) as typeof fetch;
const t = createOpenAiTranscriber({ apiKey: "k", model: "m", fetchFn });
await expect(t.transcribe(Buffer.from("x"), "a.ogg", "audio/ogg")).rejects.toThrow(/HTTP 401/);
});
it("throws when the response has no text", async () => {
const fetchFn = (async () => new Response("{}", { status: 200 })) as typeof fetch;
const t = createOpenAiTranscriber({ apiKey: "k", model: "m", fetchFn });
await expect(t.transcribe(Buffer.from("x"), "a.ogg", "audio/ogg")).rejects.toThrow(/no text/);
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/stt/openai.test.ts
Expected: FAIL, cannot find module ./openai.js.
- Step 3: Implement src/stt/openai.ts
export interface Transcriber {
transcribe(audio: Buffer, filename: string, mimeType: string): Promise<string>;
}
export interface OpenAiTranscriberOptions {
apiKey: string;
model: string;
fetchFn?: typeof fetch;
baseUrl?: string;
}
export function createOpenAiTranscriber(opts: OpenAiTranscriberOptions): Transcriber {
const fetchFn = opts.fetchFn ?? fetch;
const baseUrl = (opts.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
return {
async transcribe(audio, filename, mimeType) {
const form = new FormData();
form.append("file", new Blob([audio], { type: mimeType }), filename);
form.append("model", opts.model);
form.append("response_format", "json");
const res = await fetchFn(`${baseUrl}/audio/transcriptions`, {
method: "POST",
headers: { Authorization: `Bearer ${opts.apiKey}` },
body: form,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`OpenAI transcription failed: HTTP ${res.status} ${body.slice(0, 200)}`.trim());
}
const json = (await res.json()) as { text?: unknown };
if (typeof json.text !== "string") throw new Error("OpenAI transcription returned no text");
return json.text.trim();
},
};
}
If TypeScript rejects new Blob([audio]) because of Uint8Array<ArrayBufferLike> generics, use new Blob([new Uint8Array(audio)], { type: mimeType }).
- Step 4: Run test to verify it passes
Run: npx vitest run src/stt/openai.test.ts && npm run typecheck
Expected: PASS (3 tests), typecheck clean.
- Step 5: Commit
git add src/stt/openai.ts src/stt/openai.test.ts
git commit -m "Add OpenAI transcription client
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 9: Agent event types and tool call summaries
Files:
- Create:
src/agent/events.ts - Test:
src/agent/events.test.ts
Interfaces:
-
Produces:
interface AgentImage { data: string; mimeType: string } // base64 payload interface AgentInput { text: string; images?: AgentImage[] } interface AgentStatus { busy: boolean; model: string; thinkingLevel: string; contextTokens?: number; contextWindow?: number; sessionFile?: string } type AgentHostEvent = | { type: "run_started" } | { type: "tool_started"; toolName: string; summary: string } | { type: "assistant_text"; text: string } | { type: "run_settled"; finalText: string; aborted: boolean } | { type: "run_error"; message: string }; function summarizeToolCall(toolName: string, args: unknown): string; // at most 80 chars -
Step 1: Write the failing test
src/agent/events.test.ts:
import { describe, expect, it } from "vitest";
import { summarizeToolCall } from "./events.js";
describe("summarizeToolCall", () => {
it("uses the first command line for bash", () => {
expect(summarizeToolCall("bash", { command: "npm test\necho done" })).toBe("bash: npm test");
});
it("uses the path for file tools", () => {
expect(summarizeToolCall("read", { path: "src/main.ts" })).toBe("read: src/main.ts");
expect(summarizeToolCall("edit", { path: "a.md", oldText: "x" })).toBe("edit: a.md");
});
it("uses the pattern for search tools", () => {
expect(summarizeToolCall("grep", { pattern: "TODO", path: "." })).toBe("grep: TODO");
});
it("uses action and name for schedule", () => {
expect(summarizeToolCall("schedule", { action: "create", name: "dentist" })).toBe("schedule: create dentist");
expect(summarizeToolCall("schedule", { action: "list" })).toBe("schedule: list");
});
it("falls back to the tool name and truncates at 80 characters", () => {
expect(summarizeToolCall("mystery", { a: 1 })).toBe("mystery");
expect(summarizeToolCall("mystery", undefined)).toBe("mystery");
const long = summarizeToolCall("bash", { command: "x".repeat(200) });
expect(long).toHaveLength(80);
expect(long.endsWith("...")).toBe(true);
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/agent/events.test.ts
Expected: FAIL, cannot find module ./events.js.
- Step 3: Implement src/agent/events.ts
export interface AgentImage {
/** Base64 payload without a data: prefix. */
data: string;
mimeType: string;
}
export interface AgentInput {
text: string;
images?: AgentImage[];
}
export interface AgentStatus {
busy: boolean;
model: string;
thinkingLevel: string;
contextTokens?: number;
contextWindow?: number;
sessionFile?: string;
}
export type AgentHostEvent =
| { type: "run_started" }
| { type: "tool_started"; toolName: string; summary: string }
| { type: "assistant_text"; text: string }
| { type: "run_settled"; finalText: string; aborted: boolean }
| { type: "run_error"; message: string };
const SUMMARY_MAX = 80;
export function summarizeToolCall(toolName: string, args: unknown): string {
const a = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
const str = (key: string): string | undefined => (typeof a[key] === "string" ? (a[key] as string) : undefined);
let detail: string | undefined;
switch (toolName) {
case "bash":
case "powershell":
detail = str("command")?.split("\n")[0];
break;
case "read":
case "write":
case "edit":
case "ls":
detail = str("path");
break;
case "grep":
case "find":
detail = str("pattern");
break;
case "schedule":
detail = [str("action"), str("name")].filter(Boolean).join(" ");
break;
}
const summary = detail ? `${toolName}: ${detail}` : toolName;
return summary.length > SUMMARY_MAX ? `${summary.slice(0, SUMMARY_MAX - 3)}...` : summary;
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/agent/events.test.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add src/agent/events.ts src/agent/events.test.ts
git commit -m "Add agent host event types and tool call summaries
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 10: AgentHost over a session interface
Files:
- Create:
src/agent/host.ts - Test:
src/agent/host.test.ts
Interfaces:
- Consumes:
AgentHostEvent,AgentInput,AgentStatus,summarizeToolCall(Task 9);Logger(Task 1). - Produces:
interface SessionImage { type: "image"; data: string; mimeType: string } interface SessionMessage { role: string; content?: unknown; stopReason?: string; errorMessage?: string } type SessionEvent = | { type: "agent_start" } | { type: "agent_end"; willRetry?: boolean } | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown } | { type: "message_end"; message: SessionMessage } | { type: "agent_settled" } | { type: string }; interface SessionLike { prompt(text: string, options?: { images?: SessionImage[] }): Promise<void>; steer(text: string, images?: SessionImage[]): Promise<void>; followUp(text: string, images?: SessionImage[]): Promise<void>; abort(): Promise<void>; compact(): Promise<{ tokensBefore: number; estimatedTokensAfter?: number }>; subscribe(listener: (event: SessionEvent) => void): () => void; readonly isStreaming: boolean; readonly model: { provider: string; id: string } | undefined; readonly thinkingLevel: string; readonly sessionFile: string | undefined; getContextUsage(): { tokens: number | null; contextWindow: number } | undefined; } interface AgentBackend { start(): Promise<SessionLike>; newSession(): Promise<SessionLike>; dispose(): Promise<void> } class AgentHost { constructor(backend: AgentBackend, logger: Logger); start(): Promise<void>; newSession(): Promise<void>; dispose(): Promise<void>; readonly busy: boolean; status(): AgentStatus; send(input: AgentInput): Promise<void>; // idle: prompt (resolves when the run ends); busy: steer followUp(input: AgentInput): Promise<void>; // idle: prompt; busy: followUp abort(): Promise<boolean>; // true if something was running compact(): Promise<{ tokensBefore: number; estimatedTokensAfter?: number }>; subscribe(listener: (event: AgentHostEvent) => void): () => void; } function extractText(message: SessionMessage): string;
SessionLike is a structural subset of Pi's AgentSession; Task 11 assigns a real AgentSession to it, which is where the compiler checks compatibility. Pi's ImageContent is { type: "image"; data; mimeType }, matching SessionImage.
Terminal event rule: exactly one of run_settled or run_error per run, decided by the last assistant message when the run settles. agent_end with willRetry marks the next agent_start as a continuation instead of a new run. A new agent_start while the previous run has no terminal event (a queued follow-up) settles the previous run first.
- Step 1: Write the failing test
src/agent/host.test.ts:
import { describe, expect, it } from "vitest";
import { createLogger } from "../log.js";
import type { AgentHostEvent } from "./events.js";
import { AgentHost, type SessionEvent, type SessionLike, type SessionMessage } from "./host.js";
class FakeSession implements SessionLike {
listeners = new Set<(e: SessionEvent) => void>();
isStreaming = false;
model: { provider: string; id: string } | undefined = { provider: "anthropic", id: "claude-x" };
thinkingLevel = "medium";
sessionFile: string | undefined = "/tmp/s.jsonl";
calls: string[] = [];
promptImpl: () => Promise<void> = async () => {};
async prompt(text: string) {
this.calls.push(`prompt:${text}`);
await this.promptImpl();
}
async steer(text: string) {
this.calls.push(`steer:${text}`);
}
async followUp(text: string) {
this.calls.push(`followUp:${text}`);
}
async abort() {
this.calls.push("abort");
}
async compact() {
return { tokensBefore: 100, estimatedTokensAfter: 10 };
}
subscribe(l: (e: SessionEvent) => void) {
this.listeners.add(l);
return () => void this.listeners.delete(l);
}
getContextUsage() {
return { tokens: 1234, contextWindow: 200_000 };
}
emit(e: SessionEvent) {
for (const l of this.listeners) l(e);
}
}
const assistant = (text: string, extra: Partial<SessionMessage> = {}): SessionMessage => ({
role: "assistant",
content: [{ type: "text", text }],
stopReason: "stop",
...extra,
});
async function setup() {
const session = new FakeSession();
const host = new AgentHost(
{ start: async () => session, newSession: async () => session, dispose: async () => {} },
createLogger("error", () => {}),
);
await host.start();
const events: AgentHostEvent[] = [];
host.subscribe((e) => events.push(e));
return { session, host, events };
}
describe("AgentHost", () => {
it("maps a full run to host events and uses the last assistant text", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "tool_execution_start", toolCallId: "1", toolName: "bash", args: { command: "ls" } });
session.emit({ type: "message_end", message: assistant("Looking...") });
session.emit({ type: "message_end", message: assistant("Here you go.") });
session.emit({ type: "agent_settled" });
};
await host.send({ text: "list files" });
expect(session.calls).toEqual(["prompt:list files"]);
expect(events).toEqual([
{ type: "run_started" },
{ type: "tool_started", toolName: "bash", summary: "bash: ls" },
{ type: "assistant_text", text: "Looking..." },
{ type: "assistant_text", text: "Here you go." },
{ type: "run_settled", finalText: "Here you go.", aborted: false },
]);
});
it("routes to steer or followUp while busy", async () => {
const { session, host } = await setup();
session.isStreaming = true;
await host.send({ text: "a" });
await host.followUp({ text: "b" });
expect(session.calls).toEqual(["steer:a", "followUp:b"]);
expect(host.busy).toBe(true);
});
it("emits run_error for an error stop reason and never assistant_text for it", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("", { stopReason: "error", errorMessage: "rate limited" }) });
session.emit({ type: "agent_settled" });
};
await host.send({ text: "x" });
expect(events).toEqual([{ type: "run_started" }, { type: "run_error", message: "rate limited" }]);
});
it("emits an aborted run_settled with empty text", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("partial", { stopReason: "aborted" }) });
session.emit({ type: "agent_settled" });
};
await host.send({ text: "x" });
expect(events.at(-1)).toEqual({ type: "run_settled", finalText: "", aborted: true });
});
it("falls back to joined texts, then to the placeholder", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("one") });
session.emit({ type: "message_end", message: assistant("two") });
session.emit({ type: "message_end", message: assistant("") });
session.emit({ type: "agent_settled" });
};
await host.send({ text: "x" });
expect(events.at(-1)).toEqual({ type: "run_settled", finalText: "one\n\ntwo", aborted: false });
events.length = 0;
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "agent_settled" });
};
await host.send({ text: "y" });
expect(events.at(-1)).toEqual({ type: "run_settled", finalText: "Done, no text reply.", aborted: false });
});
it("treats an auto retry as the same run and counts only the last message", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("", { stopReason: "error", errorMessage: "overloaded" }) });
session.emit({ type: "agent_end", willRetry: true });
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("recovered") });
session.emit({ type: "agent_settled" });
};
await host.send({ text: "x" });
expect(events).toEqual([
{ type: "run_started" },
{ type: "assistant_text", text: "recovered" },
{ type: "run_settled", finalText: "recovered", aborted: false },
]);
});
it("settles the previous run when a queued follow-up starts a new one", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("first answer") });
session.emit({ type: "agent_end", willRetry: false });
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("second answer") });
session.emit({ type: "agent_settled" });
};
await host.send({ text: "x" });
expect(events.map((e) => e.type)).toEqual([
"run_started",
"assistant_text",
"run_settled",
"run_started",
"assistant_text",
"run_settled",
]);
expect((events[2] as { finalText: string }).finalText).toBe("first answer");
expect((events[5] as { finalText: string }).finalText).toBe("second answer");
});
it("synthesizes a terminal event when prompt resolves without agent_settled, exactly once", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
session.emit({ type: "message_end", message: assistant("done") });
};
await host.send({ text: "x" });
session.emit({ type: "agent_settled" });
expect(events.filter((e) => e.type === "run_settled")).toHaveLength(1);
});
it("emits run_error when prompt rejects", async () => {
const { session, host, events } = await setup();
session.promptImpl = async () => {
session.emit({ type: "agent_start" });
throw new Error("network down");
};
await expect(host.send({ text: "x" })).rejects.toThrow("network down");
expect(events.at(-1)).toEqual({ type: "run_error", message: "network down" });
});
it("reports status and abort results", async () => {
const { session, host } = await setup();
expect(host.status()).toEqual({
busy: false,
model: "anthropic/claude-x",
thinkingLevel: "medium",
contextTokens: 1234,
contextWindow: 200_000,
sessionFile: "/tmp/s.jsonl",
});
expect(await host.abort()).toBe(false);
session.isStreaming = true;
expect(await host.abort()).toBe(true);
expect(session.calls).toEqual(["abort"]);
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/agent/host.test.ts
Expected: FAIL, cannot find module ./host.js.
- Step 3: Implement src/agent/host.ts
import type { Logger } from "../log.js";
import { type AgentHostEvent, type AgentImage, type AgentInput, type AgentStatus, summarizeToolCall } from "./events.js";
export interface SessionImage {
type: "image";
data: string;
mimeType: string;
}
export interface SessionMessage {
role: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
}
export type SessionEvent =
| { type: "agent_start" }
| { type: "agent_end"; willRetry?: boolean }
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
| { type: "message_end"; message: SessionMessage }
| { type: "agent_settled" }
| { type: string };
/** The slice of Pi's AgentSession that AgentHost depends on. */
export interface SessionLike {
prompt(text: string, options?: { images?: SessionImage[] }): Promise<void>;
steer(text: string, images?: SessionImage[]): Promise<void>;
followUp(text: string, images?: SessionImage[]): Promise<void>;
abort(): Promise<void>;
compact(): Promise<{ tokensBefore: number; estimatedTokensAfter?: number }>;
subscribe(listener: (event: SessionEvent) => void): () => void;
readonly isStreaming: boolean;
readonly model: { provider: string; id: string } | undefined;
readonly thinkingLevel: string;
readonly sessionFile: string | undefined;
getContextUsage(): { tokens: number | null; contextWindow: number } | undefined;
}
export interface AgentBackend {
start(): Promise<SessionLike>;
newSession(): Promise<SessionLike>;
dispose(): Promise<void>;
}
interface RunRecord {
texts: string[];
last?: SessionMessage;
terminalEmitted: boolean;
retrying: boolean;
}
type ToolStartEvent = Extract<SessionEvent, { type: "tool_execution_start" }>;
type MessageEndEvent = Extract<SessionEvent, { type: "message_end" }>;
type AgentEndEvent = Extract<SessionEvent, { type: "agent_end" }>;
export class AgentHost {
private session: SessionLike | undefined;
private unsubscribe: (() => void) | undefined;
private listeners = new Set<(event: AgentHostEvent) => void>();
private run: RunRecord = { texts: [], terminalEmitted: true, retrying: false };
private sending = false;
constructor(
private readonly backend: AgentBackend,
private readonly logger: Logger,
) {}
async start(): Promise<void> {
this.attach(await this.backend.start());
}
async newSession(): Promise<void> {
this.attach(await this.backend.newSession());
}
async dispose(): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = undefined;
await this.backend.dispose();
}
subscribe(listener: (event: AgentHostEvent) => void): () => void {
this.listeners.add(listener);
return () => void this.listeners.delete(listener);
}
get busy(): boolean {
return this.sending || (this.session?.isStreaming ?? false);
}
status(): AgentStatus {
const s = this.requireSession();
const usage = s.getContextUsage();
return {
busy: this.busy,
model: s.model ? `${s.model.provider}/${s.model.id}` : "unknown",
thinkingLevel: s.thinkingLevel,
contextTokens: usage?.tokens ?? undefined,
contextWindow: usage?.contextWindow,
sessionFile: s.sessionFile,
};
}
send(input: AgentInput): Promise<void> {
return this.dispatch(input, "steer");
}
followUp(input: AgentInput): Promise<void> {
return this.dispatch(input, "followUp");
}
async abort(): Promise<boolean> {
const s = this.requireSession();
if (!this.busy) return false;
await s.abort();
return true;
}
compact(): Promise<{ tokensBefore: number; estimatedTokensAfter?: number }> {
return this.requireSession().compact();
}
private async dispatch(input: AgentInput, whenBusy: "steer" | "followUp"): Promise<void> {
const s = this.requireSession();
const images = toSessionImages(input.images);
if (this.busy) {
if (whenBusy === "steer") await s.steer(input.text, images);
else await s.followUp(input.text, images);
return;
}
this.sending = true;
try {
await s.prompt(input.text, { images });
} catch (err) {
this.finishRun({ type: "run_error", message: err instanceof Error ? err.message : String(err) });
throw err;
} finally {
this.sending = false;
if (!s.isStreaming) this.settle();
}
}
private attach(session: SessionLike): void {
this.unsubscribe?.();
this.session = session;
this.run = { texts: [], terminalEmitted: true, retrying: false };
this.unsubscribe = session.subscribe((event) => this.onSessionEvent(event));
}
private onSessionEvent(event: SessionEvent): void {
switch (event.type) {
case "agent_start":
if (this.run.retrying) {
this.run.retrying = false;
return;
}
this.settle();
this.run = { texts: [], terminalEmitted: false, retrying: false };
this.emit({ type: "run_started" });
return;
case "agent_end":
if ((event as AgentEndEvent).willRetry) this.run.retrying = true;
return;
case "tool_execution_start": {
const e = event as ToolStartEvent;
this.emit({ type: "tool_started", toolName: e.toolName, summary: summarizeToolCall(e.toolName, e.args) });
return;
}
case "message_end": {
const message = (event as MessageEndEvent).message;
if (message.role !== "assistant") return;
this.run.last = message;
if (message.stopReason === "error" || message.stopReason === "aborted") return;
const text = extractText(message);
if (text) {
this.run.texts.push(text);
this.emit({ type: "assistant_text", text });
}
return;
}
case "agent_settled":
this.settle();
return;
}
}
private settle(): void {
if (this.run.terminalEmitted) return;
const last = this.run.last;
if (last?.stopReason === "error") {
this.finishRun({ type: "run_error", message: last.errorMessage ?? "Unknown error" });
return;
}
if (last?.stopReason === "aborted") {
this.finishRun({ type: "run_settled", finalText: "", aborted: true });
return;
}
const lastText = last ? extractText(last) : "";
const finalText = lastText || this.run.texts.join("\n\n") || "Done, no text reply.";
this.finishRun({ type: "run_settled", finalText, aborted: false });
}
private finishRun(event: AgentHostEvent): void {
if (this.run.terminalEmitted) return;
this.run.terminalEmitted = true;
this.emit(event);
}
private emit(event: AgentHostEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch (err) {
this.logger.error("agent host listener failed", err);
}
}
}
private requireSession(): SessionLike {
if (!this.session) throw new Error("AgentHost not started");
return this.session;
}
}
export function extractText(message: SessionMessage): string {
if (typeof message.content === "string") return message.content.trim();
if (!Array.isArray(message.content)) return "";
return message.content
.filter((block): block is { type: "text"; text: string } => {
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text" && typeof (block as { text?: unknown }).text === "string";
})
.map((block) => block.text)
.join("")
.trim();
}
function toSessionImages(images: AgentImage[] | undefined): SessionImage[] | undefined {
if (!images || images.length === 0) return undefined;
return images.map((img) => ({ type: "image", data: img.data, mimeType: img.mimeType }));
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/agent/host.test.ts && npm run typecheck
Expected: PASS (10 tests), typecheck clean.
- Step 5: Commit
git add src/agent/host.ts src/agent/host.test.ts
git commit -m "Add AgentHost with run event mapping
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 11: Pi backend, system prompt, workspace templates
Files:
- Create:
src/agent/system-prompt.ts,src/workspace.ts,src/agent/pi-backend.ts - Test:
src/agent/system-prompt.test.ts,src/workspace.test.ts,src/agent/pi-backend.smoke.test.ts(opt-in)
Interfaces:
- Consumes:
AgentBackend,SessionLike(Task 10);Logger(Task 1). - Produces:
where
function secretarySystemPrompt(timezone: string): string; function memorySection(workspaceDir: string): string; // "" when MEMORY.md is missing or blank const AGENTS_TEMPLATE: string; const MEMORY_TEMPLATE: string; function ensureWorkspace(dir: string): Promise<void>; // creates scripts/, inbox/, .secretary/, template files if missing const ENABLED_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "schedule"]; interface PiBackendOptions { workspaceDir: string; piModel?: string; timezone: string; customTools: CustomTools; logger: Logger; onShutdownRequest?: () => void } function createPiBackend(opts: PiBackendOptions): AgentBackend;CustomTools = NonNullable<CreateAgentSessionFromServicesOptions["customTools"]>from Pi.
How Pi is wired: createAgentSessionRuntime with a factory that calls createAgentSessionServices (default resource loader, so ~/.pi/agent auth, settings, packages, skills and the workspace AGENTS.md are all discovered) and createAgentSessionFromServices (model, tools, custom tools). The persona is appended through resourceLoaderOptions.appendSystemPrompt. MEMORY.md is injected per turn by an inline extension's before_agent_start handler, so edits the agent makes are visible on the very next turn. Extensions are bound in print mode with no UI, like Pi's own print mode.
- Step 1: Write the failing system prompt and workspace tests
src/agent/system-prompt.test.ts:
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 });
}
});
});
src/workspace.test.ts:
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 });
}
});
});
- Step 2: Run them to verify they fail
Run: npx vitest run src/agent/system-prompt.test.ts src/workspace.test.ts
Expected: FAIL, cannot find modules.
- Step 3: Implement src/agent/system-prompt.ts
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}`;
}
- Step 4: Implement src/workspace.ts
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;
}
}
- Step 5: Run the two tests to verify they pass
Run: npx vitest run src/agent/system-prompt.test.ts src/workspace.test.ts
Expected: PASS (3 tests).
- Step 6: Implement src/agent/pi-backend.ts
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;
},
};
}
asSessionLike is the one place the compiler checks that Pi's AgentSession satisfies SessionLike. If it reports a mismatch, adjust SessionLike in src/agent/host.ts to the looser Pi shape rather than casting. The known-good shape as of Pi 0.85.1: prompt(text, { images?: ImageContent[] }), steer(text, images?), followUp(text, images?), abort(), compact(customInstructions?), subscribe(listener), getters isStreaming, model, thinkingLevel, sessionFile, and getContextUsage().
- Step 7: Typecheck
Run: npm run typecheck
Expected: clean. If InlineExtension or ExtensionAPI are not exported by the package index, import them from @earendil-works/pi-coding-agent anyway and check node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts for the exact export names; both are documented in docs/sdk.md and docs/extensions.md of the package.
- Step 8: Write the opt-in smoke test
src/agent/pi-backend.smoke.test.ts:
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);
});
- Step 9: Run the smoke test once for real
Run (PowerShell): $env:SECRETARY_SMOKE="1"; npx vitest run src/agent/pi-backend.smoke.test.ts; Remove-Item Env:SECRETARY_SMOKE
Expected: PASS with a real reply containing "pong". Without the env var the test is skipped, which is what npm test does. If it fails with "No model available", run pi once interactively to log in, or set PI_MODEL. Note this creates a session file under Pi's sessions directory for the temp workspace; that is fine.
- Step 10: Commit
git add src/agent/system-prompt.ts src/agent/system-prompt.test.ts src/workspace.ts src/workspace.test.ts src/agent/pi-backend.ts src/agent/pi-backend.smoke.test.ts
git commit -m "Add Pi backend, secretary system prompt, and workspace templates
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 12: Telegram inbound normalization
Files:
- Create:
src/telegram/inbound.ts - Test:
src/telegram/inbound.test.ts
Interfaces:
-
Consumes:
AgentInput,AgentImage(Task 9);Transcriber(Task 8). -
Produces:
interface InboundMessage { text?: string; caption?: string; photo?: Array<{ file_id: string; width: number; height: number; file_size?: number }>; voice?: { file_id: string; mime_type?: string; duration?: number }; audio?: { file_id: string; mime_type?: string; file_name?: string }; document?: { file_id: string; file_name?: string; mime_type?: string }; } interface InboundDeps { download(fileId: string): Promise<{ bytes: Buffer; filePath: string }>; transcriber?: Transcriber; workspaceDir: string; now?: () => Date } interface InboundResult { input: AgentInput | null; transcript?: string; notes: string[] } function normalizeMessage(msg: InboundMessage, deps: InboundDeps): Promise<InboundResult>; function saveToInbox(workspaceDir: string, originalName: string, bytes: Buffer, now: Date): Promise<string>; // returns "inbox/<name>"InboundMessageis a structural subset of grammY'sMessage, soctx.messagecan be passed directly. -
Step 1: Write the failing test
src/telegram/inbound.test.ts:
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Transcriber } from "../stt/openai.js";
import { type InboundDeps, normalizeMessage, saveToInbox } from "./inbound.js";
describe("normalizeMessage", () => {
let dir: string;
const files: Record<string, { bytes: Buffer; filePath: string }> = {
p1: { bytes: Buffer.from("small"), filePath: "photos/small.jpg" },
p2: { bytes: Buffer.from("large-jpeg-bytes"), filePath: "photos/large.jpg" },
v1: { bytes: Buffer.from("OggS"), filePath: "voice/file_1.oga" },
d1: { bytes: Buffer.from("csv,data"), filePath: "documents/file_2.csv" },
};
const deps = (extra: Partial<InboundDeps> = {}): InboundDeps => ({
download: async (id) => {
const f = files[id];
if (!f) throw new Error(`unknown file ${id}`);
return f;
},
workspaceDir: dir,
now: () => new Date("2026-09-06T10:00:00Z"),
...extra,
});
const transcriber: Transcriber = { transcribe: async () => "buy milk" };
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "secretary-inbound-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("passes plain text through", async () => {
expect(await normalizeMessage({ text: " hi " }, deps())).toEqual({ input: { text: "hi", images: undefined }, transcript: undefined, notes: [] });
});
it("attaches the largest photo as base64 and keeps the caption", async () => {
const r = await normalizeMessage(
{ caption: "what is this", photo: [{ file_id: "p1", width: 10, height: 10 }, { file_id: "p2", width: 100, height: 100 }] },
deps(),
);
expect(r.input?.text).toBe("what is this");
expect(r.input?.images).toEqual([{ data: Buffer.from("large-jpeg-bytes").toString("base64"), mimeType: "image/jpeg" }]);
});
it("uses a placeholder text for a photo without caption", async () => {
const r = await normalizeMessage({ photo: [{ file_id: "p2", width: 1, height: 1 }] }, deps());
expect(r.input?.text).toBe("(photo attached)");
});
it("explains missing transcription and sends nothing", async () => {
const r = await normalizeMessage({ voice: { file_id: "v1" } }, deps());
expect(r.input).toBeNull();
expect(r.notes).toEqual(["Voice transcription is not configured. Set OPENAI_API_KEY to enable it."]);
});
it("transcribes voice and reports the transcript", async () => {
const r = await normalizeMessage({ voice: { file_id: "v1", mime_type: "audio/ogg" } }, deps({ transcriber }));
expect(r.transcript).toBe("buy milk");
expect(r.input?.text).toBe("buy milk");
});
it("saves documents into inbox with a date prefix and tells the agent the path", async () => {
const r = await normalizeMessage({ document: { file_id: "d1", file_name: "my data.csv" }, caption: "look" }, deps());
expect(r.input?.text).toBe("look\n\nAttached file saved at inbox/2026-09-06-my_data.csv");
expect(await readFile(path.join(dir, "inbox", "2026-09-06-my_data.csv"), "utf8")).toBe("csv,data");
});
it("reports download failures as notes and still forwards the caption", async () => {
const r = await normalizeMessage({ caption: "see pic", photo: [{ file_id: "missing", width: 1, height: 1 }] }, deps());
expect(r.input).toEqual({ text: "see pic", images: undefined });
expect(r.notes[0]).toMatch(/Could not download photo: unknown file missing/);
});
});
describe("saveToInbox", () => {
it("adds a numeric suffix on collision", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "secretary-inbox-"));
try {
const now = new Date("2026-09-06T10:00:00Z");
expect(await saveToInbox(dir, "a.txt", Buffer.from("1"), now)).toBe("inbox/2026-09-06-a.txt");
expect(await saveToInbox(dir, "a.txt", Buffer.from("2"), now)).toBe("inbox/2026-09-06-a-1.txt");
expect(await saveToInbox(dir, "a.txt", Buffer.from("3"), now)).toBe("inbox/2026-09-06-a-2.txt");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/telegram/inbound.test.ts
Expected: FAIL, cannot find module ./inbound.js.
- Step 3: Implement src/telegram/inbound.ts
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AgentImage, AgentInput } from "../agent/events.js";
import type { Transcriber } from "../stt/openai.js";
export interface InboundMessage {
text?: string;
caption?: string;
photo?: Array<{ file_id: string; width: number; height: number; file_size?: number }>;
voice?: { file_id: string; mime_type?: string; duration?: number };
audio?: { file_id: string; mime_type?: string; file_name?: string };
document?: { file_id: string; file_name?: string; mime_type?: string };
}
export interface InboundDeps {
download(fileId: string): Promise<{ bytes: Buffer; filePath: string }>;
transcriber?: Transcriber;
workspaceDir: string;
now?: () => Date;
}
export interface InboundResult {
input: AgentInput | null;
transcript?: string;
notes: string[];
}
export async function normalizeMessage(msg: InboundMessage, deps: InboundDeps): Promise<InboundResult> {
const notes: string[] = [];
const parts: string[] = [];
const images: AgentImage[] = [];
let transcript: string | undefined;
const base = (msg.text ?? msg.caption ?? "").trim();
if (base) parts.push(base);
if (msg.photo && msg.photo.length > 0) {
const largest = msg.photo.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a));
try {
const { bytes } = await deps.download(largest.file_id);
images.push({ data: bytes.toString("base64"), mimeType: "image/jpeg" });
} catch (err) {
notes.push(`Could not download photo: ${errorText(err)}`);
}
}
const audio = msg.voice ?? msg.audio;
if (audio) {
if (!deps.transcriber) {
notes.push("Voice transcription is not configured. Set OPENAI_API_KEY to enable it.");
} else {
try {
const { bytes, filePath } = await deps.download(audio.file_id);
const filename = path.basename(filePath) || "voice.ogg";
transcript = await deps.transcriber.transcribe(bytes, filename, audio.mime_type ?? "audio/ogg");
if (transcript) parts.push(transcript);
else notes.push("The voice message came back empty from transcription.");
} catch (err) {
notes.push(`Could not transcribe voice message: ${errorText(err)}`);
}
}
}
if (msg.document) {
try {
const { bytes } = await deps.download(msg.document.file_id);
const saved = await saveToInbox(deps.workspaceDir, msg.document.file_name ?? "file", bytes, deps.now?.() ?? new Date());
parts.push(`Attached file saved at ${saved}`);
} catch (err) {
notes.push(`Could not save document: ${errorText(err)}`);
}
}
if (parts.length === 0 && images.length > 0) parts.push("(photo attached)");
const text = parts.join("\n\n");
return {
input: text ? { text, images: images.length > 0 ? images : undefined } : null,
transcript,
notes,
};
}
/** Writes bytes to <workspace>/inbox/<yyyy-mm-dd>-<safe name>, adding -N on collision. Returns the workspace-relative path. */
export async function saveToInbox(workspaceDir: string, originalName: string, bytes: Buffer, now: Date): Promise<string> {
const inbox = path.join(workspaceDir, "inbox");
await mkdir(inbox, { recursive: true });
const safe = originalName.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "") || "file";
const ext = path.extname(safe);
const stem = safe.slice(0, safe.length - ext.length);
const date = now.toISOString().slice(0, 10);
for (let i = 0; ; i++) {
const name = `${date}-${stem}${i ? `-${i}` : ""}${ext}`;
const full = path.join(inbox, name);
if (await exists(full)) continue;
await writeFile(full, bytes, { flag: "wx" });
return `inbox/${name}`;
}
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/telegram/inbound.test.ts
Expected: PASS (8 tests).
- Step 5: Commit
git add src/telegram/inbound.ts src/telegram/inbound.test.ts
git commit -m "Add Telegram inbound normalization for text, photos, voice, documents
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 13: Telegram outbound, progress message, chunked sends
Files:
- Create:
src/telegram/outbound.ts - Test:
src/telegram/outbound.test.ts
Interfaces:
-
Consumes:
markdownToTelegramHtml(Task 2),splitMarkdown(Task 3),Logger(Task 1). -
Produces:
interface TelegramApiLike { sendMessage(chatId: number, text: string, other?: { parse_mode?: "HTML" }): Promise<{ message_id: number }>; editMessageText(chatId: number, messageId: number, text: string): Promise<unknown>; deleteMessage(chatId: number, messageId: number): Promise<unknown>; sendChatAction(chatId: number, action: "typing"): Promise<unknown>; } interface OutboundOptions { editThrottleMs?: number; typingIntervalMs?: number; maxToolLines?: number } class Outbound { constructor(api: TelegramApiLike, chatId: number, logger: Logger, opts?: OutboundOptions); sendPlain(text: string): Promise<void>; sendMarkdown(markdown: string): Promise<void>; startProgress(): Promise<void>; noteTool(summary: string): void; noteNarration(text: string): void; endProgress(): Promise<void>; renderProgress(): string; }grammY's
bot.apisatisfiesTelegramApiLikestructurally. -
Step 1: Write the failing test
src/telegram/outbound.test.ts:
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createLogger } from "../log.js";
import { Outbound, type TelegramApiLike } from "./outbound.js";
class FakeApi implements TelegramApiLike {
calls: Array<{ method: string; args: unknown[] }> = [];
failHtml = false;
nextId = 100;
async sendMessage(chatId: number, text: string, other?: { parse_mode?: "HTML" }) {
if (this.failHtml && other?.parse_mode === "HTML") throw new Error("Bad Request: can't parse entities");
this.calls.push({ method: "sendMessage", args: [chatId, text, other] });
return { message_id: this.nextId++ };
}
async editMessageText(chatId: number, messageId: number, text: string) {
this.calls.push({ method: "editMessageText", args: [chatId, messageId, text] });
return true;
}
async deleteMessage(chatId: number, messageId: number) {
this.calls.push({ method: "deleteMessage", args: [chatId, messageId] });
return true;
}
async sendChatAction(chatId: number, action: "typing") {
this.calls.push({ method: "sendChatAction", args: [chatId, action] });
return true;
}
ofType(method: string) {
return this.calls.filter((c) => c.method === method);
}
}
const logger = createLogger("error", () => {});
describe("Outbound", () => {
let api: FakeApi;
beforeEach(() => {
api = new FakeApi();
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());
it("sends Markdown as HTML", async () => {
const out = new Outbound(api, 7, logger);
await out.sendMarkdown("**hi** <you>");
expect(api.calls).toEqual([{ method: "sendMessage", args: [7, "<b>hi</b> <you>", { parse_mode: "HTML" }] }]);
});
it("falls back to plain text when Telegram rejects the HTML", async () => {
api.failHtml = true;
const out = new Outbound(api, 7, logger);
await out.sendMarkdown("**hi**");
expect(api.calls).toEqual([{ method: "sendMessage", args: [7, "**hi**", undefined] }]);
});
it("splits long text into several messages", async () => {
const out = new Outbound(api, 7, logger);
await out.sendPlain("a".repeat(3800) + "\n\n" + "b".repeat(100));
expect(api.ofType("sendMessage")).toHaveLength(2);
});
it("shows tool lines and narration in one throttled, edited progress message, then deletes it", async () => {
const out = new Outbound(api, 7, logger, { editThrottleMs: 2000, typingIntervalMs: 4000, maxToolLines: 2 });
await out.startProgress();
expect(api.ofType("sendMessage")[0].args[1]).toBe("βοΈ Workingβ¦");
expect(api.ofType("sendChatAction")).toHaveLength(1);
out.noteTool("bash: ls");
out.noteTool("read: a.ts");
out.noteTool("read: b.ts");
out.noteNarration("Checking files.\nsecond line");
expect(api.ofType("editMessageText")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(2000);
expect(api.ofType("editMessageText")).toHaveLength(1);
expect(api.ofType("editMessageText")[0].args[2]).toBe("βοΈ Workingβ¦\nβ’ read: a.ts\nβ’ read: b.ts\nπ¬ Checking files.");
await vi.advanceTimersByTimeAsync(4000);
expect(api.ofType("sendChatAction").length).toBeGreaterThanOrEqual(2);
await out.endProgress();
expect(api.ofType("deleteMessage")).toEqual([{ method: "deleteMessage", args: [7, 100] }]);
const actions = api.ofType("sendChatAction").length;
await vi.advanceTimersByTimeAsync(10_000);
expect(api.ofType("sendChatAction")).toHaveLength(actions);
expect(api.ofType("editMessageText")).toHaveLength(1);
});
it("keeps tool lines noted before the progress message exists", async () => {
const out = new Outbound(api, 7, logger, { editThrottleMs: 2000 });
const starting = out.startProgress();
out.noteTool("bash: early");
await starting;
await vi.advanceTimersByTimeAsync(2000);
expect(api.ofType("editMessageText")[0].args[2]).toBe("βοΈ Workingβ¦\nβ’ bash: early");
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/telegram/outbound.test.ts
Expected: FAIL, cannot find module ./outbound.js.
- Step 3: Implement src/telegram/outbound.ts
import type { Logger } from "../log.js";
import { splitMarkdown } from "./chunk.js";
import { markdownToTelegramHtml } from "./markdown.js";
export interface TelegramApiLike {
sendMessage(chatId: number, text: string, other?: { parse_mode?: "HTML" }): Promise<{ message_id: number }>;
editMessageText(chatId: number, messageId: number, text: string): Promise<unknown>;
deleteMessage(chatId: number, messageId: number): Promise<unknown>;
sendChatAction(chatId: number, action: "typing"): Promise<unknown>;
}
export interface OutboundOptions {
editThrottleMs?: number;
typingIntervalMs?: number;
maxToolLines?: number;
}
const PROGRESS_HEADER = "βοΈ Workingβ¦";
export class Outbound {
private progressId: number | undefined;
private tools: string[] = [];
private narration = "";
private dirty = false;
private lastEditAt = 0;
private pendingEdit: NodeJS.Timeout | undefined;
private typing: NodeJS.Timeout | undefined;
private readonly editThrottleMs: number;
private readonly typingIntervalMs: number;
private readonly maxToolLines: number;
constructor(
private readonly api: TelegramApiLike,
private readonly chatId: number,
private readonly logger: Logger,
opts: OutboundOptions = {},
) {
this.editThrottleMs = opts.editThrottleMs ?? 2000;
this.typingIntervalMs = opts.typingIntervalMs ?? 4000;
this.maxToolLines = opts.maxToolLines ?? 5;
}
async sendPlain(text: string): Promise<void> {
for (const chunk of splitMarkdown(text)) await this.api.sendMessage(this.chatId, chunk);
}
async sendMarkdown(markdown: string): Promise<void> {
for (const chunk of splitMarkdown(markdown)) {
const html = markdownToTelegramHtml(chunk);
try {
await this.api.sendMessage(this.chatId, html, { parse_mode: "HTML" });
} catch (err) {
this.logger.warn("HTML message rejected, sending plain text", err);
await this.api.sendMessage(this.chatId, chunk);
}
}
}
async startProgress(): Promise<void> {
const previous = this.progressId;
this.progressId = undefined;
this.tools = [];
this.narration = "";
this.dirty = false;
this.clearTimers();
if (previous !== undefined) await this.deleteQuietly(previous);
try {
const sent = await this.api.sendMessage(this.chatId, this.renderProgress());
this.progressId = sent.message_id;
this.lastEditAt = Date.now();
if (this.dirty) this.scheduleEdit();
} catch (err) {
this.logger.warn("could not send progress message", err);
}
void this.api.sendChatAction(this.chatId, "typing").catch(() => undefined);
this.typing = setInterval(() => void this.api.sendChatAction(this.chatId, "typing").catch(() => undefined), this.typingIntervalMs);
}
noteTool(summary: string): void {
this.tools.push(summary);
if (this.tools.length > this.maxToolLines) this.tools.shift();
this.scheduleEdit();
}
noteNarration(text: string): void {
this.narration = text.split("\n")[0].slice(0, 200);
this.scheduleEdit();
}
async endProgress(): Promise<void> {
this.clearTimers();
const id = this.progressId;
this.progressId = undefined;
this.dirty = false;
if (id !== undefined) await this.deleteQuietly(id);
}
renderProgress(): string {
const lines = [PROGRESS_HEADER, ...this.tools.map((t) => `β’ ${t}`)];
if (this.narration) lines.push(`π¬ ${this.narration}`);
return lines.join("\n");
}
private scheduleEdit(): void {
this.dirty = true;
if (this.progressId === undefined || this.pendingEdit) return;
const wait = Math.max(0, this.lastEditAt + this.editThrottleMs - Date.now());
this.pendingEdit = setTimeout(() => {
this.pendingEdit = undefined;
void this.flushEdit();
}, wait);
}
private async flushEdit(): Promise<void> {
if (this.progressId === undefined || !this.dirty) return;
this.dirty = false;
this.lastEditAt = Date.now();
try {
await this.api.editMessageText(this.chatId, this.progressId, this.renderProgress());
} catch (err) {
this.logger.debug("progress edit failed", err);
}
}
private clearTimers(): void {
if (this.pendingEdit) clearTimeout(this.pendingEdit);
this.pendingEdit = undefined;
if (this.typing) clearInterval(this.typing);
this.typing = undefined;
}
private async deleteQuietly(messageId: number): Promise<void> {
try {
await this.api.deleteMessage(this.chatId, messageId);
} catch (err) {
this.logger.warn("could not delete progress message", err);
}
}
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/telegram/outbound.test.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add src/telegram/outbound.ts src/telegram/outbound.test.ts
git commit -m "Add Telegram outbound with progress message and HTML fallback
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 14: Telegram bridge (bot wiring)
Files:
- Create:
src/telegram/bridge.ts - Test:
src/telegram/bridge.test.ts
Interfaces:
- Consumes:
AgentHost,AgentHostEvent,AgentInput(Tasks 9, 10);Scheduler,JobView(Task 6);describeJob(Task 7);normalizeMessage(Task 12);Outbound(Task 13);Transcriber(Task 8);Config(Task 1). - Produces:
type HostLike = Pick<AgentHost, "busy" | "send" | "abort" | "newSession" | "compact" | "status" | "subscribe">; type SchedulerLike = Pick<Scheduler, "list" | "delete">; interface BridgeDeps { bot: Bot; config: Pick<Config, "telegramBotToken" | "telegramOwnerId" | "workspaceDir" | "timezone">; host: HostLike; scheduler: SchedulerLike; outbound: Outbound; transcriber?: Transcriber; logger: Logger; fetchFn?: typeof fetch; } const BOT_COMMANDS: Array<{ command: string; description: string }>; function registerBridge(deps: BridgeDeps): void; function formatStatus(host: HostLike, scheduler: SchedulerLike): string; function downloadFile(bot: Bot, token: string, fileId: string, fetchFn?: typeof fetch): Promise<{ bytes: Buffer; filePath: string }>;
Behaviour: owner guard middleware first; slash commands; every other message goes through normalizeMessage and then host.send without awaiting the run (grammY processes updates sequentially, so awaiting a multi-minute run would block /stop). Host events are rendered through Outbound on a serialized promise chain so a run_settled never interleaves with the next run_started.
- Step 1: Write the failing test
src/telegram/bridge.test.ts:
import { Bot } from "grammy";
import type { Update, UserFromGetMe } from "grammy/types";
import { describe, expect, it, vi } from "vitest";
import type { AgentHostEvent, AgentInput } from "../agent/events.js";
import { createLogger } from "../log.js";
import type { JobView } from "../scheduler/scheduler.js";
import { type HostLike, registerBridge, type SchedulerLike } from "./bridge.js";
import { Outbound } from "./outbound.js";
const OWNER = 4242;
const logger = createLogger("error", () => {});
class FakeHost implements HostLike {
busy = false;
sent: AgentInput[] = [];
aborted = 0;
newSessions = 0;
private listeners = new Set<(e: AgentHostEvent) => void>();
async send(input: AgentInput) {
this.sent.push(input);
}
async abort() {
this.aborted++;
return this.busy;
}
async newSession() {
this.newSessions++;
}
async compact() {
return { tokensBefore: 50, estimatedTokensAfter: 5 };
}
status() {
return { busy: this.busy, model: "anthropic/claude-x", thinkingLevel: "medium", contextTokens: 12_000, contextWindow: 200_000, sessionFile: "/x/abc.jsonl" };
}
subscribe(l: (e: AgentHostEvent) => void) {
this.listeners.add(l);
return () => void this.listeners.delete(l);
}
emit(e: AgentHostEvent) {
for (const l of this.listeners) l(e);
}
}
class FakeScheduler implements SchedulerLike {
jobs: JobView[] = [
{ id: "abc12", name: "dentist", schedule: { at: "2030-01-01T09:00:00Z" }, source: { kind: "text", text: "call" }, deliver: "direct", createdAt: "", nextRunAt: "2030-01-01T09:00:00Z" },
];
list() {
return this.jobs;
}
async delete(id: string) {
const before = this.jobs.length;
this.jobs = this.jobs.filter((j) => j.id !== id);
return this.jobs.length !== before;
}
}
function harness() {
const botInfo: UserFromGetMe = {
id: 1, is_bot: true, first_name: "sec", username: "sec_bot", can_join_groups: false,
can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false, has_main_web_app: false,
};
const bot = new Bot("1:TEST", { botInfo });
const calls: Array<{ method: string; payload: Record<string, unknown> }> = [];
let nextId = 500;
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload: payload as Record<string, unknown> });
const p = payload as Record<string, unknown>;
let result: unknown = true;
if (method === "sendMessage") result = { message_id: nextId++, date: 0, chat: { id: p.chat_id, type: "private" }, text: p.text };
if (method === "getFile") result = { file_id: p.file_id, file_unique_id: "u", file_path: "voice/file_1.oga" };
return { ok: true, result } as never;
});
const host = new FakeHost();
const scheduler = new FakeScheduler();
const outbound = new Outbound(bot.api, OWNER, logger, { editThrottleMs: 1, typingIntervalMs: 60_000 });
registerBridge({
bot,
config: { telegramBotToken: "1:TEST", telegramOwnerId: OWNER, workspaceDir: process.cwd(), timezone: "UTC" },
host,
scheduler,
outbound,
logger,
});
const texts = () => calls.filter((c) => c.method === "sendMessage").map((c) => c.payload.text as string);
return { bot, calls, host, scheduler, texts };
}
let updateId = 1;
function textUpdate(text: string, from = OWNER, chatType = "private"): Update {
const entities = text.startsWith("/") ? [{ type: "bot_command" as const, offset: 0, length: text.split(" ")[0].length }] : undefined;
return {
update_id: updateId++,
message: {
message_id: updateId, date: 0,
chat: { id: from, type: chatType as "private", first_name: "o" },
from: { id: from, is_bot: false, first_name: "o" },
text, entities,
},
} as Update;
}
describe("registerBridge", () => {
it("ignores anyone but the owner in a private chat", async () => {
const h = harness();
await h.bot.handleUpdate(textUpdate("hello", 999));
await h.bot.handleUpdate(textUpdate("hello", OWNER, "group"));
expect(h.calls).toEqual([]);
expect(h.host.sent).toEqual([]);
});
it("answers /status and /start with the status block", async () => {
const h = harness();
await h.bot.handleUpdate(textUpdate("/status"));
expect(h.texts()[0]).toBe("π’ Idle\nModel: anthropic/claude-x (thinking medium)\nContext: 12k / 200k tokens (6%)\nSession: abc.jsonl\nJobs: 1");
await h.bot.handleUpdate(textUpdate("/start"));
expect(h.texts()[1]).toContain("π’ Idle");
});
it("handles /stop, /new, /compact", async () => {
const h = harness();
await h.bot.handleUpdate(textUpdate("/stop"));
h.host.busy = true;
await h.bot.handleUpdate(textUpdate("/stop"));
h.host.busy = false;
await h.bot.handleUpdate(textUpdate("/new"));
await h.bot.handleUpdate(textUpdate("/compact"));
expect(h.texts()).toEqual(["Nothing running.", "Stopped.", "New session started.", "Compacted: 50 β 5 tokens."]);
expect(h.host.newSessions).toBe(1);
});
it("lists and cancels jobs", async () => {
const h = harness();
await h.bot.handleUpdate(textUpdate("/jobs"));
expect(h.texts()[0]).toMatch(/^abc12 Β· dentist Β· once at 1 Jan 2030, 09:00/);
await h.bot.handleUpdate(textUpdate("/cancel abc12"));
await h.bot.handleUpdate(textUpdate("/cancel abc12"));
await h.bot.handleUpdate(textUpdate("/cancel"));
await h.bot.handleUpdate(textUpdate("/jobs"));
expect(h.texts().slice(1)).toEqual(["Cancelled job abc12.", "No job with id abc12.", "Usage: /cancel <id>", "No scheduled jobs."]);
});
it("forwards text to the host and reacts when busy", async () => {
const h = harness();
await h.bot.handleUpdate(textUpdate("what time is it"));
expect(h.host.sent).toEqual([{ text: "what time is it", images: undefined }]);
expect(h.calls.find((c) => c.method === "setMessageReaction")).toBeUndefined();
h.host.busy = true;
await h.bot.handleUpdate(textUpdate("also this"));
expect(h.calls.find((c) => c.method === "setMessageReaction")).toBeDefined();
expect(h.host.sent).toHaveLength(2);
});
it("rejects unknown commands without bothering the agent", async () => {
const h = harness();
await h.bot.handleUpdate(textUpdate("/dance"));
expect(h.texts()).toEqual(["Unknown command."]);
expect(h.host.sent).toEqual([]);
});
it("tells the owner when voice transcription is not configured", async () => {
const h = harness();
const update = textUpdate("");
delete (update.message as { text?: string }).text;
(update.message as { voice?: unknown }).voice = { file_id: "v1", file_unique_id: "u", duration: 2 };
await h.bot.handleUpdate(update);
expect(h.texts()).toEqual(["Voice transcription is not configured. Set OPENAI_API_KEY to enable it."]);
});
it("renders host events: progress, final answer, and errors", async () => {
const h = harness();
h.host.emit({ type: "run_started" });
h.host.emit({ type: "tool_started", toolName: "bash", summary: "bash: ls" });
h.host.emit({ type: "run_settled", finalText: "**done**", aborted: false });
await vi.waitFor(() => expect(h.texts()).toEqual(["βοΈ Workingβ¦", "<b>done</b>"]));
expect(h.calls.find((c) => c.method === "deleteMessage")?.payload.message_id).toBe(500);
h.host.emit({ type: "run_started" });
h.host.emit({ type: "run_error", message: "boom" });
await vi.waitFor(() => expect(h.texts().at(-1)).toBe("β οΈ boom"));
h.host.emit({ type: "run_started" });
h.host.emit({ type: "run_settled", finalText: "", aborted: true });
await vi.waitFor(() => expect(h.calls.filter((c) => c.method === "deleteMessage")).toHaveLength(3));
expect(h.texts().at(-1)).toBe("βοΈ Workingβ¦");
});
});
- Step 2: Run it to verify it fails
Run: npx vitest run src/telegram/bridge.test.ts
Expected: FAIL, cannot find module ./bridge.js.
- Step 3: Implement src/telegram/bridge.ts
import path from "node:path";
import type { Bot } from "grammy";
import type { AgentHostEvent } from "../agent/events.js";
import type { AgentHost } from "../agent/host.js";
import type { Config } from "../config.js";
import type { Logger } from "../log.js";
import type { Scheduler } from "../scheduler/scheduler.js";
import type { Transcriber } from "../stt/openai.js";
import { describeJob } from "../tools/schedule.js";
import { normalizeMessage } from "./inbound.js";
import type { Outbound } from "./outbound.js";
export type HostLike = Pick<AgentHost, "busy" | "send" | "abort" | "newSession" | "compact" | "status" | "subscribe">;
export type SchedulerLike = Pick<Scheduler, "list" | "delete">;
export interface BridgeDeps {
bot: Bot;
config: Pick<Config, "telegramBotToken" | "telegramOwnerId" | "workspaceDir" | "timezone">;
host: HostLike;
scheduler: SchedulerLike;
outbound: Outbound;
transcriber?: Transcriber;
logger: Logger;
fetchFn?: typeof fetch;
}
export const BOT_COMMANDS = [
{ command: "status", description: "Show agent status" },
{ command: "new", description: "Start a fresh session" },
{ command: "stop", description: "Abort the current run" },
{ command: "jobs", description: "List scheduled jobs" },
{ command: "cancel", description: "Cancel a job: /cancel <id>" },
{ command: "compact", description: "Compact the conversation context" },
];
export function registerBridge(deps: BridgeDeps): void {
const { bot, config, host, scheduler, outbound, logger } = deps;
const warned = new Set<number>();
bot.use(async (ctx, next) => {
const from = ctx.from?.id;
if (from !== config.telegramOwnerId || ctx.chat?.type !== "private") {
if (from !== undefined && !warned.has(from)) {
warned.add(from);
logger.warn(`ignoring update from user ${from} in ${ctx.chat?.type ?? "unknown"} chat`);
}
return;
}
await next();
});
bot.command(["start", "status"], async (ctx) => {
await ctx.reply(formatStatus(host, scheduler));
});
bot.command("new", async (ctx) => {
await host.newSession();
await ctx.reply("New session started.");
});
bot.command("stop", async (ctx) => {
const aborted = await host.abort();
await ctx.reply(aborted ? "Stopped." : "Nothing running.");
});
bot.command("jobs", async (ctx) => {
const jobs = scheduler.list();
await ctx.reply(jobs.length ? jobs.map((j) => describeJob(j, config.timezone)).join("\n") : "No scheduled jobs.");
});
bot.command("cancel", async (ctx) => {
const id = ctx.match.trim();
if (!id) {
await ctx.reply("Usage: /cancel <id>");
return;
}
const ok = await scheduler.delete(id);
await ctx.reply(ok ? `Cancelled job ${id}.` : `No job with id ${id}.`);
});
bot.command("compact", async (ctx) => {
try {
const r = await host.compact();
await ctx.reply(`Compacted: ${r.tokensBefore} β ${r.estimatedTokensAfter ?? "?"} tokens.`);
} catch (err) {
await ctx.reply(`Compaction failed: ${errorText(err)}`);
}
});
bot.on("message", async (ctx) => {
if (ctx.message.text?.startsWith("/")) {
await ctx.reply("Unknown command.");
return;
}
const result = await normalizeMessage(ctx.message, {
download: (fileId) => downloadFile(bot, config.telegramBotToken, fileId, deps.fetchFn),
transcriber: deps.transcriber,
workspaceDir: config.workspaceDir,
});
for (const note of result.notes) await ctx.reply(note);
if (result.transcript) await ctx.reply(`π€ ${result.transcript}`);
if (!result.input) return;
if (host.busy) await ctx.react("π").catch(() => undefined);
void host.send(result.input).catch((err) => {
logger.error("agent send failed", err);
void outbound.sendPlain(`β οΈ ${errorText(err)}`).catch(() => undefined);
});
});
let queue: Promise<void> = Promise.resolve();
host.subscribe((event) => {
queue = queue.then(() => renderHostEvent(event, outbound)).catch((err) => logger.error("outbound rendering failed", err));
});
}
async function renderHostEvent(event: AgentHostEvent, outbound: Outbound): Promise<void> {
switch (event.type) {
case "run_started":
await outbound.startProgress();
return;
case "tool_started":
outbound.noteTool(event.summary);
return;
case "assistant_text":
outbound.noteNarration(event.text);
return;
case "run_settled":
await outbound.endProgress();
if (!event.aborted) await outbound.sendMarkdown(event.finalText);
return;
case "run_error":
await outbound.endProgress();
await outbound.sendPlain(`β οΈ ${event.message}`);
return;
}
}
export function formatStatus(host: HostLike, scheduler: SchedulerLike): string {
const s = host.status();
const context =
s.contextTokens !== undefined && s.contextWindow
? `${Math.round(s.contextTokens / 1000)}k / ${Math.round(s.contextWindow / 1000)}k tokens (${Math.round((100 * s.contextTokens) / s.contextWindow)}%)`
: "unknown";
return [
s.busy ? "π‘ Busy" : "π’ Idle",
`Model: ${s.model} (thinking ${s.thinkingLevel})`,
`Context: ${context}`,
`Session: ${s.sessionFile ? path.basename(s.sessionFile) : "in memory"}`,
`Jobs: ${scheduler.list().length}`,
].join("\n");
}
export async function downloadFile(
bot: Bot,
token: string,
fileId: string,
fetchFn: typeof fetch = fetch,
): Promise<{ bytes: Buffer; filePath: string }> {
const file = await bot.api.getFile(fileId);
if (!file.file_path) throw new Error("Telegram returned no file_path");
const res = await fetchFn(`https://api.telegram.org/file/bot${token}/${file.file_path}`);
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
return { bytes: Buffer.from(await res.arrayBuffer()), filePath: file.file_path };
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/telegram/bridge.test.ts && npm run typecheck
Expected: PASS (8 tests), typecheck clean. Notes for likely snags: grammY only matches bot.command when the message carries a bot_command entity, which the textUpdate helper adds. If ctx.react is missing on your grammY types, upgrade to grammy@^1.46. If bot.api is not assignable to TelegramApiLike, widen the other parameter in TelegramApiLike.sendMessage to Record<string, unknown>.
- Step 5: Commit
git add src/telegram/bridge.ts src/telegram/bridge.test.ts
git commit -m "Add Telegram bridge with owner guard, commands, and event rendering
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 15: main.ts wiring, startup sequence, signals
Files:
- Create:
src/main.ts - Test: manual run (this file only wires tested parts)
Interfaces:
- Consumes everything:
loadConfig/ConfigError(Task 1),createLogger(Task 1),ensureWorkspace(Task 11),AgentHost(Task 10),createPiBackend(Task 11),Scheduler/JobStore(Tasks 6, 4),createScheduleTool(Task 7),createOpenAiTranscriber(Task 8),Outbound(Task 13),registerBridge/BOT_COMMANDS/formatStatus(Task 14).
Startup order: config, logger, workspace, bot + getMe, outbound, scheduler (with late-bound host reference), backend + host start, transcriber, bridge, setMyCommands, scheduler start, signal handlers, polling, online message.
- Step 1: Implement src/main.ts
import path from "node:path";
import { autoRetry } from "@grammyjs/auto-retry";
import { Bot } from "grammy";
import { AgentHost } from "./agent/host.js";
import { createPiBackend } from "./agent/pi-backend.js";
import { ConfigError, loadConfig } from "./config.js";
import { createLogger } from "./log.js";
import { Scheduler } from "./scheduler/scheduler.js";
import { JobStore } from "./scheduler/store.js";
import { createOpenAiTranscriber } from "./stt/openai.js";
import { BOT_COMMANDS, formatStatus, registerBridge } from "./telegram/bridge.js";
import { Outbound } from "./telegram/outbound.js";
import { createScheduleTool } from "./tools/schedule.js";
import { ensureWorkspace } from "./workspace.js";
async function main(): Promise<void> {
const config = (() => {
try {
return loadConfig();
} catch (err) {
if (err instanceof ConfigError) {
console.error(`Config error: ${err.message}`);
process.exit(1);
}
throw err;
}
})();
const logger = createLogger(config.logLevel);
await ensureWorkspace(config.workspaceDir);
logger.info(`workspace ${config.workspaceDir}`);
const bot = new Bot(config.telegramBotToken);
bot.api.config.use(autoRetry());
const me = await bot.api.getMe();
logger.info(`telegram bot @${me.username}`);
const outbound = new Outbound(bot.api, config.telegramOwnerId, logger);
let host: AgentHost | undefined;
const scheduler = new Scheduler({
store: new JobStore(path.join(config.workspaceDir, ".secretary", "jobs.json")),
workspaceDir: config.workspaceDir,
timezone: config.timezone,
logger,
shellPath: config.shellPath,
deliverDirect: (text) => outbound.sendPlain(text),
deliverAgent: async (text) => {
if (!host) throw new Error("agent not ready");
await host.followUp({ text });
},
});
let stopping = false;
const shutdown = async (reason: string): Promise<void> => {
if (stopping) return;
stopping = true;
logger.info(`shutting down (${reason})`);
await bot.stop().catch(() => undefined);
await host?.abort().catch(() => undefined);
await scheduler.stop();
await host?.dispose().catch((err) => logger.warn("dispose failed", err));
process.exit(0);
};
const backend = createPiBackend({
workspaceDir: config.workspaceDir,
piModel: config.piModel,
timezone: config.timezone,
customTools: [createScheduleTool(scheduler, config.timezone)],
logger,
onShutdownRequest: () => void shutdown("extension request"),
});
host = new AgentHost(backend, logger);
await host.start();
logger.info(`agent ready: ${host.status().model}`);
const transcriber = config.openaiApiKey
? createOpenAiTranscriber({ apiKey: config.openaiApiKey, model: config.sttModel })
: undefined;
if (!transcriber) logger.warn("OPENAI_API_KEY not set, voice messages will not be transcribed");
registerBridge({ bot, config, host, scheduler, outbound, transcriber, logger });
await bot.api.setMyCommands(BOT_COMMANDS);
await scheduler.start();
logger.info(`scheduler ready with ${scheduler.list().length} job(s)`);
bot.catch((err) => logger.error("telegram handler error", err.error));
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("unhandledRejection", (err) => {
logger.error("unhandled rejection", err);
process.exit(1);
});
process.on("uncaughtException", (err) => {
logger.error("uncaught exception", err);
process.exit(1);
});
const readyHost = host;
void bot.start({
onStart: () => {
logger.info("polling started");
void outbound.sendPlain(`π’ Online.\n${formatStatus(readyHost, scheduler)}`).catch((err) => logger.warn("online message failed", err));
},
});
}
main().catch((err) => {
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exit(1);
});
- Step 2: Build and typecheck
Run: npm run typecheck && npm run build
Expected: both clean, dist/main.js exists.
- Step 3: Run it for real on Windows
Create .env from .env.example with your bot token (from @BotFather) and your numeric user id (send /start to @userinfobot to read it). Then:
Run: npm run dev
Expected log lines, in order: workspace ..., telegram bot @..., agent ready: anthropic/..., scheduler ready with 0 job(s), polling started. Telegram receives π’ Online. followed by the status block.
Then in Telegram:
- send
hiand expect a progress message that disappears and a reply - send
/statusand expect the status block withπ‘ Busyorπ’ Idle - press Ctrl+C in the terminal and expect
shutting down (SIGINT)and a clean exit
If startup fails with No model available, run pi once in this terminal to log in, or set PI_MODEL plus the provider's API key in .env.
- Step 4: Commit
git add src/main.ts
git commit -m "Wire daemon startup, shutdown, and polling
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Task 16: README, Dockerfile, compose, workspace placeholders, manual end-to-end pass
Files:
-
Create:
README.md,Dockerfile,docker-compose.yml,.dockerignore,workspace/AGENTS.md,workspace/MEMORY.md,workspace/scripts/.gitkeep,workspace/inbox/.gitkeep -
Step 1: Create the committed workspace templates
Generate them with the same content the daemon writes, so a fresh checkout and a fresh Docker volume look identical:
Run: node --import tsx -e "import('./src/workspace.ts').then(m => m.ensureWorkspace('workspace'))"
Then create the two empty placeholder files workspace/scripts/.gitkeep and workspace/inbox/.gitkeep.
Expected: git status shows workspace/AGENTS.md, workspace/MEMORY.md, and the two .gitkeep files as new; workspace/.secretary/ is ignored.
- Step 2: Create Dockerfile and .dockerignore
Dockerfile:
FROM node:24-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep python3 python-is-python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
ENV NODE_ENV=production \
WORKSPACE_DIR=/workspace \
PI_CODING_AGENT_DIR=/pi-agent
VOLUME ["/workspace", "/pi-agent"]
CMD ["node", "dist/main.js"]
.dockerignore:
node_modules
dist
.env
workspace
docs
.git
- Step 3: Create docker-compose.yml
services:
secretary:
build: .
restart: unless-stopped
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_OWNER_ID=${TELEGRAM_OWNER_ID}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- STT_MODEL=${STT_MODEL:-gpt-4o-transcribe}
- PI_MODEL=${PI_MODEL:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- SECRETARY_TIMEZONE=${SECRETARY_TIMEZONE:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
volumes:
- secretary-workspace:/workspace
- secretary-pi-agent:/pi-agent
volumes:
secretary-workspace:
secretary-pi-agent:
- Step 4: Build the image locally to prove the Dockerfile
Run: docker build -t volcanic-secretary .
Expected: image builds. (Running it needs the env and a Pi auth volume; that is the server step, not this one.) If Docker Desktop is not available on this machine, note that in the commit message and rely on the Coolify build.
- Step 5: Write README.md
# Volcanic Secretary
A personal assistant you talk to over Telegram. The agent engine is [Pi](https://pi.dev) embedded through its SDK. It lives in one workspace directory, runs tools without asking, remembers things in `MEMORY.md`, and can schedule reminders, prompts, and watcher scripts.
## Setup (Windows or any host with Node 24)
1. Create a bot with @BotFather and copy the token.
2. Find your numeric Telegram user id (send `/start` to @userinfobot).
3. Log in to a model provider once with `pi` so `~/.pi/agent/auth.json` exists, or plan to set `PI_MODEL` plus a provider API key.
4. Copy `.env.example` to `.env` and fill in `TELEGRAM_BOT_TOKEN` and `TELEGRAM_OWNER_ID`. Add `OPENAI_API_KEY` if you want voice notes transcribed.
5. `npm install`, then `npm run dev` (development) or `npm run build && npm start`.
The bot messages you `π’ Online.` when it is ready. Only your user id is accepted; everyone else is ignored.
## Talking to it
Send text, photos, voice notes, or files. Photos go to the model as images. Voice notes are transcribed with OpenAI and echoed back as `π€ ...`. Files are saved to `workspace/inbox/` and the agent is told the path.
While the agent works you see one `βοΈ Workingβ¦` message that updates with tool activity, then the answer. Send another message while it works and it is queued for the current run (you get a π reaction).
Commands: `/status`, `/new`, `/stop`, `/jobs`, `/cancel <id>`, `/compact`.
## Scheduling
Ask in plain language:
- "Remind me at 9 tomorrow to call the dentist" (one-off, sent directly)
- "Every weekday at 8, summarize what's in my inbox folder" (recurring prompt through the agent)
- "Write a script that checks the price on this page every 10 minutes and tell me only if it drops below 50" (watcher script)
Watcher scripts follow one rule: print nothing and exit 0 to stay silent, print a message to trigger delivery, exit non-zero on a real error. Failures are reported once and stay quiet while the same failure repeats.
Jobs live in `workspace/.secretary/jobs.json`.
## Workspace
workspace/ AGENTS.md persona and standing instructions (edit freely) MEMORY.md facts the assistant keeps; it edits this itself scripts/ scripts for scheduled jobs inbox/ files you sent .secretary/ runtime state
The agent's edits to `MEMORY.md` are local runtime data. Leave them uncommitted, or run `git update-index --assume-unchanged workspace/MEMORY.md`.
## Environment variables
| Variable | Required | Default | Purpose |
| --- | --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | yes | | Bot token |
| `TELEGRAM_OWNER_ID` | yes | | Your numeric user id |
| `WORKSPACE_DIR` | no | `./workspace` | Workspace directory |
| `OPENAI_API_KEY` | no | | Enables voice transcription |
| `STT_MODEL` | no | `gpt-4o-transcribe` | OpenAI transcription model |
| `PI_MODEL` | no | Pi default | `provider/model` or `provider/model:thinking` |
| `PI_CODING_AGENT_DIR` | no | `~/.pi/agent` | Pi config directory (auth, settings, packages) |
| `SECRETARY_TIMEZONE` | no | system | IANA timezone for cron and time display |
| `SECRETARY_SHELL` | no | Git Bash / bash | Shell used for scheduled scripts |
| `LOG_LEVEL` | no | `info` | `debug`, `info`, `warn`, `error` |
## Keeping it running on Windows
Use Task Scheduler (run `node dist/main.js` at logon, restart on failure) or `npm i -g pm2` then `pm2 start dist/main.js --name secretary`.
## Docker / Coolify
`docker compose up -d --build` runs it with two named volumes: `/workspace` and `/pi-agent`. Provide the environment through Coolify. For model auth either copy a Pi agent directory containing `auth.json` into the `/pi-agent` volume, or set `ANTHROPIC_API_KEY` and `PI_MODEL=anthropic/claude-opus-4-8` (or another model you have access to). The image includes bash, git, ripgrep, and Python 3 so watcher scripts work the same as on Windows.
## Tests
`npm test` runs the unit tests. `SECRETARY_SMOKE=1 npx vitest run src/agent/pi-backend.smoke.test.ts` runs one real model call through Pi.
- Step 6: Manual end-to-end checklist on Windows
Run npm run dev with a real bot and walk through every line. Record results in the commit message body.
hiβ progress message appears then disappears, reply arrives as formatted text.- Long answer (
explain git rebase in detail with code examples) β multiple messages, code blocks render, no HTML shows raw. - Photo with caption
what is thisβ reply describes the image. - Voice note β
π€ <transcript>then a reply. WithoutOPENAI_API_KEYβ the not-configured note. - Document (a small
.csv) with captionsummarize thisβ file appears inworkspace/inbox/with date prefix, reply uses it. - Send a message while a long run is in progress β π reaction, and the agent picks it up in the same run.
/stopduring a run βStopped.and no final answer message.remind me in 2 minutes to stretchβ confirmation with the fire time; two minutes later<job name>: <message>arrives directly.every minute run scripts/ping.py and tell me if it prints somethingwhereping.pyprints on even minutes β messages arrive only on even minutes;/jobslists it;/cancel <id>removes it.- Ask it to remember a fact (
remember that my dog is called Rex), then/new, thenwhat is my dog calledβ answer comes fromMEMORY.md. - Stop the daemon, start it again β conversation continues (
what did we just talk about). /compactβ reply with token counts.
- Step 7: Commit
git add README.md Dockerfile .dockerignore docker-compose.yml workspace/AGENTS.md workspace/MEMORY.md workspace/scripts/.gitkeep workspace/inbox/.gitkeep
git commit -m "Add README, Docker packaging, and workspace templates
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT"
Plan self-review notes
Spec coverage, section by section:
- Β§4.1 AgentHost interface and event mapping β Tasks 9, 10. Deviation:
abort()returnsPromise<boolean>so/stopcan answerStopped.versusNothing running.without a second status call. - Β§4.1 Pi wiring (runtime factory,
appendSystemPrompt,bindExtensionsin print mode,continueRecent,resolveCliModel, tool list) β Task 11. Deviation:MEMORY.mdis injected per turn through an inline extension'sbefore_agent_startinstead ofagentsFilesOverride, because the resource loader caches context files at load time and the agent edits the file mid-session. - Β§4.2 owner guard, commands, typing action β Tasks 13, 14.
- Β§4.3, Β§6 scheduler model, firing rules, persistence, tool β Tasks 4, 5, 6, 7.
- Β§4.4 config, STT, markdown, logger β Tasks 1, 2, 8.
- Β§5.1 inbound β Task 12. Β§5.2 outbound, chunking, HTML fallback β Tasks 3, 13, 14. Β§5.3 commands β Task 14.
- Β§7 workspace and persona β Task 11 and Task 16 templates.
- Β§8 configuration and startup sequence β Tasks 1, 15. Addition:
SECRETARY_SHELLoverride for the script runner's shell. - Β§9 error handling and lifecycle β Tasks 6, 13, 14, 15.
- Β§10 testing β every task; the integration smoke test is opt-in via
SECRETARY_SMOKE=1rather than auto-detected auth, sonpm testnever spends tokens by accident. - Β§11 layout β matches, plus
src/telegram/chunk.tsandsrc/workspace.tssplit out for focus. Dependency deviation: nomarked, the converter is hand rolled;typeboxadded for the tool schema. - Β§12 Docker β Task 16.
Type consistency checked across tasks: AgentInput/AgentImage (9 β 10, 12, 14), SessionLike/AgentBackend (10 β 11), JobView/Scheduler (6 β 7, 14), Transcriber (8 β 12, 14), TelegramApiLike/Outbound (13 β 14, 15), HostLike/SchedulerLike (14 β 15 via the concrete classes).