Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/slack-bot/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
20 changes: 20 additions & 0 deletions apps/slack-bot/src/services/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<date>-<slug>` 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).
*/
Expand Down
4 changes: 2 additions & 2 deletions apps/slack-bot/src/services/intent-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 };
}
12 changes: 6 additions & 6 deletions apps/slack-bot/src/services/post-creator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -42,15 +42,15 @@ 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),
}),

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 }) => {
Expand All @@ -70,7 +70,7 @@ export async function createBlogPost(intent, threadTs, env) {
'annotate that passage inline with <!-- [RESEARCH] topic: what needs deeper investigation --> ',
'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 <!-- [RESEARCH] topic: ... -->'),
Expand All @@ -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.
Expand Down Expand Up @@ -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`;

Expand Down
Loading