newpr 0.6.2 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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",
@@ -62,16 +62,18 @@ async function checkAgent(name: AgentToolName): Promise<ToolStatus> {
62
62
  }
63
63
 
64
64
  export async function runPreflight(): Promise<PreflightResult> {
65
- const [github, claude, opencode, codex] = await Promise.all([
65
+ const [github, claude, cursor, gemini, opencode, codex] = await Promise.all([
66
66
  checkGithubCli(),
67
67
  checkAgent("claude"),
68
+ checkAgent("cursor"),
69
+ checkAgent("gemini"),
68
70
  checkAgent("opencode"),
69
71
  checkAgent("codex"),
70
72
  ]);
71
73
 
72
74
  return {
73
75
  github,
74
- agents: [claude, opencode, codex],
76
+ agents: [claude, cursor, gemini, opencode, codex],
75
77
  openrouterKey: !!(process.env.OPENROUTER_API_KEY || await hasStoredApiKey()),
76
78
  };
77
79
  }
@@ -115,11 +117,14 @@ export function printPreflight(result: PreflightResult): void {
115
117
  }
116
118
  }
117
119
 
120
+ const hasAgent = result.agents.some((a) => a.installed);
118
121
  if (result.openrouterKey) {
119
122
  console.log(` ${check} OpenRouter API key`);
123
+ } else if (hasAgent) {
124
+ console.log(` ${dim("·")} OpenRouter API key ${dim("· not configured (using agent as LLM fallback)")}`);
120
125
  } else {
121
126
  console.log(` ${cross} OpenRouter API key ${dim("· not configured")}`);
122
- console.log(` ${dim("run: newpr auth")}`);
127
+ console.log(` ${dim("run: newpr auth —or— install an agent (claude, gemini, etc.)")}`);
123
128
  }
124
129
 
125
130
  console.log("");
package/src/llm/client.ts CHANGED
@@ -239,6 +239,10 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
239
239
  return create(options.timeout);
240
240
  }
241
241
 
242
+ export function hasApiKey(options: LlmClientOptions): boolean {
243
+ return !!options.api_key;
244
+ }
245
+
242
246
  export interface ChatTool {
243
247
  type: "function";
244
248
  function: {
@@ -14,6 +14,8 @@ import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
14
14
  import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
15
15
  import type { AnchorItem } from "./components/TipTapEditor.tsx";
16
16
  import { requestNotificationPermission } from "./lib/notify.ts";
17
+ import { analytics, initAnalytics, getConsent } from "./lib/analytics.ts";
18
+ import { AnalyticsConsent } from "./components/AnalyticsConsent.tsx";
17
19
 
18
20
  function getUrlParam(key: string): string | null {
19
21
  return new URLSearchParams(window.location.search).get(key);
@@ -39,8 +41,12 @@ export function App() {
39
41
  const features = useFeatures();
40
42
  const bgAnalyses = useBackgroundAnalyses();
41
43
  const initialLoadDone = useRef(false);
44
+ const [showConsent, setShowConsent] = useState(() => getConsent() === "pending");
42
45
 
43
- useEffect(() => { requestNotificationPermission(); }, []);
46
+ useEffect(() => {
47
+ requestNotificationPermission();
48
+ initAnalytics();
49
+ }, []);
44
50
  const [activeId, setActiveId] = useState<string | null>(null);
45
51
 
46
52
  useEffect(() => {
@@ -73,6 +79,7 @@ export function App() {
73
79
 
74
80
  const scrollGuardRef = useRef<number | null>(null);
75
81
  const handleAnchorClick = useCallback((kind: "group" | "file" | "line", id: string) => {
82
+ analytics.detailOpened(kind);
76
83
  const key = `${kind}:${id}`;
77
84
  const main = document.querySelector("main");
78
85
  const savedScroll = main?.scrollTop ?? 0;
@@ -133,6 +140,10 @@ export function App() {
133
140
  ) : null;
134
141
 
135
142
  const [activeTab, setActiveTab] = useState(() => getUrlParam("tab") ?? "story");
143
+ const handleTabChange = useCallback((tab: string) => {
144
+ analytics.tabChanged(tab);
145
+ setActiveTab(tab);
146
+ }, []);
136
147
  const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
137
148
 
138
149
  const anchorItems = useMemo<AnchorItem[]>(() => {
@@ -149,6 +160,7 @@ export function App() {
149
160
 
150
161
  return (
151
162
  <ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
163
+ {showConsent && <AnalyticsConsent onDone={() => setShowConsent(false)} />}
152
164
  <AppShell
153
165
  theme={themeCtx.theme}
154
166
  onThemeChange={themeCtx.setTheme}
@@ -187,7 +199,7 @@ export function App() {
187
199
  onAnchorClick={handleAnchorClick}
188
200
  cartoonEnabled={features.cartoon}
189
201
  sessionId={diffSessionId}
190
- onTabChange={setActiveTab}
202
+ onTabChange={handleTabChange}
191
203
  onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
192
204
  enabledPlugins={features.enabledPlugins}
193
205
  />
@@ -0,0 +1,88 @@
1
+ import { useState } from "react";
2
+ import { BarChart3, Shield } from "lucide-react";
3
+ import { getConsent, setConsent, type ConsentState } from "../lib/analytics.ts";
4
+
5
+ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
6
+ const [state] = useState<ConsentState>(() => getConsent());
7
+
8
+ if (state !== "pending") return null;
9
+
10
+ const handleAccept = () => {
11
+ setConsent("granted");
12
+ onDone();
13
+ };
14
+
15
+ const handleDecline = () => {
16
+ setConsent("denied");
17
+ onDone();
18
+ };
19
+
20
+ return (
21
+ <div className="fixed inset-0 z-[100] flex items-center justify-center">
22
+ <div className="fixed inset-0 bg-background/70 backdrop-blur-sm" />
23
+ <div className="relative z-10 w-full max-w-md mx-4 rounded-2xl border bg-background shadow-2xl overflow-hidden">
24
+ <div className="px-6 pt-6 pb-4">
25
+ <div className="flex items-center gap-3 mb-4">
26
+ <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10">
27
+ <BarChart3 className="h-5 w-5 text-blue-500" />
28
+ </div>
29
+ <div>
30
+ <h2 className="text-sm font-semibold">Help improve newpr</h2>
31
+ <p className="text-[11px] text-muted-foreground">Anonymous usage analytics</p>
32
+ </div>
33
+ </div>
34
+
35
+ <p className="text-xs text-muted-foreground leading-relaxed mb-3">
36
+ We'd like to collect anonymous usage data to understand how newpr is used and improve the experience.
37
+ </p>
38
+
39
+ <div className="rounded-lg bg-muted/40 px-3.5 py-2.5 space-y-1.5 mb-4">
40
+ <div className="flex items-start gap-2">
41
+ <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
42
+ <div className="text-[11px] text-muted-foreground leading-relaxed">
43
+ <p className="font-medium text-foreground/80 mb-1">What we collect:</p>
44
+ <ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
45
+ <li>Feature usage (which tabs, buttons, and actions you use)</li>
46
+ <li>Performance metrics (analysis duration, error rates)</li>
47
+ <li>Basic device info (browser, screen size)</li>
48
+ </ul>
49
+ </div>
50
+ </div>
51
+ <div className="flex items-start gap-2 pt-1">
52
+ <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
53
+ <div className="text-[11px] text-muted-foreground leading-relaxed">
54
+ <p className="font-medium text-foreground/80 mb-1">What we never collect:</p>
55
+ <ul className="space-y-0.5 list-disc list-inside text-[10.5px]">
56
+ <li>PR content, code, or commit messages</li>
57
+ <li>Chat messages or review comments</li>
58
+ <li>API keys, tokens, or personal data</li>
59
+ </ul>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <p className="text-[10px] text-muted-foreground/50 mb-4">
65
+ Powered by Google Analytics. You can change this anytime in Settings.
66
+ </p>
67
+ </div>
68
+
69
+ <div className="flex border-t">
70
+ <button
71
+ type="button"
72
+ onClick={handleDecline}
73
+ className="flex-1 px-4 py-3 text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
74
+ >
75
+ Decline
76
+ </button>
77
+ <button
78
+ type="button"
79
+ onClick={handleAccept}
80
+ className="flex-1 px-4 py-3 text-xs font-medium bg-foreground text-background hover:opacity-90 transition-opacity"
81
+ >
82
+ Accept
83
+ </button>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
@@ -7,6 +7,7 @@ import type { SessionRecord } from "../../../history/types.ts";
7
7
  import type { GithubUser } from "../hooks/useGithubUser.ts";
8
8
  import { SettingsPanel } from "./SettingsPanel.tsx";
9
9
  import { ResizeHandle } from "./ResizeHandle.tsx";
10
+ import { analytics } from "../lib/analytics.ts";
10
11
 
11
12
  type Theme = "light" | "dark" | "system";
12
13
 
@@ -276,7 +277,7 @@ export function AppShell({
276
277
  <button
277
278
  type="button"
278
279
  disabled={update.updating}
279
- onClick={update.doUpdate}
280
+ onClick={() => { analytics.updateClicked(); update.doUpdate(); }}
280
281
  className="w-full flex items-center justify-center gap-1.5 rounded-md bg-blue-500 hover:bg-blue-600 text-white text-[11px] font-medium py-1.5 transition-colors disabled:opacity-50"
281
282
  >
282
283
  {update.updating ? (
@@ -375,7 +376,7 @@ export function AppShell({
375
376
  <div className="flex-1" />
376
377
  <button
377
378
  type="button"
378
- onClick={() => setSettingsOpen(true)}
379
+ onClick={() => { analytics.settingsOpened(); setSettingsOpen(true); }}
379
380
  className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:bg-accent/40 hover:text-foreground transition-colors"
380
381
  title="Settings"
381
382
  >
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from "react";
2
2
  import { X, Check, MessageSquare, Loader2, AlertCircle, ExternalLink } from "lucide-react";
3
3
  import { TipTapEditor, getTextWithAnchors } from "./TipTapEditor.tsx";
4
4
  import type { useEditor } from "@tiptap/react";
5
+ import { analytics } from "../lib/analytics.ts";
5
6
 
6
7
  type ReviewEvent = "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
7
8
 
@@ -53,6 +54,7 @@ export function ReviewModal({ prUrl, onClose }: ReviewModalProps) {
53
54
  });
54
55
  const data = await res.json() as { ok?: boolean; html_url?: string; error?: string };
55
56
  if (data.ok) {
57
+ analytics.reviewSubmitted(event);
56
58
  setResult({ ok: true, html_url: data.html_url });
57
59
  } else {
58
60
  setResult({ ok: false, error: data.error ?? "Failed to submit review" });
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { X, Check, Loader2, Search, ChevronDown } from "lucide-react";
3
+ import { analytics, getConsent, setConsent } from "../lib/analytics.ts";
3
4
 
4
5
  interface ConfigData {
5
6
  model: string;
@@ -69,6 +70,8 @@ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => vo
69
70
  const save = useCallback(async (update: Record<string, unknown>) => {
70
71
  setSaving(true);
71
72
  setSaved(false);
73
+ const field = Object.keys(update).filter((k) => k !== "openrouter_api_key").join(",");
74
+ if (field) analytics.settingsChanged(field);
72
75
  try {
73
76
  await fetch("/api/config", {
74
77
  method: "PUT",
@@ -265,11 +268,47 @@ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => vo
265
268
  </div>
266
269
  </Section>
267
270
  )}
271
+ <Section title="Privacy">
272
+ <AnalyticsToggle />
273
+ </Section>
268
274
  </div>
269
275
  </div>
270
276
  );
271
277
  }
272
278
 
279
+ function AnalyticsToggle() {
280
+ const [consent, setLocal] = useState(() => getConsent());
281
+ const enabled = consent === "granted";
282
+
283
+ const toggle = () => {
284
+ const next = enabled ? "denied" : "granted";
285
+ setConsent(next);
286
+ setLocal(next);
287
+ analytics.settingsChanged("analytics");
288
+ };
289
+
290
+ return (
291
+ <Row label="Usage Analytics">
292
+ <div className="flex items-center gap-2">
293
+ <span className="text-[11px] text-muted-foreground/50">
294
+ {enabled ? "Enabled" : "Disabled"}
295
+ </span>
296
+ <button
297
+ type="button"
298
+ onClick={toggle}
299
+ className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors ${
300
+ enabled ? "bg-foreground" : "bg-muted"
301
+ }`}
302
+ >
303
+ <span className={`inline-block h-3 w-3 rounded-full bg-background transition-transform ${
304
+ enabled ? "translate-x-3.5" : "translate-x-0.5"
305
+ }`} />
306
+ </button>
307
+ </div>
308
+ </Row>
309
+ );
310
+ }
311
+
273
312
  function ModelSelect({ value, models: allModels, onChange }: { value: string; models: ModelInfo[]; onChange: (id: string) => void }) {
274
313
  const [open, setOpen] = useState(false);
275
314
  const [search, setSearch] = useState("");
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import type { ProgressEvent } from "../../../analyzer/progress.ts";
3
3
  import type { NewprOutput } from "../../../types/output.ts";
4
+ import { analytics } from "../lib/analytics.ts";
4
5
 
5
6
  type Phase = "idle" | "loading" | "done" | "error";
6
7
 
@@ -29,6 +30,7 @@ export function useAnalysis() {
29
30
  const eventSourceRef = useRef<EventSource | null>(null);
30
31
 
31
32
  const start = useCallback(async (prInput: string) => {
33
+ analytics.analysisStarted(0);
32
34
  setState({
33
35
  phase: "loading",
34
36
  sessionId: null,
@@ -79,12 +81,16 @@ export function useAnalysis() {
79
81
  eventSourceRef.current = null;
80
82
  const resultRes = await fetch(`/api/analysis/${sessionId}`);
81
83
  const data = await resultRes.json() as { result?: NewprOutput; historyId?: string };
82
- setState((s) => ({
83
- ...s,
84
- phase: "done",
85
- result: data.result ?? null,
86
- historyId: data.historyId ?? null,
87
- }));
84
+ setState((s) => {
85
+ const durationSec = s.startedAt ? Math.round((Date.now() - s.startedAt) / 1000) : 0;
86
+ analytics.analysisCompleted(data.result?.files.length ?? 0, durationSec);
87
+ return {
88
+ ...s,
89
+ phase: "done",
90
+ result: data.result ?? null,
91
+ historyId: data.historyId ?? null,
92
+ };
93
+ });
88
94
  });
89
95
 
90
96
  es.addEventListener("analysis_error", (e) => {
@@ -92,6 +98,7 @@ export function useAnalysis() {
92
98
  eventSourceRef.current = null;
93
99
  let msg = "Analysis failed";
94
100
  try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
101
+ analytics.analysisError(msg.slice(0, 100));
95
102
  setState((s) => ({ ...s, phase: "error", error: msg }));
96
103
  });
97
104
 
@@ -125,6 +132,7 @@ export function useAnalysis() {
125
132
  const res = await fetch(`/api/sessions/${sessionId}`);
126
133
  if (!res.ok) throw new Error("Session not found");
127
134
  const data = await res.json() as NewprOutput;
135
+ analytics.sessionLoaded();
128
136
  setState((s) => ({
129
137
  ...s,
130
138
  phase: "done",
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useCallback, useSyncExternalStore } from "react";
2
2
  import { sendNotification } from "../lib/notify.ts";
3
+ import { analytics } from "../lib/analytics.ts";
3
4
  import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
4
5
 
5
6
  interface ChatSessionState {
@@ -73,6 +74,7 @@ class ChatStore {
73
74
  if (s.loading) return;
74
75
 
75
76
  const startTime = Date.now();
77
+ analytics.chatSent();
76
78
  const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
77
79
  this.update(sessionId, { messages: [...s.messages, userMsg], loading: true, streaming: { segments: [] } });
78
80
 
@@ -152,6 +154,8 @@ class ChatStore {
152
154
  }
153
155
 
154
156
  const cur = this.getOrCreate(sessionId);
157
+ const durationMs = Date.now() - startTime;
158
+ analytics.chatCompleted(Math.round(durationMs / 1000), allToolCalls.length > 0);
155
159
  this.update(sessionId, {
156
160
  messages: [...cur.messages, {
157
161
  role: "assistant",
@@ -159,7 +163,7 @@ class ChatStore {
159
163
  toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
160
164
  segments: orderedSegments.length > 0 ? orderedSegments : undefined,
161
165
  timestamp: new Date().toISOString(),
162
- durationMs: Date.now() - startTime,
166
+ durationMs,
163
167
  }],
164
168
  });
165
169
  sendNotification("Chat response ready", fullText.slice(0, 100));
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
+ import { analytics } from "../lib/analytics.ts";
2
3
 
3
4
  type Theme = "light" | "dark" | "system";
4
5
 
@@ -28,6 +29,7 @@ export function useTheme() {
28
29
  const setTheme = useCallback((t: Theme) => {
29
30
  localStorage.setItem("newpr-theme", t);
30
31
  setThemeState(t);
32
+ analytics.themeChanged(t);
31
33
  }, []);
32
34
 
33
35
  return { theme, setTheme };
@@ -0,0 +1,111 @@
1
+ declare global {
2
+ interface Window {
3
+ gtag?: (...args: unknown[]) => void;
4
+ dataLayer?: unknown[];
5
+ }
6
+ }
7
+
8
+ const GA_ID = "G-L3SL6T6JQ1";
9
+ const CONSENT_KEY = "newpr-analytics-consent";
10
+
11
+ export type ConsentState = "granted" | "denied" | "pending";
12
+
13
+ export function getConsent(): ConsentState {
14
+ const stored = localStorage.getItem(CONSENT_KEY);
15
+ if (stored === "granted" || stored === "denied") return stored;
16
+ return "pending";
17
+ }
18
+
19
+ export function setConsent(state: "granted" | "denied"): void {
20
+ localStorage.setItem(CONSENT_KEY, state);
21
+ if (state === "granted") {
22
+ loadGA();
23
+ } else {
24
+ disableGA();
25
+ }
26
+ }
27
+
28
+ let gaLoaded = false;
29
+
30
+ function loadGA(): void {
31
+ if (gaLoaded) return;
32
+ gaLoaded = true;
33
+
34
+ const script = document.createElement("script");
35
+ script.async = true;
36
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`;
37
+ document.head.appendChild(script);
38
+
39
+ window.dataLayer = window.dataLayer || [];
40
+ window.gtag = function (...args: unknown[]) {
41
+ window.dataLayer!.push(args);
42
+ };
43
+ window.gtag("js", new Date());
44
+ window.gtag("config", GA_ID);
45
+ }
46
+
47
+ function disableGA(): void {
48
+ (window as unknown as Record<string, unknown>)[`ga-disable-${GA_ID}`] = true;
49
+ }
50
+
51
+ export function initAnalytics(): void {
52
+ if (getConsent() === "granted") {
53
+ loadGA();
54
+ }
55
+ }
56
+
57
+ function gtag(command: string, ...args: unknown[]): void {
58
+ if (getConsent() !== "granted") return;
59
+ window.gtag?.(command, ...args);
60
+ }
61
+
62
+ function trackEvent(name: string, params?: Record<string, string | number | boolean>): void {
63
+ gtag("event", name, params);
64
+ }
65
+
66
+ export const analytics = {
67
+ analysisStarted: (fileCount: number) =>
68
+ trackEvent("analysis_started", { file_count: fileCount }),
69
+
70
+ analysisCompleted: (fileCount: number, durationSec: number) =>
71
+ trackEvent("analysis_completed", { file_count: fileCount, duration_sec: durationSec }),
72
+
73
+ analysisError: (errorType: string) =>
74
+ trackEvent("analysis_error", { error_type: errorType }),
75
+
76
+ tabChanged: (tab: string) =>
77
+ trackEvent("tab_changed", { tab }),
78
+
79
+ chatSent: () =>
80
+ trackEvent("chat_sent"),
81
+
82
+ chatCompleted: (durationSec: number, hasTools: boolean) =>
83
+ trackEvent("chat_completed", { duration_sec: durationSec, has_tools: hasTools }),
84
+
85
+ detailOpened: (kind: string) =>
86
+ trackEvent("detail_opened", { kind }),
87
+
88
+ themeChanged: (theme: string) =>
89
+ trackEvent("theme_changed", { theme }),
90
+
91
+ settingsOpened: () =>
92
+ trackEvent("settings_opened"),
93
+
94
+ settingsChanged: (field: string) =>
95
+ trackEvent("settings_changed", { field }),
96
+
97
+ sessionLoaded: () =>
98
+ trackEvent("session_loaded"),
99
+
100
+ reviewSubmitted: (event: string) =>
101
+ trackEvent("review_submitted", { review_event: event }),
102
+
103
+ agentUsed: (agent: string) =>
104
+ trackEvent("agent_used", { agent }),
105
+
106
+ updateClicked: () =>
107
+ trackEvent("update_clicked"),
108
+
109
+ featureUsed: (feature: string) =>
110
+ trackEvent("feature_used", { feature }),
111
+ };
@@ -12,7 +12,7 @@ import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSession
12
12
  import { generateCartoon } from "../../llm/cartoon.ts";
13
13
  import { generateSlides } from "../../llm/slides.ts";
14
14
  import { getPlugin, getAllPlugins } from "../../plugins/registry.ts";
15
- import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
15
+ import { chatWithTools, createLlmClient, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
16
16
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
17
17
  import { randomBytes } from "node:crypto";
18
18
 
@@ -72,6 +72,33 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
72
72
  return { login: "anonymous" };
73
73
  }
74
74
 
75
+ function buildFallbackPrompt(
76
+ systemPrompt: string,
77
+ chatHistory: ChatMessage[],
78
+ patches?: Record<string, string> | null,
79
+ ): string {
80
+ const parts: string[] = [systemPrompt];
81
+
82
+ if (patches && Object.keys(patches).length > 0) {
83
+ const patchSummary = Object.entries(patches)
84
+ .map(([path, diff]) => `### ${path}\n\`\`\`diff\n${diff.slice(0, 3000)}\n\`\`\``)
85
+ .join("\n\n");
86
+ parts.push(`\n\n<file_diffs>\n${patchSummary}\n</file_diffs>`);
87
+ }
88
+
89
+ for (const msg of chatHistory) {
90
+ if (msg.isCompactSummary) {
91
+ parts.push(`\n[Conversation summary]: ${msg.content}`);
92
+ } else if (msg.role === "user") {
93
+ parts.push(`\nUser: ${msg.content}`);
94
+ } else if (msg.role === "assistant") {
95
+ parts.push(`\nAssistant: ${msg.content}`);
96
+ }
97
+ }
98
+
99
+ return parts.join("\n");
100
+ }
101
+
75
102
  interface SlideJob {
76
103
  status: "running" | "done" | "error";
77
104
  message: string;
@@ -580,6 +607,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
580
607
  const stored = await readStoredConfig();
581
608
  const pluginList = getAllPlugins().map((p) => ({ id: p.id, name: p.name }));
582
609
  const enabledPlugins = stored.enabled_plugins ?? pluginList.map((p) => p.id);
610
+ const agents = await detectAgents();
583
611
  return json({
584
612
  model: config.model,
585
613
  agent: config.agent ?? null,
@@ -588,6 +616,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
588
616
  timeout: config.timeout,
589
617
  concurrency: config.concurrency,
590
618
  has_api_key: !!config.openrouter_api_key,
619
+ has_agent_fallback: agents.length > 0,
591
620
  has_github_token: !!token,
592
621
  enabled_plugins: enabledPlugins,
593
622
  available_plugins: pluginList,
@@ -885,10 +914,6 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
885
914
  const segments = url.pathname.split("/");
886
915
  const sessionId = segments[3]!;
887
916
 
888
- if (!config.openrouter_api_key) {
889
- return json({ error: "OpenRouter API key required" }, 400);
890
- }
891
-
892
917
  const body = await req.json() as { message: string };
893
918
  if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
894
919
 
@@ -908,31 +933,44 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
908
933
  controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
909
934
  };
910
935
  try {
911
- await chatWithTools(
912
- { api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout },
913
- apiMessages as Parameters<typeof chatWithTools>[1],
914
- buildChatTools(),
915
- async (name: string, args: Record<string, unknown>): Promise<string> => {
916
- if (name === "get_file_diff") {
917
- const filePath = args.path as string;
918
- if (!filePath) return "Error: path required";
919
- const patches = await loadPatchesSidecar(sessionId);
920
- if (patches?.[filePath]) return patches[filePath];
921
- const patch = await loadSinglePatch(sessionId, filePath);
922
- if (patch) return patch;
923
- return `File "${filePath}" not found`;
924
- }
925
- if (name === "list_files") {
926
- return sessionData.files.map((f) => `${f.path} (${f.status}): ${f.summary}`).join("\n");
927
- }
928
- return `Tool ${name} not available in inline mode`;
929
- },
930
- (event: ChatStreamEvent) => {
931
- if (event.type === "text") send("text", JSON.stringify({ content: event.content }));
932
- else if (event.type === "error") send("chat_error", JSON.stringify({ message: event.error }));
933
- else if (event.type === "done") send("done", JSON.stringify({}));
934
- },
935
- );
936
+ if (config.openrouter_api_key) {
937
+ await chatWithTools(
938
+ { api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout },
939
+ apiMessages as Parameters<typeof chatWithTools>[1],
940
+ buildChatTools(),
941
+ async (name: string, args: Record<string, unknown>): Promise<string> => {
942
+ if (name === "get_file_diff") {
943
+ const filePath = args.path as string;
944
+ if (!filePath) return "Error: path required";
945
+ const inlinePatches = await loadPatchesSidecar(sessionId);
946
+ if (inlinePatches?.[filePath]) return inlinePatches[filePath];
947
+ const patch = await loadSinglePatch(sessionId, filePath);
948
+ if (patch) return patch;
949
+ return `File "${filePath}" not found`;
950
+ }
951
+ if (name === "list_files") {
952
+ return sessionData.files.map((f) => `${f.path} (${f.status}): ${f.summary}`).join("\n");
953
+ }
954
+ return `Tool ${name} not available in inline mode`;
955
+ },
956
+ (event: ChatStreamEvent) => {
957
+ if (event.type === "text") send("text", JSON.stringify({ content: event.content }));
958
+ else if (event.type === "error") send("chat_error", JSON.stringify({ message: event.error }));
959
+ else if (event.type === "done") send("done", JSON.stringify({}));
960
+ },
961
+ );
962
+ } else {
963
+ const llm = createLlmClient({ api_key: "", model: config.model, timeout: config.timeout });
964
+ const inlinePatches = await loadPatchesSidecar(sessionId);
965
+ const fallbackPrompt = buildFallbackPrompt(systemPrompt, [{ role: "user", content: body.message.trim(), timestamp: new Date().toISOString() }], inlinePatches);
966
+ await llm.completeStream(
967
+ "You are a helpful PR review assistant. Answer based on the provided context.",
968
+ fallbackPrompt,
969
+ (chunk: string) => {
970
+ send("text", JSON.stringify({ content: chunk }));
971
+ },
972
+ );
973
+ }
936
974
  send("done", JSON.stringify({}));
937
975
  } catch (err) {
938
976
  send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
@@ -1010,10 +1048,6 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1010
1048
  const segments = url.pathname.split("/");
1011
1049
  const sessionId = segments[3]!;
1012
1050
 
1013
- if (!config.openrouter_api_key) {
1014
- return json({ error: "OpenRouter API key required for chat" }, 400);
1015
- }
1016
-
1017
1051
  const body = await req.json() as { message: string };
1018
1052
  if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
1019
1053
 
@@ -1046,7 +1080,6 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1046
1080
 
1047
1081
  try {
1048
1082
  const compactPrompt = `Summarize the following conversation concisely for continuation. Focus on: what was discussed, key decisions made, actions taken (tool calls and their outcomes), and any unresolved topics. Be thorough but concise.\n\n${summaryLines.join("\n")}`;
1049
- const { createLlmClient } = require("../../llm/client.ts") as typeof import("../../llm/client.ts");
1050
1083
  const llm = createLlmClient({ api_key: config.openrouter_api_key, model: config.model, timeout: config.timeout });
1051
1084
  const result = await llm.complete("You are a conversation summarizer. Output a concise summary.", compactPrompt);
1052
1085
 
@@ -1377,64 +1410,83 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1377
1410
  let lastSegmentWasText = false;
1378
1411
 
1379
1412
  try {
1380
- await chatWithTools(
1381
- {
1382
- api_key: config.openrouter_api_key,
1383
- model: config.model,
1384
- timeout: config.timeout,
1385
- },
1386
- apiMessages as Parameters<typeof chatWithTools>[1],
1387
- chatTools,
1388
- executeTool,
1389
- (event: ChatStreamEvent) => {
1390
- switch (event.type) {
1391
- case "text":
1392
- fullText += event.content ?? "";
1393
- if (lastSegmentWasText && orderedSegments.length > 0) {
1394
- const last = orderedSegments[orderedSegments.length - 1]!;
1395
- if (last.type === "text") {
1396
- last.content += event.content ?? "";
1413
+ if (config.openrouter_api_key) {
1414
+ await chatWithTools(
1415
+ {
1416
+ api_key: config.openrouter_api_key,
1417
+ model: config.model,
1418
+ timeout: config.timeout,
1419
+ },
1420
+ apiMessages as Parameters<typeof chatWithTools>[1],
1421
+ chatTools,
1422
+ executeTool,
1423
+ (event: ChatStreamEvent) => {
1424
+ switch (event.type) {
1425
+ case "text":
1426
+ fullText += event.content ?? "";
1427
+ if (lastSegmentWasText && orderedSegments.length > 0) {
1428
+ const last = orderedSegments[orderedSegments.length - 1]!;
1429
+ if (last.type === "text") {
1430
+ last.content += event.content ?? "";
1431
+ }
1432
+ } else {
1433
+ orderedSegments.push({ type: "text", content: event.content ?? "" });
1434
+ lastSegmentWasText = true;
1397
1435
  }
1398
- } else {
1399
- orderedSegments.push({ type: "text", content: event.content ?? "" });
1400
- lastSegmentWasText = true;
1401
- }
1402
- send("text", JSON.stringify({ content: event.content }));
1403
- break;
1404
- case "tool_call":
1405
- if (event.toolCall) {
1406
- let args: Record<string, unknown> = {};
1407
- try { args = JSON.parse(event.toolCall.arguments); } catch {}
1408
- const tc: ChatToolCall = {
1409
- id: event.toolCall.id,
1410
- name: event.toolCall.name,
1411
- arguments: args,
1412
- };
1413
- collectedToolCalls.push(tc);
1414
- orderedSegments.push({ type: "tool_call", toolCall: tc });
1415
- lastSegmentWasText = false;
1416
- send("tool_call", JSON.stringify({
1417
- id: event.toolCall.id,
1418
- name: event.toolCall.name,
1419
- arguments: args,
1420
- }));
1421
- }
1422
- break;
1423
- case "tool_result":
1424
- if (event.toolResult) {
1425
- const tc = collectedToolCalls.find((c) => c.id === event.toolResult!.id);
1426
- if (tc) tc.result = event.toolResult.result;
1427
- send("tool_result", JSON.stringify(event.toolResult));
1428
- }
1429
- break;
1430
- case "error":
1431
- send("chat_error", JSON.stringify({ message: event.error }));
1432
- break;
1433
- case "done":
1434
- break;
1435
- }
1436
- },
1437
- );
1436
+ send("text", JSON.stringify({ content: event.content }));
1437
+ break;
1438
+ case "tool_call":
1439
+ if (event.toolCall) {
1440
+ let args: Record<string, unknown> = {};
1441
+ try { args = JSON.parse(event.toolCall.arguments); } catch {}
1442
+ const tc: ChatToolCall = {
1443
+ id: event.toolCall.id,
1444
+ name: event.toolCall.name,
1445
+ arguments: args,
1446
+ };
1447
+ collectedToolCalls.push(tc);
1448
+ orderedSegments.push({ type: "tool_call", toolCall: tc });
1449
+ lastSegmentWasText = false;
1450
+ send("tool_call", JSON.stringify({
1451
+ id: event.toolCall.id,
1452
+ name: event.toolCall.name,
1453
+ arguments: args,
1454
+ }));
1455
+ }
1456
+ break;
1457
+ case "tool_result":
1458
+ if (event.toolResult) {
1459
+ const tc = collectedToolCalls.find((c) => c.id === event.toolResult!.id);
1460
+ if (tc) tc.result = event.toolResult.result;
1461
+ send("tool_result", JSON.stringify(event.toolResult));
1462
+ }
1463
+ break;
1464
+ case "error":
1465
+ send("chat_error", JSON.stringify({ message: event.error }));
1466
+ break;
1467
+ case "done":
1468
+ break;
1469
+ }
1470
+ },
1471
+ );
1472
+ } else {
1473
+ const llm = createLlmClient({ api_key: "", model: config.model, timeout: config.timeout });
1474
+ const prompt = buildFallbackPrompt(
1475
+ systemPrompt,
1476
+ chatHistory,
1477
+ patches,
1478
+ );
1479
+ const result = await llm.completeStream(
1480
+ "You are a helpful PR review assistant. Answer based on the provided context.",
1481
+ prompt,
1482
+ (chunk: string) => {
1483
+ fullText += chunk;
1484
+ send("text", JSON.stringify({ content: chunk }));
1485
+ },
1486
+ );
1487
+ fullText = result.content;
1488
+ orderedSegments.push({ type: "text", content: fullText });
1489
+ }
1438
1490
 
1439
1491
  const assistantMsg: ChatMessage = {
1440
1492
  role: "assistant",