initial import: @mikefreno/omp-deepi-research (omp port)
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.pi-lens/
|
||||||
|
AGENTS.md
|
||||||
|
package-lock.json
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Michael Freno
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
115
README.md
Normal file
115
README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Deep Research
|
||||||
|
|
||||||
|
Multi-round deep web research powered by Firecrawl with iterative query refinement.
|
||||||
|
|
||||||
|
Deep Research is a local omp extension under `~/.omp/agent/extensions/deepi-research/`.
|
||||||
|
Omp loads it via `omp.extensions` in `package.json` (entry `./index.ts`).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-round iteration**: Each round generates follow-up queries based on previous findings (depth 1-3)
|
||||||
|
- **Parallel query expansion**: Multiple diverse search queries per round (breadth 1-5) covering technical, practical, comparative, critical, and forward-looking angles
|
||||||
|
- **Sub-question decomposition**: Broad questions are broken into focused sub-topics before query generation (depth > 1)
|
||||||
|
- **Round-robin parallel execution**: Searches and analyses run concurrently within each round using bounded-concurrency worker pools, dramatically reducing total research time
|
||||||
|
- **LLM-driven analysis**: Each query's results are analyzed by its own agent session (per-query provenance) to extract structured findings with confidence ratings
|
||||||
|
- **Source authority scoring**: Every source is scored by domain authority; low-quality SEO domains are penalized with a hard floor; findings are ranked by authority × confidence before synthesis
|
||||||
|
- **Cross-query corroboration**: A finding is corroborated only when its sources were independently surfaced by multiple different search queries
|
||||||
|
- **Citation integrity**: References are rebuilt from the authoritative bibliography (never the LLM's), and hallucinated inline citation numbers are stripped
|
||||||
|
- **Near-duplicate detection**: Syndicated copies of the same article are removed by title similarity, and duplicate findings across rounds are merged
|
||||||
|
- **Automatic deduplication**: Search results are deduplicated by URL across all queries
|
||||||
|
- **Robust LLM output parsing**: JSON output with code fences, prose prefixes, or trailing commas is parsed reliably
|
||||||
|
- **Graceful degradation**: Individual search or analysis failures don't crash the full research — partial results are preserved, with retry-with-backoff for transient Firecrawl errors
|
||||||
|
- **Progress streaming**: Real-time progress widget with spinner, phase indicators, and progress bar
|
||||||
|
- **Abort support**: Research can be cancelled mid-flight via `AbortSignal`
|
||||||
|
- **Rich TUI rendering**: Compact collapsed view and detailed expanded view in the terminal UI
|
||||||
|
- **Fallback resilience**: Built-in fallback query generation and report synthesis when LLM calls fail
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Tool (LLM-callable)
|
||||||
|
|
||||||
|
Registers the `deep_research` tool for AI agent use:
|
||||||
|
|
||||||
|
```
|
||||||
|
deep_research — multi-round deep web research via Firecrawl with iterative query refinement
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `question` | string | — | The research question to investigate |
|
||||||
|
| `depth` | integer (1-3) | 2 | Number of research rounds |
|
||||||
|
| `breadth` | integer (1-5) | 3 | Search queries per round |
|
||||||
|
| `format` | "markdown" \| "structured" | "markdown" | Output format for the report |
|
||||||
|
| `audience` | "general" \| "expert" \| "executive" | "general" | Tone and depth for the report audience |
|
||||||
|
| `details.showRoundDetails` | boolean | false | Include per-round search metadata (incl. failed searches) in output |
|
||||||
|
|
||||||
|
### Command (interactive)
|
||||||
|
|
||||||
|
```
|
||||||
|
/deepi-research <your research question>
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompts for depth (1-3 rounds) and breadth (1-5 queries) interactively, then runs the research and sends the final report as a user message.
|
||||||
|
|
||||||
|
### Recommended usage
|
||||||
|
|
||||||
|
- Use `deep_research` for complex, multi-faceted questions that benefit from multiple search angles and iterative refinement.
|
||||||
|
- The tool handles query generation, web search, result analysis, and report synthesis automatically.
|
||||||
|
- For simple fact-finding questions, use `firecrawl_search` directly instead.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Research Flow:
|
||||||
|
|
||||||
|
Question
|
||||||
|
↓
|
||||||
|
┌─ Round 1 ───────────────────────────┐
|
||||||
|
│ LLM → generate queries (N angles) │
|
||||||
|
│ Firecrawl → search each query │
|
||||||
|
│ LLM → analyze results → findings │
|
||||||
|
└──────────────┬───────────────────────┘
|
||||||
|
↓ (follow-up queries)
|
||||||
|
┌─ Round 2 ───────────────────────────┐
|
||||||
|
│ LLM → identify knowledge gaps │
|
||||||
|
│ Firecrawl → search follow-ups │
|
||||||
|
│ LLM → analyze → new findings │
|
||||||
|
└──────────────┬───────────────────────┘
|
||||||
|
↓ (iterate depth times)
|
||||||
|
┌─ Synthesis ─────────────────────────┐
|
||||||
|
│ LLM → synthesize all findings │
|
||||||
|
│ → comprehensive research report │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Deep Research reads Firecrawl configuration from omp's config.yml files, with the following resolution order (later wins):
|
||||||
|
|
||||||
|
1. Environment variables (`FIRECRAWL_BASE_URL`, `FIRECRAWL_API_KEY`)
|
||||||
|
2. Global config (`$agentDir/config.yml`) → `firecrawl.*`
|
||||||
|
3. Project config (`.omp/config.yml`) → `firecrawl.*`
|
||||||
|
4. Default `http://localhost:3002` (if nothing else sets baseUrl)
|
||||||
|
|
||||||
|
The agent directory (`$agentDir`) defaults to `~/.omp/agent`.
|
||||||
|
|
||||||
|
**Global config** (`~/.omp/agent/config.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
firecrawl:
|
||||||
|
baseUrl: http://localhost:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
**Project config** (`.omp/config.yml` — overrides global):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
firecrawl:
|
||||||
|
baseUrl: https://firecrawl.team.internal
|
||||||
|
apiKey: your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session startup check
|
||||||
|
|
||||||
|
On `session_start`, the extension checks whether the Firecrawl endpoint is reachable. If not, it shows a warning notification so you know searches will fail before you try to use it.
|
||||||
412
bun.lock
Normal file
412
bun.lock
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@mikefreno/deepi-research",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.4.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@oh-my-pi/pi-coding-agent": "17.2.12",
|
||||||
|
"@oh-my-pi/pi-tui": "17.2.12",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typebox": "^1.1.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||||
|
|
||||||
|
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
|
|
||||||
|
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
|
||||||
|
|
||||||
|
"@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
|
||||||
|
|
||||||
|
"@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="],
|
||||||
|
|
||||||
|
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/hashline": ["@oh-my-pi/hashline@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-OdqMAojK8iUZynyNAP2VnbB+EHOHM7ISDSM3f9xHh/sDNSPDi15Xwd53AIdJy+EhYoCyglDIqO9/seHjA6veQQ=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-BOyDNq8Hj/CJVc6Wntdo+1yaG/b8CqLcxsBnHefLoC7vO96YoFjt4S7GOpp3cd+ysiuxHnmrWn30kEtwR9fhjQ=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/omptype": ["@oh-my-pi/omptype@17.2.12", "", {}, "sha512-Y27VMnPbcUEOpK1DgYuvNIaVnGxEmOcEP2bYPfuAWkx2vc4PKXL0NCRzGLwYMjhwjjd9O/gn8lywZfxDCBytjA=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1" } }, "sha512-VIJnZCQyshiZoiWpvhoNVHhUQmptLG41h7XPg2IZfEdDNdDpV26JuZdDMgBr0hIz1L/RfTq8MiRcRuAcasvkOg=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-S6LswgyLgQdE5zXADf6NGwrYn1OCYp1WiKxyN6f/1RRv68vbd3y4YSoidzeOYs3kPS/cIiv3dw3LHaCrwpam/Q=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-6VycCR0ShSzbVfOa7cdeqVtxHL2IPpJXFePKaQPhQ8bOTiyE/TaUm1uIa1i4doqPTz/k6vUEs+a57cZwoUP97A=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@17.2.12", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/hashline": "17.2.12", "@oh-my-pi/omp-stats": "17.2.12", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-agent-core": "17.2.12", "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-mnemopi": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-tui": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "mupdf": "^1.28.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-+q+W4fyNQQ7xAKiN0mmOisWDDtKO0R/ZctTSsKqR4ulN3K1zfQ9HwiTxtg7HJHn5fwCy+X3BmUG72FatNUN8IA=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-lKjEexuFC/piaNnb3MzJKu8rolXwkjfv0M90UBUiNPFxKbpKHWU5np5aUAOYkT+fwKoQPQ7Tr95mJx0l32S0OQ=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@17.2.12", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "17.2.12", "@oh-my-pi/pi-natives-darwin-x64": "17.2.12", "@oh-my-pi/pi-natives-linux-arm64": "17.2.12", "@oh-my-pi/pi-natives-linux-x64": "17.2.12", "@oh-my-pi/pi-natives-win32-x64": "17.2.12" } }, "sha512-MVZq0UrrA7mk6uMKrgjnAhfrDj+58yEuu0VeVd3JuvneMjcX1duIzOdyqRG13S+/XzGOvGkk164dM6D/CwxteQ=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@17.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5iullmWEWmGRjwgo7Kw6Bub3KOXahkUX3jZfw65bM9LjFRceSwoLTt6Yh0RnXXP8hRzZnhU3b/o73peWBlpLzw=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@17.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-qNc2q02d5etsOdxmO4CNwCcEvgfWS9Ya/fuq2hDKJgp3BFeKQCzYDap0HhC/qkAfj2vpjsTyKc/PsPMMMLGytg=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@17.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HTc6efuPNoYSzHu7Zk4gl3RtQzWWprVA4x0ep0gq5B7mBS6OwgnbH5otLrXSFeQ2UgRyV/nddz1O02ek4Vg8MA=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@17.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jg9xzfqpcBDGRVnW+98z/eQPIfcnOYujd1bLW0PnJh6A0ykSqjCvt9PpN0wrlqr70WazOOURqoodN2eibIyOOQ=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@17.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb/EF7Ug7SX/YYJofgKR5Kvw0I+AqGpEfmI5inIsvFzygqlA4QgMETbAR1s08sK+AYZqPqRorBpbGNas85XFig=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-X4IKQG3alzw3ogUewxoPBaZImReEBF7P2Xu5xN5E47lfMHR0eNJ6cQnQmk7sHjfNEAclKCJDYqwzgOfRdh9GEA=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12" } }, "sha512-iYNV2y6RW9tzYaBa7hX5pzb23+zzNLF/Bs4bK7LAO2Qc7OZl5gw1lp9YOUX4/VZOO7A20KrXnZNwZVCnpPKy1A=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@17.2.12", "", {}, "sha512-UdQ0VP3gExd+jXgy8epCSZ+PTW9pkFu8FoomywmqEPj/JKjt1SqZhIiN8N51HkQCC5WJryT5AedzL/Qr6OID8w=="],
|
||||||
|
|
||||||
|
"@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-kWYY/7tgIAnXAiYogC6K1GLU2G26QXTr2vDJGq1mxCMk3/GixoNzrFwzK8w6NmKYdQpO/Sa1SRw9sypYT+v1Bw=="],
|
||||||
|
|
||||||
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||||
|
|
||||||
|
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="],
|
||||||
|
|
||||||
|
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.10.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="],
|
||||||
|
|
||||||
|
"@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.10.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q=="],
|
||||||
|
|
||||||
|
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||||
|
|
||||||
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||||
|
|
||||||
|
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||||
|
|
||||||
|
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||||
|
|
||||||
|
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||||
|
|
||||||
|
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||||
|
|
||||||
|
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||||
|
|
||||||
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||||
|
|
||||||
|
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||||
|
|
||||||
|
"adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
|
|
||||||
|
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
|
||||||
|
|
||||||
|
"chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="],
|
||||||
|
|
||||||
|
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||||
|
|
||||||
|
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||||
|
|
||||||
|
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
||||||
|
|
||||||
|
"devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
|
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||||
|
|
||||||
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||||
|
|
||||||
|
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||||
|
|
||||||
|
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
|
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||||
|
|
||||||
|
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||||
|
|
||||||
|
"modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="],
|
||||||
|
|
||||||
|
"mupdf": ["mupdf@1.28.0", "", {}, "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg=="],
|
||||||
|
|
||||||
|
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||||
|
|
||||||
|
"onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
|
||||||
|
|
||||||
|
"onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="],
|
||||||
|
|
||||||
|
"onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="],
|
||||||
|
|
||||||
|
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||||
|
|
||||||
|
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
|
||||||
|
|
||||||
|
"puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||||
|
|
||||||
|
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||||
|
|
||||||
|
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
|
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||||
|
|
||||||
|
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||||
|
|
||||||
|
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||||
|
|
||||||
|
"sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QcYKzyrTzGSx6aKCD6hUODgRS1LetqfG57Z/+i5LCyfMlrgCvDc1lRcl9cdB+TozBsLha9QwLTlI0vmDcf5JKg=="],
|
||||||
|
|
||||||
|
"sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-6RGeis9K9gV/UQWOgd6Rf3iqXr2/YsBQswxHaCR4hrYkHfEIpHMfFmRWLt6nJJCOWgYW2xFxEd9yzjrafAV/Pw=="],
|
||||||
|
|
||||||
|
"sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw=="],
|
||||||
|
|
||||||
|
"sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.4", "", { "os": "linux", "cpu": "x64" }, "sha512-WZh5NCkGPFHHpYSd78iN4OnmxQeSTGyt9uZskH+im/NFHQ7elQ7B0sLzCMeRpvJxiIKvd9C6WxIJ4hYaxClfsQ=="],
|
||||||
|
|
||||||
|
"sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="],
|
||||||
|
|
||||||
|
"sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-/JbPjldrfNv+t+uIS3MlkuhfIf5l3FHUGkRC2oRXgjRqOaVmEyP3vLlQ7dTa4J7raG5oB8c3GoPjuSWSqT9GOQ=="],
|
||||||
|
|
||||||
|
"sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.4", "", { "os": "win32", "cpu": "x64" }, "sha512-R0PWby1VxC14TDZPq7GcfSyXSY6SAFO8Y4JwdCdqouFmeXkZ1L7Is9m98C9KxQ0dN7ZtDzhAmE/43FUs/elXRQ=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||||
|
|
||||||
|
"typebox": ["typebox@1.3.11", "", {}, "sha512-tZGKIS02Opbh4EYMmAEVuXl+y3EQJ9ZlkitLZ1CmFCY4ZOqr/xWR9dDSCFmIjNDICGMWkOqMh6WxGTyRXqcSGQ=="],
|
||||||
|
|
||||||
|
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||||
|
|
||||||
|
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||||
|
|
||||||
|
"yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
|
||||||
|
|
||||||
|
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
|
||||||
|
|
||||||
|
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||||
|
|
||||||
|
"cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="],
|
||||||
|
|
||||||
|
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
590
index.ts
Normal file
590
index.ts
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
/**
|
||||||
|
* deep-research — Multi-round deep web research powered by Firecrawl
|
||||||
|
*
|
||||||
|
* Registers:
|
||||||
|
* - `deep_research` tool — callable by the LLM to conduct deep research
|
||||||
|
* - `/deepi-research` command — interactive session invocation
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* Each research round generates queries, searches in parallel via
|
||||||
|
* Firecrawl, analyzes results with agent sessions, then generates
|
||||||
|
* follow-up queries. A final synthesis step produces the report.
|
||||||
|
*
|
||||||
|
* Patterns borrowed from:
|
||||||
|
* - firecrawl.ts extension (direct Firecrawl HTTP calls)
|
||||||
|
* - ralpi executor (agent sessions, widget updates, progress UX)
|
||||||
|
* - subagent extension (structured tool rendering)
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionCommandContext,
|
||||||
|
ExtensionContext,
|
||||||
|
} from "@oh-my-pi/pi-coding-agent";
|
||||||
|
import type { TSchema } from "@oh-my-pi/pi-ai";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
truncateToWidth,
|
||||||
|
visibleWidth,
|
||||||
|
} from "@oh-my-pi/pi-tui";
|
||||||
|
import { runDeepResearch, type ResearchProgress } from "./src/research";
|
||||||
|
import { isFirecrawlReachable } from "./src/firecrawl";
|
||||||
|
import type { ResearchConfig, ResearchReport, Audience } from "./src/types";
|
||||||
|
|
||||||
|
/* ── Constants ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
const PHASE_ICONS: Record<string, string> = {
|
||||||
|
decomposing: "🧩",
|
||||||
|
generating_queries: "🔍",
|
||||||
|
searching: "🌐",
|
||||||
|
analyzing: "📊",
|
||||||
|
synthesizing: "📝",
|
||||||
|
complete: "✅",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResearchPhase = Parameters<ResearchProgress>[0]["phase"];
|
||||||
|
|
||||||
|
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
if (s.length <= max) return s;
|
||||||
|
return s.slice(0, max - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tool Definition ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const DeepResearchParams = Type.Object({
|
||||||
|
question: Type.String({
|
||||||
|
description: "The research question to investigate",
|
||||||
|
}),
|
||||||
|
depth: Type.Optional(
|
||||||
|
Type.Integer({
|
||||||
|
description:
|
||||||
|
"Number of research rounds (1-3). Each round builds on findings from the previous for deeper analysis. Default: 2",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 3,
|
||||||
|
default: 2,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
breadth: Type.Optional(
|
||||||
|
Type.Integer({
|
||||||
|
description:
|
||||||
|
"Number of search queries per round (1-5). More queries = broader coverage but slower. Default: 3",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 5,
|
||||||
|
default: 3,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
format: Type.Optional(
|
||||||
|
Type.Union([Type.Literal("markdown"), Type.Literal("structured")], {
|
||||||
|
description:
|
||||||
|
'Output format for the research report. "markdown" for prose with headings, "structured" for detailed hierarchical sections. Default: "markdown"',
|
||||||
|
default: "markdown",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
audience: Type.Optional(
|
||||||
|
Type.Union(
|
||||||
|
[
|
||||||
|
Type.Literal("general"),
|
||||||
|
Type.Literal("expert"),
|
||||||
|
Type.Literal("executive"),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Target audience for the report. 'general' (accessible), 'expert' (technical depth), 'executive' (concise, action-oriented). Default: 'general'",
|
||||||
|
default: "general",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
details: Type.Optional(
|
||||||
|
Type.Object({
|
||||||
|
showRoundDetails: Type.Optional(
|
||||||
|
Type.Boolean({
|
||||||
|
description:
|
||||||
|
"Include per-round search methodology in the output. Default: false",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ResearchDetails {
|
||||||
|
rounds: Array<{
|
||||||
|
round: number;
|
||||||
|
queries: string[];
|
||||||
|
findingsCount: number;
|
||||||
|
resultsCount: number;
|
||||||
|
failedSearches: number;
|
||||||
|
}>;
|
||||||
|
totalSearches: number;
|
||||||
|
totalPagesScraped: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Widget Helper ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a widget state that drives a spinner-based progress widget.
|
||||||
|
* Returns the state object, the timer, and cleanup function.
|
||||||
|
*/
|
||||||
|
function createProgressWidget(
|
||||||
|
ctx: any,
|
||||||
|
initialPhase: ResearchPhase = "generating_queries",
|
||||||
|
) {
|
||||||
|
const state: {
|
||||||
|
phase: ResearchPhase;
|
||||||
|
message: string;
|
||||||
|
detail: string | undefined;
|
||||||
|
fraction: number;
|
||||||
|
round: number | undefined;
|
||||||
|
totalRounds: number | undefined;
|
||||||
|
} = {
|
||||||
|
phase: initialPhase,
|
||||||
|
message: "Starting...",
|
||||||
|
detail: undefined,
|
||||||
|
fraction: 0,
|
||||||
|
round: undefined,
|
||||||
|
totalRounds: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let widgetTui: { requestRender(): void } | null = null;
|
||||||
|
let spinnerIdx = 0;
|
||||||
|
|
||||||
|
ctx.ui.setWidget(
|
||||||
|
"deep-research",
|
||||||
|
(tui: { requestRender(): void }, _theme: any) => {
|
||||||
|
widgetTui = tui;
|
||||||
|
return {
|
||||||
|
render: (width: number) => {
|
||||||
|
const spinner = SPINNER_FRAMES[spinnerIdx];
|
||||||
|
const icon = PHASE_ICONS[state.phase] ?? "";
|
||||||
|
const roundInfo =
|
||||||
|
state.round && state.totalRounds
|
||||||
|
? ` Round ${state.round}/${state.totalRounds}`
|
||||||
|
: "";
|
||||||
|
const firstLine = `${spinner} ${icon} ${state.message}${roundInfo}`;
|
||||||
|
const lines: string[] = [truncateToWidth(firstLine, width)];
|
||||||
|
if (state.detail) {
|
||||||
|
lines.push(truncateToWidth(` ${state.detail}`, width));
|
||||||
|
}
|
||||||
|
if (state.fraction > 0) {
|
||||||
|
const barLen = Math.min(15, Math.max(3, width - 4));
|
||||||
|
const filled = Math.round(barLen * state.fraction);
|
||||||
|
const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
|
||||||
|
lines.push(` ${bar}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
invalidate: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const spinnerTimer = setInterval(() => {
|
||||||
|
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
|
||||||
|
widgetTui?.requestRender();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const onProgress: ResearchProgress = (update) => {
|
||||||
|
state.phase = update.phase;
|
||||||
|
state.message = update.message;
|
||||||
|
state.detail = update.detail;
|
||||||
|
state.fraction = update.fraction ?? 0;
|
||||||
|
state.round = update.round;
|
||||||
|
state.totalRounds = update.totalRounds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(spinnerTimer);
|
||||||
|
ctx.ui.setWidget("deep-research", undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { state, onProgress, cleanup, spinnerTimer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Extension Entry ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "deep_research",
|
||||||
|
label: "Deep Research",
|
||||||
|
description: [
|
||||||
|
"Conduct multi-round deep web research on any topic using Firecrawl.",
|
||||||
|
"Generates diverse search queries, searches the web in parallel, analyzes results,",
|
||||||
|
"and produces a comprehensive report with numbered citations and a bibliography.",
|
||||||
|
"Supports iterative refinement and sub-question decomposition for deeper analysis.",
|
||||||
|
"Parameters: question (required), depth, breadth, format, audience, details.",
|
||||||
|
"Use for complex, multi-faceted questions that benefit from multiple search angles;",
|
||||||
|
"for simple fact-finding questions use firecrawl_search directly instead.",
|
||||||
|
"Set audience to 'executive' for concise, action-oriented reports; 'expert' for technical depth;",
|
||||||
|
"'general' (default) for accessible reports.",
|
||||||
|
].join(" "),
|
||||||
|
parameters: DeepResearchParams as unknown as TSchema,
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
_toolCallId: string,
|
||||||
|
params: {
|
||||||
|
question: string;
|
||||||
|
depth?: number;
|
||||||
|
breadth?: number;
|
||||||
|
format?: "markdown" | "structured";
|
||||||
|
audience?: Audience;
|
||||||
|
details?: { showRoundDetails?: boolean };
|
||||||
|
},
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
onUpdate: ((partial: any) => void) | undefined,
|
||||||
|
ctx: any,
|
||||||
|
) {
|
||||||
|
const config: ResearchConfig = {
|
||||||
|
question: params.question,
|
||||||
|
depth: params.depth ?? 2,
|
||||||
|
breadth: params.breadth ?? 3,
|
||||||
|
format: params.format ?? "markdown",
|
||||||
|
audience: params.audience ?? "general",
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortSignal = signal;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { state: _state, onProgress, cleanup } = createProgressWidget(ctx);
|
||||||
|
|
||||||
|
let researchResult: ResearchReport | null = null;
|
||||||
|
let lastError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx.ui.setStatus(
|
||||||
|
"deep-research",
|
||||||
|
`🌐 Researching: ${truncate(config.question, 40)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
phase: "generating_queries",
|
||||||
|
message: "Starting deep research...",
|
||||||
|
fraction: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
researchResult = await runDeepResearch(
|
||||||
|
config,
|
||||||
|
ctx,
|
||||||
|
onProgress,
|
||||||
|
abortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Build the tool result ──────────────────────────────────
|
||||||
|
|
||||||
|
const details: ResearchDetails = {
|
||||||
|
rounds: researchResult.rounds.map((r) => ({
|
||||||
|
round: r.round,
|
||||||
|
queries: r.queries.map((q) => q.query),
|
||||||
|
findingsCount: r.findings.length,
|
||||||
|
resultsCount: r.results.length,
|
||||||
|
failedSearches: r.failedSearches,
|
||||||
|
})),
|
||||||
|
totalSearches: researchResult.totalSearches,
|
||||||
|
totalPagesScraped: researchResult.totalPagesScraped,
|
||||||
|
durationMs: researchResult.durationMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stream final content via onUpdate before returning
|
||||||
|
if (onUpdate) {
|
||||||
|
onUpdate({
|
||||||
|
content: [{ type: "text", text: researchResult.finalReport }],
|
||||||
|
details: {
|
||||||
|
phase: "complete",
|
||||||
|
duration: researchResult.durationMs,
|
||||||
|
rounds: researchResult.rounds.length,
|
||||||
|
findings: researchResult.rounds.reduce(
|
||||||
|
(s, r) => s + r.findings.length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
references: researchResult.references.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
ctx.ui.setStatus("deep-research", undefined);
|
||||||
|
|
||||||
|
let output = researchResult.finalReport;
|
||||||
|
|
||||||
|
// Append methodology section if requested
|
||||||
|
if (params.details?.showRoundDetails) {
|
||||||
|
output += `\n\n---\n\n## Research Methodology\n\n`;
|
||||||
|
for (const round of researchResult.rounds) {
|
||||||
|
output += `### Round ${round.round}\n\n`;
|
||||||
|
output += `**Queries:**\n`;
|
||||||
|
for (const q of round.queries) {
|
||||||
|
output += `- "${q.query}" (${q.angle}) — ${q.rationale}\n`;
|
||||||
|
}
|
||||||
|
output += `\n**Results scraped:** ${round.results.length}\n`;
|
||||||
|
output += `**Findings extracted:** ${round.findings.length}\n`;
|
||||||
|
if (round.failedSearches > 0) {
|
||||||
|
output += `**Failed searches:** ${round.failedSearches}\n`;
|
||||||
|
}
|
||||||
|
output += `\n`;
|
||||||
|
}
|
||||||
|
output += `**Total searches:** ${researchResult.totalSearches}\n`;
|
||||||
|
output += `**Total pages scraped:** ${researchResult.totalPagesScraped}\n`;
|
||||||
|
output += `**Sources in bibliography:** ${researchResult.references.length}\n`;
|
||||||
|
output += `**Duration:** ${formatDuration(researchResult.durationMs)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: output }],
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
ctx.ui.setStatus("deep-research", undefined);
|
||||||
|
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Research failed: ${lastError}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
rounds: [],
|
||||||
|
totalSearches: 0,
|
||||||
|
totalPagesScraped: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
error: lastError,
|
||||||
|
} as ResearchDetails & { error: string },
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TUI: Render the tool call (collapsed view) ──────────────────
|
||||||
|
|
||||||
|
renderCall(
|
||||||
|
args: any,
|
||||||
|
_options: any,
|
||||||
|
theme: any,
|
||||||
|
) {
|
||||||
|
const question = truncate(args.question ?? "?", 70);
|
||||||
|
const depth = args.depth ?? 2;
|
||||||
|
const breadth = args.breadth ?? 3;
|
||||||
|
const format = args.format ?? "markdown";
|
||||||
|
const audience = args.audience ?? "general";
|
||||||
|
|
||||||
|
const text =
|
||||||
|
theme.fg("toolTitle", theme.bold("deep_research ")) +
|
||||||
|
theme.fg("accent", `"${question}"`) +
|
||||||
|
theme.fg(
|
||||||
|
"muted",
|
||||||
|
` [depth:${depth} breadth:${breadth} ${format} ${audience}]`,
|
||||||
|
);
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TUI: Render the tool result (expanded/collapsed) ─────────────
|
||||||
|
|
||||||
|
renderResult(
|
||||||
|
result: any,
|
||||||
|
{ expanded }: { expanded: boolean },
|
||||||
|
theme: any,
|
||||||
|
_context: any,
|
||||||
|
) {
|
||||||
|
const details = result.details as ResearchDetails | undefined;
|
||||||
|
|
||||||
|
if (!details) {
|
||||||
|
const text = result.content?.[0]?.text ?? "(no output)";
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new Box();
|
||||||
|
|
||||||
|
// ── Collapsed view ────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
const totalRounds = details.rounds.length;
|
||||||
|
const totalFindings = details.rounds.reduce(
|
||||||
|
(s, r) => s + r.findingsCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const duration = formatDuration(details.durationMs);
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
text +=
|
||||||
|
theme.fg("success", "✓ ") +
|
||||||
|
theme.fg("toolTitle", theme.bold("deep research"));
|
||||||
|
text += theme.fg(
|
||||||
|
"muted",
|
||||||
|
` — ${totalRounds} rounds, ${totalFindings} findings`,
|
||||||
|
);
|
||||||
|
text += theme.fg("dim", ` (${duration})`);
|
||||||
|
text += "\n";
|
||||||
|
|
||||||
|
for (const round of details.rounds) {
|
||||||
|
const icon =
|
||||||
|
round.findingsCount > 0
|
||||||
|
? theme.fg("success", "✓")
|
||||||
|
: theme.fg("muted", "·");
|
||||||
|
text += ` ${icon} ${theme.fg("accent", `Round ${round.round}:`)} `;
|
||||||
|
text += theme.fg(
|
||||||
|
"dim",
|
||||||
|
`${round.queries.length} queries, ${round.resultsCount} pages, ${round.findingsCount} findings`,
|
||||||
|
);
|
||||||
|
text += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
text += theme.fg("muted", "(Ctrl+O to expand)");
|
||||||
|
container.addChild(new Text(text, 0, 0));
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expanded view ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const headerText =
|
||||||
|
theme.fg("toolTitle", theme.bold("Deep Research Results")) +
|
||||||
|
"\n" +
|
||||||
|
theme.fg("dim", `Duration: ${formatDuration(details.durationMs)} | `) +
|
||||||
|
theme.fg("dim", `Searches: ${details.totalSearches} | `) +
|
||||||
|
theme.fg("dim", `Pages scraped: ${details.totalPagesScraped}`);
|
||||||
|
container.addChild(new Text(headerText, 0, 0));
|
||||||
|
|
||||||
|
for (const round of details.rounds) {
|
||||||
|
container.addChild(new Text("", 0, 0)); // Spacer
|
||||||
|
const roundHeader = `Round ${round.round}`;
|
||||||
|
container.addChild(
|
||||||
|
new Text(theme.fg("toolTitle", theme.bold(roundHeader)), 0, 0),
|
||||||
|
);
|
||||||
|
container.addChild(
|
||||||
|
new Text(
|
||||||
|
theme.fg(
|
||||||
|
"dim",
|
||||||
|
`${round.queries.length} queries → ${round.resultsCount} pages → ${round.findingsCount} findings`,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const q of round.queries) {
|
||||||
|
container.addChild(
|
||||||
|
new Text(
|
||||||
|
theme.fg("muted", " · ") + theme.fg("accent", truncate(q, 70)),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Command ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerCommand("deepi-research", {
|
||||||
|
description:
|
||||||
|
"Conduct multi-round deep web research on any topic via Firecrawl. Usage: /deepi-research <question>",
|
||||||
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
||||||
|
if (!args || args.trim().length === 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"Usage: /deepi-research <your research question>",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask about depth
|
||||||
|
const depthStr = await ctx.ui.select("Research depth?", [
|
||||||
|
"1 round (quick survey)",
|
||||||
|
"2 rounds (standard)",
|
||||||
|
"3 rounds (deep dive)",
|
||||||
|
]);
|
||||||
|
const depth = depthStr?.startsWith("1")
|
||||||
|
? 1
|
||||||
|
: depthStr?.startsWith("3")
|
||||||
|
? 3
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
// Ask about breadth
|
||||||
|
const breadthStr = await ctx.ui.select("Research breadth?", [
|
||||||
|
"1 query/round (narrow)",
|
||||||
|
"3 queries/round (balanced)",
|
||||||
|
"5 queries/round (broad)",
|
||||||
|
]);
|
||||||
|
const breadth = breadthStr?.startsWith("1")
|
||||||
|
? 1
|
||||||
|
: breadthStr?.startsWith("5")
|
||||||
|
? 5
|
||||||
|
: 3;
|
||||||
|
|
||||||
|
// Ask about audience
|
||||||
|
const audienceStr = await ctx.ui.select("Report audience?", [
|
||||||
|
"General (accessible, explains terms)",
|
||||||
|
"Expert (technical depth, assumes domain knowledge)",
|
||||||
|
"Executive (concise, action-oriented)",
|
||||||
|
]);
|
||||||
|
const audience: Audience = audienceStr?.startsWith("Expert")
|
||||||
|
? "expert"
|
||||||
|
: audienceStr?.startsWith("Executive")
|
||||||
|
? "executive"
|
||||||
|
: "general";
|
||||||
|
|
||||||
|
ctx.ui.setStatus(
|
||||||
|
"deep-research",
|
||||||
|
`🌐 Researching: ${truncate(args, 40)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config: ResearchConfig = {
|
||||||
|
question: args,
|
||||||
|
depth,
|
||||||
|
breadth,
|
||||||
|
format: "markdown",
|
||||||
|
audience,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { onProgress, cleanup } = createProgressWidget(ctx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await runDeepResearch(config, ctx, onProgress);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
ctx.ui.setStatus("deep-research", undefined);
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Research complete: ${report.rounds.length} rounds, ${report.totalSearches} searches, ${report.totalPagesScraped} pages, ${report.references.length} sources in ${formatDuration(report.durationMs)}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send the report as a user message
|
||||||
|
pi.sendUserMessage(
|
||||||
|
`## Deep Research: ${args}\n\n${report.finalReport}\n\n---\n*${report.rounds.length} rounds · ${report.totalSearches} searches · ${report.totalPagesScraped} pages · ${report.references.length} sources · ${formatDuration(report.durationMs)}*`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
ctx.ui.setStatus("deep-research", undefined);
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
ctx.ui.notify(`Research failed: ${msg}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Startup check ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
||||||
|
const reachable = await isFirecrawlReachable();
|
||||||
|
if (!reachable) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"Deep Research: Firecrawl endpoint unreachable — searches will fail. Set firecrawl.baseUrl in config.yml (global ~/.omp/agent or project .omp) or the FIRECRAWL_BASE_URL env var.",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@mikefreno/omp-deepi-research",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "Deep research extension for pi — parallel web research via Firecrawl with iterative query refinement",
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"pi-extension",
|
||||||
|
"research",
|
||||||
|
"firecrawl",
|
||||||
|
"deep-research",
|
||||||
|
"web-search",
|
||||||
|
"ai"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"src/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepublishOnly": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.3.14"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.4.0"
|
||||||
|
},
|
||||||
|
"omp": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@oh-my-pi/pi-coding-agent": "17.2.12",
|
||||||
|
"@oh-my-pi/pi-tui": "17.2.12",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typebox": "^1.1.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
115
scripts/run-harness.ts
Normal file
115
scripts/run-harness.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Deep Research — standalone end-to-end test harness
|
||||||
|
*
|
||||||
|
* Imports the extension's REAL source (fresh, uncached) and runs the full
|
||||||
|
* research pipeline with real Firecrawl + real pi agent sessions.
|
||||||
|
*
|
||||||
|
* WHY: pi caches extension modules per session (keyed by path + cwd +
|
||||||
|
* generation) and only invalidates on /reload or cwd change. Tool calls in
|
||||||
|
* a live session therefore run the version loaded at session start. This
|
||||||
|
* harness bypasses that cache so you can iterate on src/ without reloading
|
||||||
|
* pi, and it prints verification stats (round stats, angle provenance,
|
||||||
|
* corroboration distribution, citation integrity, authority stats).
|
||||||
|
*
|
||||||
|
* Usage (from repo root):
|
||||||
|
* NODE_PATH=/opt/homebrew/lib/node_modules bun scripts/run-harness.ts \
|
||||||
|
* "<question>" [depth] [breadth] [audience]
|
||||||
|
*
|
||||||
|
* Requires the pi SDK to be resolvable (NODE_PATH above points at the
|
||||||
|
* global pi install) and Firecrawl reachable (settings.json firecrawl.baseUrl).
|
||||||
|
* Report is written to /tmp/deepi-harness/report.md.
|
||||||
|
*/
|
||||||
|
import { runDeepResearch } from "../src/research.ts";
|
||||||
|
import type { ResearchReport } from "../src/types.ts";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const question =
|
||||||
|
process.argv[2] ?? "Compare Rust and Go for backend services in 2025";
|
||||||
|
const depth = Number(process.argv[3] ?? 2);
|
||||||
|
const breadth = Number(process.argv[4] ?? 3);
|
||||||
|
const audience = (process.argv[5] ?? "expert") as
|
||||||
|
| "expert"
|
||||||
|
| "general"
|
||||||
|
| "executive";
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
const report: ResearchReport = await runDeepResearch(
|
||||||
|
{
|
||||||
|
question,
|
||||||
|
depth,
|
||||||
|
breadth,
|
||||||
|
format: "markdown",
|
||||||
|
audience,
|
||||||
|
},
|
||||||
|
{ cwd: process.cwd() } as any,
|
||||||
|
(update) => {
|
||||||
|
const round = update.round
|
||||||
|
? ` [r${update.round}/${update.totalRounds}]`
|
||||||
|
: "";
|
||||||
|
console.log(` [${update.phase}${round}] ${update.message}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n" + "=".repeat(80));
|
||||||
|
console.log(`DURATION: ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
||||||
|
console.log(`TOTAL SEARCHES: ${report.totalSearches}`);
|
||||||
|
console.log(`TOTAL PAGES: ${report.totalPagesScraped}`);
|
||||||
|
console.log(`REFERENCES: ${report.references.length}`);
|
||||||
|
console.log(`ROUNDS: ${report.rounds.length}`);
|
||||||
|
|
||||||
|
for (const round of report.rounds) {
|
||||||
|
console.log(
|
||||||
|
` Round ${round.round}: ${round.queries.length} queries (${round.successfulSearches} ok, ${round.failedSearches} failed) → ${round.results.length} unique pages → ${round.findings.length} findings`,
|
||||||
|
);
|
||||||
|
const angles = new Map<string, number>();
|
||||||
|
for (const f of round.findings) {
|
||||||
|
const a = f.angle ?? "none";
|
||||||
|
angles.set(a, (angles.get(a) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` finding angles: ${Array.from(angles.entries())
|
||||||
|
.map(([a, n]) => `${a}(${n})`)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
const corr = round.findings.map((f) => f.corroborationScore ?? 0);
|
||||||
|
const strong = corr.filter((c) => c >= 0.5).length;
|
||||||
|
const partial = corr.filter((c) => c > 0 && c < 0.5).length;
|
||||||
|
const none = corr.filter((c) => c === 0).length;
|
||||||
|
console.log(
|
||||||
|
` corroboration: ${strong} strong(>=0.5), ${partial} partial, ${none} none`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Citation integrity: citations used in the report BODY vs reference list.
|
||||||
|
// (Reference titles may legitimately contain "[2026]" etc. — those live in
|
||||||
|
// the ## References section which was rebuilt authoritatively.)
|
||||||
|
const refIds = new Set(report.references.map((r) => r.id));
|
||||||
|
const body = report.finalReport.replace(/^## References[\s\S]*$/m, "");
|
||||||
|
const cited = new Set(
|
||||||
|
[...body.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1])),
|
||||||
|
);
|
||||||
|
const dangling = [...cited].filter((id) => !refIds.has(id));
|
||||||
|
console.log(
|
||||||
|
`CITATIONS (body only): ${cited.size} unique numbers used, ${dangling.length} dangling (${dangling.join(",")})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`REFERENCES SECTION PRESENT: ${/^## References/m.test(report.finalReport)}`,
|
||||||
|
);
|
||||||
|
const iconCount = (report.finalReport.match(/[⭐✓○]/g) ?? []).length;
|
||||||
|
console.log(`AUTHORITY ICONS IN REFERENCES: ${iconCount}`);
|
||||||
|
|
||||||
|
const authorities = report.references.map((r) => r.authorityScore);
|
||||||
|
const avgAuth =
|
||||||
|
authorities.reduce((a, b) => a + b, 0) / Math.max(1, authorities.length);
|
||||||
|
console.log(
|
||||||
|
`AVG SOURCE AUTHORITY: ${(avgAuth * 100).toFixed(0)}% (max ${(Math.max(...authorities) * 100).toFixed(0)}%, min ${(Math.min(...authorities) * 100).toFixed(0)}%)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"DOMAINS:",
|
||||||
|
[...new Set(report.references.map((r) => r.domain))].join(", "),
|
||||||
|
);
|
||||||
|
|
||||||
|
mkdirSync("/tmp/deepi-harness", { recursive: true });
|
||||||
|
writeFileSync("/tmp/deepi-harness/report.md", report.finalReport);
|
||||||
|
console.log("\nReport saved to /tmp/deepi-harness/report.md");
|
||||||
150
src/agent.ts
Normal file
150
src/agent.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Deep Research — Agent Session helper
|
||||||
|
*
|
||||||
|
* Uses omp's in-process `createAgentSession` for LLM subtasks
|
||||||
|
* (query generation, result analysis, report synthesis).
|
||||||
|
* Pattern borrowed from ralpi's runAgentSession().
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createAgentSession,
|
||||||
|
AgentRegistry,
|
||||||
|
SessionManager,
|
||||||
|
} from "@oh-my-pi/pi-coding-agent";
|
||||||
|
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||||
|
|
||||||
|
/** Aggregate tool usage stats */
|
||||||
|
export interface ToolUsage {
|
||||||
|
read: number;
|
||||||
|
write: number;
|
||||||
|
edit: number;
|
||||||
|
bash: number;
|
||||||
|
other: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResult {
|
||||||
|
success: boolean;
|
||||||
|
text: string;
|
||||||
|
error?: string;
|
||||||
|
toolUsage: ToolUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a prompt through an in-process omp agent session.
|
||||||
|
* Non-blocking — the event loop stays responsive.
|
||||||
|
*/
|
||||||
|
export async function runAnalysisAgent(
|
||||||
|
systemPrompt: string,
|
||||||
|
taskPrompt: string,
|
||||||
|
cwd: string,
|
||||||
|
timeoutMs: number = 120_000,
|
||||||
|
onEvent?: (event: AgentSessionEvent) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AgentResult> {
|
||||||
|
const toolUsage: ToolUsage = {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
edit: 0,
|
||||||
|
bash: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
if (timeoutMs > 0) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
sessionRef.session?.agent.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRef: {
|
||||||
|
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createAgentSession({
|
||||||
|
cwd,
|
||||||
|
sessionManager: SessionManager.inMemory(cwd),
|
||||||
|
toolNames: ["read", "grep", "glob"],
|
||||||
|
restrictToolNames: true,
|
||||||
|
disableExtensionDiscovery: true,
|
||||||
|
skills: [],
|
||||||
|
promptTemplates: [],
|
||||||
|
rules: [],
|
||||||
|
contextFiles: [],
|
||||||
|
enableMCP: false,
|
||||||
|
enableLsp: false,
|
||||||
|
agentRegistry: new AgentRegistry(),
|
||||||
|
});
|
||||||
|
sessionRef.session = result.session;
|
||||||
|
|
||||||
|
const abortHandler = () => result.session.agent.abort();
|
||||||
|
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||||
|
|
||||||
|
let finalText = "";
|
||||||
|
let errorMessage: string | undefined;
|
||||||
|
|
||||||
|
const unsubscribe = result.session.subscribe((event: AgentSessionEvent) => {
|
||||||
|
onEvent?.(event);
|
||||||
|
|
||||||
|
if (event.type === "message_end") {
|
||||||
|
const message = event.message as {
|
||||||
|
role?: string;
|
||||||
|
content?: unknown;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
if (message.role !== "assistant") return;
|
||||||
|
if (message.errorMessage) errorMessage = message.errorMessage;
|
||||||
|
const text = extractAssistantText(message.content);
|
||||||
|
if (text) finalText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "tool_execution_start") {
|
||||||
|
const name = event.toolName;
|
||||||
|
if (name in toolUsage) {
|
||||||
|
(toolUsage as unknown as Record<string, number>)[name]++;
|
||||||
|
} else {
|
||||||
|
toolUsage.other++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error("Aborted");
|
||||||
|
|
||||||
|
await result.session.prompt(`${systemPrompt}\n\n${taskPrompt}`);
|
||||||
|
await result.session.agent.waitForIdle();
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
result.session.dispose();
|
||||||
|
signal?.removeEventListener("abort", abortHandler);
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
|
||||||
|
if (errorMessage && !finalText) {
|
||||||
|
return { success: false, text: "", error: errorMessage, toolUsage };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, text: finalText.trim(), toolUsage };
|
||||||
|
} catch (error) {
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
text: "",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
toolUsage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
sessionRef.session?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssistantText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
return content
|
||||||
|
.filter(
|
||||||
|
(c): c is { type: string; text?: string } =>
|
||||||
|
!!c &&
|
||||||
|
typeof c === "object" &&
|
||||||
|
(c as { type?: string }).type === "text",
|
||||||
|
)
|
||||||
|
.map((c) => (c as { text?: string }).text ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
525
src/firecrawl.ts
Normal file
525
src/firecrawl.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* Deep Research — direct Firecrawl HTTP client
|
||||||
|
*
|
||||||
|
* Calls the self-hosted Firecrawl API directly (same approach as the
|
||||||
|
* firecrawl.ts extension)
|
||||||
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
import type { SearchResult, EnrichedSearchResult, ContentType } from "./types";
|
||||||
|
import { getAgentDir } from "@oh-my-pi/pi-coding-agent";
|
||||||
|
|
||||||
|
/* ── Config ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and merge Firecrawl settings from omp's config.yml files.
|
||||||
|
*
|
||||||
|
* Resolution order (later wins):
|
||||||
|
* 1. env vars FIRECRAWL_BASE_URL / FIRECRAWL_API_KEY
|
||||||
|
* 2. global ~/.omp/agent/config.yml → firecrawl.*
|
||||||
|
* 3. project .omp/config.yml → firecrawl.*
|
||||||
|
* 4. default http://localhost:3002 (if no baseUrl configured)
|
||||||
|
*/
|
||||||
|
function loadFirecrawlConfig() {
|
||||||
|
// Start with env var defaults
|
||||||
|
let baseUrl = process.env.FIRECRAWL_BASE_URL ?? "http://localhost:3002";
|
||||||
|
let apiKey = process.env.FIRECRAWL_API_KEY;
|
||||||
|
|
||||||
|
const agentDir = getAgentDir();
|
||||||
|
|
||||||
|
// Helper: read a config.yml and merge its firecrawl.* keys
|
||||||
|
const tryReadConfig = (configPath: string): void => {
|
||||||
|
try {
|
||||||
|
const raw = parseYaml(fs.readFileSync(configPath, "utf-8")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const fc = (raw?.firecrawl ?? {}) as Record<string, unknown>;
|
||||||
|
if (typeof fc.baseUrl === "string" && fc.baseUrl.length > 0) {
|
||||||
|
baseUrl = fc.baseUrl;
|
||||||
|
}
|
||||||
|
if (typeof fc.apiKey === "string" && fc.apiKey.length > 0) {
|
||||||
|
apiKey = fc.apiKey;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File missing or unparseable — skip
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Global config
|
||||||
|
tryReadConfig(path.join(agentDir, "config.yml"));
|
||||||
|
|
||||||
|
// 2. Project config (override global)
|
||||||
|
tryReadConfig(path.join(process.cwd(), ".omp", "config.yml"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { baseUrl: BASE_URL, apiKey: API_KEY } = loadFirecrawlConfig();
|
||||||
|
|
||||||
|
/* ── Domain Authority Heuristics ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known high-authority domains and their authority scores (0.0 – 1.0).
|
||||||
|
* Academic, official, and established technical sources score highest.
|
||||||
|
*/
|
||||||
|
const AUTHORITY_DOMAINS: Record<string, number> = {
|
||||||
|
// Academic & scholarly
|
||||||
|
"arxiv.org": 0.95,
|
||||||
|
"scholar.google.com": 0.95,
|
||||||
|
"pubmed.ncbi.nlm.nih.gov": 0.95,
|
||||||
|
"semanticscholar.org": 0.9,
|
||||||
|
"ieee.org": 0.95,
|
||||||
|
"acm.org": 0.95,
|
||||||
|
"springer.com": 0.9,
|
||||||
|
"sciencedirect.com": 0.9,
|
||||||
|
"wiley.com": 0.85,
|
||||||
|
"nature.com": 0.95,
|
||||||
|
"science.org": 0.95,
|
||||||
|
"plos.org": 0.85,
|
||||||
|
// Official documentation
|
||||||
|
"docs.python.org": 0.9,
|
||||||
|
"developer.mozilla.org": 0.9,
|
||||||
|
"learn.microsoft.com": 0.85,
|
||||||
|
"developer.apple.com": 0.85,
|
||||||
|
"kubernetes.io": 0.85,
|
||||||
|
"react.dev": 0.85,
|
||||||
|
"nextjs.org": 0.8,
|
||||||
|
// Official language/platform docs
|
||||||
|
"go.dev": 0.9,
|
||||||
|
"golang.org": 0.9,
|
||||||
|
"rust-lang.org": 0.9,
|
||||||
|
"nodejs.org": 0.85,
|
||||||
|
"python.org": 0.85,
|
||||||
|
"typescriptlang.org": 0.85,
|
||||||
|
"openai.com": 0.8,
|
||||||
|
"anthropic.com": 0.8,
|
||||||
|
"cloud.google.com": 0.8,
|
||||||
|
"aws.amazon.com": 0.8,
|
||||||
|
"azure.microsoft.com": 0.8,
|
||||||
|
"postgresql.org": 0.85,
|
||||||
|
"sqlite.org": 0.85,
|
||||||
|
"redis.io": 0.85,
|
||||||
|
"docker.com": 0.75,
|
||||||
|
"elastic.co": 0.75,
|
||||||
|
"grafana.com": 0.75,
|
||||||
|
"datadoghq.com": 0.75,
|
||||||
|
"cloudflare.com": 0.8,
|
||||||
|
"blog.cloudflare.com": 0.8,
|
||||||
|
"techempower.com": 0.8,
|
||||||
|
"goframe.org": 0.75,
|
||||||
|
"corrode.dev": 0.6,
|
||||||
|
"evrone.com": 0.4,
|
||||||
|
"rustify.rs": 0.4,
|
||||||
|
"core.cz": 0.4,
|
||||||
|
// Medical / clinical
|
||||||
|
"mayoclinic.org": 0.9,
|
||||||
|
"heart.org": 0.85,
|
||||||
|
"researchgate.net": 0.6,
|
||||||
|
"healthline.com": 0.5,
|
||||||
|
"medicalnewstoday.com": 0.5,
|
||||||
|
"webmd.com": 0.45,
|
||||||
|
"verywellhealth.com": 0.5,
|
||||||
|
// Databases & dev tools
|
||||||
|
"mysql.com": 0.85,
|
||||||
|
"mariadb.org": 0.85,
|
||||||
|
"cockroachlabs.com": 0.7,
|
||||||
|
"timescale.com": 0.7,
|
||||||
|
"mongodb.com": 0.8,
|
||||||
|
"liquibase.com": 0.6,
|
||||||
|
"sqlpipe.com": 0.5,
|
||||||
|
"data-tune.com": 0.4,
|
||||||
|
"binaryigor.com": 0.4,
|
||||||
|
// Government & non-profits
|
||||||
|
".gov": 0.9,
|
||||||
|
".edu": 0.85,
|
||||||
|
"who.int": 0.9,
|
||||||
|
"worldbank.org": 0.85,
|
||||||
|
"oecd.org": 0.85,
|
||||||
|
// Established tech & news
|
||||||
|
"github.com": 0.8,
|
||||||
|
"stackoverflow.com": 0.7,
|
||||||
|
"medium.com": 0.4,
|
||||||
|
"dev.to": 0.5,
|
||||||
|
"wikipedia.org": 0.7,
|
||||||
|
"reuters.com": 0.8,
|
||||||
|
"apnews.com": 0.8,
|
||||||
|
"bbc.com": 0.75,
|
||||||
|
"nytimes.com": 0.75,
|
||||||
|
"theguardian.com": 0.7,
|
||||||
|
"techcrunch.com": 0.6,
|
||||||
|
"arstechnica.com": 0.65,
|
||||||
|
"wired.com": 0.65,
|
||||||
|
"infoworld.com": 0.55,
|
||||||
|
// Practitioner/aggregator content with measurable quality
|
||||||
|
"github.io": 0.6,
|
||||||
|
"crates.io": 0.7,
|
||||||
|
"docs.rs": 0.75,
|
||||||
|
"digitalocean.com": 0.6,
|
||||||
|
"freecodecamp.org": 0.6,
|
||||||
|
"geeksforgeeks.org": 0.35,
|
||||||
|
"stackexchange.com": 0.65,
|
||||||
|
"huggingface.co": 0.65,
|
||||||
|
"nasa.gov": 0.9,
|
||||||
|
"mit.edu": 0.9,
|
||||||
|
"stanford.edu": 0.9,
|
||||||
|
"harvard.edu": 0.9,
|
||||||
|
"ox.ac.uk": 0.9,
|
||||||
|
"cam.ac.uk": 0.9,
|
||||||
|
// Low-authority: personal social / SEO content
|
||||||
|
"linkedin.com": 0.25,
|
||||||
|
"reddit.com": 0.25,
|
||||||
|
"x.com": 0.3,
|
||||||
|
"twitter.com": 0.3,
|
||||||
|
"youtube.com": 0.3,
|
||||||
|
"blogspot.com": 0.25,
|
||||||
|
"substack.com": 0.3,
|
||||||
|
"hashnode.dev": 0.35,
|
||||||
|
"quora.com": 0.3,
|
||||||
|
"netguru.com": 0.3,
|
||||||
|
"relisoftware.com": 0.3,
|
||||||
|
"dasroot.net": 0.3,
|
||||||
|
"devgenius.io": 0.3,
|
||||||
|
"devnewsletter.com": 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known low-quality SEO/comparison-spam domains. Content is often
|
||||||
|
* auto-generated, republished from other sites, or thin on substance.
|
||||||
|
* These get a hard authority floor so they never rank above real content.
|
||||||
|
*/
|
||||||
|
const LOW_AUTHORITY_DOMAINS: Record<string, number> = {
|
||||||
|
"markaicode.com": 0.15,
|
||||||
|
"bytegoblin.io": 0.2,
|
||||||
|
"towardsdev.com": 0.2,
|
||||||
|
"rustvsgo.com": 0.3,
|
||||||
|
"seekingalpha.com": 0.3,
|
||||||
|
"investopedia.com": 0.55,
|
||||||
|
"devops-daily.com": 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Content-type hints based on domain patterns */
|
||||||
|
const CONTENT_TYPE_HINTS: [RegExp, ContentType][] = [
|
||||||
|
[
|
||||||
|
/arxiv\.org|semanticscholar|ieee\.org|acm\.org|springer|sciencedirect|pubmed\.ncbi/,
|
||||||
|
"paper",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/docs\.|learn\.|developer\.|kubernetes\.io|react\.dev|nextjs\.org/,
|
||||||
|
"documentation",
|
||||||
|
],
|
||||||
|
[/wikipedia\.org|stackoverflow\.com|medium\.com|dev\.to/, "forum"],
|
||||||
|
[
|
||||||
|
/reuters\.com|apnews\.com|bbc\.com|nytimes\.com|techcrunch|arstechnica|wired/,
|
||||||
|
"news",
|
||||||
|
],
|
||||||
|
[/\.gov|\.edu|who\.int|worldbank|oecd\.org/, "official"],
|
||||||
|
[/github\.com/, "documentation"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ── Source enrichment helpers ───────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the registered domain from a URL (e.g., "blog.example.com" → "example.com").
|
||||||
|
* Uses a simple 2-part TLD heuristic. For common cases like .co.uk this is approximate.
|
||||||
|
*/
|
||||||
|
function extractDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.toLowerCase();
|
||||||
|
// Special-case common multi-part TLDs
|
||||||
|
const multiPartTlds =
|
||||||
|
/\.(co\.uk|org\.uk|ac\.uk|gov\.uk|com\.au|co\.jp|co\.kr|com\.br)$/;
|
||||||
|
const parts = hostname.split(".");
|
||||||
|
if (multiPartTlds.test(hostname) && parts.length >= 3) {
|
||||||
|
return parts.slice(-3).join(".");
|
||||||
|
}
|
||||||
|
return parts.slice(-2).join(".");
|
||||||
|
} catch {
|
||||||
|
return url.replace(/^https?:\/\//, "").split("/")[0] ?? url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAuthorityScore(domain: string): number {
|
||||||
|
// Hard floor for known low-authority domains first
|
||||||
|
if (LOW_AUTHORITY_DOMAINS[domain] !== undefined)
|
||||||
|
return LOW_AUTHORITY_DOMAINS[domain];
|
||||||
|
|
||||||
|
// Direct match first
|
||||||
|
if (AUTHORITY_DOMAINS[domain]) return AUTHORITY_DOMAINS[domain];
|
||||||
|
|
||||||
|
// Suffix matches (.gov, .edu, etc.)
|
||||||
|
for (const [key, score] of Object.entries(AUTHORITY_DOMAINS)) {
|
||||||
|
if (key.startsWith(".") && domain.endsWith(key)) return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subdomain matches (e.g., blog.example.com matches example.com)
|
||||||
|
const parent = domain.split(".").slice(-2).join(".");
|
||||||
|
if (parent !== domain && AUTHORITY_DOMAINS[parent]) {
|
||||||
|
return AUTHORITY_DOMAINS[parent] * 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// github.io personal sites: treat as practitioner content (medium)
|
||||||
|
if (domain.endsWith(".github.io")) return 0.55;
|
||||||
|
|
||||||
|
return 0.3; // Unknown / low-authority default
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectContentType(url: string, description: string): ContentType {
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
const lowerDesc = description.toLowerCase();
|
||||||
|
|
||||||
|
for (const [pattern, type] of CONTENT_TYPE_HINTS) {
|
||||||
|
if (pattern.test(lowerUrl)) return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristics from description text
|
||||||
|
if (/paper|research|study|experiment|analysis\b/.test(lowerDesc))
|
||||||
|
return "paper";
|
||||||
|
if (/documentation|guide|tutorial|api|reference/.test(lowerDesc))
|
||||||
|
return "documentation";
|
||||||
|
if (/blog|post|article|opinion/.test(lowerDesc)) return "blog";
|
||||||
|
if (/news|report|announce|release/.test(lowerDesc)) return "news";
|
||||||
|
if (/forum|discussion|question|answer|thread/.test(lowerDesc)) return "forum";
|
||||||
|
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseDate(dateStr: string | undefined | null): Date | null {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a title for near-duplicate detection: lowercase, strip
|
||||||
|
* punctuation, collapse whitespace, drop common filler words.
|
||||||
|
* Two syndicated copies of the same article normalize identically.
|
||||||
|
*/
|
||||||
|
export function normalizeTitle(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, " ")
|
||||||
|
.replace(
|
||||||
|
/\b(?:the|a|an|of|for|and|or|in|on|with|vs|versus|to|how|what|why|2024|2025|2026)\b/g,
|
||||||
|
" ",
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Near-duplicate check between two titles: normalized forms must share
|
||||||
|
* a substantial token overlap (same core words in the same order).
|
||||||
|
*/
|
||||||
|
export function isNearDuplicateTitle(a: string, b: string): boolean {
|
||||||
|
const normA = normalizeTitle(a);
|
||||||
|
const normB = normalizeTitle(b);
|
||||||
|
if (!normA || !normB) return false;
|
||||||
|
if (normA === normB) return true;
|
||||||
|
|
||||||
|
const tokensA = normA.split(" ");
|
||||||
|
const tokensB = normB.split(" ");
|
||||||
|
if (tokensA.length < 3 || tokensB.length < 3) return normA === normB;
|
||||||
|
|
||||||
|
// Check if one title is a substring of the other (after normalization)
|
||||||
|
if (normA.includes(normB) || normB.includes(normA)) return true;
|
||||||
|
|
||||||
|
// Jaccard-ish overlap on the shorter token set
|
||||||
|
const [short, long] =
|
||||||
|
tokensA.length <= tokensB.length ? [tokensA, tokensB] : [tokensB, tokensA];
|
||||||
|
const overlap = short.filter((t) => long.includes(t)).length;
|
||||||
|
return overlap / short.length >= 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich a raw search result with source authority metadata.
|
||||||
|
* Accepts extra fields (e.g. date) from the Firecrawl API response.
|
||||||
|
*/
|
||||||
|
export function enrichResult(
|
||||||
|
result: SearchResult & Record<string, unknown>,
|
||||||
|
): EnrichedSearchResult {
|
||||||
|
const domain = extractDomain(result.url);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
domain,
|
||||||
|
authorityScore: computeAuthorityScore(domain),
|
||||||
|
publishedDate: tryParseDate(result.date as string | undefined),
|
||||||
|
contentType: detectContentType(result.url, result.description),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function firecrawlRequest(
|
||||||
|
endpoint: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (API_KEY) {
|
||||||
|
headers["Authorization"] = `Bearer ${API_KEY}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/v1/${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(
|
||||||
|
`Firecrawl ${endpoint} failed (${res.status}): ${text.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* firecrawlRequest with retry-with-backoff for transient failures
|
||||||
|
* (429 rate limits, 5xx server errors, network blips). Does NOT retry
|
||||||
|
* 4xx client errors (invalid requests) or aborts.
|
||||||
|
*/
|
||||||
|
async function firecrawlRequestWithRetry(
|
||||||
|
endpoint: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
retries: number = 2,
|
||||||
|
): Promise<unknown> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
if (signal?.aborted) throw new Error("Aborted");
|
||||||
|
try {
|
||||||
|
return await firecrawlRequest(endpoint, body, signal);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const status =
|
||||||
|
error instanceof Error
|
||||||
|
? Number(/failed \((\d+)\)/.exec(error.message)?.[1] ?? 0)
|
||||||
|
: 0;
|
||||||
|
// Don't retry aborts or 4xx client errors (other than 429)
|
||||||
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
(status >= 400 && status < 500 && status !== 429)
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt < retries) {
|
||||||
|
const delayMs = 400 * 2 ** attempt + Math.random() * 200;
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isFirecrawlReachable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/v1/scrape`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: "https://example.com", formats: ["links"] }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the web and return structured, enriched results.
|
||||||
|
* Uses Firecrawl's search endpoint with scrape to get full page content.
|
||||||
|
*/
|
||||||
|
export async function searchWeb(
|
||||||
|
query: string,
|
||||||
|
limit: number = 5,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<EnrichedSearchResult[]> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
query,
|
||||||
|
limit: Math.min(limit, 10),
|
||||||
|
scrapeOptions: {
|
||||||
|
formats: ["markdown"],
|
||||||
|
onlyMainContent: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await firecrawlRequestWithRetry("search", body, signal);
|
||||||
|
|
||||||
|
if (!result || typeof result !== "object") return [];
|
||||||
|
|
||||||
|
const res = result as {
|
||||||
|
success?: boolean;
|
||||||
|
data?: Record<string, unknown>[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!res.success || !res.data) return [];
|
||||||
|
|
||||||
|
const rawResults: (SearchResult & Record<string, unknown>)[] = res.data
|
||||||
|
.map((doc) => ({
|
||||||
|
title: (doc.title as string) ?? "",
|
||||||
|
url: (doc.url as string) ?? "",
|
||||||
|
description: (doc.description as string) ?? "",
|
||||||
|
markdown: (doc.markdown as string) ?? "",
|
||||||
|
// Preserve extra fields for date extraction
|
||||||
|
...doc,
|
||||||
|
}))
|
||||||
|
.filter((r) => {
|
||||||
|
// Keep results with a meaningful body OR a substantive description.
|
||||||
|
// Filters out stub pages / pure navigation results that would
|
||||||
|
// waste analysis tokens.
|
||||||
|
const hasBody = (r.markdown ?? "").trim().length >= 150;
|
||||||
|
const hasSubstantiveDesc = (r.description ?? "").trim().length >= 40;
|
||||||
|
return hasBody || hasSubstantiveDesc;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich each result with source metadata
|
||||||
|
return rawResults.map(enrichResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrape ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape a single URL and return its markdown content.
|
||||||
|
*/
|
||||||
|
export async function scrapeUrl(
|
||||||
|
url: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ title: string; markdown: string; links: string[] } | null> {
|
||||||
|
const result = await firecrawlRequestWithRetry(
|
||||||
|
"scrape",
|
||||||
|
{ url, formats: ["markdown"] },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result || typeof result !== "object") return null;
|
||||||
|
|
||||||
|
const res = result as {
|
||||||
|
success?: boolean;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!res.success || !res.data) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: (res.data.title as string) ?? "",
|
||||||
|
markdown: (res.data.markdown as string) ?? "",
|
||||||
|
links: (res.data.links as string[]) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
625
src/queries.ts
Normal file
625
src/queries.ts
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
/**
|
||||||
|
* Deep Research — Search query generation & refinement
|
||||||
|
*
|
||||||
|
* Uses an LLM agent to generate search queries from different research
|
||||||
|
* angles, then analyzes results to produce follow-up queries.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
SearchQuery,
|
||||||
|
Finding,
|
||||||
|
ResearchRound,
|
||||||
|
EnrichedSearchResult,
|
||||||
|
} from "./types";
|
||||||
|
import { runAnalysisAgent } from "./agent";
|
||||||
|
|
||||||
|
/* ── System Prompts ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const DECOMPOSE_SYSTEM = `You are a research methodology expert. Given a broad research question, your job is to break it down into 4-7 focused sub-questions that, when answered, collectively provide a complete answer to the original question.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Each sub-question should tackle ONE specific facet of the research question
|
||||||
|
- Cover different dimensions: what, how, why, who, comparison, evidence, implications
|
||||||
|
- Sub-questions should be independently researchable via web search
|
||||||
|
- Avoid overlap between sub-questions
|
||||||
|
- Prioritize questions that will surface concrete evidence over speculative ones
|
||||||
|
|
||||||
|
Output ONLY a JSON array of sub-question strings.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Input: "What are the benefits and risks of artificial intelligence in healthcare?"
|
||||||
|
Output: ["What specific AI technologies are currently deployed in clinical healthcare settings?", "What peer-reviewed evidence exists for AI improving diagnostic accuracy?", "What are the documented risks and failure cases of AI in healthcare?", "How do regulatory frameworks (FDA, EMA) address AI-based medical devices?", "What do healthcare practitioners report as barriers to AI adoption?"]
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GENERATE_QUERIES_SYSTEM = `You are a research methodology expert. Your role is to generate effective web search queries that will yield high-quality, diverse information about a research topic.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Create queries from DIFFERENT angles (technical, practical, comparative, critical, forward-looking, authoritative)
|
||||||
|
- Each query should target a specific facet of the question
|
||||||
|
- Queries should use keywords that search engines rank well (avoid overly long questions)
|
||||||
|
- Cover contrasting viewpoints and alternative approaches
|
||||||
|
- Include queries for finding authoritative sources (docs, papers, official sites)
|
||||||
|
- Prioritize recent information where relevant
|
||||||
|
|
||||||
|
Output ONLY a JSON array of objects with fields:
|
||||||
|
- "query": the search query string
|
||||||
|
- "rationale": why this query will help answer the research question
|
||||||
|
- "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" | "historical" | "case-study" | "data-statistics" | "ethical"
|
||||||
|
|
||||||
|
Example:
|
||||||
|
[
|
||||||
|
{"query": "Rust async/await performance benchmarks 2024", "rationale": "Understanding current performance characteristics", "angle": "technical"},
|
||||||
|
{"query": "Rust vs Go concurrency patterns comparison", "rationale": "Comparative analysis helps contextualize trade-offs", "angle": "comparative"}
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FOLLOWUP_SYSTEM = `You are a research analyst. Given the research question, sub-questions, and findings so far, your job is to identify what's still unknown and generate follow-up search queries to fill those gaps.
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- Claims made without sufficient evidence
|
||||||
|
- Conflicting information that needs resolution
|
||||||
|
- Angles that haven't been explored yet
|
||||||
|
- Missing authoritative sources (papers, official docs, primary data)
|
||||||
|
- Practical implications that need more detail
|
||||||
|
- Recent developments that might have updated findings
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Do NOT repeat or paraphrase queries already explored — aim for genuinely new angles
|
||||||
|
- Prefer querying for authoritative/primary sources over more blog posts when evidence is weak
|
||||||
|
- When findings conflict, craft a query designed to resolve the contradiction
|
||||||
|
- Keep queries concise and keyword-rich
|
||||||
|
|
||||||
|
Output ONLY a JSON array of objects with fields:
|
||||||
|
- "query": the search query string
|
||||||
|
- "rationale": what gap this query fills or what angle it explores
|
||||||
|
- "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" | "historical" | "case-study" | "data-statistics" | "ethical"
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* ── JSON parsing helpers ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robustly parse a JSON array from LLM output.
|
||||||
|
*
|
||||||
|
* LLMs frequently wrap JSON in ```json fences, prepend prose like
|
||||||
|
* "Here are the queries:", or emit trailing punctuation. This strips
|
||||||
|
* fences and extracts the first bracketed array before parsing.
|
||||||
|
*
|
||||||
|
* Returns null when no array can be extracted.
|
||||||
|
*/
|
||||||
|
function parseJsonArray(text: string): unknown[] | null {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// Strip markdown code fences
|
||||||
|
const withoutFences = text
|
||||||
|
.replace(/```(?:json|javascript)?\s*/gi, "")
|
||||||
|
.replace(/```/g, "");
|
||||||
|
|
||||||
|
// Find the first '[' ... ']' block (arrays are our target shape)
|
||||||
|
const start = withoutFences.indexOf("[");
|
||||||
|
const end = withoutFences.lastIndexOf("]");
|
||||||
|
if (start === -1 || end === -1 || end <= start) return null;
|
||||||
|
|
||||||
|
const candidate = withoutFences.slice(start, end + 1);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(candidate);
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
// Try to salvage: strip trailing commas (common LLM artifact)
|
||||||
|
try {
|
||||||
|
const fixed = candidate.replace(/,\s*([}\]])/g, "$1");
|
||||||
|
const parsed = JSON.parse(fixed);
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a parsed array entry to a SearchQuery, tolerating missing fields. */
|
||||||
|
function toSearchQuery(q: Record<string, unknown>): SearchQuery | null {
|
||||||
|
const query = String(q.query ?? "").trim();
|
||||||
|
if (!query) return null;
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
rationale: String(q.rationale ?? "").trim(),
|
||||||
|
angle: String(q.angle ?? "technical").trim() || "technical",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sub-Question Decomposition ───────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompose a broad research question into focused, independently
|
||||||
|
* researchable sub-questions. Returns the sub-questions or an empty
|
||||||
|
* array if the LLM call fails.
|
||||||
|
*/
|
||||||
|
export async function decomposeQuestion(
|
||||||
|
question: string,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const taskPrompt = `Break down this research question into 4-7 focused sub-questions:\n\n${question}`;
|
||||||
|
|
||||||
|
const result = await runAnalysisAgent(
|
||||||
|
DECOMPOSE_SYSTEM,
|
||||||
|
taskPrompt,
|
||||||
|
cwd,
|
||||||
|
60_000,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.text) return [];
|
||||||
|
|
||||||
|
const parsed = parseJsonArray(result.text);
|
||||||
|
if (parsed) {
|
||||||
|
const subQuestions = parsed
|
||||||
|
.map(String)
|
||||||
|
.map((s: string) => s.trim())
|
||||||
|
.filter((s: string) => s.length > 10);
|
||||||
|
if (subQuestions.length > 0) return subQuestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Query Generation ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate initial search queries for a research question.
|
||||||
|
* When sub-questions are available, generates queries per sub-question
|
||||||
|
* for better depth and diversity.
|
||||||
|
*/
|
||||||
|
export async function generateQueries(
|
||||||
|
question: string,
|
||||||
|
count: number,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
subQuestions?: string[],
|
||||||
|
): Promise<SearchQuery[]> {
|
||||||
|
// If we have sub-questions, generate queries distributed across them
|
||||||
|
if (subQuestions && subQuestions.length > 0) {
|
||||||
|
const queriesPerSub = Math.max(1, Math.ceil(count / subQuestions.length));
|
||||||
|
const allQueries: SearchQuery[] = [];
|
||||||
|
|
||||||
|
for (const subQ of subQuestions) {
|
||||||
|
if (allQueries.length >= count) break;
|
||||||
|
|
||||||
|
const taskPrompt = `Research question: ${question}\nSub-question: ${subQ}\n\nGenerate ${queriesPerSub} search query(ies) to answer this sub-question specifically.`;
|
||||||
|
|
||||||
|
const result = await runAnalysisAgent(
|
||||||
|
GENERATE_QUERIES_SYSTEM,
|
||||||
|
taskPrompt,
|
||||||
|
cwd,
|
||||||
|
60_000,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.text) continue;
|
||||||
|
|
||||||
|
const parsed = parseJsonArray(result.text);
|
||||||
|
if (parsed) {
|
||||||
|
const queries = parsed
|
||||||
|
.slice(0, queriesPerSub)
|
||||||
|
.map((q) => toSearchQuery(q as Record<string, unknown>))
|
||||||
|
.filter((q): q is SearchQuery => q !== null);
|
||||||
|
allQueries.push(...queries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allQueries.length > 0) {
|
||||||
|
return allQueries.slice(0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall through to standard query generation
|
||||||
|
const taskPrompt = `Research question: ${question}
|
||||||
|
|
||||||
|
Generate ${count} diverse search queries to research this topic effectively. Cover different angles.`;
|
||||||
|
|
||||||
|
const result = await runAnalysisAgent(
|
||||||
|
GENERATE_QUERIES_SYSTEM,
|
||||||
|
taskPrompt,
|
||||||
|
cwd,
|
||||||
|
60_000,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.text) {
|
||||||
|
return generateFallbackQueries(question, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseJsonArray(result.text);
|
||||||
|
if (parsed && parsed.length > 0) {
|
||||||
|
return parsed
|
||||||
|
.slice(0, count)
|
||||||
|
.map((q) => toSearchQuery(q as Record<string, unknown>))
|
||||||
|
.filter((q): q is SearchQuery => q !== null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parse failed, fall back
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateFallbackQueries(question, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Follow-up Query Generation ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate follow-up queries based on findings from previous rounds.
|
||||||
|
*/
|
||||||
|
export async function generateFollowUpQueries(
|
||||||
|
question: string,
|
||||||
|
rounds: ResearchRound[],
|
||||||
|
count: number,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<SearchQuery[]> {
|
||||||
|
// Build a summary of findings so far
|
||||||
|
const allFindings = rounds.flatMap((r) => r.findings);
|
||||||
|
const findingsSummary = allFindings
|
||||||
|
.map((f) => {
|
||||||
|
const corr =
|
||||||
|
f.corroborationScore !== undefined
|
||||||
|
? ` [corroboration: ${(f.corroborationScore * 100).toFixed(0)}%]`
|
||||||
|
: "";
|
||||||
|
return `- ${f.title}: ${f.summary} (confidence: ${f.confidence}${corr})`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const exploredAngles = rounds
|
||||||
|
.flatMap((r) => r.queries)
|
||||||
|
.map((q) => `[${q.angle}] ${q.query} — ${q.rationale}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Find low-corroboration or low-confidence topics
|
||||||
|
const gaps = allFindings
|
||||||
|
.filter((f) => f.confidence === "low" || (f.corroborationScore ?? 1) < 0.5)
|
||||||
|
.map((f) => `Gap: ${f.title} — ${f.summary}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const taskPrompt = `Research question: ${question}
|
||||||
|
|
||||||
|
Queries already explored:
|
||||||
|
${exploredAngles}
|
||||||
|
|
||||||
|
Findings so far:
|
||||||
|
${findingsSummary}
|
||||||
|
|
||||||
|
${gaps ? `Remaining knowledge gaps:\n${gaps}` : ""}
|
||||||
|
|
||||||
|
Generate ${count} follow-up search queries to fill remaining gaps and deepen the research. Do not repeat or paraphrase the queries already explored.`;
|
||||||
|
|
||||||
|
const result = await runAnalysisAgent(
|
||||||
|
FOLLOWUP_SYSTEM,
|
||||||
|
taskPrompt,
|
||||||
|
cwd,
|
||||||
|
60_000,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const exploredNormalized = new Set(
|
||||||
|
rounds.flatMap((r) => r.queries).map((q) => normalizeQueryText(q.query)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = parseJsonArray(result.text);
|
||||||
|
if (parsed && parsed.length > 0) {
|
||||||
|
const fresh: SearchQuery[] = [];
|
||||||
|
for (const q of parsed.slice(0, count)) {
|
||||||
|
const sq = toSearchQuery(q as Record<string, unknown>);
|
||||||
|
if (!sq) continue;
|
||||||
|
const normalized = normalizeQueryText(sq.query);
|
||||||
|
// Skip queries that are near-duplicates of already-explored ones
|
||||||
|
if (exploredNormalized.has(normalized)) continue;
|
||||||
|
if (fresh.some((fq) => normalizeQueryText(fq.query) === normalized))
|
||||||
|
continue;
|
||||||
|
exploredNormalized.add(normalized);
|
||||||
|
fresh.push(sq);
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight query-text normalization for duplicate detection.
|
||||||
|
*/
|
||||||
|
function normalizeQueryText(query: string): string {
|
||||||
|
return query
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fallback Query Generation ────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback query generation when the LLM call fails.
|
||||||
|
*/
|
||||||
|
function generateFallbackQueries(
|
||||||
|
question: string,
|
||||||
|
count: number,
|
||||||
|
): SearchQuery[] {
|
||||||
|
const queries: SearchQuery[] = [];
|
||||||
|
const angles = [
|
||||||
|
{ angle: "technical", desc: "technical details and specifications" },
|
||||||
|
{
|
||||||
|
angle: "practical",
|
||||||
|
desc: "practical examples, tutorials, and best practices",
|
||||||
|
},
|
||||||
|
{ angle: "comparative", desc: "comparisons with alternatives" },
|
||||||
|
{ angle: "critical", desc: "limitations, challenges, and criticisms" },
|
||||||
|
{ angle: "forward-looking", desc: "future trends and developments" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(count, angles.length); i++) {
|
||||||
|
queries.push({
|
||||||
|
query: `${question} ${angles[i].desc}`,
|
||||||
|
rationale: `Exploring ${angles[i].desc} related to the research question`,
|
||||||
|
angle: angles[i].angle as SearchQuery["angle"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Analysis ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const ANALYZE_SYSTEM = `You are a research analyst. Given search results for a specific query, extract key findings.
|
||||||
|
|
||||||
|
For each finding:
|
||||||
|
- Give it a concise, specific title (a claim, not a topic)
|
||||||
|
- Summarize what was found in 1-3 sentences, focused on evidence
|
||||||
|
- List which source URLs support this finding
|
||||||
|
- Include 1-2 key quotes from the sources
|
||||||
|
- Rate your confidence (high/medium/low) based on source authority and consistency
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Extract 3-6 findings maximum, prioritizing the most decision-relevant
|
||||||
|
- Prefer findings with concrete evidence over generic observations
|
||||||
|
- Ignore boilerplate, navigation text, and irrelevant tangents in the content
|
||||||
|
- Do NOT invent quotes — only use text that appears in the provided content
|
||||||
|
- When sources conflict, note the conflict in the summary
|
||||||
|
|
||||||
|
Output ONLY a JSON array of objects with fields:
|
||||||
|
- "title": concise finding title
|
||||||
|
- "summary": 1-3 sentence summary
|
||||||
|
- "sources": array of source URLs
|
||||||
|
- "keyQuotes": array of 1-2 key quotes
|
||||||
|
- "confidence": "high" | "medium" | "low"`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze search results for a specific query and extract findings.
|
||||||
|
*/
|
||||||
|
export async function analyzeResults(
|
||||||
|
query: string,
|
||||||
|
results: EnrichedSearchResult[],
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
angle?: string,
|
||||||
|
): Promise<Finding[]> {
|
||||||
|
// Include authority metadata in the prompt so the LLM can consider source quality.
|
||||||
|
// Token budget: give high-authority sources generous space, truncate
|
||||||
|
// low-authority/SEO content aggressively so junk doesn't dominate the prompt.
|
||||||
|
const MAX_CHARS_HIGH_AUTH = 3500;
|
||||||
|
const MAX_CHARS_LOW_AUTH = 1200;
|
||||||
|
|
||||||
|
const resultsText = results
|
||||||
|
.map((r, i) => {
|
||||||
|
const maxChars =
|
||||||
|
r.authorityScore >= 0.6 ? MAX_CHARS_HIGH_AUTH : MAX_CHARS_LOW_AUTH;
|
||||||
|
const content = r.markdown.slice(0, maxChars).trim();
|
||||||
|
const body =
|
||||||
|
content.length > 0
|
||||||
|
? content
|
||||||
|
: `(no body content; description only)\n${r.description}`;
|
||||||
|
return `--- Result ${i + 1} ---\nTitle: ${r.title}\nURL: ${r.url}\nDomain: ${r.domain}\nAuthority Score: ${(r.authorityScore * 100).toFixed(0)}%\nContent Type: ${r.contentType}\nDescription: ${r.description}\nContent:\n${body}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const taskPrompt = `Search query: "${query}"${angle ? ` (angle: ${angle})` : ""}
|
||||||
|
|
||||||
|
Search results:
|
||||||
|
${resultsText}
|
||||||
|
|
||||||
|
Extract key findings from these results. Consider source authority when rating confidence.`;
|
||||||
|
|
||||||
|
const result = await runAnalysisAgent(
|
||||||
|
ANALYZE_SYSTEM,
|
||||||
|
taskPrompt,
|
||||||
|
cwd,
|
||||||
|
90_000,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.text) return [];
|
||||||
|
|
||||||
|
const parsed = parseJsonArray(result.text);
|
||||||
|
if (parsed) {
|
||||||
|
return parsed
|
||||||
|
.map((f) => {
|
||||||
|
const entry = f as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
title: String(entry.title ?? "").trim(),
|
||||||
|
summary: String(entry.summary ?? "").trim(),
|
||||||
|
sources: Array.isArray(entry.sources)
|
||||||
|
? entry.sources.map(String)
|
||||||
|
: [],
|
||||||
|
keyQuotes: Array.isArray(entry.keyQuotes)
|
||||||
|
? entry.keyQuotes.map(String)
|
||||||
|
: [],
|
||||||
|
confidence: (["high", "medium", "low"].includes(
|
||||||
|
String(entry.confidence),
|
||||||
|
)
|
||||||
|
? String(entry.confidence)
|
||||||
|
: "medium") as Finding["confidence"],
|
||||||
|
// Provenance: which query and angle produced this finding
|
||||||
|
query,
|
||||||
|
angle,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((f) => f.title && f.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Corroboration Tracking ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-reference all findings to compute corroboration scores.
|
||||||
|
*
|
||||||
|
* For each finding, we check:
|
||||||
|
* 1. How many other findings reference the same or similar source URLs
|
||||||
|
* 2. The authority scores of the supporting sources
|
||||||
|
* 3. Whether independent domains support the same claim
|
||||||
|
*
|
||||||
|
* Returns the findings with added corroborationScore, bestSourceAuthority,
|
||||||
|
* and avgSourceAuthority.
|
||||||
|
*/
|
||||||
|
export function computeCorroboration(
|
||||||
|
findings: Finding[],
|
||||||
|
urlQueryCounts?: Map<string, number>,
|
||||||
|
): Finding[] {
|
||||||
|
if (findings.length === 0) return [];
|
||||||
|
|
||||||
|
// Collect all unique source URLs and their authority scores
|
||||||
|
// In a real implementation, we'd map URLs to EnrichedSearchResult authority scores
|
||||||
|
// For now, extract domain-level patterns
|
||||||
|
|
||||||
|
// Build a map of domain -> authority scores from source URLs
|
||||||
|
const domainAuthority = new Map<string, number>();
|
||||||
|
for (const finding of findings) {
|
||||||
|
for (const url of finding.sources) {
|
||||||
|
try {
|
||||||
|
const domain = extractDomainSimple(url);
|
||||||
|
if (!domainAuthority.has(domain)) {
|
||||||
|
domainAuthority.set(domain, heuristicDomainScore(domain));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip invalid URLs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings.map((finding) => {
|
||||||
|
if (finding.sources.length === 0) {
|
||||||
|
return {
|
||||||
|
...finding,
|
||||||
|
corroborationScore: 0,
|
||||||
|
bestSourceAuthority: 0,
|
||||||
|
avgSourceAuthority: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute source authority stats
|
||||||
|
const authorities: number[] = finding.sources.map((url) => {
|
||||||
|
try {
|
||||||
|
const domain = extractDomainSimple(url);
|
||||||
|
return domainAuthority.get(domain) ?? 0.3;
|
||||||
|
} catch {
|
||||||
|
return 0.3;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const bestAuthority = Math.max(...authorities);
|
||||||
|
const avgAuthority =
|
||||||
|
authorities.reduce((a, b) => a + b, 0) / authorities.length;
|
||||||
|
|
||||||
|
// Compute corroboration.
|
||||||
|
//
|
||||||
|
// PRIMARY signal (when urlQueryCounts is provided): what fraction of
|
||||||
|
// this finding's sources were independently surfaced by multiple
|
||||||
|
// DIFFERENT search queries? A source found by several independent
|
||||||
|
// searches is genuinely corroborated; same-query duplicates do not
|
||||||
|
// count (findings from one query analyzed the same result set).
|
||||||
|
//
|
||||||
|
// FALLBACK signal (no map): domain-level agreement across findings
|
||||||
|
// from different queries.
|
||||||
|
let corroborationScore: number;
|
||||||
|
|
||||||
|
if (urlQueryCounts && urlQueryCounts.size > 0) {
|
||||||
|
const multiQuerySources = finding.sources.filter(
|
||||||
|
(url) => (urlQueryCounts.get(url) ?? 1) > 1,
|
||||||
|
).length;
|
||||||
|
corroborationScore =
|
||||||
|
finding.sources.length > 0
|
||||||
|
? multiQuerySources / finding.sources.length
|
||||||
|
: 0;
|
||||||
|
} else {
|
||||||
|
// Fallback: cross-query agreement by shared domain
|
||||||
|
const myDomains = new Set(
|
||||||
|
finding.sources.map((u) => extractDomainSimple(u)),
|
||||||
|
);
|
||||||
|
let corroboratingFindings = 0;
|
||||||
|
let independentOthers = 0;
|
||||||
|
|
||||||
|
for (const other of findings) {
|
||||||
|
if (other === finding) continue;
|
||||||
|
// Same query provenance = same analyzed result set = not independent
|
||||||
|
if (other.query && finding.query && other.query === finding.query) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
independentOthers++;
|
||||||
|
const otherDomains = new Set(
|
||||||
|
other.sources.map((u) => extractDomainSimple(u)),
|
||||||
|
);
|
||||||
|
const shared = [...myDomains].some((d) => otherDomains.has(d));
|
||||||
|
if (shared) corroboratingFindings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
corroborationScore =
|
||||||
|
independentOthers > 0
|
||||||
|
? Math.min(1, corroboratingFindings / independentOthers)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...finding,
|
||||||
|
corroborationScore: Math.round(corroborationScore * 100) / 100,
|
||||||
|
bestSourceAuthority: Math.round(bestAuthority * 100) / 100,
|
||||||
|
avgSourceAuthority: Math.round(avgAuthority * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple domain extraction (avoids URL constructor for compatibility).
|
||||||
|
*/
|
||||||
|
function extractDomainSimple(url: string): string {
|
||||||
|
const match = url.match(/https?:\/\/([^/]+)/);
|
||||||
|
if (!match) return url;
|
||||||
|
const hostname = match[1].toLowerCase();
|
||||||
|
const parts = hostname.split(".");
|
||||||
|
const multiPartTlds =
|
||||||
|
/\.(co\.uk|org\.uk|ac\.uk|gov\.uk|com\.au|co\.jp|co\.kr|com\.br)$/;
|
||||||
|
if (multiPartTlds.test(hostname) && parts.length >= 3) {
|
||||||
|
return parts.slice(-3).join(".");
|
||||||
|
}
|
||||||
|
return parts.slice(-2).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Very basic domain score heuristic without the full domain list.
|
||||||
|
*/
|
||||||
|
function heuristicDomainScore(domain: string): number {
|
||||||
|
if (/\.gov$|\.edu$/.test(domain)) return 0.85;
|
||||||
|
if (/arxiv|scholar|pubmed|ieee|acm|springer|nature|science/.test(domain))
|
||||||
|
return 0.9;
|
||||||
|
if (/github|gitlab|bitbucket/.test(domain)) return 0.75;
|
||||||
|
if (/wikipedia|stackoverflow|medium|dev\.to/.test(domain)) return 0.55;
|
||||||
|
if (/docs\.|learn\.|developer\./.test(domain)) return 0.8;
|
||||||
|
if (/reuters|apnews|bbc|nytimes|bloomberg/.test(domain)) return 0.75;
|
||||||
|
if (/blog|forum|reddit/.test(domain)) return 0.3;
|
||||||
|
return 0.4;
|
||||||
|
}
|
||||||
530
src/report.ts
Normal file
530
src/report.ts
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
/**
|
||||||
|
* Deep Research — Report synthesis
|
||||||
|
*
|
||||||
|
* Takes all research rounds and synthesizes a comprehensive report
|
||||||
|
* using an LLM agent. Produces:
|
||||||
|
* - Numbered inline citations with a bibliography
|
||||||
|
* - Layered report: TL;DR → Executive Summary → Key Findings
|
||||||
|
* → Detailed Analysis → Limitations/Gaps → References
|
||||||
|
* - Audience-aware tone adjustment
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ResearchRound,
|
||||||
|
ResearchConfig,
|
||||||
|
Reference,
|
||||||
|
Finding,
|
||||||
|
} from "./types";
|
||||||
|
import { runAnalysisAgent } from "./agent";
|
||||||
|
import { isNearDuplicateTitle } from "./firecrawl";
|
||||||
|
|
||||||
|
/** Return shape from synthesizeReport */
|
||||||
|
export interface SynthesisResult {
|
||||||
|
report: string;
|
||||||
|
references: Reference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum findings included in the synthesis prompt.
|
||||||
|
* Keeps token usage bounded and forces the synthesizer to focus on
|
||||||
|
* the highest-quality evidence.
|
||||||
|
*/
|
||||||
|
const MAX_SYNTHESIS_FINDINGS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank a finding for inclusion in synthesis: authority-weighted,
|
||||||
|
* confidence-weighted, with a corroboration bonus.
|
||||||
|
*/
|
||||||
|
function findingQualityScore(f: Finding): number {
|
||||||
|
const authority =
|
||||||
|
(f.bestSourceAuthority ?? f.avgSourceAuthority ?? 0.5) || 0.5;
|
||||||
|
const confidenceWeight =
|
||||||
|
f.confidence === "high" ? 1.0 : f.confidence === "medium" ? 0.7 : 0.45;
|
||||||
|
const corroborationBonus = (f.corroborationScore ?? 0) * 0.3;
|
||||||
|
return authority * confidenceWeight + corroborationBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── System Prompts ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function buildSynthesisSystem(audience: string): string {
|
||||||
|
const audienceGuidance: Record<string, string> = {
|
||||||
|
expert:
|
||||||
|
"Assume expert-level domain knowledge. Use precise technical terminology, reference specific methodologies and standards, and prioritize depth over hand-holding. The reader understands the field.",
|
||||||
|
general:
|
||||||
|
"Write for an informed general audience. Define technical terms on first use, explain context, and keep the tone accessible but not simplistic. Avoid jargon without explanation.",
|
||||||
|
executive:
|
||||||
|
"Write for a busy executive or decision-maker. Lead with actionable conclusions and recommendations. Be concise — use bold for key takeaways. Minimize technical detail; focus on implications, trade-offs, and decisions. Target 2-3 pages.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const guidance = audienceGuidance[audience] ?? audienceGuidance.general;
|
||||||
|
|
||||||
|
return `You are a senior research analyst synthesizing findings from multiple web searches into a comprehensive, well-structured report.
|
||||||
|
|
||||||
|
Audience: ${guidance}
|
||||||
|
|
||||||
|
Report structure (use ## headings):
|
||||||
|
1. **TL;DR** — One paragraph (2-3 sentences) giving the single most important answer
|
||||||
|
2. **Executive Summary** — 2-3 paragraphs covering what was found, how confident we are, and key implications
|
||||||
|
3. **Key Findings** — Tiered by importance/confidence. Bullet points with inline citations
|
||||||
|
4. **Detailed Analysis** — Organized by theme. Each section covers one aspect with evidence
|
||||||
|
5. **Limitations & Knowledge Gaps** — What evidence is weak, missing, or contradictory
|
||||||
|
6. **Conclusion** — Wrap up with actionable takeaways
|
||||||
|
|
||||||
|
Citation rules:
|
||||||
|
- Use numbered references like [1], [2] etc. throughout the text
|
||||||
|
- At the end, include a ## References section listing each citation
|
||||||
|
- Format references as: [1] Title — Domain (URL)
|
||||||
|
- Cite specific evidence, not vague associations
|
||||||
|
- When multiple sources support a claim, cite all of them: [1][3][5]
|
||||||
|
|
||||||
|
Style guidelines:
|
||||||
|
- Write in an objective, authoritative tone
|
||||||
|
- Use bullet points for listing evidence
|
||||||
|
- Note the confidence level for key claims
|
||||||
|
- Be thorough but concise — every paragraph should add value
|
||||||
|
- Use > for notable direct quotes with citations`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Evidence Builder ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function buildEvidenceText(
|
||||||
|
question: string,
|
||||||
|
rounds: ResearchRound[],
|
||||||
|
): { evidenceText: string; referenceMap: Map<string, Reference> } {
|
||||||
|
const allFindings = rounds.flatMap((r) => r.findings);
|
||||||
|
const totalSearches = rounds.reduce((sum, r) => sum + r.queries.length, 0);
|
||||||
|
const totalPages = rounds.reduce((sum, r) => sum + r.results.length, 0);
|
||||||
|
|
||||||
|
// Build a bibliography map (url -> Reference)
|
||||||
|
const seenUrls = new Map<string, Reference>();
|
||||||
|
let refId = 0;
|
||||||
|
|
||||||
|
for (const round of rounds) {
|
||||||
|
for (const result of round.results) {
|
||||||
|
if (!seenUrls.has(result.url)) {
|
||||||
|
refId++;
|
||||||
|
seenUrls.set(result.url, {
|
||||||
|
id: refId,
|
||||||
|
url: result.url,
|
||||||
|
title: result.title,
|
||||||
|
domain: result.domain,
|
||||||
|
authorityScore: result.authorityScore,
|
||||||
|
accessedAt: new Date().toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deduplicate findings across rounds ────────────────────────────
|
||||||
|
// The same claim often surfaces in multiple rounds under slightly
|
||||||
|
// different titles. Merge them (union of sources/quotes, keep the
|
||||||
|
// highest-quality version) so the synthesizer isn't double-counting.
|
||||||
|
const deduped: Finding[] = [];
|
||||||
|
for (const finding of allFindings) {
|
||||||
|
const dupIndex = deduped.findIndex(
|
||||||
|
(f) =>
|
||||||
|
f.title !== finding.title &&
|
||||||
|
isNearDuplicateTitle(f.title, finding.title),
|
||||||
|
);
|
||||||
|
if (dupIndex === -1) {
|
||||||
|
deduped.push({ ...finding });
|
||||||
|
} else {
|
||||||
|
const existing = deduped[dupIndex];
|
||||||
|
deduped[dupIndex] = {
|
||||||
|
title: existing.title,
|
||||||
|
summary: existing.summary,
|
||||||
|
sources: Array.from(new Set([...existing.sources, ...finding.sources])),
|
||||||
|
keyQuotes: Array.from(
|
||||||
|
new Set([...existing.keyQuotes, ...finding.keyQuotes]),
|
||||||
|
).slice(0, 3),
|
||||||
|
confidence:
|
||||||
|
existing.confidence === "high" || finding.confidence === "high"
|
||||||
|
? "high"
|
||||||
|
: existing.confidence === "medium" ||
|
||||||
|
finding.confidence === "medium"
|
||||||
|
? "medium"
|
||||||
|
: "low",
|
||||||
|
query: existing.query ?? finding.query,
|
||||||
|
angle: existing.angle ?? finding.angle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rank and cap findings for synthesis ───────────────────────────
|
||||||
|
const ranked = deduped
|
||||||
|
.map((f) => ({ f, score: findingQualityScore(f) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_SYNTHESIS_FINDINGS)
|
||||||
|
.map(({ f }) => f);
|
||||||
|
|
||||||
|
// Organize findings by their own angle (provenance-aware)
|
||||||
|
const evidenceByAngle = new Map<string, Finding[]>();
|
||||||
|
for (const finding of ranked) {
|
||||||
|
const angle = finding.angle ?? "general";
|
||||||
|
if (!evidenceByAngle.has(angle)) evidenceByAngle.set(angle, []);
|
||||||
|
evidenceByAngle.get(angle)!.push(finding);
|
||||||
|
}
|
||||||
|
|
||||||
|
let evidenceText = `## Research Question\n${question}\n\n`;
|
||||||
|
evidenceText += `## Overview\n- Rounds of research: ${rounds.length}\n`;
|
||||||
|
evidenceText += `- Total searches executed: ${totalSearches}\n`;
|
||||||
|
evidenceText += `- Total pages analyzed: ${totalPages}\n`;
|
||||||
|
evidenceText += `- Key findings extracted: ${allFindings.length} (${ranked.length} passed dedup/quality filter)\n\n`;
|
||||||
|
|
||||||
|
// Build evidence grouped by angle with reference IDs
|
||||||
|
for (const [angle, findings] of Array.from(evidenceByAngle)) {
|
||||||
|
if (findings.length === 0) continue;
|
||||||
|
evidenceText += `## Angle: ${angle}\n\n`;
|
||||||
|
for (const finding of findings) {
|
||||||
|
// Get reference IDs for this finding's sources
|
||||||
|
const refs = finding.sources
|
||||||
|
.map((url) => seenUrls.get(url))
|
||||||
|
.filter((r): r is Reference => !!r)
|
||||||
|
.map((r) => `[${r.id}]`);
|
||||||
|
|
||||||
|
const avgAuth =
|
||||||
|
finding.avgSourceAuthority !== undefined
|
||||||
|
? ` | Avg Authority: ${(finding.avgSourceAuthority * 100).toFixed(0)}%`
|
||||||
|
: "";
|
||||||
|
const corr =
|
||||||
|
finding.corroborationScore !== undefined
|
||||||
|
? ` | Corroboration: ${(finding.corroborationScore * 100).toFixed(0)}%`
|
||||||
|
: "";
|
||||||
|
const bestAuthStr =
|
||||||
|
finding.bestSourceAuthority !== undefined
|
||||||
|
? ` | Best Source: ${(finding.bestSourceAuthority * 100).toFixed(0)}%`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
evidenceText += `### ${finding.title}\n`;
|
||||||
|
evidenceText += `**Confidence:** ${finding.confidence}${avgAuth}${corr}${bestAuthStr}\n`;
|
||||||
|
if (refs.length > 0) {
|
||||||
|
evidenceText += `**Sources:** ${refs.join(", ")}\n`;
|
||||||
|
}
|
||||||
|
evidenceText += `${finding.summary}\n\n`;
|
||||||
|
if (finding.keyQuotes.length > 0) {
|
||||||
|
evidenceText += `> ${finding.keyQuotes[0]}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include reference metadata for the LLM to build proper citations
|
||||||
|
evidenceText += `## Reference Metadata\n\n`;
|
||||||
|
for (const [, ref] of seenUrls) {
|
||||||
|
evidenceText += `[${ref.id}] ${ref.title} (${ref.domain}, authority: ${(ref.authorityScore * 100).toFixed(0)}%) — ${ref.url}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { evidenceText, referenceMap: seenUrls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Synthesis ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthesize a research report from all rounds.
|
||||||
|
* Returns both the formatted report and the full bibliography.
|
||||||
|
*/
|
||||||
|
export async function synthesizeReport(
|
||||||
|
question: string,
|
||||||
|
rounds: ResearchRound[],
|
||||||
|
config: ResearchConfig,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<SynthesisResult> {
|
||||||
|
const audience = config.audience ?? "general";
|
||||||
|
const { evidenceText, referenceMap } = buildEvidenceText(question, rounds);
|
||||||
|
|
||||||
|
const formatInstruction =
|
||||||
|
config.format === "structured"
|
||||||
|
? "Structured report with numbered sections, clear hierarchies, and data tables where appropriate."
|
||||||
|
: "Well-formatted markdown report with ## headings, bullet points, and inline numbered citations like [1].";
|
||||||
|
|
||||||
|
const taskPrompt = `Synthesize the following research findings into a comprehensive, well-structured report.
|
||||||
|
|
||||||
|
${evidenceText}
|
||||||
|
|
||||||
|
Write a thorough report that answers the original question: "${question}"
|
||||||
|
|
||||||
|
Format: ${formatInstruction}
|
||||||
|
Audience: ${audience}
|
||||||
|
|
||||||
|
Remember to use numbered citations like [1], [2] and include a ## References section at the end.`;
|
||||||
|
|
||||||
|
const result = await runAnalysisAgent(
|
||||||
|
buildSynthesisSystem(audience),
|
||||||
|
taskPrompt,
|
||||||
|
cwd,
|
||||||
|
120_000,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.text) {
|
||||||
|
// Build bibliography section
|
||||||
|
const bibSection = buildBibliography(referenceMap);
|
||||||
|
|
||||||
|
let report = result.text;
|
||||||
|
|
||||||
|
// ── Citation integrity ─────────────────────────────────────────
|
||||||
|
// 1. Strip any references section the LLM wrote and replace it with
|
||||||
|
// the authoritative bibliography (built from real scraped sources).
|
||||||
|
// 2. Remove inline [n] citations that point at IDs outside the
|
||||||
|
// bibliography (hallucinated numbers), so every citation resolves.
|
||||||
|
report = report.replace(
|
||||||
|
/^#+\s*references\s*$/gim,
|
||||||
|
"\n## END_OF_REPORT_MARKER",
|
||||||
|
);
|
||||||
|
const markerIdx = report.indexOf("## END_OF_REPORT_MARKER");
|
||||||
|
if (markerIdx !== -1) {
|
||||||
|
report = report.slice(0, markerIdx).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRefId = Math.max(
|
||||||
|
0,
|
||||||
|
...Array.from(referenceMap.values()).map((r) => r.id),
|
||||||
|
);
|
||||||
|
report = report.replace(/\[(\d+)\]/g, (match, id: string) => {
|
||||||
|
const num = parseInt(id, 10);
|
||||||
|
return num >= 1 && num <= maxRefId ? match : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
report = report.trimEnd() + `\n\n${bibSection}`;
|
||||||
|
|
||||||
|
return { report, references: Array.from(referenceMap.values()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: generate a simple structured report
|
||||||
|
const fallbackReport = generateFallbackReport(
|
||||||
|
question,
|
||||||
|
rounds,
|
||||||
|
referenceMap,
|
||||||
|
audience,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
report: fallbackReport + `\n\n${buildBibliography(referenceMap)}`,
|
||||||
|
references: Array.from(referenceMap.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bibliography Builder ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a structured ## References section from the reference map.
|
||||||
|
*/
|
||||||
|
function buildBibliography(referenceMap: Map<string, Reference>): string {
|
||||||
|
if (referenceMap.size === 0) return "## References\n\nNo sources cited.";
|
||||||
|
|
||||||
|
const refs = Array.from(referenceMap.values()).sort((a, b) => a.id - b.id);
|
||||||
|
const lines: string[] = ["## References\n"];
|
||||||
|
for (const ref of refs) {
|
||||||
|
const authIcon =
|
||||||
|
ref.authorityScore >= 0.8 ? "⭐" : ref.authorityScore >= 0.5 ? "✓" : "○";
|
||||||
|
lines.push(
|
||||||
|
`[${ref.id}] ${authIcon} **${ref.title}** — ${ref.domain} (${ref.url}) — accessed ${ref.accessedAt}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fallback Report ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback report when the LLM synthesis fails.
|
||||||
|
* Produces a clean, structured report from the evidence.
|
||||||
|
*/
|
||||||
|
function generateFallbackReport(
|
||||||
|
question: string,
|
||||||
|
rounds: ResearchRound[],
|
||||||
|
referenceMap: Map<string, Reference>,
|
||||||
|
_audience: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const allFindings = rounds.flatMap((r) => r.findings);
|
||||||
|
|
||||||
|
// ── TL;DR ──
|
||||||
|
lines.push(`# Research Report: ${question}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const highConfFindings = allFindings.filter((f) => f.confidence === "high");
|
||||||
|
const totalHigh = highConfFindings.length;
|
||||||
|
const total = allFindings.length;
|
||||||
|
|
||||||
|
lines.push("## TL;DR");
|
||||||
|
lines.push("");
|
||||||
|
if (highConfFindings.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
`Based on analysis of ${total} findings across ${rounds.length} research round(s), ` +
|
||||||
|
`${totalHigh} high-confidence conclusions were identified. ` +
|
||||||
|
`${highConfFindings[0].title}: ${highConfFindings[0].summary}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
`This report covers findings from ${rounds.length} research round(s) exploring "${question}". ` +
|
||||||
|
`${total} findings were extracted, with varying levels of confidence.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// ── Executive Summary ──
|
||||||
|
lines.push("## Executive Summary");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
`This report synthesizes findings from ${rounds.length} research round(s), ` +
|
||||||
|
`${rounds.reduce((s, r) => s + r.queries.length, 0)} search queries, ` +
|
||||||
|
`and ${rounds.reduce((s, r) => s + r.results.length, 0)} sources.`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// ── Key Findings (tiered) ──
|
||||||
|
if (allFindings.length > 0) {
|
||||||
|
lines.push("## Key Findings");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// High confidence first
|
||||||
|
const highConf = allFindings.filter((f) => f.confidence === "high");
|
||||||
|
if (highConf.length > 0) {
|
||||||
|
lines.push("### High Confidence");
|
||||||
|
for (const finding of highConf) {
|
||||||
|
const refs = finding.sources
|
||||||
|
.map((url) => referenceMap.get(url))
|
||||||
|
.filter((r): r is Reference => !!r)
|
||||||
|
.map((r) => `[${r.id}]`);
|
||||||
|
lines.push(
|
||||||
|
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
|
||||||
|
);
|
||||||
|
lines.push(` - ${finding.summary}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium confidence
|
||||||
|
const medConf = allFindings.filter((f) => f.confidence === "medium");
|
||||||
|
if (medConf.length > 0) {
|
||||||
|
lines.push("### Moderate Confidence");
|
||||||
|
for (const finding of medConf) {
|
||||||
|
const refs = finding.sources
|
||||||
|
.map((url) => referenceMap.get(url))
|
||||||
|
.filter((r): r is Reference => !!r)
|
||||||
|
.map((r) => `[${r.id}]`);
|
||||||
|
lines.push(
|
||||||
|
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
|
||||||
|
);
|
||||||
|
lines.push(` - ${finding.summary}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low confidence
|
||||||
|
const lowConf = allFindings.filter((f) => f.confidence === "low");
|
||||||
|
if (lowConf.length > 0) {
|
||||||
|
lines.push("### Lower Confidence (Needs Further Research)");
|
||||||
|
for (const finding of lowConf) {
|
||||||
|
const refs = finding.sources
|
||||||
|
.map((url) => referenceMap.get(url))
|
||||||
|
.filter((r): r is Reference => !!r)
|
||||||
|
.map((r) => `[${r.id}]`);
|
||||||
|
lines.push(
|
||||||
|
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
|
||||||
|
);
|
||||||
|
lines.push(` - ${finding.summary}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detailed Analysis ──
|
||||||
|
lines.push("## Detailed Analysis");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const byAngle = new Map<string, Finding[]>();
|
||||||
|
for (const round of rounds) {
|
||||||
|
for (const f of round.findings) {
|
||||||
|
const angle = f.angle ?? round.queries[0]?.angle ?? "general";
|
||||||
|
if (!byAngle.has(angle)) byAngle.set(angle, []);
|
||||||
|
byAngle.get(angle)!.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [angle, findings] of byAngle) {
|
||||||
|
lines.push(`### ${angle.charAt(0).toUpperCase() + angle.slice(1)}`);
|
||||||
|
lines.push("");
|
||||||
|
for (const f of findings) {
|
||||||
|
const corrStr =
|
||||||
|
f.corroborationScore !== undefined
|
||||||
|
? ` (corroboration: ${(f.corroborationScore * 100).toFixed(0)}%)`
|
||||||
|
: "";
|
||||||
|
lines.push(`**${f.title}** — *${f.confidence} confidence${corrStr}*`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(f.summary);
|
||||||
|
lines.push("");
|
||||||
|
if (f.keyQuotes.length > 0) {
|
||||||
|
lines.push(`> ${f.keyQuotes[0]}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Limitations ──
|
||||||
|
const lowConfCount = allFindings.filter(
|
||||||
|
(f) => f.confidence === "low",
|
||||||
|
).length;
|
||||||
|
const noCorr = allFindings.filter(
|
||||||
|
(f) => (f.corroborationScore ?? 0) < 0.3,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
lines.push("## Limitations & Knowledge Gaps");
|
||||||
|
lines.push("");
|
||||||
|
if (lowConfCount > 0) {
|
||||||
|
lines.push(
|
||||||
|
`- **${lowConfCount} of ${allFindings.length} findings** have low confidence, indicating limited or conflicting evidence.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (noCorr > 0) {
|
||||||
|
lines.push(
|
||||||
|
`- **${noCorr} findings** lack corroboration from multiple independent sources.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
"- This research relied on web search results; some relevant sources may not be indexed or accessible.",
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
"- Findings are dependent on search engine ranking and the quality of indexed content.",
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// ── Conclusion ──
|
||||||
|
lines.push("## Conclusion");
|
||||||
|
lines.push("");
|
||||||
|
if (highConf.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
`The research identified ${highConf.length} high-confidence finding(s) and ${medConf.length} moderately-supported finding(s). ` +
|
||||||
|
`The strongest evidence relates to: ${highConf.map((f) => f.title).join(", ")}.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
"The research surfaced relevant information but with limited high-confidence evidence. Further investigation is recommended for the identified knowledge gaps.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Methodology ──
|
||||||
|
lines.push(`*Report prepared for: ${_audience} audience*`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## Methodology");
|
||||||
|
lines.push("");
|
||||||
|
for (const round of rounds) {
|
||||||
|
const failedSearches =
|
||||||
|
round.failedSearches ?? round.queries.length - round.successfulSearches;
|
||||||
|
lines.push(`### Round ${round.round}`);
|
||||||
|
lines.push(
|
||||||
|
`Queries: ${round.queries.map((q) => `"${q.query}" [${q.angle}]`).join(", ")}`,
|
||||||
|
);
|
||||||
|
lines.push(`Pages scraped: ${round.results.length}`);
|
||||||
|
lines.push(`Findings extracted: ${round.findings.length}`);
|
||||||
|
if (failedSearches > 0) {
|
||||||
|
lines.push(`Searches failed: ${failedSearches}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
468
src/research.ts
Normal file
468
src/research.ts
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
/**
|
||||||
|
* Deep Research — Core research orchestration
|
||||||
|
*
|
||||||
|
* Manages the multi-round deep research process:
|
||||||
|
* 1. Decompose the question into sub-questions (when depth > 1)
|
||||||
|
* 2. Generate initial search queries (per sub-question for better diversity)
|
||||||
|
* 3. Execute all queries in parallel via Firecrawl
|
||||||
|
* 4. Analyze results and extract findings
|
||||||
|
* 5. Compute corroboration scores
|
||||||
|
* 6. Generate follow-up queries for gaps
|
||||||
|
* 7. Iterate for depth rounds
|
||||||
|
* 8. Synthesize final report with numbered references
|
||||||
|
*
|
||||||
|
* Widget and progress callback patterns borrowed from ralpi's executor.
|
||||||
|
*/
|
||||||
|
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||||
|
import type {
|
||||||
|
Finding,
|
||||||
|
ResearchConfig,
|
||||||
|
EnrichedSearchResult,
|
||||||
|
ResearchRound,
|
||||||
|
ResearchReport,
|
||||||
|
} from "./types";
|
||||||
|
import type { SynthesisResult } from "./report";
|
||||||
|
import { searchWeb, isNearDuplicateTitle } from "./firecrawl";
|
||||||
|
import {
|
||||||
|
generateQueries,
|
||||||
|
generateFollowUpQueries,
|
||||||
|
analyzeResults,
|
||||||
|
computeCorroboration,
|
||||||
|
decomposeQuestion,
|
||||||
|
} from "./queries";
|
||||||
|
import { synthesizeReport } from "./report";
|
||||||
|
|
||||||
|
/** Progress callback for UI updates */
|
||||||
|
export type ResearchProgress = (update: {
|
||||||
|
phase:
|
||||||
|
| "decomposing"
|
||||||
|
| "generating_queries"
|
||||||
|
| "searching"
|
||||||
|
| "analyzing"
|
||||||
|
| "synthesizing"
|
||||||
|
| "complete";
|
||||||
|
round?: number;
|
||||||
|
totalRounds?: number;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
fraction?: number; // 0-1
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
// ── Round-Robin Parallel Execution ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum concurrent Firecrawl search requests.
|
||||||
|
* Prevents rate limiting while still parallelizing queries.
|
||||||
|
*/
|
||||||
|
const MAX_SEARCH_CONCURRENT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum concurrent analysis agent sessions.
|
||||||
|
*/
|
||||||
|
const MAX_ANALYSIS_CONCURRENT = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum findings per round before we consider early stopping.
|
||||||
|
* If we're getting very few new findings, saturation is near.
|
||||||
|
*/
|
||||||
|
const SATURATION_THRESHOLD = 0.15; // < 15% new findings = likely saturated
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded-concurrency parallel execution with round-robin slot assignment.
|
||||||
|
*
|
||||||
|
* Similar to ralpi's ModelRoundRobin: with N concurrent slots, items are
|
||||||
|
* assigned to free slots in FIFO order. When a slot finishes, the next
|
||||||
|
* item in the queue is assigned to it.
|
||||||
|
*
|
||||||
|
* This ensures even load distribution and avoids bursty concurrency.
|
||||||
|
*/
|
||||||
|
async function boundedConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
maxConcurrent: number,
|
||||||
|
mapper: (item: T, index: number) => Promise<R>,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
const currentIndex = nextIndex++;
|
||||||
|
if (currentIndex >= items.length) return;
|
||||||
|
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numWorkers = Math.min(maxConcurrent, items.length);
|
||||||
|
const workers = Array.from({ length: numWorkers }, () => worker());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assess whether the research is reaching information saturation.
|
||||||
|
*/
|
||||||
|
function assessSaturation(
|
||||||
|
previousRound: ResearchRound | undefined,
|
||||||
|
currentRound: ResearchRound,
|
||||||
|
): number {
|
||||||
|
if (!previousRound || previousRound.findings.length === 0) return 0;
|
||||||
|
|
||||||
|
const prevUrls = new Set(previousRound.results.map((r) => r.url));
|
||||||
|
const newUrls = currentRound.results.filter(
|
||||||
|
(r) => !prevUrls.has(r.url),
|
||||||
|
).length;
|
||||||
|
const totalUrls = currentRound.results.length;
|
||||||
|
const newRatio = totalUrls > 0 ? newUrls / totalUrls : 0;
|
||||||
|
|
||||||
|
// Also check finding novelty
|
||||||
|
const prevFindingTitles = new Set(
|
||||||
|
previousRound.findings.map((f) => f.title.toLowerCase()),
|
||||||
|
);
|
||||||
|
const newFindings = currentRound.findings.filter(
|
||||||
|
(f) => !prevFindingTitles.has(f.title.toLowerCase()),
|
||||||
|
).length;
|
||||||
|
const totalFindings = currentRound.findings.length;
|
||||||
|
const findingNovelty = totalFindings > 0 ? newFindings / totalFindings : 0;
|
||||||
|
|
||||||
|
// Weight: URL novelty (40%) + finding novelty (60%)
|
||||||
|
return newRatio * 0.4 + findingNovelty * 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a complete deep research session.
|
||||||
|
*/
|
||||||
|
export async function runDeepResearch(
|
||||||
|
config: ResearchConfig,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
onProgress: ResearchProgress,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ResearchReport> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const rounds: ResearchRound[] = [];
|
||||||
|
let totalSearches = 0;
|
||||||
|
let totalPages = 0;
|
||||||
|
let subQuestions: string[] = [];
|
||||||
|
|
||||||
|
// ── Phase: Decompose question into sub-questions ────────────────
|
||||||
|
|
||||||
|
if (config.depth > 1) {
|
||||||
|
onProgress({
|
||||||
|
phase: "decomposing",
|
||||||
|
round: 1,
|
||||||
|
totalRounds: config.depth,
|
||||||
|
message: "Decomposing research question into sub-topics...",
|
||||||
|
fraction: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error("Research cancelled");
|
||||||
|
|
||||||
|
subQuestions = await decomposeQuestion(config.question, ctx.cwd, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase: Generate initial queries ─────────────────────────────
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
phase: "generating_queries",
|
||||||
|
round: 1,
|
||||||
|
totalRounds: config.depth,
|
||||||
|
message:
|
||||||
|
subQuestions.length > 0
|
||||||
|
? `Generating queries across ${subQuestions.length} sub-topics...`
|
||||||
|
: "Generating initial search queries...",
|
||||||
|
fraction: 0.05,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error("Research cancelled");
|
||||||
|
|
||||||
|
const queries = await generateQueries(
|
||||||
|
config.question,
|
||||||
|
config.breadth,
|
||||||
|
ctx.cwd,
|
||||||
|
signal,
|
||||||
|
subQuestions.length > 0 ? subQuestions : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (queries.length === 0) {
|
||||||
|
throw new Error("Failed to generate any search queries");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execute rounds ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
for (let round = 1; round <= config.depth; round++) {
|
||||||
|
if (signal?.aborted) throw new Error("Research cancelled");
|
||||||
|
|
||||||
|
const isFirstRound = round === 1;
|
||||||
|
const currentQueries = isFirstRound
|
||||||
|
? queries
|
||||||
|
: await generateFollowUpQueries(
|
||||||
|
config.question,
|
||||||
|
rounds,
|
||||||
|
config.breadth,
|
||||||
|
ctx.cwd,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentQueries || currentQueries.length === 0) {
|
||||||
|
// No follow-up queries to generate — stop here
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search phase (parallel with round-robin) ────────────────────
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
phase: "searching",
|
||||||
|
round,
|
||||||
|
totalRounds: config.depth,
|
||||||
|
message: `Searching ${currentQueries.length} queries in parallel...`,
|
||||||
|
fraction: 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error("Research cancelled");
|
||||||
|
|
||||||
|
// Run searches in parallel using round-robin bounded concurrency.
|
||||||
|
// Each mapper call runs independently; failures are caught per-query.
|
||||||
|
// Results keep their originating query index for later grouping.
|
||||||
|
const searchResultsArrays: (EnrichedSearchResult[] | null)[] =
|
||||||
|
await boundedConcurrency(
|
||||||
|
currentQueries,
|
||||||
|
MAX_SEARCH_CONCURRENT,
|
||||||
|
async (q, i) => {
|
||||||
|
onProgress({
|
||||||
|
phase: "searching",
|
||||||
|
round,
|
||||||
|
totalRounds: config.depth,
|
||||||
|
message: `Searching: "${q.query.slice(0, 60)}..."`,
|
||||||
|
detail: q.rationale,
|
||||||
|
fraction: 0.25 + (i / currentQueries.length) * 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await searchWeb(q.query, 5, signal);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
onProgress({
|
||||||
|
phase: "searching",
|
||||||
|
round,
|
||||||
|
totalRounds: config.depth,
|
||||||
|
message: `Search failed: ${errorMsg.slice(0, 80)}`,
|
||||||
|
fraction: 0.25 + ((i + 1) / currentQueries.length) * 0.25,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const successfulSearches = searchResultsArrays.filter(
|
||||||
|
(r): r is EnrichedSearchResult[] => r !== null,
|
||||||
|
).length;
|
||||||
|
const failedSearches = currentQueries.length - successfulSearches;
|
||||||
|
|
||||||
|
totalSearches += currentQueries.length;
|
||||||
|
|
||||||
|
// Track which URLs were independently surfaced by MULTIPLE different
|
||||||
|
// queries. This is the corroboration signal: a source found by
|
||||||
|
// several independent searches is stronger evidence than one found
|
||||||
|
// by a single query.
|
||||||
|
const urlQueryCounts = new Map<string, number>();
|
||||||
|
searchResultsArrays.forEach((results, queryIndex) => {
|
||||||
|
if (!results) return;
|
||||||
|
const queryUrls = new Set(results.map((r) => r.url));
|
||||||
|
for (const url of queryUrls) {
|
||||||
|
urlQueryCounts.set(url, (urlQueryCounts.get(url) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
// (queryIndex is unused beyond the closure; kept for clarity)
|
||||||
|
void queryIndex;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Per-query result collection ──────────────────────────────────
|
||||||
|
// Deduplicate within each query's results by URL (prefer higher
|
||||||
|
// authority) AND by near-identical title (catches syndicated copies
|
||||||
|
// of the same article under different URLs).
|
||||||
|
const resultsByQuery: EnrichedSearchResult[][] = currentQueries.map(
|
||||||
|
() => [],
|
||||||
|
);
|
||||||
|
|
||||||
|
searchResultsArrays.forEach((results, queryIndex) => {
|
||||||
|
if (!results) return;
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
const seenTitles: string[] = [];
|
||||||
|
for (const r of results) {
|
||||||
|
if (seenUrls.has(r.url)) continue;
|
||||||
|
seenUrls.add(r.url);
|
||||||
|
|
||||||
|
// Skip syndicated duplicates (same article, different URL)
|
||||||
|
if (
|
||||||
|
r.title &&
|
||||||
|
seenTitles.some((t) => isNearDuplicateTitle(t, r.title))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r.title) seenTitles.push(r.title);
|
||||||
|
resultsByQuery[queryIndex].push(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global URL dedup across queries: a URL found by multiple queries
|
||||||
|
// stays attached to the FIRST query that surfaced it (most likely
|
||||||
|
// the most relevant one).
|
||||||
|
const globalSeen = new Set<string>();
|
||||||
|
for (const list of resultsByQuery) {
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) {
|
||||||
|
if (globalSeen.has(list[i].url)) {
|
||||||
|
list.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
globalSeen.add(list[i].url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueResults = resultsByQuery.flat();
|
||||||
|
totalPages += uniqueResults.length;
|
||||||
|
|
||||||
|
// ── Analyze phase (parallel with round-robin) ──────────────────
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
phase: "analyzing",
|
||||||
|
round,
|
||||||
|
totalRounds: config.depth,
|
||||||
|
message: `Analyzing ${uniqueResults.length} search results in parallel...`,
|
||||||
|
fraction: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error("Research cancelled");
|
||||||
|
|
||||||
|
// Build query-result pairs for parallel analysis.
|
||||||
|
// Each query is analyzed with the results IT actually produced,
|
||||||
|
// so findings stay coherent with the query's intent and angle.
|
||||||
|
const analysisTasks: Array<{
|
||||||
|
query: (typeof currentQueries)[number];
|
||||||
|
results: EnrichedSearchResult[];
|
||||||
|
index: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < currentQueries.length; i++) {
|
||||||
|
const queryResults = resultsByQuery[i];
|
||||||
|
if (!queryResults || queryResults.length === 0) continue;
|
||||||
|
|
||||||
|
analysisTasks.push({
|
||||||
|
query: currentQueries[i],
|
||||||
|
results: queryResults,
|
||||||
|
index: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run analyses in parallel using round-robin bounded concurrency
|
||||||
|
const findingsArrays: Finding[][] = await boundedConcurrency(
|
||||||
|
analysisTasks,
|
||||||
|
MAX_ANALYSIS_CONCURRENT,
|
||||||
|
async (task) => {
|
||||||
|
onProgress({
|
||||||
|
phase: "analyzing",
|
||||||
|
round,
|
||||||
|
totalRounds: config.depth,
|
||||||
|
message: `Analyzing: "${task.query.query.slice(0, 40)}..."`,
|
||||||
|
fraction:
|
||||||
|
0.6 + (task.index / Math.max(analysisTasks.length, 1)) * 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await analyzeResults(
|
||||||
|
task.query.query,
|
||||||
|
task.results,
|
||||||
|
ctx.cwd,
|
||||||
|
signal,
|
||||||
|
task.query.angle,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Analysis failure shouldn't crash the round
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flatten all findings
|
||||||
|
const allFindings: ResearchRound["findings"] = findingsArrays.flat();
|
||||||
|
|
||||||
|
// ── Corroboration pass ────────────────────────────────────────
|
||||||
|
// Cross-reference findings to compute corroboration scores.
|
||||||
|
// Corroboration = fraction of a finding's sources that were
|
||||||
|
// independently surfaced by multiple different search queries.
|
||||||
|
const corroboratedFindings = computeCorroboration(
|
||||||
|
allFindings,
|
||||||
|
urlQueryCounts,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Record this round
|
||||||
|
const followUpTopics = corroboratedFindings
|
||||||
|
.filter(
|
||||||
|
(f: Finding) =>
|
||||||
|
f.confidence === "low" && (f.corroborationScore ?? 0) < 0.5,
|
||||||
|
)
|
||||||
|
.map((f: Finding) => f.title);
|
||||||
|
|
||||||
|
rounds.push({
|
||||||
|
round,
|
||||||
|
queries: currentQueries,
|
||||||
|
results: uniqueResults,
|
||||||
|
findings: corroboratedFindings,
|
||||||
|
followUpTopics,
|
||||||
|
successfulSearches,
|
||||||
|
failedSearches,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Adaptive depth: check for saturation ──────────────────────
|
||||||
|
if (round > 1 && round < config.depth) {
|
||||||
|
const saturation = assessSaturation(
|
||||||
|
rounds[rounds.length - 2],
|
||||||
|
rounds[rounds.length - 1],
|
||||||
|
);
|
||||||
|
if (saturation < SATURATION_THRESHOLD) {
|
||||||
|
onProgress({
|
||||||
|
phase: "synthesizing",
|
||||||
|
message: `Information saturation reached (${(saturation * 100).toFixed(0)}% novelty) — synthesizing early`,
|
||||||
|
fraction: 0.85,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Synthesis phase ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
phase: "synthesizing",
|
||||||
|
message: "Synthesizing research into final report...",
|
||||||
|
fraction: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error("Research cancelled");
|
||||||
|
|
||||||
|
const synthesisResult: SynthesisResult = await synthesizeReport(
|
||||||
|
config.question,
|
||||||
|
rounds,
|
||||||
|
config,
|
||||||
|
ctx.cwd,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const finalReport = synthesisResult.report;
|
||||||
|
const references = synthesisResult.references;
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
phase: "complete",
|
||||||
|
message: "Research complete!",
|
||||||
|
fraction: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
question: config.question,
|
||||||
|
rounds,
|
||||||
|
finalReport,
|
||||||
|
totalSearches,
|
||||||
|
totalPagesScraped: totalPages,
|
||||||
|
durationMs,
|
||||||
|
references,
|
||||||
|
};
|
||||||
|
}
|
||||||
106
src/types.ts
Normal file
106
src/types.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Deep Research — type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Content type classification for a source */
|
||||||
|
export type ContentType =
|
||||||
|
| "documentation"
|
||||||
|
| "paper"
|
||||||
|
| "news"
|
||||||
|
| "blog"
|
||||||
|
| "forum"
|
||||||
|
| "official"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
/** A single search result from Firecrawl */
|
||||||
|
export interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enriched search result with source authority metadata */
|
||||||
|
export interface EnrichedSearchResult extends SearchResult {
|
||||||
|
domain: string;
|
||||||
|
authorityScore: number; // 0.0 – 1.0
|
||||||
|
publishedDate: Date | null;
|
||||||
|
contentType: ContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A finding extracted from search results by an analysis agent */
|
||||||
|
export interface Finding {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
sources: string[];
|
||||||
|
keyQuotes: string[];
|
||||||
|
confidence: "high" | "medium" | "low";
|
||||||
|
/** The search query this finding was extracted under (provenance) */
|
||||||
|
query?: string;
|
||||||
|
/** The research angle of the originating query (provenance) */
|
||||||
|
angle?: string;
|
||||||
|
/** 0.0 – 1.0: how many independent sources support this finding */
|
||||||
|
corroborationScore?: number;
|
||||||
|
/** Authority score of the best source supporting this finding */
|
||||||
|
bestSourceAuthority?: number;
|
||||||
|
/** Average authority score across all sources */
|
||||||
|
avgSourceAuthority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A numbered reference with full metadata */
|
||||||
|
export interface Reference {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
authorityScore: number;
|
||||||
|
accessedAt: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A generated search query with its intent/rationale */
|
||||||
|
export interface SearchQuery {
|
||||||
|
query: string;
|
||||||
|
rationale: string;
|
||||||
|
angle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output from one research round */
|
||||||
|
export interface ResearchRound {
|
||||||
|
round: number;
|
||||||
|
queries: SearchQuery[];
|
||||||
|
results: EnrichedSearchResult[];
|
||||||
|
findings: Finding[];
|
||||||
|
/** Any follow-up questions/angles the analysis suggests */
|
||||||
|
followUpTopics: string[];
|
||||||
|
/** Number of search queries that actually returned data (non-empty) */
|
||||||
|
successfulSearches: number;
|
||||||
|
/** Number of search queries that failed entirely */
|
||||||
|
failedSearches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Target audience expertise level */
|
||||||
|
export type Audience = "expert" | "general" | "executive";
|
||||||
|
|
||||||
|
/** Configuration for a research session */
|
||||||
|
export interface ResearchConfig {
|
||||||
|
question: string;
|
||||||
|
depth: number; // 1-3 rounds
|
||||||
|
breadth: number; // queries per round (1-5)
|
||||||
|
format: "markdown" | "structured";
|
||||||
|
audience?: Audience;
|
||||||
|
/** Focus on specific research angles only (empty = all angles) */
|
||||||
|
focus?: string[];
|
||||||
|
/** Show the research methodology section in the report */
|
||||||
|
showMethodology?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final research report */
|
||||||
|
export interface ResearchReport {
|
||||||
|
question: string;
|
||||||
|
rounds: ResearchRound[];
|
||||||
|
finalReport: string;
|
||||||
|
totalSearches: number;
|
||||||
|
totalPagesScraped: number;
|
||||||
|
durationMs: number;
|
||||||
|
references: Reference[];
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user