diff --git a/.github/issue-evidence/11305-birdclaw-desktop-inbox.png b/.github/issue-evidence/11305-birdclaw-desktop-inbox.png new file mode 100644 index 0000000000000..9a8992351161a Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-desktop-inbox.png differ diff --git a/.github/issue-evidence/11305-birdclaw-desktop-launcher.png b/.github/issue-evidence/11305-birdclaw-desktop-launcher.png new file mode 100644 index 0000000000000..586534ad117e2 Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-desktop-launcher.png differ diff --git a/.github/issue-evidence/11305-birdclaw-desktop-mentions.png b/.github/issue-evidence/11305-birdclaw-desktop-mentions.png new file mode 100644 index 0000000000000..6bb016109051b Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-desktop-mentions.png differ diff --git a/.github/issue-evidence/11305-birdclaw-desktop-timeline.png b/.github/issue-evidence/11305-birdclaw-desktop-timeline.png new file mode 100644 index 0000000000000..63ea05e81c1a1 Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-desktop-timeline.png differ diff --git a/.github/issue-evidence/11305-birdclaw-e2e-summary.json b/.github/issue-evidence/11305-birdclaw-e2e-summary.json new file mode 100644 index 0000000000000..61f82dbfd3f46 --- /dev/null +++ b/.github/issue-evidence/11305-birdclaw-e2e-summary.json @@ -0,0 +1,4 @@ +{ + "failures": [], + "ok": true +} \ No newline at end of file diff --git a/.github/issue-evidence/11305-birdclaw-phone-inbox.png b/.github/issue-evidence/11305-birdclaw-phone-inbox.png new file mode 100644 index 0000000000000..6877a3fe39a77 Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-phone-inbox.png differ diff --git a/.github/issue-evidence/11305-birdclaw-phone-launcher.png b/.github/issue-evidence/11305-birdclaw-phone-launcher.png new file mode 100644 index 0000000000000..26e6f931f897c Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-phone-launcher.png differ diff --git a/.github/issue-evidence/11305-birdclaw-phone-mentions.png b/.github/issue-evidence/11305-birdclaw-phone-mentions.png new file mode 100644 index 0000000000000..d22546d796231 Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-phone-mentions.png differ diff --git a/.github/issue-evidence/11305-birdclaw-phone-timeline.png b/.github/issue-evidence/11305-birdclaw-phone-timeline.png new file mode 100644 index 0000000000000..dc8f871eaf974 Binary files /dev/null and b/.github/issue-evidence/11305-birdclaw-phone-timeline.png differ diff --git a/bun.lock b/bun.lock index de915ffe4adf1..3d33ebd4dcabf 100644 --- a/bun.lock +++ b/bun.lock @@ -108,6 +108,7 @@ "@elizaos/plugin-app-control": "workspace:*", "@elizaos/plugin-app-manager": "workspace:*", "@elizaos/plugin-background-runner": "workspace:*", + "@elizaos/plugin-birdclaw": "workspace:*", "@elizaos/plugin-browser": "workspace:*", "@elizaos/plugin-capacitor-bridge": "workspace:*", "@elizaos/plugin-cloud-apps": "workspace:*", @@ -2963,6 +2964,30 @@ "vitest": "^4.0.18", }, }, + "plugins/plugin-birdclaw": { + "name": "@elizaos/plugin-birdclaw", + "version": "1.0.0", + "dependencies": { + "@elizaos/core": "workspace:*", + "@elizaos/ui": "workspace:*", + }, + "devDependencies": { + "@biomejs/biome": "2.5.2", + "@types/node": "^25.0.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vite": "^8.0.0", + "vitest": "^4.0.17", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, "plugins/plugin-blocker": { "name": "@elizaos/plugin-blocker", "version": "2.0.3-beta.7", @@ -6316,6 +6341,8 @@ "@elizaos/plugin-benchmarks": ["@elizaos/plugin-benchmarks@workspace:plugins/plugin-benchmarks"], + "@elizaos/plugin-birdclaw": ["@elizaos/plugin-birdclaw@workspace:plugins/plugin-birdclaw"], + "@elizaos/plugin-blocker": ["@elizaos/plugin-blocker@workspace:plugins/plugin-blocker"], "@elizaos/plugin-bluebubbles": ["@elizaos/plugin-bluebubbles@workspace:plugins/plugin-bluebubbles"], diff --git a/packages/agent/package.json b/packages/agent/package.json index de33165d6f601..0b435e9455f6a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -306,6 +306,7 @@ "@elizaos/security": "workspace:*", "@elizaos/plugin-shell": "workspace:*", "@elizaos/plugin-pty": "workspace:*", + "@elizaos/plugin-birdclaw": "workspace:*", "@elizaos/plugin-worker-runtime": "workspace:*", "@elizaos/plugin-scheduling": "workspace:*", "@elizaos/plugin-signal": "workspace:*", diff --git a/packages/agent/src/api/views-registry.package-resolution.test.ts b/packages/agent/src/api/views-registry.package-resolution.test.ts new file mode 100644 index 0000000000000..1387f6baa213e --- /dev/null +++ b/packages/agent/src/api/views-registry.package-resolution.test.ts @@ -0,0 +1,69 @@ +/** + * Plugin package-dir resolution for view registration. + * + * A plugin's short name can collide with an unrelated published npm package + * (the concrete case: plugin "birdclaw" vs the `birdclaw` CLI on npm, which + * Bun can resolve from its install cache). The registry must prefer the + * canonical `@elizaos/plugin-` package so the view bundle is served + * from the actual plugin directory, and must resolve a real workspace plugin + * end to end. + */ + +import type { Plugin } from "@elizaos/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listViews, + pluginPackageNameCandidates, + registerPluginViews, + unregisterPluginViews, +} from "./views-registry.js"; + +describe("pluginPackageNameCandidates", () => { + it("prefers the canonical @elizaos/plugin-* package over the bare short name", () => { + expect(pluginPackageNameCandidates("birdclaw")).toEqual([ + "@elizaos/plugin-birdclaw", + "birdclaw", + ]); + }); + + it("uses a scoped plugin name as-is", () => { + expect(pluginPackageNameCandidates("@elizaos/plugin-inbox")).toEqual([ + "@elizaos/plugin-inbox", + ]); + expect(pluginPackageNameCandidates("@acme/plugin-custom")).toEqual([ + "@acme/plugin-custom", + ]); + }); +}); + +describe("registerPluginViews package-dir resolution", () => { + const PLUGIN_NAME = "birdclaw"; + + afterEach(() => { + unregisterPluginViews(PLUGIN_NAME); + }); + + it("resolves a short-named workspace plugin to its plugins/plugin- dir", async () => { + const plugin: Plugin = { + name: PLUGIN_NAME, + description: "resolution fixture", + views: [ + { + id: "birdclaw-resolution-fixture", + label: "Birdclaw fixture", + bundlePath: "dist/views/bundle.js", + }, + ], + } as Plugin; + + await registerPluginViews(plugin); + + const entry = listViews({ includeAllKinds: true }).find( + (view) => view.id === "birdclaw-resolution-fixture", + ); + expect(entry).toBeDefined(); + // Normalized so the assertion holds on Windows path separators too. + const pluginDir = (entry?.pluginDir ?? "").split("\\").join("/"); + expect(pluginDir).toContain("plugins/plugin-birdclaw"); + }); +}); diff --git a/packages/agent/src/api/views-registry.ts b/packages/agent/src/api/views-registry.ts index e5e9ea7da2946..8621a5e2477ff 100644 --- a/packages/agent/src/api/views-registry.ts +++ b/packages/agent/src/api/views-registry.ts @@ -60,6 +60,20 @@ const registry = new Map(); /** View ids already warned about for oversized bundles — warn once per process. */ const warnedLargeBundles = new Set(); +/** + * Package names to probe for a plugin, in preference order. The canonical + * `@elizaos/plugin-` candidate comes BEFORE the bare short name: a + * plugin's short name can collide with an unrelated published npm package + * (e.g. plugin "birdclaw" vs the `birdclaw` CLI on npm), and under Bun a + * bare-name resolve can hit that package's install cache — registering the + * view against a directory that isn't this plugin at all. + */ +export function pluginPackageNameCandidates(pluginName: string): string[] { + return pluginName.startsWith("@") + ? [pluginName] + : [`@elizaos/plugin-${pluginName}`, pluginName]; +} + /** * Attempt to resolve the package root dir for a plugin by name using * `require.resolve`. Returns `undefined` when the package is not reachable @@ -70,9 +84,7 @@ async function resolvePluginPackageDir( ): Promise { const { createRequire } = await import("node:module"); const req = createRequire(import.meta.url); - const packageNames = pluginName.startsWith("@") - ? [pluginName] - : [pluginName, `@elizaos/plugin-${pluginName}`]; + const packageNames = pluginPackageNameCandidates(pluginName); for (const packageName of packageNames) { // Preferred: resolve the package's own package.json directly. Requires the diff --git a/packages/agent/src/config/zod-schema.agent-runtime.ts b/packages/agent/src/config/zod-schema.agent-runtime.ts index eafc94e82d179..1262f01d70432 100644 --- a/packages/agent/src/config/zod-schema.agent-runtime.ts +++ b/packages/agent/src/config/zod-schema.agent-runtime.ts @@ -497,6 +497,7 @@ export const AgentEntrySchema = z advancedMemory: z.boolean().optional(), agentOrchestrator: z.boolean().optional(), gitpathologist: z.boolean().optional(), + birdclaw: z.boolean().optional(), humanDelay: HumanDelaySchema.optional(), heartbeat: HeartbeatSchema, identity: IdentitySchema, @@ -735,6 +736,7 @@ export const AgentDefaultsSchema = z advancedMemory: z.boolean().optional(), agentOrchestrator: z.boolean().optional(), gitpathologist: z.boolean().optional(), + birdclaw: z.boolean().optional(), contextPruning: z .object({ mode: z.union([z.literal("off"), z.literal("cache-ttl")]).optional(), diff --git a/packages/agent/src/external-modules.d.ts b/packages/agent/src/external-modules.d.ts index c891e9f7136b1..7e6089b876db7 100644 --- a/packages/agent/src/external-modules.d.ts +++ b/packages/agent/src/external-modules.d.ts @@ -436,6 +436,7 @@ declare module "@elizaos/plugin-ollama"; declare module "@elizaos/plugin-openai"; declare module "@elizaos/plugin-shell"; declare module "@elizaos/plugin-pty"; +declare module "@elizaos/plugin-birdclaw"; declare module "@elizaos/plugin-x402" { import type { IAgentRuntime, diff --git a/packages/agent/src/runtime/core-plugins.ts b/packages/agent/src/runtime/core-plugins.ts index a0b11b1efbfc8..cba731037754b 100644 --- a/packages/agent/src/runtime/core-plugins.ts +++ b/packages/agent/src/runtime/core-plugins.ts @@ -280,6 +280,7 @@ export const OPTIONAL_CORE_PLUGINS: readonly string[] = [ "@elizaos/plugin-elevenlabs", // ElevenLabs text-to-speech "@elizaos/plugin-music", // Library, playback, and streaming routes. "@elizaos/plugin-gitpathologist", // forensic git-history analysis (opt-in via ELIZA_GITPATHOLOGIST, auto-on when .git/ exists) + "@elizaos/plugin-birdclaw", // birdclaw.sh local-first Twitter/X archive (auto-on when the birdclaw CLI/data root exists, gate ELIZA_BIRDCLAW) // "@elizaos/plugin-directives", // directive processing remains opt-in // "@elizaos/plugin-mcp", // MCP protocol support remains opt-in // @elizaos/plugin-scheduling is now an always-loaded CORE + MOBILE plugin. diff --git a/packages/agent/src/runtime/eliza.ts b/packages/agent/src/runtime/eliza.ts index 7948586bc5f5d..dd373880df6d5 100644 --- a/packages/agent/src/runtime/eliza.ts +++ b/packages/agent/src/runtime/eliza.ts @@ -337,6 +337,9 @@ const loadOptionalPlugin = async (packageName: string): Promise => { if (packageName === "@elizaos/plugin-pty") { return await import(/* @vite-ignore */ "@elizaos/plugin-pty"); } + if (packageName === "@elizaos/plugin-birdclaw") { + return await import(/* @vite-ignore */ "@elizaos/plugin-birdclaw"); + } if (packageName === "@elizaos/plugin-ollama") { return await import(/* @vite-ignore */ "@elizaos/plugin-ollama"); } @@ -492,6 +495,15 @@ const CORE_STATIC_PLUGIN_REGISTRATIONS: readonly CoreStaticPluginRegistration[] required: false, load: () => getOptionalPlugin("@elizaos/plugin-pty"), }, + { + // Auto-on only when the host has the birdclaw CLI or an existing + // ~/.birdclaw data root (see birdclawRequested in plugin-collector.ts). + // Registers BIRDCLAW_SERVICE + the local Twitter/X archive view/action. + packageName: "@elizaos/plugin-birdclaw", + phase: "deferred", + required: false, + load: () => getOptionalPlugin("@elizaos/plugin-birdclaw"), + }, { packageName: "@elizaos/plugin-commands", phase: "deferred", diff --git a/packages/agent/src/runtime/plugin-collector-birdclaw.test.ts b/packages/agent/src/runtime/plugin-collector-birdclaw.test.ts new file mode 100644 index 0000000000000..5e85505940da3 --- /dev/null +++ b/packages/agent/src/runtime/plugin-collector-birdclaw.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ElizaConfig } from "../config/config.ts"; +import { collectPluginNames } from "./plugin-collector.ts"; + +const BIRDCLAW = "@elizaos/plugin-birdclaw"; + +const ENV_KEYS = [ + "ELIZA_PLATFORM", + "ELIZA_BIRDCLAW", + "BIRDCLAW_BIN", + "BIRDCLAW_HOME", + "HOME", + "PATH", +] as const; + +let savedEnv: Record; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + // A hermetic host: no birdclaw binary on PATH, no ~/.birdclaw, no overrides. + delete process.env.ELIZA_BIRDCLAW; + delete process.env.BIRDCLAW_BIN; + delete process.env.BIRDCLAW_HOME; + process.env.HOME = "/nonexistent-home-for-birdclaw-test"; + process.env.PATH = "/nonexistent-bin-for-birdclaw-test"; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + const v = savedEnv[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +}); + +describe("collectPluginNames birdclaw gate", () => { + it("stays off when the host has no birdclaw binary or data root", () => { + const names = collectPluginNames({} as ElizaConfig); + expect(names.has(BIRDCLAW)).toBe(false); + }); + + it("loads on ELIZA_BIRDCLAW=1 even without auto-detection", () => { + process.env.ELIZA_BIRDCLAW = "1"; + const names = collectPluginNames({} as ElizaConfig); + expect(names.has(BIRDCLAW)).toBe(true); + }); + + it("stays off on ELIZA_BIRDCLAW=0 even when a data root exists", () => { + process.env.ELIZA_BIRDCLAW = "0"; + // Point BIRDCLAW_HOME at a directory that certainly exists. + process.env.BIRDCLAW_HOME = process.cwd(); + const names = collectPluginNames({} as ElizaConfig); + expect(names.has(BIRDCLAW)).toBe(false); + }); + + it("auto-loads when BIRDCLAW_HOME points at an existing data root", () => { + process.env.BIRDCLAW_HOME = process.cwd(); + const names = collectPluginNames({} as ElizaConfig); + expect(names.has(BIRDCLAW)).toBe(true); + }); + + it("config birdclaw:true wins over a missing host install", () => { + const config = { + agents: { defaults: { birdclaw: true } }, + } as unknown as ElizaConfig; + const names = collectPluginNames(config); + expect(names.has(BIRDCLAW)).toBe(true); + }); + + it("config birdclaw:false wins over auto-detection", () => { + process.env.BIRDCLAW_HOME = process.cwd(); + const config = { + agents: { defaults: { birdclaw: false } }, + } as unknown as ElizaConfig; + const names = collectPluginNames(config); + expect(names.has(BIRDCLAW)).toBe(false); + }); + + it("never loads on mobile even when forced by env", () => { + process.env.ELIZA_PLATFORM = "android"; + process.env.ELIZA_BIRDCLAW = "1"; + const names = collectPluginNames({} as ElizaConfig); + expect(names.has(BIRDCLAW)).toBe(false); + }); +}); diff --git a/packages/agent/src/runtime/plugin-collector.ts b/packages/agent/src/runtime/plugin-collector.ts index abea12d787369..36aa70697c9b8 100644 --- a/packages/agent/src/runtime/plugin-collector.ts +++ b/packages/agent/src/runtime/plugin-collector.ts @@ -120,6 +120,41 @@ function gitpathologistRequested(config: ElizaConfig): boolean { return existsSync(path.join(resolveGitpathologistRepoRoot(), ".git")); } +/** + * Birdclaw (@elizaos/plugin-birdclaw) wraps the birdclaw CLI — a local-first + * Twitter/X archive (https://birdclaw.sh). Auto-loads when the host actually + * has birdclaw: the `birdclaw` binary on PATH, a `BIRDCLAW_BIN`/`BIRDCLAW_HOME` + * override, or an existing `~/.birdclaw` data root. Users can force it either + * way via config `birdclaw: true|false` or ELIZA_BIRDCLAW=1/0. + */ +function birdclawBinaryOnPath(): boolean { + const rawPath = process.env.PATH; + if (!rawPath) return false; + for (const dir of rawPath.split(path.delimiter)) { + if (!dir) continue; + if (existsSync(path.join(dir, "birdclaw"))) return true; + } + return false; +} + +function birdclawRequested(config: ElizaConfig): boolean { + const agentEntry = config.agents?.list?.[0]; + const fromEntry = agentEntry?.birdclaw; + const fromDefaults = config.agents?.defaults?.birdclaw; + if (typeof fromEntry === "boolean") return fromEntry; + if (typeof fromDefaults === "boolean") return fromDefaults; + const raw = process.env.ELIZA_BIRDCLAW?.trim().toLowerCase(); + if (raw === "0" || raw === "false" || raw === "no") return false; + if (raw === "1" || raw === "true" || raw === "yes") return true; + const bin = process.env.BIRDCLAW_BIN?.trim(); + if (bin && existsSync(bin)) return true; + const home = process.env.BIRDCLAW_HOME?.trim(); + if (home && existsSync(home)) return true; + const userHome = process.env.HOME?.trim(); + if (userHome && existsSync(path.join(userHome, ".birdclaw"))) return true; + return birdclawBinaryOnPath(); +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -463,6 +498,17 @@ export function collectPluginNames( "gitpathologist (auto-on when .git/ present; gate ELIZA_GITPATHOLOGIST)", ); } + // Mobile never gets birdclaw: the plugin shells out to the birdclaw CLI, + // which cannot exist inside a store-build sandbox — gating the whole plugin + // (not just spawning) keeps its launcher tile from appearing where the + // archive can never load. + if (!onMobile && birdclawRequested(config)) { + pluginsToLoad.add("@elizaos/plugin-birdclaw"); + track( + "@elizaos/plugin-birdclaw", + "birdclaw (auto-on when the birdclaw CLI/data root is present; gate ELIZA_BIRDCLAW)", + ); + } // Allow list is additive — extra plugins on top of auto-detection, // not an exclusive whitelist that blocks everything else. if (allowList && allowList.length > 0) { diff --git a/packages/app/scripts/birdclaw-live-e2e.mjs b/packages/app/scripts/birdclaw-live-e2e.mjs new file mode 100644 index 0000000000000..74a59424c2f25 --- /dev/null +++ b/packages/app/scripts/birdclaw-live-e2e.mjs @@ -0,0 +1,161 @@ +// One-shot live e2e for the Birdclaw view against a running dev stack. +// Usage: UI=http://127.0.0.1:2168 API=http://127.0.0.1:31437 OUT=/tmp/out \ +// node scripts/birdclaw-live-e2e.mjs +// Boots chromium, completes first-run (local → other provider) if shown, +// verifies the launcher lists Birdclaw, opens /birdclaw, exercises tabs, and +// captures desktop + phone screenshots plus console/network logs. +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { chromium } from "playwright"; + +const UI = process.env.UI ?? "http://127.0.0.1:2168"; +const API = process.env.API ?? "http://127.0.0.1:31437"; +const OUT = process.env.OUT ?? "/tmp/birdclaw-e2e"; +mkdirSync(OUT, { recursive: true }); + +const failures = []; +function check(name, ok, detail = "") { + const line = `${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`; + console.log(line); + if (!ok) failures.push(line); +} + +// 1) API-side truth first. +const views = await fetch(`${API}/api/views`).then((r) => r.json()); +const entry = (views.views || []).find((v) => v.id === "birdclaw"); +check("registry lists birdclaw view", Boolean(entry), JSON.stringify(entry?.path)); +const status = await fetch(`${API}/api/birdclaw/status`).then((r) => r.json()); +check( + "birdclaw status installed", + status?.status?.installed === true, + `version=${status?.status?.version} counts=${JSON.stringify(status?.status?.counts)}`, +); + +// Readiness gate: during deferred-plugin boot the agent's event loop is +// starved and every route crawls; drive the UI only once the data route +// answers quickly twice in a row. +for (let i = 0; i < 30; i++) { + const t0 = Date.now(); + const ok = await fetch(`${API}/api/birdclaw/tweets?resource=home&limit=1`) + .then((r) => r.ok) + .catch(() => false); + if (ok && Date.now() - t0 < 3000) { + const t1 = Date.now(); + const again = await fetch(`${API}/api/birdclaw/status`) + .then((r) => r.ok) + .catch(() => false); + if (again && Date.now() - t1 < 2000) break; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); +} + +const browser = await chromium.launch({ timeout: 120000 }); + +async function completeFirstRun(page) { + const chooser = page.getByTestId("first-run-runtime-chooser"); + const visible = await chooser.isVisible({ timeout: 3000 }).catch(() => false); + if (!visible) return "already-complete"; + await page.getByTestId("first-run-chooser-local").click({ timeout: 30000 }); + await page.getByTestId("first-run-provider-other").click({ timeout: 30000 }); + await chooser + .waitFor({ state: "hidden", timeout: 45000 }) + .catch(() => chooser.waitFor({ state: "detached", timeout: 45000 })); + return "completed"; +} + +async function drive(label, viewport) { + const ctx = await browser.newContext({ viewport }); + const page = await ctx.newPage(); + // Pin the agent base for this e2e stack: the shared dev laptop can have a + // second dev agent on the default port, and the client's fallback would + // resolve there (CORS-blocked) instead of this stack's API. + await page.addInitScript((apiBase) => { + window.__ELIZA_API_BASE__ = apiBase; + }, API); + const consoleErrors = []; + page.on("pageerror", (e) => consoleErrors.push(`pageerror: ${e.message}`)); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + + await page.goto(UI, { waitUntil: "domcontentloaded", timeout: 60000 }); + const firstRun = await completeFirstRun(page); + console.log(`[${label}] first-run: ${firstRun}`); + await page.waitForTimeout(4000); + + // Launcher: the Birdclaw tile should be present somewhere on the surface. + const launcherHasTile = await page + .getByText("Birdclaw", { exact: true }) + .first() + .isVisible({ timeout: 20000 }) + .catch(() => false); + await page.screenshot({ path: path.join(OUT, `${label}-launcher.png`), fullPage: false }); + check(`[${label}] launcher shows Birdclaw tile`, launcherHasTile); + + // Open the view directly by path (same URL the tile navigates to). + await page.goto(`${UI}/birdclaw`, { waitUntil: "domcontentloaded", timeout: 60000 }); + await page.waitForTimeout(5000); + const timelineTab = page.getByText("Timeline", { exact: true }).first(); + const viewLoaded = await timelineTab.isVisible({ timeout: 30000 }).catch(() => false); + check(`[${label}] /birdclaw renders the view (Timeline tab visible)`, viewLoaded); + // Wait out the loading state (a contended host can stall the first fetch + // chain), then assert a row from the seeded archive is on screen. + await page + .getByText("Loading archive") + .waitFor({ state: "hidden", timeout: 60000 }) + .catch(() => {}); + const rowVisible = await page + .getByText("@sam", { exact: false }) + .first() + .isVisible({ timeout: 20000 }) + .catch(() => false); + check(`[${label}] timeline shows seeded archive rows (@sam)`, rowVisible); + await page.screenshot({ path: path.join(OUT, `${label}-timeline.png`), fullPage: false }); + + // Tab interactions: Mentions (needs-reply nudge) and Inbox. + await page.getByText("Mentions", { exact: true }).first().click({ timeout: 10000 }); + await page.waitForTimeout(1500); + await page + .getByText("Loading archive") + .waitFor({ state: "hidden", timeout: 60000 }) + .catch(() => {}); + const nudge = await page + .getByText(/needs? a reply/i) + .first() + .isVisible({ timeout: 20000 }) + .catch(() => false); + check(`[${label}] mentions tab loads (needs-reply marker)`, nudge); + await page.screenshot({ path: path.join(OUT, `${label}-mentions.png`), fullPage: false }); + + await page.getByText("Inbox", { exact: true }).first().click({ timeout: 10000 }); + await page.waitForTimeout(2500); + await page.screenshot({ path: path.join(OUT, `${label}-inbox.png`), fullPage: false }); + + const fatal = consoleErrors.filter( + (line) => + !line.includes("favicon") && + !line.includes("404") && + !line.includes("net::ERR_ABORTED") && + // App-shell chrome, not the view under test: the floating chat's + // slash-command catalog load degrades gracefully by design ("omitting + // them from the slash menu") when the agent is still warming up. + !line.includes("useSlashCommandController"), + ); + check(`[${label}] no fatal console errors`, fatal.length === 0, fatal.slice(0, 3).join(" | ")); + writeFileSync( + path.join(OUT, `${label}-console.json`), + JSON.stringify(consoleErrors, null, 1), + ); + await ctx.close(); +} + +await drive("desktop", { width: 1440, height: 900 }); +await drive("phone", { width: 390, height: 844 }); + +await browser.close(); +writeFileSync( + path.join(OUT, "summary.json"), + JSON.stringify({ failures, ok: failures.length === 0 }, null, 1), +); +console.log(failures.length === 0 ? "ALL PASS" : `FAILURES:\n${failures.join("\n")}`); +process.exit(failures.length === 0 ? 0 : 1); diff --git a/packages/benchmarks/view-bundle-size/budgets.json b/packages/benchmarks/view-bundle-size/budgets.json index 36d6d21251ea7..fe17852c14ac5 100644 --- a/packages/benchmarks/view-bundle-size/budgets.json +++ b/packages/benchmarks/view-bundle-size/budgets.json @@ -19,6 +19,11 @@ "measuredGzipBytes": 2245, "gzipBudgetBytes": 3000 }, + "plugin-birdclaw": { + "measuredRawBytes": 9930, + "measuredGzipBytes": 3270, + "gzipBudgetBytes": 3800 + }, "plugin-blocker": { "measuredRawBytes": 5863, "measuredGzipBytes": 1870, @@ -140,4 +145,4 @@ "gzipBudgetBytes": 19000 } } -} \ No newline at end of file +} diff --git a/packages/shared/src/config/types.agent-defaults.ts b/packages/shared/src/config/types.agent-defaults.ts index ab54c482ccd76..5c09f11698dcc 100644 --- a/packages/shared/src/config/types.agent-defaults.ts +++ b/packages/shared/src/config/types.agent-defaults.ts @@ -274,6 +274,11 @@ export type AgentDefaultsConfig = { * Unset = auto-on when the workspace has a `.git` directory. */ gitpathologist?: boolean; + /** + * Enable @elizaos/plugin-birdclaw (birdclaw.sh local-first Twitter/X archive) by default. + * Unset = auto-on when the birdclaw CLI or a `~/.birdclaw` data root is present. + */ + birdclaw?: boolean; /** Default thinking level when no /think directive is present. */ thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; /** Default verbose level when no /verbose directive is present. */ diff --git a/packages/shared/src/config/types.agents.ts b/packages/shared/src/config/types.agents.ts index 838134bb9ff62..e6982816cacf2 100644 --- a/packages/shared/src/config/types.agents.ts +++ b/packages/shared/src/config/types.agents.ts @@ -54,6 +54,11 @@ export type AgentConfig = { * Unset = auto-on when the workspace has a `.git` directory. */ gitpathologist?: boolean; + /** + * Enable @elizaos/plugin-birdclaw (birdclaw.sh local-first Twitter/X archive) for this agent. + * Unset = auto-on when the birdclaw CLI or a `~/.birdclaw` data root is present. + */ + birdclaw?: boolean; // ── Personality fields (set during first-run setup from style presets) ────── /** Agent bio lines. Set during first-run setup from the chosen style preset. */ diff --git a/plugins/plugin-birdclaw/AGENTS.md b/plugins/plugin-birdclaw/AGENTS.md new file mode 100644 index 0000000000000..bd79168ce2fb5 --- /dev/null +++ b/plugins/plugin-birdclaw/AGENTS.md @@ -0,0 +1,141 @@ +# @elizaos/plugin-birdclaw + +Birdclaw (https://birdclaw.sh) local-first Twitter/X memory for elizaOS: the +agent and the app get a typed window onto the owner's private Twitter archive +— archived timeline, mentions, DMs, likes, and bookmarks in a local SQLite +database — plus live-sync triggers and AI digests. + +## Purpose / role + +[birdclaw](https://github.com/steipete/birdclaw) is steipete's local-first +Twitter workspace: `birdclaw init` + archive import + optional `xurl`/`bird` +transports populate a single SQLite DB (default `~/.birdclaw/`). Its stable +integration surface is the CLI — "stable `--json` envelopes go to stdout, +progress and warnings to stderr". This plugin wraps that CLI with a service, +gives the agent an owner-gated `BIRDCLAW` action, serves `/api/birdclaw/*` +routes, and registers the **Birdclaw** launcher view (GUI + XR + TUI from one +spatial component). + +Everything degrades explicitly when birdclaw is missing: the view renders a +setup screen with install guidance, data routes return +`503 { installed: false }`, and the action stops validating (the planner never +offers a dead capability). + +## Enablement + +Auto-on when the host actually has birdclaw (see `birdclawRequested` in +`packages/agent/src/runtime/plugin-collector.ts`): the `birdclaw` binary on +PATH, `BIRDCLAW_BIN`/`BIRDCLAW_HOME` set, or an existing `~/.birdclaw` root. +Never loads on mobile (the CLI cannot exist in a store-build sandbox, so the +tile never appears where it cannot work). Force with config +`agents.defaults.birdclaw: true|false` or `ELIZA_BIRDCLAW=1/0`. Also listed in +`OPTIONAL_CORE_PLUGINS` for admin-panel enablement. + +## Plugin surface + +### Service + +- `BirdclawService` (`serviceType = "BIRDCLAW_SERVICE"`, `src/birdclaw/service.ts`) + — binary resolution (`BIRDCLAW_BIN` → `birdclaw` on PATH), a 30s-cached + availability probe (`--version`), and typed methods: `status()` (db stats + + transport), `searchTweets()`, `inbox()`, `sync(collection)`, + `digest(period)`. Spawns via `execFile` argv arrays (no shell), with an + env **allowlist** (PATH, HOME, `BIRDCLAW_HOME`, plus `OPENAI_API_KEY` only + from the dedicated `BIRDCLAW_OPENAI_API_KEY` knob) — the agent's own + provider keys are never inherited by the CLI. + +### Action + +- `BIRDCLAW` (`src/actions/birdclaw.ts`) — owner-only umbrella action with op + dispatch: `search` (full-text over home/mentions/authored, `--liked`, + `--bookmarked`), `inbox` (ranked mention/DM triage), `sync` + (timeline|mentions|authored|likes|bookmarks), `digest` + (today|24h|yesterday|week; needs birdclaw's OpenAI key), `status`. + `validate` requires the service to be registered AND the CLI installed + (cached probe — no per-message process storm). + +### Routes (`src/routes/birdclaw-routes.ts`, private, `rawPath`) + +- `GET /api/birdclaw/status` — always 200; `{ installed: false, message }` + drives the view's setup screen. +- `GET /api/birdclaw/tweets?resource=&q=&liked=&bookmarked=&limit=` +- `GET /api/birdclaw/inbox?kind=&limit=` +- `POST /api/birdclaw/sync { collection }` +- `POST /api/birdclaw/digest { period }` +- Errors: 400 invalid params, 502 CLI failure (stderr tail in `error`), + 503 service/binary missing. + +### View + +- `birdclaw` — `BirdclawView`, path `/birdclaw`, bundle + `dist/views/bundle.js`, modalities `["gui","xr","tui"]`. Tabs: Timeline / + Mentions / Posted / Likes / Bookmarks / Inbox; per-tab Sync button (shown + only when a live transport is installed); needs-reply nudge; chat handoff on + "Ask" (per the chat-first design law, free-form search lives in the floating + chat via the BIRDCLAW action, not an input in the view). + +## Layout + +``` +src/ + index.ts Public API barrel + plugin.ts birdclawPlugin (service + action + routes + view) + types.ts Display DTOs + enum guards (resources, kinds, collections, periods) + birdclaw/ + cli.ts execFile seam: BirdclawExec, runBirdclawJson/Text, typed BirdclawCliError + service.ts BirdclawService + pure arg builders + wire parsers + birdclaw.real.test.ts REAL-CLI suite (gated; bun run test:real) + actions/birdclaw.ts BIRDCLAW umbrella action + formatters + routes/birdclaw-routes.ts status/tweets/inbox/sync/digest handlers + components/birdclaw/ + BirdclawSpatialView.tsx Presentational (snapshot + onAction, spatial primitives) + BirdclawView.tsx Data wrapper (fetcher seam, tab state machine, sync flow) + birdclaw-view-bundle.ts Vite bundle entry — re-exports BirdclawView +``` + +## Commands + +```bash +bun run --cwd plugins/plugin-birdclaw typecheck +bun run --cwd plugins/plugin-birdclaw lint +bun run --cwd plugins/plugin-birdclaw test # offline unit suite (fake exec seam) +bun run --cwd plugins/plugin-birdclaw test:real # REAL birdclaw CLI against a temp BIRDCLAW_HOME +bun run --cwd plugins/plugin-birdclaw build # build:js (tsup) + build:views (vite) + build:types (tsc) +``` + +## Config / env vars + +| Variable | Default | Purpose | +|---|---|---| +| `BIRDCLAW_BIN` | `birdclaw` on PATH | Absolute path to the birdclaw CLI. | +| `BIRDCLAW_HOME` | CLI's own `~/.birdclaw` | Data root passed to every spawn. | +| `BIRDCLAW_OPENAI_API_KEY` | — | Forwarded to the CLI as `OPENAI_API_KEY` for its AI features (digest, inbox scoring). Never falls back to the agent's own key. | +| `ELIZA_BIRDCLAW` | auto-detect | `1/0` force-enables/disables plugin loading (collector gate). | + +## Conventions / gotchas + +- **Exit code is the only success signal.** birdclaw writes warnings (e.g. + Node 22's `node:sqlite` ExperimentalWarning) to stderr on successful runs — + never treat stderr output as failure. +- **`search tweets` returns a top-level JSON array; `inbox` returns + `{ items }`.** The parsers narrow both to flat display DTOs and skip + malformed rows (schema churn upstream is expected — "WIP. Real and usable"). +- **The digest envelope is still settling upstream** — `digest()` accepts a + JSON envelope (`digest`/`text`/`report` field) or raw markdown. +- **Timeouts:** reads 30s, sync/digest 120s. Sync runs synchronously in the + route; the view disables the button while in flight. +- **No background poll in the view.** The archive only changes via + sync/import, so the view reloads after its own syncs instead of polling. +- The view-bundle vite config must keep `codeSplitting: false` (shared config + does this) — see the #11040 blank-view regression. +- See the root `AGENTS.md` for repo-wide conventions. + +## ⛔ NON-NEGOTIABLE — evidence & real end-to-end tests + +The binding standard is **[PR_EVIDENCE.md](../../PR_EVIDENCE.md)**. The unit +suite proves the arg-building/parsing/route/action/view logic against an +injected exec seam; `birdclaw.real.test.ts` (`bun run test:real`) drives the +REAL CLI — real spawn, real SQLite, real `--json` envelopes — against a +throwaway `BIRDCLAW_HOME` seeded by `birdclaw init`. UI changes additionally +require the rendered proof set (screenshots, walkthrough, client+server logs) +from `PR_EVIDENCE.md`. diff --git a/plugins/plugin-birdclaw/CLAUDE.md b/plugins/plugin-birdclaw/CLAUDE.md new file mode 100644 index 0000000000000..bd79168ce2fb5 --- /dev/null +++ b/plugins/plugin-birdclaw/CLAUDE.md @@ -0,0 +1,141 @@ +# @elizaos/plugin-birdclaw + +Birdclaw (https://birdclaw.sh) local-first Twitter/X memory for elizaOS: the +agent and the app get a typed window onto the owner's private Twitter archive +— archived timeline, mentions, DMs, likes, and bookmarks in a local SQLite +database — plus live-sync triggers and AI digests. + +## Purpose / role + +[birdclaw](https://github.com/steipete/birdclaw) is steipete's local-first +Twitter workspace: `birdclaw init` + archive import + optional `xurl`/`bird` +transports populate a single SQLite DB (default `~/.birdclaw/`). Its stable +integration surface is the CLI — "stable `--json` envelopes go to stdout, +progress and warnings to stderr". This plugin wraps that CLI with a service, +gives the agent an owner-gated `BIRDCLAW` action, serves `/api/birdclaw/*` +routes, and registers the **Birdclaw** launcher view (GUI + XR + TUI from one +spatial component). + +Everything degrades explicitly when birdclaw is missing: the view renders a +setup screen with install guidance, data routes return +`503 { installed: false }`, and the action stops validating (the planner never +offers a dead capability). + +## Enablement + +Auto-on when the host actually has birdclaw (see `birdclawRequested` in +`packages/agent/src/runtime/plugin-collector.ts`): the `birdclaw` binary on +PATH, `BIRDCLAW_BIN`/`BIRDCLAW_HOME` set, or an existing `~/.birdclaw` root. +Never loads on mobile (the CLI cannot exist in a store-build sandbox, so the +tile never appears where it cannot work). Force with config +`agents.defaults.birdclaw: true|false` or `ELIZA_BIRDCLAW=1/0`. Also listed in +`OPTIONAL_CORE_PLUGINS` for admin-panel enablement. + +## Plugin surface + +### Service + +- `BirdclawService` (`serviceType = "BIRDCLAW_SERVICE"`, `src/birdclaw/service.ts`) + — binary resolution (`BIRDCLAW_BIN` → `birdclaw` on PATH), a 30s-cached + availability probe (`--version`), and typed methods: `status()` (db stats + + transport), `searchTweets()`, `inbox()`, `sync(collection)`, + `digest(period)`. Spawns via `execFile` argv arrays (no shell), with an + env **allowlist** (PATH, HOME, `BIRDCLAW_HOME`, plus `OPENAI_API_KEY` only + from the dedicated `BIRDCLAW_OPENAI_API_KEY` knob) — the agent's own + provider keys are never inherited by the CLI. + +### Action + +- `BIRDCLAW` (`src/actions/birdclaw.ts`) — owner-only umbrella action with op + dispatch: `search` (full-text over home/mentions/authored, `--liked`, + `--bookmarked`), `inbox` (ranked mention/DM triage), `sync` + (timeline|mentions|authored|likes|bookmarks), `digest` + (today|24h|yesterday|week; needs birdclaw's OpenAI key), `status`. + `validate` requires the service to be registered AND the CLI installed + (cached probe — no per-message process storm). + +### Routes (`src/routes/birdclaw-routes.ts`, private, `rawPath`) + +- `GET /api/birdclaw/status` — always 200; `{ installed: false, message }` + drives the view's setup screen. +- `GET /api/birdclaw/tweets?resource=&q=&liked=&bookmarked=&limit=` +- `GET /api/birdclaw/inbox?kind=&limit=` +- `POST /api/birdclaw/sync { collection }` +- `POST /api/birdclaw/digest { period }` +- Errors: 400 invalid params, 502 CLI failure (stderr tail in `error`), + 503 service/binary missing. + +### View + +- `birdclaw` — `BirdclawView`, path `/birdclaw`, bundle + `dist/views/bundle.js`, modalities `["gui","xr","tui"]`. Tabs: Timeline / + Mentions / Posted / Likes / Bookmarks / Inbox; per-tab Sync button (shown + only when a live transport is installed); needs-reply nudge; chat handoff on + "Ask" (per the chat-first design law, free-form search lives in the floating + chat via the BIRDCLAW action, not an input in the view). + +## Layout + +``` +src/ + index.ts Public API barrel + plugin.ts birdclawPlugin (service + action + routes + view) + types.ts Display DTOs + enum guards (resources, kinds, collections, periods) + birdclaw/ + cli.ts execFile seam: BirdclawExec, runBirdclawJson/Text, typed BirdclawCliError + service.ts BirdclawService + pure arg builders + wire parsers + birdclaw.real.test.ts REAL-CLI suite (gated; bun run test:real) + actions/birdclaw.ts BIRDCLAW umbrella action + formatters + routes/birdclaw-routes.ts status/tweets/inbox/sync/digest handlers + components/birdclaw/ + BirdclawSpatialView.tsx Presentational (snapshot + onAction, spatial primitives) + BirdclawView.tsx Data wrapper (fetcher seam, tab state machine, sync flow) + birdclaw-view-bundle.ts Vite bundle entry — re-exports BirdclawView +``` + +## Commands + +```bash +bun run --cwd plugins/plugin-birdclaw typecheck +bun run --cwd plugins/plugin-birdclaw lint +bun run --cwd plugins/plugin-birdclaw test # offline unit suite (fake exec seam) +bun run --cwd plugins/plugin-birdclaw test:real # REAL birdclaw CLI against a temp BIRDCLAW_HOME +bun run --cwd plugins/plugin-birdclaw build # build:js (tsup) + build:views (vite) + build:types (tsc) +``` + +## Config / env vars + +| Variable | Default | Purpose | +|---|---|---| +| `BIRDCLAW_BIN` | `birdclaw` on PATH | Absolute path to the birdclaw CLI. | +| `BIRDCLAW_HOME` | CLI's own `~/.birdclaw` | Data root passed to every spawn. | +| `BIRDCLAW_OPENAI_API_KEY` | — | Forwarded to the CLI as `OPENAI_API_KEY` for its AI features (digest, inbox scoring). Never falls back to the agent's own key. | +| `ELIZA_BIRDCLAW` | auto-detect | `1/0` force-enables/disables plugin loading (collector gate). | + +## Conventions / gotchas + +- **Exit code is the only success signal.** birdclaw writes warnings (e.g. + Node 22's `node:sqlite` ExperimentalWarning) to stderr on successful runs — + never treat stderr output as failure. +- **`search tweets` returns a top-level JSON array; `inbox` returns + `{ items }`.** The parsers narrow both to flat display DTOs and skip + malformed rows (schema churn upstream is expected — "WIP. Real and usable"). +- **The digest envelope is still settling upstream** — `digest()` accepts a + JSON envelope (`digest`/`text`/`report` field) or raw markdown. +- **Timeouts:** reads 30s, sync/digest 120s. Sync runs synchronously in the + route; the view disables the button while in flight. +- **No background poll in the view.** The archive only changes via + sync/import, so the view reloads after its own syncs instead of polling. +- The view-bundle vite config must keep `codeSplitting: false` (shared config + does this) — see the #11040 blank-view regression. +- See the root `AGENTS.md` for repo-wide conventions. + +## ⛔ NON-NEGOTIABLE — evidence & real end-to-end tests + +The binding standard is **[PR_EVIDENCE.md](../../PR_EVIDENCE.md)**. The unit +suite proves the arg-building/parsing/route/action/view logic against an +injected exec seam; `birdclaw.real.test.ts` (`bun run test:real`) drives the +REAL CLI — real spawn, real SQLite, real `--json` envelopes — against a +throwaway `BIRDCLAW_HOME` seeded by `birdclaw init`. UI changes additionally +require the rendered proof set (screenshots, walkthrough, client+server logs) +from `PR_EVIDENCE.md`. diff --git a/plugins/plugin-birdclaw/README.md b/plugins/plugin-birdclaw/README.md new file mode 100644 index 0000000000000..fad250afd5830 --- /dev/null +++ b/plugins/plugin-birdclaw/README.md @@ -0,0 +1,49 @@ +# @elizaos/plugin-birdclaw + +[Birdclaw](https://birdclaw.sh) local-first Twitter/X memory for elizaOS. + +Birdclaw keeps a private archive of your Twitter/X life — timeline, mentions, +DMs, likes, bookmarks — in a single SQLite database on your machine, imported +from your Twitter archive and optionally kept fresh with live syncs. This +plugin connects that archive to your Eliza agent: + +- **Birdclaw app** in the launcher — browse Timeline / Mentions / Posted / + Likes / Bookmarks / Inbox, trigger live syncs, see who still needs a reply. + One spatial component renders the same view on GUI, XR, and the terminal. +- **`BIRDCLAW` agent action** — ask your agent things like *"search my tweets + for that thread about sync engines"*, *"which mentions still need a reply?"*, + *"pull my latest bookmarks"*, or *"what happened on Twitter today?"* + (digest). Owner-only: this is your private archive. +- **`/api/birdclaw/*` routes** — status, tweets, inbox, sync, digest; the view + and any other client drive these. + +## Setup + +```bash +brew install steipete/tap/birdclaw # or build from source (Node 26) +birdclaw init # create ~/.birdclaw + seed the demo data +birdclaw import archive # import your Twitter archive +``` + +That's it — the agent auto-loads the plugin when it finds the `birdclaw` +binary (or an existing `~/.birdclaw`). No config required. Without birdclaw +installed the app shows a setup screen and the agent action stays dormant. + +Optional knobs: `BIRDCLAW_BIN` (explicit binary path), `BIRDCLAW_HOME` +(alternate data root), `BIRDCLAW_OPENAI_API_KEY` (enables birdclaw's AI +digest/scoring), `ELIZA_BIRDCLAW=1/0` (force on/off). + +Live sync needs one of birdclaw's transports (`xurl` OAuth2 or `bird` browser +cookies) — see the [birdclaw docs](https://github.com/steipete/birdclaw). +Everything else (browse, search, inbox, digest) works on the local archive +alone. + +## Development + +```bash +bun run --cwd plugins/plugin-birdclaw test # offline unit suite +bun run --cwd plugins/plugin-birdclaw test:real # against the real CLI +bun run --cwd plugins/plugin-birdclaw build +``` + +See [CLAUDE.md](./CLAUDE.md) for the full surface, layout, and gotchas. diff --git a/plugins/plugin-birdclaw/assets/hero.svg b/plugins/plugin-birdclaw/assets/hero.svg new file mode 100644 index 0000000000000..1eddbba7b51f1 --- /dev/null +++ b/plugins/plugin-birdclaw/assets/hero.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Birdclaw + + + + diff --git a/plugins/plugin-birdclaw/package.json b/plugins/plugin-birdclaw/package.json new file mode 100644 index 0000000000000..9c1e90a9d44c9 --- /dev/null +++ b/plugins/plugin-birdclaw/package.json @@ -0,0 +1,115 @@ +{ + "name": "@elizaos/plugin-birdclaw", + "version": "1.0.0", + "type": "module", + "description": "Birdclaw (birdclaw.sh) local-first Twitter/X memory for elizaOS — browse and search your archived timeline, mentions, likes, and bookmarks, trigger live syncs, and let the agent answer from your local Twitter archive.", + "main": "./dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/elizaos/eliza.git" + }, + "scripts": { + "typecheck": "tsgo --noEmit -p tsconfig.json", + "lint": "bunx @biomejs/biome check src/", + "lint:check": "bun run lint", + "format": "bunx @biomejs/biome format --write src/", + "format:check": "bunx @biomejs/biome format src/", + "test": "bunx vitest run --config ./vitest.config.ts", + "test:real": "BIRDCLAW_REAL_TESTS=1 bunx vitest run --config ./vitest.config.ts src/birdclaw/birdclaw.real.test.ts", + "build": "bun run build:js && bun run build:views && bun run build:types", + "clean": "node ../../packages/scripts/rm-path-recursive.mjs dist", + "build:js": "tsup --config ../tsup.plugin-packages.shared.ts", + "build:views": "bunx --bun vite build --config vite.config.views.ts", + "build:types": "tsc --noCheck -p tsconfig.build.json && node ../../packages/scripts/rewrite-dist-relative-imports-node-esm.mjs" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "eliza-source": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + }, + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./plugin": { + "types": "./dist/plugin.d.ts", + "eliza-source": { + "types": "./src/plugin.ts", + "import": "./src/plugin.ts", + "default": "./src/plugin.ts" + }, + "import": "./dist/plugin.js", + "default": "./dist/plugin.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "eliza-source": { + "types": "./src/*.ts", + "import": "./src/*.ts", + "default": "./src/*.ts" + }, + "import": "./dist/*.js", + "default": "./dist/*.js" + } + }, + "dependencies": { + "@elizaos/core": "workspace:*", + "@elizaos/ui": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@biomejs/biome": "2.5.2", + "@types/node": "^25.0.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vite": "^8.0.0", + "vitest": "^4.0.17" + }, + "elizaos": { + "app": { + "displayName": "Birdclaw", + "category": "social" + } + }, + "agentConfig": { + "pluginType": "elizaos:plugin:1.0.0", + "pluginParameters": { + "BIRDCLAW_BIN": { + "type": "string", + "description": "Absolute path to the birdclaw CLI. Defaults to resolving `birdclaw` on PATH.", + "required": false, + "sensitive": false + }, + "BIRDCLAW_HOME": { + "type": "string", + "description": "Birdclaw data root passed to the CLI (config, SQLite DB, media cache). Defaults to the CLI's own ~/.birdclaw.", + "required": false, + "sensitive": false + }, + "BIRDCLAW_OPENAI_API_KEY": { + "type": "string", + "description": "OpenAI API key forwarded to birdclaw for its AI features (digest, inbox scoring). Optional — everything else works without it.", + "required": false, + "sensitive": true + } + } + }, + "publishConfig": { + "access": "public" + }, + "types": "./dist/index.d.ts", + "files": [ + "assets", + "dist" + ] +} diff --git a/plugins/plugin-birdclaw/src/actions/birdclaw.test.ts b/plugins/plugin-birdclaw/src/actions/birdclaw.test.ts new file mode 100644 index 0000000000000..01feb8a1c7fa2 --- /dev/null +++ b/plugins/plugin-birdclaw/src/actions/birdclaw.test.ts @@ -0,0 +1,248 @@ +import type { IAgentRuntime, Memory } from "@elizaos/core"; +import { describe, expect, it } from "vitest"; +import type { BirdclawExec } from "../birdclaw/cli.ts"; +import { BirdclawCliError } from "../birdclaw/cli.ts"; +import { BirdclawService } from "../birdclaw/service.ts"; +import { + birdclawAction, + formatInboxLines, + formatStatusLine, + formatTweetLines, +} from "./birdclaw.ts"; + +const MESSAGE = { id: "msg-1" } as unknown as Memory; + +function runtimeWith(service: BirdclawService | null): IAgentRuntime { + return { + getSetting: () => undefined, + getService: (type: string) => + type === BirdclawService.serviceType ? service : null, + } as unknown as IAgentRuntime; +} + +function serviceWith( + respond: (args: readonly string[]) => { stdout: string } | Error, +): BirdclawService { + const exec: BirdclawExec = async (_bin, args) => { + const result = respond(args); + if (result instanceof Error) throw result; + return { stdout: result.stdout, stderr: "" }; + }; + return new BirdclawService( + { getSetting: () => undefined } as unknown as IAgentRuntime, + { exec }, + ); +} + +const SEARCH_PAYLOAD = JSON.stringify([ + { + id: "t1", + text: "Local-first sync engines beat manual export.", + createdAt: "2026-03-08T11:18:00.000Z", + likeCount: 42, + liked: true, + bookmarked: false, + author: { handle: "amelia", displayName: "Amelia N" }, + }, +]); + +describe("BIRDCLAW validate", () => { + it("is false when the service is missing", async () => { + await expect(birdclawAction.validate(runtimeWith(null))).resolves.toBe( + false, + ); + }); + + it("is false when the binary is missing, true when installed", async () => { + const missing = serviceWith( + () => new BirdclawCliError("not-installed", "not found"), + ); + await expect(birdclawAction.validate(runtimeWith(missing))).resolves.toBe( + false, + ); + + const installed = serviceWith(() => ({ stdout: "0.8.5" })); + await expect(birdclawAction.validate(runtimeWith(installed))).resolves.toBe( + true, + ); + }); +}); + +describe("BIRDCLAW handler", () => { + it("searches and reports formatted results through the callback", async () => { + const service = serviceWith((args) => + args[0] === "search" ? { stdout: SEARCH_PAYLOAD } : { stdout: "0.8.5" }, + ); + const said: string[] = []; + const result = await birdclawAction.handler( + runtimeWith(service), + MESSAGE, + undefined, + { parameters: { action: "search", query: "sync engines" } }, + async (content) => { + if (typeof content.text === "string") said.push(content.text); + return []; + }, + ); + expect(result.success).toBe(true); + expect(result.text).toContain("@amelia"); + expect(result.text).toContain("♥42"); + expect(said).toHaveLength(1); + expect(result.data).toMatchObject({ subaction: "search" }); + }); + + it("treats a bare query as a search intent", async () => { + const service = serviceWith((args) => + args[0] === "search" ? { stdout: "[]" } : { stdout: "0.8.5" }, + ); + const result = await birdclawAction.handler( + runtimeWith(service), + MESSAGE, + undefined, + { parameters: { query: "anything" } }, + undefined, + ); + expect(result.success).toBe(true); + expect(result.text).toContain("No archived tweets found"); + }); + + it("summarizes the inbox with a needs-reply headline", async () => { + const service = serviceWith((args) => + args[0] === "inbox" + ? { + stdout: JSON.stringify({ + items: [ + { + id: "m1", + entityKind: "mention", + title: "Mention", + text: "ping", + createdAt: "2026-03-08T11:48:00.000Z", + needsReply: true, + participant: { handle: "amelia" }, + }, + ], + }), + } + : { stdout: "0.8.5" }, + ); + const result = await birdclawAction.handler( + runtimeWith(service), + MESSAGE, + undefined, + { parameters: { action: "inbox", kind: "mentions" } }, + undefined, + ); + expect(result.success).toBe(true); + expect(result.text).toContain("1 still needs a reply"); + expect(result.text).toContain("@amelia"); + }); + + it("requires a valid collection for sync", async () => { + const service = serviceWith(() => ({ stdout: "0.8.5" })); + const result = await birdclawAction.handler( + runtimeWith(service), + MESSAGE, + undefined, + { parameters: { action: "sync", collection: "everything" } }, + undefined, + ); + expect(result.success).toBe(false); + expect(result.data).toMatchObject({ error: "INVALID_COLLECTION" }); + }); + + it("reports CLI failures as a failed result, not a throw", async () => { + const service = serviceWith((args) => + args[0] === "sync" + ? new BirdclawCliError("failed", "xurl not installed") + : { stdout: "0.8.5" }, + ); + const result = await birdclawAction.handler( + runtimeWith(service), + MESSAGE, + undefined, + { parameters: { action: "sync", collection: "timeline" } }, + undefined, + ); + expect(result.success).toBe(false); + expect(result.text).toContain("xurl not installed"); + }); + + it("rejects unknown subactions with guidance", async () => { + const service = serviceWith(() => ({ stdout: "0.8.5" })); + const result = await birdclawAction.handler( + runtimeWith(service), + MESSAGE, + undefined, + { parameters: { action: "tweetstorm" } }, + undefined, + ); + expect(result.success).toBe(false); + expect(result.data).toMatchObject({ error: "UNKNOWN_SUBACTION" }); + }); +}); + +describe("formatters", () => { + it("formats tweet lines with likes and marks", () => { + const lines = formatTweetLines([ + { + id: "t1", + text: "hello", + createdAt: "2026-03-08T11:18:00.000Z", + authorHandle: "steipete", + authorName: "Peter", + likeCount: 9, + liked: true, + bookmarked: true, + isReplied: null, + kind: "home", + }, + ]); + expect(lines).toBe( + "• @steipete — hello (♥9) [liked, bookmarked] · 2026-03-08", + ); + }); + + it("formats inbox lines with the needs-reply marker", () => { + const lines = formatInboxLines([ + { + id: "m1", + kind: "mention", + title: "Mention", + text: "ping", + createdAt: "2026-03-08T11:48:00.000Z", + needsReply: true, + score: 76, + participantHandle: "amelia", + }, + ]); + expect(lines).toBe("• @amelia: ping — needs a reply · 2026-03-08"); + }); + + it("formats status lines for installed and missing states", () => { + expect( + formatStatusLine({ + installed: false, + version: null, + home: null, + counts: null, + transport: null, + message: "install it", + }), + ).toBe("install it"); + expect( + formatStatusLine({ + installed: true, + version: "0.8.5", + home: "/home/user/.birdclaw", + counts: { home: 4, mentions: 2, dms: 4, needsReply: 2, inbox: 4 }, + transport: { + installed: false, + availableTransport: "local", + statusText: "xurl not installed. local mode active.", + }, + message: null, + }), + ).toContain("4 timeline, 2 mentions, 4 DMs (2 need a reply)"); + }); +}); diff --git a/plugins/plugin-birdclaw/src/actions/birdclaw.ts b/plugins/plugin-birdclaw/src/actions/birdclaw.ts new file mode 100644 index 0000000000000..38a72d9db70a8 --- /dev/null +++ b/plugins/plugin-birdclaw/src/actions/birdclaw.ts @@ -0,0 +1,404 @@ +/** + * `BIRDCLAW` umbrella action — the agent's hands on the owner's local-first + * Twitter/X archive (birdclaw.sh). + * + * Ops: + * - `search` — full-text search the archived tweets (optionally scoped to + * mentions/authored, liked-only, bookmarked-only) + * - `inbox` — ranked mention/DM triage ("who needs a reply?") + * - `sync` — refresh a live collection into the local store + * - `digest` — AI digest of what happened (requires birdclaw's OpenAI key) + * - `status` — install/dataset/transport state + * + * Owner-only: the archive is the owner's private Twitter memory (tweets, + * mentions, DMs). Validation requires the service to be registered AND the + * CLI to actually be installed, so the planner never offers a dead action. + */ + +import type { + ActionExample, + ActionResult, + HandlerCallback, + IAgentRuntime, + Memory, +} from "@elizaos/core"; +import { logger } from "@elizaos/core"; +import { BirdclawService } from "../birdclaw/service.ts"; +import { + BIRDCLAW_DIGEST_PERIODS, + BIRDCLAW_INBOX_KINDS, + BIRDCLAW_RESOURCES, + BIRDCLAW_SYNC_COLLECTIONS, + type BirdclawInboxItem, + type BirdclawStatusInfo, + type BirdclawTweet, + isBirdclawDigestPeriod, + isBirdclawInboxKind, + isBirdclawResource, + isBirdclawSyncCollection, +} from "../types.ts"; + +const ACTION_NAME = "BIRDCLAW"; + +const SUBACTIONS = ["search", "inbox", "sync", "digest", "status"] as const; +type Subaction = (typeof SUBACTIONS)[number]; + +export interface BirdclawActionParameters { + action?: string; + op?: string; + query?: string; + resource?: string; + liked?: boolean; + bookmarked?: boolean; + limit?: number; + kind?: string; + collection?: string; + period?: string; +} + +function getService(runtime: IAgentRuntime): BirdclawService | null { + return ( + (runtime.getService( + BirdclawService.serviceType, + ) as BirdclawService | null) ?? null + ); +} + +function getParams(options: unknown): BirdclawActionParameters { + if (typeof options !== "object" || options === null) return {}; + const record = options as Record; + const inner = record.parameters; + if (typeof inner === "object" && inner !== null) { + return inner as BirdclawActionParameters; + } + return record as BirdclawActionParameters; +} + +function resolveSubaction(params: BirdclawActionParameters): Subaction | null { + const raw = (params.action ?? params.op ?? "") + .toString() + .trim() + .toLowerCase(); + if ((SUBACTIONS as readonly string[]).includes(raw)) return raw as Subaction; + // A bare query with no explicit op is a search intent. + if (!raw && typeof params.query === "string" && params.query.trim()) { + return "search"; + } + return null; +} + +function formatTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toISOString().slice(0, 10); +} + +export function formatTweetLines( + tweets: readonly BirdclawTweet[], + cap = 10, +): string { + return tweets + .slice(0, cap) + .map((tweet) => { + const author = tweet.authorHandle ? `@${tweet.authorHandle}` : "unknown"; + const likes = tweet.likeCount !== null ? ` (♥${tweet.likeCount})` : ""; + const marks = [ + tweet.liked ? "liked" : null, + tweet.bookmarked ? "bookmarked" : null, + ] + .filter(Boolean) + .join(", "); + const suffix = marks ? ` [${marks}]` : ""; + return `• ${author} — ${tweet.text}${likes}${suffix} · ${formatTime(tweet.createdAt)}`; + }) + .join("\n"); +} + +export function formatInboxLines( + items: readonly BirdclawInboxItem[], + cap = 10, +): string { + return items + .slice(0, cap) + .map((item) => { + const who = item.participantHandle + ? `@${item.participantHandle}` + : item.kind; + const reply = item.needsReply ? " — needs a reply" : ""; + return `• ${who}: ${item.text}${reply} · ${formatTime(item.createdAt)}`; + }) + .join("\n"); +} + +export function formatStatusLine(status: BirdclawStatusInfo): string { + if (!status.installed) { + return status.message ?? "birdclaw is not installed."; + } + const counts = status.counts + ? ` Archive: ${status.counts.home} timeline, ${status.counts.mentions} mentions, ${status.counts.dms} DMs (${status.counts.needsReply} need a reply).` + : ""; + const transport = status.transport + ? ` Live sync: ${status.transport.statusText}` + : ""; + return `birdclaw ${status.version ?? ""} is installed.${counts}${transport}`.trim(); +} + +async function runSubaction( + service: BirdclawService, + subaction: Subaction, + params: BirdclawActionParameters, +): Promise { + switch (subaction) { + case "search": { + const resource = + params.resource && isBirdclawResource(params.resource) + ? params.resource + : "home"; + const tweets = await service.searchTweets({ + query: params.query, + resource, + liked: params.liked === true, + bookmarked: params.bookmarked === true, + limit: params.limit, + }); + if (tweets.length === 0) { + const scope = params.query ? ` for "${params.query}"` : ""; + return { + success: true, + text: `No archived tweets found${scope} in the ${resource} resource.`, + data: { subaction, tweets: [] }, + }; + } + const lines = formatTweetLines(tweets); + return { + success: true, + text: `Found ${tweets.length} archived tweet${tweets.length === 1 ? "" : "s"}:\n${lines}`, + data: { subaction, tweets }, + }; + } + case "inbox": { + const kind = + params.kind && isBirdclawInboxKind(params.kind) ? params.kind : "mixed"; + const items = await service.inbox({ kind, limit: params.limit }); + if (items.length === 0) { + return { + success: true, + text: "The birdclaw inbox is clear — nothing needs attention.", + data: { subaction, items: [] }, + }; + } + const needing = items.filter((item) => item.needsReply).length; + const headline = + needing > 0 + ? `${items.length} inbox item${items.length === 1 ? "" : "s"}, ${needing} still need${needing === 1 ? "s" : ""} a reply:` + : `${items.length} inbox item${items.length === 1 ? "" : "s"}:`; + return { + success: true, + text: `${headline}\n${formatInboxLines(items)}`, + data: { subaction, items }, + }; + } + case "sync": { + const collection = params.collection ?? ""; + if (!isBirdclawSyncCollection(collection)) { + return { + success: false, + text: `I can sync one of: ${BIRDCLAW_SYNC_COLLECTIONS.join(", ")}.`, + data: { subaction, error: "INVALID_COLLECTION" }, + }; + } + const result = await service.sync(collection); + return { + success: true, + text: `Synced ${collection}: ${result.summary}`, + data: { subaction, result }, + }; + } + case "digest": { + const period = + params.period && isBirdclawDigestPeriod(params.period) + ? params.period + : "today"; + const digest = await service.digest(period); + return { + success: true, + text: digest.text, + data: { subaction, period }, + }; + } + case "status": { + const status = await service.status(); + return { + success: true, + text: formatStatusLine(status), + data: { subaction, status }, + }; + } + } +} + +const examples: ActionExample[][] = [ + [ + { + name: "{{name1}}", + content: { + text: "Search my twitter archive for that thread about local-first sync engines.", + source: "chat", + }, + }, + { + name: "{{agentName}}", + content: { + text: "Searching your birdclaw archive.", + actions: [ACTION_NAME], + thought: + "Archive lookup maps to BIRDCLAW action=search with query set.", + }, + }, + ], + [ + { + name: "{{name1}}", + content: { + text: "Which twitter mentions still need a reply from me?", + source: "chat", + }, + }, + { + name: "{{agentName}}", + content: { + text: "Checking the birdclaw inbox for unreplied mentions.", + actions: [ACTION_NAME], + thought: + "Unreplied mentions map to BIRDCLAW action=inbox kind=mentions.", + }, + }, + ], + [ + { + name: "{{name1}}", + content: { + text: "Pull my latest twitter bookmarks into the local archive.", + source: "chat", + }, + }, + { + name: "{{agentName}}", + content: { + text: "Refreshing bookmarks from Twitter into birdclaw.", + actions: [ACTION_NAME], + thought: + "Live refresh maps to BIRDCLAW action=sync collection=bookmarks.", + }, + }, + ], +]; + +export const birdclawAction = { + name: ACTION_NAME, + similes: ["TWITTER_ARCHIVE", "TWEET_SEARCH", "TWITTER_MEMORY", "X_ARCHIVE"], + tags: [ + "domain:social", + "capability:read", + "capability:search", + "surface:internal", + ], + description: + "Birdclaw local Twitter/X archive: search archived tweets (timeline, mentions, authored, liked, bookmarked), triage the mention/DM inbox, refresh live collections, build digests, report status. Subactions: search, inbox, sync, digest, status.", + descriptionCompressed: + "BIRDCLAW search|inbox|sync|digest|status over the local Twitter/X archive", + routingHint: + 'local Twitter/X archive ("search my tweets", "who mentioned me", "sync my bookmarks", "what happened on twitter") -> BIRDCLAW; live posting/following -> the X connector', + contexts: ["social", "archive", "twitter"], + roleGate: { minRole: "OWNER" as const }, + validate: async (runtime: IAgentRuntime): Promise => { + const service = getService(runtime); + if (!service) return false; + return service.isAvailable(); + }, + parameters: [ + { + name: "action", + description: "Birdclaw op: search | inbox | sync | digest | status.", + schema: { type: "string" as const, enum: [...SUBACTIONS] }, + }, + { + name: "query", + description: "Full-text query for search. Omit to list recent tweets.", + schema: { type: "string" as const }, + }, + { + name: "resource", + description: + "Tweet resource for search: home | mentions | authored. Default home.", + schema: { type: "string" as const, enum: [...BIRDCLAW_RESOURCES] }, + }, + { + name: "liked", + description: "Search only liked tweets.", + schema: { type: "boolean" as const }, + }, + { + name: "bookmarked", + description: "Search only bookmarked tweets.", + schema: { type: "boolean" as const }, + }, + { + name: "limit", + description: "Max rows to return. Default 20, cap 100.", + schema: { type: "number" as const }, + }, + { + name: "kind", + description: "Inbox kind: mixed | mentions | dms. Default mixed.", + schema: { type: "string" as const, enum: [...BIRDCLAW_INBOX_KINDS] }, + }, + { + name: "collection", + description: + "Required for sync: timeline | mentions | authored | likes | bookmarks.", + schema: { type: "string" as const, enum: [...BIRDCLAW_SYNC_COLLECTIONS] }, + }, + { + name: "period", + description: + "Digest period: today | 24h | yesterday | week. Default today.", + schema: { type: "string" as const, enum: [...BIRDCLAW_DIGEST_PERIODS] }, + }, + ], + examples, + handler: async ( + runtime: IAgentRuntime, + _message: Memory, + _state: unknown, + options: unknown, + callback: HandlerCallback | undefined, + ): Promise => { + const service = getService(runtime); + if (!service) { + const text = "The birdclaw service is not available on this agent."; + await callback?.({ text }); + return { success: false, text, data: { error: "SERVICE_UNAVAILABLE" } }; + } + + const params = getParams(options); + const subaction = resolveSubaction(params); + if (!subaction) { + const text = `I can ${SUBACTIONS.join(", ")} against the birdclaw archive — which one?`; + await callback?.({ text }); + return { success: false, text, data: { error: "UNKNOWN_SUBACTION" } }; + } + + try { + const result = await runSubaction(service, subaction, params); + if (result.text) await callback?.({ text: result.text }); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`[plugin-birdclaw] ${subaction} failed: ${message}`); + const text = `birdclaw ${subaction} failed: ${message}`; + await callback?.({ text }); + return { success: false, text, data: { error: "CLI_FAILURE", message } }; + } + }, +}; diff --git a/plugins/plugin-birdclaw/src/birdclaw/birdclaw.real.test.ts b/plugins/plugin-birdclaw/src/birdclaw/birdclaw.real.test.ts new file mode 100644 index 0000000000000..cfac823104706 --- /dev/null +++ b/plugins/plugin-birdclaw/src/birdclaw/birdclaw.real.test.ts @@ -0,0 +1,113 @@ +/** + * REAL-CLI suite — excluded from the default vitest lane (root config drops + * `*.real.test.*`). Run with: + * + * bun run --cwd plugins/plugin-birdclaw test:real + * + * Requires a birdclaw binary (BIRDCLAW_REAL_BIN, or `birdclaw` on PATH). The + * suite creates a throwaway BIRDCLAW_HOME, runs `birdclaw init` (which seeds + * the demo dataset), and drives the REAL service methods end to end — the + * same spawn path, JSON envelopes, and parsers production uses. No mocks. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { IAgentRuntime } from "@elizaos/core"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { BirdclawService } from "./service.ts"; + +function resolveRealBin(): string | null { + const fromEnv = process.env.BIRDCLAW_REAL_BIN?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + const rawPath = process.env.PATH ?? ""; + for (const dir of rawPath.split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, "birdclaw"); + if (existsSync(candidate)) return candidate; + } + return null; +} + +const REAL_BIN = resolveRealBin(); + +describe.skipIf(!REAL_BIN)("birdclaw real CLI", () => { + let home: string; + let service: BirdclawService; + + beforeAll(() => { + home = mkdtempSync(path.join(tmpdir(), "birdclaw-real-")); + execFileSync(REAL_BIN as string, ["init"], { + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + BIRDCLAW_HOME: home, + }, + stdio: "ignore", + }); + const runtime = { + getSetting: (key: string) => + key === "BIRDCLAW_BIN" + ? (REAL_BIN as string) + : key === "BIRDCLAW_HOME" + ? home + : undefined, + } as unknown as IAgentRuntime; + service = new BirdclawService(runtime); + }); + + afterAll(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("probes availability and reads real status", async () => { + await expect(service.isAvailable()).resolves.toBe(true); + const status = await service.status(); + expect(status.installed).toBe(true); + expect(status.version).toMatch(/^\d+\.\d+\.\d+/); + expect(status.home).toBe(home); + // init seeds the demo dataset — counts are real rows in a real SQLite DB. + expect(status.counts).not.toBeNull(); + expect(status.counts?.home).toBeGreaterThan(0); + expect(status.transport).not.toBeNull(); + }); + + it("searches the seeded archive through the real envelope", async () => { + const tweets = await service.searchTweets({ limit: 10 }); + expect(tweets.length).toBeGreaterThan(0); + for (const tweet of tweets) { + expect(tweet.id).toBeTruthy(); + expect(tweet.text).toBeTruthy(); + expect(Number.isNaN(new Date(tweet.createdAt).getTime())).toBe(false); + } + }); + + it("filters liked tweets for the Likes tab query", async () => { + const liked = await service.searchTweets({ liked: true, limit: 10 }); + for (const tweet of liked) { + expect(tweet.liked).toBe(true); + } + }); + + it("reads the ranked inbox", async () => { + const items = await service.inbox({ kind: "mixed", limit: 10 }); + expect(items.length).toBeGreaterThan(0); + for (const item of items) { + expect(item.id).toBeTruthy(); + expect(item.text).toBeTruthy(); + } + }); + + it("reports a clean not-installed state for a bogus binary", async () => { + const runtime = { + getSetting: (key: string) => + key === "BIRDCLAW_BIN" ? "/nonexistent/birdclaw-missing" : undefined, + } as unknown as IAgentRuntime; + const missing = new BirdclawService(runtime); + await expect(missing.isAvailable()).resolves.toBe(false); + const status = await missing.status(); + expect(status.installed).toBe(false); + expect(status.message).toContain("brew install steipete/tap/birdclaw"); + }); +}); diff --git a/plugins/plugin-birdclaw/src/birdclaw/cli.test.ts b/plugins/plugin-birdclaw/src/birdclaw/cli.test.ts new file mode 100644 index 0000000000000..4ea1050db9751 --- /dev/null +++ b/plugins/plugin-birdclaw/src/birdclaw/cli.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + BirdclawCliError, + type BirdclawExec, + runBirdclawJson, + runBirdclawText, +} from "./cli.ts"; + +const OPTIONS = { env: {}, timeoutMs: 1000, maxBufferBytes: 1024 * 1024 }; + +function execReturning(stdout: string, stderr = ""): BirdclawExec { + return async () => ({ stdout, stderr }); +} + +function execRejecting(error: Error): BirdclawExec { + return async () => { + throw error; + }; +} + +describe("runBirdclawJson", () => { + it("parses a JSON envelope from stdout", async () => { + const exec = execReturning('{"stats":{"home":4}}\n'); + const payload = await runBirdclawJson( + exec, + "birdclaw", + ["db", "stats"], + OPTIONS, + ); + expect(payload).toEqual({ stats: { home: 4 } }); + }); + + it("parses a top-level array envelope (search tweets)", async () => { + const exec = execReturning('[{"id":"t1"}]'); + const payload = await runBirdclawJson( + exec, + "birdclaw", + ["search"], + OPTIONS, + ); + expect(payload).toEqual([{ id: "t1" }]); + }); + + it("ignores stderr noise when the exit code is zero", async () => { + // node:sqlite prints an ExperimentalWarning to stderr on Node 22; the + // envelope contract says stderr is progress/warnings, never data. + const exec = execReturning('{"ok":true}', "ExperimentalWarning: SQLite\n"); + const payload = await runBirdclawJson( + exec, + "birdclaw", + ["db", "stats"], + OPTIONS, + ); + expect(payload).toEqual({ ok: true }); + }); + + it("throws bad-json when stdout is empty", async () => { + const exec = execReturning("", "some warning"); + await expect( + runBirdclawJson(exec, "birdclaw", ["db", "stats"], OPTIONS), + ).rejects.toMatchObject({ name: "BirdclawCliError", kind: "bad-json" }); + }); + + it("throws bad-json when stdout is not JSON", async () => { + const exec = execReturning("not json at all"); + await expect( + runBirdclawJson(exec, "birdclaw", ["db", "stats"], OPTIONS), + ).rejects.toMatchObject({ kind: "bad-json" }); + }); + + it("propagates typed CLI errors from the exec seam", async () => { + const exec = execRejecting( + new BirdclawCliError( + "not-installed", + 'birdclaw binary not found at "birdclaw"', + ), + ); + await expect( + runBirdclawJson(exec, "birdclaw", ["db", "stats"], OPTIONS), + ).rejects.toMatchObject({ kind: "not-installed" }); + }); +}); + +describe("runBirdclawText", () => { + it("resolves trimmed stdout", async () => { + const exec = execReturning("0.8.5\n"); + await expect( + runBirdclawText(exec, "birdclaw", ["--version"], OPTIONS), + ).resolves.toBe("0.8.5"); + }); +}); diff --git a/plugins/plugin-birdclaw/src/birdclaw/cli.ts b/plugins/plugin-birdclaw/src/birdclaw/cli.ts new file mode 100644 index 0000000000000..38b9c375f5c1e --- /dev/null +++ b/plugins/plugin-birdclaw/src/birdclaw/cli.ts @@ -0,0 +1,166 @@ +/** + * Process seam for the birdclaw CLI. + * + * birdclaw's contract is "stable `--json` envelopes go to stdout, progress and + * warnings to stderr" — so this runner treats the exit code as the only + * success signal, parses stdout as JSON, and keeps a stderr tail purely for + * error reporting. Everything is `execFile` with an argv array (never a shell + * string), so user-supplied queries can't inject. + * + * The `BirdclawExec` seam is injectable: unit tests drive the runner with a + * fake process, and `birdclaw.real.test.ts` swaps in the real binary. + */ + +import { execFile } from "node:child_process"; + +/** Failure classes the service and routes branch on. */ +export type BirdclawCliErrorKind = + | "not-installed" + | "timeout" + | "failed" + | "bad-json"; + +export class BirdclawCliError extends Error { + readonly kind: BirdclawCliErrorKind; + /** Last ~2KB of stderr — birdclaw writes its human diagnostics there. */ + readonly stderrTail: string; + + constructor(kind: BirdclawCliErrorKind, message: string, stderrTail = "") { + super(message); + this.name = "BirdclawCliError"; + this.kind = kind; + this.stderrTail = stderrTail; + } +} + +export interface BirdclawExecResult { + stdout: string; + stderr: string; +} + +export interface BirdclawExecOptions { + env: Record; + timeoutMs: number; + maxBufferBytes: number; +} + +/** + * Run a binary with argv and resolve stdout/stderr. Rejections carry a typed + * {@link BirdclawCliError}: ENOENT → `not-installed`, timeout kill → + * `timeout`, any non-zero exit → `failed` with the stderr tail. + */ +export type BirdclawExec = ( + bin: string, + args: readonly string[], + options: BirdclawExecOptions, +) => Promise; + +const STDERR_TAIL_BYTES = 2048; + +function tail(text: string): string { + return text.length > STDERR_TAIL_BYTES + ? text.slice(text.length - STDERR_TAIL_BYTES) + : text; +} + +type ExecFileFailure = Error & { + code?: string | number; + killed?: boolean; + signal?: string | null; + stdout?: string; + stderr?: string; +}; + +/** Default exec: `node:child_process.execFile` (works under Bun and Node). */ +export const defaultBirdclawExec: BirdclawExec = (bin, args, options) => + new Promise((resolve, reject) => { + execFile( + bin, + [...args], + { + env: options.env, + timeout: options.timeoutMs, + maxBuffer: options.maxBufferBytes, + windowsHide: true, + }, + (error, stdout, stderr) => { + if (!error) { + resolve({ stdout, stderr }); + return; + } + const failure = error as ExecFileFailure; + const stderrTail = tail(failure.stderr ?? stderr ?? ""); + if (failure.code === "ENOENT") { + reject( + new BirdclawCliError( + "not-installed", + `birdclaw binary not found at "${bin}"`, + stderrTail, + ), + ); + return; + } + if (failure.killed || failure.signal === "SIGTERM") { + reject( + new BirdclawCliError( + "timeout", + `birdclaw ${args[0] ?? ""} timed out after ${options.timeoutMs}ms`, + stderrTail, + ), + ); + return; + } + reject( + new BirdclawCliError( + "failed", + `birdclaw ${args.join(" ")} failed${stderrTail ? `: ${stderrTail.trim()}` : ""}`, + stderrTail, + ), + ); + }, + ); + }); + +/** + * Run a birdclaw command and parse its stdout JSON envelope. + * + * With `allowTextFallback` (used for `digest`, which streams markdown before + * its envelope stabilizes), unparseable stdout resolves to the raw text + * instead of rejecting. + */ +export async function runBirdclawJson( + exec: BirdclawExec, + bin: string, + args: readonly string[], + options: BirdclawExecOptions, +): Promise { + const { stdout, stderr } = await exec(bin, args, options); + const trimmed = stdout.trim(); + if (trimmed.length === 0) { + throw new BirdclawCliError( + "bad-json", + `birdclaw ${args.join(" ")} produced no stdout`, + tail(stderr), + ); + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + throw new BirdclawCliError( + "bad-json", + `birdclaw ${args.join(" ")} stdout was not valid JSON`, + tail(stderr), + ); + } +} + +/** Run a birdclaw command and resolve trimmed plain-text stdout. */ +export async function runBirdclawText( + exec: BirdclawExec, + bin: string, + args: readonly string[], + options: BirdclawExecOptions, +): Promise { + const { stdout } = await exec(bin, args, options); + return stdout.trim(); +} diff --git a/plugins/plugin-birdclaw/src/birdclaw/service.test.ts b/plugins/plugin-birdclaw/src/birdclaw/service.test.ts new file mode 100644 index 0000000000000..0148669faebd7 --- /dev/null +++ b/plugins/plugin-birdclaw/src/birdclaw/service.test.ts @@ -0,0 +1,326 @@ +import type { IAgentRuntime } from "@elizaos/core"; +import { describe, expect, it } from "vitest"; +import type { BirdclawExec, BirdclawExecOptions } from "./cli.ts"; +import { BirdclawCliError } from "./cli.ts"; +import { + BirdclawService, + buildInboxArgs, + buildSearchArgs, + clampLimit, + parseCounts, + parseInboxItems, + parseTransport, + parseTweets, + summarizeSyncPayload, +} from "./service.ts"; + +/** Minimal runtime double: only getSetting is consulted by the service. */ +function fakeRuntime(settings: Record = {}): IAgentRuntime { + return { + getSetting: (key: string) => settings[key], + } as unknown as IAgentRuntime; +} + +interface ExecCall { + bin: string; + args: readonly string[]; + options: BirdclawExecOptions; +} + +function recordingExec( + respond: (call: ExecCall) => { stdout: string; stderr?: string } | Error, +): { exec: BirdclawExec; calls: ExecCall[] } { + const calls: ExecCall[] = []; + const exec: BirdclawExec = async (bin, args, options) => { + const call = { bin, args, options }; + calls.push(call); + const result = respond(call); + if (result instanceof Error) throw result; + return { stdout: result.stdout, stderr: result.stderr ?? "" }; + }; + return { exec, calls }; +} + +const TWEET_ROW = { + id: "tweet_002", + text: "The best product teams prune scope.", + createdAt: "2026-03-08T11:18:00.000Z", + likeCount: 382, + liked: true, + bookmarked: false, + isReplied: true, + kind: "home", + author: { handle: "destraynor", displayName: "Des Traynor" }, +}; + +describe("arg builders", () => { + it("builds default search args", () => { + expect(buildSearchArgs({})).toEqual([ + "search", + "tweets", + "--resource", + "home", + "--limit", + "20", + "--json", + ]); + }); + + it("passes the query positionally and flags conditionally", () => { + expect( + buildSearchArgs({ + query: "sync engines", + resource: "mentions", + liked: true, + bookmarked: true, + limit: 5, + }), + ).toEqual([ + "search", + "tweets", + "sync engines", + "--resource", + "mentions", + "--liked", + "--bookmarked", + "--limit", + "5", + "--json", + ]); + }); + + it("clamps limits into [1, 100]", () => { + expect(clampLimit(0, 20)).toBe(1); + expect(clampLimit(-5, 20)).toBe(1); + expect(clampLimit(10_000, 20)).toBe(100); + expect(clampLimit(Number.NaN, 20)).toBe(20); + expect(clampLimit(undefined, 20)).toBe(20); + expect(clampLimit(7.9, 20)).toBe(7); + }); + + it("builds inbox args", () => { + expect(buildInboxArgs({ kind: "mentions", limit: 3 })).toEqual([ + "inbox", + "--kind", + "mentions", + "--limit", + "3", + "--json", + ]); + }); +}); + +describe("wire parsing", () => { + it("parses tweet rows and flattens the author", () => { + const tweets = parseTweets([TWEET_ROW]); + expect(tweets).toHaveLength(1); + expect(tweets[0]).toMatchObject({ + id: "tweet_002", + authorHandle: "destraynor", + authorName: "Des Traynor", + likeCount: 382, + liked: true, + bookmarked: false, + }); + }); + + it("skips malformed tweet rows instead of crashing", () => { + const tweets = parseTweets([TWEET_ROW, { id: 42 }, null, "junk"]); + expect(tweets).toHaveLength(1); + }); + + it("rejects a non-array search envelope", () => { + expect(() => parseTweets({ items: [] })).toThrowError(BirdclawCliError); + }); + + it("parses inbox envelopes", () => { + const items = parseInboxItems({ + items: [ + { + id: "mention:tweet_004", + entityKind: "mention", + title: "Mention from Amelia N", + text: "@steipete curious how you decide...", + createdAt: "2026-03-08T11:48:00.000Z", + needsReply: true, + score: 76, + participant: { handle: "amelia", displayName: "Amelia N" }, + }, + ], + }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + kind: "mention", + needsReply: true, + score: 76, + participantHandle: "amelia", + }); + }); + + it("rejects an inbox envelope with no items array", () => { + expect(() => parseInboxItems({})).toThrowError(BirdclawCliError); + }); + + it("parses counts and transport", () => { + expect( + parseCounts({ home: 4, mentions: 2, dms: 4, needsReply: 2, inbox: 4 }), + ).toEqual({ home: 4, mentions: 2, dms: 4, needsReply: 2, inbox: 4 }); + expect(parseCounts({ home: 4 })).toBeNull(); + expect( + parseTransport({ + installed: false, + availableTransport: "local", + statusText: "xurl not installed. local mode active.", + }), + ).toMatchObject({ installed: false, availableTransport: "local" }); + expect(parseTransport({ installed: false })).toBeNull(); + }); + + it("summarizes sync payload numerics", () => { + expect(summarizeSyncPayload({ fetched: 12, inserted: 3, note: "x" })).toBe( + "sync completed (fetched=12, inserted=3)", + ); + expect(summarizeSyncPayload("weird")).toBe("sync completed"); + }); +}); + +describe("BirdclawService", () => { + it("resolves the binary from BIRDCLAW_BIN and passes BIRDCLAW_HOME", async () => { + const { exec, calls } = recordingExec(() => ({ stdout: "[]" })); + const service = new BirdclawService( + fakeRuntime({ + BIRDCLAW_BIN: "/opt/birdclaw/bin/birdclaw.mjs", + BIRDCLAW_HOME: "/data/birdclaw", + }), + { exec }, + ); + await service.searchTweets({}); + expect(calls[0]?.bin).toBe("/opt/birdclaw/bin/birdclaw.mjs"); + expect(calls[0]?.options.env.BIRDCLAW_HOME).toBe("/data/birdclaw"); + // The spawn env is an allowlist, never the full agent env. + expect(Object.keys(calls[0]?.options.env ?? {}).sort()).toEqual( + ["BIRDCLAW_HOME", "HOME", "PATH"].filter( + (key) => key === "BIRDCLAW_HOME" || process.env[key], + ), + ); + }); + + it("caches the availability probe within the TTL", async () => { + let clock = 0; + const { exec, calls } = recordingExec(() => ({ stdout: "0.8.5" })); + const service = new BirdclawService(fakeRuntime(), { + exec, + now: () => clock, + }); + await expect(service.isAvailable()).resolves.toBe(true); + await expect(service.isAvailable()).resolves.toBe(true); + expect(calls).toHaveLength(1); + clock = 60_000; // past the 30s TTL → re-probe + await expect(service.isAvailable()).resolves.toBe(true); + expect(calls).toHaveLength(2); + }); + + it("reports installed:false with install guidance when the binary is missing", async () => { + const { exec } = recordingExec( + () => + new BirdclawCliError( + "not-installed", + 'birdclaw binary not found at "birdclaw"', + ), + ); + const service = new BirdclawService(fakeRuntime(), { exec }); + const status = await service.status(); + expect(status.installed).toBe(false); + expect(status.message).toContain("brew install steipete/tap/birdclaw"); + await expect(service.isAvailable()).resolves.toBe(false); + }); + + it("combines version + db stats into status", async () => { + const { exec } = recordingExec(({ args }) => { + if (args[0] === "--version") return { stdout: "0.8.5\n" }; + return { + stdout: JSON.stringify({ + paths: { rootDir: "/home/user/.birdclaw" }, + stats: { home: 4, mentions: 2, dms: 4, needsReply: 2, inbox: 4 }, + transport: { + installed: false, + availableTransport: "local", + statusText: "xurl not installed. local mode active.", + }, + }), + }; + }); + const service = new BirdclawService(fakeRuntime(), { exec }); + const status = await service.status(); + expect(status).toMatchObject({ + installed: true, + version: "0.8.5", + home: "/home/user/.birdclaw", + counts: { home: 4, mentions: 2 }, + transport: { availableTransport: "local" }, + message: null, + }); + }); + + it("keeps installed:true but surfaces the failure when db stats breaks", async () => { + const { exec } = recordingExec(({ args }) => { + if (args[0] === "--version") return { stdout: "0.8.5" }; + return new BirdclawCliError("failed", "database is locked"); + }); + const service = new BirdclawService(fakeRuntime(), { exec }); + const status = await service.status(); + expect(status.installed).toBe(true); + expect(status.message).toContain("database is locked"); + }); + + it("runs sync with the collection argv and summarizes", async () => { + const { exec, calls } = recordingExec(() => ({ + stdout: JSON.stringify({ fetched: 7 }), + })); + const service = new BirdclawService(fakeRuntime(), { exec }); + const result = await service.sync("bookmarks"); + expect(calls[0]?.args).toEqual(["sync", "bookmarks", "--json"]); + expect(result).toEqual({ + collection: "bookmarks", + ok: true, + summary: "sync completed (fetched=7)", + }); + }); + + it("digest accepts a JSON envelope or raw markdown", async () => { + const json = recordingExec(() => ({ + stdout: JSON.stringify({ digest: "Quiet day." }), + })); + const jsonService = new BirdclawService(fakeRuntime(), { exec: json.exec }); + await expect(jsonService.digest("today")).resolves.toEqual({ + period: "today", + text: "Quiet day.", + }); + + const markdown = recordingExec(() => ({ + stdout: "# What happened\nNothing.", + })); + const mdService = new BirdclawService(fakeRuntime(), { + exec: markdown.exec, + }); + await expect(mdService.digest("week")).resolves.toEqual({ + period: "week", + text: "# What happened\nNothing.", + }); + }); + + it("forwards a dedicated OpenAI key to the CLI env only when configured", async () => { + const withKey = recordingExec(() => ({ stdout: "[]" })); + const keyed = new BirdclawService( + fakeRuntime({ BIRDCLAW_OPENAI_API_KEY: "sk-birdclaw" }), + { exec: withKey.exec }, + ); + await keyed.searchTweets({}); + expect(withKey.calls[0]?.options.env.OPENAI_API_KEY).toBe("sk-birdclaw"); + + const without = recordingExec(() => ({ stdout: "[]" })); + const bare = new BirdclawService(fakeRuntime(), { exec: without.exec }); + await bare.searchTweets({}); + expect(without.calls[0]?.options.env.OPENAI_API_KEY).toBeUndefined(); + }); +}); diff --git a/plugins/plugin-birdclaw/src/birdclaw/service.ts b/plugins/plugin-birdclaw/src/birdclaw/service.ts new file mode 100644 index 0000000000000..2ade1f292622d --- /dev/null +++ b/plugins/plugin-birdclaw/src/birdclaw/service.ts @@ -0,0 +1,514 @@ +/** + * `BIRDCLAW_SERVICE` — the one place the plugin talks to the birdclaw CLI. + * + * birdclaw (https://birdclaw.sh) is a local-first Twitter/X workspace: a + * single SQLite database (default `~/.birdclaw/`) holding archived tweets, + * mentions, DMs, likes, and bookmarks, with optional live sync through the + * `xurl`/`bird` transports. Its stable integration surface is the CLI's + * `--json` envelopes on stdout, which this service wraps with typed methods. + * + * Everything degrades explicitly: when the binary is missing, `status()` + * reports `installed: false` with the resolution message, data methods throw + * a typed `BirdclawCliError("not-installed")`, and the view renders a setup + * screen instead of an error wall. + */ + +import { type IAgentRuntime, logger, Service } from "@elizaos/core"; +import type { + BirdclawCounts, + BirdclawDigestPeriod, + BirdclawDigestResult, + BirdclawInboxItem, + BirdclawInboxKind, + BirdclawResource, + BirdclawStatusInfo, + BirdclawSyncCollection, + BirdclawSyncResult, + BirdclawTransport, + BirdclawTweet, +} from "../types.ts"; +import { + BirdclawCliError, + type BirdclawExec, + defaultBirdclawExec, + runBirdclawJson, + runBirdclawText, +} from "./cli.ts"; + +/** Read a setting first (per-agent), then the process env (deployment default). */ +function getStr( + runtime: IAgentRuntime | undefined, + key: string, +): string | undefined { + const fromSetting = runtime?.getSetting?.(key); + if (typeof fromSetting === "string" && fromSetting.trim().length > 0) { + return fromSetting.trim(); + } + const fromEnv = process.env[key]; + return fromEnv && fromEnv.trim().length > 0 ? fromEnv.trim() : undefined; +} + +/** Clamp a result limit into a sane [1, max] window. */ +export function clampLimit( + value: number | undefined, + fallback: number, + max = 100, +): number { + if (value === undefined || !Number.isFinite(value)) return fallback; + return Math.min(Math.max(Math.trunc(value), 1), max); +} + +export interface BirdclawSearchOptions { + query?: string; + resource?: BirdclawResource; + liked?: boolean; + bookmarked?: boolean; + limit?: number; +} + +export interface BirdclawInboxOptions { + kind?: BirdclawInboxKind; + limit?: number; +} + +/** Build the `search tweets` argv for the given options (pure; unit-tested). */ +export function buildSearchArgs(options: BirdclawSearchOptions): string[] { + const args = ["search", "tweets"]; + const query = options.query?.trim(); + if (query) args.push(query); + args.push("--resource", options.resource ?? "home"); + if (options.liked) args.push("--liked"); + if (options.bookmarked) args.push("--bookmarked"); + args.push("--limit", String(clampLimit(options.limit, 20)), "--json"); + return args; +} + +/** Build the `inbox` argv for the given options (pure; unit-tested). */ +export function buildInboxArgs(options: BirdclawInboxOptions): string[] { + return [ + "inbox", + "--kind", + options.kind ?? "mixed", + "--limit", + String(clampLimit(options.limit, 20)), + "--json", + ]; +} + +// --------------------------------------------------------------------------- +// Wire parsing — narrow unknown JSON to the flat display DTOs, skipping (and +// counting) malformed rows instead of crashing on schema drift upstream. +// --------------------------------------------------------------------------- + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function str(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function num(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function bool(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +export function parseTweetRow(value: unknown): BirdclawTweet | null { + const row = asRecord(value); + if (!row) return null; + const id = str(row.id); + const text = str(row.text); + const createdAt = str(row.createdAt); + if (!id || !text || !createdAt) return null; + const author = asRecord(row.author); + return { + id, + text, + createdAt, + authorHandle: author ? str(author.handle) : null, + authorName: author ? str(author.displayName) : null, + likeCount: num(row.likeCount), + liked: bool(row.liked) ?? false, + bookmarked: bool(row.bookmarked) ?? false, + isReplied: bool(row.isReplied), + kind: str(row.kind), + }; +} + +export function parseTweets(payload: unknown): BirdclawTweet[] { + if (!Array.isArray(payload)) { + throw new BirdclawCliError( + "bad-json", + "birdclaw search envelope was not an array of tweets", + ); + } + const tweets: BirdclawTweet[] = []; + let skipped = 0; + for (const row of payload) { + const tweet = parseTweetRow(row); + if (tweet) tweets.push(tweet); + else skipped += 1; + } + if (skipped > 0) { + logger.warn(`[BirdclawService] skipped ${skipped} malformed tweet row(s)`); + } + return tweets; +} + +export function parseInboxItemRow(value: unknown): BirdclawInboxItem | null { + const row = asRecord(value); + if (!row) return null; + const id = str(row.id); + const text = str(row.text); + const createdAt = str(row.createdAt); + if (!id || !text || !createdAt) return null; + const participant = asRecord(row.participant); + return { + id, + kind: str(row.entityKind) ?? "item", + title: str(row.title) ?? text.slice(0, 80), + text, + createdAt, + needsReply: bool(row.needsReply) ?? false, + score: num(row.score), + participantHandle: participant ? str(participant.handle) : null, + }; +} + +export function parseInboxItems(payload: unknown): BirdclawInboxItem[] { + const envelope = asRecord(payload); + const rows = envelope ? envelope.items : null; + if (!Array.isArray(rows)) { + throw new BirdclawCliError( + "bad-json", + "birdclaw inbox envelope had no items array", + ); + } + const items: BirdclawInboxItem[] = []; + let skipped = 0; + for (const row of rows) { + const item = parseInboxItemRow(row); + if (item) items.push(item); + else skipped += 1; + } + if (skipped > 0) { + logger.warn(`[BirdclawService] skipped ${skipped} malformed inbox row(s)`); + } + return items; +} + +export function parseCounts(value: unknown): BirdclawCounts | null { + const stats = asRecord(value); + if (!stats) return null; + const home = num(stats.home); + const mentions = num(stats.mentions); + const dms = num(stats.dms); + const needsReply = num(stats.needsReply); + const inbox = num(stats.inbox); + if ( + home === null || + mentions === null || + dms === null || + needsReply === null || + inbox === null + ) { + return null; + } + return { home, mentions, dms, needsReply, inbox }; +} + +export function parseTransport(value: unknown): BirdclawTransport | null { + const transport = asRecord(value); + if (!transport) return null; + const installed = bool(transport.installed); + const availableTransport = str(transport.availableTransport); + const statusText = str(transport.statusText); + if (installed === null || !availableTransport || !statusText) return null; + return { installed, availableTransport, statusText }; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** How long a binary probe result stays fresh before re-probing. */ +const AVAILABILITY_TTL_MS = 30_000; + +/** Read commands finish in well under this on a local SQLite DB. */ +const READ_TIMEOUT_MS = 30_000; + +/** Live syncs page a remote API; give them room but never hang a route. */ +const SYNC_TIMEOUT_MS = 120_000; + +/** Digest calls OpenAI over the archive; slowest command we expose. */ +const DIGEST_TIMEOUT_MS = 120_000; + +/** Search results with embedded profiles/media metadata can be chunky. */ +const MAX_BUFFER_BYTES = 32 * 1024 * 1024; + +interface AvailabilityProbe { + installed: boolean; + version: string | null; + message: string | null; + probedAt: number; +} + +export interface BirdclawServiceOptions { + exec?: BirdclawExec; + /** Override the probe/read/sync clock (tests). */ + now?: () => number; +} + +export class BirdclawService extends Service { + public static serviceType = "BIRDCLAW_SERVICE"; + + private readonly exec: BirdclawExec; + private readonly now: () => number; + private probe: AvailabilityProbe | null = null; + private probeInFlight: Promise | null = null; + + constructor(runtime?: IAgentRuntime, options?: BirdclawServiceOptions) { + super(runtime); + this.exec = options?.exec ?? defaultBirdclawExec; + this.now = options?.now ?? Date.now; + } + + static async start(runtime: IAgentRuntime): Promise { + const instance = new BirdclawService(runtime); + const status = await instance.status(); + logger.info( + `[BirdclawService] started (bin=${instance.binPath()}, installed=${status.installed}${ + status.version ? `, version=${status.version}` : "" + })`, + ); + return instance; + } + + get capabilityDescription(): string { + return "Local-first Twitter/X memory (birdclaw): search the archived timeline, mentions, likes, and bookmarks; trigger live syncs; build digests."; + } + + /** The binary this service will spawn: `BIRDCLAW_BIN` or `birdclaw` on PATH. */ + binPath(): string { + return getStr(this.runtime, "BIRDCLAW_BIN") ?? "birdclaw"; + } + + /** The data root passed to the CLI, when configured (else the CLI's own default). */ + homePath(): string | undefined { + return getStr(this.runtime, "BIRDCLAW_HOME"); + } + + /** + * Environment for spawned birdclaw processes: a minimal allowlist rather + * than the full agent env (the agent process carries provider keys the CLI + * has no business seeing). `OPENAI_API_KEY` is forwarded only from the + * dedicated `BIRDCLAW_OPENAI_API_KEY` knob, for birdclaw's AI features. + */ + private spawnEnv(): Record { + const env: Record = {}; + if (process.env.PATH) env.PATH = process.env.PATH; + if (process.env.HOME) env.HOME = process.env.HOME; + const home = this.homePath(); + if (home) env.BIRDCLAW_HOME = home; + const openaiKey = getStr(this.runtime, "BIRDCLAW_OPENAI_API_KEY"); + if (openaiKey) env.OPENAI_API_KEY = openaiKey; + return env; + } + + private execOptions(timeoutMs: number) { + return { + env: this.spawnEnv(), + timeoutMs, + maxBufferBytes: MAX_BUFFER_BYTES, + }; + } + + /** + * Probe the binary (`--version`), memoized for {@link AVAILABILITY_TTL_MS} + * so per-message action validation never spawns a process storm. Concurrent + * callers share one in-flight probe. + */ + private async ensureProbe(): Promise { + const cached = this.probe; + if (cached && this.now() - cached.probedAt < AVAILABILITY_TTL_MS) { + return cached; + } + if (this.probeInFlight) return this.probeInFlight; + this.probeInFlight = (async (): Promise => { + try { + const version = await runBirdclawText( + this.exec, + this.binPath(), + ["--version"], + this.execOptions(READ_TIMEOUT_MS), + ); + return { + installed: true, + version: version || null, + message: null, + probedAt: this.now(), + }; + } catch (err) { + const message = + err instanceof BirdclawCliError && err.kind === "not-installed" + ? `birdclaw is not installed (looked for "${this.binPath()}"). Install it with: brew install steipete/tap/birdclaw` + : err instanceof Error + ? err.message + : String(err); + return { + installed: false, + version: null, + message, + probedAt: this.now(), + }; + } finally { + this.probeInFlight = null; + } + })(); + const probe = await this.probeInFlight; + this.probe = probe; + return probe; + } + + /** Cheap cached availability check for action `validate`. */ + async isAvailable(): Promise { + return (await this.ensureProbe()).installed; + } + + /** Install + dataset status (binary probe, then `db stats --json`). */ + async status(): Promise { + const probe = await this.ensureProbe(); + if (!probe.installed) { + return { + installed: false, + version: null, + home: this.homePath() ?? null, + counts: null, + transport: null, + message: probe.message, + }; + } + try { + const payload = await runBirdclawJson( + this.exec, + this.binPath(), + ["db", "stats", "--json"], + this.execOptions(READ_TIMEOUT_MS), + ); + const envelope = asRecord(payload); + const paths = envelope ? asRecord(envelope.paths) : null; + return { + installed: true, + version: probe.version, + home: (paths ? str(paths.rootDir) : null) ?? this.homePath() ?? null, + counts: envelope ? parseCounts(envelope.stats) : null, + transport: envelope ? parseTransport(envelope.transport) : null, + message: null, + }; + } catch (err) { + // Binary present but the data root is unusable (permissions, corrupt + // DB, unwritable disk). Report installed with the failure message so the + // view can show a real diagnosis instead of the install screen. + const message = err instanceof Error ? err.message : String(err); + logger.warn(`[BirdclawService] db stats failed: ${message}`); + return { + installed: true, + version: probe.version, + home: this.homePath() ?? null, + counts: null, + transport: null, + message, + }; + } + } + + /** Full-text search / listing over the archived tweets. */ + async searchTweets(options: BirdclawSearchOptions): Promise { + const payload = await runBirdclawJson( + this.exec, + this.binPath(), + buildSearchArgs(options), + this.execOptions(READ_TIMEOUT_MS), + ); + return parseTweets(payload); + } + + /** Ranked mention/DM triage rows. */ + async inbox(options: BirdclawInboxOptions): Promise { + const payload = await runBirdclawJson( + this.exec, + this.binPath(), + buildInboxArgs(options), + this.execOptions(READ_TIMEOUT_MS), + ); + return parseInboxItems(payload); + } + + /** Refresh one live collection into the local store (`sync `). */ + async sync(collection: BirdclawSyncCollection): Promise { + const payload = await runBirdclawJson( + this.exec, + this.binPath(), + ["sync", collection, "--json"], + this.execOptions(SYNC_TIMEOUT_MS), + ); + return { collection, ok: true, summary: summarizeSyncPayload(payload) }; + } + + /** AI digest over the archive (requires birdclaw's OpenAI key). */ + async digest(period: BirdclawDigestPeriod): Promise { + const { stdout } = await this.exec( + this.binPath(), + ["digest", period, "--json"], + this.execOptions(DIGEST_TIMEOUT_MS), + ); + const trimmed = stdout.trim(); + if (trimmed.length === 0) { + throw new BirdclawCliError( + "failed", + "birdclaw digest produced no output", + ); + } + // The digest envelope is still settling upstream ("expect schema churn"); + // accept either a JSON envelope with a text-ish field or raw markdown. + try { + const parsed = asRecord(JSON.parse(trimmed) as unknown); + const text = + (parsed && + (str(parsed.digest) ?? str(parsed.text) ?? str(parsed.report))) ?? + trimmed; + return { period, text }; + } catch { + return { period, text: trimmed }; + } + } + + async stop(): Promise { + this.probe = null; + logger.info("[BirdclawService] stopped"); + } +} + +/** + * Reduce an arbitrary sync envelope to one human line. Sync envelopes vary by + * collection and transport; surface the numeric facts they all carry. + */ +export function summarizeSyncPayload(payload: unknown): string { + const envelope = asRecord(payload); + if (!envelope) return "sync completed"; + const parts: string[] = []; + for (const [key, value] of Object.entries(envelope)) { + if (typeof value === "number" && Number.isFinite(value)) { + parts.push(`${key}=${value}`); + } + if (parts.length >= 6) break; + } + return parts.length > 0 + ? `sync completed (${parts.join(", ")})` + : "sync completed"; +} diff --git a/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawSpatialView.test.tsx b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawSpatialView.test.tsx new file mode 100644 index 0000000000000..63e3b0a192537 --- /dev/null +++ b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawSpatialView.test.tsx @@ -0,0 +1,126 @@ +import { SpatialSurface } from "@elizaos/ui/spatial"; +import { renderViewToLines } from "@elizaos/ui/spatial/tui"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { + BIRDCLAW_TABS, + type BirdclawSnapshot, + BirdclawSpatialView, + EMPTY_BIRDCLAW_SNAPSHOT, +} from "./BirdclawSpatialView.tsx"; + +function tabs(active = "home") { + return BIRDCLAW_TABS.map((tab) => ({ + id: tab.id, + label: tab.label, + active: tab.id === active, + })); +} + +const ready: BirdclawSnapshot = { + status: "ready", + tabs: tabs("mentions"), + rows: [ + { + id: "m1", + title: "@amelia", + body: "curious how you decide when a local tool deserves a real sync engine.", + meta: "mention · needs reply", + time: "2026-03-08T11:48:00.000Z", + accent: true, + }, + { + id: "t2", + title: "@destraynor", + body: "The best product teams prune scope.", + meta: "♥382", + time: "2026-03-08T11:18:00.000Z", + accent: false, + }, + ], + transportText: "xurl not installed. local mode active.", + syncing: false, + canSync: false, + nudge: "1 item still needs a reply.", + error: null, + setupHint: null, +}; + +function renderGui(snapshot: BirdclawSnapshot): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("BirdclawSpatialView (GUI)", () => { + it("renders rows, nudge, and transport in the ready state", () => { + const html = renderGui(ready); + expect(html).toContain("@amelia"); + expect(html).toContain("@destraynor"); + expect(html).toContain("1 item still needs a reply."); + expect(html).toContain("xurl not installed. local mode active."); + expect(html).toContain("2 items"); + }); + + it("renders every tab chip", () => { + const html = renderGui(ready); + for (const tab of BIRDCLAW_TABS) { + expect(html).toContain(tab.label); + } + }); + + it("renders the setup state with install guidance", () => { + const html = renderGui({ + ...EMPTY_BIRDCLAW_SNAPSHOT, + status: "setup", + setupHint: 'birdclaw is not installed (looked for "birdclaw").', + }); + expect(html).toContain("Birdclaw is not set up yet"); + expect(html).toContain("brew install steipete/tap/birdclaw"); + expect(html).toContain("Check again"); + }); + + it("renders the error state with a retry affordance", () => { + const html = renderGui({ + ...EMPTY_BIRDCLAW_SNAPSHOT, + status: "error", + error: "database is locked", + }); + // renderToStaticMarkup escapes the apostrophe, so match around it. + expect(html).toContain("load the archive"); + expect(html).toContain("database is locked"); + expect(html).toContain("Retry"); + }); + + it("renders the empty state", () => { + const html = renderGui({ + ...ready, + status: "empty", + rows: [], + nudge: null, + }); + expect(html).toContain("Nothing here yet"); + }); + + it("shows the sync button only when a transport can sync", () => { + expect(renderGui(ready)).not.toContain(">Sync<"); + const syncable = renderGui({ ...ready, canSync: true }); + expect(syncable).toContain("Sync"); + const syncing = renderGui({ ...ready, canSync: true, syncing: true }); + expect(syncing).toContain("Syncing…"); + }); +}); + +describe("BirdclawSpatialView (TUI)", () => { + it("renders the same component to terminal lines", () => { + const lines = renderViewToLines( + , + 60, + ); + const text = lines.join("\n"); + expect(text).toContain("@amelia"); + expect(text).toContain("Mentions"); + }); +}); diff --git a/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawSpatialView.tsx b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawSpatialView.tsx new file mode 100644 index 0000000000000..5fdc889fc017b --- /dev/null +++ b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawSpatialView.tsx @@ -0,0 +1,302 @@ +/** + * BirdclawSpatialView — the Birdclaw archive browser authored once with the + * spatial vocabulary so it renders on every surface: + * + * - GUI / XR — mounted in `` (DOM; XR scales up). + * - TUI — rendered to terminal lines via the spatial TUI renderer. + * + * Purely presentational: a snapshot + an action callback in, primitives out. + * The live data wrapper {@link BirdclawView} owns the `/api/birdclaw/*` + * fetches, the tab state, and the sync flow; it builds a + * {@link BirdclawSnapshot} and dispatches user intent back through `onAction`. + * + * Per the app's chat-first design law, free-form search lives in the floating + * chat (the `BIRDCLAW` action), not in an input here — the view is the + * browse/triage surface. + */ + +import { + Button, + Card, + Divider, + HStack, + List, + Text, + VStack, +} from "@elizaos/ui/spatial"; + +/** Which fetch state the surface is in. `setup` = birdclaw not installed. */ +export type BirdclawViewStatus = + | "loading" + | "setup" + | "error" + | "empty" + | "ready"; + +/** Tabs the browser exposes; ids double as the `tab:` action namespace. */ +export const BIRDCLAW_TABS = [ + { id: "home", label: "Timeline" }, + { id: "mentions", label: "Mentions" }, + { id: "authored", label: "Posted" }, + { id: "likes", label: "Likes" }, + { id: "bookmarks", label: "Bookmarks" }, + { id: "inbox", label: "Inbox" }, +] as const; + +export type BirdclawTabId = (typeof BIRDCLAW_TABS)[number]["id"]; + +export interface BirdclawTabChip { + id: BirdclawTabId; + label: string; + active: boolean; +} + +/** One display row — a tweet or an inbox item, already flattened. */ +export interface BirdclawRow { + id: string; + /** Leading line: "@handle" or a triage title. */ + title: string; + /** Body text (the tweet / mention / DM content). */ + body: string; + /** Trailing meta: likes, marks, relative facts. */ + meta: string; + /** ISO timestamp for the row. */ + time: string; + /** Accented rows (needs-reply mentions) render an attention dot. */ + accent: boolean; +} + +export interface BirdclawSnapshot { + status: BirdclawViewStatus; + tabs: BirdclawTabChip[]; + rows: BirdclawRow[]; + /** Live-transport line ("xurl not installed. local mode active."). */ + transportText: string | null; + /** True while a sync request is in flight (disables the button). */ + syncing: boolean; + /** True when the active tab maps to a live collection AND a transport exists. */ + canSync: boolean; + /** Proactive one-liner ("2 mentions still need a reply"); null when quiet. */ + nudge: string | null; + /** Error text for the error state. */ + error: string | null; + /** Install guidance for the setup state. */ + setupHint: string | null; +} + +/** A snapshot every surface can render before live data arrives. */ +export const EMPTY_BIRDCLAW_SNAPSHOT: BirdclawSnapshot = { + status: "loading", + tabs: BIRDCLAW_TABS.map((tab, index) => ({ + id: tab.id, + label: tab.label, + active: index === 0, + })), + rows: [], + transportText: null, + syncing: false, + canSync: false, + nudge: null, + error: null, + setupHint: null, +}; + +function formatTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export interface BirdclawSpatialViewProps { + snapshot: BirdclawSnapshot; + /** + * Dispatch by agent id: `tab:` (switch tab), `sync` (refresh the active + * collection), `retry` (reload after an error), and `open:` (hand the + * row to the floating chat for follow-up). + */ + onAction?: (action: string) => void; +} + +export function BirdclawSpatialView({ + snapshot, + onAction, +}: BirdclawSpatialViewProps) { + const dispatch = (action: string) => () => onAction?.(action); + + return ( + + + + + ); +} + +function BirdclawTabs({ + tabs, + dispatch, +}: { + tabs: BirdclawTabChip[]; + dispatch: (action: string) => () => void; +}) { + return ( + + {tabs.map((tab) => ( + + ))} + + ); +} + +function BirdclawBody({ + snapshot, + dispatch, +}: { + snapshot: BirdclawSnapshot; + dispatch: (action: string) => () => void; +}) { + if (snapshot.status === "loading") { + return ( + + Loading archive + + ); + } + + if (snapshot.status === "setup") { + return ( + + Birdclaw is not set up yet + + Birdclaw keeps a local-first archive of your Twitter/X timeline, + mentions, likes, and bookmarks in a private SQLite database. + + {snapshot.setupHint ? ( + {snapshot.setupHint} + ) : null} + + Install: brew install steipete/tap/birdclaw — then birdclaw init. + Docs: birdclaw.sh + + + + ); + } + + if (snapshot.status === "error") { + return ( + + Couldn't load the archive + + {snapshot.error ?? "Could not load the birdclaw archive."} + + + + ); + } + + return ( + <> + + {snapshot.status === "empty" || snapshot.rows.length === 0 ? ( + + Nothing here yet — import an archive or sync a collection. + + ) : ( + + )} + + ); +} + +function BirdclawToolbar({ + snapshot, + dispatch, +}: { + snapshot: BirdclawSnapshot; + dispatch: (action: string) => () => void; +}) { + return ( + + {snapshot.nudge ? ( + + {snapshot.nudge} + + ) : null} + + + {snapshot.transportText ?? ""} + + {snapshot.canSync ? ( + + ) : null} + + + ); +} + +function BirdclawRows({ + rows, + dispatch, +}: { + rows: BirdclawRow[]; + dispatch: (action: string) => () => void; +}) { + return ( + <> + + + {rows.map((row) => ( + + + + {row.accent ? "●" : "○"} + + + {row.title} + + + {row.body} + + + {row.meta ? `${row.meta} • ` : ""} + {formatTime(row.time)} + + + + + ))} + + + ); +} diff --git a/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawView.test.tsx b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawView.test.tsx new file mode 100644 index 0000000000000..62c7b3e4044f8 --- /dev/null +++ b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawView.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { + BirdclawInboxItem, + BirdclawStatusInfo, + BirdclawTweet, +} from "../../types.ts"; +import type { BirdclawFetchers } from "./BirdclawView.tsx"; +import { BirdclawView } from "./BirdclawView.tsx"; + +const INSTALLED_STATUS: BirdclawStatusInfo = { + installed: true, + version: "0.8.5", + home: "/home/user/.birdclaw", + counts: { home: 4, mentions: 2, dms: 4, needsReply: 2, inbox: 4 }, + transport: { + installed: false, + availableTransport: "local", + statusText: "xurl not installed. local mode active.", + }, + message: null, +}; + +const TWEET: BirdclawTweet = { + id: "t1", + text: "Local-first sync engines beat manual export.", + createdAt: "2026-03-08T11:18:00.000Z", + authorHandle: "amelia", + authorName: "Amelia N", + likeCount: 42, + liked: false, + bookmarked: false, + isReplied: null, + kind: "home", +}; + +const INBOX_ITEM: BirdclawInboxItem = { + id: "m1", + kind: "mention", + title: "Mention from Amelia", + text: "curious how you decide...", + createdAt: "2026-03-08T11:48:00.000Z", + needsReply: true, + score: 76, + participantHandle: "amelia", +}; + +interface FetcherLog { + tweets: Array<{ resource: string; liked?: boolean; bookmarked?: boolean }>; + inbox: number; + syncs: string[]; +} + +function fetchers(overrides: Partial = {}): { + fetchers: BirdclawFetchers; + log: FetcherLog; +} { + const log: FetcherLog = { tweets: [], inbox: 0, syncs: [] }; + return { + log, + fetchers: { + fetchStatus: async () => INSTALLED_STATUS, + fetchTweets: async (params) => { + log.tweets.push(params); + return [TWEET]; + }, + fetchInbox: async () => { + log.inbox += 1; + return [INBOX_ITEM]; + }, + triggerSync: async (collection) => { + log.syncs.push(collection); + }, + ...overrides, + }, + }; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +async function render(view: BirdclawFetchers) { + await act(async () => { + root.render(); + }); + // Let the async load settle. + await act(async () => { + await Promise.resolve(); + }); +} + +function click(agentId: string) { + const button = container.querySelector( + `[data-agent-id="${agentId}"]`, + ); + if (!button) { + throw new Error( + `no element with data-agent-id="${agentId}" in: ${container.innerHTML.slice(0, 400)}`, + ); + } + return act(async () => { + button.click(); + await Promise.resolve(); + }); +} + +describe("BirdclawView", () => { + it("loads the home timeline on mount and renders rows", async () => { + const { fetchers: seam, log } = fetchers(); + await render(seam); + expect(log.tweets).toEqual([{ resource: "home" }]); + expect(container.textContent).toContain("@amelia"); + expect(container.textContent).toContain("Local-first sync engines"); + }); + + it("renders the setup state when birdclaw is not installed", async () => { + const { fetchers: seam } = fetchers({ + fetchStatus: async () => ({ + installed: false, + version: null, + home: null, + counts: null, + transport: null, + message: "birdclaw is not installed.", + }), + }); + await render(seam); + expect(container.textContent).toContain("Birdclaw is not set up yet"); + expect(container.textContent).toContain("birdclaw is not installed."); + }); + + it("renders the error state and recovers on retry", async () => { + let fail = true; + const { fetchers: seam } = fetchers({ + fetchTweets: async (params) => { + if (fail) throw new Error("database is locked"); + return params.resource === "home" ? [TWEET] : []; + }, + }); + await render(seam); + expect(container.textContent).toContain("database is locked"); + fail = false; + await click("retry"); + expect(container.textContent).toContain("@amelia"); + }); + + it("switches tabs: likes uses the liked filter, inbox hits the inbox route", async () => { + const { fetchers: seam, log } = fetchers(); + await render(seam); + await click("birdclaw-tab-likes"); + expect(log.tweets.at(-1)).toEqual({ + resource: "home", + liked: true, + bookmarked: false, + }); + await click("birdclaw-tab-inbox"); + expect(log.inbox).toBe(1); + expect(container.textContent).toContain("needs reply"); + }); + + it("shows the needs-reply nudge from inbox rows", async () => { + const { fetchers: seam } = fetchers(); + await render(seam); + await click("birdclaw-tab-inbox"); + expect(container.textContent).toContain("1 item still needs a reply."); + }); + + it("syncs the active tab's collection and reloads in place", async () => { + const { fetchers: seam, log } = fetchers({ + fetchStatus: async () => ({ + ...INSTALLED_STATUS, + transport: { + installed: true, + availableTransport: "xurl", + statusText: "xurl ready.", + }, + }), + }); + await render(seam); + await click("birdclaw-sync"); + expect(log.syncs).toEqual(["timeline"]); + // Sync triggers a background reload of the same tab. + expect(log.tweets.length).toBeGreaterThanOrEqual(2); + }); + + it("surfaces a sync failure without dropping the list", async () => { + const { fetchers: seam } = fetchers({ + fetchStatus: async () => ({ + ...INSTALLED_STATUS, + transport: { + installed: true, + availableTransport: "xurl", + statusText: "xurl ready.", + }, + }), + triggerSync: async () => { + throw new Error("xurl not installed"); + }, + }); + await render(seam); + await click("birdclaw-sync"); + expect(container.textContent).toContain("xurl not installed"); + expect(container.textContent).toContain("@amelia"); + }); +}); diff --git a/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawView.tsx b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawView.tsx new file mode 100644 index 0000000000000..4fd9e37b58d77 --- /dev/null +++ b/plugins/plugin-birdclaw/src/components/birdclaw/BirdclawView.tsx @@ -0,0 +1,369 @@ +/** + * BirdclawView — the single GUI/XR/TUI data wrapper for the Birdclaw archive + * browser. + * + * It owns the live data (the `/api/birdclaw/*` fetches, the tab selection, + * the sync flow, and the loading/setup/error/empty/ready state machine) and + * renders the one presentational {@link BirdclawSpatialView} — the same + * component every surface draws. + * + * Data source (the plugin's own routes; the service owns the CLI): + * GET {base}/api/birdclaw/status + * GET {base}/api/birdclaw/tweets?resource=&liked=&bookmarked=&limit= + * GET {base}/api/birdclaw/inbox?kind=&limit= + * POST {base}/api/birdclaw/sync {collection} + * + * The default fetchers build URLs from `client.getBaseUrl()`; tests inject the + * fetcher seam so they stay offline. There is no background poll: the archive + * only changes when a sync or import runs, so the view refreshes after its own + * syncs and on tab changes instead of burning a poll loop. + */ + +// The narrow host-external subpath (DynamicViewLoader serves it at runtime +// exactly like the barrel) — importing the full `@elizaos/ui` barrel here +// would drag every chat widget into the jsdom test graph for one singleton. +import { client } from "@elizaos/ui/api"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + BirdclawInboxItem, + BirdclawStatusInfo, + BirdclawSyncCollection, + BirdclawTweet, +} from "../../types.ts"; +import { + BIRDCLAW_TABS, + type BirdclawRow, + type BirdclawSnapshot, + BirdclawSpatialView, + type BirdclawTabId, +} from "./BirdclawSpatialView.tsx"; + +// --------------------------------------------------------------------------- +// Fetcher seam — default to real GETs; tests inject offline fakes. +// --------------------------------------------------------------------------- + +export interface BirdclawFetchers { + fetchStatus: () => Promise; + fetchTweets: (params: { + resource: "home" | "mentions" | "authored"; + liked?: boolean; + bookmarked?: boolean; + limit?: number; + }) => Promise; + fetchInbox: (params: { + kind: "mixed" | "mentions" | "dms"; + }) => Promise; + triggerSync: (collection: BirdclawSyncCollection) => Promise; +} + +async function requestJson(path: string, init?: RequestInit): Promise { + const response = await fetch(`${client.getBaseUrl()}${path}`, init); + const payload = (await response.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!response.ok) { + const message = + payload && typeof payload.error === "string" + ? payload.error + : `Request failed (${response.status})`; + throw new Error(message); + } + if (payload === null) throw new Error("Empty response"); + return payload as T; +} + +const defaultFetchers: BirdclawFetchers = { + fetchStatus: async () => + (await requestJson<{ status: BirdclawStatusInfo }>("/api/birdclaw/status")) + .status, + fetchTweets: async (params) => { + const search = new URLSearchParams({ resource: params.resource }); + if (params.liked) search.set("liked", "1"); + if (params.bookmarked) search.set("bookmarked", "1"); + if (params.limit) search.set("limit", String(params.limit)); + const payload = await requestJson<{ tweets: BirdclawTweet[] }>( + `/api/birdclaw/tweets?${search.toString()}`, + ); + return payload.tweets; + }, + fetchInbox: async (params) => { + const payload = await requestJson<{ items: BirdclawInboxItem[] }>( + `/api/birdclaw/inbox?kind=${params.kind}`, + ); + return payload.items; + }, + triggerSync: async (collection) => { + await requestJson<{ result: unknown }>("/api/birdclaw/sync", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ collection }), + }); + }, +}; + +/** Single chat-handoff seam: deeper follow-ups live in the floating chat. */ +function sendChatPrompt(prompt: string): void { + (client as { sendChatMessage?: (text: string) => void }).sendChatMessage?.( + prompt, + ); +} + +// --------------------------------------------------------------------------- +// Tab → query mapping. +// --------------------------------------------------------------------------- + +const TAB_SYNC_COLLECTION: Partial< + Record +> = { + home: "timeline", + mentions: "mentions", + authored: "authored", + likes: "likes", + bookmarks: "bookmarks", +}; + +function tweetToRow(tweet: BirdclawTweet): BirdclawRow { + const marks = [ + tweet.likeCount !== null ? `♥${tweet.likeCount}` : null, + tweet.liked ? "liked" : null, + tweet.bookmarked ? "bookmarked" : null, + ] + .filter(Boolean) + .join(" · "); + return { + id: tweet.id, + title: tweet.authorHandle + ? `@${tweet.authorHandle}` + : (tweet.authorName ?? "unknown"), + body: tweet.text, + meta: marks, + time: tweet.createdAt, + accent: tweet.kind === "mention" && tweet.isReplied === false, + }; +} + +function inboxItemToRow(item: BirdclawInboxItem): BirdclawRow { + return { + id: item.id, + title: item.participantHandle ? `@${item.participantHandle}` : item.title, + body: item.text, + meta: [ + item.kind, + item.needsReply ? "needs reply" : null, + item.score !== null ? `score ${item.score}` : null, + ] + .filter(Boolean) + .join(" · "), + time: item.createdAt, + accent: item.needsReply, + }; +} + +function needsReplyNudge(rows: readonly BirdclawRow[]): string | null { + const needing = rows.reduce((n, row) => (row.accent ? n + 1 : n), 0); + if (needing === 0) return null; + return `${needing} item${needing === 1 ? " still needs" : "s still need"} a reply.`; +} + +// --------------------------------------------------------------------------- +// Fetch-driven state machine. +// --------------------------------------------------------------------------- + +interface ReadyData { + rows: BirdclawRow[]; + status: BirdclawStatusInfo; +} + +type LoadState = + | { kind: "loading" } + | { kind: "setup"; hint: string | null } + | { kind: "error"; message: string } + | { kind: "ready"; data: ReadyData }; + +export interface BirdclawViewProps { + /** Test/host injection seam. Defaults to the real `/api/birdclaw/*` calls. */ + fetchers?: BirdclawFetchers; +} + +async function loadTab( + fetchers: BirdclawFetchers, + tab: BirdclawTabId, +): Promise { + if (tab === "inbox") { + const items = await fetchers.fetchInbox({ kind: "mixed" }); + return items.map(inboxItemToRow); + } + if (tab === "likes" || tab === "bookmarks") { + const tweets = await fetchers.fetchTweets({ + resource: "home", + liked: tab === "likes", + bookmarked: tab === "bookmarks", + }); + return tweets.map(tweetToRow); + } + const tweets = await fetchers.fetchTweets({ resource: tab }); + return tweets.map(tweetToRow); +} + +export function BirdclawView(props: BirdclawViewProps = {}): ReactNode { + const fetchers = props.fetchers ?? defaultFetchers; + const [state, setState] = useState({ kind: "loading" }); + const [activeTab, setActiveTab] = useState("home"); + const [syncing, setSyncing] = useState(false); + const [syncError, setSyncError] = useState(null); + + const fetchersRef = useRef(fetchers); + fetchersRef.current = fetchers; + + // `background` refreshes the already-rendered list in place (post-sync); + // user-driven loads (mount, tab switch, retry) show the loading state. + const load = useCallback((tab: BirdclawTabId, background = false) => { + let cancelled = false; + if (!background) setState({ kind: "loading" }); + (async () => { + const status = await fetchersRef.current.fetchStatus(); + if (!status.installed) { + return { + kind: "setup" as const, + hint: status.message, + }; + } + const rows = await loadTab(fetchersRef.current, tab); + return { kind: "ready" as const, data: { rows, status } }; + })() + .then((next) => { + if (!cancelled) setState(next); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ + kind: "error", + message: + error instanceof Error + ? error.message + : "Could not load the birdclaw archive.", + }); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + setSyncError(null); + return load(activeTab); + }, [load, activeTab]); + + const onAction = useCallback( + (action: string) => { + if (action.startsWith("tab:")) { + const tab = action.slice("tab:".length); + if (BIRDCLAW_TABS.some((candidate) => candidate.id === tab)) { + setActiveTab(tab as BirdclawTabId); + } + return; + } + if (action.startsWith("open:")) { + const id = action.slice("open:".length); + const row = + state.kind === "ready" + ? state.data.rows.find((candidate) => candidate.id === id) + : undefined; + if (row) { + sendChatPrompt( + `In my birdclaw Twitter archive, find the context around ${row.title}'s post: "${row.body.slice(0, 140)}" and summarize the conversation.`, + ); + } + return; + } + if (action === "retry") { + load(activeTab); + return; + } + if (action === "sync") { + const collection = TAB_SYNC_COLLECTION[activeTab]; + if (!collection || syncing) return; + setSyncing(true); + setSyncError(null); + fetchersRef.current + .triggerSync(collection) + .then(() => load(activeTab, true)) + .catch((error: unknown) => { + setSyncError( + error instanceof Error ? error.message : "Sync failed.", + ); + }) + .finally(() => setSyncing(false)); + } + }, + [state, activeTab, load, syncing], + ); + + const snapshot: BirdclawSnapshot = useMemo(() => { + const tabs = BIRDCLAW_TABS.map((tab) => ({ + id: tab.id, + label: tab.label, + active: tab.id === activeTab, + })); + if (state.kind === "loading") { + return { + status: "loading", + tabs, + rows: [], + transportText: null, + syncing: false, + canSync: false, + nudge: null, + error: null, + setupHint: null, + }; + } + if (state.kind === "setup") { + return { + status: "setup", + tabs, + rows: [], + transportText: null, + syncing: false, + canSync: false, + nudge: null, + error: null, + setupHint: state.hint, + }; + } + if (state.kind === "error") { + return { + status: "error", + tabs, + rows: [], + transportText: null, + syncing: false, + canSync: false, + nudge: null, + error: state.message, + setupHint: null, + }; + } + const transport = state.data.status.transport; + const transportText = syncError ?? transport?.statusText ?? null; + return { + status: state.data.rows.length === 0 ? "empty" : "ready", + tabs, + rows: state.data.rows, + transportText, + syncing, + canSync: Boolean( + transport?.installed && TAB_SYNC_COLLECTION[activeTab] !== undefined, + ), + nudge: needsReplyNudge(state.data.rows), + error: null, + setupHint: null, + }; + }, [state, activeTab, syncing, syncError]); + + return ; +} + +export default BirdclawView; diff --git a/plugins/plugin-birdclaw/src/components/birdclaw/birdclaw-view-bundle.ts b/plugins/plugin-birdclaw/src/components/birdclaw/birdclaw-view-bundle.ts new file mode 100644 index 0000000000000..4400f8c7ac083 --- /dev/null +++ b/plugins/plugin-birdclaw/src/components/birdclaw/birdclaw-view-bundle.ts @@ -0,0 +1,5 @@ +// Vite view-bundle entry. Re-exports the BirdclawView component so the built +// bundle (dist/views/bundle.js) exposes the named export the view loader +// reads. Kept separate from BirdclawView.tsx so that file exports only React +// components and stays Fast-Refresh-compatible in dev. +export { BirdclawView } from "./BirdclawView.tsx"; diff --git a/plugins/plugin-birdclaw/src/index.ts b/plugins/plugin-birdclaw/src/index.ts new file mode 100644 index 0000000000000..66ecbf7dd7ade --- /dev/null +++ b/plugins/plugin-birdclaw/src/index.ts @@ -0,0 +1,46 @@ +export { birdclawAction } from "./actions/birdclaw.ts"; +export { + BirdclawCliError, + type BirdclawCliErrorKind, + type BirdclawExec, + type BirdclawExecOptions, + type BirdclawExecResult, + defaultBirdclawExec, + runBirdclawJson, + runBirdclawText, +} from "./birdclaw/cli.ts"; +export { + type BirdclawInboxOptions, + type BirdclawSearchOptions, + BirdclawService, + type BirdclawServiceOptions, + buildInboxArgs, + buildSearchArgs, + clampLimit, + parseCounts, + parseInboxItems, + parseTransport, + parseTweets, + summarizeSyncPayload, +} from "./birdclaw/service.ts"; +export { birdclawPlugin, birdclawPlugin as default } from "./plugin.ts"; +export { birdclawRoutes } from "./routes/birdclaw-routes.ts"; +export type { + BirdclawCounts, + BirdclawDigestPeriod, + BirdclawDigestResult, + BirdclawInboxItem, + BirdclawInboxKind, + BirdclawResource, + BirdclawStatusInfo, + BirdclawSyncCollection, + BirdclawSyncResult, + BirdclawTransport, + BirdclawTweet, +} from "./types.ts"; +export { + BIRDCLAW_DIGEST_PERIODS, + BIRDCLAW_INBOX_KINDS, + BIRDCLAW_RESOURCES, + BIRDCLAW_SYNC_COLLECTIONS, +} from "./types.ts"; diff --git a/plugins/plugin-birdclaw/src/plugin.test.ts b/plugins/plugin-birdclaw/src/plugin.test.ts new file mode 100644 index 0000000000000..0d1e24e62e1f4 --- /dev/null +++ b/plugins/plugin-birdclaw/src/plugin.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { birdclawPlugin } from "./plugin.ts"; + +/** + * Structural guard for the plugin registration surface: the view descriptor + * is what the agent's view registry and the app launcher consume, so drift + * here (renamed export, moved bundle, dropped modality) breaks the tile at + * runtime without a type error. + */ +describe("birdclawPlugin registration surface", () => { + it("registers the service, action, and routes", () => { + expect(birdclawPlugin.name).toBe("birdclaw"); + expect(birdclawPlugin.services?.map((svc) => svc.serviceType)).toContain( + "BIRDCLAW_SERVICE", + ); + expect(birdclawPlugin.actions?.map((action) => action.name)).toEqual([ + "BIRDCLAW", + ]); + expect(birdclawPlugin.routes?.map((route) => route.name)).toEqual([ + "birdclaw-status", + "birdclaw-tweets", + "birdclaw-inbox", + "birdclaw-sync", + "birdclaw-digest", + ]); + }); + + it("declares the birdclaw view exactly as the bundle build emits it", () => { + const views = birdclawPlugin.views ?? []; + expect(views).toHaveLength(1); + expect(views[0]).toMatchObject({ + id: "birdclaw", + label: "Birdclaw", + path: "/birdclaw", + modalities: ["gui", "xr", "tui"], + bundlePath: "dist/views/bundle.js", + componentExport: "BirdclawView", + visibleInManager: true, + }); + }); +}); diff --git a/plugins/plugin-birdclaw/src/plugin.ts b/plugins/plugin-birdclaw/src/plugin.ts new file mode 100644 index 0000000000000..cdfe35e98968c --- /dev/null +++ b/plugins/plugin-birdclaw/src/plugin.ts @@ -0,0 +1,55 @@ +import type { Plugin } from "@elizaos/core"; +import { birdclawAction } from "./actions/birdclaw.ts"; +import { BirdclawService } from "./birdclaw/service.ts"; +import { birdclawRoutes } from "./routes/birdclaw-routes.ts"; + +/** + * `@elizaos/plugin-birdclaw` — birdclaw (https://birdclaw.sh) local-first + * Twitter/X memory for elizaOS. + * + * Registers `BIRDCLAW_SERVICE` (a typed wrapper over the birdclaw CLI's + * `--json` envelopes), the owner-gated `BIRDCLAW` action (search / inbox / + * sync / digest / status over the local archive), the `/api/birdclaw/*` + * routes, and the Birdclaw archive-browser view. + * + * Enablement: the agent auto-loads this plugin when the birdclaw CLI (or an + * existing `~/.birdclaw` data root) is present on the host — see + * `birdclawRequested` in `packages/agent/src/runtime/plugin-collector.ts`. + * `ELIZA_BIRDCLAW=1/0` forces it on or off. Everything degrades explicitly + * when the binary goes missing: the view renders a setup screen and the + * action stops validating. + */ +export const birdclawPlugin: Plugin = { + name: "birdclaw", + description: + "Local-first Twitter/X memory (birdclaw.sh): browse and search the archived timeline, mentions, likes, and bookmarks; triage the mention/DM inbox; trigger live syncs; build digests.", + services: [BirdclawService], + actions: [birdclawAction], + routes: birdclawRoutes, + views: [ + { + id: "birdclaw", + label: "Birdclaw", + description: + "Local-first Twitter/X memory — browse your archived timeline, mentions, likes, and bookmarks", + icon: "Bird", + path: "/birdclaw", + // ONE declaration → GUI + XR + TUI, all drawn from the single + // BirdclawSpatialView source. `modalities` stays a plain literal here + // (plugin.ts is not in the view bundle). + modalities: ["gui", "xr", "tui"], + bundlePath: "dist/views/bundle.js", + componentExport: "BirdclawView", + tags: ["twitter", "x", "social", "archive", "memory", "birdclaw"], + visibleInManager: true, + desktopTabEnabled: true, + }, + ], + async dispose(runtime) { + await runtime + .getService(BirdclawService.serviceType) + ?.stop(); + }, +}; + +export default birdclawPlugin; diff --git a/plugins/plugin-birdclaw/src/routes/birdclaw-routes.test.ts b/plugins/plugin-birdclaw/src/routes/birdclaw-routes.test.ts new file mode 100644 index 0000000000000..832cda7809286 --- /dev/null +++ b/plugins/plugin-birdclaw/src/routes/birdclaw-routes.test.ts @@ -0,0 +1,282 @@ +import type { IAgentRuntime, RouteHandlerContext } from "@elizaos/core"; +import { describe, expect, it } from "vitest"; +import type { BirdclawExec } from "../birdclaw/cli.ts"; +import { BirdclawCliError } from "../birdclaw/cli.ts"; +import { BirdclawService } from "../birdclaw/service.ts"; +import { birdclawRoutes } from "./birdclaw-routes.ts"; + +function route(name: string) { + const found = birdclawRoutes.find((candidate) => candidate.name === name); + if (!found?.routeHandler) throw new Error(`route ${name} not found`); + return found.routeHandler; +} + +function contextFor( + service: BirdclawService | null, + overrides: Partial = {}, +): RouteHandlerContext { + const runtime = { + getSetting: () => undefined, + getService: (type: string) => + type === BirdclawService.serviceType ? service : null, + } as unknown as IAgentRuntime; + return { + body: {}, + params: {}, + query: {}, + headers: {}, + method: "GET", + path: "/api/birdclaw/status", + runtime, + inProcess: true, + ...overrides, + } as RouteHandlerContext; +} + +function serviceWith( + respond: (args: readonly string[]) => { stdout: string } | Error, +): BirdclawService { + const exec: BirdclawExec = async (_bin, args) => { + const result = respond(args); + if (result instanceof Error) throw result; + return { stdout: result.stdout, stderr: "" }; + }; + const runtime = { + getSetting: () => undefined, + } as unknown as IAgentRuntime; + return new BirdclawService(runtime, { exec }); +} + +const HEALTHY = (args: readonly string[]) => { + if (args[0] === "--version") return { stdout: "0.8.5" }; + if (args[0] === "db") { + return { + stdout: JSON.stringify({ + paths: { rootDir: "/home/user/.birdclaw" }, + stats: { home: 4, mentions: 2, dms: 4, needsReply: 2, inbox: 4 }, + transport: { + installed: false, + availableTransport: "local", + statusText: "xurl not installed. local mode active.", + }, + }), + }; + } + if (args[0] === "search") { + return { + stdout: JSON.stringify([ + { + id: "t1", + text: "hello", + createdAt: "2026-03-08T11:18:00.000Z", + liked: false, + bookmarked: false, + author: { handle: "steipete", displayName: "Peter" }, + }, + ]), + }; + } + if (args[0] === "inbox") { + return { + stdout: JSON.stringify({ + items: [ + { + id: "m1", + entityKind: "mention", + title: "Mention", + text: "ping", + createdAt: "2026-03-08T11:48:00.000Z", + needsReply: true, + }, + ], + }), + }; + } + if (args[0] === "sync") return { stdout: JSON.stringify({ fetched: 2 }) }; + return new Error(`unexpected argv ${args.join(" ")}`); +}; + +describe("GET /api/birdclaw/status", () => { + it("returns installed:false when no service is registered", async () => { + const result = await route("birdclaw-status")(contextFor(null)); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + status: { + installed: false, + message: expect.stringContaining("not available"), + }, + }); + }); + + it("returns the full status when birdclaw is healthy", async () => { + const result = await route("birdclaw-status")( + contextFor(serviceWith(HEALTHY)), + ); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + status: { + installed: true, + version: "0.8.5", + counts: { mentions: 2 }, + transport: { availableTransport: "local" }, + }, + }); + }); + + it("returns installed:false with guidance when the binary is missing", async () => { + const service = serviceWith( + () => new BirdclawCliError("not-installed", "birdclaw binary not found"), + ); + const result = await route("birdclaw-status")(contextFor(service)); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + status: { + installed: false, + message: expect.stringContaining("brew install steipete/tap/birdclaw"), + }, + }); + }); +}); + +describe("GET /api/birdclaw/tweets", () => { + it("rejects an unknown resource", async () => { + const ctx = contextFor(serviceWith(HEALTHY), { + query: { resource: "dms" }, + }); + const result = await route("birdclaw-tweets")(ctx); + expect(result.status).toBe(400); + }); + + it("returns flattened tweets", async () => { + const ctx = contextFor(serviceWith(HEALTHY), { + query: { resource: "home", liked: "1", limit: "5" }, + }); + const result = await route("birdclaw-tweets")(ctx); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + tweets: [{ id: "t1", authorHandle: "steipete" }], + }); + }); + + it("maps a missing binary to 503 with installed:false", async () => { + const service = serviceWith( + () => new BirdclawCliError("not-installed", "birdclaw binary not found"), + ); + const result = await route("birdclaw-tweets")(contextFor(service)); + expect(result.status).toBe(503); + expect(result.body).toMatchObject({ installed: false }); + }); + + it("maps a CLI failure to 502", async () => { + const service = serviceWith( + () => new BirdclawCliError("failed", "database is locked"), + ); + const result = await route("birdclaw-tweets")(contextFor(service)); + expect(result.status).toBe(502); + expect(result.body).toMatchObject({ + error: expect.stringContaining("database is locked"), + }); + }); + + it("returns 503 when no service is registered", async () => { + const result = await route("birdclaw-tweets")(contextFor(null)); + expect(result.status).toBe(503); + }); +}); + +describe("GET /api/birdclaw/inbox", () => { + it("rejects an unknown kind", async () => { + const ctx = contextFor(serviceWith(HEALTHY), { query: { kind: "spam" } }); + const result = await route("birdclaw-inbox")(ctx); + expect(result.status).toBe(400); + }); + + it("returns flattened inbox items", async () => { + const ctx = contextFor(serviceWith(HEALTHY), { + query: { kind: "mentions" }, + }); + const result = await route("birdclaw-inbox")(ctx); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + items: [{ id: "m1", needsReply: true, kind: "mention" }], + }); + }); +}); + +describe("POST /api/birdclaw/sync", () => { + it("rejects an unknown collection", async () => { + const ctx = contextFor(serviceWith(HEALTHY), { + method: "POST", + body: { collection: "everything" }, + }); + const result = await route("birdclaw-sync")(ctx); + expect(result.status).toBe(400); + }); + + it("runs a valid sync", async () => { + const ctx = contextFor(serviceWith(HEALTHY), { + method: "POST", + body: { collection: "bookmarks" }, + }); + const result = await route("birdclaw-sync")(ctx); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + result: { collection: "bookmarks", ok: true }, + }); + }); + + it("surfaces sync transport failures as 502", async () => { + const service = serviceWith((args) => + args[0] === "sync" + ? new BirdclawCliError("failed", "xurl not installed") + : HEALTHY(args), + ); + const ctx = contextFor(service, { + method: "POST", + body: { collection: "timeline" }, + }); + const result = await route("birdclaw-sync")(ctx); + expect(result.status).toBe(502); + expect(result.body).toMatchObject({ + error: expect.stringContaining("xurl not installed"), + }); + }); +}); + +describe("POST /api/birdclaw/digest", () => { + it("rejects an unknown period", async () => { + const ctx = contextFor(serviceWith(HEALTHY), { + method: "POST", + body: { period: "decade" }, + }); + const result = await route("birdclaw-digest")(ctx); + expect(result.status).toBe(400); + }); + + it("returns the digest text", async () => { + const service = serviceWith((args) => + args[0] === "digest" + ? { stdout: JSON.stringify({ digest: "Quiet day." }) } + : HEALTHY(args), + ); + const ctx = contextFor(service, { + method: "POST", + body: { period: "today" }, + }); + const result = await route("birdclaw-digest")(ctx); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + digest: { period: "today", text: "Quiet day." }, + }); + }); +}); + +describe("route registration", () => { + it("keeps every route private (no public flag) and rawPath-stable", () => { + for (const candidate of birdclawRoutes) { + expect(candidate.public).toBeUndefined(); + expect(candidate.rawPath).toBe(true); + expect(candidate.path.startsWith("/api/birdclaw/")).toBe(true); + } + }); +}); diff --git a/plugins/plugin-birdclaw/src/routes/birdclaw-routes.ts b/plugins/plugin-birdclaw/src/routes/birdclaw-routes.ts new file mode 100644 index 0000000000000..fd22cd939fc97 --- /dev/null +++ b/plugins/plugin-birdclaw/src/routes/birdclaw-routes.ts @@ -0,0 +1,235 @@ +/** + * HTTP routes the Birdclaw view (and any other client) drives. + * + * All routes are private (no `public: true`), so the agent server's standard + * route authorization applies before a handler runs — these expose the + * owner's archived tweets, mentions, and DMs. `rawPath` keeps the URLs stable + * at `/api/birdclaw/*` for the view instead of prefixing the plugin name. + * + * Error contract the view renders from: + * - 200 `{ status }` from GET /status even when birdclaw is missing + * (`installed: false` + a resolution message → setup screen). + * - 503 `{ error, installed: false }` from data routes when the CLI is + * missing or the service is not registered. + * - 400 `{ error }` for invalid parameters. + * - 502 `{ error }` when the CLI itself fails (broken DB, sync failure). + */ + +import type { + Route, + RouteHandlerContext, + RouteHandlerResult, +} from "@elizaos/core"; +import { logger } from "@elizaos/core"; +import { BirdclawCliError } from "../birdclaw/cli.ts"; +import { BirdclawService, clampLimit } from "../birdclaw/service.ts"; +import { + isBirdclawDigestPeriod, + isBirdclawInboxKind, + isBirdclawResource, + isBirdclawSyncCollection, +} from "../types.ts"; + +function json(status: number, body: unknown): RouteHandlerResult { + return { status, headers: { "content-type": "application/json" }, body }; +} + +function getService(ctx: RouteHandlerContext): BirdclawService | null { + return ( + (ctx.runtime.getService( + BirdclawService.serviceType, + ) as BirdclawService | null) ?? null + ); +} + +function queryStr(ctx: RouteHandlerContext, name: string): string | undefined { + const value = ctx.query[name]; + const first = Array.isArray(value) ? value[0] : value; + return typeof first === "string" && first.trim().length > 0 + ? first.trim() + : undefined; +} + +function queryFlag(ctx: RouteHandlerContext, name: string): boolean { + const value = queryStr(ctx, name); + return value === "1" || value === "true"; +} + +function queryLimit(ctx: RouteHandlerContext, fallback: number): number { + const raw = queryStr(ctx, "limit"); + return clampLimit(raw === undefined ? undefined : Number(raw), fallback); +} + +/** Map a thrown CLI error to the route error contract. */ +function cliFailure(err: unknown): RouteHandlerResult { + if (err instanceof BirdclawCliError) { + if (err.kind === "not-installed") { + return json(503, { error: err.message, installed: false }); + } + return json(502, { error: err.message }); + } + const message = err instanceof Error ? err.message : String(err); + return json(502, { error: message }); +} + +async function statusHandler( + ctx: RouteHandlerContext, +): Promise { + const svc = getService(ctx); + if (!svc) { + return json(200, { + status: { + installed: false, + version: null, + home: null, + counts: null, + transport: null, + message: "BIRDCLAW_SERVICE is not available on this agent.", + }, + }); + } + return json(200, { status: await svc.status() }); +} + +async function tweetsHandler( + ctx: RouteHandlerContext, +): Promise { + const svc = getService(ctx); + if (!svc) { + return json(503, { + error: "BIRDCLAW_SERVICE is not available.", + installed: false, + }); + } + const resource = queryStr(ctx, "resource") ?? "home"; + if (!isBirdclawResource(resource)) { + return json(400, { + error: `Unsupported resource "${resource}". Expected home, mentions, or authored.`, + }); + } + try { + const tweets = await svc.searchTweets({ + query: queryStr(ctx, "q"), + resource, + liked: queryFlag(ctx, "liked"), + bookmarked: queryFlag(ctx, "bookmarked"), + limit: queryLimit(ctx, 20), + }); + return json(200, { tweets }); + } catch (err) { + return cliFailure(err); + } +} + +async function inboxHandler( + ctx: RouteHandlerContext, +): Promise { + const svc = getService(ctx); + if (!svc) { + return json(503, { + error: "BIRDCLAW_SERVICE is not available.", + installed: false, + }); + } + const kind = queryStr(ctx, "kind") ?? "mixed"; + if (!isBirdclawInboxKind(kind)) { + return json(400, { + error: `Unsupported inbox kind "${kind}". Expected mixed, mentions, or dms.`, + }); + } + try { + const items = await svc.inbox({ kind, limit: queryLimit(ctx, 20) }); + return json(200, { items }); + } catch (err) { + return cliFailure(err); + } +} + +async function syncHandler( + ctx: RouteHandlerContext, +): Promise { + const svc = getService(ctx); + if (!svc) { + return json(503, { + error: "BIRDCLAW_SERVICE is not available.", + installed: false, + }); + } + const body = (ctx.body ?? {}) as Record; + const collection = typeof body.collection === "string" ? body.collection : ""; + if (!isBirdclawSyncCollection(collection)) { + return json(400, { + error: `Unsupported sync collection "${collection}". Expected timeline, mentions, authored, likes, or bookmarks.`, + }); + } + try { + const result = await svc.sync(collection); + logger.info(`[plugin-birdclaw] sync ${collection}: ${result.summary}`); + return json(200, { result }); + } catch (err) { + return cliFailure(err); + } +} + +async function digestHandler( + ctx: RouteHandlerContext, +): Promise { + const svc = getService(ctx); + if (!svc) { + return json(503, { + error: "BIRDCLAW_SERVICE is not available.", + installed: false, + }); + } + const body = (ctx.body ?? {}) as Record; + const period = typeof body.period === "string" ? body.period : "today"; + if (!isBirdclawDigestPeriod(period)) { + return json(400, { + error: `Unsupported digest period "${period}". Expected today, 24h, yesterday, or week.`, + }); + } + try { + const digest = await svc.digest(period); + return json(200, { digest }); + } catch (err) { + return cliFailure(err); + } +} + +export const birdclawRoutes: Route[] = [ + { + type: "GET", + path: "/api/birdclaw/status", + rawPath: true, + name: "birdclaw-status", + routeHandler: statusHandler, + }, + { + type: "GET", + path: "/api/birdclaw/tweets", + rawPath: true, + name: "birdclaw-tweets", + routeHandler: tweetsHandler, + }, + { + type: "GET", + path: "/api/birdclaw/inbox", + rawPath: true, + name: "birdclaw-inbox", + routeHandler: inboxHandler, + }, + { + type: "POST", + path: "/api/birdclaw/sync", + rawPath: true, + name: "birdclaw-sync", + routeHandler: syncHandler, + }, + { + type: "POST", + path: "/api/birdclaw/digest", + rawPath: true, + name: "birdclaw-digest", + routeHandler: digestHandler, + }, +]; diff --git a/plugins/plugin-birdclaw/src/types.ts b/plugins/plugin-birdclaw/src/types.ts new file mode 100644 index 0000000000000..5e19f5e38ba69 --- /dev/null +++ b/plugins/plugin-birdclaw/src/types.ts @@ -0,0 +1,121 @@ +/** + * Display-oriented DTOs shared by the Birdclaw service, routes, action, and + * view. The birdclaw CLI's `--json` envelopes are richer than this (nested + * author profiles, entities, inline SVG avatars); the service narrows them to + * these flat rows at the parse boundary so every consumer renders display-only + * data and never touches the raw wire shape. + */ + +/** Tweet resources the birdclaw archive can list (`search tweets --resource`). */ +export const BIRDCLAW_RESOURCES = ["home", "mentions", "authored"] as const; +export type BirdclawResource = (typeof BIRDCLAW_RESOURCES)[number]; + +/** Inbox kinds (`inbox --kind`). */ +export const BIRDCLAW_INBOX_KINDS = ["mixed", "mentions", "dms"] as const; +export type BirdclawInboxKind = (typeof BIRDCLAW_INBOX_KINDS)[number]; + +/** Live collections a sync can refresh (`sync `). */ +export const BIRDCLAW_SYNC_COLLECTIONS = [ + "timeline", + "mentions", + "authored", + "likes", + "bookmarks", +] as const; +export type BirdclawSyncCollection = (typeof BIRDCLAW_SYNC_COLLECTIONS)[number]; + +/** Digest periods (`digest [period]`). */ +export const BIRDCLAW_DIGEST_PERIODS = [ + "today", + "24h", + "yesterday", + "week", +] as const; +export type BirdclawDigestPeriod = (typeof BIRDCLAW_DIGEST_PERIODS)[number]; + +/** One archived tweet row, flattened for display. */ +export interface BirdclawTweet { + id: string; + text: string; + createdAt: string; + authorHandle: string | null; + authorName: string | null; + likeCount: number | null; + liked: boolean; + bookmarked: boolean; + isReplied: boolean | null; + kind: string | null; +} + +/** One ranked inbox item (mention or DM triage row), flattened for display. */ +export interface BirdclawInboxItem { + id: string; + kind: string; + title: string; + text: string; + createdAt: string; + needsReply: boolean; + score: number | null; + participantHandle: string | null; +} + +/** Dataset counts from `db stats`. */ +export interface BirdclawCounts { + home: number; + mentions: number; + dms: number; + needsReply: number; + inbox: number; +} + +/** Live-transport state from `db stats` / `auth status`. */ +export interface BirdclawTransport { + installed: boolean; + availableTransport: string; + statusText: string; +} + +/** Install + dataset status the view's setup/ready states render from. */ +export interface BirdclawStatusInfo { + installed: boolean; + version: string | null; + home: string | null; + counts: BirdclawCounts | null; + transport: BirdclawTransport | null; + /** Human-readable reason when `installed` is false. */ + message: string | null; +} + +/** Result of a `sync ` run. */ +export interface BirdclawSyncResult { + collection: BirdclawSyncCollection; + ok: boolean; + /** One-line human summary of what the sync reported. */ + summary: string; +} + +/** Result of a `digest` run (AI digest of the local archive). */ +export interface BirdclawDigestResult { + period: BirdclawDigestPeriod; + text: string; +} + +export function isBirdclawResource(value: string): value is BirdclawResource { + return (BIRDCLAW_RESOURCES as readonly string[]).includes(value); +} + +export function isBirdclawInboxKind(value: string): value is BirdclawInboxKind { + return (BIRDCLAW_INBOX_KINDS as readonly string[]).includes(value); +} + +export function isBirdclawSyncCollection( + value: string, +): value is BirdclawSyncCollection { + return (BIRDCLAW_SYNC_COLLECTIONS as readonly string[]).includes(value); +} + +export function isBirdclawDigestPeriod( + value: string, +): value is BirdclawDigestPeriod { + return (BIRDCLAW_DIGEST_PERIODS as readonly string[]).includes(value); +} diff --git a/plugins/plugin-birdclaw/tsconfig.build.json b/plugins/plugin-birdclaw/tsconfig.build.json new file mode 100644 index 0000000000000..c26c7dc9859cb --- /dev/null +++ b/plugins/plugin-birdclaw/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.build.shared.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**"] +} diff --git a/plugins/plugin-birdclaw/tsconfig.json b/plugins/plugin-birdclaw/tsconfig.json new file mode 100644 index 0000000000000..ff066be4a194e --- /dev/null +++ b/plugins/plugin-birdclaw/tsconfig.json @@ -0,0 +1,52 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": ["node"], + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types", + "../../../node_modules/@types", + "../../../node_modules/.bun/node_modules/@types", + "../../../node_modules", + "../../../node_modules/.bun/node_modules" + ], + "allowImportingTsExtensions": true, + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "paths": { + "@elizaos/ui": ["../../packages/ui/src/index.ts"], + "@elizaos/ui/*": ["../../packages/ui/src/*"], + "@elizaos/core": ["../../packages/core/src/index.node.ts"], + "@elizaos/core/*": ["../../packages/core/src/*"], + "@elizaos/logger": ["../../packages/logger/src/index.ts"], + "@elizaos/logger/*": ["../../packages/logger/src/*"], + "@elizaos/shared": ["../../packages/shared/src/index.ts"], + "@elizaos/shared/*": ["../../packages/shared/src/*"], + "react": ["../../packages/app-core/node_modules/@types/react/index.d.ts"], + "react/jsx-runtime": [ + "../../packages/app-core/node_modules/@types/react/jsx-runtime.d.ts" + ], + "react/jsx-dev-runtime": [ + "../../packages/app-core/node_modules/@types/react/jsx-dev-runtime.d.ts" + ], + "react-dom": [ + "../../packages/app-core/node_modules/@types/react-dom/index.d.ts" + ] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.d.ts", + "../../packages/app-core/src/ambient-modules.d.ts" + ], + "exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx"] +} diff --git a/plugins/plugin-birdclaw/vite.config.views.ts b/plugins/plugin-birdclaw/vite.config.views.ts new file mode 100644 index 0000000000000..d66ef52ba7064 --- /dev/null +++ b/plugins/plugin-birdclaw/vite.config.views.ts @@ -0,0 +1,9 @@ +import { createViewBundleConfig } from "../../packages/scripts/view-bundle-vite.config.ts"; + +export default createViewBundleConfig({ + packageName: "@elizaos/plugin-birdclaw", + viewId: "birdclaw", + entry: "./src/components/birdclaw/birdclaw-view-bundle.ts", + outDir: "dist/views", + componentExport: "BirdclawView", +}); diff --git a/plugins/plugin-birdclaw/vitest.config.ts b/plugins/plugin-birdclaw/vitest.config.ts new file mode 100644 index 0000000000000..85a75b5be7de0 --- /dev/null +++ b/plugins/plugin-birdclaw/vitest.config.ts @@ -0,0 +1,107 @@ +import { readdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import baseConfig from "../../packages/test/vitest/default.config"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const baseAliases = Array.isArray(baseConfig.resolve?.alias) + ? baseConfig.resolve.alias + : []; + +/** + * Bun's isolated-store layout nests a react-router-dom copy under packages/ui + * WITHOUT a sibling react-router, so its internal `require("react-router/dom")` + * fails from inside the jsdom suite (the same pre-existing failure the other + * view plugins' suites hit). The store copy under node_modules/.bun keeps its + * dependencies as siblings and resolves cleanly — alias the bare specifier to + * it so this plugin's suite runs green regardless of hoisting. + */ +function resolveStorePackageDir(packageName: string): string | null { + const store = path.join(here, "../../node_modules/.bun"); + // Scoped packages are stored as "@scope+name@version". + const prefix = `${packageName.replace("/", "+")}@`; + try { + const entry = readdirSync(store).find((dir) => dir.startsWith(prefix)); + return entry ? path.join(store, entry, "node_modules", packageName) : null; + } catch { + return null; + } +} + +const reactRouterAliases = ( + [ + ["react-router-dom", /^react-router-dom$/], + ["@date-fns/tz", /^@date-fns\/tz$/], + ] as const +) + .map(([packageName, find]) => { + const dir = resolveStorePackageDir(packageName); + return dir ? { find, replacement: dir } : null; + }) + .filter((alias): alias is { find: RegExp; replacement: string } => + Boolean(alias), + ); + +// The unit suite covers the CLI runner seam, the service arg/parse logic, the +// route handlers, the BIRDCLAW action, and the view render states (jsdom via +// per-file directive). The base config supplies the @elizaos/* source aliases +// plugin.ts needs; the React aliases pin a single React copy so jsdom does not +// mix the workspace and hoisted peers (mirrors plugin-inbox). +// +// `birdclaw.real.test.ts` drives the REAL birdclaw CLI against a throwaway +// BIRDCLAW_HOME and is excluded from the default lane — run it with +// `bun run test:real` (requires the birdclaw binary). +const liveOnlyExcludes = [ + "dist/**", + "**/node_modules/**", + "**/*.live.test.{ts,tsx}", + "**/*.live.e2e.test.{ts,tsx}", + "**/*.real.test.{ts,tsx}", + "**/*.real.e2e.test.{ts,tsx}", + "**/*.integration.test.{ts,tsx}", + "**/*.e2e.test.{ts,tsx}", +]; + +const realLaneRequested = process.env.BIRDCLAW_REAL_TESTS === "1"; + +export default defineConfig({ + ...baseConfig, + resolve: { + ...baseConfig.resolve, + alias: [ + ...reactRouterAliases, + { + find: /^react$/, + replacement: path.dirname(require.resolve("react/package.json")), + }, + { + find: /^react\/jsx-runtime$/, + replacement: require.resolve("react/jsx-runtime"), + }, + { + find: /^react\/jsx-dev-runtime$/, + replacement: require.resolve("react/jsx-dev-runtime"), + }, + { + find: /^react-dom$/, + replacement: path.dirname(require.resolve("react-dom/package.json")), + }, + { + find: /^react-dom\/client$/, + replacement: require.resolve("react-dom/client"), + }, + ...baseAliases, + ], + }, + test: { + ...baseConfig.test, + root: here, + include: ["src/**/*.{test,spec}.{ts,tsx}"], + exclude: realLaneRequested + ? liveOnlyExcludes.filter((glob) => !glob.includes(".real.")) + : liveOnlyExcludes, + }, +});