From 152f370f6ed82738b95a84d7e90cbe736f158c75 Mon Sep 17 00:00:00 2001 From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:26:02 -0400 Subject: [PATCH] feat: add Aeon column for consuming agent output Surfaces the output of an Aeon agent (github.com/aaronjmars/aeon) fork. One column type, four interchangeable sources: - github-runs (default): Aeon's workflow runs on a fork (GitHub API) - github-articles: output/articles/*.md long-form pieces (GitHub API) - dashboard-outputs: rich per-run json-render cards from the local dashboard - dashboard-runs: recent Aeon-launched runs from the local dashboard The dashboard-outputs renderer walks Aeon's json-render spec natively (all 15 component types: Card/Stat/Table/TweetCard/StoryLink/Alert/...). GitHub sources are keyless (60 req/hr) and reuse the existing GitHub client; GITHUB_TOKEN raises that to 5,000/hr and is now documented in .env.example. Plugin: lib/columns/plugins/aeon/{plugin,server,client}; client: lib/integrations/aeon.ts; registered in manifest + both registries. --- .env.example | 9 + lib/columns/plugins/aeon/client.tsx | 617 ++++++++++++++++++++++++++++ lib/columns/plugins/aeon/plugin.ts | 107 +++++ lib/columns/plugins/aeon/server.ts | 47 +++ lib/columns/plugins/manifest.ts | 2 + lib/columns/registry.ts | 2 + lib/columns/server-registry.ts | 2 + lib/integrations/aeon.ts | 303 ++++++++++++++ 8 files changed, 1089 insertions(+) create mode 100644 lib/columns/plugins/aeon/client.tsx create mode 100644 lib/columns/plugins/aeon/plugin.ts create mode 100644 lib/columns/plugins/aeon/server.ts create mode 100644 lib/integrations/aeon.ts diff --git a/.env.example b/.env.example index 1c84b98..8427025 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,12 @@ YOUTUBE_API_KEY= # ~10–30 calls/min on the public API. Free Demo plan adds higher ceiling and # better stability. Create at https://www.coingecko.com/en/developers/dashboard COINGECKO_DEMO_API_KEY= + +# GitHub token — OPTIONAL. Every GitHub column (Trending, Issues, PRs, Actions, +# Releases, Commits, the Aeon column's run/article sources, ...) works keyless +# at 60 requests/hour. Set a token to raise that to 5,000/hour — and it's +# REQUIRED for private repos and for the Search / Stars / Forks columns. +# Create at https://github.com/settings/tokens — a fine-grained token with +# read-only "Contents" (+ "Actions" for workflow runs) is enough for private +# repos; for public repos any token, even unscoped, lifts the rate limit. +GITHUB_TOKEN= diff --git a/lib/columns/plugins/aeon/client.tsx b/lib/columns/plugins/aeon/client.tsx new file mode 100644 index 0000000..940d45c --- /dev/null +++ b/lib/columns/plugins/aeon/client.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { + Bot, + CheckCircle2, + XCircle, + Loader2, + Clock, + CircleSlash, + GitBranch, + Hash, + FileText, + Heart, + Repeat2, +} from "lucide-react"; +import { RelativeTime } from "@/components/relative-time"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + defineColumnUI, + type ConfigFormProps, + type ItemRendererProps, +} from "@/lib/columns/types"; +import { + meta, + type AeonConfig, + type AeonMeta, + type AeonSpec, + type AeonSpecElement, +} from "./plugin"; + +// ---- Config form ---------------------------------------------------------- + +const SOURCE_LABELS: Record = { + "github-runs": "Workflow runs (GitHub)", + "github-articles": "Articles (GitHub)", + "dashboard-outputs": "Feed cards (dashboard)", + "dashboard-runs": "Runs (dashboard)", +}; + +function ConfigForm({ value, onChange }: ConfigFormProps) { + const isGithub = + value.source === "github-runs" || value.source === "github-articles"; + return ( +
+
+ + +

+ GitHub sources read a fork remotely. Dashboard sources need the local + Aeon dashboard running — the Feed cards source is the + rich, per-run view. +

+
+ + {isGithub ? ( + <> +
+ + onChange({ ...value, repo: e.target.value })} + /> +

+ owner/repo of your Aeon fork. +

+
+ {value.source === "github-runs" && ( +
+ + + onChange({ ...value, workflow: e.target.value }) + } + /> +

+ Filter to one workflow — aeon.yml is the skill + runner. Empty = every workflow. +

+
+ )} + + ) : ( + <> +
+ + onChange({ ...value, baseUrl: e.target.value })} + /> +

+ Where ./aeon serves the dashboard. Must be reachable + from this machine. +

+
+
+ + onChange({ ...value, skill: e.target.value })} + /> +

+ Substring filter on the skill name. Empty = every skill. +

+
+ + )} +
+ ); +} + +// ---- Run + article rendering ---------------------------------------------- + +function StatusPill({ meta: m }: { meta: AeonMeta }) { + const status = m.status ?? ""; + const conclusion = m.conclusion ?? ""; + if (status === "in_progress") { + return ( + + + running + + ); + } + if ( + status === "queued" || + status === "waiting" || + status === "pending" || + status === "requested" + ) { + return ( + + + {status} + + ); + } + if (conclusion === "success") { + return ( + + + success + + ); + } + if (conclusion === "failure" || conclusion === "startup_failure") { + return ( + + + failure + + ); + } + const label = conclusion || status || "unknown"; + return ( + + + {label} + + ); +} + +function Pill({ + children, + bg, + color, +}: { + children: React.ReactNode; + bg: string; + color?: string; +}) { + return ( + + {children} + + ); +} + +function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return ""; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`; + const h = Math.floor(m / 60); + return m % 60 ? `${h}h ${m % 60}m` : `${h}h`; +} + +function RunRenderer({ item }: ItemRendererProps) { + const m = item.meta!; + const duration = m.durationMs != null ? formatDuration(m.durationMs) : ""; + return ( + +
+ + + {m.skill ?? "aeon"} + + {m.runNumber != null && ( + <> + · + #{m.runNumber} + + )} + · + + + +
+

+ {item.content} +

+ {(m.branch || m.shortSha || duration || m.event) && ( +
+ {m.branch && ( + + + {m.branch} + + )} + {m.shortSha && ( + + + {m.shortSha} + + )} + {duration && ( + + + {duration} + + )} + {m.event && ( + + {m.event} + + )} +
+ )} +
+ ); +} + +function ArticleRenderer({ item }: ItemRendererProps) { + return ( + +
+ + {item.author.name} + · + + + +
+

+ {item.content} +

+
+ ); +} + +// ---- json-render spec rendering (the rich dashboard-outputs view) --------- + +const s = (v: unknown): string => (typeof v === "string" ? v : ""); +const n = (v: unknown): number | undefined => + typeof v === "number" && Number.isFinite(v) ? v : undefined; + +const GAP: Record = { sm: "gap-1.5", md: "gap-3", lg: "gap-5" }; + +const BADGE_VARIANT: Record = { + default: "bg-foreground/10 text-foreground/80", + secondary: "bg-muted text-muted-foreground", + destructive: "bg-red-500/15 text-red-500", + outline: "ring-1 ring-border text-muted-foreground", +}; + +const ALERT_VARIANT: Record = { + info: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + warning: "bg-amber-500/10 text-amber-600 dark:text-amber-400", + error: "bg-red-500/10 text-red-600 dark:text-red-400", + success: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", +}; + +// Walk the spec from an element id. `elements` is a flat id→node map; children +// are id references. A visited set + depth cap defends against cyclic or +// pathologically deep specs (the generator is an LLM, so don't trust the tree). +function SpecNode({ + id, + spec, + seen, + depth, +}: { + id: string; + spec: AeonSpec; + seen: Set; + depth: number; +}) { + if (depth > 40 || seen.has(id)) return null; + const el: AeonSpecElement | undefined = spec.elements[id]; + if (!el) return null; + const next = new Set(seen).add(id); + const p = el.props ?? {}; + const kids = (el.children ?? []).map((cid) => ( + + )); + + switch (el.type) { + case "Card": + return ( +
+ {s(p.title) && ( +
+ {s(p.title)} +
+ )} + {s(p.description) && ( +
+ {s(p.description)} +
+ )} + {kids} +
+ ); + case "Stack": + return ( +
+ {kids} +
+ ); + case "Grid": + return ( +
+ {kids} +
+ ); + case "Separator": + return
; + case "Heading": { + const level = s(p.level) || "h3"; + const size = + level === "h1" + ? "text-[15px]" + : level === "h2" + ? "text-[14px]" + : "text-[13px]"; + return ( +
+ {s(p.text)} +
+ ); + } + case "Text": + return ( +

+ {s(p.text)} +

+ ); + case "Badge": + return ( + + {s(p.text)} + + ); + case "Link": + return s(p.href) ? ( + + {s(p.label) || s(p.href)} + + ) : ( + {s(p.label)} + ); + case "Stat": + return ( +
+ {s(p.label) && ( +
+ {s(p.label)} +
+ )} +
+ + {s(p.value)} + + {s(p.delta) && ( + + {s(p.delta)} + + )} +
+
+ ); + case "Progress": { + const max = n(p.max) ?? 100; + const val = Math.max(0, Math.min(n(p.value) ?? 0, max)); + const pct = max > 0 ? Math.round((val / max) * 100) : 0; + return ( +
+ {s(p.label) && ( +
+ {s(p.label)} + {pct}% +
+ )} +
+
+
+
+ ); + } + case "Table": { + const cols = Array.isArray(p.columns) ? p.columns.map(s) : []; + const rows = Array.isArray(p.rows) ? p.rows : []; + return ( +
+ + {cols.length > 0 && ( + + + {cols.map((c, i) => ( + + ))} + + + )} + + {rows.map((row, ri) => ( + + {(Array.isArray(row) ? row : []).map((cell, ci) => ( + + ))} + + ))} + +
+ {c} +
+ {s(cell)} +
+
+ ); + } + case "StoryLink": + return ( + +
+ {s(p.title)} +
+ {(s(p.source) || s(p.score)) && ( +
+ {[s(p.source), s(p.score)].filter(Boolean).join(" · ")} +
+ )} +
+ ); + case "TweetCard": + return ( +
+
+ {s(p.author)} + {s(p.handle) && @{s(p.handle)}} +
+

{s(p.text)}

+ {(n(p.likes) != null || n(p.retweets) != null) && ( +
+ {n(p.likes) != null && ( + + + {n(p.likes)} + + )} + {n(p.retweets) != null && ( + + + {n(p.retweets)} + + )} +
+ )} +
+ ); + case "Alert": + return ( +
+ {s(p.title) &&
{s(p.title)}
} + {s(p.message) &&
{s(p.message)}
} +
+ ); + case "Button": + return ( + + {s(p.label)} + + ); + default: + // Unknown component type — render its children so the tree isn't lost. + return kids.length ?
{kids}
: null; + } +} + +function OutputRenderer({ item }: ItemRendererProps) { + const m = item.meta!; + const spec = m.spec; + return ( +
+
+ + + {m.skill ?? "aeon"} + + · + + + +
+ {spec ? ( + + ) : ( +

{item.content}

+ )} +
+ ); +} + +// ---- Dispatch ------------------------------------------------------------- + +function ItemRenderer(props: ItemRendererProps) { + const kind = props.item.meta?.kind; + if (kind === "output") return ; + if (kind === "article") return ; + return ; +} + +export const column = defineColumnUI({ + ...meta, + ConfigForm, + ItemRenderer, +}); diff --git a/lib/columns/plugins/aeon/plugin.ts b/lib/columns/plugins/aeon/plugin.ts new file mode 100644 index 0000000..9bf6ad4 --- /dev/null +++ b/lib/columns/plugins/aeon/plugin.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import { Bot } from "lucide-react"; +import type { PluginMeta } from "@/lib/columns/types"; + +// Aeon (github.com/aaronjmars/aeon) is an autonomous agent that runs "skills" +// on a cron and commits the results back into its repo. This column surfaces +// that output. Four interchangeable sources, so it works whether the operator +// runs the local Aeon dashboard or only has the GitHub fork: +// dashboard-outputs rich json-render card per skill run (dashboard up) +// dashboard-runs recent Aeon-launched workflow runs (dashboard up) +// github-runs workflow runs on a fork (GitHub API) +// github-articles output/articles/*.md long-form pieces (GitHub API) +export const AEON_SOURCES = [ + "dashboard-outputs", + "dashboard-runs", + "github-runs", + "github-articles", +] as const; + +export type AeonSource = (typeof AEON_SOURCES)[number]; + +export const schema = z.object({ + source: z.enum(AEON_SOURCES).default("github-runs"), + /** owner/repo of an Aeon fork — github-runs / github-articles. */ + repo: z.string().default(""), + /** Aeon dashboard base URL — dashboard-outputs / dashboard-runs. */ + baseUrl: z.string().default("http://localhost:5555"), + /** github-runs: workflow display name or filename to filter on (e.g. aeon.yml). */ + workflow: z.string().default(""), + /** dashboard-*: substring filter on the skill / workflow name. */ + skill: z.string().default(""), +}); + +export type AeonConfig = z.infer; + +// ---- Renderer contract ---------------------------------------------------- + +/** One node of an Aeon json-render spec (aeon scripts/notify-jsonrender.sh). */ +export interface AeonSpecElement { + type: string; + props?: Record; + children?: string[]; +} + +/** A json-render spec: a root id + a flat id→element map. */ +export interface AeonSpec { + root: string; + state?: Record; + elements: Record; +} + +export interface AeonMeta { + kind: "output" | "run" | "article"; + source: AeonSource; + /** Skill (outputs/articles) or workflow display name (runs). */ + skill?: string; + // run fields (dashboard-runs + github-runs) + status?: string; + conclusion?: string | null; + runNumber?: number; + branch?: string; + shortSha?: string; + durationMs?: number; + event?: string; + fullRepo?: string; + // output field (dashboard-outputs) + spec?: AeonSpec; +} + +export const meta: PluginMeta = { + id: "aeon", + label: "Aeon", + description: + "Output from an Aeon agent — rich per-run cards, workflow runs, and long-form articles from a fork or the local dashboard.", + icon: Bot, + // Aeon brand near-black; the "ai" category groups it with the other agent / + // model columns in the Add-column picker. + accent: "#111111", + category: "ai", + schema, + defaultConfig: schema.parse({}), + defaultTitle: (c) => { + switch (c.source) { + case "dashboard-outputs": + case "dashboard-runs": { + const s = c.skill.trim(); + return s ? `Aeon · ${s}` : "Aeon"; + } + case "github-articles": { + const r = c.repo.trim(); + return r ? `Aeon · articles · ${r}` : "Aeon · articles"; + } + case "github-runs": + default: { + const r = c.repo.trim(); + return r ? `Aeon · ${r}` : "Aeon · runs"; + } + } + }, + capabilities: { + // github-* sources page 10 at a time; dashboard-* return one batch. + paginated: true, + refreshIntervalHintMs: 5 * 60_000, + rateLimitHint: + "GitHub sources: 60 req/hr keyless, 5000 with GITHUB_TOKEN. Dashboard sources need the local Aeon dashboard running (default http://localhost:5555).", + }, +}; diff --git a/lib/columns/plugins/aeon/server.ts b/lib/columns/plugins/aeon/server.ts new file mode 100644 index 0000000..40bdffd --- /dev/null +++ b/lib/columns/plugins/aeon/server.ts @@ -0,0 +1,47 @@ +import "server-only"; + +import { defineColumnServer, type ServerFetcher } from "@/lib/columns/types"; +import { PAGE_SIZE } from "@/lib/columns/constants"; +import { + fetchAeonOutputs, + fetchAeonDashboardRuns, + fetchAeonGithubRuns, + fetchAeonArticles, +} from "@/lib/integrations/aeon"; +import { meta, type AeonConfig, type AeonMeta } from "./plugin"; + +const fetch: ServerFetcher = async (config, cursor) => { + const page = cursor ? Number(cursor) || 1 : 1; + + switch (config.source) { + // Dashboard sources return one batch (the route caps at 100) — no cursor. + case "dashboard-outputs": + return { items: await fetchAeonOutputs(config.baseUrl, config.skill) }; + + case "dashboard-runs": + return { items: await fetchAeonDashboardRuns(config.baseUrl, config.skill) }; + + // GitHub sources paginate 10 at a time. + case "github-articles": { + const repo = config.repo.trim(); + if (!repo) throw new Error("Repository is required (owner/repo)."); + const { items, hasMore } = await fetchAeonArticles(repo, PAGE_SIZE, page); + return { items, nextCursor: hasMore ? String(page + 1) : undefined }; + } + + case "github-runs": + default: { + const repo = config.repo.trim(); + if (!repo) throw new Error("Repository is required (owner/repo)."); + const { items, hasMore } = await fetchAeonGithubRuns( + repo, + config.workflow, + PAGE_SIZE, + page, + ); + return { items, nextCursor: hasMore ? String(page + 1) : undefined }; + } + } +}; + +export const server = defineColumnServer({ meta, fetch }); diff --git a/lib/columns/plugins/manifest.ts b/lib/columns/plugins/manifest.ts index 9125bd8..54bafae 100644 --- a/lib/columns/plugins/manifest.ts +++ b/lib/columns/plugins/manifest.ts @@ -51,6 +51,7 @@ import { meta as githubDiscussions } from "./github-discussions/plugin"; import { meta as defillama } from "./defillama/plugin"; import { meta as dexscreener } from "./dexscreener/plugin"; import { meta as githubCommits } from "./github-commits/plugin"; +import { meta as aeon } from "./aeon/plugin"; export const PLUGIN_METAS = [ xSearch, @@ -100,6 +101,7 @@ export const PLUGIN_METAS = [ defillama, dexscreener, githubCommits, + aeon, ]; export const REGISTERED_IDS: ReadonlySet = new Set( diff --git a/lib/columns/registry.ts b/lib/columns/registry.ts index 14165f9..ecf9680 100644 --- a/lib/columns/registry.ts +++ b/lib/columns/registry.ts @@ -59,6 +59,7 @@ import { column as githubDiscussions } from "@/lib/columns/plugins/github-discus import { column as defillama } from "@/lib/columns/plugins/defillama/client"; import { column as dexscreener } from "@/lib/columns/plugins/dexscreener/client"; import { column as githubCommits } from "@/lib/columns/plugins/github-commits/client"; +import { column as aeon } from "@/lib/columns/plugins/aeon/client"; // Keyed by id rather than positional — "use client" boundary means we can't // read `column.id` reliably from a server context anyway, so the id has to @@ -111,6 +112,7 @@ const COLUMNS_BY_ID: Record = { defillama, dexscreener, "github-commits": githubCommits, + aeon, }; // Pre-built ordered list, indexed by manifest order. Built once at module init. diff --git a/lib/columns/server-registry.ts b/lib/columns/server-registry.ts index 1086983..4bba43e 100644 --- a/lib/columns/server-registry.ts +++ b/lib/columns/server-registry.ts @@ -55,6 +55,7 @@ import { server as githubDiscussions } from "@/lib/columns/plugins/github-discus import { server as defillama } from "@/lib/columns/plugins/defillama/server"; import { server as dexscreener } from "@/lib/columns/plugins/dexscreener/server"; import { server as githubCommits } from "@/lib/columns/plugins/github-commits/server"; +import { server as aeon } from "@/lib/columns/plugins/aeon/server"; const SERVERS_BY_ID: Record = { "x-search": xSearch, @@ -104,6 +105,7 @@ const SERVERS_BY_ID: Record = { defillama, dexscreener, "github-commits": githubCommits, + aeon, }; // Parity check — runs once at server module init. Throws loudly rather than diff --git a/lib/integrations/aeon.ts b/lib/integrations/aeon.ts new file mode 100644 index 0000000..2949e23 --- /dev/null +++ b/lib/integrations/aeon.ts @@ -0,0 +1,303 @@ +// Aeon integration — consumes the output an Aeon agent fork produces. +// +// Aeon (github.com/aaronjmars/aeon) is a GitHub-Actions-native agent: a cron +// scheduler dispatches skills, each run commits its results back into the repo +// and (optionally) serves them from a local dashboard. This client reads that +// output through four interchangeable sources so the column works whether or +// not the operator has the dashboard running: +// +// dashboard-outputs GET {baseUrl}/api/outputs → json-render spec per run +// dashboard-runs GET {baseUrl}/api/runs → gh run list, Aeon-filtered +// github-runs GitHub Actions API → workflow runs on a fork +// github-articles GitHub Contents API → output/articles/*.md +// +// The dashboard sources are richest (a full UI component tree per run) but are +// loopback-gated and need the local server up. The GitHub sources are always-on +// and remote, and reuse minitor's existing GitHub client. + +import { fetchUpstream } from "@/lib/integrations/fetch"; +import { + fetchWorkflowRuns, + normalizeGitHubRepo, + type GHActionRunMeta, +} from "@/lib/integrations/github"; +import type { FeedItem } from "@/lib/columns/types"; +import type { AeonMeta, AeonSpec } from "@/lib/columns/plugins/aeon/plugin"; + +const GITHUB_API = "https://api.github.com"; + +export type AeonItem = FeedItem; + +// ---- Dashboard API response shapes ---------------------------------------- + +interface DashboardOutput { + filename: string; + skill: string; + timestamp: string; // ISO 8601 (the route converts the file stamp back to ISO) + spec: AeonSpec; +} +interface OutputsResponse { + outputs?: DashboardOutput[]; + error?: string; +} + +interface DashboardRun { + id: number; + workflow: string; + status: string; + conclusion: string | null; + created_at: string; + url: string; +} +interface RunsResponse { + runs?: DashboardRun[]; + error?: string; +} + +// ---- Spec helpers --------------------------------------------------------- + +function asString(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +/** Best-effort display title: the root Card's title, else the first Heading. */ +function specTitle(spec: AeonSpec): string { + const rootEl = spec.elements[spec.root]; + const rootTitle = asString(rootEl?.props?.title); + if (rootTitle) return rootTitle; + for (const el of Object.values(spec.elements)) { + if (el.type === "Heading") { + const t = asString(el.props?.text); + if (t) return t; + } + } + return ""; +} + +/** + * Flatten every human-readable string in a spec into one blob. Used as the + * FeedItem `content` so minitor's client-side alert / filter keyword matching + * (which scans author + content + url) works over an output card's text. + */ +function specPlainText(spec: AeonSpec): string { + const parts: string[] = []; + for (const el of Object.values(spec.elements)) { + const p = el.props ?? {}; + for (const key of ["title", "description", "text", "label", "value", "message", "source"]) { + const s = asString(p[key]); + if (s) parts.push(s); + } + // Table rows: string[][] + if (Array.isArray(p.rows)) { + for (const row of p.rows) { + if (Array.isArray(row)) parts.push(row.map(asString).filter(Boolean).join(" ")); + } + } + if (Array.isArray(p.columns)) parts.push(p.columns.map(asString).filter(Boolean).join(" ")); + } + return parts.join(" · ").slice(0, 4000); +} + +function skillMatches(skill: string, filter: string): boolean { + const f = filter.trim().toLowerCase(); + if (!f) return true; + return skill.toLowerCase().includes(f); +} + +function normalizeBaseUrl(raw: string): string { + const trimmed = raw.trim().replace(/\/+$/, ""); + if (!trimmed) return "http://localhost:5555"; + return /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`; +} + +// ---- Sources -------------------------------------------------------------- + +// GET a JSON endpoint on the Aeon dashboard, converting both a network-level +// failure (dashboard down / wrong port) and an HTTP error into an actionable +// message — the dashboard-not-running case is by far the most common. +async function getDashboardJson(baseUrl: string, path: string): Promise { + const base = normalizeBaseUrl(baseUrl); + let res: Response; + try { + res = await fetchUpstream( + `${base}${path}`, + { headers: { accept: "application/json" }, cache: "no-store" }, + { label: "aeon-dashboard" }, + ); + } catch { + throw new Error( + `Can't reach the Aeon dashboard at ${base}. Start it with ./aeon in your fork (default http://localhost:5555).`, + ); + } + if (!res.ok) throw new Error(dashboardError(res.status)); + return (await res.json()) as T; +} + +/** dashboard-outputs — the rich json-render feed. Not paginated (route caps 100). */ +export async function fetchAeonOutputs( + baseUrl: string, + skill: string, +): Promise { + const json = await getDashboardJson(baseUrl, "/api/outputs"); + if (json.error) throw new Error(json.error); + const outputs = (json.outputs ?? []).filter((o) => skillMatches(o.skill, skill)); + return outputs.map((o) => ({ + id: `aeon-out-${o.filename}`, + author: { name: o.skill || "aeon" }, + content: specTitle(o.spec) || specPlainText(o.spec) || o.skill, + url: normalizeBaseUrl(baseUrl), + createdAt: o.timestamp || new Date().toISOString(), + meta: { + kind: "output", + source: "dashboard-outputs", + skill: o.skill, + spec: o.spec, + }, + })); +} + +/** dashboard-runs — gh run list, already filtered to Aeon-launched events. */ +export async function fetchAeonDashboardRuns( + baseUrl: string, + skill: string, +): Promise { + const json = await getDashboardJson(baseUrl, "/api/runs"); + if (json.error) throw new Error(json.error); + const runs = (json.runs ?? []).filter((r) => skillMatches(r.workflow, skill)); + return runs.map((r) => ({ + id: `aeon-run-${r.id}`, + author: { name: r.workflow || "aeon" }, + content: r.workflow || `Run ${r.id}`, + url: r.url, + createdAt: r.created_at, + meta: { + kind: "run", + source: "dashboard-runs", + skill: r.workflow, + status: r.status, + conclusion: r.conclusion, + }, + })); +} + +function dashboardError(status: number): string { + if (status === 403) { + return "Aeon dashboard refused the request (loopback-only). Run minitor and the dashboard on the same machine, or set AEON_DASHBOARD_ALLOWED_HOSTS in the Aeon fork."; + } + if (status === 404) { + return "Aeon dashboard reachable but /api route missing — check the base URL and that the dashboard is up to date."; + } + return `Aeon dashboard returned ${status}. Is it running? (default http://localhost:5555)`; +} + +/** github-runs — Aeon's workflow runs on a fork. Reuses minitor's GitHub client. */ +export async function fetchAeonGithubRuns( + repo: string, + workflow: string, + limit: number, + page: number, +): Promise<{ items: AeonItem[]; hasMore: boolean }> { + const { items, hasMore } = await fetchWorkflowRuns(repo, workflow, "", limit, page); + return { + items: items.map((it) => ({ + ...it, + id: `aeon-${it.id}`, + meta: runMetaFromGh(it.meta), + })), + hasMore, + }; +} + +function runMetaFromGh(m: GHActionRunMeta | undefined): AeonMeta { + return { + kind: "run", + source: "github-runs", + skill: m?.workflowName, + status: m?.status, + conclusion: m?.conclusion, + runNumber: m?.runNumber, + branch: m?.branch, + shortSha: m?.shortSha, + durationMs: m?.durationMs, + event: m?.event, + fullRepo: m?.fullRepo, + }; +} + +// ---- github-articles ------------------------------------------------------ + +interface GHContentEntry { + name: string; + path: string; + sha: string; + html_url: string | null; + download_url: string | null; + type: "file" | "dir" | string; +} + +function ghHeaders(): HeadersInit { + const h: Record = { + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "user-agent": "minitor/0.1", + }; + const token = process.env.GITHUB_TOKEN; + if (token) h.authorization = `Bearer ${token}`; + return h; +} + +// "workflow-audit-2026-04-11.md" → { title: "Workflow audit", date: "2026-04-11" } +function parseArticleName(name: string): { title: string; date?: string } { + const base = name.replace(/\.md$/i, ""); + const dateMatch = base.match(/(\d{4}-\d{2}-\d{2})$/); + const date = dateMatch?.[1]; + const slug = date ? base.slice(0, base.length - date.length).replace(/-+$/, "") : base; + const words = slug.replace(/[-_]+/g, " ").trim(); + const title = words ? words.charAt(0).toUpperCase() + words.slice(1) : base; + return { title, date }; +} + +/** + * github-articles — long-form artifacts skills commit under output/articles/. + * Titles/dates are derived from the filename to stay within one API call (the + * real H1 lives inside each file; fetching all of them would burn rate limit). + * Paginated client-side over the directory listing, newest first. + */ +export async function fetchAeonArticles( + repo: string, + limit: number, + page: number, +): Promise<{ items: AeonItem[]; hasMore: boolean }> { + const fullRepo = normalizeGitHubRepo(repo); + const url = `${GITHUB_API}/repos/${fullRepo}/contents/output/articles`; + const res = await fetchUpstream(url, { headers: ghHeaders(), cache: "no-store" }); + if (res.status === 404) { + // No articles dir (or empty fork) — treat as an empty feed, not an error. + return { items: [], hasMore: false }; + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`GitHub ${res.status}: ${body.slice(0, 200)}`); + } + const entries = (await res.json()) as GHContentEntry[]; + const files = (Array.isArray(entries) ? entries : []) + .filter((e) => e.type === "file" && /\.md$/i.test(e.name) && e.name !== ".gitkeep") + .map((e) => { + const { title, date } = parseArticleName(e.name); + const createdAt = date ? `${date}T00:00:00Z` : new Date().toISOString(); + return { entry: e, title, createdAt }; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + const start = (Math.max(page, 1) - 1) * limit; + const slice = files.slice(start, start + limit); + const items: AeonItem[] = slice.map(({ entry, title, createdAt }) => ({ + id: `aeon-article-${entry.sha}`, + author: { name: fullRepo.split("/")[0] || "aeon" }, + content: title, + url: entry.html_url ?? entry.download_url ?? undefined, + createdAt, + meta: { kind: "article", source: "github-articles", skill: entry.name }, + })); + return { items, hasMore: start + limit < files.length }; +}