newpr 0.1.3 → 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 (33) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/github/fetch-pr.ts +43 -1
  4. package/src/history/store.ts +106 -1
  5. package/src/llm/client.ts +197 -0
  6. package/src/llm/prompts.ts +33 -8
  7. package/src/tui/Shell.tsx +7 -2
  8. package/src/types/github.ts +11 -0
  9. package/src/types/output.ts +44 -0
  10. package/src/web/client/App.tsx +29 -3
  11. package/src/web/client/components/AppShell.tsx +94 -47
  12. package/src/web/client/components/ChatSection.tsx +427 -0
  13. package/src/web/client/components/DetailPane.tsx +163 -75
  14. package/src/web/client/components/DiffViewer.tsx +679 -0
  15. package/src/web/client/components/InputScreen.tsx +110 -26
  16. package/src/web/client/components/Markdown.tsx +169 -43
  17. package/src/web/client/components/ResultsScreen.tsx +66 -71
  18. package/src/web/client/components/TipTapEditor.tsx +405 -0
  19. package/src/web/client/hooks/useAnalysis.ts +8 -1
  20. package/src/web/client/lib/shiki.ts +63 -0
  21. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  22. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  23. package/src/web/client/panels/FilesPanel.tsx +435 -54
  24. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  25. package/src/web/client/panels/StoryPanel.tsx +42 -22
  26. package/src/web/components/ui/tabs.tsx +3 -3
  27. package/src/web/server/routes.ts +716 -14
  28. package/src/web/server/session-manager.ts +11 -2
  29. package/src/web/server.ts +33 -0
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +117 -1
  32. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  33. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -1,10 +1,17 @@
1
1
  import type { NewprConfig } from "../../types/config.ts";
2
- import type { NewprOutput } from "../../types/output.ts";
2
+ import type { NewprOutput, ChatMessage, ChatToolCall, ChatSegment } from "../../types/output.ts";
3
3
  import { DEFAULT_CONFIG } from "../../types/config.ts";
4
- import { listSessions, loadSession } from "../../history/store.ts";
4
+ import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar } from "../../history/store.ts";
5
+ import type { DiffComment } from "../../types/output.ts";
6
+ import { fetchPrDiff } from "../../github/fetch-diff.ts";
7
+ import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
8
+ import { parseDiff } from "../../diff/parser.ts";
9
+ import { parsePrInput } from "../../github/parse-pr.ts";
5
10
  import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
6
11
  import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
7
12
  import { generateCartoon } from "../../llm/cartoon.ts";
13
+ import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
14
+ import { randomBytes } from "node:crypto";
8
15
 
9
16
  function json(data: unknown, status = 200): Response {
10
17
  return new Response(JSON.stringify(data), {
@@ -18,6 +25,156 @@ interface RouteOptions {
18
25
  }
19
26
 
20
27
  export function createRoutes(token: string, config: NewprConfig, options: RouteOptions = {}) {
28
+ const ghHeaders = {
29
+ Authorization: `token ${token}`,
30
+ Accept: "application/vnd.github.v3+json",
31
+ "User-Agent": "newpr-cli",
32
+ "Content-Type": "application/json",
33
+ };
34
+
35
+ async function resolvePrUrl(sessionId: string): Promise<string | null> {
36
+ const stored = await loadSession(sessionId);
37
+ if (stored) return stored.meta.pr_url;
38
+ const live = getSession(sessionId);
39
+ if (live?.result?.meta?.pr_url) return live.result.meta.pr_url;
40
+ if (live?.historyId) {
41
+ const hist = await loadSession(live.historyId);
42
+ if (hist) return hist.meta.pr_url;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ async function fetchHeadSha(pr: { owner: string; repo: string; number: number }): Promise<string | null> {
48
+ try {
49
+ const res = await fetch(`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, { headers: ghHeaders });
50
+ if (!res.ok) return null;
51
+ const data = await res.json() as { head?: { sha?: string } };
52
+ return data.head?.sha ?? null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function fetchCurrentUser(): Promise<{ login: string; avatar_url?: string }> {
59
+ try {
60
+ const res = await fetch("https://api.github.com/user", { headers: ghHeaders });
61
+ if (res.ok) {
62
+ const user = await res.json() as Record<string, unknown>;
63
+ return { login: user.login as string, avatar_url: user.avatar_url as string | undefined };
64
+ }
65
+ } catch {}
66
+ return { login: "anonymous" };
67
+ }
68
+
69
+ function buildChatSystemPrompt(data: NewprOutput): string {
70
+ const fileSummaries = data.files
71
+ .map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
72
+ .join("\n");
73
+ const groupSummaries = data.groups
74
+ .map((g) => `- [${g.type}] ${g.name}: ${g.description}\n Files: ${g.files.join(", ")}`)
75
+ .join("\n");
76
+
77
+ return `You are an expert code reviewer assistant for a Pull Request analysis tool called "newpr".
78
+ You have access to the full analysis of PR #${data.meta.pr_number} "${data.meta.pr_title}" in ${data.meta.pr_url}.
79
+
80
+ ## Analysis Context
81
+
82
+ **Author**: ${data.meta.author}
83
+ **Branches**: ${data.meta.head_branch} → ${data.meta.base_branch}
84
+ **Stats**: ${data.meta.total_files_changed} files, +${data.meta.total_additions} -${data.meta.total_deletions}
85
+ **Risk**: ${data.summary.risk_level}
86
+
87
+ **Purpose**: ${data.summary.purpose}
88
+ **Scope**: ${data.summary.scope}
89
+ **Impact**: ${data.summary.impact}
90
+
91
+ ## File Changes
92
+ ${fileSummaries}
93
+
94
+ ## Change Groups
95
+ ${groupSummaries}
96
+
97
+ ## Narrative
98
+ ${data.narrative}
99
+
100
+ ${data.meta.pr_body ? `## PR Description\n${data.meta.pr_body}` : ""}
101
+
102
+ ## Anchor Syntax (CRITICAL)
103
+ You MUST use these anchors whenever you mention a file or group. They become clickable links in the UI.
104
+ - File: [[file:exact/path/here.ts]] — renders as a clickable chip that opens the file diff in the sidebar.
105
+ - Group: [[group:Exact Group Name]] — renders as a clickable chip that opens the group detail in the sidebar.
106
+ - Use the EXACT paths and names from the lists above. Partial or invented names will not work.
107
+ - ALWAYS prefer anchors over plain text. Instead of "the auth module", write "the [[group:Auth Flow]] group". Instead of "in session.ts", write "in [[file:src/auth/session.ts]]".
108
+ - When listing multiple files, anchor each one: [[file:a.ts]], [[file:b.ts]], [[file:c.ts]].
109
+ - Even in short answers, use anchors. They are the primary way users navigate from your response to the code.
110
+
111
+ ## Math / LaTeX
112
+ When expressing mathematical formulas, algorithms, or complexity analysis, use LaTeX syntax:
113
+ - Inline: $O(n \\log n)$, $\\sum_{i=1}^{n} x_i$
114
+ - Block:
115
+ $$
116
+ f(x) = \\int_{a}^{b} g(t) \\, dt
117
+ $$
118
+
119
+ ## Instructions
120
+ - Answer questions about this PR thoroughly and precisely.
121
+ - Use your tools to fetch additional context when needed (file diffs, comments, reviews).
122
+ - When referencing code, include relevant snippets from the diff.
123
+ - Be concise but thorough. Use markdown formatting.
124
+ - If the user asks in Korean, respond in Korean. Match the user's language.`;
125
+ }
126
+
127
+ function buildChatTools(): ChatTool[] {
128
+ return [
129
+ {
130
+ type: "function",
131
+ function: {
132
+ name: "get_file_diff",
133
+ description: "Get the full unified diff for a specific file in this PR. Use this to see exact code changes.",
134
+ parameters: {
135
+ type: "object",
136
+ properties: {
137
+ path: { type: "string", description: "File path (e.g. 'src/index.ts')" },
138
+ },
139
+ required: ["path"],
140
+ },
141
+ },
142
+ },
143
+ {
144
+ type: "function",
145
+ function: {
146
+ name: "list_files",
147
+ description: "List all changed files in this PR with their status, line counts, and summaries.",
148
+ parameters: { type: "object", properties: {} },
149
+ },
150
+ },
151
+ {
152
+ type: "function",
153
+ function: {
154
+ name: "get_pr_comments",
155
+ description: "Get all issue comments (discussion) on this PR from GitHub.",
156
+ parameters: { type: "object", properties: {} },
157
+ },
158
+ },
159
+ {
160
+ type: "function",
161
+ function: {
162
+ name: "get_review_comments",
163
+ description: "Get all inline review comments on specific lines of code in this PR from GitHub.",
164
+ parameters: { type: "object", properties: {} },
165
+ },
166
+ },
167
+ {
168
+ type: "function",
169
+ function: {
170
+ name: "get_pr_details",
171
+ description: "Get PR metadata from GitHub: state, mergeable status, labels, requested reviewers, etc.",
172
+ parameters: { type: "object", properties: {} },
173
+ },
174
+ },
175
+ ];
176
+ }
177
+
21
178
  return {
22
179
  "POST /api/analysis": async (req: Request) => {
23
180
  const body = await req.json() as { pr: string };
@@ -45,6 +202,7 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
45
202
  finishedAt: session.finishedAt,
46
203
  error: session.error,
47
204
  result: session.result,
205
+ historyId: session.historyId,
48
206
  });
49
207
  },
50
208
 
@@ -121,6 +279,132 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
121
279
  return json(data);
122
280
  },
123
281
 
282
+ "GET /api/sessions/:id/diff": async (req: Request) => {
283
+ const url = new URL(req.url);
284
+ const segments = url.pathname.split("/");
285
+ const id = segments[segments.length - 2]!;
286
+ const filePath = url.searchParams.get("path");
287
+ if (!filePath) return json({ error: "Missing 'path' query parameter" }, 400);
288
+
289
+ const patch = await loadSinglePatch(id, filePath);
290
+ if (patch) return json({ patch, path: filePath });
291
+
292
+ let prUrl: string | null = null;
293
+ let storeId = id;
294
+
295
+ const storedSession = await loadSession(id);
296
+ if (storedSession) {
297
+ prUrl = storedSession.meta.pr_url;
298
+ } else {
299
+ const liveSession = getSession(id);
300
+ if (liveSession?.result?.meta?.pr_url) {
301
+ prUrl = liveSession.result.meta.pr_url;
302
+ if (liveSession.historyId) storeId = liveSession.historyId;
303
+ } else if (liveSession?.historyId) {
304
+ const histPatch = await loadSinglePatch(liveSession.historyId, filePath);
305
+ if (histPatch) return json({ patch: histPatch, path: filePath });
306
+
307
+ const histSession = await loadSession(liveSession.historyId);
308
+ if (histSession) {
309
+ prUrl = histSession.meta.pr_url;
310
+ storeId = liveSession.historyId;
311
+ }
312
+ }
313
+ }
314
+
315
+ if (!prUrl) return json({ error: "Session not found" }, 404);
316
+
317
+ try {
318
+ const pr = parsePrInput(prUrl);
319
+ const rawDiff = await fetchPrDiff(pr, token);
320
+ const parsed = parseDiff(rawDiff);
321
+
322
+ const allPatches: Record<string, string> = {};
323
+ for (const file of parsed.files) {
324
+ allPatches[file.path] = file.raw;
325
+ }
326
+ await savePatchesSidecar(storeId, allPatches).catch(() => {});
327
+
328
+ const backfilledPatch = allPatches[filePath];
329
+ if (!backfilledPatch) return json({ error: "File not found in diff" }, 404);
330
+ return json({ patch: backfilledPatch, path: filePath });
331
+ } catch (err) {
332
+ const msg = err instanceof Error ? err.message : String(err);
333
+ return json({ error: `Failed to fetch diff: ${msg}` }, 500);
334
+ }
335
+ },
336
+
337
+ "GET /api/sessions/:id/discussion": async (req: Request) => {
338
+ const url = new URL(req.url);
339
+ const segments = url.pathname.split("/");
340
+ const id = segments[segments.length - 2]!;
341
+
342
+ let prUrl: string | null = null;
343
+ let body: string | null = null;
344
+
345
+ const storedSession = await loadSession(id);
346
+ if (storedSession) {
347
+ prUrl = storedSession.meta.pr_url;
348
+ body = storedSession.meta.pr_body ?? null;
349
+ } else {
350
+ const liveSession = getSession(id);
351
+ if (liveSession?.result?.meta?.pr_url) {
352
+ prUrl = liveSession.result.meta.pr_url;
353
+ body = liveSession.result.meta.pr_body ?? null;
354
+ } else if (liveSession?.historyId) {
355
+ const histSession = await loadSession(liveSession.historyId);
356
+ if (histSession) {
357
+ prUrl = histSession.meta.pr_url;
358
+ body = histSession.meta.pr_body ?? null;
359
+ }
360
+ }
361
+ }
362
+
363
+ if (!prUrl) return json({ error: "Session not found" }, 404);
364
+
365
+ try {
366
+ const pr = parsePrInput(prUrl);
367
+ if (body === null) {
368
+ body = await fetchPrBody(pr, token);
369
+ }
370
+ const comments = await fetchPrComments(pr, token);
371
+ return json({ body, comments });
372
+ } catch (err) {
373
+ const msg = err instanceof Error ? err.message : String(err);
374
+ return json({ error: `Failed to fetch discussion: ${msg}` }, 500);
375
+ }
376
+ },
377
+
378
+ "GET /api/proxy": async (req: Request) => {
379
+ const url = new URL(req.url);
380
+ const target = url.searchParams.get("url");
381
+ if (!target) return json({ error: "Missing 'url' query parameter" }, 400);
382
+
383
+ const allowed = target.startsWith("https://github.com/") || target.startsWith("https://user-images.githubusercontent.com/");
384
+ if (!allowed) return json({ error: "URL not allowed" }, 403);
385
+
386
+ try {
387
+ const res = await fetch(target, {
388
+ headers: {
389
+ "User-Agent": "newpr-cli",
390
+ Authorization: `token ${token}`,
391
+ },
392
+ redirect: "follow",
393
+ });
394
+ if (!res.ok) return new Response(null, { status: res.status });
395
+
396
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
397
+ return new Response(res.body, {
398
+ headers: {
399
+ "Content-Type": contentType,
400
+ "Cache-Control": "public, max-age=86400, immutable",
401
+ },
402
+ });
403
+ } catch {
404
+ return new Response(null, { status: 502 });
405
+ }
406
+ },
407
+
124
408
  "GET /api/me": async () => {
125
409
  try {
126
410
  const res = await fetch("https://api.github.com/user", {
@@ -209,6 +493,431 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
209
493
  return json({ cartoon: !!options.cartoon });
210
494
  },
211
495
 
496
+ "GET /api/sessions/:id/comments": async (req: Request) => {
497
+ const url = new URL(req.url);
498
+ const segments = url.pathname.split("/");
499
+ const id = segments[3]!;
500
+ const filePath = url.searchParams.get("path");
501
+
502
+ const comments = await loadCommentsSidecar(id) ?? [];
503
+ const filtered = filePath ? comments.filter((c) => c.filePath === filePath) : comments;
504
+ return json(filtered);
505
+ },
506
+
507
+ "POST /api/sessions/:id/comments": async (req: Request) => {
508
+ const url = new URL(req.url);
509
+ const segments = url.pathname.split("/");
510
+ const sessionId = segments[3]!;
511
+
512
+ const body = await req.json() as { filePath?: string; line?: number; startLine?: number; side?: string; body?: string };
513
+ if (!body.filePath || body.line == null || !body.side || !body.body?.trim()) {
514
+ return json({ error: "Missing required fields" }, 400);
515
+ }
516
+
517
+ const user = await fetchCurrentUser();
518
+ const prUrl = await resolvePrUrl(sessionId);
519
+
520
+ let githubCommentId: number | undefined;
521
+ let githubCommentUrl: string | undefined;
522
+ if (prUrl) {
523
+ try {
524
+ const pr = parsePrInput(prUrl);
525
+ const sha = await fetchHeadSha(pr);
526
+ if (sha) {
527
+ const ghSide = body.side === "old" ? "LEFT" : "RIGHT";
528
+ const ghBody: Record<string, unknown> = {
529
+ commit_id: sha,
530
+ path: body.filePath,
531
+ line: body.line,
532
+ side: ghSide,
533
+ body: body.body.trim(),
534
+ };
535
+ if (body.startLine != null && body.startLine !== body.line) {
536
+ ghBody.start_line = body.startLine;
537
+ ghBody.start_side = ghSide;
538
+ }
539
+ const res = await fetch(
540
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments`,
541
+ {
542
+ method: "POST",
543
+ headers: ghHeaders,
544
+ body: JSON.stringify(ghBody),
545
+ },
546
+ );
547
+ if (res.ok) {
548
+ const data = await res.json() as { id?: number; html_url?: string };
549
+ githubCommentId = data.id;
550
+ githubCommentUrl = data.html_url;
551
+ }
552
+ }
553
+ } catch {}
554
+ }
555
+
556
+ const hasRange = body.startLine != null && body.startLine !== body.line;
557
+ const comment: DiffComment = {
558
+ id: randomBytes(8).toString("hex"),
559
+ sessionId,
560
+ filePath: body.filePath,
561
+ line: body.line,
562
+ ...(hasRange ? { startLine: body.startLine } : {}),
563
+ side: body.side as "old" | "new",
564
+ body: body.body.trim(),
565
+ author: user.login,
566
+ authorAvatar: user.avatar_url,
567
+ createdAt: new Date().toISOString(),
568
+ githubUrl: githubCommentUrl,
569
+ githubCommentId,
570
+ };
571
+
572
+ const existing = await loadCommentsSidecar(sessionId) ?? [];
573
+ existing.push(comment);
574
+ await saveCommentsSidecar(sessionId, existing);
575
+
576
+ return json(comment, 201);
577
+ },
578
+
579
+ "PATCH /api/sessions/:id/comments/:commentId": async (req: Request) => {
580
+ const url = new URL(req.url);
581
+ const segments = url.pathname.split("/");
582
+ const sessionId = segments[3]!;
583
+ const commentId = segments[5]!;
584
+
585
+ const body = await req.json() as { body?: string };
586
+ if (!body.body?.trim()) return json({ error: "Missing body" }, 400);
587
+
588
+ const existing = await loadCommentsSidecar(sessionId) ?? [];
589
+ const comment = existing.find((c) => c.id === commentId);
590
+ if (!comment) return json({ error: "Comment not found" }, 404);
591
+
592
+ comment.body = body.body.trim();
593
+
594
+ if (comment.githubCommentId) {
595
+ const prUrl = await resolvePrUrl(sessionId);
596
+ if (prUrl) {
597
+ try {
598
+ const pr = parsePrInput(prUrl);
599
+ await fetch(
600
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/comments/${comment.githubCommentId}`,
601
+ {
602
+ method: "PATCH",
603
+ headers: ghHeaders,
604
+ body: JSON.stringify({ body: comment.body }),
605
+ },
606
+ );
607
+ } catch {}
608
+ }
609
+ }
610
+
611
+ await saveCommentsSidecar(sessionId, existing);
612
+ return json(comment);
613
+ },
614
+
615
+ "DELETE /api/sessions/:id/comments/:commentId": async (req: Request) => {
616
+ const url = new URL(req.url);
617
+ const segments = url.pathname.split("/");
618
+ const sessionId = segments[3]!;
619
+ const commentId = segments[5]!;
620
+
621
+ const existing = await loadCommentsSidecar(sessionId) ?? [];
622
+ const idx = existing.findIndex((c) => c.id === commentId);
623
+ if (idx === -1) return json({ error: "Comment not found" }, 404);
624
+
625
+ const removed = existing[idx]!;
626
+ if (removed.githubCommentId) {
627
+ const prUrl = await resolvePrUrl(sessionId);
628
+ if (prUrl) {
629
+ try {
630
+ const pr = parsePrInput(prUrl);
631
+ await fetch(
632
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/comments/${removed.githubCommentId}`,
633
+ { method: "DELETE", headers: ghHeaders },
634
+ );
635
+ } catch {}
636
+ }
637
+ }
638
+
639
+ existing.splice(idx, 1);
640
+ await saveCommentsSidecar(sessionId, existing);
641
+
642
+ return json({ ok: true });
643
+ },
644
+
645
+ "GET /api/sessions/:id/chat": async (req: Request) => {
646
+ const url = new URL(req.url);
647
+ const segments = url.pathname.split("/");
648
+ const id = segments[3]!;
649
+ const messages = await loadChatSidecar(id) ?? [];
650
+ return json(messages);
651
+ },
652
+
653
+ "POST /api/sessions/:id/chat/undo": async (req: Request) => {
654
+ const url = new URL(req.url);
655
+ const segments = url.pathname.split("/");
656
+ const sessionId = segments[3]!;
657
+ const chatHistory = await loadChatSidecar(sessionId) ?? [];
658
+ if (chatHistory.length === 0) return json({ ok: true, removed: 0 });
659
+ const lastAssistantIdx = chatHistory.findLastIndex((m) => m.role === "assistant");
660
+ if (lastAssistantIdx === -1) return json({ ok: true, removed: 0 });
661
+ const lastUserIdx = chatHistory.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
662
+ const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
663
+ const removed = chatHistory.length - removeFrom;
664
+ const updated = chatHistory.slice(0, removeFrom);
665
+ await saveChatSidecar(sessionId, updated);
666
+ return json({ ok: true, removed });
667
+ },
668
+
669
+ "POST /api/sessions/:id/chat": async (req: Request) => {
670
+ const url = new URL(req.url);
671
+ const segments = url.pathname.split("/");
672
+ const sessionId = segments[3]!;
673
+
674
+ if (!config.openrouter_api_key) {
675
+ return json({ error: "OpenRouter API key required for chat" }, 400);
676
+ }
677
+
678
+ const body = await req.json() as { message: string };
679
+ if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
680
+
681
+ const sessionData = await loadSession(sessionId);
682
+ if (!sessionData) return json({ error: "Session not found" }, 404);
683
+
684
+ const chatHistory = await loadChatSidecar(sessionId) ?? [];
685
+
686
+ const userMsg: ChatMessage = {
687
+ role: "user",
688
+ content: body.message.trim(),
689
+ timestamp: new Date().toISOString(),
690
+ };
691
+ chatHistory.push(userMsg);
692
+ await saveChatSidecar(sessionId, chatHistory);
693
+
694
+ const systemPrompt = buildChatSystemPrompt(sessionData);
695
+
696
+ const apiMessages: Array<{ role: string; content?: string | null; tool_calls?: unknown[]; tool_call_id?: string }> = [
697
+ { role: "system", content: systemPrompt },
698
+ ];
699
+ for (const msg of chatHistory) {
700
+ if (msg.role === "user") {
701
+ apiMessages.push({ role: "user", content: msg.content });
702
+ } else if (msg.role === "assistant") {
703
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
704
+ apiMessages.push({
705
+ role: "assistant",
706
+ content: msg.content || null,
707
+ tool_calls: msg.toolCalls.map((tc) => ({
708
+ id: tc.id,
709
+ type: "function",
710
+ function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
711
+ })),
712
+ });
713
+ for (const tc of msg.toolCalls) {
714
+ if (tc.result !== undefined) {
715
+ apiMessages.push({
716
+ role: "tool",
717
+ content: tc.result,
718
+ tool_call_id: tc.id,
719
+ });
720
+ }
721
+ }
722
+ } else {
723
+ apiMessages.push({ role: "assistant", content: msg.content });
724
+ }
725
+ }
726
+ }
727
+
728
+ const chatTools = buildChatTools();
729
+
730
+ const patches = await loadPatchesSidecar(sessionId);
731
+
732
+ const executeTool = async (name: string, args: Record<string, unknown>): Promise<string> => {
733
+ switch (name) {
734
+ case "get_file_diff": {
735
+ const filePath = args.path as string;
736
+ if (!filePath) return "Error: path argument required";
737
+ if (patches?.[filePath]) return patches[filePath];
738
+ const patch = await loadSinglePatch(sessionId, filePath);
739
+ if (patch) return patch;
740
+ try {
741
+ const pr = parsePrInput(sessionData.meta.pr_url);
742
+ const rawDiff = await fetchPrDiff(pr, token);
743
+ const parsed = parseDiff(rawDiff);
744
+ const file = parsed.files.find((f) => f.path === filePath);
745
+ return file?.raw ?? `File "${filePath}" not found in diff`;
746
+ } catch (err) {
747
+ return `Error fetching diff: ${err instanceof Error ? err.message : String(err)}`;
748
+ }
749
+ }
750
+ case "list_files": {
751
+ return sessionData.files
752
+ .map((f) => `${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
753
+ .join("\n");
754
+ }
755
+ case "get_pr_comments": {
756
+ try {
757
+ const pr = parsePrInput(sessionData.meta.pr_url);
758
+ const comments = await fetchPrComments(pr, token);
759
+ if (comments.length === 0) return "No comments on this PR.";
760
+ return comments.map((c) => `@${c.author} (${c.created_at}):\n${c.body}`).join("\n\n---\n\n");
761
+ } catch (err) {
762
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
763
+ }
764
+ }
765
+ case "get_review_comments": {
766
+ try {
767
+ const pr = parsePrInput(sessionData.meta.pr_url);
768
+ const res = await fetch(
769
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments?per_page=100`,
770
+ { headers: ghHeaders },
771
+ );
772
+ if (!res.ok) return `GitHub API error: ${res.status}`;
773
+ const reviews = await res.json() as Array<{ user?: { login?: string }; path?: string; body?: string; created_at?: string; line?: number }>;
774
+ if (reviews.length === 0) return "No review comments on this PR.";
775
+ return reviews.map((r) =>
776
+ `@${r.user?.login ?? "unknown"} on ${r.path ?? "?"}${r.line ? `:${r.line}` : ""} (${r.created_at}):\n${r.body ?? ""}`,
777
+ ).join("\n\n---\n\n");
778
+ } catch (err) {
779
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
780
+ }
781
+ }
782
+ case "get_pr_details": {
783
+ try {
784
+ const pr = parsePrInput(sessionData.meta.pr_url);
785
+ const res = await fetch(
786
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
787
+ { headers: ghHeaders },
788
+ );
789
+ if (!res.ok) return `GitHub API error: ${res.status}`;
790
+ const data = await res.json() as Record<string, unknown>;
791
+ return JSON.stringify({
792
+ title: data.title,
793
+ body: data.body,
794
+ state: data.state,
795
+ merged: data.merged,
796
+ mergeable: data.mergeable,
797
+ additions: data.additions,
798
+ deletions: data.deletions,
799
+ changed_files: data.changed_files,
800
+ labels: (data.labels as Array<{ name: string }>)?.map((l) => l.name),
801
+ requested_reviewers: (data.requested_reviewers as Array<{ login: string }>)?.map((r) => r.login),
802
+ }, null, 2);
803
+ } catch (err) {
804
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
805
+ }
806
+ }
807
+ default:
808
+ return `Unknown tool: ${name}`;
809
+ }
810
+ };
811
+
812
+ const encoder = new TextEncoder();
813
+ const stream = new ReadableStream({
814
+ async start(controller) {
815
+ const send = (eventType: string, data: string) => {
816
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
817
+ };
818
+
819
+ let fullText = "";
820
+ const collectedToolCalls: ChatToolCall[] = [];
821
+ const orderedSegments: ChatSegment[] = [];
822
+ let lastSegmentWasText = false;
823
+
824
+ try {
825
+ await chatWithTools(
826
+ {
827
+ api_key: config.openrouter_api_key,
828
+ model: config.model,
829
+ timeout: config.timeout,
830
+ },
831
+ apiMessages as Parameters<typeof chatWithTools>[1],
832
+ chatTools,
833
+ executeTool,
834
+ (event: ChatStreamEvent) => {
835
+ switch (event.type) {
836
+ case "text":
837
+ fullText += event.content ?? "";
838
+ if (lastSegmentWasText && orderedSegments.length > 0) {
839
+ const last = orderedSegments[orderedSegments.length - 1]!;
840
+ if (last.type === "text") {
841
+ last.content += event.content ?? "";
842
+ }
843
+ } else {
844
+ orderedSegments.push({ type: "text", content: event.content ?? "" });
845
+ lastSegmentWasText = true;
846
+ }
847
+ send("text", JSON.stringify({ content: event.content }));
848
+ break;
849
+ case "tool_call":
850
+ if (event.toolCall) {
851
+ let args: Record<string, unknown> = {};
852
+ try { args = JSON.parse(event.toolCall.arguments); } catch {}
853
+ const tc: ChatToolCall = {
854
+ id: event.toolCall.id,
855
+ name: event.toolCall.name,
856
+ arguments: args,
857
+ };
858
+ collectedToolCalls.push(tc);
859
+ orderedSegments.push({ type: "tool_call", toolCall: tc });
860
+ lastSegmentWasText = false;
861
+ send("tool_call", JSON.stringify({
862
+ id: event.toolCall.id,
863
+ name: event.toolCall.name,
864
+ arguments: args,
865
+ }));
866
+ }
867
+ break;
868
+ case "tool_result":
869
+ if (event.toolResult) {
870
+ const tc = collectedToolCalls.find((c) => c.id === event.toolResult!.id);
871
+ if (tc) tc.result = event.toolResult.result;
872
+ send("tool_result", JSON.stringify(event.toolResult));
873
+ }
874
+ break;
875
+ case "error":
876
+ send("chat_error", JSON.stringify({ message: event.error }));
877
+ break;
878
+ case "done":
879
+ break;
880
+ }
881
+ },
882
+ );
883
+
884
+ const assistantMsg: ChatMessage = {
885
+ role: "assistant",
886
+ content: fullText,
887
+ toolCalls: collectedToolCalls.length > 0 ? collectedToolCalls : undefined,
888
+ segments: orderedSegments.length > 0 ? orderedSegments : undefined,
889
+ timestamp: new Date().toISOString(),
890
+ };
891
+ chatHistory.push(assistantMsg);
892
+ await saveChatSidecar(sessionId, chatHistory);
893
+
894
+ send("done", JSON.stringify({}));
895
+ } catch (err) {
896
+ send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
897
+ } finally {
898
+ controller.close();
899
+ }
900
+ },
901
+ });
902
+
903
+ return new Response(stream, {
904
+ headers: {
905
+ "Content-Type": "text/event-stream",
906
+ "Cache-Control": "no-cache",
907
+ "Connection": "keep-alive",
908
+ },
909
+ });
910
+ },
911
+
912
+ "GET /api/sessions/:id/cartoon": async (req: Request) => {
913
+ const url = new URL(req.url);
914
+ const segments = url.pathname.split("/");
915
+ const id = segments[3]!;
916
+ const cartoon = await loadCartoonSidecar(id);
917
+ if (!cartoon) return json(null);
918
+ return json(cartoon);
919
+ },
920
+
212
921
  "POST /api/cartoon": async (req: Request) => {
213
922
  if (!options.cartoon) return json({ error: "Cartoon mode not enabled. Start with --cartoon flag." }, 403);
214
923
  if (!config.openrouter_api_key) return json({ error: "OpenRouter API key required for cartoon generation" }, 400);
@@ -226,18 +935,11 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
226
935
  const result = await generateCartoon(config.openrouter_api_key, data, config.language);
227
936
 
228
937
  if (sessionId) {
229
- const sessionData = await loadSession(sessionId);
230
- if (sessionData) {
231
- sessionData.cartoon = {
232
- imageBase64: result.imageBase64,
233
- mimeType: result.mimeType,
234
- generatedAt: new Date().toISOString(),
235
- };
236
- const { join } = await import("node:path");
237
- const { homedir } = await import("node:os");
238
- const sessionsDir = join(homedir(), ".newpr", "history", "sessions");
239
- await Bun.write(join(sessionsDir, `${sessionId}.json`), JSON.stringify(sessionData, null, 2));
240
- }
938
+ await saveCartoonSidecar(sessionId, {
939
+ imageBase64: result.imageBase64,
940
+ mimeType: result.mimeType,
941
+ generatedAt: new Date().toISOString(),
942
+ });
241
943
  }
242
944
 
243
945
  return json(result);