@juanhuttemann/pi-deep-research 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -2
- package/package.json +56 -4
- package/src/args.ts +81 -0
- package/src/bridge.ts +210 -0
- package/src/events.ts +171 -0
- package/src/index.ts +604 -0
- package/src/openai-map.ts +344 -0
- package/src/plan.ts +43 -0
- package/src/render.ts +133 -0
- package/src/research.ts +215 -0
- package/src/result.ts +187 -0
- package/src/runner.ts +115 -0
- package/src/settings.ts +199 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Juan Huttemann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @juanhuttemann/pi-deep-research
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fact-checked, cited web research inside [Pi](https://pi.dev). The extension
|
|
4
|
+
runs [deep-research](https://github.com/juanhuttemann/deep-research)
|
|
5
|
+
(`drsh`): it plans sub-topics, searches them in parallel, analyzes the
|
|
6
|
+
evidence, and checks every claim against the text of the pages it fetched.
|
|
7
|
+
Pi's own model answers the model calls, through Pi's sign-in.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
`drsh` must be installed (0.30.0 or newer):
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
curl -fsSL https://raw.githubusercontent.com/juanhuttemann/deep-research/main/scripts/install.sh | bash
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pi install npm:@juanhuttemann/pi-deep-research
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
From a clone, `pi install ./integrations/pi`. For one session,
|
|
26
|
+
`pi -e ./integrations/pi/src/index.ts`.
|
|
27
|
+
|
|
28
|
+
drsh's search and scraper settings still apply. SearXNG works with no
|
|
29
|
+
setup, while Exa, Tavily and Firecrawl use their keys from `drsh setup`.
|
|
30
|
+
`/research-doctor` checks them.
|
|
31
|
+
|
|
32
|
+
## What it adds
|
|
33
|
+
|
|
34
|
+
- **`deep_research` tool.** Ask Pi to research something.
|
|
35
|
+
- While the run goes, the call shows the phase, each sub-agent, the
|
|
36
|
+
searches, pages and sources so far, and the model's reasoning.
|
|
37
|
+
- When it ends, the call shows the confidence, the fact-check's verdicts,
|
|
38
|
+
the claims it did not confirm, and the report's path.
|
|
39
|
+
- Esc stops a run. It keeps a checkpoint, and resuming from it skips the
|
|
40
|
+
searches already done.
|
|
41
|
+
- When the question leaves something open, the clarifying questions open
|
|
42
|
+
as Pi dialogs. In print or JSON mode they go back to the model, which
|
|
43
|
+
asks you.
|
|
44
|
+
- Every run setting drsh has is a tool parameter: depth (instant, quick,
|
|
45
|
+
standard, deep), sub-topics, pages per topic, follow-ups, format,
|
|
46
|
+
words, tables and clarify. `domains`, `trace` and `review_plan`
|
|
47
|
+
(review and edit the plan before any search) are parameters too. The
|
|
48
|
+
settings come from `drsh settings`, so a new setting in drsh appears
|
|
49
|
+
here with no update.
|
|
50
|
+
- **`research_ask` tool.** Answers a follow-up question from the pages a
|
|
51
|
+
run already fetched, without searching again. The answer is not
|
|
52
|
+
fact-checked.
|
|
53
|
+
- **`/research [--topics 3 --follow-ups off --domains a,b …] question`**
|
|
54
|
+
runs a research yourself.
|
|
55
|
+
- With no flags it opens the settings screen first.
|
|
56
|
+
- You answer the clarifying questions and review the plan, then the run
|
|
57
|
+
starts and the agent reports what it found.
|
|
58
|
+
- `/research-stop` ends the run.
|
|
59
|
+
- **`/research-model`** picks the model research runs use:
|
|
60
|
+
- Pi's current model, the default.
|
|
61
|
+
- Another of Pi's models, for example a cheaper one for the many calls a
|
|
62
|
+
run makes.
|
|
63
|
+
- drsh's own configured model.
|
|
64
|
+
- **`/research-doctor`** checks the model (through Pi), search and the
|
|
65
|
+
scraper.
|
|
66
|
+
|
|
67
|
+
Each run's files (`.md`, `.pdf`, `.json`, checkpoint) go in
|
|
68
|
+
`~/.cache/deep-research-pi/runs/` (`$DEEP_RESEARCH_PI_RUNS` to change it),
|
|
69
|
+
never into your project. `DRSH_BIN` points to a `drsh` that is not on
|
|
70
|
+
`PATH`.
|
|
71
|
+
|
|
72
|
+
## How the model is reached
|
|
73
|
+
|
|
74
|
+
For each call, the extension starts an HTTP endpoint on `127.0.0.1` that
|
|
75
|
+
speaks the OpenAI chat-completions API. The endpoint accepts only a random
|
|
76
|
+
token, given to drsh as its API key, and closes when the call ends.
|
|
77
|
+
|
|
78
|
+
drsh runs with `OPENAI_BASE_URL` pointed at that endpoint, and each request
|
|
79
|
+
it sends is answered by `ctx.modelRegistry.streamSimple`. Streamed
|
|
80
|
+
reasoning, JSON mode, tool calls (for `research_ask`) and usage all pass
|
|
81
|
+
through. The run's usage is added to the tool result, so Pi's session
|
|
82
|
+
totals count it.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
npm ci
|
|
88
|
+
npm run typecheck
|
|
89
|
+
DRSH_BIN=/path/to/drsh npm test # bridge tests run that drsh offline
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
From the repo root, `make pi` builds drsh from the tree and runs all three.
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juanhuttemann/pi-deep-research",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Fact-checked, cited web research inside Pi: runs the deep-research CLI (drsh) on Pi's own model, with live progress, clarifying questions and plan review",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"research",
|
|
9
|
+
"web-search",
|
|
10
|
+
"fact-check",
|
|
11
|
+
"citations"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"homepage": "https://github.com/juanhuttemann/deep-research/tree/main/integrations/pi",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/juanhuttemann/deep-research.git",
|
|
19
|
+
"directory": "integrations/pi"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src/",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./src/index.ts"
|
|
29
|
+
],
|
|
30
|
+
"image": "https://raw.githubusercontent.com/juanhuttemann/deep-research/main/docs/images/pi-research.png"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "node --import jiti/register --test --test-reporter=spec \"test/*.test.ts\""
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "*",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-agent-core": "1.4.0",
|
|
44
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
46
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
47
|
+
"@types/node": "^22.19.0",
|
|
48
|
+
"jiti": "2.7.0",
|
|
49
|
+
"typebox": "1.3.27",
|
|
50
|
+
"typescript": "^5.9.3"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.19.0"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The drsh command lines a research call runs. The mutual exclusions are the
|
|
2
|
+
// CLI's (internal/cli/cli.go): --resume and --plan carry their own question
|
|
3
|
+
// and settings, so they take none of the others.
|
|
4
|
+
|
|
5
|
+
import { settingArg, type Setting } from "./settings.ts";
|
|
6
|
+
import type { Question } from "./events.ts";
|
|
7
|
+
|
|
8
|
+
export interface Answer {
|
|
9
|
+
question: string;
|
|
10
|
+
answer: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Clarified is the --answers JSON: the questions asked and the reader's
|
|
14
|
+
// answers, as ui.ParseAnswers reads it.
|
|
15
|
+
export interface Clarified {
|
|
16
|
+
questions: Question[];
|
|
17
|
+
answers: Answer[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NewRun {
|
|
21
|
+
question: string;
|
|
22
|
+
values: Record<string, string>;
|
|
23
|
+
domains?: string[];
|
|
24
|
+
answers?: Clarified;
|
|
25
|
+
trace?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Stage = "clarify" | "plan" | "run";
|
|
29
|
+
|
|
30
|
+
function common(reports: string): string[] {
|
|
31
|
+
return ["--jsonl", `--reports=${reports}`];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function settingArgs(settings: Setting[], values: Record<string, string>): string[] {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
for (const s of settings) {
|
|
37
|
+
const v = values[s.key];
|
|
38
|
+
if (v !== undefined) {
|
|
39
|
+
out.push(settingArg(s, v));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const k of Object.keys(values)) {
|
|
43
|
+
if (!settings.some((s) => s.key === k)) {
|
|
44
|
+
throw new Error(`unknown setting ${JSON.stringify(k)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// newRunArgs is a question's command line at one stage: the clarifying
|
|
51
|
+
// questions only, the plan only, or the whole run.
|
|
52
|
+
export function newRunArgs(settings: Setting[], r: NewRun, reports: string, stage: Stage): string[] {
|
|
53
|
+
// --prompt=: a question that starts with "-" is still the value.
|
|
54
|
+
const args = [...common(reports), `--prompt=${r.question}`, ...settingArgs(settings, r.values)];
|
|
55
|
+
if (r.domains?.length) {
|
|
56
|
+
args.push(`--domains=${r.domains.join(",")}`);
|
|
57
|
+
}
|
|
58
|
+
if (stage === "clarify") {
|
|
59
|
+
args.push("--clarify-only");
|
|
60
|
+
return args;
|
|
61
|
+
}
|
|
62
|
+
if (r.answers) {
|
|
63
|
+
args.push(`--answers=${JSON.stringify(r.answers)}`);
|
|
64
|
+
}
|
|
65
|
+
if (stage === "plan") {
|
|
66
|
+
args.push("--plan-only");
|
|
67
|
+
} else if (r.trace) {
|
|
68
|
+
args.push("--trace");
|
|
69
|
+
}
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// planRunArgs runs a plan written by --plan-only, edited or not.
|
|
74
|
+
export function planRunArgs(planFile: string, reports: string, trace?: boolean): string[] {
|
|
75
|
+
return [...common(reports), `--plan=${planFile}`, ...(trace ? ["--trace"] : [])];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// resumeArgs continues a run from its checkpoint.
|
|
79
|
+
export function resumeArgs(checkpoint: string, reports: string): string[] {
|
|
80
|
+
return [...common(reports), `--resume=${checkpoint}`];
|
|
81
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The local endpoint drsh calls instead of a provider: OpenAI's chat
|
|
2
|
+
// completions over HTTP on 127.0.0.1, answered by Pi's own model layer with
|
|
3
|
+
// Pi's sign-in. drsh needs no model setup of its own, any provider Pi
|
|
4
|
+
// reaches works (OAuth subscriptions included), and the run's usage is
|
|
5
|
+
// counted in Pi's session.
|
|
6
|
+
//
|
|
7
|
+
// One bridge serves one drsh process. It listens on a random loopback port
|
|
8
|
+
// and answers only requests carrying its random token, which drsh is given
|
|
9
|
+
// as its API key.
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
13
|
+
import type { AddressInfo } from "node:net";
|
|
14
|
+
import type { Api, AssistantMessageEvent, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
|
|
15
|
+
import {
|
|
16
|
+
addUsage,
|
|
17
|
+
completion,
|
|
18
|
+
errorBody,
|
|
19
|
+
errorStatus,
|
|
20
|
+
streamChunks,
|
|
21
|
+
toContext,
|
|
22
|
+
withJSONMode,
|
|
23
|
+
zeroUsage,
|
|
24
|
+
type ChatRequest,
|
|
25
|
+
type StreamState,
|
|
26
|
+
} from "./openai-map.ts";
|
|
27
|
+
|
|
28
|
+
// StreamFn is ctx.modelRegistry.streamSimple: a provider-neutral call with
|
|
29
|
+
// auth resolved by Pi.
|
|
30
|
+
export type StreamFn = (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AsyncIterable<AssistantMessageEvent>;
|
|
31
|
+
|
|
32
|
+
export interface Bridge {
|
|
33
|
+
// baseURL is what drsh takes as OPENAI_BASE_URL.
|
|
34
|
+
baseURL: string;
|
|
35
|
+
token: string;
|
|
36
|
+
// modelName is what drsh takes as OPENAI_MODEL and what each answer
|
|
37
|
+
// names as the model that served it.
|
|
38
|
+
modelName: string;
|
|
39
|
+
usage: Usage;
|
|
40
|
+
calls: number;
|
|
41
|
+
// env is drsh's environment for the bridge: the endpoint, the token as
|
|
42
|
+
// its key, the model, and the OpenAI-compatible transport over whatever
|
|
43
|
+
// provider the user saved with drsh setup.
|
|
44
|
+
env: Record<string, string>;
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// maxBody bounds a request body. drsh's largest prompt (the fact-check with
|
|
49
|
+
// every quoted page) is a few MB.
|
|
50
|
+
const maxBody = 64 << 20;
|
|
51
|
+
|
|
52
|
+
function sendJSON(res: ServerResponse, status: number, body: unknown): void {
|
|
53
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
54
|
+
res.end(JSON.stringify(body));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// fail reports a failed call: with its status before anything streamed, else
|
|
58
|
+
// as an error event. openai-go ends a stream that closes without [DONE] as
|
|
59
|
+
// a success, so an answer cut off by a provider failure would otherwise
|
|
60
|
+
// reach drsh as a complete, truncated one.
|
|
61
|
+
function fail(res: ServerResponse, message: string, status: number): void {
|
|
62
|
+
if (!res.headersSent) {
|
|
63
|
+
sendJSON(res, status, errorBody(message, status));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.end(`data: ${JSON.stringify(errorBody(message, status))}\n\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readBody(req: IncomingMessage): Promise<string> {
|
|
70
|
+
const parts: Buffer[] = [];
|
|
71
|
+
let n = 0;
|
|
72
|
+
for await (const c of req) {
|
|
73
|
+
n += (c as Buffer).length;
|
|
74
|
+
if (n > maxBody) throw new Error("request body too large");
|
|
75
|
+
parts.push(c as Buffer);
|
|
76
|
+
}
|
|
77
|
+
return Buffer.concat(parts).toString("utf8");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function startBridge(o: { model: Model<Api>; stream: StreamFn; signal?: AbortSignal }): Promise<Bridge> {
|
|
81
|
+
const token = randomBytes(24).toString("hex");
|
|
82
|
+
const modelName = `${o.model.provider}/${o.model.id}`;
|
|
83
|
+
const usage = zeroUsage();
|
|
84
|
+
const bridge = { token, modelName, usage, calls: 0 } as Bridge;
|
|
85
|
+
|
|
86
|
+
const chat = async (req: IncomingMessage, res: ServerResponse) => {
|
|
87
|
+
let body: ChatRequest;
|
|
88
|
+
try {
|
|
89
|
+
body = JSON.parse(await readBody(req)) as ChatRequest;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
sendJSON(res, 400, errorBody(`bad request: ${(err as Error).message}`, 400));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const { context, options } = toContext(body, o.model);
|
|
95
|
+
const abort = new AbortController();
|
|
96
|
+
// The client hanging up (drsh's per-call timeout, or the run
|
|
97
|
+
// cancelled) ends the provider call too.
|
|
98
|
+
res.on("close", () => {
|
|
99
|
+
if (!res.writableFinished) abort.abort();
|
|
100
|
+
});
|
|
101
|
+
const outer = () => abort.abort();
|
|
102
|
+
o.signal?.addEventListener("abort", outer, { once: true });
|
|
103
|
+
bridge.calls++;
|
|
104
|
+
const stream = o.stream(o.model, context, {
|
|
105
|
+
signal: abort.signal,
|
|
106
|
+
reasoning: options.reasoning,
|
|
107
|
+
toolChoice: options.toolChoice,
|
|
108
|
+
maxTokens: options.maxTokens,
|
|
109
|
+
temperature: options.temperature,
|
|
110
|
+
onPayload: options.json ? (p, m) => withJSONMode(p, m) : undefined,
|
|
111
|
+
});
|
|
112
|
+
const state: StreamState = {
|
|
113
|
+
id: `chatcmpl-${randomBytes(8).toString("hex")}`,
|
|
114
|
+
model: modelName,
|
|
115
|
+
created: Math.floor(Date.now() / 1000),
|
|
116
|
+
toolIndex: 0,
|
|
117
|
+
includeUsage: body.stream_options?.include_usage === true,
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
const final = body.stream ? await streamTo(res, stream, state) : await collect(stream);
|
|
121
|
+
if (final?.type === "error") {
|
|
122
|
+
const msg = final.error.errorMessage || final.reason;
|
|
123
|
+
fail(res, msg, errorStatus(msg));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (final?.type === "done") {
|
|
127
|
+
addUsage(usage, final.message.usage);
|
|
128
|
+
if (!body.stream) sendJSON(res, 200, completion(final.message, state.id, modelName));
|
|
129
|
+
}
|
|
130
|
+
// A stream that stopped with neither an answer nor a failure
|
|
131
|
+
// still has to end, or drsh waits out its timeout on it.
|
|
132
|
+
if (!res.writableEnded) fail(res, "the model ended without an answer", 502);
|
|
133
|
+
} finally {
|
|
134
|
+
o.signal?.removeEventListener("abort", outer);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const server = createServer((req, res) => {
|
|
139
|
+
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
140
|
+
sendJSON(res, 401, errorBody("this endpoint serves only the drsh run it was started for", 401));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const path = (req.url ?? "").split("?")[0].replace(/\/+$/, "");
|
|
144
|
+
if (req.method === "GET" && (path === "/v1/models" || path === "/models")) {
|
|
145
|
+
sendJSON(res, 200, { object: "list", data: [{ id: modelName, object: "model", owned_by: o.model.provider }] });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (req.method === "POST" && (path === "/v1/chat/completions" || path === "/chat/completions")) {
|
|
149
|
+
chat(req, res).catch((err: Error) => {
|
|
150
|
+
if (!res.writableEnded) fail(res, err.message, errorStatus(err.message));
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
sendJSON(res, 404, errorBody(`no route ${req.method} ${path}`, 404));
|
|
155
|
+
});
|
|
156
|
+
await new Promise<void>((resolve, reject) => {
|
|
157
|
+
server.once("error", reject);
|
|
158
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
159
|
+
});
|
|
160
|
+
const { port } = server.address() as AddressInfo;
|
|
161
|
+
bridge.baseURL = `http://127.0.0.1:${port}/v1`;
|
|
162
|
+
bridge.env = {
|
|
163
|
+
OPENAI_BASE_URL: bridge.baseURL,
|
|
164
|
+
OPENAI_API_KEY: token,
|
|
165
|
+
OPENAI_MODEL: modelName,
|
|
166
|
+
DEEP_RESEARCH_PROVIDER: "openai",
|
|
167
|
+
};
|
|
168
|
+
bridge.close = () =>
|
|
169
|
+
new Promise<void>((resolve) => {
|
|
170
|
+
server.closeAllConnections();
|
|
171
|
+
server.close(() => resolve());
|
|
172
|
+
});
|
|
173
|
+
return bridge;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type Final = Extract<AssistantMessageEvent, { type: "done" } | { type: "error" }>;
|
|
177
|
+
|
|
178
|
+
async function collect(stream: AsyncIterable<AssistantMessageEvent>): Promise<Final | undefined> {
|
|
179
|
+
let final: Final | undefined;
|
|
180
|
+
for await (const e of stream) {
|
|
181
|
+
if (e.type === "done" || e.type === "error") final = e;
|
|
182
|
+
}
|
|
183
|
+
return final;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// streamTo writes the answer as server-sent events. Headers wait for the
|
|
187
|
+
// first event that is not a failure: a provider that refuses the call
|
|
188
|
+
// (a bad key, a rate limit) does so before anything streams, and is then
|
|
189
|
+
// answered with the status drsh's retry logic reads.
|
|
190
|
+
async function streamTo(res: ServerResponse, stream: AsyncIterable<AssistantMessageEvent>, s: StreamState): Promise<Final | undefined> {
|
|
191
|
+
let final: Final | undefined;
|
|
192
|
+
for await (const e of stream) {
|
|
193
|
+
if (e.type === "error") {
|
|
194
|
+
final = e;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (!res.headersSent) {
|
|
198
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
|
199
|
+
}
|
|
200
|
+
for (const c of streamChunks(s, e)) {
|
|
201
|
+
res.write(`data: ${JSON.stringify(c)}\n\n`);
|
|
202
|
+
}
|
|
203
|
+
if (e.type === "done") {
|
|
204
|
+
final = e;
|
|
205
|
+
res.end("data: [DONE]\n\n");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return final;
|
|
209
|
+
}
|
|
210
|
+
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The --jsonl event stream drsh writes, and the run state the progress view
|
|
2
|
+
// is drawn from. The field names mirror ui.Event in internal/ui/events.go; a
|
|
3
|
+
// Go test decodes test/fixtures/*.jsonl into that struct with unknown fields
|
|
4
|
+
// refused, so a renamed field fails there before it silently reads as empty
|
|
5
|
+
// here.
|
|
6
|
+
|
|
7
|
+
export interface Question {
|
|
8
|
+
id: string;
|
|
9
|
+
text: string;
|
|
10
|
+
options?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DomainCheck {
|
|
14
|
+
domain: string;
|
|
15
|
+
status: string;
|
|
16
|
+
pages?: number;
|
|
17
|
+
detail?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DrshEvent {
|
|
21
|
+
type: string;
|
|
22
|
+
time?: string;
|
|
23
|
+
phase?: string;
|
|
24
|
+
detail?: string;
|
|
25
|
+
query?: string;
|
|
26
|
+
terms?: string[];
|
|
27
|
+
url?: string;
|
|
28
|
+
domain?: string;
|
|
29
|
+
source_title?: string;
|
|
30
|
+
source_url?: string;
|
|
31
|
+
status?: string;
|
|
32
|
+
code?: string;
|
|
33
|
+
tokens?: number;
|
|
34
|
+
sources?: number;
|
|
35
|
+
sub_id?: string;
|
|
36
|
+
sub_name?: string;
|
|
37
|
+
sub_state?: string;
|
|
38
|
+
progress?: number;
|
|
39
|
+
line?: string;
|
|
40
|
+
part?: string;
|
|
41
|
+
transient?: boolean;
|
|
42
|
+
artifacts?: string[];
|
|
43
|
+
checkpoint?: string;
|
|
44
|
+
plan?: unknown;
|
|
45
|
+
questions?: Question[];
|
|
46
|
+
check?: DomainCheck;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SubAgent {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
state: string;
|
|
53
|
+
progress: number;
|
|
54
|
+
line: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// RunState is what the live view shows. It is a tool result's details, so it
|
|
58
|
+
// holds plain JSON only.
|
|
59
|
+
export interface RunState {
|
|
60
|
+
phase: string;
|
|
61
|
+
phaseDetail: string;
|
|
62
|
+
subagents: SubAgent[];
|
|
63
|
+
searches: number;
|
|
64
|
+
reads: number;
|
|
65
|
+
sources: number;
|
|
66
|
+
tokens: number;
|
|
67
|
+
// status is the latest transient line: which model is asked, a retry.
|
|
68
|
+
status: string;
|
|
69
|
+
// thinking is the tail of the reasoning streaming now, for a model that
|
|
70
|
+
// thinks for minutes before its first word.
|
|
71
|
+
thinking: string;
|
|
72
|
+
errors: string[];
|
|
73
|
+
domains: DomainCheck[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function initialState(): RunState {
|
|
77
|
+
return {
|
|
78
|
+
phase: "",
|
|
79
|
+
phaseDetail: "",
|
|
80
|
+
subagents: [],
|
|
81
|
+
searches: 0,
|
|
82
|
+
reads: 0,
|
|
83
|
+
sources: 0,
|
|
84
|
+
tokens: 0,
|
|
85
|
+
status: "",
|
|
86
|
+
thinking: "",
|
|
87
|
+
errors: [],
|
|
88
|
+
domains: [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// thinkingTail bounds the reasoning kept: the view shows a line or two of it.
|
|
93
|
+
const thinkingTail = 240;
|
|
94
|
+
|
|
95
|
+
// maxErrors bounds the non-fatal errors kept for the view; the full list is
|
|
96
|
+
// in the run's timeline.
|
|
97
|
+
const maxErrors = 5;
|
|
98
|
+
|
|
99
|
+
export function parseLine(line: string): DrshEvent | undefined {
|
|
100
|
+
const s = line.trim();
|
|
101
|
+
if (!s.startsWith("{")) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const e = JSON.parse(s) as DrshEvent;
|
|
106
|
+
return typeof e.type === "string" ? e : undefined;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function subagent(s: RunState, e: DrshEvent): void {
|
|
113
|
+
if (!e.sub_id) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let a = s.subagents.find((x) => x.id === e.sub_id);
|
|
117
|
+
if (!a) {
|
|
118
|
+
a = { id: e.sub_id, name: e.sub_name ?? e.sub_id, state: "queued", progress: 0, line: "" };
|
|
119
|
+
s.subagents.push(a);
|
|
120
|
+
}
|
|
121
|
+
if (e.sub_name) a.name = e.sub_name;
|
|
122
|
+
if (e.sub_state) a.state = e.sub_state;
|
|
123
|
+
if (e.progress) a.progress = e.progress;
|
|
124
|
+
if (e.line) a.line = e.line;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// reduce folds one event into the state, in place, and returns it.
|
|
128
|
+
export function reduce(s: RunState, e: DrshEvent): RunState {
|
|
129
|
+
switch (e.type) {
|
|
130
|
+
case "phase":
|
|
131
|
+
s.phase = e.phase ?? s.phase;
|
|
132
|
+
s.phaseDetail = e.detail ?? "";
|
|
133
|
+
s.thinking = "";
|
|
134
|
+
break;
|
|
135
|
+
case "subagent":
|
|
136
|
+
subagent(s, e);
|
|
137
|
+
break;
|
|
138
|
+
case "search":
|
|
139
|
+
s.searches++;
|
|
140
|
+
subagent(s, e);
|
|
141
|
+
break;
|
|
142
|
+
case "read":
|
|
143
|
+
s.reads++;
|
|
144
|
+
break;
|
|
145
|
+
case "report":
|
|
146
|
+
s.phase = e.phase || "Report";
|
|
147
|
+
s.phaseDetail = e.detail ?? "";
|
|
148
|
+
s.thinking = "";
|
|
149
|
+
if (e.sources) s.sources = e.sources;
|
|
150
|
+
break;
|
|
151
|
+
case "citation":
|
|
152
|
+
case "token":
|
|
153
|
+
if (e.sources) s.sources = e.sources;
|
|
154
|
+
if (e.tokens) s.tokens = e.tokens;
|
|
155
|
+
break;
|
|
156
|
+
case "info":
|
|
157
|
+
if (e.transient && e.detail) s.status = e.detail.split("\n")[0];
|
|
158
|
+
break;
|
|
159
|
+
case "thinking":
|
|
160
|
+
s.thinking = (e.status === "start" ? "" : s.thinking) + (e.detail ?? "");
|
|
161
|
+
s.thinking = s.thinking.slice(-thinkingTail);
|
|
162
|
+
break;
|
|
163
|
+
case "error":
|
|
164
|
+
if (e.detail) s.errors = [...s.errors, e.detail].slice(-maxErrors);
|
|
165
|
+
break;
|
|
166
|
+
case "source":
|
|
167
|
+
if (e.check) s.domains.push(e.check);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
return s;
|
|
171
|
+
}
|