This repository has no description
1

Configure Feed

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

loup — turn user session recordings into cited PRs & issues on Tangled

A webhook harness for Tangled (AT Protocol). POST a screen recording; loup cuts
it into 1-frame-per-second screenshots, feeds them + the app's source code to
Claude vision under a single system prompt, and the model finds where the user
got stuck, locates it in the code, and writes the fix. loup opens a real PR
(patch computed from the model's corrected file via git diff, so it always
applies) or files an issue — each citing the exact moment of confusion as a
screenshot deep-linked into the recording.

loup init scaffold loup.config.json + .env
loup serve the real pipeline (vision + posts to Tangled)
loup serve --demo offline replay of known-good PRs (no LLM, no network)
loup send <video> POST a recording to the listener

Every artifact is a real AT Protocol record (sh.tangled.repo.pull / .issue /
feed.comment) — no bespoke backend; the protocol carries the state.

author
Filip Stål
date (Jun 25, 2026, 6:16 PM +0300) commit ff8d932e
+2269
+5
.gitignore
··· 1 + node_modules/ 2 + .env 3 + dist/ 4 + *.log 5 + .DS_Store
+116
README.md
··· 1 + # 🔎 loup 2 + 3 + **Turn user session recordings into cited pull requests & issues — on [Tangled](https://tangled.org), over the AT Protocol.** 4 + 5 + loup is a webhook harness you drop into your repo. Point your session-recording 6 + provider at it; when a recording comes in, loup finds where the user struggled, 7 + locates it in your code, and opens a **PR** (with a fix) or an **issue** (for the 8 + fuzzy stuff) on Tangled — each citing the **exact moment of confusion** as a 9 + screenshot that deep-links into the recording. 10 + 11 + Every artifact it creates — the PR, the issue, the screenshot citations — is a 12 + real AT Protocol record. No bespoke backend; the protocol carries the state. 13 + 14 + ``` 15 + recording (mp4/mov) + session context 16 + 17 + ▼ ffmpeg cuts citation frames at the hotspots 18 + findings ──► localize in your repo ──► PR (fix) | gzipped patch in a round 19 + │ └► issue | flag for humans 20 + 21 + open on Tangled with inline, video-deep-linked screenshot citations 22 + ``` 23 + 24 + ## Install 25 + 26 + ```bash 27 + npm install -g loup-cli # installs the `loup` command 28 + ``` 29 + 30 + ## Setup 31 + 32 + Run **`loup init`** inside the repo you want loup to operate on. It scaffolds two 33 + files (and adds the secret one to `.gitignore`): 34 + 35 + **`loup.config.json`** — non-secret, safe to commit: 36 + 37 + ```jsonc 38 + { 39 + "service": "https://tngl.sh", 40 + "targetRepo": "your-handle.tngl.sh/your-repo", // repo to open PRs/issues on 41 + "targetRepoDid": "did:plc:…", // that repo's DID 42 + "targetBranch": "main", 43 + "port": 4319 44 + } 45 + ``` 46 + 47 + **`.env`** — secrets, never committed: 48 + 49 + ```bash 50 + TANGLED_HANDLE=your-handle.tngl.sh 51 + TANGLED_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # tngl.sh → Settings → App passwords 52 + # ANTHROPIC_API_KEY=sk-ant-... # optional — turns on the vision pass 53 + ``` 54 + 55 + > **No SSH key needed.** loup posts over the AT Protocol (it writes pull/issue 56 + > *records* to your PDS), not via `git push`. The only credential it needs is a 57 + > Tangled **app password** — generate one at **tngl.sh → Settings → App 58 + > passwords** and paste it into `.env`. Use an app password, not your account 59 + > password. 60 + 61 + ## Use 62 + 63 + ```bash 64 + loup serve # the real pipeline — analyzes & posts to Tangled 65 + loup serve --demo # offline replay of known-good PRs (no LLM, no network) 66 + 67 + # from another terminal, send a recording (a link is optional, for deep-link citations): 68 + loup send recording.mov --url https://youtu.be/VIDEO 69 + ``` 70 + 71 + `loup serve` **posts for real** as soon as `.env` has your Tangled credentials — 72 + it prints the live PR/issue URLs. If those credentials are missing it runs a safe 73 + **dry-run** (analyzes + drafts, posts nothing) instead of erroring. 74 + 75 + `loup serve --demo` replays a fixed set of real, already-open PRs/issue offline — 76 + handy for a guaranteed demo with no network or API key. 77 + 78 + The webhook also accepts a raw POST (`multipart/form-data`: `video`, optional 79 + `video_url`, optional `text`) — exactly the shape a recording provider would send. 80 + 81 + ## How it works 82 + 83 + When a recording arrives: 84 + 85 + 1. **ffmpeg** cuts it into **one screenshot per second**, each with its timestamp 86 + burned in. 87 + 2. loup hands those frames **+ your app's source code** (`sourcePaths`) to a 88 + Claude model (**`claude-opus-4-8`** by default) under a **single system prompt**. 89 + 3. The model watches the session frame by frame, finds every place the user hit a 90 + bug or got confused, cross-references the source for the root cause, and — for 91 + things it can fix — returns the **corrected file**. 92 + 4. loup turns that corrected file into a patch with `git diff` (so it **always 93 + applies**), then opens a **PR** with it, or files an **issue** for the fuzzy 94 + stuff — each citing the exact frame, deep-linked back into the recording. 95 + 96 + Pass optional `--text` notes alongside the recording (a provider's session events, 97 + your own annotations) to nudge the model — but it works from the recording alone. 98 + 99 + ## Why Tangled / AT Protocol 100 + 101 + A pull request is a `sh.tangled.repo.pull` record (patch as a gzipped blob in a 102 + round); an issue is `sh.tangled.repo.issue`; a citation is a `sh.tangled.feed.comment` 103 + whose markdown body embeds the screenshot blob. loup just writes records to its 104 + own PDS — no clone, no push, no server to run for state. Because contribution is 105 + federated, it can open PRs/issues on **any** repo, not just its own. 106 + 107 + ## Config 108 + 109 + | File | What | 110 + |---|---| 111 + | `loup.config.json` | target repo (`targetRepo`, `targetRepoDid`, `targetBranch`), `repoRoot` (defaults to CWD), **`sourcePaths`** (files/dirs to feed the model), `port`, `model` | 112 + | `.env` | `TANGLED_HANDLE`, `TANGLED_APP_PASSWORD`, `ANTHROPIC_API_KEY` (the vision pass), optional `LOUP_MODEL` (never commit) | 113 + 114 + `sourcePaths` points loup at your UI source (templates, components, pages) so the 115 + model can locate issues and write fixes. `loup serve` posts for real once `.env` 116 + has `TANGLED_HANDLE` + `TANGLED_APP_PASSWORD`; without them it dry-runs.
+6
bin/loup.mjs
··· 1 + #!/usr/bin/env node 2 + // Bin launcher: register tsx so we can run the TypeScript sources directly, 3 + // then hand off to the CLI. Lets the package ship source and run with no build. 4 + import { register } from "tsx/esm/api"; 5 + register(); 6 + await import(new URL("../src/cli.ts", import.meta.url).href);
+23
demo/canned.json
··· 1 + { 2 + "note": "Known-good PRs/issue on tangled.org/core. `loup serve --demo` replays these offline (no LLM, no network) so the demo always works.", 3 + "posted": [ 4 + { 5 + "kind": "pr", 6 + "title": "Center the newsletter \"Subscribe\" button label", 7 + "at": "0:09", 8 + "webUrl": "https://tangled.org/tangled.org/core/pulls/1827" 9 + }, 10 + { 11 + "kind": "pr", 12 + "title": "Keep the newsletter form usable after a validation error", 13 + "at": "0:12", 14 + "webUrl": "https://tangled.org/tangled.org/core/pulls/1828" 15 + }, 16 + { 17 + "kind": "issue", 18 + "title": "Newsletter email is only validated server-side, after submit", 19 + "at": "0:12", 20 + "webUrl": "https://tangled.org/tangled.org/core/issues/638" 21 + } 22 + ] 23 + }
+23
demo/send.sh
··· 1 + #!/usr/bin/env bash 2 + # Send a recording to the running loup webhook. 3 + # 4 + # ./demo/send.sh [video] [video_url] [server] 5 + # 6 + # Defaults: ~/Downloads/tangled-bugs.mov, the demo YouTube link, localhost:4319. 7 + # Start the server first in another terminal: npm run serve (or: npx loup serve) 8 + set -euo pipefail 9 + 10 + VIDEO="${1:-$HOME/Downloads/tangled-bugs.mov}" 11 + VIDEO_URL="${2:-https://youtu.be/nzT_EvvxESo}" 12 + SERVER="${3:-http://localhost:4319/webhook}" 13 + 14 + if [[ ! -f "$VIDEO" ]]; then 15 + echo "video not found: $VIDEO" >&2 16 + exit 1 17 + fi 18 + 19 + echo "→ sending $VIDEO to $SERVER (watch the server window for results)…" 20 + curl -s -X POST "$SERVER" \ 21 + -F "video=@${VIDEO}" \ 22 + -F "video_url=${VIDEO_URL}" \ 23 + | python3 -m json.tool
+12
loup.config.json
··· 1 + { 2 + "service": "https://tngl.sh", 3 + "targetRepo": "tangled.org/core", 4 + "targetRepoDid": "did:plc:j5hmlfdrwkvtxm7cjmu7j2is", 5 + "targetBranch": "master", 6 + "repoRoot": "~/dev/tangled-core", 7 + "port": 4319, 8 + "sourcePaths": [ 9 + "appview/pages/templates/timeline/fragments/newsletterForm.html", 10 + "appview/pages/templates/timeline/fragments/newsletterResponse.html" 11 + ] 12 + }
+804
package-lock.json
··· 1 + { 2 + "name": "loup-cli", 3 + "version": "0.3.1", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "loup-cli", 9 + "version": "0.3.1", 10 + "license": "MIT", 11 + "dependencies": { 12 + "@anthropic-ai/sdk": "^0.106.0", 13 + "@atproto/api": "^0.13.18", 14 + "busboy": "^1.6.0", 15 + "tsx": "^4.19.2" 16 + }, 17 + "bin": { 18 + "loup": "bin/loup.mjs" 19 + }, 20 + "devDependencies": { 21 + "@types/busboy": "^1.5.4", 22 + "@types/node": "^22.10.0", 23 + "typescript": "^5.7.2" 24 + }, 25 + "engines": { 26 + "node": ">=20" 27 + } 28 + }, 29 + "node_modules/@anthropic-ai/sdk": { 30 + "version": "0.106.0", 31 + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.106.0.tgz", 32 + "integrity": "sha512-ufwVvYNDBj2dzOGupBCTaNzBLxqcTnGOzI4z8Wouxlt+mT3J3HuOmatgCy1VmwCHOUueqZ41ERhm0O99OUcbWA==", 33 + "license": "MIT", 34 + "dependencies": { 35 + "json-schema-to-ts": "^3.1.1", 36 + "standardwebhooks": "^1.0.0" 37 + }, 38 + "bin": { 39 + "anthropic-ai-sdk": "bin/cli" 40 + }, 41 + "peerDependencies": { 42 + "zod": "^3.25.0 || ^4.0.0" 43 + }, 44 + "peerDependenciesMeta": { 45 + "zod": { 46 + "optional": true 47 + } 48 + } 49 + }, 50 + "node_modules/@atproto/api": { 51 + "version": "0.13.35", 52 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.35.tgz", 53 + "integrity": "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==", 54 + "license": "MIT", 55 + "dependencies": { 56 + "@atproto/common-web": "^0.4.0", 57 + "@atproto/lexicon": "^0.4.6", 58 + "@atproto/syntax": "^0.3.2", 59 + "@atproto/xrpc": "^0.6.8", 60 + "await-lock": "^2.2.2", 61 + "multiformats": "^9.9.0", 62 + "tlds": "^1.234.0", 63 + "zod": "^3.23.8" 64 + } 65 + }, 66 + "node_modules/@atproto/common-web": { 67 + "version": "0.4.21", 68 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.21.tgz", 69 + "integrity": "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==", 70 + "license": "MIT", 71 + "dependencies": { 72 + "@atproto/lex-data": "^0.0.15", 73 + "@atproto/lex-json": "^0.0.16", 74 + "@atproto/syntax": "^0.5.4", 75 + "zod": "^3.23.8" 76 + } 77 + }, 78 + "node_modules/@atproto/common-web/node_modules/@atproto/syntax": { 79 + "version": "0.5.4", 80 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.4.tgz", 81 + "integrity": "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==", 82 + "license": "MIT", 83 + "dependencies": { 84 + "tslib": "^2.8.1" 85 + } 86 + }, 87 + "node_modules/@atproto/lex-data": { 88 + "version": "0.0.15", 89 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.15.tgz", 90 + "integrity": "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==", 91 + "license": "MIT", 92 + "dependencies": { 93 + "multiformats": "^9.9.0", 94 + "tslib": "^2.8.1", 95 + "uint8arrays": "3.0.0", 96 + "unicode-segmenter": "^0.14.0" 97 + } 98 + }, 99 + "node_modules/@atproto/lex-json": { 100 + "version": "0.0.16", 101 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.16.tgz", 102 + "integrity": "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==", 103 + "license": "MIT", 104 + "dependencies": { 105 + "@atproto/lex-data": "^0.0.15", 106 + "tslib": "^2.8.1" 107 + } 108 + }, 109 + "node_modules/@atproto/lexicon": { 110 + "version": "0.4.14", 111 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.14.tgz", 112 + "integrity": "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==", 113 + "license": "MIT", 114 + "dependencies": { 115 + "@atproto/common-web": "^0.4.2", 116 + "@atproto/syntax": "^0.4.0", 117 + "iso-datestring-validator": "^2.2.2", 118 + "multiformats": "^9.9.0", 119 + "zod": "^3.23.8" 120 + } 121 + }, 122 + "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 123 + "version": "0.4.3", 124 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 125 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 126 + "license": "MIT", 127 + "dependencies": { 128 + "tslib": "^2.8.1" 129 + } 130 + }, 131 + "node_modules/@atproto/syntax": { 132 + "version": "0.3.4", 133 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.4.tgz", 134 + "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 135 + "license": "MIT" 136 + }, 137 + "node_modules/@atproto/xrpc": { 138 + "version": "0.6.12", 139 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.12.tgz", 140 + "integrity": "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==", 141 + "license": "MIT", 142 + "dependencies": { 143 + "@atproto/lexicon": "^0.4.10", 144 + "zod": "^3.23.8" 145 + } 146 + }, 147 + "node_modules/@babel/runtime": { 148 + "version": "7.29.7", 149 + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", 150 + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", 151 + "license": "MIT", 152 + "engines": { 153 + "node": ">=6.9.0" 154 + } 155 + }, 156 + "node_modules/@esbuild/aix-ppc64": { 157 + "version": "0.28.1", 158 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", 159 + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", 160 + "cpu": [ 161 + "ppc64" 162 + ], 163 + "license": "MIT", 164 + "optional": true, 165 + "os": [ 166 + "aix" 167 + ], 168 + "engines": { 169 + "node": ">=18" 170 + } 171 + }, 172 + "node_modules/@esbuild/android-arm": { 173 + "version": "0.28.1", 174 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", 175 + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", 176 + "cpu": [ 177 + "arm" 178 + ], 179 + "license": "MIT", 180 + "optional": true, 181 + "os": [ 182 + "android" 183 + ], 184 + "engines": { 185 + "node": ">=18" 186 + } 187 + }, 188 + "node_modules/@esbuild/android-arm64": { 189 + "version": "0.28.1", 190 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", 191 + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", 192 + "cpu": [ 193 + "arm64" 194 + ], 195 + "license": "MIT", 196 + "optional": true, 197 + "os": [ 198 + "android" 199 + ], 200 + "engines": { 201 + "node": ">=18" 202 + } 203 + }, 204 + "node_modules/@esbuild/android-x64": { 205 + "version": "0.28.1", 206 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", 207 + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", 208 + "cpu": [ 209 + "x64" 210 + ], 211 + "license": "MIT", 212 + "optional": true, 213 + "os": [ 214 + "android" 215 + ], 216 + "engines": { 217 + "node": ">=18" 218 + } 219 + }, 220 + "node_modules/@esbuild/darwin-arm64": { 221 + "version": "0.28.1", 222 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", 223 + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", 224 + "cpu": [ 225 + "arm64" 226 + ], 227 + "license": "MIT", 228 + "optional": true, 229 + "os": [ 230 + "darwin" 231 + ], 232 + "engines": { 233 + "node": ">=18" 234 + } 235 + }, 236 + "node_modules/@esbuild/darwin-x64": { 237 + "version": "0.28.1", 238 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", 239 + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", 240 + "cpu": [ 241 + "x64" 242 + ], 243 + "license": "MIT", 244 + "optional": true, 245 + "os": [ 246 + "darwin" 247 + ], 248 + "engines": { 249 + "node": ">=18" 250 + } 251 + }, 252 + "node_modules/@esbuild/freebsd-arm64": { 253 + "version": "0.28.1", 254 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", 255 + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", 256 + "cpu": [ 257 + "arm64" 258 + ], 259 + "license": "MIT", 260 + "optional": true, 261 + "os": [ 262 + "freebsd" 263 + ], 264 + "engines": { 265 + "node": ">=18" 266 + } 267 + }, 268 + "node_modules/@esbuild/freebsd-x64": { 269 + "version": "0.28.1", 270 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", 271 + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", 272 + "cpu": [ 273 + "x64" 274 + ], 275 + "license": "MIT", 276 + "optional": true, 277 + "os": [ 278 + "freebsd" 279 + ], 280 + "engines": { 281 + "node": ">=18" 282 + } 283 + }, 284 + "node_modules/@esbuild/linux-arm": { 285 + "version": "0.28.1", 286 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", 287 + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", 288 + "cpu": [ 289 + "arm" 290 + ], 291 + "license": "MIT", 292 + "optional": true, 293 + "os": [ 294 + "linux" 295 + ], 296 + "engines": { 297 + "node": ">=18" 298 + } 299 + }, 300 + "node_modules/@esbuild/linux-arm64": { 301 + "version": "0.28.1", 302 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", 303 + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", 304 + "cpu": [ 305 + "arm64" 306 + ], 307 + "license": "MIT", 308 + "optional": true, 309 + "os": [ 310 + "linux" 311 + ], 312 + "engines": { 313 + "node": ">=18" 314 + } 315 + }, 316 + "node_modules/@esbuild/linux-ia32": { 317 + "version": "0.28.1", 318 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", 319 + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", 320 + "cpu": [ 321 + "ia32" 322 + ], 323 + "license": "MIT", 324 + "optional": true, 325 + "os": [ 326 + "linux" 327 + ], 328 + "engines": { 329 + "node": ">=18" 330 + } 331 + }, 332 + "node_modules/@esbuild/linux-loong64": { 333 + "version": "0.28.1", 334 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", 335 + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", 336 + "cpu": [ 337 + "loong64" 338 + ], 339 + "license": "MIT", 340 + "optional": true, 341 + "os": [ 342 + "linux" 343 + ], 344 + "engines": { 345 + "node": ">=18" 346 + } 347 + }, 348 + "node_modules/@esbuild/linux-mips64el": { 349 + "version": "0.28.1", 350 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", 351 + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", 352 + "cpu": [ 353 + "mips64el" 354 + ], 355 + "license": "MIT", 356 + "optional": true, 357 + "os": [ 358 + "linux" 359 + ], 360 + "engines": { 361 + "node": ">=18" 362 + } 363 + }, 364 + "node_modules/@esbuild/linux-ppc64": { 365 + "version": "0.28.1", 366 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", 367 + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", 368 + "cpu": [ 369 + "ppc64" 370 + ], 371 + "license": "MIT", 372 + "optional": true, 373 + "os": [ 374 + "linux" 375 + ], 376 + "engines": { 377 + "node": ">=18" 378 + } 379 + }, 380 + "node_modules/@esbuild/linux-riscv64": { 381 + "version": "0.28.1", 382 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", 383 + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", 384 + "cpu": [ 385 + "riscv64" 386 + ], 387 + "license": "MIT", 388 + "optional": true, 389 + "os": [ 390 + "linux" 391 + ], 392 + "engines": { 393 + "node": ">=18" 394 + } 395 + }, 396 + "node_modules/@esbuild/linux-s390x": { 397 + "version": "0.28.1", 398 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", 399 + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", 400 + "cpu": [ 401 + "s390x" 402 + ], 403 + "license": "MIT", 404 + "optional": true, 405 + "os": [ 406 + "linux" 407 + ], 408 + "engines": { 409 + "node": ">=18" 410 + } 411 + }, 412 + "node_modules/@esbuild/linux-x64": { 413 + "version": "0.28.1", 414 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", 415 + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", 416 + "cpu": [ 417 + "x64" 418 + ], 419 + "license": "MIT", 420 + "optional": true, 421 + "os": [ 422 + "linux" 423 + ], 424 + "engines": { 425 + "node": ">=18" 426 + } 427 + }, 428 + "node_modules/@esbuild/netbsd-arm64": { 429 + "version": "0.28.1", 430 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", 431 + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", 432 + "cpu": [ 433 + "arm64" 434 + ], 435 + "license": "MIT", 436 + "optional": true, 437 + "os": [ 438 + "netbsd" 439 + ], 440 + "engines": { 441 + "node": ">=18" 442 + } 443 + }, 444 + "node_modules/@esbuild/netbsd-x64": { 445 + "version": "0.28.1", 446 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", 447 + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", 448 + "cpu": [ 449 + "x64" 450 + ], 451 + "license": "MIT", 452 + "optional": true, 453 + "os": [ 454 + "netbsd" 455 + ], 456 + "engines": { 457 + "node": ">=18" 458 + } 459 + }, 460 + "node_modules/@esbuild/openbsd-arm64": { 461 + "version": "0.28.1", 462 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", 463 + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", 464 + "cpu": [ 465 + "arm64" 466 + ], 467 + "license": "MIT", 468 + "optional": true, 469 + "os": [ 470 + "openbsd" 471 + ], 472 + "engines": { 473 + "node": ">=18" 474 + } 475 + }, 476 + "node_modules/@esbuild/openbsd-x64": { 477 + "version": "0.28.1", 478 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", 479 + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", 480 + "cpu": [ 481 + "x64" 482 + ], 483 + "license": "MIT", 484 + "optional": true, 485 + "os": [ 486 + "openbsd" 487 + ], 488 + "engines": { 489 + "node": ">=18" 490 + } 491 + }, 492 + "node_modules/@esbuild/openharmony-arm64": { 493 + "version": "0.28.1", 494 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", 495 + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", 496 + "cpu": [ 497 + "arm64" 498 + ], 499 + "license": "MIT", 500 + "optional": true, 501 + "os": [ 502 + "openharmony" 503 + ], 504 + "engines": { 505 + "node": ">=18" 506 + } 507 + }, 508 + "node_modules/@esbuild/sunos-x64": { 509 + "version": "0.28.1", 510 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", 511 + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", 512 + "cpu": [ 513 + "x64" 514 + ], 515 + "license": "MIT", 516 + "optional": true, 517 + "os": [ 518 + "sunos" 519 + ], 520 + "engines": { 521 + "node": ">=18" 522 + } 523 + }, 524 + "node_modules/@esbuild/win32-arm64": { 525 + "version": "0.28.1", 526 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", 527 + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", 528 + "cpu": [ 529 + "arm64" 530 + ], 531 + "license": "MIT", 532 + "optional": true, 533 + "os": [ 534 + "win32" 535 + ], 536 + "engines": { 537 + "node": ">=18" 538 + } 539 + }, 540 + "node_modules/@esbuild/win32-ia32": { 541 + "version": "0.28.1", 542 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", 543 + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", 544 + "cpu": [ 545 + "ia32" 546 + ], 547 + "license": "MIT", 548 + "optional": true, 549 + "os": [ 550 + "win32" 551 + ], 552 + "engines": { 553 + "node": ">=18" 554 + } 555 + }, 556 + "node_modules/@esbuild/win32-x64": { 557 + "version": "0.28.1", 558 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", 559 + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", 560 + "cpu": [ 561 + "x64" 562 + ], 563 + "license": "MIT", 564 + "optional": true, 565 + "os": [ 566 + "win32" 567 + ], 568 + "engines": { 569 + "node": ">=18" 570 + } 571 + }, 572 + "node_modules/@stablelib/base64": { 573 + "version": "1.0.1", 574 + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", 575 + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", 576 + "license": "MIT" 577 + }, 578 + "node_modules/@types/busboy": { 579 + "version": "1.5.4", 580 + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz", 581 + "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==", 582 + "dev": true, 583 + "license": "MIT", 584 + "dependencies": { 585 + "@types/node": "*" 586 + } 587 + }, 588 + "node_modules/@types/node": { 589 + "version": "22.20.0", 590 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", 591 + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", 592 + "dev": true, 593 + "license": "MIT", 594 + "dependencies": { 595 + "undici-types": "~6.21.0" 596 + } 597 + }, 598 + "node_modules/await-lock": { 599 + "version": "2.2.2", 600 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 601 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", 602 + "license": "MIT" 603 + }, 604 + "node_modules/busboy": { 605 + "version": "1.6.0", 606 + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", 607 + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", 608 + "dependencies": { 609 + "streamsearch": "^1.1.0" 610 + }, 611 + "engines": { 612 + "node": ">=10.16.0" 613 + } 614 + }, 615 + "node_modules/esbuild": { 616 + "version": "0.28.1", 617 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", 618 + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", 619 + "hasInstallScript": true, 620 + "license": "MIT", 621 + "bin": { 622 + "esbuild": "bin/esbuild" 623 + }, 624 + "engines": { 625 + "node": ">=18" 626 + }, 627 + "optionalDependencies": { 628 + "@esbuild/aix-ppc64": "0.28.1", 629 + "@esbuild/android-arm": "0.28.1", 630 + "@esbuild/android-arm64": "0.28.1", 631 + "@esbuild/android-x64": "0.28.1", 632 + "@esbuild/darwin-arm64": "0.28.1", 633 + "@esbuild/darwin-x64": "0.28.1", 634 + "@esbuild/freebsd-arm64": "0.28.1", 635 + "@esbuild/freebsd-x64": "0.28.1", 636 + "@esbuild/linux-arm": "0.28.1", 637 + "@esbuild/linux-arm64": "0.28.1", 638 + "@esbuild/linux-ia32": "0.28.1", 639 + "@esbuild/linux-loong64": "0.28.1", 640 + "@esbuild/linux-mips64el": "0.28.1", 641 + "@esbuild/linux-ppc64": "0.28.1", 642 + "@esbuild/linux-riscv64": "0.28.1", 643 + "@esbuild/linux-s390x": "0.28.1", 644 + "@esbuild/linux-x64": "0.28.1", 645 + "@esbuild/netbsd-arm64": "0.28.1", 646 + "@esbuild/netbsd-x64": "0.28.1", 647 + "@esbuild/openbsd-arm64": "0.28.1", 648 + "@esbuild/openbsd-x64": "0.28.1", 649 + "@esbuild/openharmony-arm64": "0.28.1", 650 + "@esbuild/sunos-x64": "0.28.1", 651 + "@esbuild/win32-arm64": "0.28.1", 652 + "@esbuild/win32-ia32": "0.28.1", 653 + "@esbuild/win32-x64": "0.28.1" 654 + } 655 + }, 656 + "node_modules/fast-sha256": { 657 + "version": "1.3.0", 658 + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", 659 + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", 660 + "license": "Unlicense" 661 + }, 662 + "node_modules/fsevents": { 663 + "version": "2.3.3", 664 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 665 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 666 + "hasInstallScript": true, 667 + "license": "MIT", 668 + "optional": true, 669 + "os": [ 670 + "darwin" 671 + ], 672 + "engines": { 673 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 674 + } 675 + }, 676 + "node_modules/iso-datestring-validator": { 677 + "version": "2.2.2", 678 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 679 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 680 + "license": "MIT" 681 + }, 682 + "node_modules/json-schema-to-ts": { 683 + "version": "3.1.1", 684 + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", 685 + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", 686 + "license": "MIT", 687 + "dependencies": { 688 + "@babel/runtime": "^7.18.3", 689 + "ts-algebra": "^2.0.0" 690 + }, 691 + "engines": { 692 + "node": ">=16" 693 + } 694 + }, 695 + "node_modules/multiformats": { 696 + "version": "9.9.0", 697 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 698 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 699 + "license": "(Apache-2.0 AND MIT)" 700 + }, 701 + "node_modules/standardwebhooks": { 702 + "version": "1.0.0", 703 + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", 704 + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", 705 + "license": "MIT", 706 + "dependencies": { 707 + "@stablelib/base64": "^1.0.0", 708 + "fast-sha256": "^1.3.0" 709 + } 710 + }, 711 + "node_modules/streamsearch": { 712 + "version": "1.1.0", 713 + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", 714 + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", 715 + "engines": { 716 + "node": ">=10.0.0" 717 + } 718 + }, 719 + "node_modules/tlds": { 720 + "version": "1.261.0", 721 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", 722 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 723 + "license": "MIT", 724 + "bin": { 725 + "tlds": "bin.js" 726 + } 727 + }, 728 + "node_modules/ts-algebra": { 729 + "version": "2.0.0", 730 + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", 731 + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", 732 + "license": "MIT" 733 + }, 734 + "node_modules/tslib": { 735 + "version": "2.8.1", 736 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 737 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 738 + "license": "0BSD" 739 + }, 740 + "node_modules/tsx": { 741 + "version": "4.22.4", 742 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", 743 + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", 744 + "license": "MIT", 745 + "dependencies": { 746 + "esbuild": "~0.28.0" 747 + }, 748 + "bin": { 749 + "tsx": "dist/cli.mjs" 750 + }, 751 + "engines": { 752 + "node": ">=18.0.0" 753 + }, 754 + "optionalDependencies": { 755 + "fsevents": "~2.3.3" 756 + } 757 + }, 758 + "node_modules/typescript": { 759 + "version": "5.9.3", 760 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 761 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 762 + "dev": true, 763 + "license": "Apache-2.0", 764 + "bin": { 765 + "tsc": "bin/tsc", 766 + "tsserver": "bin/tsserver" 767 + }, 768 + "engines": { 769 + "node": ">=14.17" 770 + } 771 + }, 772 + "node_modules/uint8arrays": { 773 + "version": "3.0.0", 774 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 775 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 776 + "license": "MIT", 777 + "dependencies": { 778 + "multiformats": "^9.4.2" 779 + } 780 + }, 781 + "node_modules/undici-types": { 782 + "version": "6.21.0", 783 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 784 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 785 + "dev": true, 786 + "license": "MIT" 787 + }, 788 + "node_modules/unicode-segmenter": { 789 + "version": "0.14.5", 790 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 791 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 792 + "license": "MIT" 793 + }, 794 + "node_modules/zod": { 795 + "version": "3.25.76", 796 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 797 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 798 + "license": "MIT", 799 + "funding": { 800 + "url": "https://github.com/sponsors/colinhacks" 801 + } 802 + } 803 + } 804 + }
+51
package.json
··· 1 + { 2 + "name": "loup-cli", 3 + "version": "0.4.0", 4 + "description": "Turn user session recordings into cited pull requests and issues on Tangled", 5 + "keywords": [ 6 + "tangled", 7 + "atproto", 8 + "session-recording", 9 + "code-review", 10 + "pull-request", 11 + "webhook", 12 + "agent" 13 + ], 14 + "license": "MIT", 15 + "author": "Filip Stål", 16 + "homepage": "https://tangled.org/filipstal.tngl.sh/loup", 17 + "repository": { 18 + "type": "git", 19 + "url": "git+https://tangled.org/filipstal.tngl.sh/loup.git" 20 + }, 21 + "type": "module", 22 + "bin": { 23 + "loup": "bin/loup.mjs" 24 + }, 25 + "engines": { 26 + "node": ">=20" 27 + }, 28 + "files": [ 29 + "bin", 30 + "src", 31 + "demo", 32 + "loup.config.json", 33 + "README.md" 34 + ], 35 + "scripts": { 36 + "serve": "node bin/loup.mjs serve", 37 + "demo": "node bin/loup.mjs serve --demo", 38 + "typecheck": "tsc --noEmit" 39 + }, 40 + "dependencies": { 41 + "@anthropic-ai/sdk": "^0.106.0", 42 + "@atproto/api": "^0.13.18", 43 + "busboy": "^1.6.0", 44 + "tsx": "^4.19.2" 45 + }, 46 + "devDependencies": { 47 + "@types/busboy": "^1.5.4", 48 + "@types/node": "^22.10.0", 49 + "typescript": "^5.7.2" 50 + } 51 + }
+170
src/cli.ts
··· 1 + // loup — turn user session recordings into cited PRs/issues on Tangled. 2 + // 3 + // loup init scaffold loup.config.json + .env in this repo 4 + // loup serve start the webhook listener 5 + // loup send <video> [opts] POST a recording to the running listener 6 + // 7 + // Run inside the repo you want loup to operate on (repoRoot defaults to CWD). 8 + 9 + import { readFile, writeFile } from "node:fs/promises"; 10 + import { existsSync } from "node:fs"; 11 + import { basename, resolve } from "node:path"; 12 + import { canGoLive, loadConfig } from "./config.js"; 13 + import { serve } from "./server.js"; 14 + 15 + const CONFIG_TEMPLATE = { 16 + // Tangled PDS endpoint. tngl.sh is the hosted instance. 17 + service: "https://tngl.sh", 18 + // The repo loup opens PRs/issues against — "<handle>/<repo>". 19 + targetRepo: "your-handle.tngl.sh/your-repo", 20 + // That repo's DID (find it on the repo page, or `loup` will tell you if it's wrong). 21 + targetRepoDid: "did:plc:...", 22 + // Branch the patches apply onto. 23 + targetBranch: "main", 24 + // Source files/dirs (repo-relative) loup feeds the model so it can locate + fix 25 + // issues. Point this at your UI source — templates, components, pages. 26 + sourcePaths: ["src", "templates"], 27 + // Local port the webhook listener binds. 28 + port: 4319, 29 + }; 30 + 31 + const ENV_TEMPLATE = `# ── loup credentials ──────────────────────────────────────────────── 32 + # loup posts via the AT Protocol (it creates pull/issue records on your PDS). 33 + # You do NOT need an SSH key for this — that's only for \`git push\`. You need a 34 + # Tangled handle + an app password. 35 + # 36 + # 1. TANGLED_HANDLE — your Tangled handle, e.g. alice.tngl.sh 37 + # 2. TANGLED_APP_PASSWORD — create one at https://tngl.sh → Settings → 38 + # App passwords. It looks like xxxx-xxxx-xxxx-xxxx. Use an app password, 39 + # NOT your account password. 40 + TANGLED_HANDLE=your-handle.tngl.sh 41 + TANGLED_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 42 + 43 + # ── optional: Claude vision ───────────────────────────────────────── 44 + # With a key, loup watches the recording and confirms each bug from the actual 45 + # frames before opening a PR. 46 + # ANTHROPIC_API_KEY=sk-ant-... 47 + # LOUP_MODEL=claude-sonnet-4-6 48 + `; 49 + 50 + const [cmd, ...rest] = process.argv.slice(2); 51 + 52 + switch (cmd) { 53 + case "serve": 54 + case undefined: { 55 + // `loup serve` → the real pipeline (vision + posts to Tangled). 56 + // `loup serve --demo` → offline replay of the known-good PRs (no LLM, no network). 57 + if (rest.includes("--demo")) { 58 + process.env.LOUP_DEMO = "1"; 59 + } else { 60 + const { ok, missing } = canGoLive(loadConfig()); 61 + if (ok) process.env.LOUP_LIVE = "1"; 62 + else 63 + console.warn( 64 + `⚠ dry-run — missing ${missing.join(", ")}. ` + 65 + `Run \`loup init\` and fill them in to open real PRs.`, 66 + ); 67 + } 68 + serve(loadConfig()); 69 + break; 70 + } 71 + case "init": 72 + await init(); 73 + break; 74 + case "send": 75 + await send(rest); 76 + break; 77 + case "-h": 78 + case "--help": 79 + help(); 80 + break; 81 + default: 82 + console.error(`unknown command: ${cmd}`); 83 + help(); 84 + process.exit(1); 85 + } 86 + 87 + function help() { 88 + console.log(`loup — turn session recordings into cited PRs/issues on Tangled 89 + 90 + usage: 91 + loup init scaffold loup.config.json + .env (run this first) 92 + loup serve start the webhook listener in this repo 93 + loup serve --demo offline replay of the known-good PRs (no LLM/network) 94 + loup send <video> [opts] POST a recording to the running listener 95 + --url <link> source video URL (cited in the PR) 96 + --text <file> optional notes for extra context 97 + --server <url> listener URL (default localhost:PORT) 98 + 99 + setup (see \`loup init\`): 100 + loup.config.json target repo, branch, port (committed, no secrets) 101 + .env TANGLED_HANDLE + TANGLED_APP_PASSWORD (secret, gitignored) 102 + ANTHROPIC_API_KEY (optional) enables the Claude vision pass 103 + 104 + \`loup serve\` posts for real once .env has your Tangled credentials; until then 105 + it runs a safe dry-run (analyzes + drafts, posts nothing). 106 + `); 107 + } 108 + 109 + async function init() { 110 + const cwd = process.cwd(); 111 + const cfgPath = resolve(cwd, "loup.config.json"); 112 + const envPath = resolve(cwd, ".env"); 113 + const gitignorePath = resolve(cwd, ".gitignore"); 114 + 115 + if (!existsSync(cfgPath)) { 116 + await writeFile(cfgPath, JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n"); 117 + console.log("✅ wrote loup.config.json"); 118 + } else { 119 + console.log("• loup.config.json already exists — left as is"); 120 + } 121 + 122 + if (!existsSync(envPath)) { 123 + await writeFile(envPath, ENV_TEMPLATE); 124 + console.log("✅ wrote .env"); 125 + } else { 126 + console.log("• .env already exists — left as is"); 127 + } 128 + 129 + // Make sure the secret file never gets committed. 130 + const ignore = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : ""; 131 + if (!/^\.env$/m.test(ignore)) { 132 + await writeFile(gitignorePath, (ignore && !ignore.endsWith("\n") ? ignore + "\n" : ignore) + ".env\n"); 133 + console.log("✅ added .env to .gitignore"); 134 + } 135 + 136 + console.log(` 137 + next steps: 138 + 1. edit loup.config.json — set "targetRepo" + "targetRepoDid" 139 + 2. edit .env — set TANGLED_HANDLE + TANGLED_APP_PASSWORD 140 + app password: https://tngl.sh → Settings → App passwords 141 + (optional) ANTHROPIC_API_KEY to enable the Claude vision pass 142 + 3. run \`loup serve\` inside the repo you want loup to operate on 143 + `); 144 + } 145 + 146 + async function send(args: string[]) { 147 + const video = args.find((a) => !a.startsWith("--")); 148 + if (!video) { 149 + console.error("usage: loup send <video> [--url <video_url>] [--text <file>] [--server <url>]"); 150 + process.exit(1); 151 + } 152 + const opt = (name: string) => { 153 + const i = args.indexOf(`--${name}`); 154 + return i >= 0 ? args[i + 1] : undefined; 155 + }; 156 + const server = opt("server") ?? `http://localhost:${loadConfig().port}/webhook`; 157 + const textFile = opt("text"); 158 + const text = textFile ? await readFile(resolve(textFile), "utf8") : ""; 159 + const url = opt("url"); 160 + 161 + const bytes = await readFile(resolve(video)); 162 + const fd = new FormData(); 163 + fd.append("video", new Blob([bytes]), basename(video)); 164 + if (url) fd.append("video_url", url); 165 + fd.append("text", text); 166 + 167 + console.log(`→ sending ${basename(video)} to ${server} …`); 168 + const res = await fetch(server, { method: "POST", body: fd }); 169 + console.log(await res.text()); 170 + }
+96
src/config.ts
··· 1 + // Config = committed loup.config.json (non-secret: target repo, branch, repo root) 2 + // + .env (secret: handle, app password). Both read from CWD, falling back to the 3 + // package dir. repoRoot defaults to the CWD — so `loup serve` run inside a repo 4 + // operates on that repo. 5 + 6 + import { existsSync, readFileSync } from "node:fs"; 7 + import { dirname, resolve } from "node:path"; 8 + import { fileURLToPath } from "node:url"; 9 + 10 + const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); 11 + 12 + export interface Config { 13 + live: boolean; 14 + service: string; 15 + handle: string | undefined; 16 + appPassword: string | undefined; 17 + targetRepoDid: string | undefined; 18 + targetRepoName: string; 19 + targetBranch: string; 20 + repoRoot: string; 21 + /** Source files/dirs (repo-relative) loup feeds the model so it can locate + fix issues. */ 22 + sourcePaths: string[]; 23 + port: number; 24 + anthropicKey: string | undefined; 25 + /** Vision model. Override with LOUP_MODEL. */ 26 + model: string; 27 + /** When true, skip the vision pass. */ 28 + visionOff: boolean; 29 + /** Offline canned demo (`--demo`): replay the known-good PRs, no LLM/network. */ 30 + demo: boolean; 31 + } 32 + 33 + function expandHome(p: string): string { 34 + return p.startsWith("~/") ? resolve(process.env.HOME ?? "", p.slice(2)) : resolve(p); 35 + } 36 + 37 + function loadEnvFile(): void { 38 + for (const dir of [process.cwd(), PKG_ROOT]) { 39 + const p = resolve(dir, ".env"); 40 + if (!existsSync(p)) continue; 41 + for (const raw of readFileSync(p, "utf8").split("\n")) { 42 + const line = raw.trim(); 43 + if (!line || line.startsWith("#")) continue; 44 + const i = line.indexOf("="); 45 + if (i === -1) continue; 46 + const k = line.slice(0, i).trim(); 47 + const v = line.slice(i + 1).trim(); 48 + if (k && process.env[k] === undefined) process.env[k] = v; 49 + } 50 + break; 51 + } 52 + } 53 + 54 + function loadJsonConfig(): Record<string, unknown> { 55 + for (const dir of [process.cwd(), PKG_ROOT]) { 56 + const p = resolve(dir, "loup.config.json"); 57 + if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")); 58 + } 59 + return {}; 60 + } 61 + 62 + export function loadConfig(): Config { 63 + loadEnvFile(); 64 + const j = loadJsonConfig(); 65 + const env = process.env; 66 + const repoRoot = expandHome((env.LOUP_REPO_ROOT ?? (j.repoRoot as string)) || process.cwd()); 67 + return { 68 + live: env.LOUP_LIVE === "1" || env.LOUP_LIVE === "true", 69 + service: (j.service as string) ?? "https://tngl.sh", 70 + handle: env.TANGLED_HANDLE, 71 + appPassword: env.TANGLED_APP_PASSWORD, 72 + targetRepoDid: env.TANGLED_TARGET_REPO_DID ?? (j.targetRepoDid as string), 73 + targetRepoName: (j.targetRepo as string) ?? "unknown/repo", 74 + targetBranch: (j.targetBranch as string) ?? "main", 75 + repoRoot, 76 + sourcePaths: Array.isArray(j.sourcePaths) ? (j.sourcePaths as string[]) : [], 77 + port: Number(env.PORT ?? j.port ?? 4319), 78 + anthropicKey: env.ANTHROPIC_API_KEY, 79 + model: env.LOUP_MODEL ?? (j.model as string) ?? "claude-opus-4-8", 80 + visionOff: env.LOUP_NO_VISION === "1" || env.LOUP_NO_VISION === "true", 81 + demo: env.LOUP_DEMO === "1" || env.LOUP_DEMO === "true", 82 + }; 83 + } 84 + 85 + // pds.ts (copied) imports resolveRepoRoot; keep a compatible shim. 86 + export function resolveRepoRoot(cfg: Config, _fallback: string): string { 87 + return cfg.repoRoot; 88 + } 89 + 90 + export function canGoLive(c: Config): { ok: boolean; missing: string[] } { 91 + const missing: string[] = []; 92 + if (!c.handle) missing.push("TANGLED_HANDLE"); 93 + if (!c.appPassword) missing.push("TANGLED_APP_PASSWORD"); 94 + if (!c.targetRepoDid) missing.push("targetRepoDid"); 95 + return { ok: missing.length === 0, missing }; 96 + }
+46
src/frames.ts
··· 1 + // Frame extraction via ffmpeg. Operates on a local video FILE — it decodes frames, 2 + // it does not "play" anything. (mp4/mov in → png out.) 3 + 4 + import { execFile } from "node:child_process"; 5 + import { mkdir } from "node:fs/promises"; 6 + import { resolve } from "node:path"; 7 + import { promisify } from "node:util"; 8 + 9 + const exec = promisify(execFile); 10 + 11 + /** "1:23" | "0:09" | "83" -> seconds. */ 12 + export function toSeconds(at: string): number { 13 + const parts = at.split(":").map(Number); 14 + if (parts.some((n) => Number.isNaN(n))) return 0; 15 + return parts.reduce((acc, n) => acc * 60 + n, 0); 16 + } 17 + 18 + function hhmmss(sec: number): string { 19 + const h = Math.floor(sec / 3600); 20 + const m = Math.floor((sec % 3600) / 60); 21 + const s = Math.floor(sec % 60); 22 + return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":"); 23 + } 24 + 25 + /** Cut a single frame at `at` from `videoPath` into outDir; returns the png path. */ 26 + export async function extractFrameAt(videoPath: string, at: string, outDir: string): Promise<string> { 27 + await mkdir(outDir, { recursive: true }); 28 + const sec = toSeconds(at); 29 + const out = resolve(outDir, `frame_${String(sec).padStart(4, "0")}s.png`); 30 + // -ss before -i = fast, frame-accurate seek; one frame; scale down for a ≤1MB blob. 31 + await exec("ffmpeg", [ 32 + "-y", 33 + "-ss", 34 + hhmmss(sec), 35 + "-i", 36 + videoPath, 37 + "-frames:v", 38 + "1", 39 + "-vf", 40 + "scale='min(1600,iw)':-2", 41 + out, 42 + "-loglevel", 43 + "error", 44 + ]); 45 + return out; 46 + }
+129
src/lexicon.ts
··· 1 + // Builders for the Tangled AT Protocol records we create. 2 + // 3 + // Shapes verified against tangled.org/core lexicons + generated Go types 4 + // (see docs/lexicon-spec.md). Notable realities: 5 + // • pull patch is a GZIPPED BLOB in rounds[].patchBlob — there is no `patch` string. 6 + // • comments are `sh.tangled.feed.comment` with a `markup.markdown` body object; 7 + // images ride in body.blobs[] and are referenced from body.text. 8 + // • repos are keyed by DID (v1.14): target.repo / issue.repo = target repo DID, 9 + // while createRecord.repo = the AUTHORING account DID. 10 + 11 + export const NSID = { 12 + pull: "sh.tangled.repo.pull", 13 + issue: "sh.tangled.repo.issue", 14 + comment: "sh.tangled.feed.comment", 15 + markdown: "sh.tangled.markup.markdown", 16 + } as const; 17 + 18 + /** atproto blob ref object as returned by uploadBlob; embedded verbatim in records. */ 19 + export interface BlobRef { 20 + $type: "blob"; 21 + ref: { $link: string }; 22 + mimeType: string; 23 + size: number; 24 + } 25 + 26 + /** com.atproto.repo.strongRef — uri + cid of a record. */ 27 + export interface StrongRef { 28 + uri: string; 29 + cid: string; 30 + } 31 + 32 + // --- pull ------------------------------------------------------------------- 33 + 34 + export interface BuildPullArgs { 35 + targetRepoDid: string; // target repository DID 36 + targetBranch: string; 37 + title: string; 38 + body: string; 39 + patchBlob: BlobRef; // gzipped git format-patch, already uploaded 40 + createdAt: string; 41 + sourceBranch?: string; // optional; omit for patch-only PRs 42 + sourceRepoDid?: string; 43 + } 44 + 45 + export function buildPullRecord(a: BuildPullArgs) { 46 + const record: Record<string, unknown> = { 47 + $type: NSID.pull, 48 + title: a.title, 49 + body: a.body, 50 + target: { repo: a.targetRepoDid, branch: a.targetBranch }, 51 + rounds: [{ patchBlob: a.patchBlob, createdAt: a.createdAt }], 52 + createdAt: a.createdAt, 53 + }; 54 + if (a.sourceBranch) { 55 + record.source = a.sourceRepoDid 56 + ? { branch: a.sourceBranch, repo: a.sourceRepoDid } 57 + : { branch: a.sourceBranch }; 58 + } 59 + return record; 60 + } 61 + 62 + // --- issue ------------------------------------------------------------------ 63 + 64 + export interface BuildIssueArgs { 65 + targetRepoDid: string; 66 + title: string; 67 + body: string; 68 + createdAt: string; 69 + } 70 + 71 + export function buildIssueRecord(a: BuildIssueArgs) { 72 + return { 73 + $type: NSID.issue, 74 + repo: a.targetRepoDid, 75 + title: a.title, 76 + body: a.body, 77 + createdAt: a.createdAt, 78 + }; 79 + } 80 + 81 + // --- comment (feed.comment with markdown body) ------------------------------ 82 + 83 + export interface MarkdownBody { 84 + $type: typeof NSID.markdown; 85 + text: string; 86 + original: string; 87 + blobs?: BlobRef[]; 88 + } 89 + 90 + export function buildMarkdown(text: string, blobs?: BlobRef[]): MarkdownBody { 91 + const body: MarkdownBody = { $type: NSID.markdown, text, original: text }; 92 + if (blobs && blobs.length) body.blobs = blobs; 93 + return body; 94 + } 95 + 96 + export interface BuildCommentArgs { 97 + subject: StrongRef; // the issue/pull record being commented on 98 + body: MarkdownBody; 99 + createdAt: string; 100 + replyTo?: StrongRef; 101 + /** Required when subject is a pull — which round the comment is on (0-based). */ 102 + pullRoundIdx?: number; 103 + } 104 + 105 + export function buildCommentRecord(a: BuildCommentArgs) { 106 + const record: Record<string, unknown> = { 107 + $type: NSID.comment, 108 + subject: a.subject, 109 + body: a.body, 110 + createdAt: a.createdAt, 111 + }; 112 + if (a.replyTo) record.replyTo = a.replyTo; 113 + if (a.pullRoundIdx !== undefined) record.pullRoundIdx = a.pullRoundIdx; 114 + return record; 115 + } 116 + 117 + // --- helpers ---------------------------------------------------------------- 118 + 119 + /** Standard markdown image embedding an uploaded blob by its CDN url. */ 120 + export function imageMarkdown(blobUrl: string, alt: string): string { 121 + return `![${alt}](${blobUrl})`; 122 + } 123 + 124 + /** com.atproto.sync.getBlob CDN url for a blob (used to embed in markdown text). */ 125 + export function blobCdnUrl(service: string, did: string, cid: string): string { 126 + return `${service.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent( 127 + did, 128 + )}&cid=${encodeURIComponent(cid)}`; 129 + }
+142
src/pds.ts
··· 1 + // Thin wrapper over the atproto client for talking to Tangled's PDS. 2 + // 3 + // In DRY-RUN (default) nothing is transmitted: uploads return a structurally-valid 4 + // fake blob ref and createRecord just echoes the would-be payload. Flip LOUP_LIVE=1 5 + // with creds to actually open PRs/issues + post screenshot comments on Tangled. 6 + 7 + import { readFile } from "node:fs/promises"; 8 + import { basename } from "node:path"; 9 + import { gzipSync } from "node:zlib"; 10 + import { AtpAgent } from "@atproto/api"; 11 + import type { Config } from "./config.js"; 12 + import { blobCdnUrl, type BlobRef } from "./lexicon.js"; 13 + 14 + export interface PostedRecord { 15 + uri: string; 16 + cid: string; 17 + collection: string; 18 + record: unknown; 19 + dryRun: boolean; 20 + } 21 + 22 + export interface UploadedBlob { 23 + ref: BlobRef; // drop verbatim into patchBlob / blobs[] 24 + url: string; // markdown-embeddable CDN url 25 + cid: string; 26 + dryRun: boolean; 27 + } 28 + 29 + export class TangledClient { 30 + private agent: AtpAgent | null = null; 31 + private did = "did:plc:DRYRUN_AUTHOR"; 32 + constructor(private cfg: Config) {} 33 + 34 + get live() { 35 + return this.cfg.live; 36 + } 37 + get authorDid() { 38 + return this.did; 39 + } 40 + 41 + async login(): Promise<void> { 42 + if (!this.cfg.live) return; // dry-run: no network 43 + if (this.agent) return; // already logged in — reuse the session (avoids per-run createSession) 44 + if (!this.cfg.handle || !this.cfg.appPassword) { 45 + throw new Error("Live mode needs TANGLED_HANDLE + TANGLED_APP_PASSWORD"); 46 + } 47 + this.agent = new AtpAgent({ service: this.cfg.service }); 48 + const res = await this.agent.login({ 49 + identifier: this.cfg.handle, 50 + password: this.cfg.appPassword, 51 + }); 52 + this.did = res.data.did; 53 + } 54 + 55 + /** Upload an image file (for comment citations). */ 56 + async uploadImage(path: string): Promise<UploadedBlob> { 57 + const bytes = await readFile(path); 58 + return this.uploadBytes(bytes, mimeFor(path), basename(path)); 59 + } 60 + 61 + /** gzip a git format-patch and upload it as an application/gzip blob (for a PR round). */ 62 + async uploadPatch(patchText: string): Promise<UploadedBlob> { 63 + const gz = gzipSync(Buffer.from(patchText, "utf8")); 64 + return this.uploadBytes(gz, "application/gzip", "patch.gz"); 65 + } 66 + 67 + private async uploadBytes(bytes: Buffer, mime: string, label: string): Promise<UploadedBlob> { 68 + if (!this.cfg.live || !this.agent) { 69 + const cid = fakeCid(label); 70 + return { 71 + ref: { $type: "blob", ref: { $link: cid }, mimeType: mime, size: bytes.length }, 72 + url: blobCdnUrl(this.cfg.service, this.did, cid), 73 + cid, 74 + dryRun: true, 75 + }; 76 + } 77 + const res = await this.agent.uploadBlob(bytes, { encoding: mime }); 78 + const blob = res.data.blob; 79 + const ref = blob.ref as { toString?: () => string; $link?: string } | undefined; 80 + const cid = ref?.toString?.() ?? ref?.$link ?? ""; 81 + return { 82 + ref: { 83 + $type: "blob", 84 + ref: { $link: cid }, 85 + mimeType: blob.mimeType ?? mime, 86 + size: blob.size ?? bytes.length, 87 + }, 88 + url: blobCdnUrl(this.cfg.service, this.did, cid), 89 + cid, 90 + dryRun: false, 91 + }; 92 + } 93 + 94 + /** Create a record in the agent's own repo. Returns the at-uri + cid. */ 95 + async createRecord(collection: string, record: unknown): Promise<PostedRecord> { 96 + if (!this.cfg.live || !this.agent) { 97 + const rkey = fakeRkey(); 98 + return { 99 + uri: `at://${this.did}/${collection}/${rkey}`, 100 + cid: `bafyreidryrun${rkey}`, 101 + collection, 102 + record, 103 + dryRun: true, 104 + }; 105 + } 106 + const res = await this.agent.com.atproto.repo.createRecord({ 107 + repo: this.did, // authoring account DID 108 + collection, 109 + record: record as Record<string, unknown>, 110 + // Tangled lexicons may be unknown to a vanilla PDS; skip validation to be safe. 111 + validate: false, 112 + }); 113 + return { 114 + uri: res.data.uri, 115 + cid: res.data.cid, 116 + collection, 117 + record, 118 + dryRun: false, 119 + }; 120 + } 121 + } 122 + 123 + function mimeFor(path: string): string { 124 + if (path.endsWith(".png")) return "image/png"; 125 + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg"; 126 + if (path.endsWith(".webp")) return "image/webp"; 127 + if (path.endsWith(".gif")) return "image/gif"; 128 + return "application/octet-stream"; 129 + } 130 + 131 + let blobCounter = 0; 132 + function fakeCid(label: string): string { 133 + blobCounter += 1; 134 + const tag = label.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase(); 135 + return `bafkreidryrun${tag}${blobCounter.toString().padStart(4, "0")}`; 136 + } 137 + 138 + let rkeyCounter = 0; 139 + function fakeRkey(): string { 140 + rkeyCounter += 1; 141 + return `3kdryrun${rkeyCounter.toString().padStart(4, "0")}`; 142 + }
+210
src/pipeline.ts
··· 1 + // The loop: findings + recording -> PRs/issues on Tangled, with frame citations. 2 + // A PR's patch is computed from the model's corrected file via `git diff`, so it is 3 + // guaranteed to apply. If we can't produce a clean patch, the finding is filed as 4 + // an issue instead of opening a PR with a broken diff. 5 + 6 + import { execFile } from "node:child_process"; 7 + import { mkdtemp, readFile, writeFile } from "node:fs/promises"; 8 + import { tmpdir } from "node:os"; 9 + import { join, resolve } from "node:path"; 10 + import { promisify } from "node:util"; 11 + import type { Config } from "./config.js"; 12 + import { extractFrameAt, toSeconds } from "./frames.js"; 13 + import { 14 + type BlobRef, 15 + buildCommentRecord, 16 + buildIssueRecord, 17 + buildMarkdown, 18 + buildPullRecord, 19 + imageMarkdown, 20 + NSID, 21 + type StrongRef, 22 + } from "./lexicon.js"; 23 + import { TangledClient } from "./pds.js"; 24 + import type { Finding, WebhookEvent } from "./types.js"; 25 + 26 + const exec = promisify(execFile); 27 + 28 + export interface PostedFinding { 29 + kind: string; 30 + title: string; 31 + uri: string; 32 + webUrl?: string; 33 + commentUri?: string; 34 + file: string | null; 35 + line: number | null; 36 + dryRun: boolean; 37 + } 38 + 39 + /** 40 + * Turn the model's corrected file into a real unified diff: write it over the working 41 + * tree, `git diff`, then restore the original. The result is a genuine diff against the 42 + * actual source, so it always applies. Returns null if there's no change or git fails. 43 + */ 44 + async function patchFromNewContent(cfg: Config, file: string, newContent: string, log: (m: string) => void): Promise<string | null> { 45 + const abs = resolve(cfg.repoRoot, file); 46 + let original: string; 47 + try { 48 + original = await readFile(abs, "utf8"); 49 + } catch { 50 + log(`patch: cannot read ${file} — skipping fix`); 51 + return null; 52 + } 53 + if (newContent === original) { 54 + log(`patch: model returned ${file} unchanged — no fix`); 55 + return null; 56 + } 57 + try { 58 + await writeFile(abs, newContent); 59 + const { stdout: diff } = await exec("git", ["-C", cfg.repoRoot, "diff", "--", file]); 60 + if (!diff.trim()) return null; 61 + return diff; 62 + } catch (e: any) { 63 + log(`patch: git diff failed for ${file} (${String(e?.message ?? e).split("\n")[0]})`); 64 + return null; 65 + } finally { 66 + await writeFile(abs, original); // always restore the working tree 67 + } 68 + } 69 + 70 + /** Verify a patch applies cleanly to the current repo. */ 71 + async function patchApplies(cfg: Config, diff: string): Promise<boolean> { 72 + try { 73 + const dir = await mkdtemp(join(tmpdir(), "loup-patch-")); 74 + const pf = join(dir, "fix.diff"); 75 + await writeFile(pf, diff.endsWith("\n") ? diff : diff + "\n"); 76 + await exec("git", ["-C", cfg.repoRoot, "apply", "--check", pf]); 77 + return true; 78 + } catch { 79 + return false; 80 + } 81 + } 82 + 83 + async function resolveWebUrls(cfg: Config, posted: PostedFinding[]): Promise<void> { 84 + const base = `https://tangled.org/${cfg.targetRepoName}`; 85 + for (const [kind, path] of [ 86 + ["pr", "pulls"], 87 + ["issue", "issues"], 88 + ] as const) { 89 + const mine = posted.filter((p) => p.kind === kind); 90 + if (!mine.length) continue; 91 + try { 92 + const html = await (await fetch(`${base}/${path}`)).text(); 93 + const re = new RegExp(`/${cfg.targetRepoName.replace(/[.]/g, "\\.")}/${path}/(\\d+)`, "g"); 94 + const nums = [...new Set([...html.matchAll(re)].map((m) => Number(m[1])))].sort((a, b) => b - a); 95 + mine.forEach((it, i) => { 96 + const num = nums[mine.length - 1 - i]; 97 + if (num != null) it.webUrl = `${base}/${path}/${num}`; 98 + }); 99 + } catch { 100 + /* best-effort */ 101 + } 102 + } 103 + } 104 + 105 + export async function processEvent( 106 + ev: WebhookEvent, 107 + findings: Finding[], 108 + cfg: Config, 109 + framesDir: string, 110 + log: (m: string) => void = () => {}, 111 + ): Promise<PostedFinding[]> { 112 + const client = new TangledClient(cfg); 113 + await client.login(); 114 + const did = cfg.targetRepoDid ?? "did:plc:DRYRUN_TARGET"; 115 + const now = new Date().toISOString(); 116 + const out: PostedFinding[] = []; 117 + 118 + for (const f of findings) { 119 + // 1. cut the citation frame from the recording at the finding's timestamp 120 + const framePath = await extractFrameAt(ev.videoPath, f.at, framesDir); 121 + 122 + // 2. build the patch (PR only). If we can't make a clean one, fall back to an issue. 123 + let patchText: string | null = null; 124 + let kind = f.kind; 125 + if (kind === "pr" && f.file && f.newContent) { 126 + const diff = await patchFromNewContent(cfg, f.file, f.newContent, log); 127 + if (diff && (await patchApplies(cfg, diff))) { 128 + patchText = diff; 129 + } else { 130 + log(`patch: no clean patch for "${f.title}" — filing as issue`); 131 + kind = "issue"; 132 + } 133 + } else if (kind === "pr") { 134 + kind = "issue"; // PR with no fix → issue 135 + } 136 + 137 + // 3. upload the screenshot; build its citation markdown (deep-linked if hosted) 138 + const img = await client.uploadImage(framePath); 139 + const sec = toSeconds(f.at); 140 + const deepLink = ev.videoUrl ? `${ev.videoUrl}${ev.videoUrl.includes("?") ? "&" : "?"}t=${sec}` : null; 141 + const citation = deepLink 142 + ? `[${imageMarkdown(img.url, f.title)}](${deepLink})\n\n▶ [Watch at ${f.at}](${deepLink})` 143 + : imageMarkdown(img.url, f.title); 144 + 145 + // 4. open the PR or issue 146 + const body = renderBody(f, citation); 147 + let subject; 148 + if (kind === "pr" && patchText) { 149 + const patchBlob = (await client.uploadPatch(patchText)).ref; 150 + subject = await client.createRecord( 151 + NSID.pull, 152 + buildPullRecord({ 153 + targetRepoDid: did, 154 + targetBranch: cfg.targetBranch, 155 + title: f.title, 156 + body, 157 + patchBlob, 158 + createdAt: now, 159 + sourceBranch: `loup/${slug(f.title)}`, 160 + }), 161 + ); 162 + } else { 163 + subject = await client.createRecord( 164 + NSID.issue, 165 + buildIssueRecord({ targetRepoDid: did, title: f.title, body, createdAt: now }), 166 + ); 167 + } 168 + log(`${kind.toUpperCase()}: ${subject.uri}${subject.dryRun ? " (dry-run)" : ""}`); 169 + 170 + // 5. post the screenshot as a citation comment too 171 + const ref: StrongRef = { uri: subject.uri, cid: subject.cid }; 172 + const comment = await client.createRecord( 173 + NSID.comment, 174 + buildCommentRecord({ 175 + subject: ref, 176 + body: buildMarkdown(`📎 **Evidence @ ${f.at}**\n\n${citation}`, [img.ref as BlobRef]), 177 + createdAt: now, 178 + pullRoundIdx: kind === "pr" ? 0 : undefined, 179 + }), 180 + ); 181 + 182 + out.push({ 183 + kind, 184 + title: f.title, 185 + uri: subject.uri, 186 + commentUri: comment.uri, 187 + file: f.file ?? null, 188 + line: null, 189 + dryRun: subject.dryRun, 190 + }); 191 + } 192 + 193 + if (client.live && out.length) { 194 + log("resolving web URLs…"); 195 + await new Promise((r) => setTimeout(r, 2000)); 196 + await resolveWebUrls(cfg, out); 197 + } 198 + return out; 199 + } 200 + 201 + function renderBody(f: Finding, citation: string): string { 202 + const lines = [`> 🔎 Opened by **loup** from a user session recording.`, "", f.body]; 203 + if (f.file) lines.push("", `**Where:** \`${f.file}\``); 204 + lines.push("", `### Evidence`, citation); 205 + return lines.join("\n"); 206 + } 207 + 208 + function slug(s: string): string { 209 + return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40); 210 + }
+186
src/server.ts
··· 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 + 6 + import { createWriteStream, readFileSync } from "node:fs"; 7 + import { mkdir, mkdtemp } from "node:fs/promises"; 8 + import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; 9 + import { tmpdir } from "node:os"; 10 + import { dirname, join, resolve } from "node:path"; 11 + import { pipeline as streamPipeline } from "node:stream/promises"; 12 + import { fileURLToPath } from "node:url"; 13 + import busboy from "busboy"; 14 + import type { Config } from "./config.js"; 15 + import { type PostedFinding, processEvent } from "./pipeline.js"; 16 + import type { WebhookEvent } from "./types.js"; 17 + import { analyzeWithVision, prewarm } from "./vision.js"; 18 + 19 + /** The real pipeline: sweep the recording + source code through Claude vision. */ 20 + async 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). */ 26 + function 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 + 40 + export 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 + 58 + async 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 + 145 + async 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 + 179 + function 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 + }
+30
src/types.ts
··· 1 + export type Kind = "pr" | "issue"; 2 + 3 + /** One finding the vision model produced from the recording + source code. */ 4 + export interface Finding { 5 + kind: Kind; 6 + title: string; 7 + /** Timestamp in the recording, "m:ss" — used to cut the citation frame + deep-link. */ 8 + at: string; 9 + /** Source file the issue lives in (repo-relative), as identified by the model. */ 10 + file?: string | null; 11 + /** One-line note of what was actually visible in the frame. */ 12 + evidence?: string; 13 + /** Free-text body (observation + why it matters + what the fix does). */ 14 + body: string; 15 + /** 16 + * For a PR: the COMPLETE corrected contents of `file`. The pipeline turns this 17 + * into a real patch by writing it over the working tree and running `git diff` 18 + * (so the patch is guaranteed to apply). null/absent for an issue. 19 + */ 20 + newContent?: string | null; 21 + } 22 + 23 + /** The webhook payload: a recording (+ optional hosted URL and freeform notes). */ 24 + export interface WebhookEvent { 25 + videoPath: string; 26 + /** Optional hosted URL of the same recording, for timestamped deep-link citations. */ 27 + videoUrl?: string; 28 + /** Optional freeform notes — an extra nudge for the model. Never required. */ 29 + text: string; 30 + }
+205
src/vision.ts
··· 1 + // The real engine. Cut the recording into 1-frame-per-second screenshots (with the 2 + // timestamp burned into each), hand them + the app's source code to Claude under a 3 + // single system prompt, and let the model itself find the UX bugs, locate them in 4 + // the source, and write the fix (as the full corrected file). No spoon-feeding. 5 + 6 + import { execFile } from "node:child_process"; 7 + import { existsSync, statSync } from "node:fs"; 8 + import { mkdir, readFile, readdir } from "node:fs/promises"; 9 + import { resolve } from "node:path"; 10 + import { promisify } from "node:util"; 11 + import Anthropic from "@anthropic-ai/sdk"; 12 + import type { Config } from "./config.js"; 13 + import type { Finding, WebhookEvent } from "./types.js"; 14 + 15 + const exec = promisify(execFile); 16 + 17 + const MAC_FONTS = [ 18 + "/System/Library/Fonts/Supplemental/Arial.ttf", 19 + "/System/Library/Fonts/Helvetica.ttc", 20 + "/Library/Fonts/Arial.ttf", 21 + ]; 22 + 23 + const CODE_EXT = /\.(html|htm|js|ts|jsx|tsx|css|svelte|vue|go|py|rb|templ|gohtml|tmpl)$/i; 24 + const MAX_SOURCE_BYTES = 256 * 1024; // bound the source we feed so we don't blow context 25 + 26 + function mmss(sec: number): string { 27 + return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, "0")}`; 28 + } 29 + 30 + /** Sweep the whole recording at 1 fps → downscaled PNGs with the timestamp drawn on. */ 31 + async function sweepFrames(videoPath: string, outDir: string): Promise<{ path: string; at: string }[]> { 32 + await mkdir(outDir, { recursive: true }); 33 + const font = MAC_FONTS.find(existsSync); 34 + const draw = font 35 + ? `,drawtext=fontfile='${font}':text='%{pts\\:hms}':x=12:y=12:fontsize=30:fontcolor=yellow:box=1:boxcolor=black@0.65:boxborderw=8` 36 + : ""; 37 + const pattern = resolve(outDir, "f_%04d.png"); 38 + try { 39 + await exec("ffmpeg", ["-y", "-i", videoPath, "-vf", `fps=1,scale='min(1280,iw)':-2${draw}`, pattern, "-loglevel", "error"]); 40 + } catch { 41 + await exec("ffmpeg", ["-y", "-i", videoPath, "-vf", `fps=1,scale='min(1280,iw)':-2`, pattern, "-loglevel", "error"]); 42 + } 43 + const files = (await readdir(outDir)).filter((f) => f.startsWith("f_") && f.endsWith(".png")).sort(); 44 + // fps=1 emits frames at t = 0,1,2,… so frame index i (0-based) ≈ i seconds. 45 + return files.map((f, i) => ({ path: resolve(outDir, f), at: mmss(i) })); 46 + } 47 + 48 + /** Read the configured source paths (files or dirs), bounded by total bytes. */ 49 + async function readSource(cfg: Config, log: (m: string) => void): Promise<{ rel: string; code: string }[]> { 50 + const out: { rel: string; code: string }[] = []; 51 + let budget = MAX_SOURCE_BYTES; 52 + const addFile = async (rel: string) => { 53 + if (budget <= 0) return; 54 + const abs = resolve(cfg.repoRoot, rel); 55 + try { 56 + const code = await readFile(abs, "utf8"); 57 + if (code.length > budget) return; 58 + budget -= code.length; 59 + out.push({ rel, code }); 60 + } catch { 61 + /* skip unreadable */ 62 + } 63 + }; 64 + const walk = async (relDir: string) => { 65 + let entries; 66 + try { 67 + entries = await readdir(resolve(cfg.repoRoot, relDir), { withFileTypes: true }); 68 + } catch { 69 + return; 70 + } 71 + for (const e of entries) { 72 + const rel = relDir ? `${relDir}/${e.name}` : e.name; 73 + if (e.isDirectory()) await walk(rel); 74 + else if (CODE_EXT.test(e.name)) await addFile(rel); 75 + } 76 + }; 77 + for (const p of cfg.sourcePaths) { 78 + const abs = resolve(cfg.repoRoot, p); 79 + if (existsSync(abs) && statSync(abs).isDirectory()) await walk(p); 80 + else await addFile(p); 81 + } 82 + log(`source: ${out.length} file(s), ${MAX_SOURCE_BYTES - budget} bytes`); 83 + return out; 84 + } 85 + 86 + const SYS = `You are loup, a senior engineer who reviews real user session recordings, finds where the user hit a bug or got confused, and fixes it in the code. 87 + 88 + You receive: 89 + 1. SOURCE CODE — the app's relevant source files (path + full contents). 90 + 2. SCREENSHOTS — frames sampled at 1 per second from a screen recording of a real user, each labeled with its timestamp. 91 + 92 + Find EVERY distinct issue the recording reveals — there are usually several, of different kinds: 93 + - visual/layout problems (misalignment, off-center labels, cramped or clipped controls), 94 + - broken flows (a step that leaves the user stuck, controls that vanish, no way to recover/retry), 95 + - missing safeguards (no validation before submit, no inline feedback, confusing errors). 96 + Report each as its OWN finding. Do not stop at the first. Watch the frames in order and cross-reference the SOURCE CODE for the root cause. 97 + 98 + For each finding decide: 99 + - kind "pr": you can fix it in the given source. Put the COMPLETE corrected file in "newContent" — the entire file, byte-for-byte identical to the source EXCEPT your fix. Do not reformat or touch unrelated lines. The fix must be a real, minimal code change that resolves the issue. 100 + - kind "issue": it needs human judgement and has no clear single code fix. "newContent" is null. 101 + 102 + Respond with ONLY a JSON array, no prose, no code fences. Each item: 103 + { 104 + "kind": "pr" | "issue", 105 + "title": string, // concise, imperative 106 + "at": "m:ss", // timestamp of the frame that shows it 107 + "file": string | null, // exact source file path (from the SOURCE CODE headers) the issue lives in 108 + "evidence": string, // one line: exactly what you saw in the frame(s) 109 + "body": string, // what the user hit, why it matters, and what your fix does 110 + "newContent": string | null // for "pr": the full corrected file contents. null for "issue". 111 + }`; 112 + 113 + interface RawFinding { 114 + kind: "pr" | "issue"; 115 + title: string; 116 + at: string; 117 + file: string | null; 118 + evidence?: string; 119 + body: string; 120 + newContent: string | null; 121 + } 122 + 123 + /** Build model-specific thinking + effort params (Opus/Sonnet 4.6+ use adaptive). */ 124 + function reasoningParams(model: string): Record<string, unknown> { 125 + const adaptive = /opus-4-[678]|sonnet-4-6|fable-5|mythos-5/.test(model); 126 + if (adaptive) { 127 + const effort = process.env.LOUP_EFFORT ?? "high"; 128 + return { thinking: { type: "adaptive" }, output_config: { effort } }; 129 + } 130 + const budget = Number(process.env.LOUP_THINK ?? 1024); 131 + return { thinking: budget > 0 ? { type: "enabled", budget_tokens: budget } : { type: "disabled" } }; 132 + } 133 + 134 + export async function analyzeWithVision( 135 + event: WebhookEvent, 136 + cfg: Config, 137 + framesDir: string, 138 + log: (m: string) => void = () => {}, 139 + ): Promise<Finding[]> { 140 + if (!cfg.anthropicKey) throw new Error("no ANTHROPIC_API_KEY"); 141 + 142 + const frames = await sweepFrames(event.videoPath, framesDir); 143 + const source = await readSource(cfg, log); 144 + log(`vision: ${frames.length} frame(s) @ 1fps; model ${cfg.model}`); 145 + 146 + const content: Anthropic.MessageParam["content"] = [{ type: "text", text: "SOURCE CODE:\n" }]; 147 + for (const s of source) content.push({ type: "text", text: `\n===== ${s.rel} =====\n${s.code}\n` }); 148 + if (event.text.trim()) { 149 + content.push({ 150 + type: "text", 151 + text: `\nOPERATOR NOTES (optional context — verify against the frames, do not just trust):\n${event.text.trim()}\n`, 152 + }); 153 + } 154 + content.push({ type: "text", text: "\nSCREENSHOTS (1 fps, timestamp tagged):" }); 155 + for (const fr of frames) { 156 + const data = (await readFile(fr.path)).toString("base64"); 157 + content.push({ type: "text", text: `Frame @ ${fr.at}` }); 158 + content.push({ type: "image", source: { type: "base64", media_type: "image/png", data } }); 159 + } 160 + 161 + const client = new Anthropic({ apiKey: cfg.anthropicKey }); 162 + const params = { 163 + model: cfg.model, 164 + max_tokens: 8192, 165 + system: SYS, 166 + messages: [{ role: "user", content }], 167 + ...reasoningParams(cfg.model), 168 + } as unknown as Anthropic.MessageCreateParamsNonStreaming; 169 + 170 + const resp = await client.messages.create(params); 171 + const text = resp.content.find((b) => b.type === "text"); 172 + const raw = text && "text" in text ? text.text : "[]"; 173 + const parsed = JSON.parse(stripFences(raw)) as RawFinding[]; 174 + 175 + const findings: Finding[] = []; 176 + for (const f of parsed) { 177 + log(`vision: [${f.kind}] "${f.title}" @ ${f.at}${f.evidence ? ` — ${f.evidence}` : ""}`); 178 + findings.push({ 179 + kind: f.kind, 180 + title: f.title, 181 + at: f.at, 182 + file: f.file ?? null, 183 + evidence: f.evidence, 184 + body: f.evidence ? `${f.body}\n\n*Verified from the recording at ${f.at}: ${f.evidence}*` : f.body, 185 + newContent: f.kind === "pr" ? f.newContent : null, 186 + }); 187 + } 188 + return findings; 189 + } 190 + 191 + function stripFences(s: string): string { 192 + const m = s.match(/```(?:json)?\s*([\s\S]*?)```/); 193 + return (m ? m[1] : s).trim(); 194 + } 195 + 196 + /** Warm the model connection (TLS/auth) at startup so the first real send is fast. */ 197 + export async function prewarm(cfg: Config): Promise<void> { 198 + if (!cfg.anthropicKey || cfg.visionOff) return; 199 + try { 200 + const client = new Anthropic({ apiKey: cfg.anthropicKey }); 201 + await client.messages.create({ model: cfg.model, max_tokens: 1, messages: [{ role: "user", content: "hi" }] }); 202 + } catch { 203 + /* best-effort warmup */ 204 + } 205 + }
+15
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "Bundler", 6 + "lib": ["ES2022"], 7 + "strict": true, 8 + "esModuleInterop": true, 9 + "skipLibCheck": true, 10 + "resolveJsonModule": true, 11 + "noEmit": true, 12 + "types": ["node"] 13 + }, 14 + "include": ["src"] 15 + }