diff --git a/apps/slack-bot/src/index.js b/apps/slack-bot/src/index.js index 5d84949..316b492 100644 --- a/apps/slack-bot/src/index.js +++ b/apps/slack-bot/src/index.js @@ -23,6 +23,7 @@ import { createBlogPost } from './services/post-creator.js'; import { mergeBlogPost } from './services/pr-merger.js'; import { slackReply } from './services/slack-reply.js'; import { getThreadContext, setThreadContext } from './services/thread-context.js'; +import { getLatestIdeaBatch } from './services/github.js'; const app = new Hono(); @@ -90,6 +91,32 @@ async function handleMessage(event, env) { return; } + // Allow referencing an idea by number from the most recent Looper batch + // (e.g. "#3 ...", "for #1 ...", "idea 2 ..."). + const numberedCtx = await resolveNumberedIdea(cleanText, env); + if (numberedCtx) { + const intent = { + action: 'update_blog_post', + summary: `Revise "${numberedCtx.title}": ${numberedCtx.instructions.slice(0, 80)}`, + blogInstructions: numberedCtx.instructions, + }; + const result = await updateBlogPost(intent, env, numberedCtx); + await slackReply( + channel, replyThread, + `✅ Updated #${numberedCtx.number} — *${numberedCtx.title}*. <${result.commitUrl}|View commit> · <${numberedCtx.prUrl}|PR>`, + env + ); + await postContent(channel, replyThread, numberedCtx.title, result.content, [], env); + // Persist context so follow-ups in this thread don't need the number again + await setThreadContext(replyThread, { + branch: numberedCtx.branch, + filePath: numberedCtx.filePath, + prUrl: numberedCtx.prUrl, + title: numberedCtx.title, + }, env); + return; + } + // No thread context — parse intent normally const intent = await parseIntent(cleanText, env); @@ -207,4 +234,35 @@ async function postContent(channel, threadTs, title, content, researchNeeded, en } } +// ── #N idea reference resolver ──────────────────────────────────────────────── +// Matches messages that lead with a numeric reference to an idea from the most +// recent Looper batch. Returns full context (branch, file, PR, title) plus the +// stripped instructions, or null if no number ref was found / no batch exists. +async function resolveNumberedIdea(text, env) { + const re = /^\s*(?:(?:on|for|re|about)\s+)?(?:#|idea\s+|number\s+|no\.?\s*)(\d{1,2})\b[\s,.:;\-—)]*/i; + const m = text.match(re); + if (!m) return null; + const n = parseInt(m[1], 10); + if (n < 1 || n > 10) return null; + + const batch = await getLatestIdeaBatch(env.DOTCOM_REPO, env.GITHUB_TOKEN); + if (batch.length === 0 || n > batch.length) return null; + + const pr = batch[n - 1]; + const branch = pr.head.ref; + const slug = branch.replace(/^blog\//, ''); + const filePath = `${env.BLOG_CONTENT_PATH}/${slug}.md`; + const instructions = text.slice(m[0].length).trim(); + if (!instructions) return null; // bare "#3" with no edit guidance + + return { + number: n, + branch, + filePath, + prUrl: pr.html_url, + title: pr.title, + instructions, + }; +} + export default app; diff --git a/apps/slack-bot/src/services/github.js b/apps/slack-bot/src/services/github.js index 79bb3a5..18adf52 100644 --- a/apps/slack-bot/src/services/github.js +++ b/apps/slack-bot/src/services/github.js @@ -154,6 +154,26 @@ export async function listPRs(repo, token, headPrefix) { return data; } +/** + * Return the latest editorial-loop batch of draft blog PRs, ordered to match + * the numbered Slack post (idea 1 first). Groups by the YYYY-MM-DD prefix + * embedded in the `blog/-` branch name and picks the most recent + * date, then sorts that group by PR creation time ascending. + */ +export async function getLatestIdeaBatch(repo, token) { + const prs = await ghFetch(`/repos/${repo}/pulls?state=open&per_page=50`, {}, token); + const dateOf = (pr) => { + const m = pr.head.ref.match(/^blog\/(\d{4}-\d{2}-\d{2})-/); + return m ? m[1] : null; + }; + const drafts = prs.filter(pr => pr.draft && dateOf(pr)); + if (drafts.length === 0) return []; + const latestDate = drafts.map(dateOf).sort().pop(); + return drafts + .filter(pr => dateOf(pr) === latestDate) + .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); +} + /** * Merge a PR by number (squash). */ diff --git a/apps/slack-bot/src/services/intent-parser.js b/apps/slack-bot/src/services/intent-parser.js index f0d88e4..1fed06f 100644 --- a/apps/slack-bot/src/services/intent-parser.js +++ b/apps/slack-bot/src/services/intent-parser.js @@ -25,7 +25,7 @@ export async function parseIntent(userText, env) { tools: { parse_intent: tool({ description: 'Parse the user\'s editorial feedback into a structured action plan', - parameters: z.object({ + inputSchema: z.object({ action: z.enum([ 'update_voice_profile', 'update_blog_post', @@ -80,5 +80,5 @@ For blogInstructions, synthesize their intent into clear instructions — do not }], }); - return toolCalls[0]?.args ?? { action: 'unclear', summary: userText }; + return toolCalls[0]?.input ?? { action: 'unclear', summary: userText }; } diff --git a/apps/slack-bot/src/services/post-creator.js b/apps/slack-bot/src/services/post-creator.js index bc1588c..175098a 100644 --- a/apps/slack-bot/src/services/post-creator.js +++ b/apps/slack-bot/src/services/post-creator.js @@ -18,7 +18,7 @@ * messages in the same Slack thread auto-route to this post. */ -import { generateText, tool } from 'ai'; +import { generateText, tool, stepCountIs } from 'ai'; import { createAnthropic } from '@ai-sdk/anthropic'; import { z } from 'zod'; import { getRef, createBranch, createFileOnBranch, createPR, listDir, readFile } from './github.js'; @@ -42,7 +42,7 @@ export async function createBlogPost(intent, threadTs, env) { const tools = { web_search: tool({ description: 'Search the web for facts, examples, data, or recent context. Keep queries specific and technical. Use sparingly — 2-3 searches max.', - parameters: z.object({ + inputSchema: z.object({ query: z.string().describe('A focused, specific search query.'), }), execute: async ({ query }) => tavilySearch(query, env.TAVILY_API_KEY), @@ -50,7 +50,7 @@ export async function createBlogPost(intent, threadTs, env) { read_existing_post: tool({ description: 'Read an existing blog post to calibrate voice, depth, and to avoid repeating covered ground.', - parameters: z.object({ + inputSchema: z.object({ filename: z.string().describe('Filename from the existing posts list.'), }), execute: async ({ filename }) => { @@ -70,7 +70,7 @@ export async function createBlogPost(intent, threadTs, env) { 'annotate that passage inline with ', 'and list it in research_needed. Be honest about the limits of what a web search can confirm.', ].join(' '), - parameters: z.object({ + inputSchema: z.object({ title: z.string().describe('Post title — specific, compelling, not a listicle'), slug: z.string().describe('URL slug, e.g. "enterprise-ai-org-design-tradeoffs"'), draft_content: z.string().describe('Full markdown with frontmatter. Annotate uncertain passages with '), @@ -83,7 +83,7 @@ export async function createBlogPost(intent, threadTs, env) { model: anthropic('claude-opus-4-6'), tools, toolChoice: 'auto', - maxSteps: STEP_LIMIT, + stopWhen: stepCountIs(STEP_LIMIT), system: `You are an editorial agent writing a blog post for Doug Hatcher at doughatcher.com. Doug is a senior technical architect in mid-market commerce (SFCC, Shopify Plus) with Adobe corporate experience. @@ -123,7 +123,7 @@ tags: [] const submitCall = toolCalls.find(tc => tc.toolName === 'submit_draft'); if (!submitCall) throw new Error('Agent did not produce a draft'); - const { title, slug, draft_content, research_needed } = submitCall.args; + const { title, slug, draft_content, research_needed } = submitCall.input; const branch = `blog/${today}-${slug}`; const filePath = `${contentPath}/${today}-${slug}.md`;