This repository has no description
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 7.8 kB View raw
1// Webhook listener. POST /webhook with either: 2// multipart/form-data: video=@recording.mp4 text="<findings>" [video_url=...] 3// application/json: { "videoPath": "...", "videoUrl": "...", "text": "..." } 4// On an event it runs the full loop and replies with the artifacts it created. 5 6import { createWriteStream, readFileSync } from "node:fs"; 7import { mkdir, mkdtemp } from "node:fs/promises"; 8import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; 9import { tmpdir } from "node:os"; 10import { dirname, join, resolve } from "node:path"; 11import { pipeline as streamPipeline } from "node:stream/promises"; 12import { fileURLToPath } from "node:url"; 13import busboy from "busboy"; 14import type { Config } from "./config.js"; 15import { type PostedFinding, processEvent } from "./pipeline.js"; 16import type { WebhookEvent } from "./types.js"; 17import { analyzeWithVision, prewarm } from "./vision.js"; 18 19/** The real pipeline: sweep the recording + source code through Claude vision. */ 20async function analyze(ev: WebhookEvent, cfg: Config, log: (m: string) => void) { 21 const sweepDir = await mkdtemp(join(tmpdir(), "loup-sweep-")); 22 return analyzeWithVision(ev, cfg, sweepDir, log); 23} 24 25/** `--demo`: the known-good PRs/issue, replayed offline (no LLM, no network). */ 26function loadCanned(): PostedFinding[] { 27 const p = resolve(dirname(fileURLToPath(import.meta.url)), "..", "demo", "canned.json"); 28 const data = JSON.parse(readFileSync(p, "utf8")) as { posted: { kind: string; title: string; webUrl: string }[] }; 29 return data.posted.map((x) => ({ 30 kind: x.kind, 31 title: x.title, 32 uri: x.webUrl, 33 webUrl: x.webUrl, 34 file: null, 35 line: null, 36 dryRun: false, 37 })); 38} 39 40export function serve(cfg: Config): void { 41 const server = createServer((req, res) => void handle(req, res, cfg)); 42 server.listen(cfg.port, () => { 43 console.log(`\n◆ loup listening on http://localhost:${cfg.port}/webhook`); 44 if (cfg.demo) { 45 console.log(` mode: DEMO (offline) — replays the known-good PRs, no LLM, no network`); 46 } else { 47 const analysis = 48 cfg.anthropicKey && !cfg.visionOff ? `vision (${cfg.model})` : "vision (set ANTHROPIC_API_KEY)"; 49 console.log(` analysis: ${analysis}`); 50 console.log(` output: ${cfg.live ? `posts to ${cfg.targetRepoName}` : "dry-run (nothing posted)"}`); 51 console.log(` repo source: ${cfg.repoRoot}`); 52 } 53 console.log(` waiting for events…\n`); 54 if (cfg.live && !cfg.demo) void prewarm(cfg); // warm the model connection 55 }); 56} 57 58async function handle(req: IncomingMessage, res: ServerResponse, cfg: Config) { 59 if (req.method !== "POST" || !req.url?.startsWith("/webhook")) { 60 res.writeHead(404).end("not found"); 61 return; 62 } 63 try { 64 const ev = await readEvent(req); 65 console.log(`\n▸ New session recording received — ${ev.videoPath.split("/").pop()}\n`); 66 67 // Staged progress narration (the user watches this scroll while loup works). 68 // Internal step logs are quiet unless LOUP_VERBOSE=1. 69 const verbose = process.env.LOUP_VERBOSE === "1"; 70 const log = verbose ? (m: string) => console.log(" · " + m) : () => {}; 71 const beats = [ 72 "◉ Watching the recording…", 73 "⌖ Spotting where the user got stuck…", 74 "⌕ Locating it in the codebase…", 75 "✎ Drafting fixes and citing the evidence…", 76 ]; 77 const timers: ReturnType<typeof setTimeout>[] = []; 78 let dotTimer: ReturnType<typeof setInterval> | undefined; 79 let dotsActive = false; 80 console.log(" " + beats[0]); 81 for (let i = 1; i < beats.length; i++) { 82 timers.push(setTimeout(() => console.log(" " + beats[i]), i * 3000)); 83 } 84 // After the last beat, an animated "thinking" line — dots written one at a 85 // time, 0→3, then back to 0, repeating until the work finishes. 86 timers.push( 87 setTimeout( 88 () => { 89 dotsActive = true; 90 let n = 0; 91 dotTimer = setInterval(() => { 92 process.stdout.write(`\r ◌ thinking${".".repeat(n)}${" ".repeat(3 - n)}`); 93 n = (n + 1) % 4; 94 }, 450); 95 }, 96 (beats.length - 1) * 3000 + 500, 97 ), 98 ); 99 100 let posted; 101 try { 102 if (cfg.demo) { 103 // offline: replay the known-good PRs on a simulated timer (no LLM, no network) 104 await new Promise((r) => setTimeout(r, Number(process.env.LOUP_DEMO_MS ?? 12000))); 105 posted = loadCanned(); 106 } else { 107 const findings = await analyze(ev, cfg, log); 108 const framesDir = await mkdtemp(join(tmpdir(), "loup-frames-")); 109 posted = await processEvent(ev, findings, cfg, framesDir, log); 110 } 111 } finally { 112 for (const t of timers) clearTimeout(t); 113 if (dotTimer) clearInterval(dotTimer); 114 if (dotsActive) process.stdout.write("\r" + " ".repeat(40) + "\r"); 115 } 116 117 const dry = posted[0]?.dryRun ?? true; 118 const base = `https://tangled.org/${cfg.targetRepoName}`; 119 console.log( 120 `\n✓ Opened ${posted.filter((p) => p.kind === "pr").length} PR(s) + ` + 121 `${posted.filter((p) => p.kind === "issue").length} issue(s)` + 122 `${dry ? " (dry-run — mocked, nothing posted)" : ` on ${cfg.targetRepoName}`}`, 123 ); 124 console.log(" ──────────────────────────────────────────────"); 125 for (const p of posted) { 126 const url = dry 127 ? `${base}/${p.kind === "pr" ? "pulls" : "issues"} (mock)` 128 : (p.webUrl ?? `${base}/${p.kind === "pr" ? "pulls" : "issues"}`); 129 console.log(` ${p.kind === "pr" ? "PR " : "ISSUE"} ${p.title}`); 130 console.log(`${url}`); 131 } 132 console.log(" ──────────────────────────────────────────────\n"); 133 134 res.writeHead(200, { "content-type": "application/json" }).end( 135 JSON.stringify({ ok: true, dryRun: dry, posted }, null, 2), 136 ); 137 } catch (e: any) { 138 console.error("event failed:", e?.message ?? e); 139 res.writeHead(500, { "content-type": "application/json" }).end( 140 JSON.stringify({ ok: false, error: String(e?.message ?? e) }), 141 ); 142 } 143} 144 145async function readEvent(req: IncomingMessage): Promise<WebhookEvent> { 146 const ct = req.headers["content-type"] ?? ""; 147 if (ct.includes("application/json")) { 148 const raw = await readBody(req); 149 const j = JSON.parse(raw.toString("utf8")); 150 return { videoPath: resolve(j.videoPath), videoUrl: j.videoUrl, text: j.text ?? "" }; 151 } 152 // multipart 153 return await new Promise<WebhookEvent>((resolveP, reject) => { 154 const bb = busboy({ headers: req.headers }); 155 const fields: Record<string, string> = {}; 156 let videoPath = ""; 157 const pending: Promise<void>[] = []; 158 bb.on("file", (name, file, info) => { 159 const dir = join(tmpdir(), "loup-uploads"); 160 const dest = join(dir, info.filename || `${name}.bin`); 161 videoPath = dest; 162 pending.push( 163 mkdir(dir, { recursive: true }).then(() => streamPipeline(file, createWriteStream(dest))), 164 ); 165 }); 166 bb.on("field", (name, val) => { 167 fields[name] = val; 168 }); 169 bb.on("close", () => { 170 Promise.all(pending) 171 .then(() => resolveP({ videoPath, videoUrl: fields.video_url, text: fields.text ?? "" })) 172 .catch(reject); 173 }); 174 bb.on("error", reject); 175 req.pipe(bb); 176 }); 177} 178 179function readBody(req: IncomingMessage): Promise<Buffer> { 180 return new Promise((res, rej) => { 181 const chunks: Buffer[] = []; 182 req.on("data", (c) => chunks.push(c)); 183 req.on("end", () => res(Buffer.concat(chunks))); 184 req.on("error", rej); 185 }); 186}