From 90bcb6fd1f8349238dba4f30cb78de2c7fec3ad7 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Tue, 22 Sep 2026 08:15:37 +0000 Subject: [PATCH] Implementation plan for reply and reply-with-quote context --- .../plans/2026-09-22-reply-context.md | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-22-reply-context.md diff --git a/docs/superpowers/plans/2026-09-22-reply-context.md b/docs/superpowers/plans/2026-09-22-reply-context.md new file mode 100644 index 0000000..f2ea11f --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-reply-context.md @@ -0,0 +1,516 @@ +# Reply and quoted-reply context 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:** When the owner replies to a Telegram message (or "reply with quote"s a substring), inject a `[Reply to your message: "…"]`-style header into the agent's next turn so it knows what's being answered. + +**Architecture:** Extend the `InboundMessage` and `InboundDeps` shapes in `src/telegram/inbound.ts` to carry `reply_to_message`, `quote`, and `botUserId`. Add a pure `buildReplyHeader(reply, quote, botUserId)` helper in the same file that returns the header line (or `undefined`). Compose the header ahead of the existing forward header when both are present. Thread `bot.botInfo?.id` from `bridge.ts` into the deps. No changes to the debouncer, outbound, or agent host. + +**Tech Stack:** TypeScript, Node 24, `grammy` (Telegram), `vitest`. + +**Design spec:** `docs/superpowers/specs/2026-09-22-reply-context-design.md` + +--- + +## File Structure + +- **Modify** `src/telegram/inbound.ts` — extend `InboundMessage` and `InboundDeps`, add `RepliedMessage` and `Quote` types, add `buildReplyHeader()` helper, prepend the header before the forward header when composing the final text. +- **Modify** `src/telegram/inbound.test.ts` — new tests for every branch of the header helper and the composition ordering. +- **Modify** `src/telegram/bridge.ts` — pass `botUserId: bot.botInfo?.id` into `normalizeMessage` deps. +- **Modify** `README.md` — one bullet under "Talking to it", one new manual E2E item #19. + +The header helper is small enough that a separate file would be premature — it lives alongside `describeForwardOrigin`, which is exactly the same shape and function. + +--- + +## Task 1: Add types and a truncation helper + +**Files:** +- Modify: `src/telegram/inbound.ts` + +- [ ] **Step 1: Add `RepliedMessage`, `Quote`, and `MAX_REPLY_CONTEXT_CHARS` at the top of the file, next to `ForwardOrigin`** + +Insert after the existing `ForwardOrigin` union (around `inbound.ts:10`): + +```ts +export interface RepliedMessage { + from?: { id: number; is_bot?: boolean }; + text?: string; + caption?: string; + photo?: Array; + voice?: unknown; + audio?: { file_name?: string }; + document?: { file_name?: string }; +} + +export interface Quote { + text: string; + is_manual?: boolean; +} + +/** Longest replied-to text (or quote) we inline into the header before truncating with an ellipsis. */ +export const MAX_REPLY_CONTEXT_CHARS = 500; +``` + +- [ ] **Step 2: Extend `InboundMessage` with `reply_to_message` and `quote`** + +Change `inbound.ts`'s existing `InboundMessage` interface to add two optional fields at the end: + +```ts +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 }; + forward_origin?: ForwardOrigin; + reply_to_message?: RepliedMessage; + quote?: Quote; +} +``` + +- [ ] **Step 3: Extend `InboundDeps` with `botUserId`** + +Change the existing `InboundDeps` interface to add one optional field: + +```ts +export interface InboundDeps { + download(fileId: string): Promise<{ bytes: Buffer; filePath: string }>; + transcriber?: Transcriber; + workspaceDir: string; + now?: () => Date; + /** The bot's own user id, for labelling replies to bot vs owner messages. */ + botUserId?: number; +} +``` + +- [ ] **Step 4: Typecheck** + +Run: `npm run typecheck` +Expected: exits 0 (no `noEmit` errors). If it fails, the shape of an existing test's `InboundMessage`/`InboundDeps` object is now under-typed — but every new field is optional so this should pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/telegram/inbound.ts +git commit -m "Add reply, quote, and botUserId types to inbound" +``` + +--- + +## Task 2: Write and pass tests for `buildReplyHeader` + +**Files:** +- Modify: `src/telegram/inbound.ts` +- Modify: `src/telegram/inbound.test.ts` + +- [ ] **Step 1: Add the failing tests for the header helper** + +Append to `src/telegram/inbound.test.ts` after the last `describe(...)` block (i.e. at the end of the file). This starts a new `describe` block so it can co-exist with the existing `saveToInbox` block: + +```ts +describe("buildReplyHeader", () => { + const BOT_ID = 42; + + it("returns undefined when there is no reply and no quote", () => { + expect(buildReplyHeader(undefined, undefined, BOT_ID)).toBeUndefined(); + }); + + it("labels a plain reply to a bot message with 'your message'", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, text: "Which file?" }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "Which file?"]'); + }); + + it("labels a plain reply to the owner's own earlier message", () => { + const header = buildReplyHeader({ from: { id: 99, is_bot: false }, text: "Buy milk" }, undefined, BOT_ID); + expect(header).toBe('[Reply to your own earlier message: "Buy milk"]'); + }); + + it("falls back to 'the earlier message' when `from` is absent", () => { + const header = buildReplyHeader({ text: "hello" }, undefined, BOT_ID); + expect(header).toBe('[Reply to the earlier message: "hello"]'); + }); + + it("uses the quote text and 'quoting your message' wording for a reply-with-quote to the bot", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, text: "The full assistant answer with lots of context." }, + { text: "assistant answer" }, + BOT_ID, + ); + expect(header).toBe('[Reply quoting your message: "assistant answer"]'); + }); + + it("uses 'quoting your own earlier message' when the source is the owner's own", () => { + const header = buildReplyHeader( + { from: { id: 99, is_bot: false }, text: "shopping list" }, + { text: "shopping" }, + BOT_ID, + ); + expect(header).toBe('[Reply quoting your own earlier message: "shopping"]'); + }); + + it("treats an empty quote.text as if there were no quote (falls through to plain reply)", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, text: "The answer" }, + { text: "" }, + BOT_ID, + ); + expect(header).toBe('[Reply to your message: "The answer"]'); + }); + + it("describes a photo-only replied-to message", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, photo: [{}] }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(photo)"]'); + }); + + it("describes a voice-only replied-to message", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, voice: {} }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(voice message)"]'); + }); + + it("describes a document with a filename", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, document: { file_name: "notes.txt" } }, + undefined, + BOT_ID, + ); + expect(header).toBe('[Reply to your message: "(document: notes.txt)"]'); + }); + + it("describes an unnamed document", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, document: {} }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(document)"]'); + }); + + it("uses the caption instead of the media descriptor when both are present", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, photo: [{}], caption: "the sunset" }, + undefined, + BOT_ID, + ); + expect(header).toBe('[Reply to your message: "the sunset"]'); + }); + + it("truncates replied-to text longer than 500 characters with an ellipsis", () => { + const long = "a".repeat(600); + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, text: long }, undefined, BOT_ID); + expect(header).toBe(`[Reply to your message: "${"a".repeat(499)}…"]`); + }); + + it("truncates a very long quote in the same way", () => { + const long = "b".repeat(600); + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, text: "irrelevant" }, + { text: long }, + BOT_ID, + ); + expect(header).toBe(`[Reply quoting your message: "${"b".repeat(499)}…"]`); + }); + + it("labels as 'your message' when botUserId is missing but is_bot is true", () => { + const header = buildReplyHeader({ from: { id: 0, is_bot: true }, text: "hi" }, undefined, undefined); + expect(header).toBe('[Reply to your message: "hi"]'); + }); + + it("falls back to '(message)' when the replied-to message has no usable fields", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true } }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(message)"]'); + }); +}); +``` + +Also update the top-of-file import to pull in `buildReplyHeader` and `MAX_REPLY_CONTEXT_CHARS` (the constant is exported so tests can reference the boundary if needed, though this batch only uses the function): + +```ts +import { buildReplyHeader, type InboundDeps, normalizeMessage, saveToInbox } from "./inbound.js"; +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `npx vitest run src/telegram/inbound.test.ts` +Expected: FAIL — the tests reference `buildReplyHeader` which doesn't exist yet. Error like "buildReplyHeader is not a function" or a TS compilation error. + +- [ ] **Step 3: Implement `buildReplyHeader` in `src/telegram/inbound.ts`** + +Add at the end of `src/telegram/inbound.ts`, after the existing `errorText` helper: + +```ts +/** Builds the `[Reply ...]` header line for the agent, or returns undefined when there is nothing to reply to. */ +export function buildReplyHeader( + reply: RepliedMessage | undefined, + quote: Quote | undefined, + botUserId: number | undefined, +): string | undefined { + if (!reply) return undefined; + const isFromBot = + reply.from !== undefined && + (botUserId !== undefined ? reply.from.id === botUserId : reply.from.is_bot === true); + const isFromOwner = reply.from !== undefined && !isFromBot; + const quoted = quote?.text ?? ""; + if (quoted.length > 0) { + const who = isFromBot + ? "your message" + : isFromOwner + ? "your own earlier message" + : "the earlier message"; + return `[Reply quoting ${who}: "${truncateReplyContext(quoted)}"]`; + } + const who = isFromBot + ? "your message" + : isFromOwner + ? "your own earlier message" + : "the earlier message"; + const content = truncateReplyContext(replyContent(reply)); + return `[Reply to ${who}: "${content}"]`; +} + +function replyContent(reply: RepliedMessage): string { + if (reply.text && reply.text.length > 0) return reply.text; + if (reply.caption && reply.caption.length > 0) return reply.caption; + if (reply.photo && reply.photo.length > 0) return "(photo)"; + if (reply.voice) return "(voice message)"; + if (reply.audio) return reply.audio.file_name ? `(audio: ${reply.audio.file_name})` : "(audio)"; + if (reply.document) return reply.document.file_name ? `(document: ${reply.document.file_name})` : "(document)"; + return "(message)"; +} + +function truncateReplyContext(text: string): string { + const chars = Array.from(text); + if (chars.length <= MAX_REPLY_CONTEXT_CHARS) return text; + return `${chars.slice(0, MAX_REPLY_CONTEXT_CHARS - 1).join("")}…`; +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `npx vitest run src/telegram/inbound.test.ts` +Expected: PASS — all new `buildReplyHeader` cases green, existing `normalizeMessage` and `saveToInbox` cases still green. + +- [ ] **Step 5: Commit** + +```bash +git add src/telegram/inbound.ts src/telegram/inbound.test.ts +git commit -m "Add buildReplyHeader helper with truncation and media descriptors" +``` + +--- + +## Task 3: Compose the reply header into `normalizeMessage` output + +**Files:** +- Modify: `src/telegram/inbound.ts` +- Modify: `src/telegram/inbound.test.ts` + +- [ ] **Step 1: Add failing integration tests to `normalizeMessage`** + +Insert new test cases inside the existing `describe("normalizeMessage", ...)` block in `src/telegram/inbound.test.ts`, after the last existing case (the "labels forwarded photos even when there is no caption" test) but before the block closes: + +```ts + it("prepends a reply header when the user replies to a bot message", async () => { + const r = await normalizeMessage( + { + text: "workspace/foo.csv", + reply_to_message: { from: { id: 42, is_bot: true }, text: "Which file did you mean?" }, + }, + deps({ botUserId: 42 }), + ); + expect(r.input?.text).toBe('[Reply to your message: "Which file did you mean?"]\nworkspace/foo.csv'); + }); + + it("uses 'your own earlier message' when replying to the owner's own message", async () => { + const r = await normalizeMessage( + { + text: "still true", + reply_to_message: { from: { id: 99, is_bot: false }, text: "the dog's name is Rex" }, + }, + deps({ botUserId: 42 }), + ); + expect(r.input?.text).toBe('[Reply to your own earlier message: "the dog\'s name is Rex"]\nstill true'); + }); + + it("uses the quote wording and text on reply-with-quote", async () => { + const r = await normalizeMessage( + { + text: "what does this mean?", + reply_to_message: { from: { id: 42, is_bot: true }, text: "long assistant explanation" }, + quote: { text: "geothermal power plant" }, + }, + deps({ botUserId: 42 }), + ); + expect(r.input?.text).toBe('[Reply quoting your message: "geothermal power plant"]\nwhat does this mean?'); + }); + + it("puts the reply header before the forward header when both are present", async () => { + const r = await normalizeMessage( + { + text: "this one", + reply_to_message: { from: { id: 42, is_bot: true }, text: "which article?" }, + forward_origin: { type: "channel", chat: { title: "Daily News" } }, + }, + deps({ botUserId: 42 }), + ); + expect(r.input?.text).toBe('[Reply to your message: "which article?"]\n[Forwarded from Daily News]\nthis one'); + }); + + it("keeps the reply header on a photo-only forwarded reply", async () => { + const r = await normalizeMessage( + { + photo: [{ file_id: "p2", width: 1, height: 1 }], + reply_to_message: { from: { id: 42, is_bot: true }, text: "which of these?" }, + forward_origin: { type: "user", sender_user: { first_name: "Alice" } }, + }, + deps({ botUserId: 42 }), + ); + expect(r.input?.text).toBe( + '[Reply to your message: "which of these?"]\n[Forwarded from Alice]\n(photo attached)', + ); + }); + + it("adds a reply header on a photo-only reply with no body text", async () => { + const r = await normalizeMessage( + { + photo: [{ file_id: "p2", width: 1, height: 1 }], + reply_to_message: { from: { id: 42, is_bot: true }, text: "which of these?" }, + }, + deps({ botUserId: 42 }), + ); + expect(r.input?.text).toBe('[Reply to your message: "which of these?"]\n(photo attached)'); + }); +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `npx vitest run src/telegram/inbound.test.ts` +Expected: FAIL on the new `normalizeMessage` cases — output still equals the body without the header, e.g. actual `"workspace/foo.csv"` vs expected `"[Reply to your message: \"Which file did you mean?\"]\nworkspace/foo.csv"`. + +- [ ] **Step 3: Wire the header into `normalizeMessage`** + +Modify the tail of `normalizeMessage` in `src/telegram/inbound.ts`. Locate the existing forward-header block: + +```ts + if (parts.length === 0 && images.length > 0) parts.push("(photo attached)"); + let text = parts.join("\n\n"); + if (msg.forward_origin && (text || images.length > 0)) { + const label = describeForwardOrigin(msg.forward_origin); + text = text ? `[Forwarded from ${label}]\n${text}` : `[Forwarded from ${label}]`; + } + return { + input: text ? { text, images: images.length > 0 ? images : undefined } : null, + transcript, + notes, + }; +``` + +Replace with: + +```ts + if (parts.length === 0 && images.length > 0) parts.push("(photo attached)"); + let text = parts.join("\n\n"); + if (msg.forward_origin && (text || images.length > 0)) { + const label = describeForwardOrigin(msg.forward_origin); + text = text ? `[Forwarded from ${label}]\n${text}` : `[Forwarded from ${label}]`; + } + const replyHeader = buildReplyHeader(msg.reply_to_message, msg.quote, deps.botUserId); + if (replyHeader && (text || images.length > 0)) { + text = text ? `${replyHeader}\n${text}` : replyHeader; + } + return { + input: text ? { text, images: images.length > 0 ? images : undefined } : null, + transcript, + notes, + }; +``` + +The reply header is prepended after the forward header is prepended — so when both are set, the string ends up `[Reply ...]\n[Forwarded ...]\n`, which matches the ordering the tests expect. + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `npx vitest run src/telegram/inbound.test.ts` +Expected: PASS — all new `normalizeMessage` cases green, plus every existing case still green. + +- [ ] **Step 5: Commit** + +```bash +git add src/telegram/inbound.ts src/telegram/inbound.test.ts +git commit -m "Prepend reply header ahead of forward header in normalizeMessage" +``` + +--- + +## Task 4: Thread `botUserId` from `bridge.ts` + +**Files:** +- Modify: `src/telegram/bridge.ts` + +- [ ] **Step 1: Pass `bot.botInfo?.id` into the deps** + +Locate the `bot.on("message", ...)` handler in `src/telegram/bridge.ts` (around `bridge.ts:110-129`). Modify the `normalizeMessage` call: + +```ts + const result = await normalizeMessage(ctx.message, { + download: (fileId) => downloadFile(bot, config.telegramBotToken, fileId, deps.fetchFn), + transcriber: deps.transcriber, + workspaceDir: config.workspaceDir, + botUserId: bot.botInfo?.id, + }); +``` + +`bot.botInfo` is populated by grammy once `bot.init()` (or `bot.start()`) has run. By the time this handler fires, both are done. The optional chaining plus the `is_bot` fallback in `buildReplyHeader` keep things safe if somehow it isn't. + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +- [ ] **Step 3: Run the full test suite** + +Run: `npm test` +Expected: all tests pass, including the ones added in Tasks 2 and 3. + +- [ ] **Step 4: Commit** + +```bash +git add src/telegram/bridge.ts +git commit -m "Pass bot user id into normalizeMessage for reply labelling" +``` + +--- + +## Task 5: README updates + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add the "Talking to it" bullet** + +In `README.md`, find the "Talking to it" section (around README.md:17-25). After the paragraph starting "It can also send files back", add: + +```markdown +Reply to any of my messages (or "reply with quote" a specific part) and I'll see what you're answering to — useful for coming back to something from earlier in the day. +``` + +- [ ] **Step 2: Add a manual E2E item** + +At the end of the "Manual end-to-end checklist" list, after item 18, add: + +```markdown +19. Reply to one of the bot's earlier messages with a follow-up like "what did you mean by that?" → the answer references that specific message, not the most recent one. Then "reply with quote" a specific phrase from a bot message → the answer references the quoted phrase. +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "Document reply / reply-with-quote support in the README" +``` + +--- + +## Self-review notes (for the executor) + +- Every task above ends in a green test run or a typecheck plus a commit — no batching. +- The types added in Task 1 (`RepliedMessage`, `Quote`) are the exact same shapes the tests in Task 2 rely on. The `botUserId` field type on `InboundDeps` matches the value we thread through in Task 4 (`bot.botInfo?.id` returns `number | undefined`). +- The header helper name is stable across all tasks (`buildReplyHeader`). No aliasing. +- Truncation constant is a single exported symbol (`MAX_REPLY_CONTEXT_CHARS`) referenced from one place; tests use the numeric value directly, so a future change to the constant only requires updating the corresponding assertions. +- Ordering: forward header is prepended first, reply header second, resulting in the `[Reply ...]\n[Forwarded ...]\n` order the tests expect.