Skip to content
Open
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
81 changes: 73 additions & 8 deletions lib/dispatch/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,80 @@ function asObject(v: unknown): Record<string, unknown> {
*/
export const PROVIDERS_BY_ARTIFACT: Record<string, ProviderAction[]> = {
/**
* BlogDraft approvals carry `targets: ToolkitId[]` set by the founder via
* the channels picker. The dispatcher fans out one publish per target by
* looking up the matching ChannelVariant entry below — there's no single
* "BlogDraft provider" call. We expose all 7 target toolkits here so the
* approval card can render the channels picker; the dispatcher itself
* doesn't invoke these directly for BlogDraft (it routes through Formatter
* + ChannelVariant approvals).
* BlogDraft approvals — direct one-click Reddit publish. Skips the
* Formatter/ChannelVariant fanout: the founder reviews the draft once and
* "Approve & send via Reddit" posts the markdown body verbatim to the
* configured subreddit. Default destination is the founder's profile
* subreddit (`u_<username>`); override per-draft by setting
* `proposed.subreddit` upstream.
*/
BlogDraft: [],
BlogDraft: [
/**
* GitHub PR — opens a pull request against a static-site repo with the
* draft's markdown body as a new content file. The dispatcher recognizes
* this provider and runs a 2-step flow (commit then PR) using the same
* choreography as the ChannelVariant.github path. Default repo +
* frontmatter target the Anvil marketing site; override per-draft via
* `proposed_action.metadata.{repo, branch, path, prTitle, prBody}`.
*
* Listed FIRST so an "internal" BlogDraft with `targets: ["github"]`
* auto-picks GitHub as the default destination — Reddit ranks lower
* because it's a public publish.
*/
{
toolkit: "github",
action: "GITHUB_CREATE_PULL_REQUEST",
label: "GitHub PR",
buildArgs: (p) => {
const metadata = asObject(p.metadata);
const repo = asString(metadata.repo) ?? "anvil-co/anvil-site";
const [owner, repoName] = repo.split("/");
const slug = asString(p.slug) ?? "post";
const title = asString(p.title) ?? "(untitled)";
const body =
asString(p.bodyMarkdown) ??
asString(p.content) ??
asString(p.excerpt) ??
"";
return {
owner,
repo: repoName,
title: asString(metadata.prTitle) ?? `Add post: ${title}`,
head: asString(metadata.branch) ?? `content/${slug}`,
base: "main",
body:
asString(metadata.prBody) ??
`Adds new blog post: **${title}**\n\n_Drafted by GMaestro and approved by the founder._`,
// The dispatcher's pre-step calls GITHUB_COMMIT_MULTIPLE_FILES with
// these values to land the markdown file before opening the PR.
_commitFile: {
path: asString(metadata.path) ?? `content/blog/${slug}.md`,
content: body,
},
};
},
},
{
toolkit: "reddit",
action: "REDDIT_CREATE_REDDIT_POST",
label: "Reddit",
buildArgs: (p) => {
const subreddit = asString(p.subreddit) ?? "u_Pale-Taste2766";
const title = asString(p.title) ?? "(untitled)";
const body =
asString(p.bodyMarkdown) ??
asString(p.content) ??
asString(p.excerpt) ??
"";
return {
subreddit,
kind: "self",
title,
text: body,
};
},
},
],

/**
* ChannelVariant — one provider per target. The dispatcher reads the
Expand Down
41 changes: 35 additions & 6 deletions lib/personas/prompts/writer.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ output_schema: BlogDraft

# Content Writer

You are the **Writer** for GMaestro. Your job in one sentence:
You are the **Writer** for AutoBlog. Your job in one sentence:

> **Translate technical documentation into a human-readable blog post in the company's voice.**

Expand Down Expand Up @@ -41,6 +41,24 @@ The docs are written for AI parsers and reference lookups — dense, exhaustive,
}
```

### `bodyMarkdown` may be a string OR an array of section strings

For `blog-html` runs (target 1,800–2,200 words), prefer the **array form** — emit one entry per `##` H2 section. The schema accepts either shape and the runtime joins paragraphs with a blank line before persisting; the array form prevents the model from trying to escape one ~13KB string in a single JSON value, which has truncated past responses ("Unterminated string in JSON" failures).

```json
"bodyMarkdown": [
"## Hook + claim + TL;DR\n\n<paragraph 1>\n\n<paragraph 2>\n\n- TL;DR bullet 1\n- TL;DR bullet 2",
"## Mechanism — how it works under the hood\n\n<paragraph 1>\n\n```ts\n// code block\n```\n\n<paragraph after code>",
"## Concrete usage, end-to-end\n\n<paragraph>\n\n```bash\n# example\n```",
"## Edge cases & alternatives\n\n### What breaks at the 99-page MAP ceiling\n\n<paragraph>\n\n### Rejected alternative — direct Firecrawl API\n\n<paragraph>",
"## Wrap-up + CTA\n\n<closing paragraph matching closingPattern>"
]
```

For `reddit` and `x-thread` (short-form), keep the single-string form — there's no chunking benefit.

**Either shape is valid output.** The string form is fine for short posts; the array form is required-strength guidance for `blog-html` to avoid truncation.

## Translation rules (the core of your job)

1. **Open with what changed / what's new / what broke — not what the doc IS.** The doc says "Firecrawl supports markdown extraction." A summary reads "This post explains Firecrawl's markdown support." A translation reads: *"You can scrape any page and get clean markdown back in one call. Here's why that matters for your RAG pipeline."* The first is reference material; the second is a blog.
Expand Down Expand Up @@ -74,14 +92,25 @@ The outline's `geoSignals` will tell you which moves to apply. Honor them litera
- `single-line-punch`: end with one declarative sentence restating the thesis. *"Your agents decide. We make it happen."*
- `wrapping-up`: 2–3 takeaways + low-friction CTA (Discord, install command).
- `cta-only`: end with the next action. *"Try it: `pnpm install gmaestro`."*
6. **Word count target ±10%.** Outline says 1,000 → aim for 900–1,100. Don't pad. Don't truncate mid-thought.
6. **Word count target ±10%.** Outline says 2,000 → aim for 1,800–2,200. Don't pad. Don't truncate mid-thought. Hit the depth — a senior engineer should read end-to-end and learn something specific they didn't know.
7. **Technical depth, not technical jargon.** Show actual mechanism, not vibes. When the doc describes a flow, render it: an ASCII diagram, a numbered step-by-step, a config snippet with the relevant fields highlighted. When the doc names a parameter, explain *why* that parameter exists and what breaks without it. **Every H2 averages ~400 words, every H3 has ≥1 full paragraph (3–5 sentences) of real substance** — never a heading followed by a single sentence stub.
8. **At least one runnable code block per major section that warrants one.** Use ` ``` ` fenced blocks with the language tag (`bash`, `ts`, `py`, `json`, `yaml`, etc.). Prefer real, copy-pasteable snippets pulled from the doc over hand-waved pseudocode.
9. **Edge cases get their own paragraph or callout.** "What if X is null." "What about rate limits." "How to debug when this fails." If the doc lists errors / caveats / limits, name at least 2 by the actual error string or limit value.
10. **Alternatives must name names.** "We considered X" — say what X is, link to it, and give the one specific reason it didn't fit. No "various other approaches" hedging.

## Per-destination overrides

### `blog-html` (900–1,100 words)
- Full markdown post per outline.
- `##` H2s, `###` H3s if needed. NEVER `#`.
- Code blocks: ` ``` ` fenced with language tag.
### `blog-html` (1,800–2,200 words)
- Full markdown post per outline. **Exactly 5 `##` H2s** — match the strategist's 5-section arc. Use `###` H3s INSIDE an H2 when it covers 2–4 sub-ideas (this is encouraged — it's how you fill the section). NEVER `#` (title is separate). NEVER add a 6th H2.
- Each H2 section is **~400 words on average** — substantial bodies, not paragraphs. Don't drop into a single sentence under a heading; if you don't have enough to say in a section, pull material from the doc to fill it. Empty sub-bodies under H3 sub-headings are the most common failure mode — every H3 needs at least 1 full paragraph (3–5 sentences) of substance, not just a sentence stub.
- Code blocks: ` ``` ` fenced with language tag (`bash`, `ts`, `py`, `json`, `yaml`). 2–6 blocks total. Each block load-bearing — no example-for-example's-sake. Surround each block with 1–3 paragraphs of narration explaining *why each line exists* and *what happens at runtime*.
- ASCII diagrams welcome for flows, request lifecycles, retry/queue topology. Wrap in a fenced ` ``` ` block (no language).
- The 5 H2s in order:
1. **Hook + claim + TL;DR** — anomaly/contrarian/stat opening, the thesis, then a 3–5-bullet TL;DR of what the post proves. (~350 words)
2. **Mechanism — how it works under the hood** — the actual moving parts, components, phases. 2–3 H3s by component. (~450 words)
3. **Concrete usage, end-to-end** — 2+ code blocks with full narration. Walk through runtime behaviour. (~500 words)
4. **Edge cases & alternatives** — actual error strings / limits from the doc, how to detect each, 1–2 alternatives by name. 2–3 H3s. (~450 words)
5. **Wrap-up + CTA** — restate the thesis + next step, closing per `closingPattern`. (~250 words)

### `reddit` (~250 words body)
- No `#` heading (Reddit titles are separate).
Expand Down
47 changes: 37 additions & 10 deletions lib/personas/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export async function runPersona<TOut = unknown>(
query({
prompt: buildUserPrompt(personaId, parsedInput, founderObjective),
options: {
model: getModelForTier(persona.modelTier),
model: resolveModelForPersona(personaId, persona.modelTier),
systemPrompt: promptBody,
mcpServers: { composio: mcpConfig },
allowedTools: getAllowedToolsForPersona(personaId),
Expand Down Expand Up @@ -175,15 +175,42 @@ const BATCH_CHUNK_SIZE_ON_RETRY = 10;
const BATCH_TIMEOUT_MS = 90_000;
/**
* Hard ceiling for single-task fanout personas. Bumped from 120s → 300s
* 2026-05-10: the content pivot's writer + geo-editor + formatter each
* generate / edit 1,800–2,200 word blog posts. Sonnet 4.6 routinely lands
* those at 90–150s for the writer alone, plus 60–120s for geo-editing and
* 30–90s for formatter. The previous 120s budget caused all three to
* silently time out under triggerRule: "all_done" so the workflow reported
* "done" with no draft produced. 300s gives long-form generation real
* room while still failing fast on a hung model.
* 2026-05-10 (content pivot), then 300s → 600s same day after a 2,000-word
* Composio Firecrawl blog timed out at exactly 300s on Sonnet 4.6 with
* GEO-Editor + Formatter cascade-skipping behind it. Sonnet 4.6 on a
* deep-technical 2K-word draft routinely lands at 4–6 min — 300s clipped
* legitimate completions and the workflow's `triggerRule: "all_done"`
* tail (pipeline-reporter, slack-digest) made the run report "done" with
* no actual draft produced. 600s gives long-form generation real room
* while still failing fast on a genuinely hung model.
*/
const SINGLE_TIMEOUT_MS = 300_000;
const SINGLE_TIMEOUT_MS = 600_000;

/**
* Per-persona hard model pins. When a persona is in this map, we use the
* pinned model regardless of `getModelForTier(persona.modelTier)` AND
* regardless of `GMAESTRO_LLM_PROVIDER`. Used today to keep the writer on
* Anthropic Sonnet 4.6 (the only model that produces coherent 2,000-word
* drafts at this latency budget), so flipping the global provider env
* doesn't silently downgrade content quality.
*
* Caveat: the SDK still reads the global ANTHROPIC_BASE_URL / auth env vars,
* so if the user is on `GMAESTRO_LLM_PROVIDER=ollama` (which force-rewrites
* those vars), this pin will route the model name through the Ollama
* endpoint — which 404s. Either run with `GMAESTRO_LLM_PROVIDER=anthropic`
* (current setup) or extend env.ts to preserve the Anthropic credentials
* for pinned-model calls.
*/
const PERSONA_MODEL_PINS: Partial<Record<PersonaId, string>> = {
writer: "claude-sonnet-4-6",
};

function resolveModelForPersona(
personaId: PersonaId,
tier: Parameters<typeof getModelForTier>[0],
): string {
return PERSONA_MODEL_PINS[personaId] ?? getModelForTier(tier);
}

/**
* Run a persona in BATCH mode: one LLM call processes all items at once.
Expand Down Expand Up @@ -331,7 +358,7 @@ async function runBatchAttempt<TItem>(
query({
prompt: userPrompt,
options: {
model: getModelForTier(persona.modelTier),
model: resolveModelForPersona(personaId, persona.modelTier),
systemPrompt: promptBody,
mcpServers: { composio: mcpConfig },
allowedTools: getAllowedToolsForPersona(personaId),
Expand Down
13 changes: 12 additions & 1 deletion lib/shared/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const CitationSourceSchema = z.enum([
"twitter",
"linkedin",
"blog",
"docs",
"perplexity",
"hackernews",
"other",
Expand Down Expand Up @@ -212,7 +213,17 @@ export const BlogDraftSchema = z.object({
title: z.string(),
slug: z.string(),
excerpt: z.string(),
bodyMarkdown: z.string(),
// Accepts either a single markdown string OR an array of markdown sections
// that the Writer can emit when long-form output (~2000 words) would
// otherwise blow past the model's per-response max_tokens budget. The
// transform joins sections with a blank line so downstream consumers (DB,
// dashboard, GEO-Editor, Formatter) keep their `bodyMarkdown: string`
// contract unchanged. Background: the previous failure mode was
// "Unterminated string in JSON" mid-body when Kimi K2.6 hit its response
// budget while emitting a single ~13KB JSON-escaped string.
bodyMarkdown: z
.union([z.string(), z.array(z.string())])
.transform((v) => (Array.isArray(v) ? v.join("\n\n") : v)),
tags: z.array(z.string()).default([]),
citations: z.array(SourceCitationSchema).default([]),
geoNotes: z.array(z.string()).optional(),
Expand Down
15 changes: 15 additions & 0 deletions lib/ui/components/dag-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,21 @@ function deriveNodeStatuses(events: WireEvent[]): Map<string, NodeStatus> {
break;
}
case "workflow_done":
// Finalize any nodes still showing "running" once the workflow has
// terminated. Two known causes leave a stuck-running node behind:
// 1. The persona failed at exec/parse and the workflow function
// recorded the failure in workflow_nodes but never emitted a
// persona_completed event to the activity bus.
// 2. Skip-cascade dropped the started→completed pair for a
// mid-chain node whose upstream errored.
// In both cases the workflow is over — leaving the node painted
// "running" forever is misleading. We bias to "done" since the
// detailed per-node error (when there is one) is already surfaced
// via the workflow_nodes status badge inside the popover; the DAG
// overview just needs to reflect "this workflow is finished."
for (const [id, s] of statuses) {
if (s === "running") apply(id, "done");
}
break;
}
}
Expand Down
Loading