Skip to content

Commit bf9e277

Browse files
khaliqgantclaude
andcommitted
feat(runtime): wire weekly-digest end-to-end via relaycron + Pages Function
The agent now actually does work when invoked. Architecture: relaycron (cloud) schedule lives here │ cron fires Sat 09:00 UTC ▼ POST /api/cron/weekly-digest (X-Cron-Secret header) Pages Function in this repo verifies secret; builds Context │ ▼ agents/weekly-digest/agent.ts Brave search → cluster → upsert issue │ ▼ GitHub Contents API log entry committed to content/agent-log.json │ ▼ site rebuilds /agent page reflects the new entry Pieces: - agents/shared/openrouter.ts OpenRouter client; default model is google/gemini-2.5-flash (cheap, JSON-good) - agents/shared/github-app.ts App auth → Octokit + REST plugins; helpers for read/write JSON files in the repo - agents/shared/runtime/cloudflare-context.ts spec Context impl that maps VFS paths to repo paths, persists log + dedup state via the Contents API - agents/shared/log.ts now takes ctx; uses ctx.files so the same call site works in any runtime - agents/weekly-digest/agent.ts clusterByTopic → real OpenRouter call; upsertDigestIssue → real Octokit; setEnv() hook so the Pages Function can pass env in - agents/shared/sdk.ts shim handle now exposes .definition so the function can dispatch onEvent directly until @agent-relay/agent ships - functions/api/cron/[agent].ts Pages Function — secret check, dispatch, onError fallback - scripts/register-schedules.ts one-shot: reads each agent's schedule, upserts into relaycron with the webhook URL + shared-secret header Other 4 agents updated to writeLogEntry(ctx, …) signature; they remain scaffolds (not yet wired to the function). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 692a94d commit bf9e277

15 files changed

Lines changed: 1244 additions & 108 deletions

File tree

agents/manual-chatbot/agent.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default agent({
4141

4242
if (!onTopic) {
4343
await polite("Sorry — I only answer questions about proactive agents. Try reframing?");
44-
await writeLogEntry({
44+
await writeLogEntry(ctx, {
4545
agent: "manual-chatbot",
4646
trigger: "message",
4747
action: "Refused — off topic",
@@ -62,7 +62,7 @@ export default agent({
6262
await polite(
6363
`I couldn't find a confident answer in the published essays. Either the topic isn't covered yet, or the question phrasing doesn't match what's there. Try the [essays index](https://proactiveagents.dev/posts) directly?`,
6464
);
65-
await writeLogEntry({
65+
await writeLogEntry(ctx, {
6666
agent: "manual-chatbot",
6767
trigger: "message",
6868
action: "Refused — low confidence",
@@ -80,7 +80,7 @@ export default agent({
8080

8181
await ctx.messages.reply(msg.data.threadId ?? msg.data.channel, answer);
8282

83-
await writeLogEntry({
83+
await writeLogEntry(ctx, {
8484
agent: "manual-chatbot",
8585
trigger: "message",
8686
action: `Answered ${msg.data.channel}`,

agents/notion-to-blog/agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default agent({
4242
const title = event.summary.title ?? "(untitled)";
4343

4444
if (status !== "ready") {
45-
await writeLogEntry({
45+
await writeLogEntry(ctx, {
4646
agent: "notion-to-blog",
4747
trigger: "change",
4848
action: `Skipped — page still ${status ?? "unset"}`,
@@ -69,7 +69,7 @@ export default agent({
6969
// () => openPr({ path: `content/posts/${slug}.mdx`, body: mdx, title: `Publish: ${title}` }),
7070
// );
7171

72-
await writeLogEntry({
72+
await writeLogEntry(ctx, {
7373
agent: "notion-to-blog",
7474
trigger: "change",
7575
action: "Published essay",

agents/pr-reviewer/agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default agent({
3939
);
4040

4141
if (mdxChanged.length === 0 && codeChanged.length === 0) {
42-
await writeLogEntry({
42+
await writeLogEntry(ctx, {
4343
agent: "pr-reviewer",
4444
trigger: "change",
4545
action: `Skipped PR #${pr.data.number}`,
@@ -70,7 +70,7 @@ export default agent({
7070
// await ctx.once(`pr-comment:${pr.data.number}:${pr.data.head.sha}`,
7171
// () => upsertPrComment(pr.data.number, body));
7272

73-
await writeLogEntry({
73+
await writeLogEntry(ctx, {
7474
agent: "pr-reviewer",
7575
trigger: "change",
7676
action: `Reviewed PR #${pr.data.number}`,

agents/shared/github-app.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* GitHub App auth → Octokit. Works in Cloudflare Workers via @octokit/app
3+
* (uses Web Crypto under the hood, no Node deps).
4+
*
5+
* Reads the three GITHUB_APP_* env vars. The PRIVATE_KEY may have escaped
6+
* newlines if it was set via dashboard text input — we normalise.
7+
*/
8+
import { App } from "@octokit/app";
9+
import { Octokit } from "octokit";
10+
11+
/** Octokit with REST + paginate plugins, configured via App auth. */
12+
export type AppOctokit = Octokit;
13+
14+
export type GithubEnv = {
15+
GITHUB_APP_ID: string;
16+
GITHUB_APP_PRIVATE_KEY: string;
17+
GITHUB_APP_INSTALLATION_ID: string;
18+
};
19+
20+
let cached: { installationId: number; octokit: AppOctokit } | null = null;
21+
22+
export async function getOctokit(env: GithubEnv): Promise<AppOctokit> {
23+
const installationId = Number(env.GITHUB_APP_INSTALLATION_ID);
24+
if (cached?.installationId === installationId) return cached.octokit;
25+
26+
const privateKey = env.GITHUB_APP_PRIVATE_KEY.replace(/\\n/g, "\n");
27+
28+
// Pass the umbrella `Octokit` class so installation instances inherit
29+
// .rest (the REST methods we use) and .paginate.
30+
const app = new App({
31+
appId: env.GITHUB_APP_ID,
32+
privateKey,
33+
Octokit,
34+
});
35+
const octokit = (await app.getInstallationOctokit(installationId)) as unknown as AppOctokit;
36+
cached = { installationId, octokit };
37+
return octokit;
38+
}
39+
40+
/**
41+
* Read a JSON file from the repo, return null if missing.
42+
* Returns the file's content + sha (sha is required to update it).
43+
*/
44+
export async function readRepoJson<T = unknown>(
45+
octokit: AppOctokit,
46+
args: { owner: string; repo: string; path: string; ref?: string },
47+
): Promise<{ data: T; sha: string } | null> {
48+
try {
49+
const res = await octokit.rest.repos.getContent({
50+
owner: args.owner,
51+
repo: args.repo,
52+
path: args.path,
53+
ref: args.ref,
54+
});
55+
if (Array.isArray(res.data) || res.data.type !== "file") return null;
56+
const text = atob(res.data.content.replace(/\n/g, ""));
57+
return { data: JSON.parse(text) as T, sha: res.data.sha };
58+
} catch (err) {
59+
const status = (err as { status?: number }).status;
60+
if (status === 404) return null;
61+
throw err;
62+
}
63+
}
64+
65+
/**
66+
* Idempotent JSON write. If the file exists, updates with the prior sha;
67+
* if not, creates. Commit message includes the agent name + summary so the
68+
* git log doubles as a coarser activity feed.
69+
*/
70+
export async function writeRepoJson(
71+
octokit: AppOctokit,
72+
args: {
73+
owner: string;
74+
repo: string;
75+
path: string;
76+
branch?: string;
77+
data: unknown;
78+
message: string;
79+
},
80+
): Promise<void> {
81+
const existing = await readRepoJson(octokit, {
82+
owner: args.owner,
83+
repo: args.repo,
84+
path: args.path,
85+
ref: args.branch,
86+
});
87+
const content = btoa(unescape(encodeURIComponent(JSON.stringify(args.data, null, 2) + "\n")));
88+
89+
await octokit.rest.repos.createOrUpdateFileContents({
90+
owner: args.owner,
91+
repo: args.repo,
92+
path: args.path,
93+
branch: args.branch,
94+
message: args.message,
95+
content,
96+
sha: existing?.sha,
97+
});
98+
}

agents/shared/log.ts

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,45 @@
1-
import fs from "node:fs/promises";
2-
import path from "node:path";
3-
import type { AgentEntry } from "@/lib/agent-log";
4-
5-
const LOG_PATH = path.join(process.cwd(), "content", "agent-log.json");
6-
71
/**
8-
* Appends an entry to the public agent log. Idempotent on `id` — if an entry
9-
* with the same id is already present, it's replaced rather than duplicated.
2+
* Append an entry to the public agent log via the spec Context.
103
*
11-
* Designed to run from a Node-side handler (Cloudflare Worker, scheduled job,
12-
* webhook receiver). At deploy time, the static export bakes the log into the
13-
* /agent page; for live updates we'd swap this for an edge KV write.
4+
* The log file is a JSON array at /_meta/agent-log.json in workspace VFS.
5+
* Each runtime impl decides what that path means:
6+
* - Cloudflare runtime → committed to content/agent-log.json on GitHub
7+
* - Node/local dev → written to ./content/agent-log.json on disk
8+
*
9+
* Agent code calls this via `await writeLogEntry(ctx, {...})` and stays
10+
* runtime-agnostic.
1411
*/
15-
export async function writeLogEntry(
16-
entry: Omit<AgentEntry, "id" | "timestamp"> & {
17-
id?: string;
18-
timestamp?: string;
19-
}
20-
): Promise<AgentEntry> {
21-
const full: AgentEntry = {
22-
id: entry.id ?? `${Date.now()}-${entry.agent}-${Math.random().toString(36).slice(2, 8)}`,
23-
timestamp: entry.timestamp ?? new Date().toISOString(),
24-
agent: entry.agent,
25-
trigger: entry.trigger,
26-
action: entry.action,
27-
summary: entry.summary,
28-
outcome: entry.outcome,
29-
links: entry.links,
30-
skippedReason: entry.skippedReason,
12+
import type { Context } from "./sdk";
13+
import type { AgentEntry } from "@/lib/agent-log";
14+
15+
const LOG_VFS_PATH = "/_meta/agent-log.json";
16+
17+
export type LogInput = Omit<AgentEntry, "id" | "timestamp"> & {
18+
id?: string;
19+
timestamp?: string;
20+
};
21+
22+
export async function writeLogEntry(ctx: Context, input: LogInput): Promise<AgentEntry> {
23+
const entry: AgentEntry = {
24+
id: input.id ?? `${Date.now()}-${input.agent}-${Math.random().toString(36).slice(2, 8)}`,
25+
timestamp: input.timestamp ?? new Date().toISOString(),
26+
agent: input.agent,
27+
trigger: input.trigger,
28+
action: input.action,
29+
summary: input.summary,
30+
outcome: input.outcome,
31+
links: input.links,
32+
skippedReason: input.skippedReason,
3133
};
3234

33-
const existing = await readLog();
34-
const without = existing.filter((e) => e.id !== full.id);
35-
const next = [full, ...without];
36-
await fs.writeFile(LOG_PATH, JSON.stringify(next, null, 2) + "\n", "utf8");
37-
return full;
38-
}
35+
const file = await ctx.files.read(LOG_VFS_PATH);
36+
const existing = (file?.body as AgentEntry[] | undefined) ?? [];
37+
const without = existing.filter((e) => e.id !== entry.id);
38+
const next = [entry, ...without];
39+
40+
await ctx.files.write(LOG_VFS_PATH, next, {
41+
message: `[${entry.agent}] ${entry.action}`,
42+
});
3943

40-
async function readLog(): Promise<AgentEntry[]> {
41-
try {
42-
const raw = await fs.readFile(LOG_PATH, "utf8");
43-
return JSON.parse(raw) as AgentEntry[];
44-
} catch {
45-
return [];
46-
}
44+
return entry;
4745
}

agents/shared/openrouter.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Thin OpenRouter wrapper. OpenRouter speaks the OpenAI chat-completions
3+
* API, so this is a single fetch with no SDK dependency — keeps the bundle
4+
* tiny on Cloudflare Workers.
5+
*
6+
* Model choice is centralised here. Cheap-and-good default for clustering
7+
* and structured-output tasks; swap one constant if it ever needs upgrading.
8+
*/
9+
10+
export const DEFAULT_MODEL = "google/gemini-2.5-flash";
11+
12+
export type ChatMessage =
13+
| { role: "system"; content: string }
14+
| { role: "user"; content: string }
15+
| { role: "assistant"; content: string };
16+
17+
export type CompletionOptions = {
18+
apiKey: string;
19+
model?: string;
20+
messages: ChatMessage[];
21+
/** Force JSON output. Most modern models honour this. */
22+
jsonMode?: boolean;
23+
/** 0–2. Default 0.2 for analytical tasks. */
24+
temperature?: number;
25+
signal?: AbortSignal;
26+
};
27+
28+
export async function complete(opts: CompletionOptions): Promise<string> {
29+
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
30+
method: "POST",
31+
headers: {
32+
Authorization: `Bearer ${opts.apiKey}`,
33+
"Content-Type": "application/json",
34+
// Optional but nice — OpenRouter shows it in their dashboard
35+
"HTTP-Referer": "https://proactiveagents.dev",
36+
"X-Title": "Proactive Agents",
37+
},
38+
body: JSON.stringify({
39+
model: opts.model ?? DEFAULT_MODEL,
40+
messages: opts.messages,
41+
temperature: opts.temperature ?? 0.2,
42+
...(opts.jsonMode ? { response_format: { type: "json_object" } } : {}),
43+
}),
44+
signal: opts.signal,
45+
});
46+
if (!res.ok) {
47+
throw new Error(`openrouter ${res.status}: ${await res.text().catch(() => "")}`);
48+
}
49+
const data = (await res.json()) as {
50+
choices?: { message?: { content?: string } }[];
51+
error?: { message: string };
52+
};
53+
if (data.error) throw new Error(`openrouter: ${data.error.message}`);
54+
const text = data.choices?.[0]?.message?.content;
55+
if (!text) throw new Error("openrouter: empty response");
56+
return text;
57+
}
58+
59+
/** Convenience: prompt the model for a JSON object, parse, validate shape. */
60+
export async function completeJson<T = unknown>(
61+
opts: Omit<CompletionOptions, "jsonMode">,
62+
): Promise<T> {
63+
const text = await complete({ ...opts, jsonMode: true });
64+
try {
65+
return JSON.parse(text) as T;
66+
} catch {
67+
throw new Error(`openrouter returned non-JSON despite jsonMode: ${text.slice(0, 200)}`);
68+
}
69+
}

0 commit comments

Comments
 (0)