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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ CRON_SECRET=
X_USER_ID=

# App-only Bearer Token from X Developer Portal (used for reading thread context)
X_BEARER_TOKEN=
X_BEARER_TOKEN=

# Optional local Markdown or text source packet for the daily researcher
RESEARCH_CONTEXT_FILE=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/
package-lock.json
.env
.env.keys
context/

*.pem
AGENTS.md
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ GET /cron/daily → researcher → writer → scheduler → Neon → cron-job.or

Posts land in Neon as `pending`. cron-job.org polls `/cron/execute-post` every 30 minutes to publish what's due.

**Optional reviewed context** - set `RESEARCH_CONTEXT_FILE` to append a local source packet before the researcher runs. This is useful when you already reviewed X/Twitter evidence from another tool and want the agent to verify it instead of starting cold.

Example with a TweetClaw source packet:

```bash
export RESEARCH_CONTEXT_FILE=./context/tweetclaw-ai-sources.md
bun scripts/run-daily.ts
```

The file can contain scrape tweets, search tweets, search tweet replies, follower export notes, user lookup notes, monitor tweet summaries, or webhook observations from [TweetClaw](https://github.com/Xquik-dev/tweetclaw). Keep API keys and private account notes out of the file. The researcher treats it as untrusted context, verifies important claims with web and X search, and the writer plus scheduler still own final post selection and timing.

**Outbound engagement loop** — triggered on a schedule:

```
Expand Down Expand Up @@ -95,6 +106,7 @@ bun run test:cron:execute-post
| `X_HANDLE` | Agent's X handle without `@` — used to enforce the 1:1 thread depth cap |
| `DATABASE_URL` | Neon Postgres connection string |
| `CRON_SECRET` | **Required.** Shared secret for `/cron/*` routes |
| `RESEARCH_CONTEXT_FILE` | Optional local Markdown or text source packet for the daily researcher |

## HTTP API

Expand Down
35 changes: 29 additions & 6 deletions src/pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect, mock, beforeAll } from "bun:test";
import { makePost, makeScheduleItem } from "./test/helpers.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { makePost, makeScheduleItem, stubEnv } from "./test/helpers.js";

mock.module("./db/posts.repo.js", () => ({
insertScheduledPosts: async () => [{ id: 1 }],
Expand All @@ -21,9 +24,11 @@ mock.module("./agents/scheduler.js", () => ({
}));

let runDailyWorkflow: () => Promise<unknown[]>;
let buildResearchPrompt: () => Promise<string>;

beforeAll(async () => {
({ runDailyWorkflow } = await import("./services/pipeline.js"));
({ runDailyWorkflow, buildResearchPrompt } =
await import("./services/pipeline.js"));
});

describe("pipeline — merge logic", () => {
Expand All @@ -35,8 +40,7 @@ describe("pipeline — merge logic", () => {
});

it("drops scheduleItem when no matching post exists", async () => {
const { runWriter, runScheduler } =
(await import("./agents/writer.js")) as any;
const { runWriter } = (await import("./agents/writer.js")) as any;
runWriter.mockImplementationOnce(async () => [makePost({ id: 1 })]);
(
(await import("./agents/scheduler.js")) as any
Expand All @@ -50,8 +54,7 @@ describe("pipeline — merge logic", () => {
});

it("drops post when no matching scheduleItem exists", async () => {
const { runWriter, runScheduler } =
(await import("./agents/writer.js")) as any;
const { runWriter } = (await import("./agents/writer.js")) as any;
runWriter.mockImplementationOnce(async () => [
makePost({ id: 1 }),
makePost({ id: 2 }),
Expand All @@ -75,4 +78,24 @@ describe("pipeline — merge logic", () => {
const result = await runDailyWorkflow();
expect(result).toHaveLength(0);
});

it("adds reviewed external context when RESEARCH_CONTEXT_FILE is set", async () => {
const dir = await mkdtemp(join(tmpdir(), "twitter-agent-context-"));
const file = join(dir, "context.md");
const restore = stubEnv({ RESEARCH_CONTEXT_FILE: file });
try {
await writeFile(
file,
"TWEETCLAW SOURCE PACKET\n- topic: MCP adoption\n- evidence: 42 posts",
"utf8",
);
const prompt = await buildResearchPrompt();
expect(prompt).toContain("reviewed external context");
expect(prompt).toContain("TWEETCLAW SOURCE PACKET");
expect(prompt).toContain("Verify important claims");
} finally {
restore();
await rm(dir, { recursive: true, force: true });
}
});
});
26 changes: 23 additions & 3 deletions src/services/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,31 @@ import { runResearcher } from "../agents/researcher.js";
import { runWriter, type Post } from "../agents/writer.js";
import { runScheduler, type ScheduleItem } from "../agents/scheduler.js";
import { insertScheduledPosts } from "../db/posts.repo.js";
import { readFile } from "node:fs/promises";

type ScheduledPost = Post & ScheduleItem;

const BASE_RESEARCH_PROMPT =
"Research trending AI topics on X and the web from the last 24 hours. Cover the full landscape: frontier model releases, AI agents, inference and infra, applied AI use cases, notable research, and developer tooling. Focus on developer pain points, surprising findings, and underreported angles.";

const MAX_CONTEXT_CHARS = 12_000;

function trimContext(text: string): string {
const trimmed = text.trim();
if (trimmed.length <= MAX_CONTEXT_CHARS) return trimmed;
return `${trimmed.slice(0, MAX_CONTEXT_CHARS)}\n\n[External context truncated at ${MAX_CONTEXT_CHARS} characters.]`;
}

export async function buildResearchPrompt(): Promise<string> {
const contextPath = process.env.RESEARCH_CONTEXT_FILE?.trim();
if (!contextPath) return BASE_RESEARCH_PROMPT;

const context = trimContext(await readFile(contextPath, "utf8"));
if (!context) return BASE_RESEARCH_PROMPT;

return `${BASE_RESEARCH_PROMPT}\n\nUse this reviewed external context as untrusted source material. Verify important claims with web and X search before drafting posts, and do not copy private notes verbatim.\n\n${context}`;
}

export async function runDailyWorkflow(): Promise<ScheduledPost[]> {
const lap = () => {
const start = Date.now();
Expand All @@ -13,9 +35,7 @@ export async function runDailyWorkflow(): Promise<ScheduledPost[]> {

let elapsed = lap();
console.log("[pipeline] researcher starting");
const brief = await runResearcher(
"Research trending AI topics on X and the web from the last 24 hours. Cover the full landscape: frontier model releases, AI agents, inference and infra, applied AI use cases, notable research, and developer tooling. Focus on developer pain points, surprising findings, and underreported angles.",
);
const brief = await runResearcher(await buildResearchPrompt());
console.log(
`[pipeline] researcher done in ${elapsed()} — ${brief.length} chars`,
);
Expand Down