-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement the six showcase proactive agents #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2e0967d
3fca0aa
40d472b
090833b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| .DS_Store | ||
| node_modules/ |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,127 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * granola-prospect handler. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * a new Granola note syncs in (storage `file.created` at /granola/notes/…) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * → read the note's transcript from the VFS | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * → ask the model "is this a prospect call, and what did they ask for?" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * → if yes: file a Linear issue, then have the coding agent open a PR for it | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { handler, type WorkforceCtx } from '@agentworkforce/runtime'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface Ask { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isProspect: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| title: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| summary: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default handler(async (ctx, event) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Notes arrive via the Nango sync as storage events; the clock isn't one. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (event.source === 'cron') return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const notePath = readNotePath(event.payload); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!notePath || !notePath.includes('/granola/notes/')) return; // ignore folders/other writes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!ctx.linear) throw new Error('granola-prospect requires the linear integration'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const transcript = await readNote(ctx, notePath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!transcript) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ask = await classify(ctx, transcript); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!ask.isProspect) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.log('info', 'granola-prospect.not-a-prospect', {}); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const teamId = await resolveTeamId(ctx); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const issue = await ctx.linear.createIssue({ teamId, title: ask.title, description: ask.summary }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.log('info', 'granola-prospect.issue-created', { url: issue.url }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The cloud materializes the github repo into the sandbox (ctx.sandbox.cwd) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // via relayfile — no clone, no gh/git. The GitHub integration opens the PR. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const run = await ctx.harness.run({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cwd: ctx.sandbox.cwd, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prompt: `A prospect asked for the following. Comprehensively implement it (every change needed to fully address the ask), then open a GitHub pull request with your changes — the GitHub integration opens it, do not use git or the \`gh\` CLI. Put the PR URL on the last line.\n\nLinear issue: ${issue.url}\n\n${ask.summary}` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prUrl = run.output.match(/https?:\/\/\S*\/pull\/\d+/g)?.pop(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (prUrl) await ctx.linear.comment(issue.id, `:rocket: Implementation PR: ${prUrl}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** A storage `file.created` event carries the VFS path of the file that landed. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function readNotePath(payload: unknown): string | undefined { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const p = payload as { path?: string; relayfilePath?: string; data?: { path?: string } } | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return p?.path ?? p?.relayfilePath ?? p?.data?.path; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Read the synced note JSON and pull out its transcript / content text. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function readNote(ctx: WorkforceCtx, path: string): Promise<string | undefined> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const note = JSON.parse(await ctx.files.read(path)) as { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| transcript?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| summary?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return note.transcript ?? note.content ?? note.summary; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function classify(ctx: WorkforceCtx, transcript: string): Promise<Ask> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prompt = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Read this meeting transcript. Decide if it is a sales/prospect call where the', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'prospect asked for a feature or change. Reply with JSON only:', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '{"isProspect": boolean, "title": "short issue title", "summary": "what they asked for"}', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| transcript.slice(0, 8000) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ].join('\n'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Models often wrap JSON in ```json fences — strip them before parsing. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const raw = (await ctx.llm.complete(prompt, { maxTokens: 400 })).replace(/```json\s*|```/g, '').trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return JSON.parse(raw) as Ask; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { isProspect: false, title: '', summary: '' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+69
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate LLM output shape before treating it as
Proposed fix async function classify(ctx: WorkforceCtx, transcript: string): Promise<Ask> {
@@
try {
- return JSON.parse((await ctx.llm.complete(prompt, { maxTokens: 400 })).trim()) as Ask;
+ const raw = JSON.parse((await ctx.llm.complete(prompt, { maxTokens: 400 })).trim()) as Partial<Ask>;
+ if (
+ typeof raw?.isProspect === 'boolean' &&
+ typeof raw?.title === 'string' &&
+ typeof raw?.summary === 'string'
+ ) {
+ return {
+ isProspect: raw.isProspect,
+ title: raw.title.trim(),
+ summary: raw.summary.trim()
+ };
+ }
+ return { isProspect: false, title: '', summary: '' };
} catch {
return { isProspect: false, title: '', summary: '' };
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Which Linear team to file under. An explicit LINEAR_TEAM_ID wins; otherwise | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * we auto-pick when the `fetch-teams` sync shows exactly one team, and block | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * (with a helpful list) when it's ambiguous. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function resolveTeamId(ctx: WorkforceCtx): Promise<string> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const configured = input(ctx, 'LINEAR_TEAM_ID'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (configured) return configured; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const teams = await listLinearTeams(ctx); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (teams.length === 1) return teams[0].id; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const found = teams.length ? ` Teams: ${teams.map((t) => `${t.name} (${t.id})`).join(', ')}.` : ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `Can't pick a Linear team automatically — found ${teams.length}. Set LINEAR_TEAM_ID.${found}` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Linear teams the `fetch-teams` sync materialized at /linear/teams/*.json. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function listLinearTeams(ctx: WorkforceCtx): Promise<Array<{ id: string; name: string }>> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const root = process.env.RELAYFILE_MOUNT_ROOT?.replace(/\/$/, '') ?? ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { output } = await ctx.sandbox.exec( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `find ${root}/linear/teams -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Avoid unquoted shell interpolation for Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+104
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid interpolating env-derived paths directly into a shell command.
Proposed fix async function listLinearTeams(ctx: WorkforceCtx): Promise<Array<{ id: string; name: string }>> {
const root = process.env.RELAYFILE_MOUNT_ROOT?.replace(/\/$/, '') ?? '';
+ const teamsDir = `${root}/linear/teams`;
+ const safeTeamsDir = JSON.stringify(teamsDir);
const { output } = await ctx.sandbox.exec(
- `find ${root}/linear/teams -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
+ `find ${safeTeamsDir} -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
);📝 Committable suggestion
Suggested change
🧰 Tools🪛 OpenGrep (1.22.0)[ERROR] 105-107: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead. (coderabbit.command-injection.exec-js) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const teams: Array<{ id: string; name: string }> = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const file of output.split('\n').map((l) => l.trim()).filter(Boolean)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const t = JSON.parse(await ctx.files.read(file)) as { id?: string; name?: string }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (t.id) teams.push({ id: t.id, name: t.name ?? t.id }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /* skip unreadable entries */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return teams; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ── tiny helpers ──────────────────────────────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function input(ctx: WorkforceCtx, name: string): string | undefined { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const spec = ctx.persona.inputSpecs?.[name]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const v = process.env[spec?.env ?? name] ?? ctx.persona.inputs?.[name] ?? spec?.default; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return v && v.trim() ? v : undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { definePersona } from '@agentworkforce/persona-kit'; | ||
|
|
||
| /** | ||
| * Granola Agent — when a Granola meeting recording lands, detects prospect | ||
| * calls, files a Linear issue with what they asked for, and opens a GitHub PR | ||
| * implementing it. | ||
| */ | ||
| export default definePersona({ | ||
| id: 'granola-prospect', | ||
| intent: 'relay-orchestrator', | ||
| tags: ['discovery', 'implementation'], | ||
| description: 'When a Granola recording lands, detects prospect calls, files a Linear issue with the ask, and opens a GitHub PR implementing it.', | ||
| cloud: true, | ||
| useSubscription: true, | ||
|
|
||
| integrations: { | ||
| // Granola has no realtime webhook yet, so notes arrive via the Nango | ||
| // `granola-relay:fetch-notes` sync, which writes each note to the VFS at | ||
| // /granola/notes/<id>.json and fires a storage `file.created` event. | ||
| granola: { triggers: [{ on: 'file.created' }] }, | ||
| linear: {}, | ||
| // The cloud materializes this repo into the sandbox (ctx.sandbox.cwd) via | ||
| // relayfile — the agent never clones it. | ||
| github: { scope: { repo: 'your-org/your-repo' } } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The GitHub repo scope is still a placeholder ( Prompt for AI agents |
||
| }, | ||
|
|
||
| inputs: { | ||
| // Optional: auto-resolved from the `fetch-teams` sync when there's exactly | ||
| // one Linear team. Only needed to disambiguate when you have several. | ||
| LINEAR_TEAM_ID: { | ||
| description: 'Linear team to file prospect issues under (only needed if you have multiple teams).', | ||
| env: 'LINEAR_TEAM_ID', | ||
| optional: true | ||
| } | ||
| }, | ||
|
|
||
| harness: 'claude', | ||
| model: 'claude-sonnet-4-6', | ||
| systemPrompt: 'Turn prospect asks from meeting transcripts into a Linear issue and a small implementing PR.', | ||
| harnessSettings: { | ||
| reasoning: 'high', | ||
| timeoutSeconds: 1800, | ||
| sandboxMode: 'workspace-write', | ||
| workspaceWriteNetworkAccess: true | ||
| }, | ||
|
|
||
| onEvent: './agent.ts' | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| /** | ||
| * hn-monitor handler. | ||
| * | ||
| * fetch the HN front page | ||
| * → keep stories whose title matches one of your TOPICS | ||
| * → drop ones already posted (durable memory) | ||
| * → summarize with ctx.llm | ||
| * → post to Slack | ||
| */ | ||
| import { handler, type WorkforceCtx } from '@agentworkforce/runtime'; | ||
|
|
||
| interface Story { | ||
| id: number; | ||
| title: string; | ||
| url: string; | ||
| points: number; | ||
| } | ||
|
|
||
| export default handler(async (ctx, event) => { | ||
| if (event.source !== 'cron') return; | ||
| if (!ctx.slack) throw new Error('hn-monitor requires the slack integration'); | ||
|
|
||
| const channel = input(ctx, 'SLACK_CHANNEL'); | ||
| if (!channel) throw new Error('SLACK_CHANNEL is required'); | ||
| const topics = list(input(ctx, 'TOPICS')).map((t) => t.toLowerCase()); | ||
|
|
||
| const stories = await fetchFrontPage(); | ||
| const matches = stories.filter((s) => topics.some((t) => s.title.toLowerCase().includes(t))); | ||
|
|
||
| const seen = await loadSeen(ctx); | ||
| const fresh = matches.filter((s) => !seen.includes(s.id)); | ||
| if (fresh.length === 0) { | ||
| ctx.log('info', 'hn-monitor.nothing-new', { matched: matches.length }); | ||
| return; | ||
| } | ||
|
|
||
| await ctx.slack.post(channel, await summarize(ctx, fresh)); | ||
| await saveSeen(ctx, [...seen, ...fresh.map((s) => s.id)].slice(-200)); | ||
| }); | ||
|
|
||
| /** Top ~30 front-page stories via the public HN Algolia API. */ | ||
| async function fetchFrontPage(): Promise<Story[]> { | ||
| const res = await fetch('https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30'); | ||
| const data = (await res.json()) as { hits: Array<{ objectID: string; title: string; url: string | null; points: number }> }; | ||
| return data.hits.map((h) => ({ | ||
| id: Number(h.objectID), | ||
| title: h.title, | ||
| url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`, | ||
| points: h.points | ||
| })); | ||
| } | ||
|
Comment on lines
+42
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the Hacker News Algolia API is temporarily down or a network error occurs, the unhandled exception will crash the cron job. Wrapping the API call in a async function fetchFrontPage(): Promise<Story[]> {
try {
const res = await fetch('https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30');
if (!res.ok) return [];
const data = (await res.json()) as { hits: Array<{ objectID: string; title: string; url: string | null; points: number }> };
return data.hits.map((h) => ({
id: Number(h.objectID),
title: h.title,
url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
points: h.points
}));
} catch {
return [];
}
} |
||
|
|
||
| async function summarize(ctx: WorkforceCtx, stories: Story[]): Promise<string> { | ||
| const lines = stories.map((s) => `- ${s.title} (${s.points} pts) ${s.url}`).join('\n'); | ||
| const digest = await ctx.llm.complete( | ||
| `Write a tight Slack digest (mrkdwn, one bullet per story, lead with why it matters):\n\n${lines}`, | ||
| { maxTokens: 500 } | ||
| ); | ||
| return `:newspaper: *Hacker News* — ${stories.length} new match(es)\n${digest.trim()}`; | ||
| } | ||
|
|
||
| // ── tiny helpers ──────────────────────────────────────────────────────────── | ||
| function list(raw: string | undefined): string[] { | ||
| return (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean); | ||
| } | ||
| function input(ctx: WorkforceCtx, name: string): string | undefined { | ||
| const spec = ctx.persona.inputSpecs?.[name]; | ||
| const v = process.env[spec?.env ?? name] ?? ctx.persona.inputs?.[name] ?? spec?.default; | ||
| return v && v.trim() ? v : undefined; | ||
| } | ||
| async function loadSeen(ctx: WorkforceCtx): Promise<number[]> { | ||
| const [item] = await ctx.memory.recall('hn-monitor seen', { tags: ['hn-monitor:seen'], limit: 1 }); | ||
| try { | ||
| return item ? (JSON.parse(item.content) as number[]) : []; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| async function saveSeen(ctx: WorkforceCtx, ids: number[]): Promise<void> { | ||
| await ctx.memory.save(JSON.stringify(ids), { tags: ['hn-monitor:seen'], scope: 'workspace' }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { definePersona } from '@agentworkforce/persona-kit'; | ||
|
|
||
| /** | ||
| * Hacker News Monitor — scans HN a few times a day for the topics you care | ||
| * about and posts a short digest to Slack. | ||
| */ | ||
| export default definePersona({ | ||
| id: 'hn-monitor', | ||
| intent: 'relay-orchestrator', | ||
| tags: ['discovery'], | ||
| description: 'Scans Hacker News a few times a day for topics you care about and posts a summary to Slack.', | ||
| cloud: true, | ||
|
|
||
| // Runs on a clock (09:00 & 17:00), not an event. No triggers needed. | ||
| schedules: [{ name: 'scan', cron: '0 9,17 * * *', tz: 'America/New_York' }], | ||
|
|
||
| // `slack` gives the handler the ctx.slack client to post the digest. | ||
| integrations: { slack: {} }, | ||
|
|
||
| inputs: { | ||
| TOPICS: { | ||
| description: 'Comma-separated keywords to watch for (matched against story titles).', | ||
| env: 'TOPICS', | ||
| default: 'agents,ai,typescript,developer tools' | ||
| }, | ||
| SLACK_CHANNEL: { description: 'Slack channel id to post the digest to.', env: 'SLACK_CHANNEL' } | ||
| }, | ||
|
|
||
| // ctx.llm uses this model to summarize the matching stories. | ||
| harness: 'claude', | ||
| model: 'claude-haiku-4-5-20251001', | ||
| systemPrompt: 'Summarize Hacker News stories into a short, skimmable Slack digest.', | ||
| harnessSettings: { reasoning: 'low', timeoutSeconds: 120 }, | ||
|
|
||
| memory: { enabled: true, scopes: ['workspace'], ttlDays: 7 }, | ||
|
|
||
| onEvent: './agent.ts' | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LLMs frequently wrap JSON responses in markdown code blocks (e.g.,
```json ... ```). If this happens, a directJSON.parsewill throw an error and silently return{ isProspect: false }. Stripping any markdown code block formatting before parsing makes the JSON extraction significantly more robust.