newpr 0.1.1 → 0.2.0

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.
Files changed (37) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/cli/args.ts +6 -1
  4. package/src/cli/index.ts +2 -2
  5. package/src/github/fetch-pr.ts +43 -1
  6. package/src/history/store.ts +106 -1
  7. package/src/llm/cartoon.ts +128 -0
  8. package/src/llm/client.ts +197 -0
  9. package/src/llm/prompts.ts +33 -8
  10. package/src/tui/Shell.tsx +7 -2
  11. package/src/types/github.ts +11 -0
  12. package/src/types/output.ts +51 -0
  13. package/src/web/client/App.tsx +32 -2
  14. package/src/web/client/components/AppShell.tsx +94 -47
  15. package/src/web/client/components/ChatSection.tsx +427 -0
  16. package/src/web/client/components/DetailPane.tsx +163 -75
  17. package/src/web/client/components/DiffViewer.tsx +679 -0
  18. package/src/web/client/components/InputScreen.tsx +110 -26
  19. package/src/web/client/components/Markdown.tsx +169 -43
  20. package/src/web/client/components/ResultsScreen.tsx +135 -110
  21. package/src/web/client/components/TipTapEditor.tsx +405 -0
  22. package/src/web/client/hooks/useAnalysis.ts +8 -1
  23. package/src/web/client/hooks/useFeatures.ts +18 -0
  24. package/src/web/client/lib/shiki.ts +63 -0
  25. package/src/web/client/panels/CartoonPanel.tsx +153 -0
  26. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  27. package/src/web/client/panels/FilesPanel.tsx +435 -54
  28. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  29. package/src/web/client/panels/StoryPanel.tsx +42 -22
  30. package/src/web/components/ui/tabs.tsx +3 -3
  31. package/src/web/server/routes.ts +752 -2
  32. package/src/web/server/session-manager.ts +11 -2
  33. package/src/web/server.ts +42 -2
  34. package/src/web/styles/built.css +1 -1
  35. package/src/web/styles/globals.css +117 -1
  36. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  37. package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -61,16 +61,26 @@
61
61
  "@radix-ui/react-slot": "^1.2.4",
62
62
  "@radix-ui/react-tabs": "^1.1.13",
63
63
  "@tailwindcss/cli": "^4.1.18",
64
+ "@tiptap/extension-mention": "^3.19.0",
65
+ "@tiptap/extension-placeholder": "^3.19.0",
66
+ "@tiptap/pm": "^3.19.0",
67
+ "@tiptap/react": "^3.19.0",
68
+ "@tiptap/starter-kit": "^3.19.0",
64
69
  "class-variance-authority": "^0.7.1",
65
70
  "clsx": "^2.1.1",
66
71
  "ink": "6.6.0",
67
72
  "ink-spinner": "5.0.0",
68
73
  "ink-text-input": "6.0.0",
74
+ "katex": "^0.16.28",
69
75
  "lucide-react": "^0.567.0",
70
76
  "react": "19.1.0",
71
77
  "react-dom": "19.1.0",
72
78
  "react-markdown": "^10.1.0",
79
+ "rehype-katex": "^7.0.1",
80
+ "rehype-raw": "^7.0.0",
73
81
  "remark-gfm": "^4.0.1",
82
+ "remark-math": "^6.0.0",
83
+ "shiki": "^3.22.0",
74
84
  "tailwind-merge": "^3.4.1",
75
85
  "tailwindcss": "^4.1.18",
76
86
  "tailwindcss-animate": "^1.0.7"
@@ -6,7 +6,7 @@ import type { ExplorationResult } from "../workspace/types.ts";
6
6
  import type { AgentToolName } from "../workspace/types.ts";
7
7
  import { parseDiff } from "../diff/parser.ts";
8
8
  import { chunkDiff } from "../diff/chunker.ts";
9
- import { fetchPrData } from "../github/fetch-pr.ts";
9
+ import { fetchPrData, fetchPrComments } from "../github/fetch-pr.ts";
10
10
  import { fetchPrDiff } from "../github/fetch-diff.ts";
11
11
  import { createLlmClient, type LlmClient, type LlmResponse } from "../llm/client.ts";
12
12
  import {
@@ -50,6 +50,7 @@ interface PipelineOptions {
50
50
  token: string;
51
51
  config: NewprConfig;
52
52
  onProgress?: ProgressCallback;
53
+ onFilePatches?: (patches: Record<string, string>) => void;
53
54
  noClone?: boolean;
54
55
  preferredAgent?: AgentToolName;
55
56
  }
@@ -158,12 +159,13 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
158
159
  timeout: config.timeout,
159
160
  });
160
161
 
161
- progress({ stage: "fetching", message: "Fetching PR data and diff..." });
162
- const [prData, rawDiff] = await Promise.all([
162
+ progress({ stage: "fetching", message: "Fetching PR data, diff, and discussion..." });
163
+ const [prData, rawDiff, prComments] = await Promise.all([
163
164
  fetchPrData(pr, token),
164
165
  fetchPrDiff(pr, token),
166
+ fetchPrComments(pr, token).catch(() => []),
165
167
  ]);
166
- progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions}` });
168
+ progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments` });
167
169
 
168
170
  progress({ stage: "parsing", message: "Parsing diff..." });
169
171
  const parsed = parseDiff(rawDiff);
@@ -175,6 +177,15 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
175
177
  const totalDel = chunks.reduce((s, c) => s + c.deletions, 0);
176
178
  progress({ stage: "parsing", message: `${chunks.length} files · +${totalAdd} −${totalDel}${wasTruncated ? ` (${allChunks.length - config.max_files} skipped)` : ""}` });
177
179
 
180
+ const changedFilesSet = new Set(changedFiles);
181
+ const filePatches: Record<string, string> = {};
182
+ for (const fileDiff of parsed.files) {
183
+ if (changedFilesSet.has(fileDiff.path)) {
184
+ filePatches[fileDiff.path] = fileDiff.raw;
185
+ }
186
+ }
187
+ options.onFilePatches?.(filePatches);
188
+
178
189
  let exploration: ExplorationResult | null = null;
179
190
  if (!noClone) {
180
191
  exploration = await tryExploreCodebase(
@@ -183,7 +194,12 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
183
194
  );
184
195
  }
185
196
 
186
- const promptCtx: PromptContext = { commits: prData.commits, language: config.language };
197
+ const promptCtx: PromptContext = {
198
+ commits: prData.commits,
199
+ language: config.language,
200
+ prBody: prData.body,
201
+ discussion: prComments.map((c) => ({ author: c.author, body: c.body })),
202
+ };
187
203
  const enrichedTag = exploration ? " + codebase context" : "";
188
204
 
189
205
  progress({
@@ -279,6 +295,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
279
295
  meta: {
280
296
  pr_number: prData.number,
281
297
  pr_title: prData.title,
298
+ pr_body: prData.body || undefined,
282
299
  pr_url: prData.url,
283
300
  base_branch: prData.base_branch,
284
301
  head_branch: prData.head_branch,
package/src/cli/args.ts CHANGED
@@ -10,6 +10,7 @@ export interface CliArgs {
10
10
  noClone: boolean;
11
11
  agent?: AgentToolName;
12
12
  port?: number;
13
+ cartoon?: boolean;
13
14
  subArgs: string[];
14
15
  }
15
16
 
@@ -101,8 +102,12 @@ export function parseArgs(argv: string[]): CliArgs {
101
102
  const portIdx = args.indexOf("--port");
102
103
  if (portIdx !== -1 && args[portIdx + 1]) {
103
104
  port = Number.parseInt(args[portIdx + 1]!, 10) || 3000;
105
+ } else {
106
+ const eqArg = args.find((a) => a.startsWith("--port="));
107
+ if (eqArg) port = Number.parseInt(eqArg.split("=")[1]!, 10) || 3000;
104
108
  }
105
- return { command: "web", port, ...DEFAULTS };
109
+ const cartoon = args.includes("--cartoon");
110
+ return { command: "web", port, cartoon, ...DEFAULTS };
106
111
  }
107
112
 
108
113
  if (args.length === 0) {
package/src/cli/index.ts CHANGED
@@ -11,7 +11,7 @@ import { createStderrProgress, createSilentProgress, createStreamJsonProgress }
11
11
  import { renderLoading, renderShell } from "../tui/render.tsx";
12
12
  import { checkForUpdate, printUpdateNotice } from "./update-check.ts";
13
13
 
14
- const VERSION = "0.1.1";
14
+ const VERSION = "0.1.3";
15
15
 
16
16
  async function main(): Promise<void> {
17
17
  const args = parseArgs(process.argv);
@@ -56,7 +56,7 @@ async function main(): Promise<void> {
56
56
  const updateInfo = await updatePromise;
57
57
  if (updateInfo) printUpdateNotice(updateInfo);
58
58
  const { startWebServer } = await import("../web/server.ts");
59
- await startWebServer({ port: args.port ?? 3000, token, config });
59
+ await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon });
60
60
  } catch (error) {
61
61
  const message = error instanceof Error ? error.message : String(error);
62
62
  process.stderr.write(`Error: ${message}\n`);
@@ -1,4 +1,4 @@
1
- import type { GithubPrData, PrCommit, PrIdentifier } from "../types/github.ts";
1
+ import type { GithubPrData, PrComment, PrCommit, PrIdentifier } from "../types/github.ts";
2
2
 
3
3
  export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData, "commits"> {
4
4
  const user = json.user as Record<string, unknown> | undefined;
@@ -8,6 +8,7 @@ export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData,
8
8
  return {
9
9
  number: json.number as number,
10
10
  title: json.title as string,
11
+ body: (json.body as string) ?? "",
11
12
  url: json.html_url as string,
12
13
  base_branch: (base?.ref as string) ?? "unknown",
13
14
  head_branch: (head?.ref as string) ?? "unknown",
@@ -88,3 +89,44 @@ export async function fetchPrData(pr: PrIdentifier, token: string): Promise<Gith
88
89
 
89
90
  return { ...base, commits };
90
91
  }
92
+
93
+ interface GithubCommentResponse {
94
+ id: number;
95
+ user: { login: string; avatar_url?: string } | null;
96
+ body: string;
97
+ created_at: string;
98
+ updated_at: string;
99
+ html_url: string;
100
+ }
101
+
102
+ export async function fetchPrComments(pr: PrIdentifier, token: string): Promise<PrComment[]> {
103
+ const allComments: GithubCommentResponse[] = [];
104
+ let page = 1;
105
+
106
+ while (true) {
107
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments?per_page=100&page=${page}`;
108
+ const response = await githubGet(url, token);
109
+ const items = (await response.json()) as GithubCommentResponse[];
110
+ if (items.length === 0) break;
111
+ allComments.push(...items);
112
+ if (items.length < 100) break;
113
+ page++;
114
+ }
115
+
116
+ return allComments.map((c) => ({
117
+ id: c.id,
118
+ author: c.user?.login ?? "unknown",
119
+ author_avatar: c.user?.avatar_url ?? undefined,
120
+ body: c.body,
121
+ created_at: c.created_at,
122
+ updated_at: c.updated_at,
123
+ html_url: c.html_url,
124
+ }));
125
+ }
126
+
127
+ export async function fetchPrBody(pr: PrIdentifier, token: string): Promise<string> {
128
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`;
129
+ const response = await githubGet(url, token);
130
+ const json = (await response.json()) as Record<string, unknown>;
131
+ return (json.body as string) ?? "";
132
+ }
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { mkdirSync, rmSync, existsSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
- import type { NewprOutput } from "../types/output.ts";
5
+ import type { NewprOutput, DiffComment, ChatMessage, CartoonImage } from "../types/output.ts";
6
6
  import type { SessionRecord } from "./types.ts";
7
7
 
8
8
  const HISTORY_DIR = join(homedir(), ".newpr", "history");
@@ -91,6 +91,111 @@ export async function clearHistory(): Promise<void> {
91
91
  }
92
92
  }
93
93
 
94
+ export async function savePatchesSidecar(
95
+ id: string,
96
+ patches: Record<string, string>,
97
+ ): Promise<void> {
98
+ ensureDirs();
99
+ await Bun.write(
100
+ join(SESSIONS_DIR, `${id}.patches.json`),
101
+ JSON.stringify(patches),
102
+ );
103
+ }
104
+
105
+ export async function loadPatchesSidecar(
106
+ id: string,
107
+ ): Promise<Record<string, string> | null> {
108
+ try {
109
+ const filePath = join(SESSIONS_DIR, `${id}.patches.json`);
110
+ const file = Bun.file(filePath);
111
+ if (!(await file.exists())) return null;
112
+ return JSON.parse(await file.text()) as Record<string, string>;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ export async function loadSinglePatch(
119
+ id: string,
120
+ filePath: string,
121
+ ): Promise<string | null> {
122
+ const patches = await loadPatchesSidecar(id);
123
+ if (!patches) return null;
124
+ return patches[filePath] ?? null;
125
+ }
126
+
127
+ export async function saveCommentsSidecar(
128
+ id: string,
129
+ comments: DiffComment[],
130
+ ): Promise<void> {
131
+ ensureDirs();
132
+ await Bun.write(
133
+ join(SESSIONS_DIR, `${id}.comments.json`),
134
+ JSON.stringify(comments, null, 2),
135
+ );
136
+ }
137
+
138
+ export async function loadCommentsSidecar(
139
+ id: string,
140
+ ): Promise<DiffComment[] | null> {
141
+ try {
142
+ const filePath = join(SESSIONS_DIR, `${id}.comments.json`);
143
+ const file = Bun.file(filePath);
144
+ if (!(await file.exists())) return null;
145
+ return JSON.parse(await file.text()) as DiffComment[];
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ export async function saveChatSidecar(
152
+ id: string,
153
+ messages: ChatMessage[],
154
+ ): Promise<void> {
155
+ ensureDirs();
156
+ await Bun.write(
157
+ join(SESSIONS_DIR, `${id}.chat.json`),
158
+ JSON.stringify(messages, null, 2),
159
+ );
160
+ }
161
+
162
+ export async function loadChatSidecar(
163
+ id: string,
164
+ ): Promise<ChatMessage[] | null> {
165
+ try {
166
+ const filePath = join(SESSIONS_DIR, `${id}.chat.json`);
167
+ const file = Bun.file(filePath);
168
+ if (!(await file.exists())) return null;
169
+ return JSON.parse(await file.text()) as ChatMessage[];
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ export async function saveCartoonSidecar(
176
+ id: string,
177
+ cartoon: CartoonImage,
178
+ ): Promise<void> {
179
+ ensureDirs();
180
+ await Bun.write(
181
+ join(SESSIONS_DIR, `${id}.cartoon.json`),
182
+ JSON.stringify(cartoon),
183
+ );
184
+ }
185
+
186
+ export async function loadCartoonSidecar(
187
+ id: string,
188
+ ): Promise<CartoonImage | null> {
189
+ try {
190
+ const filePath = join(SESSIONS_DIR, `${id}.cartoon.json`);
191
+ const file = Bun.file(filePath);
192
+ if (!(await file.exists())) return null;
193
+ return JSON.parse(await file.text()) as CartoonImage;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
94
199
  export function getHistoryPath(): string {
95
200
  return HISTORY_DIR;
96
201
  }
@@ -0,0 +1,128 @@
1
+ import type { NewprOutput } from "../types/output.ts";
2
+
3
+ const CARTOON_MODEL = "google/gemini-3-pro-image-preview";
4
+
5
+ function buildCartoonPrompt(data: NewprOutput, language: string): string {
6
+ const { meta, summary, groups, narrative } = data;
7
+ const lang = language === "auto" ? "English" : language;
8
+ const groupList = groups.slice(0, 5).map((g) => `- ${g.name}: ${g.description.slice(0, 80)}`).join("\n");
9
+ const storyExcerpt = narrative
10
+ .replace(/\[\[(group|file):[^\]]+\]\]/g, "")
11
+ .split("\n")
12
+ .filter((l) => l.trim() && !l.startsWith("#"))
13
+ .slice(0, 6)
14
+ .join(" ")
15
+ .slice(0, 500);
16
+
17
+ return `Generate an image of a funny 4-panel comic strip about this Pull Request.
18
+
19
+ ## PR Context
20
+ Title: "${meta.pr_title}"
21
+ Author: ${meta.author}
22
+ Purpose: ${summary.purpose}
23
+ Scope: ${summary.scope}
24
+ Impact: ${summary.impact}
25
+ Risk: ${summary.risk_level}
26
+ Changes: +${meta.total_additions} -${meta.total_deletions} across ${meta.total_files_changed} files
27
+
28
+ ## What happened (key changes):
29
+ ${groupList}
30
+
31
+ ## Story:
32
+ ${storyExcerpt}
33
+
34
+ ## Comic Requirements:
35
+ - 2x2 grid, 4 panels
36
+ - Cute stick-figure developer characters with expressive faces and gestures
37
+ - Speech bubbles with SHORT, witty dialogue in ${lang}
38
+ - Panel 1: The developer discovers the problem or receives the task (based on the PR purpose above)
39
+ - Panel 2: The developer's ambitious plan or approach (based on the actual changes)
40
+ - Panel 3: A funny complication that reflects the real complexity (based on risk/impact)
41
+ - Panel 4: The resolution with a developer humor punchline
42
+ - The humor should be SPECIFIC to this PR's content, not generic programming jokes
43
+ - Make the characters expressive and the scenes detailed
44
+ - The image must be square (1:1 aspect ratio, 1080x1080px), suitable for Instagram
45
+ - Output only the image`;
46
+ }
47
+
48
+ interface CartoonResponse {
49
+ choices: Array<{
50
+ message: {
51
+ content: string;
52
+ images?: Array<{
53
+ image_url: { url: string };
54
+ }>;
55
+ };
56
+ }>;
57
+ }
58
+
59
+ const MAX_RETRIES = 3;
60
+
61
+ export async function generateCartoon(
62
+ apiKey: string,
63
+ data: NewprOutput,
64
+ language: string,
65
+ ): Promise<{ imageBase64: string; mimeType: string }> {
66
+ const prompt = buildCartoonPrompt(data, language);
67
+
68
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
69
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
70
+ method: "POST",
71
+ headers: {
72
+ Authorization: `Bearer ${apiKey}`,
73
+ "Content-Type": "application/json",
74
+ "HTTP-Referer": "https://github.com/jiwonMe/newpr",
75
+ "X-Title": "newpr-cartoon",
76
+ },
77
+ body: JSON.stringify({
78
+ model: CARTOON_MODEL,
79
+ messages: [
80
+ { role: "user", content: prompt },
81
+ ],
82
+ modalities: ["image", "text"],
83
+ temperature: 1.0,
84
+ }),
85
+ });
86
+
87
+ if (response.status === 500 || response.status === 502 || response.status === 503) {
88
+ if (attempt < MAX_RETRIES) {
89
+ await new Promise((r) => setTimeout(r, 2000 * attempt));
90
+ continue;
91
+ }
92
+ }
93
+
94
+ if (!response.ok) {
95
+ const body = await response.text();
96
+ throw new Error(`Cartoon generation failed (${response.status}): ${body.slice(0, 300)}`);
97
+ }
98
+
99
+ const result = await response.json() as CartoonResponse;
100
+ const message = result.choices?.[0]?.message;
101
+
102
+ if (message?.images?.length) {
103
+ const imageUrl = message.images[0]!.image_url.url;
104
+ const match = imageUrl.match(/^data:image\/(png|jpeg|webp|gif);base64,(.+)$/);
105
+ if (match) {
106
+ return { imageBase64: match[2]!, mimeType: `image/${match[1]}` };
107
+ }
108
+ const imgRes = await fetch(imageUrl);
109
+ if (imgRes.ok) {
110
+ const buf = await imgRes.arrayBuffer();
111
+ return {
112
+ imageBase64: Buffer.from(buf).toString("base64"),
113
+ mimeType: imgRes.headers.get("content-type") ?? "image/png",
114
+ };
115
+ }
116
+ }
117
+
118
+ if (attempt < MAX_RETRIES) {
119
+ await new Promise((r) => setTimeout(r, 2000 * attempt));
120
+ continue;
121
+ }
122
+
123
+ const raw = JSON.stringify(result).slice(0, 500);
124
+ throw new Error(`No image in response. Raw: ${raw}`);
125
+ }
126
+
127
+ throw new Error("Cartoon generation failed after retries");
128
+ }
package/src/llm/client.ts CHANGED
@@ -238,3 +238,200 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
238
238
  const { createClaudeCodeClient: create } = require("./claude-code-client.ts");
239
239
  return create(options.timeout);
240
240
  }
241
+
242
+ export interface ChatTool {
243
+ type: "function";
244
+ function: {
245
+ name: string;
246
+ description: string;
247
+ parameters: Record<string, unknown>;
248
+ };
249
+ }
250
+
251
+ export interface ChatToolCallDelta {
252
+ id: string;
253
+ name: string;
254
+ arguments: string;
255
+ }
256
+
257
+ export interface ChatStreamEvent {
258
+ type: "text" | "tool_call" | "tool_result" | "done" | "error";
259
+ content?: string;
260
+ toolCall?: ChatToolCallDelta;
261
+ toolResult?: { id: string; result: string };
262
+ error?: string;
263
+ }
264
+
265
+ export type ChatStreamCallback = (event: ChatStreamEvent) => void;
266
+
267
+ interface OpenRouterChatMessage {
268
+ role: "system" | "user" | "assistant" | "tool";
269
+ content?: string | null;
270
+ tool_calls?: Array<{
271
+ id: string;
272
+ type: "function";
273
+ function: { name: string; arguments: string };
274
+ }>;
275
+ tool_call_id?: string;
276
+ }
277
+
278
+ interface OpenRouterStreamToolCallDelta {
279
+ index?: number;
280
+ id?: string;
281
+ type?: string;
282
+ function?: { name?: string; arguments?: string };
283
+ }
284
+
285
+ interface OpenRouterStreamChunkWithTools {
286
+ choices: Array<{
287
+ delta: {
288
+ content?: string;
289
+ tool_calls?: OpenRouterStreamToolCallDelta[];
290
+ };
291
+ finish_reason?: string | null;
292
+ }>;
293
+ model?: string;
294
+ usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
295
+ }
296
+
297
+ export async function chatWithTools(
298
+ options: LlmClientOptions,
299
+ messages: OpenRouterChatMessage[],
300
+ tools: ChatTool[],
301
+ executeTool: (name: string, args: Record<string, unknown>) => Promise<string>,
302
+ onEvent: ChatStreamCallback,
303
+ ): Promise<void> {
304
+ const MAX_TOOL_ROUNDS = 10;
305
+
306
+ let currentMessages = [...messages];
307
+
308
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
309
+ const controller = new AbortController();
310
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout * 1000);
311
+
312
+ let response: Response;
313
+ try {
314
+ response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
315
+ method: "POST",
316
+ signal: controller.signal,
317
+ headers: {
318
+ Authorization: `Bearer ${options.api_key}`,
319
+ "Content-Type": "application/json",
320
+ "HTTP-Referer": "https://github.com/sionic/newpr",
321
+ "X-Title": "newpr",
322
+ },
323
+ body: JSON.stringify({
324
+ model: options.model,
325
+ messages: currentMessages,
326
+ tools: tools.length > 0 ? tools : undefined,
327
+ temperature: 0.3,
328
+ stream: true,
329
+ }),
330
+ });
331
+ } catch (err) {
332
+ clearTimeout(timeoutId);
333
+ onEvent({ type: "error", error: err instanceof Error ? err.message : String(err) });
334
+ return;
335
+ }
336
+
337
+ clearTimeout(timeoutId);
338
+
339
+ if (!response.ok) {
340
+ const body = await response.text();
341
+ onEvent({ type: "error", error: `OpenRouter API error ${response.status}: ${body}` });
342
+ return;
343
+ }
344
+
345
+ const reader = response.body!.getReader();
346
+ const decoder = new TextDecoder();
347
+ let buffer = "";
348
+ let textContent = "";
349
+ const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
350
+
351
+ try {
352
+ while (true) {
353
+ const { done, value } = await reader.read();
354
+ if (done) break;
355
+
356
+ buffer += decoder.decode(value, { stream: true });
357
+ const lines = buffer.split("\n");
358
+ buffer = lines.pop() ?? "";
359
+
360
+ for (const line of lines) {
361
+ const trimmed = line.trim();
362
+ if (!trimmed || trimmed === "data: [DONE]") continue;
363
+ if (!trimmed.startsWith("data: ")) continue;
364
+
365
+ try {
366
+ const chunk = JSON.parse(trimmed.slice(6)) as OpenRouterStreamChunkWithTools;
367
+ const delta = chunk.choices[0]?.delta;
368
+ if (!delta) continue;
369
+
370
+ if (delta.content) {
371
+ textContent += delta.content;
372
+ onEvent({ type: "text", content: delta.content });
373
+ }
374
+
375
+ if (delta.tool_calls) {
376
+ for (const tc of delta.tool_calls) {
377
+ const idx = tc.index ?? 0;
378
+ if (!toolCalls.has(idx)) {
379
+ toolCalls.set(idx, { id: tc.id ?? "", name: "", arguments: "" });
380
+ }
381
+ const entry = toolCalls.get(idx)!;
382
+ if (tc.id) entry.id = tc.id;
383
+ if (tc.function?.name) entry.name += tc.function.name;
384
+ if (tc.function?.arguments) entry.arguments += tc.function.arguments;
385
+ }
386
+ }
387
+ } catch {}
388
+ }
389
+ }
390
+ } finally {
391
+ reader.releaseLock();
392
+ }
393
+
394
+ if (toolCalls.size === 0) {
395
+ onEvent({ type: "done" });
396
+ return;
397
+ }
398
+
399
+ const assistantMsg: OpenRouterChatMessage = {
400
+ role: "assistant",
401
+ content: textContent || null,
402
+ tool_calls: [...toolCalls.values()].map((tc) => ({
403
+ id: tc.id,
404
+ type: "function" as const,
405
+ function: { name: tc.name, arguments: tc.arguments },
406
+ })),
407
+ };
408
+ currentMessages.push(assistantMsg);
409
+
410
+ for (const tc of toolCalls.values()) {
411
+ onEvent({
412
+ type: "tool_call",
413
+ toolCall: { id: tc.id, name: tc.name, arguments: tc.arguments },
414
+ });
415
+
416
+ let args: Record<string, unknown> = {};
417
+ try { args = JSON.parse(tc.arguments); } catch {}
418
+
419
+ let result: string;
420
+ try {
421
+ result = await executeTool(tc.name, args);
422
+ } catch (err) {
423
+ result = `Error: ${err instanceof Error ? err.message : String(err)}`;
424
+ }
425
+
426
+ onEvent({ type: "tool_result", toolResult: { id: tc.id, result } });
427
+
428
+ currentMessages.push({
429
+ role: "tool",
430
+ content: result,
431
+ tool_call_id: tc.id,
432
+ });
433
+ }
434
+ }
435
+
436
+ onEvent({ type: "done" });
437
+ }