Files
VolcanicSecretary/docs/superpowers/plans/2026-09-08-send-attachments.md
2026-09-08 11:49:13 +00:00

20 KiB
Raw Permalink Blame History

Send-attachments tool 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: Add a send Pi custom tool that lets the agent push a file from the workspace back to the owner as a Telegram photo or document.

Architecture: New module src/tools/send.ts exports createSendTool({ bot, chatId, workspaceDir }) returning a Pi defineTool result. It validates the path stays inside workspaceDir, fs.stats it, auto-detects photo-vs-document by extension (with an explicit kind override), enforces Telegram's 10 MB photo / 50 MB document limits, then calls bot.sendPhoto or bot.sendDocument with a new InputFile(absPath). Wired into main.ts alongside schedule / calendar / Playwright, with a small paragraph appended to the system prompt so the model knows when to use it.

Tech Stack: TypeScript, Node 24, grammy (Telegram), @earendil-works/pi-coding-agent (defineTool), typebox (schema), vitest (tests).

Design spec: docs/superpowers/specs/2026-09-08-send-attachments-design.md


File Structure

  • Create src/tools/send.ts — schema, handleSend, createSendTool factory. One file, all responsibility for the send tool.
  • Create src/tools/send.test.ts — unit tests with a fake bot (two vi.fn()s), tmpdir workspaces, sparse files for size cases.
  • Modify src/agent/system-prompt.ts — append a ## Sending files paragraph.
  • Modify src/agent/system-prompt.test.ts — one new assertion the prompt mentions the send tool.
  • Modify src/main.ts — push createSendTool({...}) into customTools.
  • Modify README.md — one line in the "Talking to it" section + one new manual E2E item.

Task 1: Create the send tool skeleton with schema and factory

Files:

  • Create: src/tools/send.ts

  • Step 1: Create the file with schema and factory stub

import { stat } from "node:fs/promises";
import path from "node:path";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { InputFile } from "grammy";
import { type Static, Type } from "typebox";

export interface SendBotApi {
  sendPhoto(
    chatId: number,
    photo: InputFile,
    other?: { caption?: string },
  ): Promise<unknown>;
  sendDocument(
    chatId: number,
    document: InputFile,
    other?: { caption?: string },
  ): Promise<unknown>;
}

export interface SendToolOptions {
  bot: SendBotApi;
  chatId: number;
  workspaceDir: string;
}

export const sendParams = Type.Object({
  path: Type.String({
    description: "Workspace-relative path to the file to send.",
  }),
  kind: Type.Optional(
    Type.Union(
      [Type.Literal("auto"), Type.Literal("photo"), Type.Literal("document")],
      {
        description:
          "auto (default): photo for image/jpeg|png|webp|gif, document otherwise. photo/document: force that kind.",
      },
    ),
  ),
  caption: Type.Optional(
    Type.String({ description: "Optional caption shown under the attachment." }),
  ),
});

export type SendParams = Static<typeof sendParams>;

const PHOTO_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif"]);
const PHOTO_MAX_BYTES = 10 * 1024 * 1024;
const DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;

export function createSendTool(opts: SendToolOptions) {
  return defineTool({
    name: "send",
    label: "Send",
    description:
      "Send a file from the workspace to the owner as a Telegram attachment. Use for photos, screenshots, generated files, PDFs, or anything the owner should see or keep.",
    promptSnippet: "Send a workspace file to the owner as a Telegram attachment",
    parameters: sendParams,
    execute: async (_toolCallId, params) => {
      const text = await handleSend(params, opts);
      return { content: [{ type: "text", text }], details: {} };
    },
  });
}

export async function handleSend(params: SendParams, opts: SendToolOptions): Promise<string> {
  throw new Error("not implemented");
}
  • Step 2: Type-check compiles

Run: npm run typecheck Expected: PASS (no errors).

  • Step 3: Commit
git add src/tools/send.ts
git commit -m "Add send tool skeleton with schema and factory"

Task 2: Reject paths that escape the workspace

Files:

  • Create: src/tools/send.test.ts

  • Modify: src/tools/send.ts

  • Step 1: Write the failing test

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InputFile } from "grammy";
import { createSendTool, handleSend, type SendBotApi } from "./send.js";

describe("send tool", () => {
  let dir: string;
  let bot: { sendPhoto: ReturnType<typeof vi.fn>; sendDocument: ReturnType<typeof vi.fn> };
  let opts: { bot: SendBotApi; chatId: number; workspaceDir: string };

  beforeEach(async () => {
    dir = await mkdtemp(path.join(os.tmpdir(), "secretary-send-"));
    bot = { sendPhoto: vi.fn().mockResolvedValue({}), sendDocument: vi.fn().mockResolvedValue({}) };
    opts = { bot, chatId: 42, workspaceDir: dir };
  });
  afterEach(async () => {
    await rm(dir, { recursive: true, force: true });
  });

  it("rejects paths that escape the workspace", async () => {
    await expect(handleSend({ path: "../etc/passwd" }, opts)).rejects.toThrow(/escapes workspace/);
    await expect(handleSend({ path: "/etc/passwd" }, opts)).rejects.toThrow(/escapes workspace/);
    expect(bot.sendPhoto).not.toHaveBeenCalled();
    expect(bot.sendDocument).not.toHaveBeenCalled();
  });
});
  • Step 2: Run to verify it fails

Run: npx vitest run src/tools/send.test.ts Expected: FAIL with "not implemented" (the current stub) or type/import issue if the file is empty.

  • Step 3: Add the path-resolution guard to handleSend

Replace the stub body of handleSend in src/tools/send.ts:

export async function handleSend(params: SendParams, opts: SendToolOptions): Promise<string> {
  const abs = path.resolve(opts.workspaceDir, params.path);
  const root = opts.workspaceDir.endsWith(path.sep) ? opts.workspaceDir : opts.workspaceDir + path.sep;
  if (abs !== opts.workspaceDir && !abs.startsWith(root)) {
    throw new Error(`path escapes workspace: ${params.path}`);
  }
  throw new Error("not implemented");
}
  • Step 4: Run to verify it passes

Run: npx vitest run src/tools/send.test.ts Expected: the "rejects paths that escape" test PASSES. Other unwritten tests don't exist yet.

  • Step 5: Commit
git add src/tools/send.ts src/tools/send.test.ts
git commit -m "Reject send paths that escape the workspace"

Task 3: Reject missing files and directory paths

Files:

  • Modify: src/tools/send.test.ts

  • Modify: src/tools/send.ts

  • Step 1: Add failing tests

Append inside the describe("send tool", …) block in src/tools/send.test.ts:

  it("throws when the file does not exist", async () => {
    await expect(handleSend({ path: "nope.png" }, opts)).rejects.toThrow(/file not found: nope\.png/);
  });

  it("throws when the path is a directory", async () => {
    const { mkdir } = await import("node:fs/promises");
    await mkdir(path.join(dir, "sub"));
    await expect(handleSend({ path: "sub" }, opts)).rejects.toThrow(/path is a directory, not a file: sub/);
  });
  • Step 2: Run to verify they fail

Run: npx vitest run src/tools/send.test.ts Expected: two new tests FAIL with "not implemented".

  • Step 3: Add stat + directory checks

Replace the tail of handleSend in src/tools/send.ts (the trailing throw new Error("not implemented");) with:

  let s;
  try {
    s = await stat(abs);
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === "ENOENT") {
      throw new Error(`file not found: ${params.path}`);
    }
    throw err;
  }
  if (s.isDirectory()) throw new Error(`path is a directory, not a file: ${params.path}`);
  throw new Error("not implemented");
  • Step 4: Run to verify they pass

Run: npx vitest run src/tools/send.test.ts Expected: three tests PASS (traversal, missing, directory). Anything else still throws "not implemented" and isn't asserted yet.

  • Step 5: Commit
git add src/tools/send.ts src/tools/send.test.ts
git commit -m "Reject missing files and directory paths in send"

Task 4: Send a photo when the extension is an image

Files:

  • Modify: src/tools/send.test.ts

  • Modify: src/tools/send.ts

  • Step 1: Add failing test

Append inside the describe block:

  it("sends a photo when kind is auto and the extension is an image", async () => {
    await writeFile(path.join(dir, "hello.png"), Buffer.from("fake-png"));
    const text = await handleSend({ path: "hello.png", caption: "hi" }, opts);
    expect(text).toBe("Sent hello.png as photo.");
    expect(bot.sendPhoto).toHaveBeenCalledTimes(1);
    const [chatId, inputFile, other] = bot.sendPhoto.mock.calls[0];
    expect(chatId).toBe(42);
    expect(inputFile).toBeInstanceOf(InputFile);
    expect(other).toEqual({ caption: "hi" });
    expect(bot.sendDocument).not.toHaveBeenCalled();
  });
  • Step 2: Run to verify it fails

Run: npx vitest run src/tools/send.test.ts Expected: this new test FAILs with "not implemented".

  • Step 3: Implement the send branch

Replace the trailing throw new Error("not implemented"); in handleSend with:

  const kind = resolveKind(params.kind, abs);
  const other = params.caption !== undefined ? { caption: params.caption } : undefined;
  const inputFile = new InputFile(abs);
  if (kind === "photo") {
    if (s.size > PHOTO_MAX_BYTES) {
      throw new Error(
        `photo too large: ${mb(s.size)} MB > 10 MB (Telegram limit). Use kind:"document" or downscale.`,
      );
    }
    await opts.bot.sendPhoto(opts.chatId, inputFile, other);
  } else {
    if (s.size > DOCUMENT_MAX_BYTES) {
      throw new Error(`document too large: ${mb(s.size)} MB > 50 MB (Telegram limit).`);
    }
    await opts.bot.sendDocument(opts.chatId, inputFile, other);
  }
  return `Sent ${params.path} as ${kind}.`;
}

function resolveKind(kind: SendParams["kind"], abs: string): "photo" | "document" {
  if (kind === "photo") return "photo";
  if (kind === "document") return "document";
  return PHOTO_EXTS.has(path.extname(abs).toLowerCase()) ? "photo" : "document";
}

function mb(bytes: number): string {
  return (bytes / (1024 * 1024)).toFixed(1);
}
  • Step 4: Run to verify it passes

Run: npx vitest run src/tools/send.test.ts Expected: four tests PASS.

  • Step 5: Commit
git add src/tools/send.ts src/tools/send.test.ts
git commit -m "Send photos through sendPhoto with auto-detection"

Task 5: Route non-image extensions to sendDocument

Files:

  • Modify: src/tools/send.test.ts

  • Step 1: Add failing test

Append inside the describe block:

  it("sends a document when kind is auto and the extension is not an image", async () => {
    await writeFile(path.join(dir, "report.pdf"), Buffer.from("%PDF-1.4"));
    const text = await handleSend({ path: "report.pdf" }, opts);
    expect(text).toBe("Sent report.pdf as document.");
    expect(bot.sendDocument).toHaveBeenCalledTimes(1);
    const [chatId, inputFile, other] = bot.sendDocument.mock.calls[0];
    expect(chatId).toBe(42);
    expect(inputFile).toBeInstanceOf(InputFile);
    expect(other).toBeUndefined();
    expect(bot.sendPhoto).not.toHaveBeenCalled();
  });
  • Step 2: Run to verify it passes without further code changes

Run: npx vitest run src/tools/send.test.ts Expected: PASS. resolveKind from Task 4 already handles this. If it fails, the branch above is wrong — fix it before continuing.

  • Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test document routing for non-image extensions"

Task 6: Honor explicit kind overrides

Files:

  • Modify: src/tools/send.test.ts

  • Step 1: Add failing test

Append inside the describe block:

  it("honors an explicit kind override", async () => {
    await writeFile(path.join(dir, "hello.png"), Buffer.from("fake-png"));
    await writeFile(path.join(dir, "report.pdf"), Buffer.from("%PDF-1.4"));

    await handleSend({ path: "hello.png", kind: "document" }, opts);
    expect(bot.sendDocument).toHaveBeenCalledTimes(1);
    expect(bot.sendPhoto).not.toHaveBeenCalled();

    await handleSend({ path: "report.pdf", kind: "photo" }, opts);
    expect(bot.sendPhoto).toHaveBeenCalledTimes(1);
    expect(bot.sendDocument).toHaveBeenCalledTimes(1);
  });
  • Step 2: Run to verify it passes without further code changes

Run: npx vitest run src/tools/send.test.ts Expected: PASS. If it fails, resolveKind isn't respecting the explicit override — fix it.

  • Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test explicit kind override for send"

Task 7: Reject oversized photos and documents

Files:

  • Modify: src/tools/send.test.ts

  • Step 1: Add failing tests

Append inside the describe block:

  it("rejects photos over 10 MB", async () => {
    const { open } = await import("node:fs/promises");
    const fh = await open(path.join(dir, "big.png"), "w");
    try {
      await fh.truncate(11 * 1024 * 1024);
    } finally {
      await fh.close();
    }
    await expect(handleSend({ path: "big.png" }, opts)).rejects.toThrow(/photo too large: 11\.0 MB > 10 MB/);
    expect(bot.sendPhoto).not.toHaveBeenCalled();
  });

  it("rejects documents over 50 MB", async () => {
    const { open } = await import("node:fs/promises");
    const fh = await open(path.join(dir, "big.bin"), "w");
    try {
      await fh.truncate(51 * 1024 * 1024);
    } finally {
      await fh.close();
    }
    await expect(handleSend({ path: "big.bin" }, opts)).rejects.toThrow(/document too large: 51\.0 MB > 50 MB/);
    expect(bot.sendDocument).not.toHaveBeenCalled();
  });
  • Step 2: Run to verify they pass

Run: npx vitest run src/tools/send.test.ts Expected: PASS. The size checks from Task 4 already cover this. If either fails, revisit the size branch in handleSend.

  • Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test oversize photo/document rejection"

Task 8: Test the Pi tool wrapper end-to-end

Files:

  • Modify: src/tools/send.test.ts

  • Step 1: Add failing test

Append inside the describe block:

  it("exposes a Pi tool that returns text content", async () => {
    await writeFile(path.join(dir, "hello.png"), Buffer.from("fake-png"));
    const tool = createSendTool(opts);
    expect(tool.name).toBe("send");
    const result = await tool.execute("call-1", { path: "hello.png" }, undefined, undefined, undefined as never);
    expect(result.content).toEqual([{ type: "text", text: "Sent hello.png as photo." }]);
    expect(bot.sendPhoto).toHaveBeenCalledTimes(1);
  });
  • Step 2: Run to verify it passes

Run: npx vitest run src/tools/send.test.ts Expected: PASS. Nine tests total in the file.

  • Step 3: Commit
git add src/tools/send.test.ts
git commit -m "Test Pi tool wrapper for send"

Task 9: Teach the system prompt about the send tool

Files:

  • Modify: src/agent/system-prompt.ts

  • Modify: src/agent/system-prompt.test.ts

  • Step 1: Add the failing assertion

Edit src/agent/system-prompt.test.ts, inside the existing describe("secretarySystemPrompt", …)'s only it(...) block, add one more assertion at the end:

    expect(p).toContain("send");
    expect(p).toContain("Sending files");
  • Step 2: Run to verify it fails

Run: npx vitest run src/agent/system-prompt.test.ts Expected: FAIL — the string "Sending files" isn't in the prompt yet.

  • Step 3: Append the section to the prompt

Edit src/agent/system-prompt.ts. Extend the returned template literal in secretarySystemPrompt by appending, right before the closing backtick, this section (mind the leading \n\n — the existing template ends without a trailing newline):

- After creating a job, confirm it to the owner in one line including the next fire time.

## Sending files
- To send a file or image to the owner, use the \`send\` tool with a workspace-relative path.
- \`kind\` defaults to \`auto\` (photos for image extensions, documents otherwise); override with \`kind:"document"\` or \`kind:"photo"\` when needed.
- Prefer \`send\` over pasting long text when the owner asked for a file, a screenshot, or a rendered report.
- The attachment is separate from your final text reply — a one-line confirmation ("Sent, see above.") is enough.

Concretely: replace the current final bullet in the source file

- After creating a job, confirm it to the owner in one line including the next fire time.`;

with

- After creating a job, confirm it to the owner in one line including the next fire time.

## Sending files
- To send a file or image to the owner, use the \`send\` tool with a workspace-relative path.
- \`kind\` defaults to \`auto\` (photos for image extensions, documents otherwise); override with \`kind:"document"\` or \`kind:"photo"\` when needed.
- Prefer \`send\` over pasting long text when the owner asked for a file, a screenshot, or a rendered report.
- The attachment is separate from your final text reply — a one-line confirmation ("Sent, see above.") is enough.`;
  • Step 4: Run to verify it passes

Run: npx vitest run src/agent/system-prompt.test.ts Expected: PASS.

  • Step 5: Commit
git add src/agent/system-prompt.ts src/agent/system-prompt.test.ts
git commit -m "Document the send tool in the system prompt"

Task 10: Wire the send tool into main.ts

Files:

  • Modify: src/main.ts

  • Step 1: Add the import

Edit src/main.ts. Under the existing import { createScheduleTool } from "./tools/schedule.js"; add:

import { createSendTool } from "./tools/send.js";
  • Step 2: Register the tool alongside schedule

Immediately after the line

  const customTools: CustomTools = [createScheduleTool(scheduler, config.timezone)];

add:

  customTools.push(createSendTool({
    bot: bot.api,
    chatId: config.telegramOwnerId,
    workspaceDir: config.workspaceDir,
  }));
  • Step 3: Type-check compiles

Run: npm run typecheck Expected: PASS. If bot.api doesn't satisfy SendBotApi (grammy's Api return types are broader than the interface), the compiler will point at the exact incompatibility — narrow SendBotApi return types to unknown (already done in Task 1) if it complains again.

  • Step 4: Run the full test suite

Run: npm test Expected: PASS. No behavioural regressions in existing tests; the new send tests are green.

  • Step 5: Commit
git add src/main.ts
git commit -m "Wire send tool into main.ts"

Task 11: Document the tool in the README

Files:

  • Modify: README.md

  • Step 1: Add a "Sending files" line

Edit README.md. After the "Talking to it" section's second paragraph (the one ending "you get a 👀 reaction."), add a new paragraph:

It can also send files back. When you ask for a screenshot, a generated file, or a rendered report, the agent uses its `send` tool to attach the file directly — photos appear inline, other files arrive as documents.
  • Step 2: Add a manual E2E item

In the "Manual end-to-end checklist" section, append a new numbered item after the current #17:

18. Ask "write a short poem to poem.txt and send it to me" → a `.txt` document attachment arrives with the poem inside, and the reply confirms with one line.
  • Step 3: Commit
git add README.md
git commit -m "Document the send tool in the README"

Task 12: Final verification

Files: none (verification only).

  • Step 1: Run the full test suite

Run: npm test Expected: all tests PASS, including the ~9 new ones in src/tools/send.test.ts and the extended system-prompt assertion.

  • Step 2: Build

Run: npm run build Expected: PASS. dist/tools/send.js exists.

  • Step 3: Sanity-check the git log

Run: git log --oneline -12 Expected: 11 commits from tasks 1–11, one per commit, in order, no stray "wip" / "fix" fixups.