This repository has no description
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}