diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..3015ae1 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "gravity", + "runtimeExecutable": "/bin/bash", + "runtimeArgs": ["-c", "cd /Users/pluto/Downloads/Documents/sanctuary-parc/miniverse/gravity && node server/index.js"], + "port": 3847 + } + ] +} diff --git a/README.md b/README.md index ef0d246..cd907e2 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,52 @@ -# Sanctuary Parc +# šŸ”ļø Sanctuary -Sanctuary Parc is the main monorepo for your active projects. The goal is simple: one home base, clean folders, predictable branch names, and fewer accidental commits from the wrong tool or IDE. +The Sanctuary Parc venture studio game — one monorepo, all the plays. -## Local git identity +## What's inside -This repo is set up to use: +### `miniverse/proposal-builder` +Client-facing proposal engine. Scoped quotes, deal rooms, the works. -- `user.name = 0xbeam` -- `user.email = p@spacekayak.xyz` +### `miniverse/andromeda` +Personal CRM for tracking people, intros, and relationship context across deals. -## Project layout +### `miniverse/design-plus` +Design builds, experiments, and Figma plugin work. The creative lab. -- `dashboard/` - Shared notes for the separate Saakets' Sanctuary Parc dashboard fork. -- `miniverse/` - Home for core product projects: Proposal Builder, Andromeda, and Design+. -- `bangalore/` - Smaller Bangalore-focused projects and experiments. -- `data/` - Debug assets, data utilities, and smaller research-heavy work. +### `miniverse/gravity` +Gravity engine — the connective tissue between projects. -## Preferred branches +### `miniverse/parc` +Core Parc workspace and shared tooling. -- `main` - Shared repo structure, docs, scripts, and multi-project changes. -- `project/proposal-builder` - `miniverse/proposal-builder` -- `project/andromeda` - `miniverse/andromeda` -- `project/design-plus` - `miniverse/design-plus` -- `project/bangalore` - `bangalore` -- `project/data` - `data` -- `project/dashboard-fork` - `dashboard` +### `brane/` +Full-stack app runtime — Vite + Express, deployed on Vercel. -## Daily workflow +### `bangalore/` +Bangalore-focused projects and experiments (including attnc-preview). -1. Run `git workon ` before starting. -2. Work inside that project's folder. -3. Run `git context` any time you want a quick branch/path sanity check. -4. Commit normally. The pre-commit hook will stop obvious branch/path mismatches. +### `dashboard/` +Shared notes and docs for the Sanctuary dashboard. -Examples: +### `data/` +Debug assets, data utilities, and research-heavy work. -```bash -git workon proposal-builder -git workon andromeda -git context -``` +## Branches -## Remote layout +| Branch | Maps to | +|---|---| +| `main` | Repo structure, docs, multi-project changes | +| `project/proposal-builder` | `miniverse/proposal-builder` | +| `project/andromeda` | `miniverse/andromeda` | +| `project/design-plus` | `miniverse/design-plus` | +| `project/bangalore` | `bangalore` | +| `project/data` | `data` | -- `origin` - Sanctuary Parc master repo. -- `dashboard-fork` - Separate dashboard fork remote. +## Workflow -## Notes +```bash +git workon proposal-builder # switch context +git context # sanity check branch vs. path +``` -- `dashboard/` is intentionally documentation-only inside this monorepo. Keep the actual dashboard fork as its own repo and use the `dashboard-fork` remote when you need it. -- `miniverse/andromeda` is ready as the personal CRM workspace, with `project/andromeda` reserved for Claude or any other agent. -- `miniverse/design-plus` is the umbrella for design builds, experiments, and Figma plugin work. +Pre-commit hooks catch branch/path mismatches before they happen. diff --git a/architecture.html b/architecture.html new file mode 100644 index 0000000..099c34c --- /dev/null +++ b/architecture.html @@ -0,0 +1,578 @@ + + + + + +sanctuary-parc — Architecture Map + + + +
+ +
+
+

sanctuary-parc

+

Architecture Map — generated by parc

+
+
+
135files
+
0routes
+
46API endpoints
+
0agents
+
72components
+ +
+
+ +
+ feature/feedback-hub + Unknown + TypeScriptJavaScript + +
+ +
+
Overview
+ +
API
+ +
Components
+ +
Git
+
+ + +
+
+ + + +
+
API Layer
+
46 Endpoints
+
+ ) || content.includes( (2)); + }); + + for (const f of expressFiles) { + const content = readFileSafe(f.path); + const routeMatches = content.matchAll( (1)alerts (1)auth (6)collaboration (2)comments (6)components (4)config (2)dashboard (1)designers (4)digest (2)files (1)gallery (1)milestones (1)projects (2)reports (2)sessions (2)status (1)sync (1)webhook (1)workload (3) +
+
+ + + + +
+
Components
+
72 Components
+
+ feature (52)ui (20) +
+
+ + + + +
+ + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthFile
GET) || content.includes(yesminiverse/parc/core/analyzers/repo.js
POST) || content.includes(yesminiverse/parc/core/analyzers/repo.js
GET); + }); + + for (const f of expressFiles) { + const content = readFileSafe(f.path); + const routeMatches = content.matchAll(/(app|router)\.(get|post|put|patch|delete)\s*\(\s*[yesminiverse/parc/core/analyzers/repo.js
GET/api/alerts/staleyesminiverse/gravity/server/index.js
GET/api/auth/figmayesminiverse/gravity/server/index.js
GET/api/auth/figma/callbackyesminiverse/gravity/server/index.js
GET/api/auth/googleyesminiverse/gravity/server/index.js
GET/api/auth/google/callbackyesminiverse/gravity/server/index.js
POST/api/auth/logoutyesminiverse/gravity/server/index.js
GET/api/auth/statusyesminiverse/gravity/server/index.js
GET/api/collaboration/isolationyesminiverse/gravity/server/index.js
GET/api/collaboration/networkyesminiverse/gravity/server/index.js
GET/api/commentsyesminiverse/gravity/server/index.js
GET/api/comments/metricsyesminiverse/gravity/server/index.js
GET/api/comments/response-timesyesminiverse/gravity/server/index.js
GET/api/comments/review-statusyesminiverse/gravity/server/index.js
GET/api/comments/threadsyesminiverse/gravity/server/index.js
GET/api/comments/threads/openyesminiverse/gravity/server/index.js
GET/api/componentsyesminiverse/gravity/server/index.js
GET/api/components/changelogyesminiverse/gravity/server/index.js
GET/api/components/coverage-trendyesminiverse/gravity/server/index.js
GET/api/components/governanceyesminiverse/gravity/server/index.js
GET/api/configyesminiverse/gravity/server/index.js
POST/api/configyesminiverse/gravity/server/index.js
GET/api/dashboardyesminiverse/gravity/server/index.js
GET/api/designersyesminiverse/gravity/server/index.js
GET/api/designers/:name/collaboratorsyesminiverse/gravity/server/index.js
GET/api/designers/:name/patternyesminiverse/gravity/server/index.js
GET/api/designers/:name/profileyesminiverse/gravity/server/index.js
GET/api/digest/historyyesminiverse/gravity/server/index.js
GET/api/digest/weeklyyesminiverse/gravity/server/index.js
GET/api/files/:key/timelineyesminiverse/gravity/server/index.js
GET/api/galleryyesminiverse/gravity/server/index.js
GET/api/milestonesyesminiverse/gravity/server/index.js
GET/api/projectsyesminiverse/gravity/server/index.js
GET/api/projects/:name/velocityyesminiverse/gravity/server/index.js
GET/api/reports/multiyesminiverse/gravity/server/index.js
GET/api/reports/projectyesminiverse/gravity/server/index.js
GET/api/sessionsyesminiverse/gravity/server/index.js
GET/api/sessions/summaryyesminiverse/gravity/server/index.js
GET/api/statusyesminiverse/gravity/server/index.js
POST/api/syncyesminiverse/gravity/server/index.js
POST/api/webhookyesminiverse/gravity/server/index.js
GET/api/workload/balanceyesminiverse/gravity/server/index.js
GET/api/workload/currentyesminiverse/gravity/server/index.js
GET/api/workload/historyyesminiverse/gravity/server/index.js
+
+ + + + + + +
+
+ +
+
feature
+
52 Components
+
+ AgentDispatchViewCategoryBadgeFeedViewInstructionCardInstructionDetailSourceIconAppShellHeaderSidebarProjectsViewSettingsViewScrapeModalSourcesViewMagneticButtonRevealStaggerGroupFooterLenisProviderNavigationAttnCoCloseCTADeploymentFounderFounderRadarFundHeroHonestRisksRenaissanceReturnModelSanctuaryTheSystemVisionMagneticButtonRevealStaggerGroupFooterLenisProviderNavigationAttnCoCloseCTADeploymentFounderFounderRadarFundHeroHonestRisksRenaissanceReturnModelSanctuaryTheSystemVisionGlobe +
+
+ +
+
ui
+
20 Components
+
+ BadgeCardEmptyStateModalBattleCardProviderCiteMarkContourLinesGreenRuleMagneticButtonSectionEyebrowStatCounterBattleCardProviderCiteMarkContourLinesDataVizGreenRuleMagneticButtonSectionEyebrowStatCounterWireframeShapes +
+
+ +
+
+ + + + + +
+
+
+
Repository
+
sanctuary-parc
+
+ Branch: feature/feedback-hub
+ Last commit: chore: add Vercel deployment config for feedback-hub
+ Author: 0xbeam
+ Commits: 17
+ Remote: https://github.com/0xbeam/sanctuary-parc.git +
+
+
+
File Breakdown
+
135 Files
+
+ .tsx (59).js (35).jsx (21).ts (12).json (7).mjs (1) +
+
+
+
Configs
+
0 Config Files
+
+ +
+
+
+
Dependencies
+
0 packages
+
+ + +
+
+
+
+ + + +
+ + + \ No newline at end of file diff --git a/brane/.claude/launch.json b/brane/.claude/launch.json new file mode 100644 index 0000000..5a06bca --- /dev/null +++ b/brane/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "brane", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5180 + } + ] +} diff --git a/brane/.env.example b/brane/.env.example new file mode 100644 index 0000000..a12aa7a --- /dev/null +++ b/brane/.env.example @@ -0,0 +1,26 @@ +# Slack Bot Token (xoxb-...) — needs channels:history, groups:history, files:read, users:read +SLACK_BOT_TOKEN=xoxb-your-token-here + +# Figma Personal Access Token (optional) +FIGMA_TOKEN= + +# ─── Browser Engines (optional — enables JS-rendered page scraping) ─── + +# Cloudflare Browser Rendering (production, edge-powered) +# Pricing: 10 hrs/month free, then $0.09/hr +# Get your token: https://dash.cloudflare.com/profile/api-tokens +CF_API_TOKEN= +CF_ACCOUNT_ID= + +# Lightpanda (local dev, self-hosted — 11x faster than Chrome) +# Start: ./lightpanda serve --host 127.0.0.1 --port 9222 +# Download: https://github.com/lightpanda-io/browser/releases +LIGHTPANDA_URL= + +# ─── Server ─── + +# API server port (default: 3210) +API_PORT=3210 + +# Output directory (default: ./output) +OUTPUT_DIR=./output diff --git a/brane/.gitignore b/brane/.gitignore new file mode 100644 index 0000000..e726a7f --- /dev/null +++ b/brane/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +output/ +*.log +.vercel diff --git a/brane/bin/cli.js b/brane/bin/cli.js new file mode 100644 index 0000000..f876a48 --- /dev/null +++ b/brane/bin/cli.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +import { program } from "commander"; +import { config } from "dotenv"; +import { Dispatcher } from "../core/dispatch.js"; +import { detectAdapter } from "../core/adapters/index.js"; +import { SOURCE_LABELS } from "../core/types.js"; + +config(); + +const OUTPUT_DIR = process.env.OUTPUT_DIR || "./output"; + +program + .name("brane") + .description("Multi-source feedback → agent instruction markdown") + .version("1.0.0"); + +program + .command("scrape") + .description("Scrape a URL and generate instruction markdown") + .argument("", "URL to scrape (Slack thread, Figma file, tweet, or any URL)") + .option("-p, --project ", "Project name to tag the output") + .option("-o, --output ", "Output directory", OUTPUT_DIR) + .action(async (url, opts) => { + const AdapterClass = detectAdapter(url); + console.log(`Detected source: ${SOURCE_LABELS[AdapterClass.sourceType]} (${AdapterClass.sourceType})`); + + const dispatcher = new Dispatcher(opts.output); + const job = await dispatcher.dispatch(url, opts.project || ""); + + if (job.status === "complete") { + console.log(`\nDone!`); + console.log(` ID: ${job.resultId}`); + console.log(` Output: ${opts.output}/${job.resultId}/`); + } else { + console.error(`\nFailed: ${job.error}`); + process.exit(1); + } + }); + +program + .command("dispatch") + .description("Dispatch multiple URLs in parallel") + .argument("", "URLs to scrape") + .option("-p, --project ", "Project name") + .option("-o, --output ", "Output directory", OUTPUT_DIR) + .action(async (urls, opts) => { + console.log(`Dispatching ${urls.length} URLs in parallel...`); + + const dispatcher = new Dispatcher(opts.output); + const jobs = await dispatcher.dispatchBatch(urls, opts.project || ""); + + const complete = jobs.filter((j) => j.status === "complete"); + const errors = jobs.filter((j) => j.status === "error"); + + console.log(`\nComplete: ${complete.length}/${jobs.length}`); + for (const j of complete) { + console.log(` [OK] ${j.resultId} (${j.detectedSource})`); + } + for (const j of errors) { + console.log(` [ERR] ${j.url}: ${j.error}`); + } + }); + +program + .command("list") + .description("List all scraped instructions") + .option("-o, --output ", "Output directory", OUTPUT_DIR) + .action(async (opts) => { + const { loadIndex } = await import("../core/store.js"); + const index = await loadIndex(opts.output); + + if (index.instructions.length === 0) { + console.log("No instructions scraped yet. Run: brane scrape "); + return; + } + + console.log(`\n${index.instructions.length} instruction(s):\n`); + for (const inst of index.instructions) { + const badges = []; + if (inst.stats.blockerCount) badges.push(`${inst.stats.blockerCount} blockers`); + if (inst.stats.revisionCount) badges.push(`${inst.stats.revisionCount} revisions`); + if (inst.stats.imageCount) badges.push(`${inst.stats.imageCount} images`); + + console.log(` [${inst.source.toUpperCase().padEnd(7)}] ${inst.title}`); + if (inst.project) console.log(` Project: ${inst.project}`); + if (badges.length) console.log(` ${badges.join(" | ")}`); + console.log(` ${inst.scrapedAt}`); + console.log(""); + } + }); + +program.parse(); diff --git a/brane/bin/seed.js b/brane/bin/seed.js new file mode 100644 index 0000000..c6c5307 --- /dev/null +++ b/brane/bin/seed.js @@ -0,0 +1,272 @@ +#!/usr/bin/env node + +/** + * Seed script — generates test data in output/ for dashboard development. + * Run: node bin/seed.js + */ + +import { saveInstruction } from "../core/store.js"; +import { generateInstructionMd } from "../core/markdown-generator.js"; + +const OUTPUT_DIR = "./public/output"; + +const SEED_INSTRUCTIONS = [ + { + id: "slack-1710500000-000000", + source: "slack", + sourceUrl: "https://spacekayak.slack.com/archives/C0DESIGN/p1710500000000000", + project: "sanctuary-parc", + title: "Updated dashboard layout for Sanctuary Parc v2", + root: { + id: "1710500000.000000", + author: "Alex (Design Lead)", + authorId: "U001", + text: "Here's the updated dashboard layout for Sanctuary Parc v2. Key changes:\n• Sidebar nav moved to left\n• New card grid for project overview\n• Dark mode support added\nPlease review and share feedback — need sign-off by EOD Friday.", + category: "context", + attachments: [ + { type: "image", name: "dashboard-v2-light.png", title: "Dashboard V2 — Light Mode", mimetype: "image/png", url: "" }, + { type: "image", name: "dashboard-v2-dark.png", title: "Dashboard V2 — Dark Mode", mimetype: "image/png", url: "" }, + ], + timestamp: "2026-03-15T10:53:00.000Z", + isRoot: true, + meta: { reactions: [{ name: "eyes", count: 3 }] }, + }, + replies: [ + { + id: "1710500100.000000", + author: "Jordan (Engineer)", + authorId: "U002", + text: "Love the new layout! The card grid looks solid. One thing — can we swap the font on the sidebar to match the heading font?", + category: "approval", + attachments: [], + timestamp: "2026-03-15T10:55:00.000Z", + isRoot: false, + meta: { reactions: [{ name: "thumbsup", count: 2 }] }, + }, + { + id: "1710500200.000000", + author: "Sam (PM)", + authorId: "U003", + text: "Looks good overall. Question — are we tracking click-through rates on the project cards? We need analytics hooks before shipping.", + category: "question", + attachments: [], + timestamp: "2026-03-15T10:56:00.000Z", + isRoot: false, + meta: {}, + }, + { + id: "1710500300.000000", + author: "Riley (QA)", + authorId: "U004", + text: "Blocker: the dark mode toggle is broken on Safari. The theme doesn't persist after page refresh. This needs to be fixed before we ship.", + category: "blocker", + attachments: [ + { type: "image", name: "safari-bug.png", title: "Safari dark mode bug", mimetype: "image/png", url: "" }, + ], + timestamp: "2026-03-15T10:58:00.000Z", + isRoot: false, + meta: { reactions: [{ name: "rotating_light", count: 2 }] }, + }, + { + id: "1710500400.000000", + author: "Casey (Stakeholder)", + authorId: "U005", + text: "Ship it! LGTM", + category: "approval", + attachments: [], + timestamp: "2026-03-15T11:00:00.000Z", + isRoot: false, + meta: { reactions: [{ name: "white_check_mark", count: 3 }] }, + }, + { + id: "1710500500.000000", + author: "Jordan (Engineer)", + authorId: "U002", + text: "Also — the spacing between the cards should be 24px instead of 16px. It looks cramped on smaller screens. And make the card hover state more subtle.", + category: "revision", + attachments: [ + { type: "image", name: "spacing-comparison.png", title: "Card spacing — 16px vs 24px", mimetype: "image/png", url: "" }, + ], + timestamp: "2026-03-15T11:01:00.000Z", + isRoot: false, + meta: {}, + }, + ], + allEntries: [], // filled below + stats: { + totalEntries: 6, + totalReplies: 5, + categories: { context: 1, approval: 2, question: 1, blocker: 1, revision: 1 }, + imageCount: 4, + fileCount: 0, + blockerCount: 1, + revisionCount: 1, + }, + scrapedAt: "2026-03-15T12:00:00.000Z", + }, + { + id: "figma-abc123def", + source: "figma", + sourceUrl: "https://figma.com/design/abc123def/Sanctuary-Parc-Mobile", + project: "sanctuary-parc", + title: "Sanctuary Parc Mobile — Navigation Redesign", + root: { + id: "fc001", + author: "Maya (Designer)", + authorId: "figma-maya", + text: "Mobile nav redesign — bottom tab bar with 5 items. Icons use the Lucide set. Please review the spacing and tap targets.", + category: "context", + attachments: [], + timestamp: "2026-03-14T09:00:00.000Z", + isRoot: true, + meta: {}, + }, + replies: [ + { + id: "fc002", + author: "Dev Team", + authorId: "figma-dev", + text: "The icons are too small on the bottom nav. Should be at least 24px for accessibility. Also the labels overlap on smaller screens.", + category: "revision", + attachments: [], + timestamp: "2026-03-14T10:15:00.000Z", + isRoot: false, + meta: {}, + }, + { + id: "fc003", + author: "PM", + authorId: "figma-pm", + text: "Looks great, approved for development!", + category: "approval", + attachments: [], + timestamp: "2026-03-14T11:00:00.000Z", + isRoot: false, + meta: {}, + }, + ], + allEntries: [], + stats: { + totalEntries: 3, + totalReplies: 2, + categories: { context: 1, revision: 1, approval: 1 }, + imageCount: 0, + fileCount: 0, + blockerCount: 0, + revisionCount: 1, + }, + scrapedAt: "2026-03-14T12:00:00.000Z", + }, + { + id: "twitter-1234567890", + source: "twitter", + sourceUrl: "https://x.com/designinspo/status/1234567890", + project: "spacekayak", + title: "Great thread on dashboard design patterns for SaaS products", + root: { + id: "1234567890", + author: "designinspo", + authorId: "designinspo", + text: "Great thread on dashboard design patterns for SaaS products. Key takeaway: use progressive disclosure, show only what matters at each level of the hierarchy.", + category: "context", + attachments: [], + timestamp: "2026-03-13T15:30:00.000Z", + isRoot: true, + meta: { likes: 1240, retweets: 380 }, + }, + replies: [], + allEntries: [], + stats: { + totalEntries: 1, + totalReplies: 0, + categories: { context: 1 }, + imageCount: 0, + fileCount: 0, + blockerCount: 0, + revisionCount: 0, + }, + scrapedAt: "2026-03-13T16:00:00.000Z", + }, + { + id: "slack-1710600000-000000", + source: "slack", + sourceUrl: "https://spacekayak.slack.com/archives/C0DEV/p1710600000000000", + project: "spacekayak", + title: "API rate limiting needs to be implemented before launch", + root: { + id: "1710600000.000000", + author: "Devon (Backend Lead)", + authorId: "U010", + text: "We need to add rate limiting to the public API before launch. Currently no throttling in place. Proposing 100 req/min for free tier, 1000 for pro.", + category: "context", + attachments: [], + timestamp: "2026-03-16T08:00:00.000Z", + isRoot: true, + meta: {}, + }, + replies: [ + { + id: "1710600100.000000", + author: "Security (Audit)", + authorId: "U011", + text: "This is a blocker for launch. Without rate limiting we're exposed to DDoS and scraping attacks. Critical priority.", + category: "blocker", + attachments: [], + timestamp: "2026-03-16T08:15:00.000Z", + isRoot: false, + meta: { reactions: [{ name: "octagonal_sign", count: 3 }] }, + }, + { + id: "1710600200.000000", + author: "CTO", + authorId: "U012", + text: "Should we use a token bucket or sliding window algorithm? What are the tradeoffs?", + category: "question", + attachments: [], + timestamp: "2026-03-16T08:30:00.000Z", + isRoot: false, + meta: {}, + }, + { + id: "1710600300.000000", + author: "Devon (Backend Lead)", + authorId: "U010", + text: "I'd recommend sliding window — it's smoother for burst traffic. We can use Redis for distributed counting. I'll draft the implementation.", + category: "approval", + attachments: [], + timestamp: "2026-03-16T09:00:00.000Z", + isRoot: false, + meta: { reactions: [{ name: "thumbsup", count: 4 }] }, + }, + ], + allEntries: [], + stats: { + totalEntries: 4, + totalReplies: 3, + categories: { context: 1, blocker: 1, question: 1, approval: 1 }, + imageCount: 0, + fileCount: 0, + blockerCount: 1, + revisionCount: 0, + }, + scrapedAt: "2026-03-16T10:00:00.000Z", + }, +]; + +async function seed() { + console.log("Seeding test data...\n"); + + for (const inst of SEED_INSTRUCTIONS) { + // Fill allEntries + inst.allEntries = [inst.root, ...inst.replies]; + + const md = generateInstructionMd(inst); + await saveInstruction(inst, OUTPUT_DIR, md); + console.log(` [${inst.source.toUpperCase().padEnd(7)}] ${inst.title}`); + } + + console.log(`\nSeeded ${SEED_INSTRUCTIONS.length} instructions to ${OUTPUT_DIR}/`); + console.log("Run 'npm run dev' to see them in the dashboard."); +} + +seed().catch(console.error); diff --git a/brane/core/adapters/base-adapter.js b/brane/core/adapters/base-adapter.js new file mode 100644 index 0000000..cd52cfb --- /dev/null +++ b/brane/core/adapters/base-adapter.js @@ -0,0 +1,36 @@ +/** + * Base adapter interface. All source adapters extend this. + */ +export class BaseAdapter { + /** @type {import('../types.js').SourceType} */ + static sourceType = "url"; + + /** + * Test whether this adapter can handle a given URL or input. + * @param {string} url + * @returns {boolean} + */ + static canHandle(url) { + return false; + } + + /** + * Scrape the URL and return a normalized InstructionSet. + * @param {string} url + * @param {Object} options - { project, outputDir } + * @returns {Promise} + */ + async scrape(url, options = {}) { + throw new Error("scrape() not implemented"); + } + + /** + * Download all attachments to the local output directory. + * @param {import('../types.js').InstructionSet} instructionSet + * @param {string} outputDir + * @returns {Promise<{downloaded: number, total: number}>} + */ + async downloadAssets(instructionSet, outputDir) { + throw new Error("downloadAssets() not implemented"); + } +} diff --git a/brane/core/adapters/figma-adapter.js b/brane/core/adapters/figma-adapter.js new file mode 100644 index 0000000..099a4ec --- /dev/null +++ b/brane/core/adapters/figma-adapter.js @@ -0,0 +1,129 @@ +import { BaseAdapter } from "./base-adapter.js"; +import { generateId } from "../types.js"; + +/** + * Figma adapter — extracts comments from a Figma file. + * Requires FIGMA_TOKEN env var. + */ +export class FigmaAdapter extends BaseAdapter { + static sourceType = "figma"; + + static canHandle(url) { + return /figma\.com\/(file|design|proto)\//.test(url); + } + + async scrape(url, options = {}) { + const token = options.env?.FIGMA_TOKEN || process.env.FIGMA_TOKEN; + if (!token) throw new Error("FIGMA_TOKEN not set"); + + const fileKey = url.match(/\/(file|design|proto)\/([a-zA-Z0-9]+)/)?.[2]; + if (!fileKey) throw new Error(`Could not parse Figma file key from: ${url}`); + + // Fetch file metadata + const fileMeta = await figmaApi(`/v1/files/${fileKey}?depth=1`, token); + const fileName = fileMeta.name || "Figma File"; + + // Fetch comments + const commentsRes = await figmaApi(`/v1/files/${fileKey}/comments`, token); + const comments = commentsRes.comments || []; + + // Build entries + const entries = []; + const rootComments = comments.filter((c) => !c.parent_id); + const childComments = comments.filter((c) => c.parent_id); + + // Group by thread (root comment + replies) + for (const root of rootComments) { + const replies = childComments.filter((c) => c.parent_id === root.id); + const allInThread = [root, ...replies]; + + for (let i = 0; i < allInThread.length; i++) { + const c = allInThread[i]; + entries.push({ + id: c.id, + author: c.user.handle, + authorId: c.user.id, + text: c.message, + category: i === 0 ? "context" : categorizeComment(c.message), + attachments: [], + timestamp: c.created_at, + isRoot: i === 0 && entries.length === 0, + meta: { + resolved: c.resolved_at != null, + nodeId: c.client_meta?.node_id, + nodeOffset: c.client_meta?.node_offset, + }, + }); + } + } + + // If no comments, create a single context entry + if (entries.length === 0) { + entries.push({ + id: generateId(), + author: "Figma", + authorId: "figma", + text: `Figma file: ${fileName}\n${url}`, + category: "context", + attachments: [], + timestamp: new Date().toISOString(), + isRoot: true, + meta: {}, + }); + } + + const categories = {}; + for (const e of entries) { + categories[e.category] = (categories[e.category] || 0) + 1; + } + + return { + id: `figma-${fileKey}`, + source: "figma", + sourceUrl: url, + project: options.project || "", + title: fileName, + root: entries[0], + replies: entries.slice(1), + allEntries: entries, + stats: { + totalEntries: entries.length, + totalReplies: entries.length - 1, + categories, + imageCount: 0, + fileCount: 0, + blockerCount: categories.blocker || 0, + revisionCount: categories.revision || 0, + }, + scrapedAt: new Date().toISOString(), + }; + } + + async downloadAssets() { + return { downloaded: 0, total: 0 }; + } +} + +async function figmaApi(path, token) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + try { + const res = await fetch(`https://api.figma.com${path}`, { + headers: { "X-FIGMA-TOKEN": token }, + signal: controller.signal, + }); + if (!res.ok) throw new Error(`Figma API error: ${res.status} ${res.statusText}`); + return res.json(); + } finally { + clearTimeout(timeout); + } +} + +function categorizeComment(text) { + const lower = (text || "").toLowerCase(); + if (/blocker|broken|critical|bug|wrong/.test(lower)) return "blocker"; + if (/change|fix|update|adjust|move|swap|should be|instead/.test(lower)) return "revision"; + if (/\?|why|how|what if/.test(lower)) return "question"; + if (/looks good|lgtm|approved|love|great|nice|perfect/.test(lower)) return "approval"; + return "context"; +} diff --git a/brane/core/adapters/index.js b/brane/core/adapters/index.js new file mode 100644 index 0000000..602df69 --- /dev/null +++ b/brane/core/adapters/index.js @@ -0,0 +1,18 @@ +import { SlackAdapter } from "./slack-adapter.js"; +import { TwitterAdapter } from "./twitter-adapter.js"; +import { FigmaAdapter } from "./figma-adapter.js"; +import { UrlAdapter } from "./url-adapter.js"; + +const ADAPTERS = [SlackAdapter, FigmaAdapter, TwitterAdapter, UrlAdapter]; + +/** + * Auto-detect the right adapter for a URL. + * Falls back to UrlAdapter if nothing else matches. + */ +export function detectAdapter(url) { + const AdapterClass = ADAPTERS.find((A) => A.canHandle(url)); + return AdapterClass || UrlAdapter; +} + +export { SlackAdapter, TwitterAdapter, FigmaAdapter, UrlAdapter }; +export { ADAPTERS }; diff --git a/brane/core/adapters/slack-adapter.js b/brane/core/adapters/slack-adapter.js new file mode 100644 index 0000000..e69faff --- /dev/null +++ b/brane/core/adapters/slack-adapter.js @@ -0,0 +1,218 @@ +import { WebClient } from "@slack/web-api"; +import { BaseAdapter } from "./base-adapter.js"; +import { generateId } from "../types.js"; + +// --- Slack-specific helpers (ported from slack-thread-to-instructions) --- + +const FEEDBACK_SIGNALS = { + approval: { + emoji: ["white_check_mark", "+1", "thumbsup", "heavy_check_mark", "100", "fire", "heart", "tada", "rocket"], + keywords: ["looks good", "lgtm", "approved", "love it", "ship it", "perfect", "great", "nice", "awesome", "solid", "good to go", "yes"], + }, + revision: { + emoji: ["x", "warning", "thinking_face", "eyes", "memo"], + keywords: [ + "change", "update", "fix", "adjust", "move", "swap", "replace", + "instead", "should be", "needs to", "can we", "could you", "try", + "make it", "switch", "tweak", "modify", "redo", "rework", + ], + }, + question: { + emoji: ["question", "thinking_face"], + keywords: ["why", "how", "what if", "is this", "are we", "should we", "can we", "?"], + }, + blocker: { + emoji: ["octagonal_sign", "no_entry", "rotating_light", "x"], + keywords: [ + "blocker", "blocked", "can't ship", "don't ship", "stop", "hold", + "critical", "breaking", "broken", "bug", "issue", "wrong", + ], + }, +}; + +function categorizeMessage(text, reactions) { + const lower = (text || "").toLowerCase(); + const scores = { approval: 0, revision: 0, question: 0, blocker: 0 }; + + for (const [category, signals] of Object.entries(FEEDBACK_SIGNALS)) { + for (const kw of signals.keywords) { + if (lower.includes(kw)) scores[category]++; + } + } + + if (reactions) { + for (const reaction of reactions) { + for (const [category, signals] of Object.entries(FEEDBACK_SIGNALS)) { + if (signals.emoji.includes(reaction.name)) { + scores[category] += reaction.count; + } + } + } + } + + const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0]; + return best[1] > 0 ? best[0] : "context"; +} + +function parseThreadUrl(url) { + const match = url.match( + /archives\/([A-Z0-9]+)\/p(\d{10})(\d{6})(?:\?.*thread_ts=(\d+\.\d+))?/ + ); + if (!match) { + throw new Error(`Invalid Slack thread URL: ${url}`); + } + return { + channelId: match[1], + threadTs: match[4] || `${match[2]}.${match[3]}`, + }; +} + +const userCache = new Map(); + +async function resolveUser(client, userId) { + if (userCache.has(userId)) return userCache.get(userId); + try { + const result = await client.users.info({ user: userId }); + const name = result.user.profile.display_name || result.user.real_name || result.user.name; + userCache.set(userId, name); + return name; + } catch { + return userId; + } +} + +async function resolveUserMentions(client, text) { + if (!text) return ""; + const matches = [...text.matchAll(/<@([A-Z0-9]+)>/g)]; + let resolved = text; + for (const match of matches) { + const name = await resolveUser(client, match[1]); + resolved = resolved.replace(match[0], `@${name}`); + } + return resolved; +} + +// --- Adapter --- + +export class SlackAdapter extends BaseAdapter { + static sourceType = "slack"; + + static canHandle(url) { + return /slack\.com\/archives\/[A-Z0-9]+\/p\d+/.test(url); + } + + async scrape(url, options = {}) { + const token = options.env?.SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN; + if (!token) throw new Error("SLACK_BOT_TOKEN not set"); + + const client = new WebClient(token); + const { channelId, threadTs } = parseThreadUrl(url); + + // Fetch all messages + const messages = []; + let cursor; + do { + const result = await client.conversations.replies({ + channel: channelId, + ts: threadTs, + limit: 200, + cursor, + }); + messages.push(...result.messages); + cursor = result.response_metadata?.next_cursor; + } while (cursor); + + // Convert to unified FeedbackEntry format + const entries = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const author = await resolveUser(client, msg.user); + const text = await resolveUserMentions(client, msg.text); + const isRoot = i === 0; + const category = isRoot ? "context" : categorizeMessage(msg.text, msg.reactions); + + const attachments = (msg.files || []).map((f) => ({ + type: f.mimetype?.startsWith("image/") ? "image" : "file", + name: f.name, + title: f.title || f.name, + mimetype: f.mimetype, + url: f.url_private, + permalink: f.permalink, + })); + + entries.push({ + id: msg.ts, + author, + authorId: msg.user, + text, + category, + attachments, + timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(), + isRoot, + meta: { reactions: msg.reactions || [] }, + }); + } + + const allAttachments = entries.flatMap((e) => e.attachments); + const imageCount = allAttachments.filter((a) => a.type === "image").length; + const categories = {}; + for (const e of entries) { + categories[e.category] = (categories[e.category] || 0) + 1; + } + + const title = entries[0]?.text?.split("\n")[0]?.slice(0, 80) || "Slack Thread"; + const id = `slack-${threadTs.replace(".", "-")}`; + + return { + id, + source: "slack", + sourceUrl: url, + project: options.project || "", + title, + root: entries[0], + replies: entries.slice(1), + allEntries: entries, + stats: { + totalEntries: entries.length, + totalReplies: entries.length - 1, + categories, + imageCount, + fileCount: allAttachments.length - imageCount, + blockerCount: categories.blocker || 0, + revisionCount: categories.revision || 0, + }, + scrapedAt: new Date().toISOString(), + }; + } + + async downloadAssets(instructionSet, outputDir) { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) return { downloaded: 0, total: 0 }; + + const { mkdir, writeFile } = await import("fs/promises"); + const { join } = await import("path"); + const imagesDir = join(outputDir, "images"); + await mkdir(imagesDir, { recursive: true }); + + const allAttachments = instructionSet.allEntries.flatMap((e) => e.attachments); + const images = allAttachments.filter((a) => a.type === "image"); + let downloaded = 0; + + for (const img of images) { + try { + const response = await fetch(img.url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) continue; + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(join(imagesDir, img.name), buffer); + img.localPath = `images/${img.name}`; + downloaded++; + } catch { + // skip failed downloads + } + } + + return { downloaded, total: images.length }; + } +} diff --git a/brane/core/adapters/twitter-adapter.js b/brane/core/adapters/twitter-adapter.js new file mode 100644 index 0000000..7b9b340 --- /dev/null +++ b/brane/core/adapters/twitter-adapter.js @@ -0,0 +1,649 @@ +import { BaseAdapter } from "./base-adapter.js"; +import { generateId } from "../types.js"; +import { getBrowserManager } from "../browser/index.js"; +import { categorize } from "../categorizer.js"; + +/** + * Twitter/X adapter — multi-strategy tweet scraping. + * + * Strategy chain (tries in order, first success wins): + * 1. Twitter oEmbed API (publish.twitter.com, official, no auth) + * 2. Twitter Syndication API (embed endpoint, no auth) + * 3. fxtwitter API (public proxy, structured JSON) + * 4. Browser engine (Cloudflare/Lightpanda if available) + * 5. Nitter instances (public mirrors, no JS needed) + * + * Also supports: + * - Thread scraping (tweet + replies in thread) + * - Image/media extraction + * - Local JSON bookmark export import + */ +export class TwitterAdapter extends BaseAdapter { + static sourceType = "twitter"; + + static canHandle(url) { + return /(twitter\.com|x\.com)\/\w+\/status\/\d+/.test(url); + } + + async scrape(url, options = {}) { + const tweetId = url.match(/status\/(\d+)/)?.[1]; + if (!tweetId) throw new Error(`Could not parse tweet ID from: ${url}`); + + const author = extractAuthor(url); + + // Try each strategy in order — oEmbed first (most reliable) + const strategies = [ + () => this.scrapeOEmbed(tweetId, author, url), + () => this.scrapeSyndication(tweetId, url), + () => this.scrapeFxTwitter(tweetId, author, url), + () => this.scrapeBrowser(url), + () => this.scrapeNitter(tweetId, author, url), + ]; + + let tweetData = null; + let strategyUsed = "none"; + + for (const strategy of strategies) { + try { + tweetData = await strategy(); + if (tweetData && tweetData.text && tweetData.text.length > 10) { + strategyUsed = tweetData.strategy; + break; + } + } catch (err) { + // Try next strategy + continue; + } + } + + // If all strategies failed, return what we can + if (!tweetData || !tweetData.text) { + tweetData = { + text: `Could not fetch tweet content. The tweet may be protected, deleted, or all scraping strategies failed.`, + author: author, + authorName: author, + timestamp: new Date().toISOString(), + images: [], + likes: 0, + retweets: 0, + replies: 0, + strategy: "fallback", + thread: [], + }; + strategyUsed = "fallback"; + } + + // Build the root entry + const category = categorize(tweetData.text); + const rootEntry = { + id: tweetId, + author: tweetData.authorName || tweetData.author || author, + authorId: `@${tweetData.author || author}`, + text: tweetData.text, + category, + attachments: (tweetData.images || []).map((img, i) => ({ + type: "image", + name: `tweet-media-${i}.jpg`, + title: `Media ${i + 1}`, + mimetype: "image/jpeg", + url: img, + })), + timestamp: tweetData.timestamp || new Date().toISOString(), + isRoot: true, + meta: { + tweetId, + originalUrl: url, + likes: tweetData.likes || 0, + retweets: tweetData.retweets || 0, + replyCount: tweetData.replies || 0, + strategy: strategyUsed, + }, + }; + + // Build thread replies + const replyEntries = (tweetData.thread || []).map((reply, i) => ({ + id: reply.id || `${tweetId}-reply-${i}`, + author: reply.authorName || reply.author || author, + authorId: `@${reply.author || author}`, + text: reply.text, + category: categorize(reply.text), + attachments: (reply.images || []).map((img, j) => ({ + type: "image", + name: `reply-${i}-media-${j}.jpg`, + title: `Reply ${i + 1} Media ${j + 1}`, + mimetype: "image/jpeg", + url: img, + })), + timestamp: reply.timestamp || new Date().toISOString(), + isRoot: false, + meta: { + tweetId: reply.id, + strategy: strategyUsed, + }, + })); + + const allEntries = [rootEntry, ...replyEntries]; + const categories = {}; + for (const e of allEntries) { + categories[e.category] = (categories[e.category] || 0) + 1; + } + + const imageCount = allEntries.reduce((sum, e) => sum + e.attachments.length, 0); + + return { + id: `twitter-${tweetId}`, + source: "twitter", + sourceUrl: url, + project: options.project || "", + title: `@${rootEntry.author}: ${tweetData.text.slice(0, 60)}${tweetData.text.length > 60 ? "..." : ""}`, + root: rootEntry, + replies: replyEntries, + allEntries, + stats: { + totalEntries: allEntries.length, + totalReplies: replyEntries.length, + categories, + imageCount, + fileCount: 0, + blockerCount: categories.blocker || 0, + revisionCount: categories.revision || 0, + }, + scrapedAt: new Date().toISOString(), + meta: { engine: strategyUsed }, + }; + } + + // ─── Strategy 1: Twitter oEmbed API ─── + // Official endpoint, most reliable, returns tweet text in blockquote HTML + async scrapeOEmbed(tweetId, author, originalUrl) { + // oEmbed requires twitter.com URLs (not x.com) + const twitterUrl = `https://twitter.com/${author}/status/${tweetId}`; + const url = `https://publish.twitter.com/oembed?url=${encodeURIComponent(twitterUrl)}&omit_script=true`; + const res = await fetchWithTimeout(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; Brane/1.0)", + "Accept": "application/json", + }, + }, 8000); + + if (!res.ok) throw new Error(`oEmbed API ${res.status}`); + + const data = await res.json(); + if (!data || !data.html) throw new Error("No HTML in oEmbed response"); + + // Extract tweet text from the blockquote HTML + // Format:

TWEET TEXT

— Author (@handle) Date
+ const textMatch = data.html.match(/]*>([\s\S]*?)<\/p>/); + const text = textMatch + ? textMatch[1] + .replace(//gi, "\n") + .replace(/]+>([\s\S]*?)<\/a>/gi, "$1") + .replace(/<[^>]+>/g, "") + .replace(/—/g, "—") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") + .trim() + : ""; + + if (!text) throw new Error("Could not extract text from oEmbed HTML"); + + // Extract date from the oEmbed HTML + const dateMatch = data.html.match(/href="[^"]*">([^<]+)<\/a><\/blockquote>/); + let timestamp = new Date().toISOString(); + if (dateMatch) { + try { timestamp = new Date(dateMatch[1]).toISOString(); } catch { /* keep default */ } + } + + // Extract images from the oEmbed HTML (pic.twitter.com links) + const images = []; + const imgLinks = data.html.matchAll(/pic\.twitter\.com\/\w+/g); + // Note: oEmbed doesn't include direct image URLs, just pic.twitter.com short links + // We'll extract what we can from the syndication API later if needed + + return { + text, + author: data.author_url?.split("/").pop() || author, + authorName: data.author_name || author, + timestamp, + images, + likes: 0, // oEmbed doesn't include engagement metrics + retweets: 0, + replies: 0, + strategy: "oembed", + thread: [], + }; + } + + // ─── Strategy 2: Twitter Syndication API ─── + // The embed/tweet endpoint returns rendered tweet data without auth + async scrapeSyndication(tweetId, originalUrl) { + const url = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&token=0`; + const res = await fetchWithTimeout(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; Brane/1.0)", + "Accept": "application/json", + }, + }, 8000); + + if (!res.ok) throw new Error(`Syndication API ${res.status}`); + + const data = await res.json(); + if (!data || !data.text) throw new Error("No text in syndication response"); + + const images = []; + // Extract media from syndication response + if (data.mediaDetails) { + for (const media of data.mediaDetails) { + if (media.media_url_https) { + images.push(media.media_url_https); + } + } + } + if (data.photos) { + for (const photo of data.photos) { + if (photo.url) images.push(photo.url); + } + } + + // Extract thread/quoted tweets + const thread = []; + if (data.quoted_tweet) { + thread.push({ + id: data.quoted_tweet.id_str, + author: data.quoted_tweet.user?.screen_name || "unknown", + authorName: data.quoted_tweet.user?.name || "unknown", + text: data.quoted_tweet.text, + timestamp: data.quoted_tweet.created_at ? new Date(data.quoted_tweet.created_at).toISOString() : undefined, + images: (data.quoted_tweet.photos || []).map((p) => p.url).filter(Boolean), + }); + } + + return { + text: data.text, + author: data.user?.screen_name || extractAuthor(originalUrl), + authorName: data.user?.name || data.user?.screen_name || extractAuthor(originalUrl), + timestamp: data.created_at ? new Date(data.created_at).toISOString() : new Date().toISOString(), + images, + likes: data.favorite_count || 0, + retweets: data.retweet_count || 0, + replies: data.conversation_count || 0, + strategy: "syndication", + thread, + }; + } + + // ─── Strategy 2: fxtwitter API ─── + // Public proxy that returns structured tweet data + async scrapeFxTwitter(tweetId, author, originalUrl) { + const url = `https://api.fxtwitter.com/${author}/status/${tweetId}`; + const res = await fetchWithTimeout(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; Brane/1.0)", + "Accept": "application/json", + }, + }, 8000); + + if (!res.ok) throw new Error(`fxtwitter API ${res.status}`); + + const data = await res.json(); + const tweet = data.tweet; + if (!tweet || !tweet.text) throw new Error("No tweet in fxtwitter response"); + + const images = []; + if (tweet.media?.photos) { + for (const photo of tweet.media.photos) { + if (photo.url) images.push(photo.url); + } + } + // Also grab video thumbnails + if (tweet.media?.videos) { + for (const video of tweet.media.videos) { + if (video.thumbnail_url) images.push(video.thumbnail_url); + } + } + + return { + text: tweet.text, + author: tweet.author?.screen_name || author, + authorName: tweet.author?.name || author, + timestamp: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(), + images, + likes: tweet.likes || 0, + retweets: tweet.retweets || 0, + replies: tweet.replies || 0, + strategy: "fxtwitter", + thread: [], + }; + } + + // ─── Strategy 3: Browser Engine ─── + // Use Cloudflare/Lightpanda to render the full page + async scrapeBrowser(url) { + const browserManager = getBrowserManager(); + if (!browserManager.isAvailable()) throw new Error("No browser engine available"); + + const result = await browserManager.scrape(url, { + timeout: 20000, + waitFor: "[data-testid='tweetText']", + extractImages: true, + }); + + if (!result || !result.text) throw new Error("Browser returned no content"); + + // Parse the rendered HTML for tweet-specific content + const tweetData = parseTweetFromHtml(result.html || result.text, url); + + return { + text: tweetData.text || result.text.slice(0, 3000), + author: tweetData.author || extractAuthor(url), + authorName: tweetData.authorName || extractAuthor(url), + timestamp: new Date().toISOString(), + images: tweetData.images || (result.images || []).map((i) => i.url).filter(Boolean), + likes: tweetData.likes || 0, + retweets: tweetData.retweets || 0, + replies: tweetData.replies || 0, + strategy: `browser:${browserManager.activeEngine?.name || "unknown"}`, + thread: tweetData.thread || [], + }; + } + + // ─── Strategy 4: Nitter Instances ─── + // Public Nitter mirrors serve Twitter content as static HTML + async scrapeNitter(tweetId, author, originalUrl) { + const nitterInstances = [ + "nitter.privacydev.net", + "nitter.poast.org", + "nitter.woodland.cafe", + ]; + + for (const instance of nitterInstances) { + try { + const url = `https://${instance}/${author}/status/${tweetId}`; + const res = await fetchWithTimeout(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; Brane/1.0)", + }, + }, 8000); + + if (!res.ok) continue; + + const html = await res.text(); + const tweetData = parseTweetFromNitter(html); + + if (tweetData && tweetData.text) { + return { + ...tweetData, + author, + strategy: `nitter:${instance}`, + }; + } + } catch { + continue; + } + } + + throw new Error("All Nitter instances failed"); + } + + /** + * Import from a Twitter bookmark export JSON file. + */ + async importBookmarks(bookmarks, options = {}) { + return bookmarks.map((bm) => { + const text = bm.full_text || bm.text || ""; + const category = categorize(text); + const entry = { + id: bm.id || generateId(), + author: bm.user?.screen_name || bm.author || "unknown", + authorId: bm.user?.id_str || bm.author_id || "unknown", + text, + category, + attachments: (bm.media || []).map((m, i) => ({ + type: "image", + name: `tweet-media-${i}.jpg`, + title: `Media ${i + 1}`, + mimetype: "image/jpeg", + url: m.media_url_https || m.url, + })), + timestamp: bm.created_at ? new Date(bm.created_at).toISOString() : new Date().toISOString(), + isRoot: true, + meta: { likes: bm.favorite_count, retweets: bm.retweet_count }, + }; + + return { + id: `twitter-${entry.id}`, + source: "twitter", + sourceUrl: `https://x.com/${entry.author}/status/${entry.id}`, + project: options.project || "", + title: entry.text.slice(0, 80), + root: entry, + replies: [], + allEntries: [entry], + stats: { + totalEntries: 1, + totalReplies: 0, + categories: { [category]: 1 }, + imageCount: entry.attachments.length, + fileCount: 0, + blockerCount: category === "blocker" ? 1 : 0, + revisionCount: category === "revision" ? 1 : 0, + }, + scrapedAt: new Date().toISOString(), + }; + }); + } + + async downloadAssets(instructionSet, outputDir) { + const { mkdir, writeFile } = await import("fs/promises"); + const { join } = await import("path"); + const imagesDir = join(outputDir, "images"); + await mkdir(imagesDir, { recursive: true }); + + const images = instructionSet.allEntries.flatMap((e) => + e.attachments.filter((a) => a.type === "image") + ); + + if (images.length === 0) return { downloaded: 0, total: 0 }; + + let downloaded = 0; + const BATCH_SIZE = 5; + + for (let i = 0; i < images.length; i += BATCH_SIZE) { + const batch = images.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async (img) => { + try { + const res = await fetchWithTimeout(img.url, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; Brane/1.0)" }, + }, 8000); + if (!res.ok) return; + const buffer = Buffer.from(await res.arrayBuffer()); + await writeFile(join(imagesDir, img.name), buffer); + img.localPath = `images/${img.name}`; + return true; + } catch { + return false; + } + }) + ); + downloaded += results.filter((r) => r.status === "fulfilled" && r.value).length; + } + + return { downloaded, total: images.length }; + } +} + +// ─── Helpers ─── + +function extractAuthor(url) { + return url.match(/(twitter\.com|x\.com)\/(\w+)\/status/)?.[2] || "unknown"; +} + +/** + * Fetch with a timeout (AbortController). + */ +async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +/** + * Parse tweet content from rendered HTML (browser engine output). + * Extracts tweet text, media, and engagement metrics. + */ +function parseTweetFromHtml(html, originalUrl) { + const result = { + text: "", + author: extractAuthor(originalUrl), + authorName: "", + images: [], + likes: 0, + retweets: 0, + replies: 0, + thread: [], + }; + + // Extract tweet text from data-testid="tweetText" + const tweetTextMatch = html.match( + /data-testid="tweetText"[^>]*>([\s\S]*?)<\/div>/i + ); + if (tweetTextMatch) { + result.text = tweetTextMatch[1] + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&#\d+;/g, "") + .replace(/\s+/g, " ") + .trim(); + } + + // Extract display name + const nameMatch = html.match( + /data-testid="User-Name"[^>]*>[\s\S]*?]*>([\s\S]*?)<\/span>/i + ); + if (nameMatch) { + result.authorName = nameMatch[1].replace(/<[^>]+>/g, "").trim(); + } + + // Extract images from tweet media + const imgMatches = html.matchAll( + /data-testid="tweetPhoto"[\s\S]*?]+src="([^"]+)"/gi + ); + for (const match of imgMatches) { + const src = match[1]; + if (src && !src.includes("emoji") && !src.includes("profile_images")) { + result.images.push(src); + } + } + + // Also look for og:image meta tags (tweet card images) + const ogImageMatch = html.match( + /]+property="og:image"[^>]+content="([^"]+)"/i + ); + if (ogImageMatch && result.images.length === 0) { + result.images.push(ogImageMatch[1]); + } + + // Extract engagement metrics + const likesMatch = html.match(/(\d[\d,]*)\s*(?:Likes?|likes?)/); + if (likesMatch) result.likes = parseInt(likesMatch[1].replace(/,/g, ""), 10); + + const retweetsMatch = html.match(/(\d[\d,]*)\s*(?:Repost|repost|Retweet)/); + if (retweetsMatch) result.retweets = parseInt(retweetsMatch[1].replace(/,/g, ""), 10); + + const repliesMatch = html.match(/(\d[\d,]*)\s*(?:Repl|repl)/); + if (repliesMatch) result.replies = parseInt(repliesMatch[1].replace(/,/g, ""), 10); + + return result; +} + +/** + * Parse tweet content from Nitter HTML. + */ +function parseTweetFromNitter(html) { + const result = { + text: "", + authorName: "", + images: [], + likes: 0, + retweets: 0, + replies: 0, + thread: [], + }; + + // Nitter puts tweet content in .tweet-content + const contentMatch = html.match( + /class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/i + ); + if (contentMatch) { + result.text = contentMatch[1] + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&#\d+;/g, "") + .replace(/\s+/g, " ") + .trim(); + } + + // Nitter display name + const nameMatch = html.match( + /class="fullname"[^>]*>([\s\S]*?)<\/a>/i + ); + if (nameMatch) { + result.authorName = nameMatch[1].replace(/<[^>]+>/g, "").trim(); + } + + // Nitter images + const imgMatches = html.matchAll( + /class="still-image"[^>]+href="([^"]+)"/gi + ); + for (const match of imgMatches) { + result.images.push(match[1]); + } + + // Nitter engagement — in .tweet-stat spans + const statsSection = html.match(/class="tweet-stats[^"]*"[^>]*>([\s\S]*?)<\/div>/i); + if (statsSection) { + const stats = statsSection[1]; + const replyCount = stats.match(/class="icon-comment[^"]*"[\s\S]*?(\d[\d,]*)/); + const rtCount = stats.match(/class="icon-retweet[^"]*"[\s\S]*?(\d[\d,]*)/); + const likeCount = stats.match(/class="icon-heart[^"]*"[\s\S]*?(\d[\d,]*)/); + if (replyCount) result.replies = parseInt(replyCount[1].replace(/,/g, ""), 10); + if (rtCount) result.retweets = parseInt(rtCount[1].replace(/,/g, ""), 10); + if (likeCount) result.likes = parseInt(likeCount[1].replace(/,/g, ""), 10); + } + + // Nitter thread replies (main-thread class) + const threadMatches = html.matchAll( + /class="timeline-item[^"]*"[\s\S]*?class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/gi + ); + let isFirst = true; + for (const match of threadMatches) { + if (isFirst) { isFirst = false; continue; } // Skip the root tweet + const replyText = match[1] + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (replyText) { + result.thread.push({ + text: replyText, + images: [], + }); + } + } + + return result; +} diff --git a/brane/core/adapters/url-adapter.js b/brane/core/adapters/url-adapter.js new file mode 100644 index 0000000..2850ea0 --- /dev/null +++ b/brane/core/adapters/url-adapter.js @@ -0,0 +1,241 @@ +import { BaseAdapter } from "./base-adapter.js"; +import { generateId } from "../types.js"; +import { getBrowserManager, needsBrowser } from "../browser/index.js"; + +/** + * Generic URL adapter — fetches a page and extracts text content. + * Now supports dual-mode: plain fetch (fast) or browser rendering (for SPAs). + * + * Decision chain: + * 1. If domain in SPA_DOMAINS → browser + * 2. If plain fetch returns <1KB text → retry with browser + * 3. If options.browser === true → browser + * 4. Otherwise → plain fetch + */ +export class UrlAdapter extends BaseAdapter { + static sourceType = "url"; + + static canHandle() { + return true; // fallback — always matches + } + + async scrape(url, options = {}) { + const browserManager = getBrowserManager(); + const forceBrowser = options.browser === true; + const shouldTryBrowser = forceBrowser || browserManager.shouldUseBrowser(url); + + // ─── Browser mode ─── + if (shouldTryBrowser && browserManager.isAvailable()) { + try { + return await this.scrapeBrowser(url, options, browserManager); + } catch (err) { + // Fallback to fetch if browser fails + console.warn(`Browser scrape failed for ${url}, falling back to fetch: ${err.message}`); + } + } + + // ─── Fetch mode (default) ─── + const result = await this.scrapeFetch(url, options); + + // Auto-retry with browser if fetch got very little content + if ( + !shouldTryBrowser && + browserManager.isAvailable() && + result.root.text.length < 500 + ) { + try { + return await this.scrapeBrowser(url, options, browserManager); + } catch { + // Keep fetch result + } + } + + return result; + } + + /** + * Scrape using browser engine (Cloudflare or Lightpanda). + */ + async scrapeBrowser(url, options, browserManager) { + const result = await browserManager.scrape(url, { + timeout: 15000, + extractImages: true, + }); + + if (!result) throw new Error("Browser returned no result"); + + const root = { + id: generateId(), + author: "Web Page", + authorId: url, + text: result.text, + category: "context", + attachments: result.images || [], + timestamp: new Date().toISOString(), + isRoot: true, + meta: { + url, + engine: result.meta?.engine || "browser", + statusCode: result.meta?.statusCode, + }, + }; + + const id = `url-${generateId()}`; + + return { + id, + source: "url", + sourceUrl: url, + project: options.project || "", + title: (result.title || url).slice(0, 80), + root, + replies: [], + allEntries: [root], + stats: { + totalEntries: 1, + totalReplies: 0, + categories: { context: 1 }, + imageCount: (result.images || []).length, + fileCount: 0, + blockerCount: 0, + revisionCount: 0, + }, + scrapedAt: new Date().toISOString(), + meta: { engine: result.meta?.engine }, + }; + } + + /** + * Scrape using plain fetch (fast, no JS rendering). + */ + async scrapeFetch(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + let response; + try { + response = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); + + const html = await response.text(); + const title = html.match(/]*>(.*?)<\/title>/i)?.[1]?.trim() || url; + const text = extractText(html); + + const root = { + id: generateId(), + author: "Web Page", + authorId: url, + text, + category: "context", + attachments: extractImages(html, url), + timestamp: new Date().toISOString(), + isRoot: true, + meta: { url, engine: "fetch", statusCode: response.status }, + }; + + const id = `url-${generateId()}`; + + return { + id, + source: "url", + sourceUrl: url, + project: options.project || "", + title: title.slice(0, 80), + root, + replies: [], + allEntries: [root], + stats: { + totalEntries: 1, + totalReplies: 0, + categories: { context: 1 }, + imageCount: root.attachments.filter((a) => a.type === "image").length, + fileCount: 0, + blockerCount: 0, + revisionCount: 0, + }, + scrapedAt: new Date().toISOString(), + meta: { engine: "fetch" }, + }; + } + + /** + * Download images — now parallel with concurrency limit. + */ + async downloadAssets(instructionSet, outputDir) { + const { mkdir, writeFile } = await import("fs/promises"); + const { join } = await import("path"); + const imagesDir = join(outputDir, "images"); + await mkdir(imagesDir, { recursive: true }); + + const images = instructionSet.allEntries.flatMap((e) => + e.attachments.filter((a) => a.type === "image") + ); + + if (images.length === 0) return { downloaded: 0, total: 0 }; + + // Download in parallel batches of 5 + const BATCH_SIZE = 5; + let downloaded = 0; + + for (let i = 0; i < images.length; i += BATCH_SIZE) { + const batch = images.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async (img) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await fetch(img.url, { signal: controller.signal }); + if (!response.ok) return; + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(join(imagesDir, img.name), buffer); + img.localPath = `images/${img.name}`; + return true; + } finally { + clearTimeout(timeout); + } + }) + ); + downloaded += results.filter((r) => r.status === "fulfilled" && r.value).length; + } + + return { downloaded, total: images.length }; + } +} + +function extractText(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 5000); +} + +function extractImages(html, baseUrl) { + const imgs = []; + const matches = html.matchAll(/]+src="([^"]+)"[^>]*>/gi); + let i = 0; + for (const match of matches) { + if (i >= 10) break; + const src = match[1]; + if (src.startsWith("data:")) continue; + try { + const fullUrl = src.startsWith("http") ? src : new URL(src, baseUrl).href; + const name = `image-${i}.${fullUrl.split(".").pop()?.split("?")[0] || "png"}`; + imgs.push({ + type: "image", + name, + title: `Image ${i + 1}`, + mimetype: "image/png", + url: fullUrl, + }); + i++; + } catch { + // Invalid URL, skip + } + } + return imgs; +} diff --git a/brane/core/agent-registry.js b/brane/core/agent-registry.js new file mode 100644 index 0000000..6c22b5a --- /dev/null +++ b/brane/core/agent-registry.js @@ -0,0 +1,307 @@ +import { readdir, readFile, writeFile, unlink } from "fs/promises"; +import { join, basename } from "path"; +import { homedir } from "os"; +import { execSync } from "child_process"; +import { generateId } from "./types.js"; + +const BRANE_DIR = join(homedir(), ".brane"); +const AGENTS_DIR = join(BRANE_DIR, "agents"); +const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects"); + +/** + * Read the last N lines from a file efficiently. + * Reads the file and splits, returning only the tail. + * @param {string} filePath + * @param {number} n + * @returns {Promise} + */ +async function readLastLines(filePath, n = 20) { + try { + const content = await readFile(filePath, "utf-8"); + const lines = content.trim().split("\n").filter(Boolean); + return lines.slice(-n); + } catch { + return []; + } +} + +/** + * Parse a single JSONL line safely. + * @param {string} line + * @returns {Object|null} + */ +function parseLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} + +/** + * Check if a process with the given PID is alive. + * @param {number} pid + * @returns {boolean} + */ +function isPidAlive(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Try to find a running Claude process associated with a session or cwd. + * @param {string} sessionId + * @param {string} cwd + * @returns {number|null} PID or null + */ +function findClaudePid(sessionId, cwd) { + try { + const result = execSync( + `pgrep -f "claude"`, + { encoding: "utf-8", timeout: 3000 } + ).trim(); + const pids = result.split("\n").filter(Boolean).map(Number); + return pids[0] || null; + } catch { + return null; + } +} + +/** + * Scan all Claude Code session JSONL files under ~/.claude/projects/ + * and create/update agent records in ~/.brane/agents/. + * @returns {Promise} Array of agent objects created or updated + */ +export async function scanClaudeSessions() { + const agents = []; + + let projectDirs; + try { + projectDirs = await readdir(CLAUDE_PROJECTS_DIR); + } catch { + return agents; + } + + // Collect all session entries keyed by sessionId + const sessionMap = new Map(); + + for (const dir of projectDirs) { + const dirPath = join(CLAUDE_PROJECTS_DIR, dir); + let files; + try { + files = await readdir(dirPath); + } catch { + continue; + } + + const jsonlFiles = files.filter((f) => f.endsWith(".jsonl")); + + for (const file of jsonlFiles) { + const filePath = join(dirPath, file); + const lines = await readLastLines(filePath, 20); + + for (const line of lines) { + const entry = parseLine(line); + if (!entry || !entry.sessionId) continue; + + const existing = sessionMap.get(entry.sessionId); + if (!existing || (entry.timestamp && entry.timestamp > existing.timestamp)) { + sessionMap.set(entry.sessionId, entry); + } + } + } + } + + // Load existing agents to avoid duplicates + const existingAgents = await getAgents(); + const existingBySession = new Map(); + for (const agent of existingAgents) { + if (agent.meta?.sessionId) { + existingBySession.set(agent.meta.sessionId, agent); + } + } + + // Create/update agents from sessions + for (const [sessionId, entry] of sessionMap) { + const pid = findClaudePid(sessionId, entry.cwd); + const isAlive = isPidAlive(pid); + + const agentData = { + name: entry.slug || basename(entry.cwd || "unknown"), + cwd: entry.cwd || null, + gitBranch: entry.gitBranch || null, + gitRepo: entry.cwd ? basename(entry.cwd) : null, + pid: pid, + model: entry.version || null, + status: isAlive ? "active" : "idle", + meta: { + sessionId: sessionId, + slug: entry.slug || null, + version: entry.version || null, + }, + }; + + const existing = existingBySession.get(sessionId); + if (existing) { + const updated = await updateAgent(existing.id, { + ...agentData, + lastSeen: new Date().toISOString(), + }); + agents.push(updated); + } else { + const registered = await registerAgent(agentData); + agents.push(registered); + } + } + + return agents; +} + +/** + * Register a new agent in the filesystem. + * @param {Object} data - Agent data to merge with defaults + * @returns {Promise} The saved agent object + */ +export async function registerAgent(data) { + const now = new Date().toISOString(); + const agent = { + id: generateId(), + name: data.name || "unnamed-agent", + status: data.status || "idle", + cwd: data.cwd || null, + gitBranch: data.gitBranch || null, + gitRepo: data.gitRepo || null, + pid: data.pid || null, + model: data.model || null, + parentAgentId: data.parentAgentId || null, + taskIds: data.taskIds || [], + capabilities: data.capabilities || [], + lastSeen: now, + createdAt: now, + meta: { + sessionId: data.meta?.sessionId || null, + slug: data.meta?.slug || null, + version: data.meta?.version || null, + }, + ...data, + }; + // Ensure id is always generated fresh if not provided + if (!data.id) agent.id = generateId(); + + const filePath = join(AGENTS_DIR, `${agent.id}.json`); + await writeFile(filePath, JSON.stringify(agent, null, 2), "utf-8"); + return agent; +} + +/** + * Get all registered agents. + * @returns {Promise} Array of agent objects + */ +export async function getAgents() { + try { + const files = await readdir(AGENTS_DIR); + const jsonFiles = files.filter((f) => f.endsWith(".json")); + const agents = []; + + for (const file of jsonFiles) { + try { + const raw = await readFile(join(AGENTS_DIR, file), "utf-8"); + agents.push(JSON.parse(raw)); + } catch { + // Skip malformed files + } + } + + return agents; + } catch { + return []; + } +} + +/** + * Get a single agent by ID. + * @param {string} id + * @returns {Promise} + */ +export async function getAgent(id) { + try { + const raw = await readFile(join(AGENTS_DIR, `${id}.json`), "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Update an agent with a partial patch (read-modify-write). + * @param {string} id + * @param {Object} patch - Fields to merge + * @returns {Promise} Updated agent or null if not found + */ +export async function updateAgent(id, patch) { + const agent = await getAgent(id); + if (!agent) return null; + + const updated = { ...agent, ...patch }; + // Deep merge meta + if (patch.meta) { + updated.meta = { ...agent.meta, ...patch.meta }; + } + + const filePath = join(AGENTS_DIR, `${id}.json`); + await writeFile(filePath, JSON.stringify(updated, null, 2), "utf-8"); + return updated; +} + +/** + * Check if an agent's PID is still alive. + * @param {Object} agent + * @returns {boolean} + */ +export function checkLiveness(agent) { + return isPidAlive(agent.pid); +} + +/** + * Remove an agent file from the registry. + * @param {string} id + * @returns {Promise} true if removed + */ +export async function removeAgent(id) { + try { + await unlink(join(AGENTS_DIR, `${id}.json`)); + return true; + } catch { + return false; + } +} + +/** + * Update an agent's heartbeat (lastSeen) and merge optional patch. + * @param {string} id + * @param {Object} [patch={}] + * @returns {Promise} + */ +export async function heartbeat(id, patch = {}) { + return updateAgent(id, { + ...patch, + lastSeen: new Date().toISOString(), + }); +} + +export async function pauseAgent(id) { + return updateAgent(id, { status: "paused" }); +} + +export async function resumeAgent(id) { + return updateAgent(id, { status: "active" }); +} + +export async function decommissionAgent(id) { + return updateAgent(id, { status: "decommissioned" }); +} diff --git a/brane/core/browser/cloudflare.js b/brane/core/browser/cloudflare.js new file mode 100644 index 0000000..d33a450 --- /dev/null +++ b/brane/core/browser/cloudflare.js @@ -0,0 +1,189 @@ +import { BrowserEngine } from "./engine.js"; + +/** + * Cloudflare Browser Rendering engine. + * Uses the REST API directly — no Workers deployment needed. + * + * Requires: + * - CF_API_TOKEN (Cloudflare API token with Browser Rendering permissions) + * - CF_ACCOUNT_ID (Cloudflare account ID) + * + * Pricing: 10 hrs/month free, then $0.09/hr + * Docs: https://developers.cloudflare.com/browser-rendering/ + */ +export class CloudflareEngine extends BrowserEngine { + constructor(options = {}) { + super(options); + this.name = "cloudflare"; + this.apiToken = options.apiToken || process.env.CF_API_TOKEN; + this.accountId = options.accountId || process.env.CF_ACCOUNT_ID; + this.baseUrl = `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/browser-rendering`; + } + + async isAvailable() { + return !!(this.apiToken && this.accountId); + } + + async connect() { + if (!this.apiToken || !this.accountId) { + throw new Error("CF_API_TOKEN and CF_ACCOUNT_ID required for Cloudflare Browser Rendering"); + } + // Test connectivity + try { + const res = await this.cfFetch("/content", { + method: "POST", + body: JSON.stringify({ url: "https://example.com" }), + }); + this.connected = true; + } catch (err) { + this.connected = false; + throw new Error(`Cloudflare connection failed: ${err.message}`); + } + } + + async scrape(url, options = {}) { + const timeout = options.timeout || 15000; + + // Use the /content endpoint for rendered HTML + const result = await this.cfFetch("/content", { + method: "POST", + body: JSON.stringify({ + url, + renderJs: true, + waitUntil: "networkidle2", + timeout, + ...(options.waitFor ? { waitForSelector: options.waitFor } : {}), + }), + }); + + const html = result.html || result.content || ""; + const text = extractTextFromHtml(html); + const title = html.match(/]*>(.*?)<\/title>/i)?.[1]?.trim() || url; + const images = options.extractImages !== false ? extractImagesFromHtml(html, url) : []; + + return { + html, + text, + title, + images, + meta: { + engine: "cloudflare", + browserMs: result.browserMs, + statusCode: result.statusCode, + }, + }; + } + + async screenshot(url, options = {}) { + try { + const result = await this.cfFetch("/screenshot", { + method: "POST", + body: JSON.stringify({ + url, + renderJs: true, + screenshotOptions: { + type: "png", + fullPage: options.fullPage || false, + }, + }), + }); + return result; + } catch { + return null; + } + } + + async close() { + this.connected = false; + } + + getStatus() { + return { + name: this.name, + connected: this.connected, + configured: !!(this.apiToken && this.accountId), + }; + } + + // ─── Private ─── + + async cfFetch(path, options = {}) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const res = await fetch(`${this.baseUrl}${path}`, { + ...options, + signal: controller.signal, + headers: { + "Authorization": `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!res.ok) { + const errorBody = await res.text().catch(() => ""); + throw new Error(`Cloudflare API ${res.status}: ${errorBody.slice(0, 200)}`); + } + + // Check content type — screenshot returns binary + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("image/")) { + return Buffer.from(await res.arrayBuffer()); + } + + return await res.json(); + } finally { + clearTimeout(timeoutId); + } + } +} + +// ─── HTML Extraction Helpers ─── + +function extractTextFromHtml(html) { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/nav>/gi, "") + .replace(/]*>[\s\S]*?<\/header>/gi, "") + .replace(/]*>[\s\S]*?<\/footer>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&#\d+;/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 10000); +} + +function extractImagesFromHtml(html, baseUrl) { + const imgs = []; + // Match both src and data-src (lazy loaded) + const matches = html.matchAll(/]+(?:src|data-src)="([^"]+)"[^>]*>/gi); + let i = 0; + for (const match of matches) { + if (i >= 15) break; + const src = match[1]; + if (src.startsWith("data:")) continue; + if (src.includes("1x1") || src.includes("pixel") || src.includes("tracking")) continue; + try { + const fullUrl = src.startsWith("http") ? src : new URL(src, baseUrl).href; + const ext = fullUrl.split(".").pop()?.split("?")[0] || "png"; + imgs.push({ + type: "image", + name: `image-${i}.${ext}`, + title: `Image ${i + 1}`, + mimetype: `image/${ext === "jpg" ? "jpeg" : ext}`, + url: fullUrl, + }); + i++; + } catch { + // Invalid URL, skip + } + } + return imgs; +} diff --git a/brane/core/browser/engine.js b/brane/core/browser/engine.js new file mode 100644 index 0000000..a15275e --- /dev/null +++ b/brane/core/browser/engine.js @@ -0,0 +1,71 @@ +/** + * BrowserEngine — abstract interface for headless browser scraping. + * Implementations: CloudflareEngine, LightpandaEngine + * + * The engine provides a unified way to: + * - Scrape JS-rendered pages (SPAs) + * - Extract text/images from DOM + * - Take screenshots (Cloudflare only) + */ +export class BrowserEngine { + constructor(options = {}) { + this.name = "base"; + this.connected = false; + this.options = options; + } + + /** Check if the engine is available and configured */ + async isAvailable() { return false; } + + /** Connect / warm up */ + async connect() { throw new Error("Not implemented"); } + + /** + * Scrape a URL with full JS rendering. + * @param {string} url + * @param {Object} options - { timeout, waitFor, extractImages } + * @returns {Promise<{ html: string, text: string, title: string, images: Array, meta: Object }>} + */ + async scrape(url, options = {}) { throw new Error("Not implemented"); } + + /** + * Take a screenshot (not all engines support this). + * @returns {Promise} + */ + async screenshot(url) { return null; } + + /** Cleanup */ + async close() { this.connected = false; } + + /** Engine info for health checks */ + getStatus() { + return { name: this.name, connected: this.connected }; + } +} + +/** + * Domains known to require JS rendering (SPAs). + */ +export const SPA_DOMAINS = [ + "x.com", "twitter.com", + "notion.so", + "linear.app", + "figma.com", // already handled by FigmaAdapter, but fallback + "vercel.app", + "app.slack.com", // web client (API adapter is preferred) + "github.com", // mostly works without JS, but issues/PRs render dynamically + "medium.com", + "substack.com", +]; + +/** + * Check if a URL likely needs a browser for proper rendering. + */ +export function needsBrowser(url) { + try { + const hostname = new URL(url).hostname; + return SPA_DOMAINS.some((d) => hostname === d || hostname.endsWith(`.${d}`)); + } catch { + return false; + } +} diff --git a/brane/core/browser/index.js b/brane/core/browser/index.js new file mode 100644 index 0000000..f4439e6 --- /dev/null +++ b/brane/core/browser/index.js @@ -0,0 +1,148 @@ +import { CloudflareEngine } from "./cloudflare.js"; +import { LightpandaEngine } from "./lightpanda.js"; +import { needsBrowser } from "./engine.js"; + +/** + * BrowserManager — manages the fallback chain of browser engines. + * + * Priority: + * 1. Cloudflare Browser Rendering (production, edge-powered) + * 2. Lightpanda / local CDP (dev, self-hosted) + * 3. null (graceful fallback to plain fetch) + */ +export class BrowserManager { + constructor() { + this.engines = []; + this.activeEngine = null; + this._initialized = false; + } + + /** + * Initialize — detect available engines and connect. + * Non-destructive: if no engines available, returns gracefully. + */ + async init() { + if (this._initialized) return; + this._initialized = true; + + // Register engines in priority order + const candidates = [ + new CloudflareEngine(), + new LightpandaEngine(), + ]; + + for (const engine of candidates) { + const available = await engine.isAvailable(); + if (available) { + this.engines.push(engine); + console.log(` āœ“ Browser engine available: ${engine.name}`); + } + } + + // Connect to the first available engine + if (this.engines.length > 0) { + try { + await this.engines[0].connect(); + this.activeEngine = this.engines[0]; + console.log(` ⚔ Active browser engine: ${this.activeEngine.name}`); + } catch (err) { + console.warn(` ⚠ Failed to connect to ${this.engines[0].name}: ${err.message}`); + // Try next engine + if (this.engines.length > 1) { + try { + await this.engines[1].connect(); + this.activeEngine = this.engines[1]; + console.log(` ⚔ Fallback browser engine: ${this.activeEngine.name}`); + } catch (err2) { + console.warn(` ⚠ Failed to connect to ${this.engines[1].name}: ${err2.message}`); + } + } + } + } else { + console.log(" ℹ No browser engines available — using plain fetch"); + } + } + + /** + * Check if browser scraping is available. + */ + isAvailable() { + return !!this.activeEngine?.connected; + } + + /** + * Check if a URL should use browser rendering. + */ + shouldUseBrowser(url) { + return this.isAvailable() && needsBrowser(url); + } + + /** + * Scrape a URL using the active browser engine. + * Falls through the engine chain on failure. + * + * @returns {Promise<{ html, text, title, images, meta } | null>} + */ + async scrape(url, options = {}) { + if (!this.isAvailable()) return null; + + // Try active engine first + try { + return await this.activeEngine.scrape(url, options); + } catch (err) { + console.warn(`Browser scrape failed (${this.activeEngine.name}): ${err.message}`); + + // Try fallback engines + for (const engine of this.engines) { + if (engine === this.activeEngine) continue; + if (!engine.connected) { + try { await engine.connect(); } catch { continue; } + } + try { + const result = await engine.scrape(url, options); + this.activeEngine = engine; // Switch to working engine + return result; + } catch { + continue; + } + } + + return null; // All engines failed — caller should fallback to fetch + } + } + + /** + * Get status of all engines for the Settings page. + */ + getStatus() { + return { + available: this.isAvailable(), + activeEngine: this.activeEngine?.name || null, + engines: this.engines.map((e) => e.getStatus()), + }; + } + + /** + * Shutdown all engines. + */ + async shutdown() { + for (const engine of this.engines) { + await engine.close().catch(() => {}); + } + this.activeEngine = null; + } +} + +// Singleton — shared across the server +let _instance = null; + +export function getBrowserManager() { + if (!_instance) { + _instance = new BrowserManager(); + } + return _instance; +} + +export { needsBrowser } from "./engine.js"; +export { CloudflareEngine } from "./cloudflare.js"; +export { LightpandaEngine } from "./lightpanda.js"; diff --git a/brane/core/browser/lightpanda.js b/brane/core/browser/lightpanda.js new file mode 100644 index 0000000..20bc743 --- /dev/null +++ b/brane/core/browser/lightpanda.js @@ -0,0 +1,150 @@ +import { BrowserEngine } from "./engine.js"; + +/** + * Lightpanda browser engine — local headless browser via CDP. + * 11x faster than Chrome, 9x less memory. + * + * Requires: + * - Lightpanda running locally: ./lightpanda serve --host 127.0.0.1 --port 9222 + * - Set LIGHTPANDA_URL=ws://127.0.0.1:9222 + * + * Falls back to puppeteer-core connecting to any CDP endpoint. + * Also works with regular Chrome/Chromium if Lightpanda isn't available. + */ +export class LightpandaEngine extends BrowserEngine { + constructor(options = {}) { + super(options); + this.name = "lightpanda"; + this.endpoint = options.endpoint || process.env.LIGHTPANDA_URL || "ws://127.0.0.1:9222"; + this.browser = null; + this._puppeteer = null; + } + + async isAvailable() { + if (!this.endpoint) return false; + + // Check if the CDP endpoint is reachable + try { + const httpUrl = this.endpoint + .replace("ws://", "http://") + .replace("wss://", "https://") + .replace(/\/$/, ""); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2000); + const res = await fetch(`${httpUrl}/json/version`, { signal: controller.signal }); + clearTimeout(timeout); + return res.ok; + } catch { + return false; + } + } + + async connect() { + try { + this._puppeteer = (await import("puppeteer-core")).default; + } catch { + throw new Error("puppeteer-core is required for Lightpanda engine. Run: npm i puppeteer-core"); + } + + try { + this.browser = await this._puppeteer.connect({ + browserWSEndpoint: this.endpoint, + defaultViewport: { width: 1280, height: 800 }, + }); + this.connected = true; + } catch (err) { + this.connected = false; + throw new Error(`Failed to connect to Lightpanda at ${this.endpoint}: ${err.message}`); + } + } + + async scrape(url, options = {}) { + if (!this.browser || !this.connected) { + await this.connect(); + } + + const page = await this.browser.newPage(); + const timeout = options.timeout || 15000; + + try { + await page.goto(url, { + waitUntil: "networkidle2", + timeout, + }); + + // Wait for optional selector + if (options.waitFor) { + await page.waitForSelector(options.waitFor, { timeout: 5000 }).catch(() => {}); + } + + // Extract content from DOM + const result = await page.evaluate(() => { + const title = document.title || ""; + + // Get main content text + const mainContent = document.querySelector("main, article, [role='main'], .content, #content"); + const textSource = mainContent || document.body; + const text = textSource ? textSource.innerText : ""; + + // Get all images + const images = Array.from(document.querySelectorAll("img")).map((img) => ({ + src: img.src || img.dataset?.src || "", + alt: img.alt || "", + width: img.naturalWidth || img.width, + height: img.naturalHeight || img.height, + })).filter((img) => + img.src && + !img.src.startsWith("data:") && + img.width > 50 && img.height > 50 // Skip tiny tracking pixels + ); + + return { title, text, html: document.documentElement.outerHTML, images }; + }); + + const images = (options.extractImages !== false ? result.images : []).slice(0, 15).map((img, i) => { + const ext = img.src.split(".").pop()?.split("?")[0] || "png"; + return { + type: "image", + name: `image-${i}.${ext}`, + title: img.alt || `Image ${i + 1}`, + mimetype: `image/${ext === "jpg" ? "jpeg" : ext}`, + url: img.src, + }; + }); + + return { + html: result.html, + text: result.text.slice(0, 10000), + title: result.title, + images, + meta: { + engine: "lightpanda", + }, + }; + } finally { + await page.close().catch(() => {}); + } + } + + async screenshot(url) { + // Lightpanda doesn't support screenshots + return null; + } + + async close() { + if (this.browser) { + await this.browser.disconnect().catch(() => {}); + this.browser = null; + } + this.connected = false; + } + + getStatus() { + return { + name: this.name, + connected: this.connected, + configured: !!this.endpoint, + endpoint: this.endpoint, + }; + } +} diff --git a/brane/core/categorizer.js b/brane/core/categorizer.js new file mode 100644 index 0000000..ceda0a3 --- /dev/null +++ b/brane/core/categorizer.js @@ -0,0 +1,47 @@ +/** + * Shared feedback categorizer — keyword + signal detection. + * Used across all adapters for consistent categorization. + */ + +const FEEDBACK_SIGNALS = { + approval: { + keywords: ["looks good", "lgtm", "approved", "love it", "ship it", "perfect", "great", "nice", "awesome", "solid", "good to go", "yes"], + }, + revision: { + keywords: [ + "change", "update", "fix", "adjust", "move", "swap", "replace", + "instead", "should be", "needs to", "can we", "could you", "try", + "make it", "switch", "tweak", "modify", "redo", "rework", + ], + }, + question: { + keywords: ["why", "how", "what if", "is this", "are we", "should we", "can we", "?"], + }, + blocker: { + keywords: [ + "blocker", "blocked", "can't ship", "don't ship", "stop", "hold", + "critical", "breaking", "broken", "bug", "issue", "wrong", + ], + }, +}; + +/** + * Categorize a text string into feedback categories. + * @param {string} text + * @returns {'blocker' | 'revision' | 'question' | 'approval' | 'context'} + */ +export function categorize(text) { + const lower = (text || "").toLowerCase(); + const scores = { approval: 0, revision: 0, question: 0, blocker: 0 }; + + for (const [category, signals] of Object.entries(FEEDBACK_SIGNALS)) { + for (const kw of signals.keywords) { + if (lower.includes(kw)) scores[category]++; + } + } + + const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0]; + return best[1] > 0 ? best[0] : "context"; +} + +export { FEEDBACK_SIGNALS }; diff --git a/brane/core/claude-bridge.js b/brane/core/claude-bridge.js new file mode 100644 index 0000000..940adc3 --- /dev/null +++ b/brane/core/claude-bridge.js @@ -0,0 +1,157 @@ +import { spawn, execSync } from "child_process"; +import { createWriteStream, mkdirSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; +import { generateId } from "./types.js"; + +/** + * Common paths where the Claude CLI might be installed. + */ +const CLAUDE_PATHS = [ + "claude", + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + `${process.env.HOME}/.local/bin/claude`, + `${process.env.HOME}/.npm-global/bin/claude`, +]; + +/** + * Find the Claude CLI binary path. + * @returns {string|null} Path to claude binary or null + */ +function findClaudeBinary() { + for (const bin of CLAUDE_PATHS) { + try { + execSync(`command -v ${bin}`, { encoding: "utf-8", timeout: 3000 }); + return bin; + } catch { + // Try next + } + } + + // Also try `which` + try { + return execSync("which claude", { encoding: "utf-8", timeout: 3000 }).trim(); + } catch { + return null; + } +} + +/** + * Check if Claude Code CLI is installed and accessible. + * @returns {{ installed: boolean, path: string|null }} + */ +export function isClaudeInstalled() { + const bin = findClaudeBinary(); + return { installed: !!bin, path: bin }; +} + +/** + * Spawn a Claude Code background agent. + * @param {Object} options + * @param {string} options.cwd - Working directory for the agent + * @param {string} [options.branch] - Git branch to work on + * @param {string} options.prompt - The prompt/task for the agent + * @param {string} [options.name] - Optional name for the agent + * @returns {{ id: string, pid: number|null, name: string, cwd: string, error?: string }} + */ +export function spawnAgent({ cwd, branch, prompt, name }) { + const { installed, path: claudePath } = isClaudeInstalled(); + const agentId = generateId(); + const agentName = name || `agent-${agentId}`; + + if (!installed) { + return { + id: agentId, + pid: null, + name: agentName, + cwd, + error: "Claude CLI not found. Install it first.", + }; + } + + try { + const args = ["-p", prompt, "--output-format", "json"]; + + // Log file for capturing output + const agentsLogDir = join(homedir(), ".brane", "agents"); + mkdirSync(agentsLogDir, { recursive: true }); + const logPath = join(agentsLogDir, `${agentId}.log`); + const logStream = createWriteStream(logPath, { flags: "a" }); + + const child = spawn(claudePath, args, { + cwd, + detached: true, + stdio: ["ignore", logStream, logStream], + }); + + // Allow the parent to exit independently + child.unref(); + + // Track exit + child.on("exit", (code) => { + logStream.write(`\n[brane] Process exited with code ${code}\n`); + logStream.end(); + }); + + return { + id: agentId, + pid: child.pid || null, + name: agentName, + cwd, + logPath, + }; + } catch (err) { + return { + id: agentId, + pid: null, + name: agentName, + cwd, + error: err.message, + }; + } +} + +/** + * List running Claude processes. + * @returns {{ pid: number, cwd: string, command: string }[]} + */ +export function listClaudeProcesses() { + try { + const output = execSync("ps aux", { encoding: "utf-8", timeout: 5000 }); + const lines = output.split("\n"); + + const claudeProcesses = []; + for (const line of lines) { + if (!line.toLowerCase().includes("claude")) continue; + // Skip the grep process itself + if (line.includes("grep")) continue; + + const parts = line.trim().split(/\s+/); + if (parts.length < 11) continue; + + const pid = parseInt(parts[1], 10); + const command = parts.slice(10).join(" "); + + // Try to extract cwd from /proc or lsof (macOS fallback) + let cwd = ""; + try { + cwd = execSync(`lsof -p ${pid} | grep cwd`, { + encoding: "utf-8", + timeout: 3000, + }) + .trim() + .split(/\s+/) + .pop() || ""; + } catch { + // cwd detection may fail + } + + claudeProcesses.push({ pid, cwd, command }); + } + + return claudeProcesses; + } catch { + return []; + } +} diff --git a/brane/core/dispatch.js b/brane/core/dispatch.js new file mode 100644 index 0000000..05e7d46 --- /dev/null +++ b/brane/core/dispatch.js @@ -0,0 +1,319 @@ +import { detectAdapter } from "./adapters/index.js"; +import { generateInstructionMd } from "./markdown-generator.js"; +import { saveInstruction } from "./store.js"; +import { getBrowserManager, needsBrowser } from "./browser/index.js"; +import { createTask, getTask, getTasks } from "./task-manager.js"; +import { writeFile, readFile } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; +import { syncFromScraped } from "./knowledge-store.js"; + +const TASKS_DIR = join(homedir(), ".brane", "tasks"); + +/** + * Pipeline stages for activity tracking. + */ +const STAGES = [ + { id: "detect", label: "Detecting source", icon: "šŸ”" }, + { id: "connect", label: "Connecting to source", icon: "šŸ”—" }, + { id: "fetch", label: "Fetching content", icon: "šŸ“”" }, + { id: "parse", label: "Parsing entries", icon: "🧩" }, + { id: "categorize", label: "Categorizing feedback", icon: "šŸ·ļø" }, + { id: "markdown", label: "Generating instructions", icon: "šŸ“" }, + { id: "assets", label: "Downloading assets", icon: "šŸ–¼ļø" }, + { id: "save", label: "Saving to disk", icon: "šŸ’¾" }, + { id: "done", label: "Complete", icon: "āœ“" }, +]; + +/** Max concurrent dispatch jobs */ +const MAX_CONCURRENT = 5; + +/** + * Push activity entry to local job object (in-memory for fast polling). + */ +function logLocal(job, stageId, message) { + const stage = STAGES.find((s) => s.id === stageId); + const entry = { + stage: stageId, + label: stage?.label || stageId, + message, + timestamp: Date.now(), + }; + job.activity.push(entry); + job.currentStage = stageId; + job.stageIndex = STAGES.findIndex((s) => s.id === stageId); +} + +/** + * Persist the current in-memory job state to the task file. + * Does a direct read-modify-write to *replace* activity (not append), + * since we hold the full activity array in memory. + */ +async function persistJob(job) { + try { + const filePath = join(TASKS_DIR, `${job.id}.json`); + const raw = await readFile(filePath, "utf-8"); + const task = JSON.parse(raw); + + // Overwrite with in-memory state (activity is replaced, not appended) + task.status = job.status; + task.activity = job.activity; + task.currentStage = job.currentStage; + task.stageIndex = job.stageIndex; + task.totalStages = job.totalStages; + task.input = { ...task.input, ...job.input }; + task.output = job.output; + task.completedAt = job.completedAt; + + await writeFile(filePath, JSON.stringify(task, null, 2), "utf-8"); + } catch (err) { + // Non-fatal — don't break the pipeline if persist fails + console.error(`[dispatch] persist failed for ${job.id}:`, err.message); + } +} + +/** + * Map a task-manager task object to the shape the UI expects. + */ +function taskToJob(task) { + if (!task) return null; + return { + id: task.id, + url: task.input?.url || "", + detectedSource: task.input?.detectedSource || null, + status: task.status, + project: task.input?.project || "", + resultId: task.output?.resultId || null, + error: task.output?.error || null, + createdAt: task.createdAt, + completedAt: task.completedAt, + activity: task.activity || [], + currentStage: task.currentStage, + stageIndex: task.stageIndex ?? -1, + totalStages: task.totalStages || STAGES.length, + stats: task.output?.stats || null, + engine: task.input?.engine || null, + }; +} + +/** + * Dispatcher — auto-detects adapters and processes URLs in parallel. + * Tracks pipeline activity per job for live visualization. + * Supports browser engine fallback chain. + * + * Jobs are persisted to ~/.brane/tasks/ via task-manager so they + * survive server restarts and page refreshes. + */ +export class Dispatcher { + constructor(outputDir) { + this.outputDir = outputDir; + // In-memory cache for active jobs (fast polling during dispatch) + this._activeJobs = new Map(); + this._activeCount = 0; + this._queue = []; + } + + async createPendingJob(url, project = "") { + const AdapterClass = detectAdapter(url); + + // Check for existing pending task for the same URL + const existing = await getTasks({ type: "scrape", status: "pending" }); + const dup = existing.find((t) => t.input?.url === url); + if (dup) return taskToJob(dup); + + const browserManager = getBrowserManager(); + const willUseBrowser = needsBrowser(url) && browserManager.isAvailable(); + const engine = willUseBrowser ? browserManager.activeEngine?.name : "fetch"; + + const task = await createTask({ + type: "scrape", + title: url, + status: "pending", + totalStages: STAGES.length, + input: { + url, + project, + detectedSource: AdapterClass.sourceType, + engine, + }, + output: {}, + activity: [], + currentStage: null, + stageIndex: -1, + }); + + return taskToJob(task); + } + + async dispatch(url, project = "") { + // Concurrency gate + if (this._activeCount >= MAX_CONCURRENT) { + await new Promise((resolve) => this._queue.push(resolve)); + } + this._activeCount++; + + const AdapterClass = detectAdapter(url); + + // Find or create the pending task + let tasks = await getTasks({ type: "scrape", status: "pending" }); + let task = tasks.find((t) => t.input?.url === url); + if (!task) { + const stub = await this.createPendingJob(url, project); + task = await getTask(stub.id); + } + + // Build in-memory job for fast polling + const job = { + id: task.id, + status: "processing", + activity: [], + currentStage: null, + stageIndex: -1, + totalStages: STAGES.length, + input: { ...task.input }, + output: {}, + completedAt: null, + }; + this._activeJobs.set(job.id, job); + + // Persist initial processing status + await persistJob(job); + + try { + // Stage 1: Detect + const browserManager = getBrowserManager(); + const engineName = (needsBrowser(url) && browserManager.isAvailable()) + ? browserManager.activeEngine?.name + : "fetch"; + job.input.engine = engineName; + + logLocal(job, "detect", `Source: ${AdapterClass.sourceType} Ā· Engine: ${engineName}`); + await tick(); + + // Stage 2: Connect + logLocal(job, "connect", `Initializing ${AdapterClass.sourceType} adapter`); + const adapter = new AdapterClass(); + await tick(); + + // Persist after connect stage + await persistJob(job); + + // Stage 3-5: Fetch, Parse, Categorize + logLocal(job, "fetch", `Requesting content from ${truncateUrl(url)}`); + const instructionSet = await adapter.scrape(url, { project }); + + const actualEngine = instructionSet.meta?.engine || engineName; + job.input.engine = actualEngine; + + logLocal(job, "parse", `Found ${instructionSet.stats.totalEntries} entries via ${actualEngine}`); + await tick(); + + logLocal(job, "categorize", `${instructionSet.stats.blockerCount || 0} blockers, ${instructionSet.stats.revisionCount || 0} changes, ${instructionSet.stats.imageCount || 0} images`); + await tick(); + + // Persist after categorize + await persistJob(job); + + // Stage 6: Generate markdown + logLocal(job, "markdown", `Building agent instruction document`); + const md = generateInstructionMd(instructionSet); + await tick(); + + // Stage 7: Save + logLocal(job, "save", `Writing to ${instructionSet.id}/`); + await saveInstruction(instructionSet, this.outputDir, md); + + // Stage 8: Assets + logLocal(job, "assets", `Downloading ${instructionSet.stats.imageCount} images`); + const assetResult = await adapter.downloadAssets(instructionSet, `${this.outputDir}/${instructionSet.id}`); + if (assetResult.total > 0) { + logLocal(job, "assets", `Downloaded ${assetResult.downloaded}/${assetResult.total} assets`); + } + + // Done + logLocal(job, "done", `Instruction set ready: ${instructionSet.title}`); + job.output = { resultId: instructionSet.id, stats: instructionSet.stats }; + job.status = "complete"; + } catch (err) { + job.activity.push({ + stage: "error", + label: "Error", + message: err.message, + timestamp: Date.now(), + }); + job.output = { error: err.message }; + job.status = "error"; + } + + job.completedAt = new Date().toISOString(); + + // Final persist + await persistJob(job); + + // Remove from active cache + this._activeJobs.delete(job.id); + + // Auto-sync knowledge from scraped output + if (job.status === "complete") { + try { + await syncFromScraped(this.outputDir); + } catch (err) { + console.error(`[dispatch] knowledge sync failed:`, err.message); + } + } + + // Release concurrency slot + this._activeCount--; + if (this._queue.length > 0) { + const next = this._queue.shift(); + next(); + } + + return taskToJob(job); + } + + async dispatchBatch(urls, project = "") { + const results = await Promise.allSettled( + urls.map((url) => this.dispatch(url, project)) + ); + return results.map((r) => (r.status === "fulfilled" ? r.value : r.reason)); + } + + async getJobs() { + // Merge persisted tasks with in-memory active jobs (active have fresher data) + const tasks = await getTasks({ type: "scrape" }); + const jobs = tasks.map((t) => { + // If this task is currently active, use the in-memory version for freshness + const active = this._activeJobs.get(t.id); + if (active) { + return taskToJob(active); + } + return taskToJob(t); + }); + return jobs; + } + + async getJob(id) { + // Prefer in-memory active job for freshness + const active = this._activeJobs.get(id); + if (active) return taskToJob(active); + + const task = await getTask(id); + if (!task || task.type !== "scrape") return null; + return taskToJob(task); + } +} + +// Small delay to let polling pick up stage changes +function tick() { + return new Promise((r) => setTimeout(r, 80)); +} + +function truncateUrl(url) { + try { + const u = new URL(url); + return u.hostname + u.pathname.slice(0, 30) + (u.pathname.length > 30 ? "…" : ""); + } catch { + return url.slice(0, 50); + } +} diff --git a/brane/core/git-tracker.js b/brane/core/git-tracker.js new file mode 100644 index 0000000..1098746 --- /dev/null +++ b/brane/core/git-tracker.js @@ -0,0 +1,226 @@ +import { execSync } from "child_process"; + +/** + * Execute a git command in the given working directory. + * @param {string} cmd - Git command to run + * @param {string} cwd - Working directory + * @returns {string} Trimmed stdout + */ +function git(cmd, cwd) { + return execSync(cmd, { cwd, encoding: "utf-8", timeout: 5000 }).trim(); +} + +/** + * Get current branch info for a repository. + * @param {string} cwd - Repository path + * @returns {{ branch: string, dirty: boolean, uncommittedFiles: string[], aheadBehind: { ahead: number, behind: number } }} + */ +export function getBranchInfo(cwd) { + try { + const branch = git("git rev-parse --abbrev-ref HEAD", cwd); + + const porcelain = git("git status --porcelain", cwd); + const uncommittedFiles = porcelain + ? porcelain.split("\n").map((line) => line.trim()).filter(Boolean) + : []; + const dirty = uncommittedFiles.length > 0; + + let aheadBehind = { ahead: 0, behind: 0 }; + try { + const ab = git("git rev-list --left-right --count @{u}...HEAD", cwd); + const [behind, ahead] = ab.split(/\s+/).map(Number); + aheadBehind = { ahead: ahead || 0, behind: behind || 0 }; + } catch { + // No upstream tracking branch + } + + return { branch, dirty, uncommittedFiles, aheadBehind }; + } catch (err) { + return { + branch: "unknown", + dirty: false, + uncommittedFiles: [], + aheadBehind: { ahead: 0, behind: 0 }, + }; + } +} + +/** + * Get recent commits from a repository. + * @param {string} cwd - Repository path + * @param {number} [n=10] - Number of commits to return + * @returns {{ sha: string, message: string }[]} + */ +export function getRecentCommits(cwd, n = 10) { + try { + const output = git(`git log --oneline -${n}`, cwd); + if (!output) return []; + + return output.split("\n").map((line) => { + const spaceIdx = line.indexOf(" "); + return { + sha: line.slice(0, spaceIdx), + message: line.slice(spaceIdx + 1), + }; + }); + } catch { + return []; + } +} + +/** + * Switch to an existing branch. Refuses if working tree is dirty. + * @param {string} cwd - Repository path + * @param {string} branch - Branch name to switch to + * @returns {{ ok: boolean, error?: string }} + */ +export function switchBranch(cwd, branch) { + try { + const { dirty } = getBranchInfo(cwd); + if (dirty) { + return { ok: false, error: "Working tree is dirty. Commit or stash changes first." }; + } + + git(`git checkout ${branch}`, cwd); + return { ok: true }; + } catch (err) { + return { ok: false, error: err.message }; + } +} + +/** + * Create a new branch, optionally from a base. + * @param {string} cwd - Repository path + * @param {string} name - New branch name + * @param {string} [from] - Optional base branch/commit + * @returns {{ ok: boolean, error?: string }} + */ +export function createBranch(cwd, name, from) { + try { + const cmd = from + ? `git checkout -b ${name} ${from}` + : `git checkout -b ${name}`; + git(cmd, cwd); + return { ok: true }; + } catch (err) { + return { ok: false, error: err.message }; + } +} + +/** + * List all local branches. + * @param {string} cwd - Repository path + * @returns {string[]} Array of branch names + */ +export function listBranches(cwd) { + try { + const output = git("git branch --list", cwd); + if (!output) return []; + + return output + .split("\n") + .map((line) => line.replace(/^\*?\s+/, "").trim()) + .filter(Boolean); + } catch { + return []; + } +} + +/** + * Get a diff stat summary between two refs. + * @param {string} cwd - Repository path + * @param {string} base - Base ref (branch, sha) + * @param {string} head - Head ref + * @returns {string} Diff stat output + */ +export function diffSummary(cwd, base, head) { + try { + return git(`git diff --stat ${base}..${head}`, cwd); + } catch (err) { + return err.message; + } +} + +/** + * Fetch from remote. + * @param {string} cwd + * @param {string} [remote="origin"] + */ +export function fetchRemote(cwd, remote = "origin") { + try { + execSync(`git fetch ${remote}`, { cwd, encoding: "utf-8", timeout: 30000 }); + return { success: true, remote }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** + * Pull branch from remote. Refuses if working tree is dirty. + */ +export function pullBranch(cwd, branch, remote = "origin") { + const info = getBranchInfo(cwd); + if (info.dirty) { + return { success: false, error: "Working tree is dirty. Commit or stash changes first." }; + } + try { + const output = execSync(`git pull ${remote} ${branch || ""}`.trim(), { cwd, encoding: "utf-8", timeout: 60000 }); + return { success: true, output: output.trim() }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** + * Push branch to remote. + */ +export function pushBranch(cwd, branch, remote = "origin") { + try { + const branchArg = branch || ""; + const output = execSync(`git push ${remote} ${branchArg}`.trim(), { cwd, encoding: "utf-8", timeout: 60000 }); + return { success: true, output: output.trim() }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** + * List remotes. + */ +export function getRemotes(cwd) { + try { + const output = execSync("git remote -v", { cwd, encoding: "utf-8", timeout: 5000 }); + const remotes = []; + for (const line of output.trim().split("\n")) { + const [name, url, type] = line.split(/\s+/); + if (name && url) remotes.push({ name, url, type: type?.replace(/[()]/g, "") }); + } + return remotes; + } catch { + return []; + } +} + +/** + * Stash changes. + */ +export function stashChanges(cwd, message = "brane-auto-stash") { + try { + const output = execSync(`git stash push -m "${message}"`, { cwd, encoding: "utf-8", timeout: 10000 }); + return { success: true, output: output.trim() }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** + * Pop stash. + */ +export function popStash(cwd) { + try { + const output = execSync("git stash pop", { cwd, encoding: "utf-8", timeout: 10000 }); + return { success: true, output: output.trim() }; + } catch (err) { + return { success: false, error: err.message }; + } +} diff --git a/brane/core/knowledge-pipeline.js b/brane/core/knowledge-pipeline.js new file mode 100644 index 0000000..bd3646a --- /dev/null +++ b/brane/core/knowledge-pipeline.js @@ -0,0 +1,108 @@ +import { getKnowledge, addKnowledge } from "./knowledge-store.js"; +import { createTask } from "./task-manager.js"; +import { publish } from "./message-bus.js"; +import { getTasks } from "./task-manager.js"; + +/** + * Get knowledge entries that haven't been analyzed yet. + */ +export async function getUnprocessedKnowledge() { + const all = await getKnowledge(); + // Filter entries that don't have an 'analyzed' field or it's false + return all.filter(entry => !entry.analyzed); +} + +/** + * Create an analysis task for a knowledge entry. + * The orchestrator or an available agent will pick this up. + */ +export async function createAnalysisTask(knowledgeId, knowledgeTitle) { + const task = await createTask({ + type: "knowledge-analysis", + title: `Analyze: ${knowledgeTitle}`, + description: `Analyze knowledge entry ${knowledgeId} to determine if it should be promoted to a skill or instruction, and which agents would benefit.`, + priority: "p2", + input: { knowledgeId }, + createdBy: "system", + }); + + // Notify on knowledge channel + await publish("knowledge", { + from: "system", + type: "knowledge", + payload: { + action: "analysis-queued", + knowledgeId, + taskId: task.id, + title: knowledgeTitle, + }, + }); + + return task; +} + +/** + * Process analysis results and auto-promote if appropriate. + * Called when a knowledge-analysis task completes. + */ +export async function processAnalysisResult(knowledgeId, analysis) { + // analysis shape: { action: "promote-to-skill"|"promote-to-instruction"|"archive", + // skillName?, projectPath?, confidence?, tags?, summary? } + + if (!analysis || !analysis.action) return { status: "no-action" }; + + // Notify about the analysis result + await publish("knowledge", { + from: "system", + type: "knowledge", + payload: { + action: "analysis-complete", + knowledgeId, + result: analysis, + }, + }); + + return { status: "processed", action: analysis.action, knowledgeId }; +} + +/** + * Queue analysis tasks for all unprocessed knowledge entries. + */ +export async function processNewKnowledge() { + const unprocessed = await getUnprocessedKnowledge(); + const tasks = []; + + for (const entry of unprocessed) { + // Check if there's already a pending analysis task for this entry + const existing = await getTasks({ type: "knowledge-analysis" }); + const alreadyQueued = existing.some(t => + t.input?.knowledgeId === entry.id && + (t.status === "queued" || t.status === "assigned" || t.status === "running") + ); + + if (!alreadyQueued) { + const task = await createAnalysisTask(entry.id, entry.title); + tasks.push(task); + } + } + + return { created: tasks.length, entries: unprocessed.length }; +} + +/** + * Notify agents about new or promoted knowledge. + */ +export async function notifyAgents(knowledgeId, agentIds, message) { + for (const agentId of agentIds) { + await publish("knowledge-updates", { + from: "system", + to: agentId, + type: "knowledge", + payload: { + action: "new-knowledge", + knowledgeId, + message, + }, + }); + } +} diff --git a/brane/core/knowledge-store.js b/brane/core/knowledge-store.js new file mode 100644 index 0000000..e736ea5 --- /dev/null +++ b/brane/core/knowledge-store.js @@ -0,0 +1,347 @@ +import { readFile, writeFile, readdir, mkdir } from "fs/promises"; +import { join, basename } from "path"; +import { homedir } from "os"; +import { generateId } from "./types.js"; + +const BRANE_DIR = join(homedir(), ".brane"); +const KNOWLEDGE_DIR = join(BRANE_DIR, "knowledge"); +const INDEX_PATH = join(KNOWLEDGE_DIR, "index.json"); + +/** + * Load the knowledge index. + * @returns {Promise} + */ +async function loadIndex() { + try { + const raw = await readFile(INDEX_PATH, "utf-8"); + return JSON.parse(raw); + } catch { + return { entries: [] }; + } +} + +/** + * Save the knowledge index. + * @param {Object} index + */ +async function saveIndex(index) { + await mkdir(KNOWLEDGE_DIR, { recursive: true }); + await writeFile(INDEX_PATH, JSON.stringify(index, null, 2), "utf-8"); +} + +/** + * Serialize frontmatter object to YAML block. + * @param {Object} fm + * @returns {string} + */ +function serializeFrontmatter(fm) { + const lines = ["---"]; + for (const [key, value] of Object.entries(fm)) { + if (value === null || value === undefined) { + lines.push(`${key}: null`); + } else if (Array.isArray(value)) { + lines.push(`${key}: [${value.join(", ")}]`); + } else { + lines.push(`${key}: ${value}`); + } + } + lines.push("---"); + return lines.join("\n"); +} + +/** + * Parse YAML frontmatter from a markdown string. + * @param {string} content + * @returns {{ frontmatter: Object, body: string }} + */ +function parseFrontmatter(content) { + const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!match) return { frontmatter: {}, body: content }; + + const fm = {}; + const fmLines = match[1].split("\n"); + for (const line of fmLines) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + let value = line.slice(colonIdx + 1).trim(); + + // Parse arrays + if (value.startsWith("[") && value.endsWith("]")) { + value = value + .slice(1, -1) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (value === "null") { + value = null; + } else if (value === "true") { + value = true; + } else if (value === "false") { + value = false; + } else if (!isNaN(Number(value)) && value !== "") { + value = Number(value); + } + + fm[key] = value; + } + + return { frontmatter: fm, body: match[2].trim() }; +} + +/** + * Add a new knowledge entry. + * Writes a markdown file with YAML frontmatter and updates the index. + * @param {Object} data - Knowledge data: { type, title, content, source, sourceTaskId, tags, project } + * @returns {Promise} The created knowledge entry metadata + */ +export async function addKnowledge(data) { + const now = new Date().toISOString(); + const id = generateId(); + + const frontmatter = { + id, + type: data.type || "note", + title: data.title || "Untitled", + source: data.source || "manual", + sourceTaskId: data.sourceTaskId || null, + tags: data.tags || [], + project: data.project || null, + promotedTo: null, + appliedCount: 0, + createdAt: now, + updatedAt: now, + }; + + const content = data.content || ""; + const fileContent = `${serializeFrontmatter(frontmatter)}\n\n# ${frontmatter.title}\n\n${content}\n`; + + await mkdir(KNOWLEDGE_DIR, { recursive: true }); + await writeFile(join(KNOWLEDGE_DIR, `${id}.md`), fileContent, "utf-8"); + + // Update index + const index = await loadIndex(); + index.entries.push({ + id, + type: frontmatter.type, + title: frontmatter.title, + source: frontmatter.source, + sourceTaskId: frontmatter.sourceTaskId, + tags: frontmatter.tags, + project: frontmatter.project, + createdAt: now, + }); + await saveIndex(index); + + return frontmatter; +} + +/** + * Get knowledge entries from the index, optionally filtered. + * @param {Object} [filters] - { type, tags, project, source } + * @returns {Promise} + */ +export async function getKnowledge(filters = {}) { + const index = await loadIndex(); + let entries = index.entries || []; + + if (filters.type) { + entries = entries.filter((e) => e.type === filters.type); + } + if (filters.source) { + entries = entries.filter((e) => e.source === filters.source); + } + if (filters.project) { + entries = entries.filter((e) => e.project === filters.project); + } + if (filters.tags && filters.tags.length > 0) { + entries = entries.filter((e) => + filters.tags.some((t) => (e.tags || []).includes(t)) + ); + } + + return entries; +} + +/** + * Get a single knowledge entry by ID, including full content. + * @param {string} id + * @returns {Promise} { frontmatter, body } or null + */ +export async function getKnowledgeById(id) { + try { + const raw = await readFile(join(KNOWLEDGE_DIR, `${id}.md`), "utf-8"); + const { frontmatter, body } = parseFrontmatter(raw); + return { ...frontmatter, content: body }; + } catch { + return null; + } +} + +/** + * Search knowledge by text query across title and tags. + * @param {string} query - Search string + * @returns {Promise} Matching index entries + */ +export async function searchKnowledge(query) { + const index = await loadIndex(); + const q = query.toLowerCase(); + + return (index.entries || []).filter((entry) => { + const titleMatch = (entry.title || "").toLowerCase().includes(q); + const tagMatch = (entry.tags || []).some((t) => + t.toLowerCase().includes(q) + ); + return titleMatch || tagMatch; + }); +} + +/** + * Update a knowledge entry's frontmatter fields. + * @param {string} id + * @param {Object} patch - Fields to update in frontmatter + * @returns {Promise} + */ +export async function updateKnowledge(id, patch) { + const filePath = join(KNOWLEDGE_DIR, `${id}.md`); + let raw; + try { + raw = await readFile(filePath, "utf-8"); + } catch { + return null; + } + + const { frontmatter, body } = parseFrontmatter(raw); + const updated = { ...frontmatter, ...patch, updatedAt: new Date().toISOString() }; + + const fileContent = `${serializeFrontmatter(updated)}\n\n${body}\n`; + await writeFile(filePath, fileContent, "utf-8"); + + // Update index entry too + const index = await loadIndex(); + const idx = (index.entries || []).findIndex((e) => e.id === id); + if (idx >= 0) { + index.entries[idx] = { + ...index.entries[idx], + ...patch, + }; + await saveIndex(index); + } + + return updated; +} + +/** + * Promote a knowledge entry to a Claude skill. + * Writes to ~/.claude/skills/{skillName}/SKILL.md + * @param {string} id - Knowledge entry ID + * @param {string} skillName - Name for the skill + * @returns {Promise} Path to the created SKILL.md + */ +export async function promoteToSkill(id, skillName) { + const entry = await getKnowledgeById(id); + if (!entry) throw new Error(`Knowledge entry ${id} not found`); + + const skillDir = join(homedir(), ".claude", "skills", skillName); + await mkdir(skillDir, { recursive: true }); + + const skillFrontmatter = serializeFrontmatter({ + name: skillName, + version: "1.0.0", + description: entry.title || "Skill promoted from knowledge base", + }); + + const skillContent = `${skillFrontmatter}\n\n${entry.content}\n`; + const skillPath = join(skillDir, "SKILL.md"); + await writeFile(skillPath, skillContent, "utf-8"); + + // Mark as promoted in knowledge + await updateKnowledge(id, { promotedTo: `skill:${skillName}` }); + + return skillPath; +} + +/** + * Promote a knowledge entry to a project instruction. + * Appends content to {projectPath}/CLAUDE.md + * @param {string} id - Knowledge entry ID + * @param {string} projectPath - Absolute path to the project directory + * @returns {Promise} Path to the updated CLAUDE.md + */ +export async function promoteToInstruction(id, projectPath) { + const entry = await getKnowledgeById(id); + if (!entry) throw new Error(`Knowledge entry ${id} not found`); + + const claudeMdPath = join(projectPath, "CLAUDE.md"); + + let existing = ""; + try { + existing = await readFile(claudeMdPath, "utf-8"); + } catch { + // File doesn't exist yet + } + + const separator = existing ? "\n\n---\n\n" : ""; + const appendContent = `${separator}## ${entry.title || "Instruction"}\n\n${entry.content}\n`; + + await writeFile(claudeMdPath, existing + appendContent, "utf-8"); + + // Mark as promoted + await updateKnowledge(id, { promotedTo: `instruction:${projectPath}` }); + + return claudeMdPath; +} + +/** + * Sync knowledge from scraped output directory. + * Reads output/index.json and creates knowledge entries for any instruction + * not already in the knowledge store. + * @param {string} outputDir - Path to the scraper output directory + * @returns {Promise} Newly created knowledge entries + */ +export async function syncFromScraped(outputDir) { + let outputIndex; + try { + const raw = await readFile(join(outputDir, "index.json"), "utf-8"); + outputIndex = JSON.parse(raw); + } catch { + return []; + } + + const existingKnowledge = await getKnowledge({ source: "scraped" }); + const existingSourceIds = new Set( + existingKnowledge.map((e) => e.sourceTaskId || e.id) + ); + + const created = []; + const instructions = outputIndex.instructions || []; + + for (const instruction of instructions) { + if (existingSourceIds.has(instruction.id)) continue; + + // Try to read the instruction markdown + let content = ""; + try { + content = await readFile( + join(outputDir, instruction.id, "instruction.md"), + "utf-8" + ); + } catch { + content = instruction.title || ""; + } + + const entry = await addKnowledge({ + type: "instruction", + title: instruction.title || instruction.id, + content, + source: "scraped", + sourceTaskId: instruction.id, + tags: [instruction.source || "unknown"], + project: instruction.project || null, + }); + + created.push(entry); + } + + return created; +} diff --git a/brane/core/markdown-generator.js b/brane/core/markdown-generator.js new file mode 100644 index 0000000..5183eef --- /dev/null +++ b/brane/core/markdown-generator.js @@ -0,0 +1,193 @@ +import { CATEGORY_LABELS } from "./types.js"; + +const CATEGORY_ICONS = { + approval: "[OK]", + revision: "[CHANGE]", + question: "[?]", + blocker: "[!!]", + context: "[i]", +}; + +function cleanSlackMarkdown(text) { + if (!text) return ""; + return text + .replace(/(?]+)\|([^>]+)>/g, "[$2]($1)") + .replace(/<(https?:\/\/[^>]+)>/g, "$1") + .replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1") + .replace(/```([^`]+)```/g, "\n```\n$1\n```\n"); +} + +function formatTimestamp(ts) { + try { + return new Date(ts).toISOString().replace("T", " ").slice(0, 16); + } catch { + return ts; + } +} + +/** + * Generate instruction markdown from a unified InstructionSet. + * @param {import('./types.js').InstructionSet} set + * @returns {string} + */ +export function generateInstructionMd(set) { + const lines = []; + const { root, replies, stats } = set; + + // Frontmatter + lines.push("---"); + lines.push(`source: ${set.source}`); + lines.push(`source_url: ${set.sourceUrl}`); + lines.push(`scraped_at: ${set.scrapedAt}`); + if (set.project) lines.push(`project: ${set.project}`); + lines.push(`total_entries: ${stats.totalEntries}`); + lines.push(`images: ${stats.imageCount}`); + lines.push("---"); + lines.push(""); + + // Title + lines.push(`# ${set.title}`); + lines.push(""); + + // Context + lines.push("## Context"); + lines.push(""); + lines.push(`**Posted by:** ${root.author} — ${formatTimestamp(root.timestamp)}`); + lines.push(`**Source:** ${set.source} — [Original](${set.sourceUrl})`); + lines.push(""); + lines.push(cleanSlackMarkdown(root.text)); + lines.push(""); + + if (root.attachments.length > 0) { + lines.push("### Reference Assets"); + lines.push(""); + for (const att of root.attachments) { + const path = att.localPath || `images/${att.name}`; + if (att.type === "image") { + lines.push(`![${att.title}](./${path})`); + lines.push(`> ${att.title}`); + } else { + lines.push(`- [${att.title}](./${path})`); + } + } + lines.push(""); + } + + // Blockers + const blockers = replies.filter((r) => r.category === "blocker"); + if (blockers.length > 0) { + lines.push("## Blockers"); + lines.push(""); + for (const b of blockers) { + lines.push(`> **${CATEGORY_ICONS.blocker} ${b.author}:** ${cleanSlackMarkdown(b.text)}`); + renderAttachments(lines, b.attachments); + } + lines.push(""); + } + + // Revisions + const revisions = replies.filter((r) => r.category === "revision"); + if (revisions.length > 0) { + lines.push("## Required Changes"); + lines.push(""); + for (let i = 0; i < revisions.length; i++) { + lines.push(`${i + 1}. **${revisions[i].author}:** ${cleanSlackMarkdown(revisions[i].text)}`); + renderAttachments(lines, revisions[i].attachments); + } + lines.push(""); + } + + // Questions + const questions = replies.filter((r) => r.category === "question"); + if (questions.length > 0) { + lines.push("## Open Questions"); + lines.push(""); + for (const q of questions) { + lines.push(`- **${q.author}:** ${cleanSlackMarkdown(q.text)}`); + } + lines.push(""); + } + + // Approvals + const approvals = replies.filter((r) => r.category === "approval"); + if (approvals.length > 0) { + lines.push("## Approvals"); + lines.push(""); + for (const a of approvals) { + lines.push(`- **${a.author}:** ${cleanSlackMarkdown(a.text) || "Approved"}`); + } + lines.push(""); + } + + // Full thread + lines.push("## Full Thread"); + lines.push(""); + lines.push("
"); + lines.push(`Expand full conversation (${stats.totalEntries} entries)`); + lines.push(""); + for (const entry of set.allEntries) { + const tag = CATEGORY_ICONS[entry.category] || ""; + lines.push(`**${entry.author}** ${tag} — _${formatTimestamp(entry.timestamp)}_`); + lines.push(cleanSlackMarkdown(entry.text)); + renderAttachments(lines, entry.attachments); + lines.push(""); + lines.push("---"); + lines.push(""); + } + lines.push("
"); + lines.push(""); + + // Agent Instructions + lines.push("## Agent Instructions"); + lines.push(""); + lines.push("Use this section as your primary directive when working on this feedback."); + lines.push(""); + + if (blockers.length > 0) { + lines.push("### Must Fix (Blockers)"); + lines.push(""); + for (const b of blockers) { + lines.push(`- [ ] ${cleanSlackMarkdown(b.text)} _(${b.author})_`); + } + lines.push(""); + } + + if (revisions.length > 0) { + lines.push("### Changes Requested"); + lines.push(""); + for (const r of revisions) { + lines.push(`- [ ] ${cleanSlackMarkdown(r.text)} _(${r.author})_`); + } + lines.push(""); + } + + if (questions.length > 0) { + lines.push("### Clarify Before Proceeding"); + lines.push(""); + for (const q of questions) { + lines.push(`- [ ] ${cleanSlackMarkdown(q.text)} _(${q.author})_`); + } + lines.push(""); + } + + if (stats.imageCount > 0) { + lines.push("### Reference Images"); + lines.push(""); + lines.push("Review all images in the `./images/` folder — they contain visual context for the changes above."); + lines.push(""); + } + + return lines.join("\n"); +} + +function renderAttachments(lines, attachments) { + for (const att of attachments || []) { + const path = att.localPath || `images/${att.name}`; + if (att.type === "image") { + lines.push(` ![${att.title}](./${path})`); + } else { + lines.push(` - Attachment: [${att.title}](./${path})`); + } + } +} diff --git a/brane/core/message-bus.js b/brane/core/message-bus.js new file mode 100644 index 0000000..8246bc9 --- /dev/null +++ b/brane/core/message-bus.js @@ -0,0 +1,108 @@ +import { readFile, appendFile, readdir, mkdir } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; +import { EventEmitter } from "events"; +import { generateId } from "./types.js"; + +const MESSAGES_DIR = join(homedir(), ".brane", "messages"); + +/** + * Singleton EventEmitter for real-time message subscriptions (e.g. SSE). + * Events are emitted as `message:{channel}` with the message object. + */ +export const bus = new EventEmitter(); +bus.setMaxListeners(50); + +/** + * Publish a message to a channel. + * Appends a JSON line to ~/.brane/messages/{channel}.jsonl and emits on the bus. + * @param {string} channel - Channel name (e.g. "tasks", "agents", "system") + * @param {Object} message - Message payload to publish + * @returns {Promise} The complete message object with id and timestamp + */ +export async function publish(channel, message) { + const now = new Date().toISOString(); + const msg = { + id: generateId(), + from: message.from || "system", + to: message.to || null, + type: message.type || "command", + payload: message.payload || {}, + replyTo: message.replyTo || null, + timestamp: now, + ...message, + }; + // Ensure auto-fields override any user-provided values + msg.id = msg.id || generateId(); + msg.timestamp = now; + + await mkdir(MESSAGES_DIR, { recursive: true }); + + const filePath = join(MESSAGES_DIR, `${channel}.jsonl`); + const line = JSON.stringify(msg) + "\n"; + await appendFile(filePath, line, "utf-8"); + + // Emit for real-time listeners + bus.emit(`message:${channel}`, msg); + bus.emit("message", { channel, message: msg }); + + return msg; +} + +/** + * Read messages from a channel. + * @param {string} channel - Channel name + * @param {Object} [options] + * @param {string} [options.since] - ISO timestamp, only return messages after this time + * @param {number} [options.limit] - Maximum number of messages to return (from the end) + * @returns {Promise} + */ +export async function getMessages(channel, { since, limit } = {}) { + const filePath = join(MESSAGES_DIR, `${channel}.jsonl`); + + let content; + try { + content = await readFile(filePath, "utf-8"); + } catch { + return []; + } + + const lines = content.trim().split("\n").filter(Boolean); + let messages = []; + + for (const line of lines) { + try { + messages.push(JSON.parse(line)); + } catch { + // Skip malformed lines + } + } + + // Filter by since + if (since) { + const sinceTime = new Date(since).getTime(); + messages = messages.filter((m) => new Date(m.timestamp).getTime() > sinceTime); + } + + // Limit to last N + if (limit && limit > 0) { + messages = messages.slice(-limit); + } + + return messages; +} + +/** + * List all available message channels. + * @returns {Promise} Array of channel names + */ +export async function getChannels() { + try { + const files = await readdir(MESSAGES_DIR); + return files + .filter((f) => f.endsWith(".jsonl")) + .map((f) => f.replace(/\.jsonl$/, "")); + } catch { + return []; + } +} diff --git a/brane/core/obsidian-bridge.js b/brane/core/obsidian-bridge.js new file mode 100644 index 0000000..d53766e --- /dev/null +++ b/brane/core/obsidian-bridge.js @@ -0,0 +1,324 @@ +import { readFile, writeFile, mkdir, readdir } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; + +const VAULT_PATH = join(homedir(), "Documents", "Vault"); +const PROJECTS = join(VAULT_PATH, "01-Projects"); +const RESOURCES = join(VAULT_PATH, "03-Resources"); +const AGENT_LIB = join(RESOURCES, "Agent-Library"); +const PATTERNS = join(RESOURCES, "Patterns"); +const SESSIONS = join(VAULT_PATH, "Dev", "Sessions"); +const TASKS_DIR = join(PROJECTS, "brane-tasks"); + +/** + * Slugify a string for filenames. + */ +function slugify(str) { + return (str || "unknown") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60); +} + +/** + * Format a date for frontmatter. + */ +function formatDate(ts) { + const d = ts ? new Date(ts) : new Date(); + return d.toISOString().split("T")[0]; +} + +/** + * Sync an agent to the vault (update brane-agents.md index). + */ +export async function syncAgent(agent) { + if (!agent || !agent.id) return; + await mkdir(AGENT_LIB, { recursive: true }); + + const indexPath = join(AGENT_LIB, "brane-agents.md"); + let content; + + try { + content = await readFile(indexPath, "utf-8"); + } catch { + // Create new index + content = `--- +date: ${formatDate()} +tags: [resource, ai, agents, catalog] +type: catalog +status: active +last_edited_by: brane +--- + +# Brane Agents + +Live index of agents registered with the Brane control plane. Auto-updated. + +| Name | Status | Branch | CWD | Last Seen | +|------|--------|--------|-----|-----------| +`; + } + + const name = agent.name || agent.slug || agent.id.slice(0, 8); + const branch = agent.branch || agent.gitBranch || "—"; + const cwd = agent.cwd ? agent.cwd.split("/").slice(-2).join("/") : "—"; + const lastSeen = agent.lastSeen ? formatDate(agent.lastSeen) : "—"; + const row = `| ${name} | ${agent.status || "unknown"} | ${branch} | ${cwd} | ${lastSeen} |`; + + // Check if agent already in table (by id in a comment or name match) + const marker = ``; + if (content.includes(marker)) { + // Replace existing row + const regex = new RegExp(`${marker}.*\n`, "g"); + content = content.replace(regex, `${marker} ${row}\n`); + } else { + // Append new row + content = content.trimEnd() + `\n${marker} ${row}\n`; + } + + // Update date + content = content.replace(/date: \d{4}-\d{2}-\d{2}/, `date: ${formatDate()}`); + + await writeFile(indexPath, content, "utf-8"); +} + +/** + * Sync a task to the vault (create/update task page in brane-tasks/). + */ +export async function syncTask(task) { + if (!task || !task.id) return; + await mkdir(TASKS_DIR, { recursive: true }); + + const slug = slugify(task.title || task.type || task.id); + const filePath = join(TASKS_DIR, `${slug}-${task.id.slice(0, 6)}.md`); + + const agentLink = task.agentId ? `[[brane-agents|${task.agentId.slice(0, 8)}]]` : "unassigned"; + const branchInfo = task.gitBranch ? `\`${task.gitBranch}\`` : "—"; + + const content = `--- +date: ${formatDate(task.createdAt)} +tags: [project, brane, task, ${task.type || "custom"}] +type: task +status: ${task.status || "queued"} +brane_id: ${task.id} +last_edited_by: brane +--- + +# ${task.title || "Untitled Task"} + +**Type:** ${task.type || "custom"} +**Status:** ${task.status || "queued"} +**Priority:** ${task.priority || "p2"} +**Agent:** ${agentLink} +**Branch:** ${branchInfo} +**Project:** [[brane]] + +## Description + +${task.description || "No description provided."} + +## Activity + +${(task.activity || []).map(a => `- **${a.label}** — ${a.message} (${new Date(a.timestamp).toLocaleString()})`).join("\n") || "No activity yet."} + +${task.output ? `## Output\n\n\`\`\`json\n${JSON.stringify(task.output, null, 2)}\n\`\`\`` : ""} +`; + + await writeFile(filePath, content, "utf-8"); +} + +/** + * Sync a knowledge entry to the vault (write to Patterns/). + */ +export async function syncKnowledge(entry) { + if (!entry || !entry.id) return; + await mkdir(PATTERNS, { recursive: true }); + + const slug = slugify(entry.title || entry.id); + const filePath = join(PATTERNS, `${slug}.md`); + + const tags = (entry.tags || []).map(t => `#${t}`).join(" "); + const projectLink = entry.project ? `[[${entry.project}]]` : "[[brane]]"; + + const content = `--- +date: ${formatDate(entry.createdAt)} +tags: [resource, pattern, ${entry.type || "knowledge"}, ${(entry.tags || []).join(", ")}] +type: pattern +source: ${entry.source || "scraped"} +brane_id: ${entry.id} +last_edited_by: brane +--- + +# ${entry.title || "Untitled Knowledge"} + +**Source:** ${entry.source || "unknown"} +**Project:** ${projectLink} +**Tags:** ${tags || "none"} + +${entry.content || entry.description || "No content available."} + +## Related + +- [[brane]] — source control plane +${entry.promotedTo ? `- Promoted to: \`${entry.promotedTo}\`` : ""} +`; + + await writeFile(filePath, content, "utf-8"); +} + +/** + * Add a row to the skills catalog when a skill is promoted. + */ +export async function syncSkill(skill) { + if (!skill || !skill.name) return; + + const catalogPath = join(AGENT_LIB, "skills-catalog.md"); + let content; + + try { + content = await readFile(catalogPath, "utf-8"); + } catch { + return; // Don't create if it doesn't exist + } + + const marker = ``; + const row = `| ${skill.name} | — | ${skill.description || "Brane-generated skill"} | \`${skill.path || "~/.claude/skills/" + skill.name}\` |`; + + if (content.includes(marker)) { + const regex = new RegExp(`${marker}.*\n`, "g"); + content = content.replace(regex, `${marker} ${row}\n`); + } else { + // Find the "Standalone Skills" section or append at end + const insertPoint = content.indexOf("## Claude Code — Standalone Skills"); + if (insertPoint !== -1) { + // Find the table after this header + const afterHeader = content.indexOf("|---", insertPoint); + if (afterHeader !== -1) { + const lineEnd = content.indexOf("\n", afterHeader); + content = content.slice(0, lineEnd + 1) + `${marker} ${row}\n` + content.slice(lineEnd + 1); + } + } else { + content = content.trimEnd() + `\n\n## Brane-Generated Skills\n\n| Name | Version | What It Does | Path |\n|------|---------|-------------|------|\n${marker} ${row}\n`; + } + } + + await writeFile(catalogPath, content, "utf-8"); +} + +/** + * Write a daily session summary. + */ +export async function syncSessionSummary(summary) { + await mkdir(SESSIONS, { recursive: true }); + + const date = formatDate(); + const slug = slugify(summary.title || "brane-session"); + const filePath = join(SESSIONS, `${date}-${slug}.md`); + + const content = `--- +date: ${date} +tags: [session, brane, ai] +type: session +last_edited_by: brane +--- + +# ${summary.title || "Brane Session"} — ${date} + +${summary.body || ""} + +## Agents Active + +${(summary.agents || []).map(a => `- ${a.name || a.id} (${a.status})`).join("\n") || "None tracked."} + +## Tasks Completed + +${(summary.completedTasks || []).map(t => `- ${t.title} (${t.type})`).join("\n") || "None."} + +## Related + +- [[brane]] +`; + + await writeFile(filePath, content, "utf-8"); +} + +/** + * Update the main brane project page with live stats. + */ +export async function updateProjectPage(stats) { + const projectPath = join(PROJECTS, "brane.md"); + let content; + + try { + content = await readFile(projectPath, "utf-8"); + } catch { + return; // Don't create if it doesn't exist + } + + // Find or create a Live Stats section + const statsSection = `\n## Live Stats (auto-updated)\n\n- **Active Agents:** ${stats.activeAgents || 0}\n- **Total Agents:** ${stats.totalAgents || 0}\n- **Queued Tasks:** ${stats.queuedTasks || 0}\n- **Running Tasks:** ${stats.runningTasks || 0}\n- **Knowledge Entries:** ${stats.knowledgeCount || 0}\n- **Last Sync:** ${formatDate()}\n`; + + const marker = "## Live Stats (auto-updated)"; + if (content.includes(marker)) { + // Replace existing section (up to next ## or end of file) + const start = content.indexOf(marker); + const nextSection = content.indexOf("\n## ", start + marker.length); + if (nextSection !== -1) { + content = content.slice(0, start) + statsSection.trim() + "\n\n" + content.slice(nextSection + 1); + } else { + content = content.slice(0, start) + statsSection.trim() + "\n"; + } + } else { + // Append before "## Related" if it exists, otherwise at end + const relatedIdx = content.indexOf("## Related"); + if (relatedIdx !== -1) { + content = content.slice(0, relatedIdx) + statsSection + "\n" + content.slice(relatedIdx); + } else { + content = content.trimEnd() + "\n" + statsSection; + } + } + + await writeFile(projectPath, content, "utf-8"); +} + +/** + * Full sync — sync everything to the vault. + * Called on startup and periodically. + */ +export async function fullSync(agents = [], tasks = [], knowledge = []) { + try { + // Sync agents index + for (const agent of agents) { + await syncAgent(agent); + } + + // Sync recent tasks (last 50) + const recentTasks = tasks.slice(-50); + for (const task of recentTasks) { + await syncTask(task); + } + + // Sync knowledge + for (const entry of knowledge) { + await syncKnowledge(entry); + } + + // Update project page stats + const activeAgents = agents.filter(a => a.status === "active").length; + const queuedTasks = tasks.filter(t => t.status === "queued").length; + const runningTasks = tasks.filter(t => t.status === "running").length; + + await updateProjectPage({ + activeAgents, + totalAgents: agents.length, + queuedTasks, + runningTasks, + knowledgeCount: knowledge.length, + }); + + return { synced: true, agents: agents.length, tasks: recentTasks.length, knowledge: knowledge.length }; + } catch (err) { + return { synced: false, error: err.message }; + } +} diff --git a/brane/core/orchestrator.js b/brane/core/orchestrator.js new file mode 100644 index 0000000..be3be4e --- /dev/null +++ b/brane/core/orchestrator.js @@ -0,0 +1,195 @@ +import { spawn } from "child_process"; +import { createWriteStream } from "fs"; +import { readFile, writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; +import { isClaudeInstalled } from "./claude-bridge.js"; + +const BRANE_DIR = join(homedir(), ".brane"); +const ORCH_DIR = join(BRANE_DIR, "orchestrator"); +const STATE_FILE = join(ORCH_DIR, "state.json"); +const LOG_FILE = join(ORCH_DIR, "orchestrator.log"); + +const ORCHESTRATOR_PROMPT = `You are the Brane Master Orchestrator — the central coordinator for all agent operations. + +Your MCP tools give you access to the Brane control plane. Use them to: + +1. MONITOR: Periodically check for queued tasks with brane_get_tasks +2. DISCOVER: Check available agents with brane_get_agents +3. ASSIGN: Match tasks to agents based on their cwd (project directory), capabilities, and current load +4. COMMUNICATE: Read messages from the "orchestrator" channel with brane_get_messages for human instructions +5. RESPOND: Reply on the "orchestrator" channel with brane_publish + +Decision framework for task assignment: +- Match agent cwd to task gitRepo when possible +- Prefer idle agents over active ones +- Consider agent capabilities and the task type +- For tasks tagged with approval:true, propose the assignment via message and wait + +When you receive a message on the orchestrator channel: +- If it's a question, answer it based on current system state +- If it's a command (e.g., "assign task X to agent Y"), execute it +- If it's a strategic decision, provide your analysis and recommendation + +Start by scanning the current state: check agents, tasks, and any pending messages.`; + +/** + * Get orchestrator state from disk. + */ +async function getState() { + try { + const raw = await readFile(STATE_FILE, "utf-8"); + return JSON.parse(raw); + } catch { + return { pid: null, startedAt: null, status: "stopped" }; + } +} + +/** + * Save orchestrator state to disk. + */ +async function saveState(state) { + await mkdir(ORCH_DIR, { recursive: true }); + await writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf-8"); +} + +/** + * Check if a PID is alive. + */ +function isPidAlive(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Spawn the orchestrator agent. + */ +export async function spawnOrchestrator() { + const state = await getState(); + + // Already running? + if (state.pid && isPidAlive(state.pid)) { + return { status: "already-running", pid: state.pid, startedAt: state.startedAt }; + } + + const { installed, path: claudePath } = isClaudeInstalled(); + if (!installed) { + return { status: "error", error: "Claude CLI not found" }; + } + + // Ensure orchestrator directory exists + await mkdir(ORCH_DIR, { recursive: true }); + + // Create MCP config for orchestrator + const mcpConfig = { + mcpServers: { + brane: { + command: "node", + args: [join(process.cwd(), "server/mcp.js")] + } + } + }; + await writeFile(join(ORCH_DIR, ".mcp.json"), JSON.stringify(mcpConfig, null, 2), "utf-8"); + + // Create CLAUDE.md for orchestrator + await writeFile(join(ORCH_DIR, "CLAUDE.md"), `# Brane Orchestrator\n\nYou are the master orchestrator. Use brane_* MCP tools to manage the agent pool and task queue.\n`, "utf-8"); + + try { + const logStream = createWriteStream(LOG_FILE, { flags: "a" }); + + const child = spawn(claudePath, ["-p", ORCHESTRATOR_PROMPT, "--output-format", "json"], { + cwd: ORCH_DIR, + detached: true, + stdio: ["ignore", logStream, logStream], + }); + + child.unref(); + + const newState = { + pid: child.pid, + startedAt: new Date().toISOString(), + status: "running", + }; + await saveState(newState); + + // Monitor for exit + child.on("exit", async (code) => { + const s = await getState(); + s.status = "stopped"; + s.exitCode = code; + s.stoppedAt = new Date().toISOString(); + await saveState(s); + }); + + return { status: "started", pid: child.pid, startedAt: newState.startedAt }; + } catch (err) { + return { status: "error", error: err.message }; + } +} + +/** + * Stop the orchestrator. + */ +export async function stopOrchestrator() { + const state = await getState(); + if (!state.pid || !isPidAlive(state.pid)) { + state.status = "stopped"; + await saveState(state); + return { status: "already-stopped" }; + } + + try { + process.kill(state.pid, "SIGTERM"); + state.status = "stopped"; + state.stoppedAt = new Date().toISOString(); + await saveState(state); + return { status: "stopped", pid: state.pid }; + } catch (err) { + return { status: "error", error: err.message }; + } +} + +/** + * Get orchestrator status. + */ +export async function getOrchestratorStatus() { + const state = await getState(); + + // Verify PID is still alive + if (state.pid && !isPidAlive(state.pid)) { + state.status = "stopped"; + state.stoppedAt = state.stoppedAt || new Date().toISOString(); + await saveState(state); + } + + // Read last few lines of log + let recentLog = ""; + try { + const log = await readFile(LOG_FILE, "utf-8"); + const lines = log.trim().split("\n"); + recentLog = lines.slice(-20).join("\n"); + } catch {} + + return { + ...state, + alive: state.pid ? isPidAlive(state.pid) : false, + recentLog, + }; +} + +/** + * Ensure orchestrator is alive, restart if dead. + */ +export async function ensureOrchestratorAlive() { + const state = await getState(); + if (state.status === "running" && state.pid && !isPidAlive(state.pid)) { + // It died, restart it + return spawnOrchestrator(); + } + return state; +} diff --git a/brane/core/store.js b/brane/core/store.js new file mode 100644 index 0000000..bc06d5d --- /dev/null +++ b/brane/core/store.js @@ -0,0 +1,68 @@ +import { readFile, writeFile, mkdir } from "fs/promises"; +import { join } from "path"; + +/** + * File-based persistence for InstructionSets. + * Maintains an index.json manifest and per-instruction folders. + */ + +const INDEX_FILE = "index.json"; + +export async function loadIndex(outputDir) { + try { + const raw = await readFile(join(outputDir, INDEX_FILE), "utf-8"); + return JSON.parse(raw); + } catch { + return { instructions: [] }; + } +} + +export async function saveIndex(outputDir, index) { + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, INDEX_FILE), JSON.stringify(index, null, 2), "utf-8"); +} + +export async function saveInstruction(instructionSet, outputDir, markdownContent) { + const dir = join(outputDir, instructionSet.id); + await mkdir(dir, { recursive: true }); + await mkdir(join(dir, "images"), { recursive: true }); + + // Write the full instruction JSON + await writeFile(join(dir, "instruction.json"), JSON.stringify(instructionSet, null, 2), "utf-8"); + + // Write the markdown + if (markdownContent) { + const mdPath = join(dir, "instruction.md"); + await writeFile(mdPath, markdownContent, "utf-8"); + instructionSet.markdownPath = `${instructionSet.id}/instruction.md`; + } + + // Update index + const index = await loadIndex(outputDir); + const existing = index.instructions.findIndex((i) => i.id === instructionSet.id); + const entry = { + id: instructionSet.id, + source: instructionSet.source, + sourceUrl: instructionSet.sourceUrl, + project: instructionSet.project, + title: instructionSet.title, + scrapedAt: instructionSet.scrapedAt, + path: instructionSet.id, + stats: instructionSet.stats, + }; + + if (existing >= 0) { + index.instructions[existing] = entry; + } else { + index.instructions.unshift(entry); + } + + await saveIndex(outputDir, index); + + return dir; +} + +export async function loadInstruction(outputDir, id) { + const raw = await readFile(join(outputDir, id, "instruction.json"), "utf-8"); + return JSON.parse(raw); +} diff --git a/brane/core/task-manager.js b/brane/core/task-manager.js new file mode 100644 index 0000000..ead96ee --- /dev/null +++ b/brane/core/task-manager.js @@ -0,0 +1,277 @@ +import { readdir, readFile, writeFile, unlink } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; +import { generateId } from "./types.js"; + +const BRANE_DIR = join(homedir(), ".brane"); +const TASKS_DIR = join(BRANE_DIR, "tasks"); + +/** + * Default task schema with all fields. + * @returns {Object} + */ +function taskDefaults() { + const now = new Date().toISOString(); + return { + id: generateId(), + type: "custom", + title: "", + description: "", + status: "queued", + priority: "p2", + agentId: null, + parentTaskId: null, + gitBranch: null, + gitRepo: null, + commits: [], + input: {}, + output: null, + activity: [], + currentStage: null, + stageIndex: -1, + totalStages: 0, + dependencies: [], + createdBy: "human", + createdByAgentId: null, + createdAt: now, + completedAt: null, + }; +} + +/** + * Create a new task and persist it to disk. + * @param {Object} data - Task fields to set (merged with defaults) + * @returns {Promise} The created task + */ +export async function createTask(data) { + const task = { ...taskDefaults(), ...data }; + // Ensure an id even if data had one + if (!task.id) task.id = generateId(); + + const filePath = join(TASKS_DIR, `${task.id}.json`); + await writeFile(filePath, JSON.stringify(task, null, 2), "utf-8"); + return task; +} + +/** + * Get all tasks, optionally filtered. + * @param {Object} [filters] - Optional filters: { type, status, agentId, gitBranch, gitRepo } + * @returns {Promise} + */ +export async function getTasks(filters = {}) { + let files; + try { + files = await readdir(TASKS_DIR); + } catch { + return []; + } + + const jsonFiles = files.filter((f) => f.endsWith(".json")); + const tasks = []; + + for (const file of jsonFiles) { + try { + const raw = await readFile(join(TASKS_DIR, file), "utf-8"); + const task = JSON.parse(raw); + tasks.push(task); + } catch { + // Skip malformed + } + } + + // Apply filters + return tasks.filter((task) => { + if (filters.type && task.type !== filters.type) return false; + if (filters.status && task.status !== filters.status) return false; + if (filters.agentId && task.agentId !== filters.agentId) return false; + if (filters.gitBranch && task.gitBranch !== filters.gitBranch) return false; + if (filters.gitRepo && task.gitRepo !== filters.gitRepo) return false; + return true; + }); +} + +/** + * Get a single task by ID. + * @param {string} id + * @returns {Promise} + */ +export async function getTask(id) { + try { + const raw = await readFile(join(TASKS_DIR, `${id}.json`), "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Update a task with a partial patch (read-modify-write). + * Handles activity array by appending if patch.activity is an array. + * @param {string} id + * @param {Object} patch + * @returns {Promise} + */ +export async function updateTask(id, patch) { + const task = await getTask(id); + if (!task) return null; + + // Handle activity append: if patch has activity entries, append them + let mergedActivity = task.activity; + if (Array.isArray(patch.activity)) { + mergedActivity = [...task.activity, ...patch.activity]; + } + + const updated = { ...task, ...patch, activity: mergedActivity }; + + // Deep merge input + if (patch.input && task.input) { + updated.input = { ...task.input, ...patch.input }; + } + + // Append commits + if (Array.isArray(patch.commits)) { + updated.commits = [...(task.commits || []), ...patch.commits]; + } + + const filePath = join(TASKS_DIR, `${id}.json`); + await writeFile(filePath, JSON.stringify(updated, null, 2), "utf-8"); + return updated; +} + +/** + * Assign a task to an agent. + * @param {string} id - Task ID + * @param {string} agentId - Agent ID to assign + * @returns {Promise} + */ +export async function assignTask(id, agentId) { + return updateTask(id, { + agentId, + status: "assigned", + activity: [ + { + stage: "assigned", + label: "Task Assigned", + message: `Assigned to agent ${agentId}`, + timestamp: Date.now(), + }, + ], + }); +} + +/** + * Cancel a task. + * @param {string} id + * @returns {Promise} + */ +export async function cancelTask(id) { + return updateTask(id, { + status: "cancelled", + completedAt: new Date().toISOString(), + activity: [ + { + stage: "cancelled", + label: "Cancelled", + message: "Task was cancelled", + timestamp: Date.now(), + }, + ], + }); +} + +/** + * Get all tasks assigned to a specific agent. + * @param {string} agentId + * @returns {Promise} + */ +export async function getTasksByAgent(agentId) { + return getTasks({ agentId }); +} + +/** + * Get all tasks for a specific git branch. + * @param {string} branch + * @returns {Promise} + */ +export async function getTasksByBranch(branch) { + return getTasks({ gitBranch: branch }); +} + +/** + * Start a task (transition from assigned/queued to running). + */ +export async function startTask(id) { + return updateTask(id, { + status: "running", + activity: [{ + stage: "started", + label: "Task Started", + message: "Agent began working on this task", + timestamp: Date.now(), + }], + }); +} + +/** + * Complete a task with output. + */ +export async function completeTask(id, output = {}) { + return updateTask(id, { + status: "complete", + output, + completedAt: new Date().toISOString(), + activity: [{ + stage: "complete", + label: "Complete", + message: "Task completed successfully", + timestamp: Date.now(), + }], + }); +} + +/** + * Fail a task with error details. + */ +export async function failTask(id, error) { + return updateTask(id, { + status: "failed", + output: { error: typeof error === "string" ? error : error.message }, + completedAt: new Date().toISOString(), + activity: [{ + stage: "failed", + label: "Failed", + message: typeof error === "string" ? error : error.message, + timestamp: Date.now(), + }], + }); +} + +/** + * Retry a failed task (reset to queued). + */ +export async function retryTask(id) { + const task = await getTask(id); + if (!task) return null; + const retryCount = (task.retryCount || 0) + 1; + return updateTask(id, { + status: "queued", + retryCount, + output: null, + completedAt: null, + activity: [{ + stage: "retry", + label: "Retrying", + message: `Retry attempt ${retryCount}`, + timestamp: Date.now(), + }], + }); +} + +/** + * Get queued tasks sorted by priority. + */ +export async function getTaskQueue() { + const tasks = await getTasks({ status: "queued" }); + const priorityOrder = { p0: 0, p1: 1, p2: 2, p3: 3 }; + return tasks.sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2)); +} diff --git a/brane/core/types.js b/brane/core/types.js new file mode 100644 index 0000000..45ff82a --- /dev/null +++ b/brane/core/types.js @@ -0,0 +1,101 @@ +/** + * Unified data model for the Brane platform. + * All source adapters produce InstructionSet objects conforming to this shape. + */ + +/** @typedef {'blocker' | 'revision' | 'question' | 'approval' | 'context'} Category */ +/** @typedef {'slack' | 'twitter' | 'figma' | 'url'} SourceType */ +/** @typedef {'pending' | 'processing' | 'complete' | 'error'} JobStatus */ + +/** + * @typedef {Object} Attachment + * @property {'image' | 'file'} type + * @property {string} name + * @property {string} title + * @property {string} mimetype + * @property {string} url + * @property {string} [localPath] + * @property {string} [permalink] + */ + +/** + * @typedef {Object} FeedbackEntry + * @property {string} id + * @property {string} author + * @property {string} authorId + * @property {string} text + * @property {Category} category + * @property {Attachment[]} attachments + * @property {string} timestamp - ISO 8601 + * @property {boolean} isRoot + * @property {Object} meta - Source-specific metadata (reactions, likes, etc.) + */ + +/** + * @typedef {Object} InstructionStats + * @property {number} totalEntries + * @property {number} totalReplies + * @property {Object} categories + * @property {number} imageCount + * @property {number} fileCount + * @property {number} blockerCount + * @property {number} revisionCount + */ + +/** + * @typedef {Object} InstructionSet + * @property {string} id + * @property {SourceType} source + * @property {string} sourceUrl + * @property {string} project + * @property {string} title + * @property {FeedbackEntry} root + * @property {FeedbackEntry[]} replies + * @property {FeedbackEntry[]} allEntries + * @property {InstructionStats} stats + * @property {string} scrapedAt - ISO 8601 + * @property {string} [markdownPath] + */ + +/** + * @typedef {Object} DispatchJob + * @property {string} id + * @property {string} url + * @property {SourceType} detectedSource + * @property {JobStatus} status + * @property {string} project + * @property {InstructionSet|null} result + * @property {string|null} error + * @property {string} createdAt + * @property {string|null} completedAt + */ + +export const CATEGORIES = ["blocker", "revision", "question", "approval", "context"]; +export const SOURCE_TYPES = ["slack", "twitter", "figma", "url"]; + +export const CATEGORY_COLORS = { + blocker: { bg: "#FEE2E2", text: "#991B1B", border: "#FECACA" }, + revision: { bg: "#FEF3C7", text: "#92400E", border: "#FDE68A" }, + question: { bg: "#DBEAFE", text: "#1E40AF", border: "#BFDBFE" }, + approval: { bg: "#D1FAE5", text: "#065F46", border: "#A7F3D0" }, + context: { bg: "#F3F4F6", text: "#374151", border: "#E5E7EB" }, +}; + +export const CATEGORY_LABELS = { + blocker: "Blocker", + revision: "Change Requested", + question: "Question", + approval: "Approved", + context: "Context", +}; + +export const SOURCE_LABELS = { + slack: "Slack", + twitter: "Twitter/X", + figma: "Figma", + url: "URL", +}; + +export function generateId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} diff --git a/brane/index.html b/brane/index.html new file mode 100644 index 0000000..194650a --- /dev/null +++ b/brane/index.html @@ -0,0 +1,15 @@ + + + + + + Brane + + + + + +
+ + + diff --git a/brane/package-lock.json b/brane/package-lock.json new file mode 100644 index 0000000..f772af6 --- /dev/null +++ b/brane/package-lock.json @@ -0,0 +1,4035 @@ +{ + "name": "brane", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "brane", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "@slack/web-api": "^7.9.1", + "commander": "^13.1.0", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "lucide-react": "^0.577.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "bin": { + "brane": "bin/cli.js" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.27", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.1", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.1.tgz", + "integrity": "sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.0.tgz", + "integrity": "sha512-va7zYIt3QHG1x9M/jqXXRPFMoOVlVSSRHC5YH+DzKYsrz5xUKOA3lR4THsu/Zxha9N1jOndbKFKLtr0WOPW1Vw==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.13.5", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001779", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/brane/package.json b/brane/package.json new file mode 100644 index 0000000..a72445a --- /dev/null +++ b/brane/package.json @@ -0,0 +1,39 @@ +{ + "name": "brane", + "private": true, + "version": "1.0.0", + "type": "module", + "bin": { + "brane": "./bin/cli.js" + }, + "scripts": { + "dev": "node server/dev.js", + "dev:client": "vite", + "build": "vite build", + "preview": "vite preview", + "scrape": "node bin/cli.js scrape", + "dispatch": "node bin/cli.js dispatch", + "seed": "node bin/seed.js", + "server": "node server/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "@slack/web-api": "^7.9.1", + "commander": "^13.1.0", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "lucide-react": "^0.577.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.27", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.1", + "vite": "^7.3.1" + } +} diff --git a/brane/server/dev.js b/brane/server/dev.js new file mode 100644 index 0000000..63a60a2 --- /dev/null +++ b/brane/server/dev.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +/** + * Dev orchestrator — runs both the Vite dev server and the API server. + */ + +import { spawn } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +const colors = { + api: "\x1b[36m", // cyan + vite: "\x1b[35m", // magenta + reset: "\x1b[0m", +}; + +function run(label, cmd, args, env = {}) { + const proc = spawn(cmd, args, { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...env }, + }); + + const prefix = `${colors[label]}[${label}]${colors.reset}`; + + proc.stdout.on("data", (data) => { + data.toString().split("\n").filter(Boolean).forEach((line) => { + console.log(`${prefix} ${line}`); + }); + }); + + proc.stderr.on("data", (data) => { + data.toString().split("\n").filter(Boolean).forEach((line) => { + console.error(`${prefix} ${line}`); + }); + }); + + proc.on("exit", (code) => { + console.log(`${prefix} exited with code ${code}`); + }); + + return proc; +} + +console.log("\n 🧠 Brane — starting dev environment...\n"); + +// Start API server +const api = run("api", "node", ["server/index.js"]); + +// Start Vite dev server +const vite = run("vite", "npx", ["vite", "--port", "5180"]); + +// Handle shutdown +process.on("SIGINT", () => { + api.kill(); + vite.kill(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + api.kill(); + vite.kill(); + process.exit(0); +}); diff --git a/brane/server/index.js b/brane/server/index.js new file mode 100644 index 0000000..d5ca109 --- /dev/null +++ b/brane/server/index.js @@ -0,0 +1,771 @@ +import express from "express"; +import { config } from "dotenv"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { mkdir } from "fs/promises"; +import { homedir } from "os"; +import { Dispatcher } from "../core/dispatch.js"; +import { loadIndex, loadInstruction } from "../core/store.js"; +import { getBrowserManager } from "../core/browser/index.js"; +import { scanClaudeSessions, getAgents, getAgent, registerAgent, updateAgent, heartbeat, removeAgent, pauseAgent, resumeAgent, decommissionAgent } from "../core/agent-registry.js"; +import { createTask, getTasks, getTask, updateTask, assignTask, cancelTask, startTask, completeTask, failTask, retryTask, getTaskQueue } from "../core/task-manager.js"; +import { publish, getMessages, getChannels, bus } from "../core/message-bus.js"; +import { addKnowledge, getKnowledge, getKnowledgeById, searchKnowledge, promoteToSkill, promoteToInstruction, syncFromScraped } from "../core/knowledge-store.js"; +import { getBranchInfo, getRecentCommits, switchBranch, listBranches, fetchRemote, pullBranch, pushBranch, getRemotes, stashChanges, popStash } from "../core/git-tracker.js"; +import { spawnAgent, listClaudeProcesses } from "../core/claude-bridge.js"; +import { spawnOrchestrator, stopOrchestrator, getOrchestratorStatus } from "../core/orchestrator.js"; +import { fullSync as obsidianSync } from "../core/obsidian-bridge.js"; +import { processNewKnowledge } from "../core/knowledge-pipeline.js"; + +config(); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUTPUT_DIR = process.env.OUTPUT_DIR || join(__dirname, "..", "output"); +const PORT = process.env.API_PORT || 3210; + +const app = express(); +app.use(express.json()); + +// CORS for dev +app.use((req, res, next) => { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Content-Type"); + res.header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS"); + if (req.method === "OPTIONS") return res.sendStatus(200); + next(); +}); + +// Dispatcher instance (persistent across requests) +const dispatcher = new Dispatcher(OUTPUT_DIR); + +// ─── API Routes ─── + +// GET /api/index — load instruction index +app.get("/api/index", async (req, res) => { + try { + const index = await loadIndex(OUTPUT_DIR); + res.json(index); + } catch (err) { + res.json({ instructions: [] }); + } +}); + +// GET /api/instructions/:id — load full instruction detail +app.get("/api/instructions/:id", async (req, res) => { + try { + const data = await loadInstruction(OUTPUT_DIR, req.params.id); + res.json(data); + } catch (err) { + res.status(404).json({ error: "Instruction not found" }); + } +}); + +// POST /api/scrape — dispatch a single URL +app.post("/api/scrape", async (req, res) => { + const { url, project } = req.body; + if (!url) return res.status(400).json({ error: "url is required" }); + + const jobStub = await dispatcher.createPendingJob(url, project || ""); + res.json({ job: jobStub }); + + dispatcher.dispatch(url, project || "").catch((err) => { + console.error(`Scrape failed for ${url}:`, err.message); + }); +}); + +// POST /api/dispatch — dispatch multiple URLs in parallel +app.post("/api/dispatch", async (req, res) => { + const { urls, project } = req.body; + if (!urls || !Array.isArray(urls) || urls.length === 0) { + return res.status(400).json({ error: "urls array is required" }); + } + + const stubs = await Promise.all(urls.map((url) => dispatcher.createPendingJob(url, project || ""))); + res.json({ jobs: stubs }); + + dispatcher.dispatchBatch(urls, project || "").catch((err) => { + console.error("Batch dispatch error:", err.message); + }); +}); + +// GET /api/jobs — list all dispatch jobs with activity logs +app.get("/api/jobs", async (req, res) => { + const jobs = await dispatcher.getJobs(); + res.json({ jobs }); +}); + +// GET /api/jobs/:id — get single job status +app.get("/api/jobs/:id", async (req, res) => { + const job = await dispatcher.getJob(req.params.id); + if (!job) return res.status(404).json({ error: "Job not found" }); + res.json({ job }); +}); + +// Serve output files statically +app.use("/output", express.static(OUTPUT_DIR)); + +// ─── Agent Routes ─── + +// POST /api/agents/spawn — spawn a new Claude agent (BEFORE /:id) +app.post("/api/agents/spawn", async (req, res) => { + try { + const { prompt, cwd, name, branch } = req.body || {}; + if (!prompt) { + return res.status(400).json({ error: "Missing required field: prompt" }); + } + const result = await spawnAgent({ prompt, cwd, name, branch }); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/agents — list agents +app.get("/api/agents", async (req, res) => { + try { + let agents = await getAgents(); + if (req.query.status) { + agents = agents.filter((a) => a.status === req.query.status); + } + res.json(agents); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/agents/:id — get single agent +app.get("/api/agents/:id", async (req, res) => { + try { + const agent = await getAgent(req.params.id); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + res.json(agent); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/agents — register agent +app.post("/api/agents", async (req, res) => { + try { + const agent = await registerAgent(req.body); + res.json(agent); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// PATCH /api/agents/:id — update agent +app.patch("/api/agents/:id", async (req, res) => { + try { + const agent = await updateAgent(req.params.id, req.body); + res.json(agent); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// DELETE /api/agents/:id — remove agent +app.delete("/api/agents/:id", async (req, res) => { + try { + const result = await removeAgent(req.params.id); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Task Routes ─── + +// GET /api/tasks/branch/:branch — tasks by branch (BEFORE /:id) +app.get("/api/tasks/branch/:branch", async (req, res) => { + try { + const tasks = await getTasks({ gitBranch: req.params.branch }); + res.json(tasks); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/tasks/agent/:agentId — tasks by agent (BEFORE /:id) +app.get("/api/tasks/agent/:agentId", async (req, res) => { + try { + const tasks = await getTasks({ agentId: req.params.agentId }); + res.json(tasks); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/tasks — list tasks with optional filters +app.get("/api/tasks", async (req, res) => { + try { + const tasks = await getTasks(req.query); + res.json(tasks); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/tasks — create task +app.post("/api/tasks", async (req, res) => { + try { + const task = await createTask(req.body); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/tasks/:id — get single task +app.get("/api/tasks/:id", async (req, res) => { + try { + const task = await getTask(req.params.id); + if (!task) return res.status(404).json({ error: "Task not found" }); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// PATCH /api/tasks/:id — update task +app.patch("/api/tasks/:id", async (req, res) => { + try { + const task = await updateTask(req.params.id, req.body); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/tasks/:id/assign — assign task to agent +app.post("/api/tasks/:id/assign", async (req, res) => { + try { + const task = await assignTask(req.params.id, req.body.agentId); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/tasks/:id/start — start a task +app.post("/api/tasks/:id/start", async (req, res) => { + try { + const task = await startTask(req.params.id); + if (!task) return res.status(404).json({ error: "Task not found" }); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/tasks/:id/complete — complete a task +app.post("/api/tasks/:id/complete", async (req, res) => { + try { + const task = await completeTask(req.params.id, req.body.output); + if (!task) return res.status(404).json({ error: "Task not found" }); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/tasks/:id/fail — fail a task +app.post("/api/tasks/:id/fail", async (req, res) => { + try { + const task = await failTask(req.params.id, req.body.error || "Unknown error"); + if (!task) return res.status(404).json({ error: "Task not found" }); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/tasks/:id/retry — retry a failed task +app.post("/api/tasks/:id/retry", async (req, res) => { + try { + const task = await retryTask(req.params.id); + if (!task) return res.status(404).json({ error: "Task not found" }); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/tasks/:id/cancel — cancel a task +app.post("/api/tasks/:id/cancel", async (req, res) => { + try { + const task = await cancelTask(req.params.id); + if (!task) return res.status(404).json({ error: "Task not found" }); + res.json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Agent Lifecycle Routes ─── + +// POST /api/agents/:id/pause — pause an agent +app.post("/api/agents/:id/pause", async (req, res) => { + try { + const agent = await pauseAgent(req.params.id); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + res.json(agent); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/agents/:id/resume — resume a paused agent +app.post("/api/agents/:id/resume", async (req, res) => { + try { + const agent = await resumeAgent(req.params.id); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + res.json(agent); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/agents/:id/decommission — decommission an agent +app.post("/api/agents/:id/decommission", async (req, res) => { + try { + const agent = await decommissionAgent(req.params.id); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + res.json(agent); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/agents/:id/tasks — get tasks for a specific agent +app.get("/api/agents/:id/tasks", async (req, res) => { + try { + const tasks = await getTasks({ agentId: req.params.id }); + res.json(tasks); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Message Routes ─── + +// GET /api/channels — list channels +app.get("/api/channels", async (req, res) => { + try { + const channels = await getChannels(); + res.json(channels); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/messages/:channel/stream — SSE endpoint (BEFORE /:channel GET) +app.get("/api/messages/:channel/stream", (req, res) => { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const handler = (msg) => { + res.write(`data: ${JSON.stringify(msg)}\n\n`); + }; + + bus.on(`message:${req.params.channel}`, handler); + req.on("close", () => { + bus.off(`message:${req.params.channel}`, handler); + }); +}); + +// GET /api/messages/:channel — get messages +app.get("/api/messages/:channel", async (req, res) => { + try { + const messages = await getMessages(req.params.channel, { + since: req.query.since, + limit: req.query.limit, + }); + res.json(messages); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/messages/:channel — publish message +app.post("/api/messages/:channel", async (req, res) => { + try { + const result = await publish(req.params.channel, req.body); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Knowledge Routes ─── + +// GET /api/knowledge/search — search knowledge (BEFORE /:id) +app.get("/api/knowledge/search", async (req, res) => { + try { + const results = await searchKnowledge(req.query.q); + res.json(results); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/knowledge — list knowledge +app.get("/api/knowledge", async (req, res) => { + try { + const knowledge = await getKnowledge(req.query); + res.json(knowledge); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/knowledge — add knowledge +app.post("/api/knowledge", async (req, res) => { + try { + const entry = await addKnowledge(req.body); + res.json(entry); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/knowledge/:id — get knowledge by id +app.get("/api/knowledge/:id", async (req, res) => { + try { + const entry = await getKnowledgeById(req.params.id); + if (!entry) return res.status(404).json({ error: "Knowledge entry not found" }); + res.json(entry); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/knowledge/:id/promote — promote knowledge to skill or instruction +app.post("/api/knowledge/:id/promote", async (req, res) => { + try { + const { target, name, projectPath } = req.body; + let result; + if (target === "skill") { + result = await promoteToSkill(req.params.id, { name, projectPath }); + } else if (target === "instruction") { + result = await promoteToInstruction(req.params.id, { name, projectPath }); + } else { + return res.status(400).json({ error: "target must be 'skill' or 'instruction'" }); + } + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Git Routes ─── + +// GET /api/git/branches — branch info for all agents with unique cwds +app.get("/api/git/branches", async (req, res) => { + try { + const agents = await getAgents(); + const seen = new Set(); + const results = []; + for (const agent of agents) { + if (agent.cwd && !seen.has(agent.cwd)) { + seen.add(agent.cwd); + try { + const info = await getBranchInfo(agent.cwd); + results.push({ agentId: agent.id, cwd: agent.cwd, ...info }); + } catch (e) { + results.push({ agentId: agent.id, cwd: agent.cwd, error: e.message }); + } + } + } + res.json(results); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/git/:agentId/status — git status for an agent +app.get("/api/git/:agentId/status", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const [branchInfo, commits] = await Promise.all([ + getBranchInfo(agent.cwd), + getRecentCommits(agent.cwd), + ]); + res.json({ agentId: agent.id, cwd: agent.cwd, branch: branchInfo, commits }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/git/:agentId/branches — list branches for an agent's repo +app.get("/api/git/:agentId/branches", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const branches = await listBranches(agent.cwd); + res.json({ agentId: agent.id, cwd: agent.cwd, branches }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/git/:agentId/checkout — switch branch for an agent +app.post("/api/git/:agentId/checkout", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const result = await switchBranch(agent.cwd, req.body.branch); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Git write operations +app.post("/api/git/:agentId/fetch", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const result = fetchRemote(agent.cwd, req.body.remote); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/git/:agentId/pull", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const result = pullBranch(agent.cwd, req.body.branch, req.body.remote); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/git/:agentId/push", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const result = pushBranch(agent.cwd, req.body.branch, req.body.remote); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get("/api/git/:agentId/remotes", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const remotes = getRemotes(agent.cwd); + res.json({ agentId: agent.id, cwd: agent.cwd, remotes }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/git/:agentId/stash", async (req, res) => { + try { + const agent = await getAgent(req.params.agentId); + if (!agent) return res.status(404).json({ error: "Agent not found" }); + const result = req.body.pop ? popStash(agent.cwd) : stashChanges(agent.cwd, req.body.message); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Orchestrator Routes ─── + +// POST /api/orchestrator/start — start the master orchestrator +app.post("/api/orchestrator/start", async (req, res) => { + const result = await spawnOrchestrator(); + res.json(result); +}); + +// POST /api/orchestrator/stop — stop the master orchestrator +app.post("/api/orchestrator/stop", async (req, res) => { + const result = await stopOrchestrator(); + res.json(result); +}); + +// GET /api/orchestrator/status — get orchestrator status +app.get("/api/orchestrator/status", async (req, res) => { + const result = await getOrchestratorStatus(); + res.json(result); +}); + +// POST /api/orchestrator/chat — send a message to the orchestrator channel +app.post("/api/orchestrator/chat", async (req, res) => { + const { message } = req.body; + if (!message) return res.status(400).json({ error: "Message required" }); + const msg = await publish("orchestrator", { + from: "human", + type: "command", + payload: { message }, + }); + res.json(msg); +}); + +// ─── Obsidian Sync Routes ─── + +// POST /api/obsidian/sync — full sync to Obsidian vault +app.post("/api/obsidian/sync", async (req, res) => { + try { + const agents = await getAgents(); + const tasks = await getTasks(); + const knowledge = await getKnowledge(); + const result = await obsidianSync(agents, tasks, knowledge); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Knowledge Pipeline Routes ─── + +// POST /api/knowledge/analyze — queue analysis for unprocessed knowledge +app.post("/api/knowledge/analyze", async (req, res) => { + try { + const result = await processNewKnowledge(); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Health & Status ─── + +// Health check — includes browser engine status + agent/task/knowledge stats +app.get("/api/health", async (req, res) => { + try { + const browserManager = getBrowserManager(); + const [allAgents, allTasks, allKnowledge, runningTasks] = await Promise.all([ + getAgents(), + getTasks(), + getKnowledge(), + getTasks({ status: "running" }), + ]); + res.json({ + status: "ok", + name: "brane", + version: "1.1.0", + uptime: process.uptime(), + env: { + slack: !!process.env.SLACK_BOT_TOKEN, + figma: !!process.env.FIGMA_TOKEN, + cloudflare: !!(process.env.CF_API_TOKEN && process.env.CF_ACCOUNT_ID), + lightpanda: !!process.env.LIGHTPANDA_URL, + output: OUTPUT_DIR, + }, + browser: browserManager.getStatus(), + jobs: await (async () => { + const jobs = await dispatcher.getJobs(); + return { + total: jobs.length, + active: jobs.filter((j) => j.status === "processing" || j.status === "pending").length, + complete: jobs.filter((j) => j.status === "complete").length, + errors: jobs.filter((j) => j.status === "error").length, + }; + })(), + agents: { + total: allAgents.length, + active: allAgents.filter((a) => a.status === "active").length, + }, + tasks: { + total: allTasks.length, + running: runningTasks.length, + }, + knowledge: { + total: allKnowledge.length, + }, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Startup ─── +async function start() { + // Ensure ~/.brane/ directories exist + const braneHome = join(homedir(), ".brane"); + await mkdir(braneHome, { recursive: true }); + await mkdir(join(braneHome, "agents"), { recursive: true }); + await mkdir(join(braneHome, "tasks"), { recursive: true }); + await mkdir(join(braneHome, "knowledge"), { recursive: true }); + await mkdir(join(braneHome, "messages"), { recursive: true }); + + // Initialize browser manager (non-blocking, graceful if none available) + const browserManager = getBrowserManager(); + await browserManager.init().catch((err) => { + console.warn(` ⚠ Browser engine init failed: ${err.message}`); + }); + + // Initial scan of Claude sessions + await scanClaudeSessions().catch((err) => { + console.warn(` ⚠ Initial session scan failed: ${err.message}`); + }); + + // Periodic session scanning every 10 seconds + setInterval(scanClaudeSessions, 10000); + + // Sync existing scraped content to knowledge store + await syncFromScraped(OUTPUT_DIR).catch((err) => { + console.warn(` ⚠ Knowledge sync from scraped failed: ${err.message}`); + }); + + // Initial Obsidian vault sync + try { + const agents = await getAgents(); + const tasks = await getTasks(); + const knowledge = await getKnowledge(); + await obsidianSync(agents, tasks, knowledge); + console.log(" āœ“ Obsidian vault synced"); + } catch (err) { + console.warn(` ⚠ Initial Obsidian sync failed: ${err.message}`); + } + + // Periodic Obsidian sync every 60 seconds + setInterval(async () => { + try { + const agents = await getAgents(); + const tasks = await getTasks(); + const knowledge = await getKnowledge(); + await obsidianSync(agents, tasks, knowledge); + } catch (err) { + console.warn(`Obsidian sync error: ${err.message}`); + } + }, 60000); + + // Periodic knowledge pipeline processing every 30 seconds + setInterval(async () => { + try { + await processNewKnowledge(); + } catch (err) { + console.warn(`Knowledge pipeline error: ${err.message}`); + } + }, 30000); + + app.listen(PORT, () => { + console.log(`\n ⚔ Brane API server running on http://localhost:${PORT}`); + console.log(` Output: ${OUTPUT_DIR}`); + console.log(` Slack: ${process.env.SLACK_BOT_TOKEN ? "āœ“ connected" : "āœ— no token"}`); + console.log(` Figma: ${process.env.FIGMA_TOKEN ? "āœ“ connected" : "āœ— no token"}`); + console.log(` Cloudflare: ${process.env.CF_API_TOKEN ? "āœ“ configured" : "āœ— no token"}`); + console.log(` Lightpanda: ${process.env.LIGHTPANDA_URL || "āœ— not configured"}`); + console.log(` Browser: ${browserManager.activeEngine?.name || "none (fetch-only mode)"}`); + console.log(` Agents: scanning every 10s`); + console.log(` Brane home: ${braneHome}\n`); + }); +} + +start(); + +export default app; diff --git a/brane/server/mcp.js b/brane/server/mcp.js new file mode 100644 index 0000000..9a3be3e --- /dev/null +++ b/brane/server/mcp.js @@ -0,0 +1,673 @@ +#!/usr/bin/env node + +/** + * Brane MCP Server + * + * Exposes the Brane agent control plane as an MCP (Model Context Protocol) + * server over stdio, so Claude Code sessions can connect and coordinate. + * + * Run: node server/mcp.js + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import crypto from "node:crypto"; +import os from "node:os"; + +// ── Paths ────────────────────────────────────────────────────────────────────── + +const BRANE_HOME = path.join(os.homedir(), ".brane"); +const DIRS = { + agents: path.join(BRANE_HOME, "agents"), + tasks: path.join(BRANE_HOME, "tasks"), + messages: path.join(BRANE_HOME, "messages"), + knowledge: path.join(BRANE_HOME, "knowledge"), +}; + +// Ensure all directories exist +for (const dir of Object.values(DIRS)) { + fs.mkdirSync(dir, { recursive: true }); +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function uid() { + return crypto.randomUUID().slice(0, 8); +} + +function readJSON(filepath) { + return JSON.parse(fs.readFileSync(filepath, "utf-8")); +} + +function writeJSON(filepath, data) { + fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + "\n"); +} + +function listJSONFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter((f) => f.endsWith(".json")); +} + +function ok(data) { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function err(message) { + return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true }; +} + +// ── Try-wrapper for tool handlers ────────────────────────────────────────────── + +function safe(fn) { + return async (params) => { + try { + return await fn(params); + } catch (e) { + return err(e.message); + } + }; +} + +// ── Core module imports (built in parallel — use dynamic import with fallback) ─ + +let coreModules = {}; + +async function tryImportCore() { + const modules = [ + ["agentRegistry", "../core/agent-registry.js"], + ["taskManager", "../core/task-manager.js"], + ["messageBus", "../core/message-bus.js"], + ["knowledgeStore", "../core/knowledge-store.js"], + ["gitTracker", "../core/git-tracker.js"], + ]; + for (const [key, modPath] of modules) { + try { + coreModules[key] = await import(modPath); + } catch { + // Core module not available yet — we'll use inline implementations + } + } +} + +// ── Inline implementations (used when core modules aren't available yet) ─────── + +function inlineRegisterAgent({ name, cwd, gitBranch, model, capabilities, pid }) { + const id = uid(); + const agent = { + id, + name: name || `agent-${id}`, + cwd: cwd || process.cwd(), + gitBranch: gitBranch || null, + model: model || null, + capabilities: capabilities || [], + pid: pid || null, + status: "active", + registeredAt: new Date().toISOString(), + lastSeen: new Date().toISOString(), + }; + writeJSON(path.join(DIRS.agents, `${id}.json`), agent); + return { id, status: "registered" }; +} + +function inlineHeartbeat({ agentId, gitBranch, status, taskIds }) { + const filepath = path.join(DIRS.agents, `${agentId}.json`); + if (!fs.existsSync(filepath)) throw new Error(`Agent not found: ${agentId}`); + const agent = readJSON(filepath); + agent.lastSeen = new Date().toISOString(); + if (gitBranch !== undefined) agent.gitBranch = gitBranch; + if (status !== undefined) agent.status = status; + if (taskIds !== undefined) agent.taskIds = taskIds; + writeJSON(filepath, agent); + return { ok: true }; +} + +function inlineGetAgents({ status } = {}) { + const agents = listJSONFiles(DIRS.agents).map((f) => readJSON(path.join(DIRS.agents, f))); + const filtered = status ? agents.filter((a) => a.status === status) : agents; + return { agents: filtered }; +} + +function inlineCreateTask({ type, title, description, agentId, gitBranch, priority, input, dependencies }) { + const id = uid(); + const task = { + id, + type: type || "general", + title, + description: description || null, + agentId: agentId || null, + gitBranch: gitBranch || null, + priority: priority || "normal", + status: "queued", + input: input || null, + dependencies: dependencies || [], + output: null, + activity: [], + commits: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + writeJSON(path.join(DIRS.tasks, `${id}.json`), task); + return { id, status: "queued" }; +} + +function inlineGetTasks({ agentId, status, type, gitBranch } = {}) { + let tasks = listJSONFiles(DIRS.tasks).map((f) => readJSON(path.join(DIRS.tasks, f))); + if (agentId) tasks = tasks.filter((t) => t.agentId === agentId); + if (status) tasks = tasks.filter((t) => t.status === status); + if (type) tasks = tasks.filter((t) => t.type === type); + if (gitBranch) tasks = tasks.filter((t) => t.gitBranch === gitBranch); + return { tasks }; +} + +function inlineUpdateTask({ taskId, status, output, activity, commits }) { + const filepath = path.join(DIRS.tasks, `${taskId}.json`); + if (!fs.existsSync(filepath)) throw new Error(`Task not found: ${taskId}`); + const task = readJSON(filepath); + if (status !== undefined) task.status = status; + if (output !== undefined) task.output = output; + if (activity !== undefined) task.activity = [...(task.activity || []), ...([].concat(activity))]; + if (commits !== undefined) task.commits = [...(task.commits || []), ...([].concat(commits))]; + task.updatedAt = new Date().toISOString(); + writeJSON(filepath, task); + return { ok: true }; +} + +function inlinePublish({ channel, type, payload, to }) { + const id = uid(); + const message = { + id, + channel, + type: type || "message", + payload, + to: to || null, + timestamp: new Date().toISOString(), + }; + const filepath = path.join(DIRS.messages, `${channel}.jsonl`); + fs.appendFileSync(filepath, JSON.stringify(message) + "\n"); + return { id, published: true }; +} + +function inlineGetMessages({ channel, since, limit }) { + const filepath = path.join(DIRS.messages, `${channel}.jsonl`); + if (!fs.existsSync(filepath)) return { messages: [] }; + let lines = fs.readFileSync(filepath, "utf-8").trim().split("\n").filter(Boolean); + let messages = lines.map((l) => JSON.parse(l)); + if (since) { + const sinceDate = new Date(since); + messages = messages.filter((m) => new Date(m.timestamp) > sinceDate); + } + if (limit) messages = messages.slice(-limit); + return { messages }; +} + +function inlineAddKnowledge({ type, title, content, tags, project, source }) { + const id = uid(); + const meta = { + id, + type: type || "note", + title, + tags: tags || [], + project: project || null, + source: source || null, + createdAt: new Date().toISOString(), + }; + + // Write the content as markdown + const header = `---\n${Object.entries(meta).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n")}\n---\n\n`; + fs.writeFileSync(path.join(DIRS.knowledge, `${id}.md`), header + content + "\n"); + + // Update index + const indexPath = path.join(DIRS.knowledge, "index.json"); + const index = fs.existsSync(indexPath) ? readJSON(indexPath) : { entries: [] }; + index.entries.push(meta); + writeJSON(indexPath, index); + + return { id, stored: true }; +} + +function inlineGetKnowledge({ query, tags, project, type } = {}) { + const indexPath = path.join(DIRS.knowledge, "index.json"); + if (!fs.existsSync(indexPath)) return { entries: [] }; + let { entries } = readJSON(indexPath); + + if (type) entries = entries.filter((e) => e.type === type); + if (project) entries = entries.filter((e) => e.project === project); + if (tags && tags.length > 0) { + entries = entries.filter((e) => tags.some((t) => (e.tags || []).includes(t))); + } + if (query) { + const q = query.toLowerCase(); + entries = entries.filter((e) => { + if (e.title.toLowerCase().includes(q)) return true; + // Also search file content + const fp = path.join(DIRS.knowledge, `${e.id}.md`); + if (fs.existsSync(fp)) { + return fs.readFileSync(fp, "utf-8").toLowerCase().includes(q); + } + return false; + }); + } + + return { entries }; +} + +function inlineGitStatus({ cwd }) { + const run = (cmd) => execSync(cmd, { cwd, encoding: "utf-8", timeout: 10000 }).trim(); + try { + const branch = run("git rev-parse --abbrev-ref HEAD"); + const statusOutput = run("git status --porcelain"); + const dirty = statusOutput.length > 0; + const uncommittedFiles = dirty + ? statusOutput.split("\n").filter(Boolean).map((l) => l.trim()) + : []; + let recentCommits = []; + try { + recentCommits = run('git log --oneline -10') + .split("\n") + .filter(Boolean) + .map((l) => { + const [hash, ...rest] = l.split(" "); + return { hash, message: rest.join(" ") }; + }); + } catch { + // no commits yet + } + return { branch, dirty, uncommittedFiles, recentCommits }; + } catch (e) { + throw new Error(`Git error in ${cwd}: ${e.message}`); + } +} + +// ── MCP Server Setup ─────────────────────────────────────────────────────────── + +const server = new McpServer({ + name: "brane", + version: "1.0.0", + description: "Brane agent control plane — coordinate Claude Code sessions", +}); + +// ── Tool Definitions ─────────────────────────────────────────────────────────── + +server.tool( + "brane_register", + "Register a new agent with the Brane control plane. Returns the agent's unique ID.", + { + name: z.string().optional().describe("Human-readable agent name"), + cwd: z.string().optional().describe("Working directory of the agent"), + gitBranch: z.string().optional().describe("Current git branch"), + model: z.string().optional().describe("Model being used (e.g. claude-opus-4-20250514)"), + capabilities: z.array(z.string()).optional().describe("List of capabilities"), + pid: z.number().optional().describe("Process ID of the agent"), + }, + safe((params) => { + const fn = coreModules.agentRegistry?.registerAgent || inlineRegisterAgent; + return ok(fn(params)); + }) +); + +server.tool( + "brane_heartbeat", + "Send a heartbeat to indicate this agent is still alive. Call periodically.", + { + agentId: z.string().describe("The agent ID returned from brane_register"), + gitBranch: z.string().optional().describe("Current git branch (if changed)"), + status: z.string().optional().describe("Current status"), + taskIds: z.array(z.string()).optional().describe("Task IDs the agent is working on"), + }, + safe((params) => { + const fn = coreModules.agentRegistry?.heartbeat || inlineHeartbeat; + return ok(fn(params)); + }) +); + +server.tool( + "brane_get_agents", + "List all registered agents. Optionally filter by status.", + { + status: z.string().optional().describe("Filter by status (e.g. 'active', 'idle')"), + }, + safe((params) => { + const fn = coreModules.agentRegistry?.getAgents || inlineGetAgents; + return ok(fn(params)); + }) +); + +server.tool( + "brane_create_task", + "Create a new task for agents to pick up. Returns the task ID.", + { + type: z.string().optional().describe("Task type (e.g. 'code', 'review', 'test')"), + title: z.string().describe("Short title for the task"), + description: z.string().optional().describe("Detailed task description"), + agentId: z.string().optional().describe("Assign to a specific agent"), + gitBranch: z.string().optional().describe("Associated git branch"), + priority: z.enum(["low", "normal", "high", "critical"]).optional().describe("Task priority"), + input: z.any().optional().describe("Arbitrary input data for the task"), + dependencies: z.array(z.string()).optional().describe("Task IDs this depends on"), + }, + safe((params) => { + const fn = coreModules.taskManager?.createTask || inlineCreateTask; + return ok(fn(params)); + }) +); + +server.tool( + "brane_get_tasks", + "Query tasks. Filter by agent, status, type, or git branch.", + { + agentId: z.string().optional().describe("Filter by assigned agent"), + status: z.string().optional().describe("Filter by status (queued, active, done, failed)"), + type: z.string().optional().describe("Filter by task type"), + gitBranch: z.string().optional().describe("Filter by git branch"), + }, + safe((params) => { + const fn = coreModules.taskManager?.getTasks || inlineGetTasks; + return ok(fn(params)); + }) +); + +server.tool( + "brane_update_task", + "Update a task's status, output, activity log, or commits.", + { + taskId: z.string().describe("The task ID to update"), + status: z.string().optional().describe("New status"), + output: z.any().optional().describe("Task output/result"), + activity: z.any().optional().describe("Activity entry or entries to append"), + commits: z.any().optional().describe("Commit hash(es) to append"), + }, + safe((params) => { + const fn = coreModules.taskManager?.updateTask || inlineUpdateTask; + return ok(fn(params)); + }) +); + +server.tool( + "brane_publish", + "Publish a message to a named channel. Other agents can read it.", + { + channel: z.string().describe("Channel name (e.g. 'coordination', 'status')"), + type: z.string().optional().describe("Message type"), + payload: z.any().describe("Message payload (any JSON)"), + to: z.string().optional().describe("Target agent ID (for directed messages)"), + }, + safe((params) => { + const fn = coreModules.messageBus?.publish || inlinePublish; + return ok(fn(params)); + }) +); + +server.tool( + "brane_get_messages", + "Read messages from a channel. Optionally filter by time or limit count.", + { + channel: z.string().describe("Channel name to read from"), + since: z.string().optional().describe("ISO timestamp — only return messages after this time"), + limit: z.number().optional().describe("Maximum number of messages to return"), + }, + safe((params) => { + const fn = coreModules.messageBus?.getMessages || inlineGetMessages; + return ok(fn(params)); + }) +); + +server.tool( + "brane_add_knowledge", + "Store a piece of knowledge (decision, pattern, context) for other agents to find.", + { + type: z.string().optional().describe("Knowledge type (e.g. 'decision', 'pattern', 'context')"), + title: z.string().describe("Title for this knowledge entry"), + content: z.string().describe("The knowledge content (markdown)"), + tags: z.array(z.string()).optional().describe("Tags for searchability"), + project: z.string().optional().describe("Project name"), + source: z.string().optional().describe("Where this knowledge came from"), + }, + safe((params) => { + const fn = coreModules.knowledgeStore?.addKnowledge || inlineAddKnowledge; + return ok(fn(params)); + }) +); + +server.tool( + "brane_get_knowledge", + "Search stored knowledge by query, tags, project, or type.", + { + query: z.string().optional().describe("Free-text search query"), + tags: z.array(z.string()).optional().describe("Filter by tags"), + project: z.string().optional().describe("Filter by project"), + type: z.string().optional().describe("Filter by knowledge type"), + }, + safe((params) => { + const fn = coreModules.knowledgeStore?.getKnowledge || inlineGetKnowledge; + return ok(fn(params)); + }) +); + +server.tool( + "brane_git_status", + "Get git status for a directory: branch, dirty state, uncommitted files, recent commits.", + { + cwd: z.string().describe("Directory to check git status in"), + }, + safe((params) => { + const fn = coreModules.gitTracker?.getBranchInfo || inlineGitStatus; + return ok(fn(params)); + }) +); + +// ── Git Write Tools ───────────────────────────────────────────────────────────── + +server.tool( + "brane_git_fetch", + "Fetch from a git remote. Defaults to 'origin'.", + { + cwd: z.string().describe("Directory of the git repository"), + remote: z.string().optional().describe("Remote name (default: origin)"), + }, + safe((params) => { + const { cwd, remote } = params; + const fn = coreModules.gitTracker?.fetchRemote; + if (fn) return ok(fn(cwd, remote)); + // Inline fallback + try { + execSync(`git fetch ${remote || "origin"}`, { cwd, encoding: "utf-8", timeout: 30000 }); + return ok({ success: true, remote: remote || "origin" }); + } catch (e) { + return ok({ success: false, error: e.message }); + } + }) +); + +server.tool( + "brane_git_pull", + "Pull a branch from a remote. Refuses if the working tree is dirty.", + { + cwd: z.string().describe("Directory of the git repository"), + branch: z.string().optional().describe("Branch to pull (default: current)"), + remote: z.string().optional().describe("Remote name (default: origin)"), + }, + safe((params) => { + const { cwd, branch, remote } = params; + const fn = coreModules.gitTracker?.pullBranch; + if (fn) return ok(fn(cwd, branch, remote)); + // Inline fallback + const statusFn = coreModules.gitTracker?.getBranchInfo || inlineGitStatus; + const info = statusFn({ cwd }); + if (info.dirty) { + return ok({ success: false, error: "Working tree is dirty. Commit or stash changes first." }); + } + try { + const output = execSync(`git pull ${remote || "origin"} ${branch || ""}`.trim(), { cwd, encoding: "utf-8", timeout: 60000 }); + return ok({ success: true, output: output.trim() }); + } catch (e) { + return ok({ success: false, error: e.message }); + } + }) +); + +server.tool( + "brane_git_push", + "Push a branch to a remote.", + { + cwd: z.string().describe("Directory of the git repository"), + branch: z.string().optional().describe("Branch to push (default: current)"), + remote: z.string().optional().describe("Remote name (default: origin)"), + }, + safe((params) => { + const { cwd, branch, remote } = params; + const fn = coreModules.gitTracker?.pushBranch; + if (fn) return ok(fn(cwd, branch, remote)); + // Inline fallback + try { + const output = execSync(`git push ${remote || "origin"} ${branch || ""}`.trim(), { cwd, encoding: "utf-8", timeout: 60000 }); + return ok({ success: true, output: output.trim() }); + } catch (e) { + return ok({ success: false, error: e.message }); + } + }) +); + +// ── Task Execution Tools ──────────────────────────────────────────────────────── + +server.tool( + "brane_claim_task", + "Claim and start a task: assigns it to the given agent and transitions it to running.", + { + taskId: z.string().describe("The task ID to claim"), + agentId: z.string().describe("The agent ID claiming the task"), + }, + safe(async (params) => { + const { taskId, agentId } = params; + const filepath = path.join(DIRS.tasks, `${taskId}.json`); + if (!fs.existsSync(filepath)) throw new Error(`Task not found: ${taskId}`); + + // Assign + const task = readJSON(filepath); + task.agentId = agentId; + task.status = "running"; + task.activity = [ + ...(task.activity || []), + { + stage: "assigned", + label: "Task Assigned", + message: `Assigned to agent ${agentId}`, + timestamp: Date.now(), + }, + { + stage: "started", + label: "Task Started", + message: "Agent began working on this task", + timestamp: Date.now(), + }, + ]; + writeJSON(filepath, task); + return ok({ taskId, agentId, status: "running" }); + }) +); + +server.tool( + "brane_complete_task", + "Mark a task as successfully completed with optional output.", + { + taskId: z.string().describe("The task ID to complete"), + output: z.any().optional().describe("Task output/result data"), + }, + safe(async (params) => { + const { taskId, output } = params; + const filepath = path.join(DIRS.tasks, `${taskId}.json`); + if (!fs.existsSync(filepath)) throw new Error(`Task not found: ${taskId}`); + + const task = readJSON(filepath); + task.status = "complete"; + task.output = output || {}; + task.completedAt = new Date().toISOString(); + task.activity = [ + ...(task.activity || []), + { + stage: "complete", + label: "Complete", + message: "Task completed successfully", + timestamp: Date.now(), + }, + ]; + writeJSON(filepath, task); + return ok({ taskId, status: "complete" }); + }) +); + +server.tool( + "brane_fail_task", + "Mark a task as failed with an error message.", + { + taskId: z.string().describe("The task ID to fail"), + error: z.string().describe("Error message describing what went wrong"), + }, + safe(async (params) => { + const { taskId, error: errorMsg } = params; + const filepath = path.join(DIRS.tasks, `${taskId}.json`); + if (!fs.existsSync(filepath)) throw new Error(`Task not found: ${taskId}`); + + const task = readJSON(filepath); + task.status = "failed"; + task.output = { error: errorMsg }; + task.completedAt = new Date().toISOString(); + task.activity = [ + ...(task.activity || []), + { + stage: "failed", + label: "Failed", + message: errorMsg, + timestamp: Date.now(), + }, + ]; + writeJSON(filepath, task); + return ok({ taskId, status: "failed" }); + }) +); + +server.tool( + "brane_report_progress", + "Report progress on a task by appending an activity entry with optional percentage.", + { + taskId: z.string().describe("The task ID to report progress on"), + stage: z.string().describe("Current stage name (e.g. 'analyzing', 'implementing')"), + message: z.string().describe("Progress message"), + percentage: z.number().optional().describe("Completion percentage (0-100)"), + }, + safe(async (params) => { + const { taskId, stage, message, percentage } = params; + const filepath = path.join(DIRS.tasks, `${taskId}.json`); + if (!fs.existsSync(filepath)) throw new Error(`Task not found: ${taskId}`); + + const task = readJSON(filepath); + const activityEntry = { + stage, + label: stage, + message, + timestamp: Date.now(), + }; + if (percentage !== undefined) activityEntry.percentage = percentage; + + task.activity = [...(task.activity || []), activityEntry]; + task.currentStage = stage; + writeJSON(filepath, task); + return ok({ taskId, stage, message, percentage }); + }) +); + +// ── Start ────────────────────────────────────────────────────────────────────── + +async function main() { + await tryImportCore(); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((e) => { + console.error("Brane MCP server failed to start:", e); + process.exit(1); +}); diff --git a/brane/src/App.jsx b/brane/src/App.jsx new file mode 100644 index 0000000..5728f5d --- /dev/null +++ b/brane/src/App.jsx @@ -0,0 +1,57 @@ +import { UIProvider, useUI } from "./contexts/UIContext"; +import { DataProvider } from "./contexts/DataContext"; +import { AgentProvider } from "./contexts/AgentContext"; +import { TaskProvider } from "./contexts/TaskContext"; +import { MessageProvider } from "./contexts/MessageContext"; +import { AppShell } from "./components/layout/AppShell"; +import { DashboardView } from "./components/dashboard/DashboardView"; +import { FeedView } from "./components/feed/FeedView"; +import { SourcesView } from "./components/sources/SourcesView"; +import { ScrapeModal } from "./components/sources/ScrapeModal"; +import { ProjectsView } from "./components/projects/ProjectsView"; +import { AgentGridView } from "./components/agents/AgentGridView"; +import { TaskListView } from "./components/tasks/TaskListView"; +import { KnowledgeView } from "./components/knowledge/KnowledgeView"; +import { MessageStreamView } from "./components/messages/MessageStreamView"; +import { SettingsView } from "./components/settings/SettingsView"; +import { OrchestratorView } from "./components/orchestrator/OrchestratorView"; + +function TabRouter() { + const { activeTab } = useUI(); + + switch (activeTab) { + case "dashboard": return ; + case "orchestrator": return ; + case "agents": return ; + case "tasks": return ; + case "feed": return ; + case "knowledge": return ; + case "messages": return ; + case "settings": return ; + default: return ; + } +} + +function ModalLayer() { + const { showScrapeModal } = useUI(); + return showScrapeModal ? : null; +} + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/brane/src/components/agents/AgentActivityView.jsx b/brane/src/components/agents/AgentActivityView.jsx new file mode 100644 index 0000000..97e4672 --- /dev/null +++ b/brane/src/components/agents/AgentActivityView.jsx @@ -0,0 +1,214 @@ +import { useState, useEffect, useRef } from "react"; +import { SourceIcon } from "../feed/SourceIcon"; + +const PIPELINE_STAGES = [ + { id: "detect", label: "Detect", icon: "šŸ”" }, + { id: "connect", label: "Connect", icon: "šŸ”—" }, + { id: "fetch", label: "Fetch", icon: "šŸ“”" }, + { id: "parse", label: "Parse", icon: "🧩" }, + { id: "categorize", label: "Categorize", icon: "šŸ·ļø" }, + { id: "markdown", label: "Generate", icon: "šŸ“" }, + { id: "assets", label: "Assets", icon: "šŸ–¼ļø" }, + { id: "save", label: "Save", icon: "šŸ’¾" }, + { id: "done", label: "Done", icon: "āœ“" }, +]; + +/** + * Live agent activity visualization — replaces the boring spinner. + * Shows a pipeline of stages with live log entries streaming in. + */ +export function AgentActivityView({ job }) { + const logEndRef = useRef(null); + const [elapsed, setElapsed] = useState(0); + + // Timer + useEffect(() => { + if (job.status !== "processing" && job.status !== "pending") return; + const start = new Date(job.createdAt).getTime(); + const timer = setInterval(() => { + setElapsed(((Date.now() - start) / 1000).toFixed(1)); + }, 100); + return () => clearInterval(timer); + }, [job.status, job.createdAt]); + + // Auto-scroll log + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [job.activity?.length]); + + const currentIdx = job.stageIndex ?? -1; + const isActive = job.status === "processing" || job.status === "pending"; + const isError = job.status === "error"; + const isDone = job.status === "complete"; + + return ( +
+ {/* Header bar */} +
+
+ +
+

{job.url}

+
+ {job.project && ( + + {job.project} + + )} + + {new Date(job.createdAt).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })} + +
+
+ {/* Timer */} +
+ {isDone && job.completedAt + ? `${((new Date(job.completedAt) - new Date(job.createdAt)) / 1000).toFixed(1)}s` + : isActive + ? `${elapsed}s` + : "—" + } +
+
+
+ + {/* Pipeline visualization */} +
+
+ {PIPELINE_STAGES.map((stage, i) => { + const isPast = i < currentIdx; + const isCurrent = i === currentIdx && isActive; + const isFuture = i > currentIdx; + const isErrorStage = isError && i === currentIdx; + + return ( +
+ {/* Node */} +
+
+ {isPast || isDone ? "āœ“" : stage.icon} +
+ + {stage.label} + +
+ {/* Connector line */} + {i < PIPELINE_STAGES.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ + {/* Live activity log — terminal style */} +
+ {/* Header line */} +
+ {">"}{" "} + brane{" "} + scrape{" "} + {job.url} + {job.project && --project {job.project}} +
+ + {/* Activity entries */} + {(job.activity || []).map((entry, i) => { + const isLast = i === (job.activity?.length || 0) - 1 && isActive; + const isErr = entry.stage === "error"; + + return ( +
+ + {formatTime(entry.timestamp, job.createdAt)} + + + {isErr ? "āœ—" : entry.stage === "done" ? "āœ“" : "ā–ø"} + + + + {entry.label} + + {" — "} + {entry.message} + +
+ ); + })} + + {/* Cursor blink when active */} + {isActive && ( +
+ + {elapsed}s + + ā–Š +
+ )} + + {/* Done summary */} + {isDone && job.stats && ( +
+
+ āœ“ Instruction set complete +
+
+

entries: {job.stats.totalEntries}

+ {job.stats.blockerCount > 0 && ( +

blockers: {job.stats.blockerCount}

+ )} + {job.stats.revisionCount > 0 && ( +

changes: {job.stats.revisionCount}

+ )} + {job.stats.imageCount > 0 && ( +

images: {job.stats.imageCount}

+ )} +
+
+ )} + + {/* Error state */} + {isError && ( +
+
+ āœ— Pipeline failed +
+
+ {job.error} +
+
+ )} + +
+
+
+ ); +} + +function formatTime(timestamp, createdAt) { + const baseTime = new Date(createdAt).getTime(); + const diff = (timestamp - baseTime) / 1000; + return `+${diff.toFixed(1)}s`; +} diff --git a/brane/src/components/agents/AgentDetailPanel.jsx b/brane/src/components/agents/AgentDetailPanel.jsx new file mode 100644 index 0000000..8026e82 --- /dev/null +++ b/brane/src/components/agents/AgentDetailPanel.jsx @@ -0,0 +1,346 @@ +import { useState, useEffect, useCallback } from "react"; +import { GitBranch, GitCommit, FolderOpen, Hash, Clock, Cpu, CheckCircle2, AlertCircle, Pause, Play, Trash2, ArrowDownToLine, ArrowUpFromLine, RefreshCw } from "lucide-react"; +import { Card } from "../ui/Card"; +import { useTasks } from "../../contexts/TaskContext"; + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3210"; + +function relativeTime(ts) { + if (!ts) return "—"; + const diff = (Date.now() - new Date(ts).getTime()) / 1000; + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function StatusBadge({ status }) { + const styles = { + active: "bg-emerald-50 text-emerald-700", + idle: "bg-stone-100 text-stone-600", + terminated: "bg-red-50 text-red-600", + }; + return ( + + {status || "unknown"} + + ); +} + +export function AgentDetailPanel({ agent }) { + const { tasks } = useTasks(); + const [gitStatus, setGitStatus] = useState(null); + const [branches, setBranches] = useState([]); + const [switching, setSwitching] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + const [agentTaskList, setAgentTaskList] = useState([]); + + const agentTasks = tasks.filter((t) => t.agentId === agent.id); + + // Fetch tasks assigned to this agent + useEffect(() => { + const fetchAgentTasks = async () => { + try { + const res = await fetch(`${API_BASE}/api/agents/${agent.id}/tasks`); + if (res.ok) { + const data = await res.json(); + setAgentTaskList(Array.isArray(data) ? data : data.tasks || []); + } + } catch { /* skip */ } + }; + fetchAgentTasks(); + }, [agent.id]); + + // Lifecycle actions + const pauseAgent = async (id) => { + setActionLoading("pause"); + try { + await fetch(`${API_BASE}/api/agents/${id}/pause`, { method: "POST" }); + } catch { /* skip */ } + setActionLoading(null); + }; + + const resumeAgent = async (id) => { + setActionLoading("resume"); + try { + await fetch(`${API_BASE}/api/agents/${id}/resume`, { method: "POST" }); + } catch { /* skip */ } + setActionLoading(null); + }; + + const decommissionAgent = async (id) => { + setActionLoading("decommission"); + try { + await fetch(`${API_BASE}/api/agents/${id}/decommission`, { method: "POST" }); + } catch { /* skip */ } + setActionLoading(null); + }; + + // Git operations + const gitFetch = async (id) => { + setActionLoading("git-fetch"); + try { + await fetch(`${API_BASE}/api/git/${id}/fetch`, { method: "POST" }); + await fetchGitStatus(); + } catch { /* skip */ } + setActionLoading(null); + }; + + const gitPull = async (id) => { + setActionLoading("git-pull"); + try { + await fetch(`${API_BASE}/api/git/${id}/pull`, { method: "POST" }); + await fetchGitStatus(); + } catch { /* skip */ } + setActionLoading(null); + }; + + const gitPush = async (id) => { + setActionLoading("git-push"); + try { + await fetch(`${API_BASE}/api/git/${id}/push`, { method: "POST" }); + await fetchGitStatus(); + } catch { /* skip */ } + setActionLoading(null); + }; + + // Fetch git status + const fetchGitStatus = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/git/${agent.id}/status`); + if (res.ok) { + const data = await res.json(); + setGitStatus(data); + } + } catch { /* skip */ } + }, [agent.id]); + + // Fetch branches + const fetchBranches = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/git/${agent.id}/branches`); + if (res.ok) { + const data = await res.json(); + setBranches(data.branches || data || []); + } + } catch { /* skip */ } + }, [agent.id]); + + useEffect(() => { + fetchGitStatus(); + fetchBranches(); + }, [fetchGitStatus, fetchBranches]); + + const handleCheckout = async (branch) => { + setSwitching(true); + try { + await fetch(`${API_BASE}/api/git/${agent.id}/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ branch }), + }); + await fetchGitStatus(); + } catch { /* skip */ } + setSwitching(false); + }; + + const name = agent.name || agent.slug || `Agent ${(agent.id || "").slice(0, 6)}`; + + return ( +
+ {/* Header */} +
+
+

+ {name} +

+ + {agent.model && ( + + {agent.model} + + )} +
+ {/* Lifecycle controls */} +
+ {agent.status === "active" && ( + + )} + {agent.status === "paused" && ( + + )} + +
+
+ +
+ {/* Git Section */} + +
+ +

Git

+ {gitStatus && ( + + {gitStatus.dirty ? "dirty" : "clean"} + + )} +
+ +
+
+ Branch + + {gitStatus?.branch || agent.branch || "—"} + +
+ + {/* Branch Switcher */} + {branches.length > 0 && ( +
+ +
+ )} + + {/* Git operations */} +
+ + + +
+ + {/* Recent commits */} + {gitStatus?.commits && gitStatus.commits.length > 0 && ( +
+

Recent Commits

+
+ {gitStatus.commits.slice(0, 5).map((c, i) => ( +
+ + {c.message || c} +
+ ))} +
+
+ )} +
+
+ + {/* Active Tasks */} + +

Active Tasks

+ {agentTasks.length > 0 ? ( +
+ {agentTasks.map((task) => ( +
+ + {task.title || task.type || task.id} + {task.status} +
+ ))} +
+ ) : ( +

No tasks assigned

+ )} +
+ + {/* Server-side Agent Tasks */} + {agentTaskList.length > 0 && agentTaskList.length !== agentTasks.length && ( + +

All Tasks ({agentTaskList.length})

+
+ {agentTaskList.map((task) => ( +
+ + {task.title || task.type || task.id} + {task.status} +
+ ))} +
+
+ )} + + {/* Info */} + +

Info

+
+ + + + + +
+
+
+
+ ); +} + +function InfoRow({ icon: Icon, label, value, mono }) { + return ( +
+ + {label} + + {value || "—"} + +
+ ); +} diff --git a/brane/src/components/agents/AgentDispatchView.jsx b/brane/src/components/agents/AgentDispatchView.jsx new file mode 100644 index 0000000..9f5be93 --- /dev/null +++ b/brane/src/components/agents/AgentDispatchView.jsx @@ -0,0 +1,219 @@ +import { useState, useMemo } from "react"; +import { Bot, CheckCircle2, XCircle, Loader2, Clock, ChevronRight } from "lucide-react"; +import { useData } from "../../contexts/DataContext"; +import { Card } from "../ui/Card"; +import { EmptyState } from "../ui/EmptyState"; +import { SourceIcon } from "../feed/SourceIcon"; +import { ResultDetailPanel } from "./ResultDetailPanel"; + +const STATUS_CONFIG = { + pending: { icon: Clock, color: "text-stone-400", bg: "bg-stone-50", label: "Pending" }, + processing: { icon: Loader2, color: "text-amber-500", bg: "bg-amber-50", label: "Processing", animate: true }, + complete: { icon: CheckCircle2, color: "text-emerald-500", bg: "bg-emerald-50", label: "Complete" }, + error: { icon: XCircle, color: "text-red-500", bg: "bg-red-50", label: "Failed" }, +}; + +export function AgentDispatchView() { + const { jobs, instructions } = useData(); + const [selectedJobId, setSelectedJobId] = useState(null); + + // Find the matching instruction result for a selected job + const selectedJob = useMemo(() => jobs.find((j) => j.id === selectedJobId), [jobs, selectedJobId]); + + // Try to match a job to an instruction (by URL or project) + const matchedInstruction = useMemo(() => { + if (!selectedJob) return null; + return instructions.find( + (inst) => inst.sourceUrl === selectedJob.url || inst.id === selectedJob.resultId + ); + }, [selectedJob, instructions]); + + // Also allow selecting an instruction directly if we have results + const [selectedInstructionId, setSelectedInstructionId] = useState(null); + const directInstruction = useMemo( + () => instructions.find((i) => i.id === selectedInstructionId), + [instructions, selectedInstructionId] + ); + + const activeInstruction = matchedInstruction || directInstruction; + + const handleSelectJob = (job) => { + setSelectedJobId(job.id); + setSelectedInstructionId(null); + }; + + const handleSelectResult = (inst) => { + setSelectedInstructionId(inst.id); + setSelectedJobId(null); + }; + + return ( +
+ {/* ═══ Left panel — Jobs & Results list ═══ */} +
+ {/* Header */} +
+

+ Agent Dispatch +

+

+ Subagent jobs processed in parallel +

+
+ +
+ {/* Active Jobs */} + {jobs.length > 0 && ( +
+

Jobs ({jobs.length})

+
+ {jobs.map((job) => ( + handleSelectJob(job)} + /> + ))} +
+
+ )} + + {/* Completed Results */} + {instructions.length > 0 && ( +
+

Results ({instructions.length})

+
+ {instructions.map((inst) => ( + handleSelectResult(inst)} + /> + ))} +
+
+ )} + + {jobs.length === 0 && instructions.length === 0 && ( +
+ +
+ )} + + {/* How it works — only show when empty */} + {jobs.length === 0 && ( +
+
+

How Dispatch Works

+
+

1. Paste URLs into the scrape modal

+

2. Auto-detected (Slack, Figma, Twitter…)

+

3. Subagents process in parallel

+

4. Results appear here →

+
+
+ + node bin/cli.js dispatch url1 url2 + +
+
+
+ )} +
+
+ + {/* ═══ Right panel — Result Detail ═══ */} +
+ +
+
+ ); +} + +/* ─── Job Row ─── */ +function AgentJobRow({ job, isSelected, onClick }) { + const config = STATUS_CONFIG[job.status] || STATUS_CONFIG.pending; + const StatusIcon = config.icon; + + return ( +
+ +
+

{job.url}

+
+ {job.project && ( + + {job.project} + + )} + + {new Date(job.createdAt).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })} + +
+
+
+ + {config.label} +
+ {isSelected && } +
+ ); +} + +/* ─── Result Row ─── */ +function ResultRow({ instruction, isSelected, onClick }) { + const { title, source, project, stats, scrapedAt } = instruction; + + return ( +
+ +
+

{title}

+
+ {project && ( + + {project} + + )} + {stats.blockerCount > 0 && ( + {stats.blockerCount} blocker{stats.blockerCount > 1 ? "s" : ""} + )} + {stats.revisionCount > 0 && ( + {stats.revisionCount} change{stats.revisionCount > 1 ? "s" : ""} + )} +
+
+
+ + {isSelected && } +
+
+ ); +} diff --git a/brane/src/components/agents/AgentGridView.jsx b/brane/src/components/agents/AgentGridView.jsx new file mode 100644 index 0000000..8cb2f48 --- /dev/null +++ b/brane/src/components/agents/AgentGridView.jsx @@ -0,0 +1,155 @@ +import { useState, useMemo } from "react"; +import { Search, Filter } from "lucide-react"; +import { Card } from "../ui/Card"; +import { EmptyState } from "../ui/EmptyState"; +import { useAgents } from "../../contexts/AgentContext"; +import { AgentDetailPanel } from "./AgentDetailPanel"; +import { Bot } from "lucide-react"; + +function relativeTime(ts) { + if (!ts) return "—"; + const diff = (Date.now() - new Date(ts).getTime()) / 1000; + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function StatusDot({ status }) { + const color = status === "active" ? "bg-emerald-400" + : status === "idle" ? "bg-stone-300" + : status === "terminated" ? "bg-red-400" + : "bg-stone-300"; + return ; +} + +export function AgentGridView() { + const { agents, selectedAgent, setSelectedAgent, loading } = useAgents(); + const [statusFilter, setStatusFilter] = useState("all"); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + // Filter out stale agents with no real working directory + let list = agents.filter((a) => a.cwd && a.cwd !== "/" && a.cwd.length > 1); + if (statusFilter !== "all") { + list = list.filter((a) => a.status === statusFilter); + } + if (search) { + const q = search.toLowerCase(); + list = list.filter((a) => + (a.name || a.slug || a.id || "").toLowerCase().includes(q) || + (a.cwd || "").toLowerCase().includes(q) + ); + } + return list; + }, [agents, statusFilter, search]); + + return ( +
+ {/* Left panel — Agent Grid */} +
+ {/* Header */} +
+

+ Agents +

+

+ {filtered.length} registered agent{filtered.length !== 1 ? "s" : ""} +

+
+ + {/* Filter bar */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-xs bg-surface rounded-lg border-0 focus:ring-1 focus:ring-accent/30 outline-none" + /> +
+ +
+ + {/* Agent cards grid */} +
+ {loading && agents.length === 0 ? ( +

Loading agents...

+ ) : filtered.length > 0 ? ( +
+ {filtered.map((agent) => ( + setSelectedAgent(agent)} + /> + ))} +
+ ) : ( + + )} +
+
+ + {/* Right panel — Agent Detail */} +
+ {selectedAgent ? ( + + ) : ( +
+

Select an agent to view details

+
+ )} +
+
+ ); +} + +function AgentCard({ agent, isSelected, onClick }) { + const name = agent.name || agent.slug || `Agent ${(agent.id || "").slice(0, 6)}`; + + return ( + +
+ + {name} +
+
+ {agent.cwd && ( +

{agent.cwd}

+ )} +
+ {agent.branch && agent.branch !== "HEAD" && ( + + {agent.branch} + + )} + {agent.model && ( + + {agent.model} + + )} +
+

{relativeTime(agent.lastSeen || agent.updatedAt)}

+
+
+ ); +} diff --git a/brane/src/components/agents/ResultDetailPanel.jsx b/brane/src/components/agents/ResultDetailPanel.jsx new file mode 100644 index 0000000..0ff1d60 --- /dev/null +++ b/brane/src/components/agents/ResultDetailPanel.jsx @@ -0,0 +1,315 @@ +import { useState, useEffect } from "react"; +import { ExternalLink, Image, CheckSquare, Square, FileText, Copy, Check, ChevronDown, ChevronRight } from "lucide-react"; +import { CategoryBadge } from "../feed/CategoryBadge"; +import { SourceIcon } from "../feed/SourceIcon"; +import { useData } from "../../contexts/DataContext"; +import { AgentActivityView } from "./AgentActivityView"; + +export function ResultDetailPanel({ instruction, job }) { + const { loadInstruction, instructionCache } = useData(); + const [detail, setDetail] = useState(null); + const [checkedItems, setCheckedItems] = useState({}); + const [copied, setCopied] = useState(false); + const [threadOpen, setThreadOpen] = useState(false); + + useEffect(() => { + if (!instruction) { setDetail(null); return; } + const cached = instructionCache[instruction.id]; + if (cached) { + setDetail(cached); + } else { + loadInstruction(instruction.id).then(setDetail); + } + setCheckedItems({}); + setThreadOpen(false); + }, [instruction?.id, loadInstruction, instructionCache]); + + const toggleCheck = (idx) => { + setCheckedItems((prev) => ({ ...prev, [idx]: !prev[idx] })); + }; + + const copyMarkdown = () => { + if (!detail) return; + const lines = []; + lines.push(`# ${detail.title}`); + lines.push(`**Source:** ${detail.source} Ā· **Project:** ${detail.project || "—"}`); + lines.push(`**URL:** ${detail.sourceUrl}`); + lines.push(""); + if (detail.root?.text) { + lines.push("## Context"); + lines.push(detail.root.text); + lines.push(""); + } + const blockers = detail.replies?.filter((r) => r.category === "blocker") || []; + const revisions = detail.replies?.filter((r) => r.category === "revision") || []; + const questions = detail.replies?.filter((r) => r.category === "question") || []; + const actionItems = [...blockers, ...revisions, ...questions]; + if (actionItems.length > 0) { + lines.push("## Agent Instructions"); + actionItems.forEach((item) => { + lines.push(`- [ ] **[${item.category}]** ${item.text} _(${item.author})_`); + }); + } + navigator.clipboard.writeText(lines.join("\n")); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // ═══ Empty state — no selection ═══ + if (!instruction && !job) { + return ( +
+
+
+ +
+

Select a job to view results

+

Click any dispatch job on the left

+
+
+ ); + } + + // ═══ Active job — show the live agent activity view ═══ + if (job && (job.status === "pending" || job.status === "processing" || (job.status === "error" && !instruction))) { + return ; + } + + // ═══ Job just completed but we can show both: activity + result ═══ + // If job is complete and we have an instruction, show the result detail + // But also show the activity log if there's recent activity + + // ═══ Loading detail ═══ + if (!detail) { + return ( +
+
+
+
+
+
+
+ ); + } + + const blockers = detail.replies?.filter((r) => r.category === "blocker") || []; + const revisions = detail.replies?.filter((r) => r.category === "revision") || []; + const questions = detail.replies?.filter((r) => r.category === "question") || []; + const approvals = detail.replies?.filter((r) => r.category === "approval") || []; + const actionItems = [...blockers, ...revisions, ...questions]; + + return ( +
+ {/* Completed job activity — collapsible at top */} + {job && job.status === "complete" && job.activity?.length > 0 && ( + + )} + + {/* Sticky header */} +
+
+ +
+

+ {detail.title} +

+
+ {detail.project && ( + + {detail.project} + + )} + + {new Date(detail.scrapedAt).toLocaleDateString("en-US", { + month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + })} + +
+
+
+ + + + +
+
+
+ +
+ {/* Context */} +
+

Context

+
+

{detail.root?.author}

+ {detail.root?.text} +
+ {detail.root?.attachments?.length > 0 && ( +
+ {detail.root.attachments.filter((a) => a.type === "image").map((att, i) => ( +
+
+ + {att.title || att.name} +
+
+ ))} +
+ )} +
+ + {/* Agent Instructions Checklist */} + {actionItems.length > 0 && ( +
+
+

Agent Instructions

+ + {Object.values(checkedItems).filter(Boolean).length}/{actionItems.length} + +
+
+
+
+
+ {actionItems.map((item, idx) => ( +
toggleCheck(idx)} + className="flex items-start gap-2.5 p-3 bg-white border border-stone-200 rounded-lg cursor-pointer hover:bg-stone-50 transition-colors" + > + {checkedItems[idx] ? ( + + ) : ( + + )} +
+

+ {item.text} +

+
+ + {item.author} +
+
+
+ ))} +
+
+ )} + + {/* Approvals */} + {approvals.length > 0 && ( +
+

Approvals

+
+ {approvals.map((a, i) => ( +
+ + {a.author} + {a.text} +
+ ))} +
+
+ )} + + {/* Full Thread */} + {detail.allEntries?.length > 0 && ( +
+ + {threadOpen && ( +
+ {detail.allEntries.map((entry, i) => ( +
+
+
+ {entry.author} + + + {new Date(entry.timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })} + +
+

{entry.text}

+ {entry.attachments?.length > 0 && ( +
+ {entry.attachments.map((att, j) => ( + + + {att.name} + + ))} +
+ )} +
+
+ ))} +
+ )} +
+ )} +
+
+ ); +} + +/** Compact completed activity summary bar */ +function CompletedActivitySummary({ job }) { + const [expanded, setExpanded] = useState(false); + const duration = job.completedAt + ? ((new Date(job.completedAt) - new Date(job.createdAt)) / 1000).toFixed(1) + : "—"; + + return ( +
+ + {expanded && ( +
+ {(job.activity || []).map((entry, i) => ( +
+ + +{((entry.timestamp - new Date(job.createdAt).getTime()) / 1000).toFixed(1)}s + + + {entry.stage === "done" ? "āœ“" : "ā–ø"} + + + {entry.label} + {" — "}{entry.message} + +
+ ))} +
+ )} +
+ ); +} diff --git a/brane/src/components/dashboard/DashboardView.jsx b/brane/src/components/dashboard/DashboardView.jsx new file mode 100644 index 0000000..0762cb4 --- /dev/null +++ b/brane/src/components/dashboard/DashboardView.jsx @@ -0,0 +1,134 @@ +import { Users, ListTodo, BookOpen, GitBranch, Activity } from "lucide-react"; +import { Card, StatCard } from "../ui/Card"; +import { useAgents } from "../../contexts/AgentContext"; +import { useTasks } from "../../contexts/TaskContext"; +import { useMessages } from "../../contexts/MessageContext"; +import { useData } from "../../contexts/DataContext"; + +function relativeTime(ts) { + if (!ts) return ""; + const diff = (Date.now() - new Date(ts).getTime()) / 1000; + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function StatusDot({ status }) { + const color = status === "active" ? "bg-emerald-400" + : status === "idle" ? "bg-stone-300" + : status === "terminated" ? "bg-red-400" + : "bg-stone-300"; + return ; +} + +export function DashboardView() { + const { agents } = useAgents(); + const { tasks } = useTasks(); + const { instructions } = useData(); + const { messages } = useMessages(); + + // Filter out stale agents with no real working directory + const realAgents = agents.filter((a) => a.cwd && a.cwd !== "/" && a.cwd.length > 1); + const activeAgents = realAgents.filter((a) => a.status === "active").length; + const runningTasks = tasks.filter((t) => t.status === "running" || t.status === "in-progress" || t.status === "processing").length; + const knowledgeCount = instructions.length; + const branches = new Set(realAgents.map((a) => a.branch).filter((b) => b && b !== "HEAD" && b !== "unknown")); + + // Get last 10 messages for recent activity + const recentMessages = messages.slice(-10).reverse(); + + return ( +
+

+ Dashboard +

+ + {/* Row 1 — Stats */} +
+ + + + +
+ + {/* Row 2 — Two columns */} +
+ {/* Recent Activity */} + +
+ +

Recent Activity

+
+ {recentMessages.length > 0 ? ( +
+ {recentMessages.map((msg, i) => ( +
+ + {msg.timestamp + ? new Date(msg.timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }) + : "--:--" + } + + + {msg.payload?.summary || msg.payload?.message || msg.type || "event"} + +
+ ))} +
+ ) : ( +

No recent activity

+ )} +
+ + {/* Agent Overview */} + +
+ +

Agent Overview

+
+ {realAgents.length > 0 ? ( +
+ {realAgents.map((agent) => ( +
+ + + {agent.name || agent.slug || `Agent ${(agent.id || "").slice(0, 6)}`} + + {agent.branch && agent.branch !== "HEAD" && ( + + {agent.branch} + + )} + + {agent.cwd ? agent.cwd.split("/").slice(-2).join("/") : ""} + +
+ ))} +
+ ) : ( +

No agents registered

+ )} +
+
+
+ ); +} diff --git a/brane/src/components/feed/CategoryBadge.jsx b/brane/src/components/feed/CategoryBadge.jsx new file mode 100644 index 0000000..37f0eb0 --- /dev/null +++ b/brane/src/components/feed/CategoryBadge.jsx @@ -0,0 +1,27 @@ +const CATEGORY_STYLES = { + blocker: "bg-red-100 text-red-700 border border-red-200", + revision: "bg-amber-100 text-amber-700 border border-amber-200", + question: "bg-blue-100 text-blue-700 border border-blue-200", + approval: "bg-emerald-100 text-emerald-700 border border-emerald-200", + context: "bg-stone-100 text-stone-600 border border-stone-200", +}; + +const CATEGORY_LABELS = { + blocker: "Blocker", + revision: "Change", + question: "Question", + approval: "Approved", + context: "Context", +}; + +export function CategoryBadge({ category, count }) { + const style = CATEGORY_STYLES[category] || CATEGORY_STYLES.context; + const label = CATEGORY_LABELS[category] || category; + + return ( + + {label} + {count > 0 && {count}} + + ); +} diff --git a/brane/src/components/feed/FeedView.jsx b/brane/src/components/feed/FeedView.jsx new file mode 100644 index 0000000..4417463 --- /dev/null +++ b/brane/src/components/feed/FeedView.jsx @@ -0,0 +1,143 @@ +import { useMemo } from "react"; +import { Inbox } from "lucide-react"; +import { useUI } from "../../contexts/UIContext"; +import { useData } from "../../contexts/DataContext"; +import { InstructionCard } from "./InstructionCard"; +import { InstructionDetail } from "./InstructionDetail"; +import { EmptyState } from "../ui/EmptyState"; +import { StatCard } from "../ui/Card"; + +export function FeedView() { + const { searchQuery, filterSource, filterCategory, filterProject, selectedInstruction, openDetail, closeDetail } = useUI(); + const { instructions, loading, projects, sources } = useData(); + + const filtered = useMemo(() => { + return instructions.filter((inst) => { + if (filterSource !== "all" && inst.source !== filterSource) return false; + if (filterProject !== "all" && inst.project !== filterProject) return false; + if (filterCategory !== "all") { + const cats = inst.stats.categories || {}; + if (!cats[filterCategory]) return false; + } + if (searchQuery) { + const q = searchQuery.toLowerCase(); + const match = inst.title.toLowerCase().includes(q) || + inst.project?.toLowerCase().includes(q) || + inst.source.includes(q); + if (!match) return false; + } + return true; + }); + }, [instructions, filterSource, filterCategory, filterProject, searchQuery]); + + // Aggregate stats + const totalBlockers = instructions.reduce((s, i) => s + (i.stats.blockerCount || 0), 0); + const totalRevisions = instructions.reduce((s, i) => s + (i.stats.revisionCount || 0), 0); + const totalImages = instructions.reduce((s, i) => s + (i.stats.imageCount || 0), 0); + + if (loading) { + return ( +
+
+ {[1, 2, 3, 4].map((i) =>
)} +
+
+ {[1, 2, 3].map((i) =>
)} +
+
+ ); + } + + return ( +
+ {/* Stats row */} + {instructions.length > 0 && ( +
+ + 0 ? "text-red-600" : "text-stone-300"} /> + 0 ? "text-amber-600" : "text-stone-300"} /> + +
+ )} + + {/* Filters */} + {instructions.length > 0 && ( + + )} + + {/* Cards */} + {filtered.length > 0 ? ( +
+ {filtered.map((inst) => ( + openDetail(inst)} + /> + ))} +
+ ) : ( + + )} + + {/* Detail modal */} + {selectedInstruction && ( + + )} +
+ ); +} + +function FilterBar() { + const { filterSource, setFilterSource, filterCategory, setFilterCategory, filterProject, setFilterProject } = useUI(); + const { projects, sources } = useData(); + + return ( +
+ Filter + + + {projects.length > 0 && ( + + )} +
+ ); +} diff --git a/brane/src/components/feed/InstructionCard.jsx b/brane/src/components/feed/InstructionCard.jsx new file mode 100644 index 0000000..9afa115 --- /dev/null +++ b/brane/src/components/feed/InstructionCard.jsx @@ -0,0 +1,67 @@ +import { Image, MessageSquare, Clock } from "lucide-react"; +import { Card } from "../ui/Card"; +import { CategoryBadge } from "./CategoryBadge"; +import { SourceIcon } from "./SourceIcon"; + +export function InstructionCard({ instruction, onClick }) { + const { title, source, project, stats, scrapedAt } = instruction; + const date = new Date(scrapedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + + return ( + +
+ +
+ {/* Title */} +

+ {title} +

+ + {/* Meta row */} +
+ {project && ( + + {project} + + )} + + + {stats.totalEntries} + + {stats.imageCount > 0 && ( + + + {stats.imageCount} + + )} + + + {date} + +
+ + {/* Category badges */} +
+ {stats.blockerCount > 0 && ( + + )} + {stats.revisionCount > 0 && ( + + )} + {(stats.categories?.question || 0) > 0 && ( + + )} + {(stats.categories?.approval || 0) > 0 && ( + + )} +
+
+
+
+ ); +} diff --git a/brane/src/components/feed/InstructionDetail.jsx b/brane/src/components/feed/InstructionDetail.jsx new file mode 100644 index 0000000..4c3dfa7 --- /dev/null +++ b/brane/src/components/feed/InstructionDetail.jsx @@ -0,0 +1,185 @@ +import { useState, useEffect } from "react"; +import { X, ExternalLink, Image, CheckSquare, Square } from "lucide-react"; +import { Modal } from "../ui/Modal"; +import { CategoryBadge } from "./CategoryBadge"; +import { SourceIcon } from "./SourceIcon"; +import { useData } from "../../contexts/DataContext"; + +export function InstructionDetail({ instruction, onClose }) { + const { loadInstruction, instructionCache } = useData(); + const [detail, setDetail] = useState(null); + const [checkedItems, setCheckedItems] = useState({}); + + useEffect(() => { + const cached = instructionCache[instruction.id]; + if (cached) { + setDetail(cached); + } else { + loadInstruction(instruction.id).then(setDetail); + } + }, [instruction.id, loadInstruction, instructionCache]); + + const toggleCheck = (idx) => { + setCheckedItems((prev) => ({ ...prev, [idx]: !prev[idx] })); + }; + + if (!detail) { + return ( + +
+
+
+
+ + ); + } + + const blockers = detail.replies?.filter((r) => r.category === "blocker") || []; + const revisions = detail.replies?.filter((r) => r.category === "revision") || []; + const questions = detail.replies?.filter((r) => r.category === "question") || []; + const approvals = detail.replies?.filter((r) => r.category === "approval") || []; + const actionItems = [...blockers, ...revisions, ...questions]; + + return ( + + {/* Header */} +
+ +
+

+ {detail.title} +

+
+ {detail.project && ( + + {detail.project} + + )} + + {new Date(detail.scrapedAt).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + + Original + +
+
+ +
+ + {/* Context */} +
+

Context

+
+

{detail.root?.author}

+ {detail.root?.text} +
+ {detail.root?.attachments?.length > 0 && ( +
+ {detail.root.attachments.filter((a) => a.type === "image").map((att, i) => ( +
+
+ + {att.title || att.name} +
+
+ ))} +
+ )} +
+ + {/* Agent Instructions Checklist */} + {actionItems.length > 0 && ( +
+

Agent Instructions

+
+ {actionItems.map((item, idx) => ( +
toggleCheck(idx)} + className="flex items-start gap-2.5 p-3 bg-white border border-stone-200 rounded-lg cursor-pointer hover:bg-stone-50 transition-colors" + > + {checkedItems[idx] ? ( + + ) : ( + + )} +
+

+ {item.text} +

+
+ + {item.author} +
+
+
+ ))} +
+
+ )} + + {/* Approvals */} + {approvals.length > 0 && ( +
+

Approvals

+
+ {approvals.map((a, i) => ( +
+ + {a.author} + {a.text} +
+ ))} +
+
+ )} + + {/* Full Thread */} +
+ + Full Thread ({detail.allEntries?.length || 0} entries) + +
+ {detail.allEntries?.map((entry, i) => ( +
+
+
+ {entry.author} + + + {new Date(entry.timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })} + +
+

{entry.text}

+ {entry.attachments?.length > 0 && ( +
+ {entry.attachments.map((att, j) => ( + + + {att.name} + + ))} +
+ )} +
+
+ ))} +
+
+
+ ); +} diff --git a/brane/src/components/feed/SourceIcon.jsx b/brane/src/components/feed/SourceIcon.jsx new file mode 100644 index 0000000..52c6913 --- /dev/null +++ b/brane/src/components/feed/SourceIcon.jsx @@ -0,0 +1,28 @@ +import { Hash, Twitter, Figma, Globe } from "lucide-react"; + +const SOURCE_ICONS = { + slack: Hash, + twitter: Twitter, + figma: Figma, + url: Globe, +}; + +const SOURCE_COLORS = { + slack: "text-purple-600 bg-purple-50", + twitter: "text-sky-600 bg-sky-50", + figma: "text-pink-600 bg-pink-50", + url: "text-stone-600 bg-stone-100", +}; + +export function SourceIcon({ source, size = "md" }) { + const Icon = SOURCE_ICONS[source] || Globe; + const color = SOURCE_COLORS[source] || SOURCE_COLORS.url; + const sizeClass = size === "sm" ? "w-6 h-6" : "w-8 h-8"; + const iconSize = size === "sm" ? "w-3 h-3" : "w-4 h-4"; + + return ( +
+ +
+ ); +} diff --git a/brane/src/components/knowledge/KnowledgeView.jsx b/brane/src/components/knowledge/KnowledgeView.jsx new file mode 100644 index 0000000..7e0b331 --- /dev/null +++ b/brane/src/components/knowledge/KnowledgeView.jsx @@ -0,0 +1,306 @@ +import { useState, useMemo, useCallback } from "react"; +import { Search, BookOpen, ChevronRight, ArrowUpRight, Tag } from "lucide-react"; +import { Card } from "../ui/Card"; +import { EmptyState } from "../ui/EmptyState"; +import { useData } from "../../contexts/DataContext"; + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3210"; + +const TYPE_COLORS = { + skill: "bg-violet-50 text-violet-700", + instruction: "bg-blue-50 text-blue-700", + pattern: "bg-emerald-50 text-emerald-700", + rule: "bg-red-50 text-red-700", +}; + +const SOURCE_COLORS = { + scraped: "bg-cyan-50 text-cyan-700", + learned: "bg-amber-50 text-amber-700", + manual: "bg-stone-100 text-stone-600", +}; + +export function KnowledgeView() { + const { instructions, projects } = useData(); + const [selectedId, setSelectedId] = useState(null); + const [typeFilter, setTypeFilter] = useState("all"); + const [projectFilter, setProjectFilter] = useState("all"); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + let list = instructions; + if (typeFilter !== "all") { + list = list.filter((i) => (i.type || i.category) === typeFilter); + } + if (projectFilter !== "all") { + list = list.filter((i) => i.project === projectFilter); + } + if (search) { + const q = search.toLowerCase(); + list = list.filter((i) => + (i.title || "").toLowerCase().includes(q) || + (i.tags || []).some((t) => t.toLowerCase().includes(q)) + ); + } + return list; + }, [instructions, typeFilter, projectFilter, search]); + + const selected = useMemo(() => { + return instructions.find((i) => i.id === selectedId) || null; + }, [instructions, selectedId]); + + const types = useMemo(() => { + const set = new Set(instructions.map((i) => i.type || i.category).filter(Boolean)); + return [...set].sort(); + }, [instructions]); + + const handlePromote = useCallback(async (id, promoteTo) => { + const extra = {}; + if (promoteTo === "skill") { + const name = prompt("Skill name:"); + if (!name) return; + extra.name = name; + } else if (promoteTo === "instruction") { + const path = prompt("Project path:"); + if (!path) return; + extra.projectPath = path; + } + try { + await fetch(`${API_BASE}/api/knowledge/${id}/promote`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ promoteTo, ...extra }), + }); + } catch (err) { + console.error("Promote failed:", err); + } + }, []); + + return ( +
+ {/* Left panel — Knowledge List */} +
+
+

+ Knowledge +

+

+ {instructions.length} entr{instructions.length !== 1 ? "ies" : "y"} +

+
+ + {/* Filter bar */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-xs bg-surface rounded-lg border-0 focus:ring-1 focus:ring-accent/30 outline-none" + /> +
+ {types.length > 0 && ( + + )} + {projects.length > 0 && ( + + )} +
+ + {/* Knowledge list */} +
+ {filtered.length > 0 ? ( +
+ {filtered.map((entry) => { + const entryType = entry.type || entry.category; + const isSelected = selectedId === entry.id; + return ( +
setSelectedId(entry.id)} + className={`px-3 py-2.5 rounded-lg cursor-pointer transition-all duration-150 ${ + isSelected + ? "bg-accent/5 border border-accent/20 shadow-sm" + : "hover:bg-stone-50 border border-transparent" + }`} + > +
+ {entryType && ( + + {entryType} + + )} + + {entry.title} + + {isSelected && } +
+
+ {(entry.tags || []).slice(0, 3).map((tag) => ( + + {tag} + + ))} + {entry.source && ( + + {entry.source} + + )} + {entry.project && ( + + {entry.project} + + )} +
+
+ ); + })} +
+ ) : ( + + )} +
+
+ + {/* Right panel — Knowledge Detail */} +
+ {selected ? ( + + ) : ( +
+

Select an entry to view details

+
+ )} +
+
+ ); +} + +function KnowledgeDetail({ entry, onPromote }) { + const entryType = entry.type || entry.category; + + return ( +
+
+

+ {entry.title} +

+
+ {entryType && ( + + {entryType} + + )} + {entry.source && ( + + {entry.source} + + )} + {entry.project && ( + + {entry.project} + + )} +
+
+ +
+ {/* Content */} + {(entry.content || entry.markdown) && ( + +

Content

+
+ {entry.content || entry.markdown} +
+
+ )} + + {/* Tags */} + {entry.tags && entry.tags.length > 0 && ( + +
+ +

Tags

+
+
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {/* Metadata */} + +

Metadata

+
+
+ Source + {entry.source || "—"} +
+ {entry.sourceUrl && ( +
+ URL + {entry.sourceUrl} +
+ )} +
+ Created + + {entry.scrapedAt ? new Date(entry.scrapedAt).toLocaleDateString() : "—"} + +
+ {entry.appliedCount !== undefined && ( +
+ Applied + {entry.appliedCount} times +
+ )} +
+
+ + {/* Promote Actions */} + +

Promote

+
+ + +
+
+
+
+ ); +} diff --git a/brane/src/components/layout/AppShell.jsx b/brane/src/components/layout/AppShell.jsx new file mode 100644 index 0000000..626e494 --- /dev/null +++ b/brane/src/components/layout/AppShell.jsx @@ -0,0 +1,16 @@ +import { Sidebar } from "./Sidebar"; +import { Header } from "./Header"; + +export function AppShell({ children }) { + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} diff --git a/brane/src/components/layout/Header.jsx b/brane/src/components/layout/Header.jsx new file mode 100644 index 0000000..5921837 --- /dev/null +++ b/brane/src/components/layout/Header.jsx @@ -0,0 +1,45 @@ +import { Search, Plus, RefreshCw } from "lucide-react"; +import { useUI } from "../../contexts/UIContext"; +import { useData } from "../../contexts/DataContext"; + +export function Header() { + const { searchQuery, setSearchQuery, openScrapeModal } = useUI(); + const { loadIndex, loading } = useData(); + + return ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="bg-transparent text-sm outline-none flex-1 placeholder:text-stone-400" + /> +
+ +
+ {/* Refresh */} + + + {/* New Scrape */} + +
+
+ ); +} diff --git a/brane/src/components/layout/Sidebar.jsx b/brane/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..9db1505 --- /dev/null +++ b/brane/src/components/layout/Sidebar.jsx @@ -0,0 +1,69 @@ +import { useUI } from "../../contexts/UIContext"; +import { useData } from "../../contexts/DataContext"; +import { LayoutDashboard, Brain, Bot, ListTodo, Rss, BookOpen, MessageSquare, Settings } from "lucide-react"; + +const TAB_CONFIG = [ + { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, + { id: "orchestrator", label: "Orchestrator", icon: Brain }, + { id: "agents", label: "Agents", icon: Bot }, + { id: "tasks", label: "Tasks", icon: ListTodo }, + { id: "feed", label: "Feed", icon: Rss }, + { id: "knowledge", label: "Knowledge", icon: BookOpen }, + { id: "messages", label: "Messages", icon: MessageSquare }, + { id: "settings", label: "Settings", icon: Settings }, +]; + +export function Sidebar() { + const { activeTab, setActiveTab } = useUI(); + const { instructions, jobs } = useData(); + + const pendingJobs = jobs.filter((j) => j.status === "pending" || j.status === "processing").length; + + return ( + + ); +} diff --git a/brane/src/components/messages/MessageStreamView.jsx b/brane/src/components/messages/MessageStreamView.jsx new file mode 100644 index 0000000..0f94f00 --- /dev/null +++ b/brane/src/components/messages/MessageStreamView.jsx @@ -0,0 +1,151 @@ +import { useState, useEffect, useRef } from "react"; +import { Send, MessageSquare } from "lucide-react"; +import { useMessages } from "../../contexts/MessageContext"; + +const TYPE_COLORS = { + "task-update": "text-amber-400", + "agent-event": "text-emerald-400", + "system": "text-cyan-400", + "human": "text-violet-400", + "error": "text-red-400", +}; + +export function MessageStreamView() { + const { channels, activeChannel, setActiveChannel, messages, publishMessage } = useMessages(); + const [input, setInput] = useState(""); + const logEndRef = useRef(null); + + // Auto-scroll on new messages + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length]); + + // Default to first channel + useEffect(() => { + if (!activeChannel && channels.length > 0) { + setActiveChannel(channels[0]?.id || channels[0]); + } + }, [channels, activeChannel, setActiveChannel]); + + const handleSend = async () => { + if (!input.trim() || !activeChannel) return; + await publishMessage(activeChannel, { + from: "human", + type: "human", + payload: { message: input.trim() }, + }); + setInput(""); + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Header + Channel tabs */} +
+

+ Messages +

+ {channels.length > 0 ? ( +
+ {channels.map((ch) => { + const chId = ch.id || ch; + const chName = ch.name || ch; + const isActive = activeChannel === chId; + return ( + + ); + })} +
+ ) : ( +

No channels available

+ )} +
+ + {/* Message stream — terminal style */} +
+ {messages.length > 0 ? ( + <> + {messages.map((msg, i) => { + const from = msg.from || msg.agentId || "system"; + const typeColor = TYPE_COLORS[msg.type] || "text-stone-500"; + const payload = msg.payload || {}; + const summary = payload.summary || payload.message || payload.text || msg.type || "event"; + + return ( +
+ + {msg.timestamp + ? new Date(msg.timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + : "--:--:--" + } + + + {msg.type === "error" ? "!" : ">"} + + + {from} + + {msg.type && ( + + [{msg.type}] + + )} + + {summary} + +
+ ); + })} +
+ + ) : ( +
+
+ +

+ {activeChannel ? "No messages yet" : "Select a channel"} +

+
+
+ )} +
+ + {/* Input bar */} +
+ {">"} + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={activeChannel ? "Type a message..." : "Select a channel first"} + disabled={!activeChannel} + className="flex-1 bg-transparent text-stone-200 text-xs font-mono outline-none placeholder:text-stone-600 disabled:opacity-50" + /> + +
+
+ ); +} diff --git a/brane/src/components/orchestrator/OrchestratorView.jsx b/brane/src/components/orchestrator/OrchestratorView.jsx new file mode 100644 index 0000000..ddf5843 --- /dev/null +++ b/brane/src/components/orchestrator/OrchestratorView.jsx @@ -0,0 +1,187 @@ +import { useState, useEffect, useRef } from "react"; +import { Card } from "../ui/Card"; +import { Brain, Play, Square, Send } from "lucide-react"; + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3210"; + +export function OrchestratorView() { + const [status, setStatus] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + const eventSourceRef = useRef(null); + + // Poll orchestrator status + useEffect(() => { + const poll = async () => { + try { + const res = await fetch(`${API_BASE}/api/orchestrator/status`); + const data = await res.json(); + setStatus(data); + } catch {} + }; + poll(); + const interval = setInterval(poll, 5000); + return () => clearInterval(interval); + }, []); + + // SSE for orchestrator messages + useEffect(() => { + const es = new EventSource(`${API_BASE}/api/messages/orchestrator/stream`); + es.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + setMessages((prev) => [...prev, msg]); + } catch {} + }; + eventSourceRef.current = es; + + // Load existing messages + fetch(`${API_BASE}/api/messages/orchestrator?limit=50`) + .then((r) => r.json()) + .then((data) => setMessages(Array.isArray(data) ? data : [])) + .catch(() => {}); + + return () => es.close(); + }, []); + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const startOrchestrator = async () => { + setLoading(true); + try { + await fetch(`${API_BASE}/api/orchestrator/start`, { method: "POST" }); + } catch {} + setLoading(false); + }; + + const stopOrchestrator = async () => { + setLoading(true); + try { + await fetch(`${API_BASE}/api/orchestrator/stop`, { method: "POST" }); + } catch {} + setLoading(false); + }; + + const sendMessage = async () => { + if (!input.trim()) return; + try { + await fetch(`${API_BASE}/api/orchestrator/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: input }), + }); + setInput(""); + } catch {} + }; + + const isRunning = status?.alive || status?.status === "running"; + + return ( +
+
+
+

+ Orchestrator +

+ + + {isRunning ? "Running" : "Stopped"} + +
+
+ {!isRunning ? ( + + ) : ( + + )} +
+
+ + {/* Status card */} + {status && ( + +
+ {status.pid && PID: {status.pid}} + {status.startedAt && Started: {new Date(status.startedAt).toLocaleString()}} + {status.exitCode !== undefined && status.exitCode !== null && ( + Exit code: {status.exitCode} + )} +
+
+ )} + + {/* Messages area */} + +
+ {messages.length === 0 ? ( +
+
+ +

{isRunning ? "Orchestrator is running. Send a message." : "Start the orchestrator to begin."}

+
+
+ ) : ( + messages.map((msg, i) => ( +
+
+
+ {msg.from || "system"} Ā· {msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : ""} +
+
{msg.payload?.message || msg.payload?.summary || JSON.stringify(msg.payload)}
+
+
+ )) + )} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()} + placeholder={isRunning ? "Message the orchestrator..." : "Start the orchestrator first"} + disabled={!isRunning} + className="flex-1 px-3 py-2 rounded-lg border border-border bg-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:opacity-50" + /> + +
+
+ +
+ ); +} diff --git a/brane/src/components/projects/ProjectsView.jsx b/brane/src/components/projects/ProjectsView.jsx new file mode 100644 index 0000000..20fb13e --- /dev/null +++ b/brane/src/components/projects/ProjectsView.jsx @@ -0,0 +1,71 @@ +import { FolderOpen } from "lucide-react"; +import { useUI } from "../../contexts/UIContext"; +import { useData } from "../../contexts/DataContext"; +import { Card } from "../ui/Card"; +import { CategoryBadge } from "../feed/CategoryBadge"; +import { EmptyState } from "../ui/EmptyState"; + +export function ProjectsView() { + const { setActiveTab, setFilterProject } = useUI(); + const { instructions, projects } = useData(); + + const goToProject = (project) => { + setFilterProject(project); + setActiveTab("feed"); + }; + + if (projects.length === 0) { + return ( +
+

+ Projects +

+ +
+ ); + } + + return ( +
+

+ Projects +

+ +
+ {projects.map((project) => { + const projectInstructions = instructions.filter((i) => i.project === project); + const totalBlockers = projectInstructions.reduce((s, i) => s + (i.stats.blockerCount || 0), 0); + const totalRevisions = projectInstructions.reduce((s, i) => s + (i.stats.revisionCount || 0), 0); + const totalImages = projectInstructions.reduce((s, i) => s + (i.stats.imageCount || 0), 0); + const sources = [...new Set(projectInstructions.map((i) => i.source))]; + + return ( + goToProject(project)} className="p-5 card-hover"> +
+
+ +
+
+

+ {project} +

+

+ {projectInstructions.length} instructions Ā· {sources.join(", ")} Ā· {totalImages} images +

+
+
+ {totalBlockers > 0 && } + {totalRevisions > 0 && } +
+
+
+ ); + })} +
+
+ ); +} diff --git a/brane/src/components/settings/SettingsView.jsx b/brane/src/components/settings/SettingsView.jsx new file mode 100644 index 0000000..fd526d2 --- /dev/null +++ b/brane/src/components/settings/SettingsView.jsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import { Settings, CircleCheck, CircleX, Loader2, RefreshCw, Globe, Zap } from "lucide-react"; +import { Card } from "../ui/Card"; +import { useData } from "../../contexts/DataContext"; + +function StatusDot({ connected }) { + return connected ? ( + + ) : ( + + ); +} + +export function SettingsView() { + const { apiStatus, checkHealth } = useData(); + const [refreshing, setRefreshing] = useState(false); + const handleRefresh = async () => { + setRefreshing(true); + await checkHealth(); + setTimeout(() => setRefreshing(false), 500); + }; + + return ( +
+

+ Settings +

+ + {/* API Status */} + +
+

API Server

+ +
+ {apiStatus ? ( +
+
+ + + {apiStatus.status === "ok" ? "Connected" : "Offline"} + + localhost:3210 +
+ {apiStatus.env && ( + <> +
+ + Slack + + {apiStatus.env.slack ? "Token connected" : "SLACK_BOT_TOKEN not set"} + +
+
+ + Figma + + {apiStatus.env.figma ? "Token connected" : "FIGMA_TOKEN not set"} + +
+
+ Output: {apiStatus.env.output} +
+ + )} +
+ ) : ( +
+ + Checking API status… +
+ )} +
+ + {/* Browser Engine Status */} + +

Browser Engine

+ {apiStatus?.browser ? ( +
+
+ + + {apiStatus.browser.available ? `Active: ${apiStatus.browser.activeEngine}` : "No browser engine"} + + {!apiStatus.browser.available && ( + Fetch-only mode + )} +
+ + {/* Engine list */} + {apiStatus.browser.engines?.length > 0 ? ( +
+ {apiStatus.browser.engines.map((eng) => ( +
+ {eng.name === "cloudflare" ? ( + + ) : ( + + )} + {eng.name} + + {eng.name === apiStatus.browser.activeEngine && ( + + ACTIVE + + )} +
+ ))} +
+ ) : ( +
+ No engines configured. Set CF_API_TOKEN or LIGHTPANDA_URL to enable browser rendering. +
+ )} + + {/* SPA domains */} +
+

+ Auto-browser domains: x.com, twitter.com, notion.so, linear.app, medium.com, substack.com +

+
+
+ ) : ( +
+ {apiStatus ? "Browser info not available" : "Waiting for API…"} +
+ )} +
+ + {/* Environment */} + +

Environment Variables

+
+
+ + + SLACK_BOT_TOKEN=xoxb-... (channels:history, files:read, users:read) + +
+
+ + + FIGMA_TOKEN=figd_... + +
+
+ + + CF_API_TOKEN=your-cloudflare-api-token + + + CF_ACCOUNT_ID=your-account-id + +
+
+ + + LIGHTPANDA_URL=ws://127.0.0.1:9222 + +
+
+ + + API_PORT=3210 (default) + +
+
+
+ + {/* CLI Commands */} + +

CLI Commands

+
+
+ brane scrape <url> -p <project> +
+
+ brane scrape <url> --browser + # force browser engine +
+
+ brane dispatch <url1> <url2> -p <project> +
+
+ brane list +
+
+ npm run dev — starts API + Vite together +
+
+
+
+ ); +} diff --git a/brane/src/components/sources/ScrapeModal.jsx b/brane/src/components/sources/ScrapeModal.jsx new file mode 100644 index 0000000..032e036 --- /dev/null +++ b/brane/src/components/sources/ScrapeModal.jsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { Link2, Zap, FolderOpen } from "lucide-react"; +import { Modal } from "../ui/Modal"; +import { SourceIcon } from "../feed/SourceIcon"; +import { useUI } from "../../contexts/UIContext"; +import { useData } from "../../contexts/DataContext"; + +// Adapter detection (mirrors core/adapters/index.js logic) +function detectSource(url) { + if (/slack\.com\/archives\/[A-Z0-9]+\/p\d+/.test(url)) return "slack"; + if (/figma\.com\/(file|design|proto)\//.test(url)) return "figma"; + if (/(twitter\.com|x\.com)\/\w+\/status\/\d+/.test(url)) return "twitter"; + return "url"; +} + +export function ScrapeModal() { + const { closeScrapeModal } = useUI(); + const { dispatchScrape, projects } = useData(); + const [urls, setUrls] = useState(""); + const [project, setProject] = useState(""); + const [newProject, setNewProject] = useState(""); + + const urlList = urls.split("\n").map((u) => u.trim()).filter(Boolean); + const detected = urlList.map((u) => ({ url: u, source: detectSource(u) })); + + const [dispatching, setDispatching] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setDispatching(true); + const proj = newProject || project; + for (const { url, source } of detected) { + await dispatchScrape(url, proj, source); + } + setDispatching(false); + closeScrapeModal(); + }; + + return ( + +
+

+ New Scrape +

+

+ Paste one or more URLs. Sources are auto-detected. +

+ + {/* URL input */} +
+ +