@in-the-loop-labs/pair-review 1.6.2 → 2.0.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/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- package/src/routes/comments.js +0 -534
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Chat Routes
|
|
4
|
+
*
|
|
5
|
+
* Handles chat session endpoints:
|
|
6
|
+
* - Creating chat sessions
|
|
7
|
+
* - Sending messages
|
|
8
|
+
* - SSE streaming for real-time responses
|
|
9
|
+
* - Message history
|
|
10
|
+
* - Closing sessions
|
|
11
|
+
* - Listing sessions for a review
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const express = require('express');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { queryOne, query, AnalysisRunRepository, RepoSettingsRepository } = require('../database');
|
|
17
|
+
const { buildChatPrompt, buildInitialContext } = require('../chat/prompt-builder');
|
|
18
|
+
const { GitWorktreeManager } = require('../git/worktree');
|
|
19
|
+
const logger = require('../utils/logger');
|
|
20
|
+
const { sseClients } = require('../sse/review-events');
|
|
21
|
+
|
|
22
|
+
const pairReviewSkillPath = path.resolve(__dirname, '../../.pi/skills/pair-review-api/SKILL.md');
|
|
23
|
+
|
|
24
|
+
const router = express.Router();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the working directory for a chat session.
|
|
28
|
+
* - Local reviews: use review.local_path (the git root being reviewed)
|
|
29
|
+
* - PR reviews: look up the worktree path from the worktrees table
|
|
30
|
+
* @param {Object} db - Database instance
|
|
31
|
+
* @param {Object} review - Review record from the database
|
|
32
|
+
* @returns {Promise<string|null>} Absolute path to the code directory, or null
|
|
33
|
+
*/
|
|
34
|
+
async function resolveReviewCwd(db, review) {
|
|
35
|
+
// Local reviews store the path directly
|
|
36
|
+
if (review.local_path) {
|
|
37
|
+
return review.local_path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// PR reviews: resolve worktree via the worktree manager
|
|
41
|
+
if (review.pr_number && review.repository) {
|
|
42
|
+
const [owner, repo] = review.repository.split('/');
|
|
43
|
+
if (owner && repo) {
|
|
44
|
+
const worktreeManager = new GitWorktreeManager(db);
|
|
45
|
+
return worktreeManager.getWorktreePath({ owner, repo, number: review.pr_number });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch PR data (base_sha, head_sha) from pr_metadata for a PR review.
|
|
54
|
+
* Returns null for local reviews or if pr_data is unavailable.
|
|
55
|
+
* @param {Object} db - Database instance
|
|
56
|
+
* @param {Object} review - Review record from the database
|
|
57
|
+
* @returns {Promise<Object|null>} Parsed PR data with base_sha/head_sha, or null
|
|
58
|
+
*/
|
|
59
|
+
async function fetchPrData(db, review) {
|
|
60
|
+
if (review.review_type === 'local' || !review.pr_number || !review.repository) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const row = await queryOne(db, `
|
|
64
|
+
SELECT pr_data FROM pr_metadata
|
|
65
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
66
|
+
`, [review.pr_number, review.repository]);
|
|
67
|
+
if (row?.pr_data) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(row.pr_data);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Unsubscribe functions for SSE broadcast listeners, keyed by session ID.
|
|
79
|
+
* Each value is an array of unsubscribe functions returned by the on* methods.
|
|
80
|
+
* Used to clean up listeners when a session is closed.
|
|
81
|
+
* @type {Map<number, function[]>}
|
|
82
|
+
*/
|
|
83
|
+
const sseUnsubscribers = new Map();
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a regex that matches bash commands curling the pair-review
|
|
87
|
+
* server's own API on a specific port. The port is required so we
|
|
88
|
+
* don't accidentally suppress tool badges for unrelated local services.
|
|
89
|
+
* @param {number} port - The server's listening port
|
|
90
|
+
* @returns {RegExp}
|
|
91
|
+
*/
|
|
92
|
+
function buildPairReviewApiRe(port) {
|
|
93
|
+
return new RegExp(`\\bcurl\\b.*\\bhttps?://(?:localhost|127\\.0\\.0\\.1):${port}/api/`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Broadcast an SSE event to all connected clients.
|
|
98
|
+
* @param {number} sessionId - Chat session ID to include in the event
|
|
99
|
+
* @param {Object} payload - Event data (will be merged with sessionId)
|
|
100
|
+
*/
|
|
101
|
+
function broadcastSSE(sessionId, payload) {
|
|
102
|
+
const data = JSON.stringify({ ...payload, sessionId });
|
|
103
|
+
for (const client of sseClients) {
|
|
104
|
+
try {
|
|
105
|
+
client.write(`data: ${data}\n\n`);
|
|
106
|
+
} catch {
|
|
107
|
+
// Client disconnected — remove from set
|
|
108
|
+
sseClients.delete(client);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Register SSE broadcast listeners on a chat session so that all events
|
|
115
|
+
* (delta, tool_use, complete, status, error) are forwarded to connected SSE clients.
|
|
116
|
+
* @param {Object} chatSessionManager
|
|
117
|
+
* @param {number} sessionId
|
|
118
|
+
* @param {number} port - The server's listening port (used to scope API-call suppression)
|
|
119
|
+
*/
|
|
120
|
+
function registerSSEBroadcast(chatSessionManager, sessionId, port) {
|
|
121
|
+
// Guard against double-registration
|
|
122
|
+
if (sseUnsubscribers.has(sessionId)) {
|
|
123
|
+
logger.debug(`[ChatRoute] SSE broadcast already registered for session ${sessionId}, skipping`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const unsubs = [];
|
|
129
|
+
|
|
130
|
+
unsubs.push(chatSessionManager.onDelta(sessionId, (data) => {
|
|
131
|
+
broadcastSSE(sessionId, { type: 'delta', text: data.text });
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
const hiddenToolCallIds = new Set();
|
|
135
|
+
const pairReviewApiRe = buildPairReviewApiRe(port);
|
|
136
|
+
|
|
137
|
+
unsubs.push(chatSessionManager.onToolUse(sessionId, (data) => {
|
|
138
|
+
// Suppress tool badges for curl commands hitting the pair-review API
|
|
139
|
+
if (data.toolName?.toLowerCase() === 'bash') {
|
|
140
|
+
if (data.status === 'start' && pairReviewApiRe.test(data.args?.command || '')) {
|
|
141
|
+
hiddenToolCallIds.add(data.toolCallId);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (hiddenToolCallIds.has(data.toolCallId)) {
|
|
145
|
+
if (data.status === 'end') hiddenToolCallIds.delete(data.toolCallId);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Suppress tool badges for reading the API skill file
|
|
151
|
+
if (data.toolName?.toLowerCase() === 'read') {
|
|
152
|
+
const readPath = data.args?.path || data.args?.file_path || '';
|
|
153
|
+
if (readPath.endsWith('pair-review-api/SKILL.md')) {
|
|
154
|
+
hiddenToolCallIds.add(data.toolCallId);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Suppress follow-up events (update/end) for any hidden tool call
|
|
160
|
+
if (hiddenToolCallIds.has(data.toolCallId)) {
|
|
161
|
+
if (data.status === 'end') hiddenToolCallIds.delete(data.toolCallId);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const event = { type: 'tool_use', toolName: data.toolName, status: data.status };
|
|
166
|
+
if (data.args) {
|
|
167
|
+
event.toolInput = data.args;
|
|
168
|
+
}
|
|
169
|
+
broadcastSSE(sessionId, event);
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
unsubs.push(chatSessionManager.onComplete(sessionId, (data) => {
|
|
173
|
+
logger.debug(`[ChatRoute] SSE broadcast complete for session ${sessionId}, messageId=${data.messageId}`);
|
|
174
|
+
broadcastSSE(sessionId, { type: 'complete', messageId: data.messageId });
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
unsubs.push(chatSessionManager.onStatus(sessionId, (data) => {
|
|
178
|
+
broadcastSSE(sessionId, { type: 'status', status: data.status });
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
unsubs.push(chatSessionManager.onError(sessionId, (data) => {
|
|
182
|
+
logger.debug(`[ChatRoute] SSE broadcast error for session ${sessionId}: ${data.message}`);
|
|
183
|
+
broadcastSSE(sessionId, { type: 'error', message: data.message });
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
sseUnsubscribers.set(sessionId, unsubs);
|
|
187
|
+
logger.debug(`[ChatRoute] SSE broadcast listeners registered for session ${sessionId}`);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logger.warn(`[ChatRoute] Failed to register SSE broadcast for session ${sessionId}: ${err.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Unsubscribe all SSE broadcast listeners for a session.
|
|
195
|
+
* @param {number} sessionId
|
|
196
|
+
*/
|
|
197
|
+
function unregisterSSEBroadcast(sessionId) {
|
|
198
|
+
const unsubs = sseUnsubscribers.get(sessionId);
|
|
199
|
+
if (unsubs) {
|
|
200
|
+
for (const unsub of unsubs) {
|
|
201
|
+
try { unsub(); } catch { /* session may already be closed */ }
|
|
202
|
+
}
|
|
203
|
+
sseUnsubscribers.delete(sessionId);
|
|
204
|
+
logger.debug(`[ChatRoute] SSE broadcast listeners unregistered for session ${sessionId}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Fetch chat instructions from repo settings for a review.
|
|
210
|
+
* @param {Database} db - Database instance
|
|
211
|
+
* @param {Object} review - Review record with repository field
|
|
212
|
+
* @returns {Promise<string|null>} Chat instructions or null
|
|
213
|
+
*/
|
|
214
|
+
async function getChatInstructions(db, review) {
|
|
215
|
+
if (!review || !review.repository) return null;
|
|
216
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
217
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
|
|
218
|
+
return repoSettings ? repoSettings.default_chat_instructions : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Create a new chat session
|
|
223
|
+
*/
|
|
224
|
+
router.post('/api/chat/session', async (req, res) => {
|
|
225
|
+
try {
|
|
226
|
+
// contextCommentId: stored in session metadata (no longer used for prompt enrichment)
|
|
227
|
+
const { provider, model, contextCommentId, systemPrompt, cwd, skipAnalysisContext } = req.body || {};
|
|
228
|
+
const reviewId = parseInt(req.body?.reviewId, 10);
|
|
229
|
+
|
|
230
|
+
if (!provider || !reviewId || isNaN(reviewId)) {
|
|
231
|
+
return res.status(400).json({
|
|
232
|
+
error: 'Missing required fields: provider, reviewId'
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
237
|
+
const db = req.app.get('db');
|
|
238
|
+
|
|
239
|
+
// Always load the review so we can resolve the worktree CWD
|
|
240
|
+
const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [reviewId]);
|
|
241
|
+
if (!review) {
|
|
242
|
+
return res.status(404).json({ error: 'Review not found' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Build system prompt if not provided directly
|
|
246
|
+
let finalSystemPrompt = systemPrompt;
|
|
247
|
+
let initialContext = null;
|
|
248
|
+
let suggestions = null;
|
|
249
|
+
let analysisRun = null;
|
|
250
|
+
|
|
251
|
+
if (!finalSystemPrompt) {
|
|
252
|
+
|
|
253
|
+
const chatInstructions = await getChatInstructions(db, review);
|
|
254
|
+
const prData = await fetchPrData(db, review);
|
|
255
|
+
|
|
256
|
+
finalSystemPrompt = buildChatPrompt({ review, prData, skillPath: pairReviewSkillPath, chatInstructions });
|
|
257
|
+
|
|
258
|
+
if (!skipAnalysisContext) {
|
|
259
|
+
// Fetch all AI suggestions from the latest analysis run
|
|
260
|
+
suggestions = await query(db, `
|
|
261
|
+
SELECT
|
|
262
|
+
id, ai_run_id, ai_level, ai_confidence,
|
|
263
|
+
file, line_start, line_end, type, title, body,
|
|
264
|
+
reasoning, status, is_file_level
|
|
265
|
+
FROM comments
|
|
266
|
+
WHERE review_id = ?
|
|
267
|
+
AND source = 'ai'
|
|
268
|
+
-- ai_level IS NULL = orchestrated/final suggestions only
|
|
269
|
+
-- TODO: If single-level results can be saved without orchestration,
|
|
270
|
+
-- we may need an \`is_final\` flag to identify displayable suggestions.
|
|
271
|
+
AND ai_level IS NULL
|
|
272
|
+
AND (is_raw = 0 OR is_raw IS NULL)
|
|
273
|
+
AND ai_run_id = (
|
|
274
|
+
SELECT ai_run_id FROM comments
|
|
275
|
+
WHERE review_id = ? AND source = 'ai' AND ai_run_id IS NOT NULL
|
|
276
|
+
ORDER BY created_at DESC
|
|
277
|
+
LIMIT 1
|
|
278
|
+
)
|
|
279
|
+
ORDER BY file, line_start
|
|
280
|
+
`, [reviewId, reviewId]);
|
|
281
|
+
|
|
282
|
+
// Fetch the analysis run record for metadata and summary
|
|
283
|
+
if (suggestions && suggestions.length > 0 && suggestions[0].ai_run_id) {
|
|
284
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
285
|
+
analysisRun = await analysisRunRepo.getById(suggestions[0].ai_run_id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
initialContext = buildInitialContext({
|
|
289
|
+
suggestions,
|
|
290
|
+
analysisRun
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Resolve cwd: explicit from request body, or the review's code directory
|
|
296
|
+
const resolvedCwd = cwd || await resolveReviewCwd(db, review);
|
|
297
|
+
|
|
298
|
+
// Inject the server port into the initial context so the agent learns it
|
|
299
|
+
// once at session start. This avoids wasting tokens by repeating the port
|
|
300
|
+
// with every user message. If the server restarts on a new port, the next
|
|
301
|
+
// session will pick up the new value automatically.
|
|
302
|
+
const serverPort = req.socket.localPort;
|
|
303
|
+
const portContext = `[Server port: ${serverPort}] The pair-review API is at http://localhost:${serverPort}`;
|
|
304
|
+
const initialContextWithPort = initialContext
|
|
305
|
+
? portContext + '\n\n' + initialContext
|
|
306
|
+
: portContext;
|
|
307
|
+
|
|
308
|
+
const session = await chatSessionManager.createSession({
|
|
309
|
+
provider,
|
|
310
|
+
model,
|
|
311
|
+
reviewId,
|
|
312
|
+
contextCommentId: contextCommentId || null,
|
|
313
|
+
systemPrompt: finalSystemPrompt,
|
|
314
|
+
cwd: resolvedCwd,
|
|
315
|
+
initialContext: initialContextWithPort
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
logger.info(`Chat session created: ${session.id} (provider=${provider}, model=${model})`);
|
|
319
|
+
|
|
320
|
+
// Register SSE broadcast listeners so events reach all connected clients
|
|
321
|
+
registerSSEBroadcast(chatSessionManager, session.id, serverPort);
|
|
322
|
+
|
|
323
|
+
const responseData = { id: session.id, status: session.status };
|
|
324
|
+
|
|
325
|
+
// Include analysis context metadata so the frontend can show a context indicator
|
|
326
|
+
if (initialContext && suggestions && suggestions.length > 0) {
|
|
327
|
+
responseData.context = {
|
|
328
|
+
suggestionCount: suggestions.length,
|
|
329
|
+
aiRunId: suggestions[0].ai_run_id || null
|
|
330
|
+
};
|
|
331
|
+
// Attach run metadata for richer frontend display
|
|
332
|
+
if (analysisRun) {
|
|
333
|
+
responseData.context.provider = analysisRun.provider || null;
|
|
334
|
+
responseData.context.model = analysisRun.model || null;
|
|
335
|
+
responseData.context.summary = analysisRun.summary || null;
|
|
336
|
+
responseData.context.completedAt = analysisRun.completed_at || null;
|
|
337
|
+
responseData.context.configType = analysisRun.config_type || null;
|
|
338
|
+
responseData.context.parentRunId = analysisRun.parent_run_id || null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
res.json({ data: responseData });
|
|
343
|
+
} catch (error) {
|
|
344
|
+
logger.error(`Error creating chat session: ${error.message}`);
|
|
345
|
+
res.status(500).json({ error: 'Failed to create chat session' });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Send a user message to a chat session (auto-resumes if needed)
|
|
351
|
+
*/
|
|
352
|
+
router.post('/api/chat/session/:id/message', async (req, res) => {
|
|
353
|
+
try {
|
|
354
|
+
const sessionId = parseInt(req.params.id, 10);
|
|
355
|
+
const { content, context, contextData, actionContext } = req.body || {};
|
|
356
|
+
|
|
357
|
+
if (!content) {
|
|
358
|
+
return res.status(400).json({ error: 'Missing required field: content' });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
362
|
+
const db = req.app.get('db');
|
|
363
|
+
|
|
364
|
+
// Auto-resume: if session is not active in memory, try to resume it
|
|
365
|
+
if (!chatSessionManager.isSessionActive(sessionId)) {
|
|
366
|
+
const session = chatSessionManager.getSession(sessionId);
|
|
367
|
+
if (!session) {
|
|
368
|
+
return res.status(404).json({ error: 'Chat session not found' });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!session.agent_session_id) {
|
|
372
|
+
return res.status(410).json({ error: 'Session is not resumable (no session file)' });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Build system prompt and cwd from the review
|
|
376
|
+
const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [session.review_id]);
|
|
377
|
+
if (!review) {
|
|
378
|
+
return res.status(404).json({ error: 'Review not found for session' });
|
|
379
|
+
}
|
|
380
|
+
const chatInstructions = await getChatInstructions(db, review);
|
|
381
|
+
const prData = await fetchPrData(db, review);
|
|
382
|
+
|
|
383
|
+
const systemPrompt = buildChatPrompt({ review, prData, skillPath: pairReviewSkillPath, chatInstructions });
|
|
384
|
+
const cwd = await resolveReviewCwd(db, review);
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
|
|
388
|
+
unregisterSSEBroadcast(sessionId);
|
|
389
|
+
registerSSEBroadcast(chatSessionManager, sessionId, req.socket.localPort);
|
|
390
|
+
logger.info(`[ChatRoute] Auto-resumed session ${sessionId} for message delivery`);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
logger.error(`[ChatRoute] Failed to auto-resume session ${sessionId}: ${err.message}`);
|
|
393
|
+
return res.status(410).json({ error: 'Failed to resume session: ' + err.message });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
logger.debug(`[ChatRoute] Forwarding message to session ${sessionId} (${content.length} chars)`);
|
|
398
|
+
const result = await chatSessionManager.sendMessage(sessionId, content, { context, contextData, actionContext });
|
|
399
|
+
logger.debug(`[ChatRoute] Message stored as ID ${result.id}, awaiting agent response via SSE`);
|
|
400
|
+
res.json({ data: { messageId: result.id } });
|
|
401
|
+
} catch (error) {
|
|
402
|
+
logger.error(`Error sending chat message: ${error.message}`);
|
|
403
|
+
res.status(500).json({ error: 'Failed to send message' });
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Multiplexed SSE stream for all chat sessions.
|
|
409
|
+
* Clients connect once and receive events tagged with sessionId.
|
|
410
|
+
*/
|
|
411
|
+
router.get('/api/chat/stream', (req, res) => {
|
|
412
|
+
// Set up SSE headers
|
|
413
|
+
res.writeHead(200, {
|
|
414
|
+
'Content-Type': 'text/event-stream',
|
|
415
|
+
'Cache-Control': 'no-cache',
|
|
416
|
+
'Connection': 'keep-alive'
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Send initial connection acknowledgement
|
|
420
|
+
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
|
421
|
+
logger.debug(`[ChatRoute] Multiplexed SSE client connected (total: ${sseClients.size + 1})`);
|
|
422
|
+
|
|
423
|
+
sseClients.add(res);
|
|
424
|
+
|
|
425
|
+
// Handle client disconnect
|
|
426
|
+
const cleanup = () => {
|
|
427
|
+
sseClients.delete(res);
|
|
428
|
+
logger.debug(`[ChatRoute] Multiplexed SSE client disconnected (total: ${sseClients.size})`);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
req.on('close', cleanup);
|
|
432
|
+
req.on('error', cleanup);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Abort the current agent turn in a chat session
|
|
437
|
+
*/
|
|
438
|
+
router.post('/api/chat/session/:id/abort', async (req, res) => {
|
|
439
|
+
try {
|
|
440
|
+
const sessionId = parseInt(req.params.id, 10);
|
|
441
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
442
|
+
|
|
443
|
+
if (!chatSessionManager.isSessionActive(sessionId)) {
|
|
444
|
+
return res.status(404).json({ error: 'Chat session not found or not active' });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
chatSessionManager.abortSession(sessionId);
|
|
448
|
+
res.json({ data: { success: true } });
|
|
449
|
+
} catch (error) {
|
|
450
|
+
logger.error(`Error aborting chat session: ${error.message}`);
|
|
451
|
+
res.status(500).json({ error: 'Failed to abort' });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Get message history for a chat session
|
|
457
|
+
*/
|
|
458
|
+
router.get('/api/chat/session/:id/messages', (req, res) => {
|
|
459
|
+
try {
|
|
460
|
+
const sessionId = parseInt(req.params.id, 10);
|
|
461
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
462
|
+
|
|
463
|
+
const session = chatSessionManager.getSession(sessionId);
|
|
464
|
+
if (!session) {
|
|
465
|
+
return res.status(404).json({ error: 'Chat session not found' });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const messages = chatSessionManager.getMessages(sessionId);
|
|
469
|
+
res.json({ data: { messages } });
|
|
470
|
+
} catch (error) {
|
|
471
|
+
logger.error(`Error fetching chat messages: ${error.message}`);
|
|
472
|
+
res.status(500).json({ error: 'Failed to fetch messages' });
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Save a context message to a chat session (e.g., analysis context card).
|
|
478
|
+
* Used to persist context cards immediately without waiting for the next user message.
|
|
479
|
+
*/
|
|
480
|
+
router.post('/api/chat/session/:id/context', (req, res) => {
|
|
481
|
+
try {
|
|
482
|
+
const sessionId = parseInt(req.params.id, 10);
|
|
483
|
+
const { contextData } = req.body || {};
|
|
484
|
+
|
|
485
|
+
if (!contextData) {
|
|
486
|
+
return res.status(400).json({ error: 'Missing required field: contextData' });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
490
|
+
const result = chatSessionManager.saveContextMessage(sessionId, contextData);
|
|
491
|
+
res.json({ data: { messageId: result.id } });
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (error.message.includes('not found')) {
|
|
494
|
+
return res.status(404).json({ error: error.message });
|
|
495
|
+
}
|
|
496
|
+
logger.error(`Error saving context message: ${error.message}`);
|
|
497
|
+
res.status(500).json({ error: 'Failed to save context message' });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Explicitly resume a chat session (pre-warm the bridge before sending a message)
|
|
503
|
+
*/
|
|
504
|
+
router.post('/api/chat/session/:id/resume', async (req, res) => {
|
|
505
|
+
try {
|
|
506
|
+
const sessionId = parseInt(req.params.id, 10);
|
|
507
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
508
|
+
const db = req.app.get('db');
|
|
509
|
+
|
|
510
|
+
// Already active
|
|
511
|
+
if (chatSessionManager.isSessionActive(sessionId)) {
|
|
512
|
+
return res.json({ data: { id: sessionId, status: 'active' } });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const session = chatSessionManager.getSession(sessionId);
|
|
516
|
+
if (!session) {
|
|
517
|
+
return res.status(404).json({ error: 'Chat session not found' });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!session.agent_session_id) {
|
|
521
|
+
return res.status(410).json({ error: 'Session is not resumable (no session file)' });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [session.review_id]);
|
|
525
|
+
if (!review) {
|
|
526
|
+
return res.status(404).json({ error: 'Review not found for session' });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Pi's --session replays the original conversation; --append-system-prompt
|
|
530
|
+
// re-injects the review context so the agent retains awareness of the codebase
|
|
531
|
+
// even if the system prompt was only in the initial session's context.
|
|
532
|
+
const chatInstructions = await getChatInstructions(db, review);
|
|
533
|
+
const prData = await fetchPrData(db, review);
|
|
534
|
+
|
|
535
|
+
const systemPrompt = buildChatPrompt({ review, prData, skillPath: pairReviewSkillPath, chatInstructions });
|
|
536
|
+
const cwd = await resolveReviewCwd(db, review);
|
|
537
|
+
|
|
538
|
+
await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
|
|
539
|
+
unregisterSSEBroadcast(sessionId);
|
|
540
|
+
registerSSEBroadcast(chatSessionManager, sessionId, req.socket.localPort);
|
|
541
|
+
|
|
542
|
+
logger.info(`[ChatRoute] Explicitly resumed session ${sessionId}`);
|
|
543
|
+
res.json({ data: { id: sessionId, status: 'active' } });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
logger.error(`Error resuming chat session: ${error.message}`);
|
|
546
|
+
res.status(500).json({ error: 'Failed to resume session: ' + error.message });
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Close a chat session
|
|
552
|
+
*/
|
|
553
|
+
router.delete('/api/chat/session/:id', async (req, res) => {
|
|
554
|
+
try {
|
|
555
|
+
const sessionId = parseInt(req.params.id, 10);
|
|
556
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
557
|
+
|
|
558
|
+
// Unregister SSE broadcast listeners before closing the session
|
|
559
|
+
unregisterSSEBroadcast(sessionId);
|
|
560
|
+
|
|
561
|
+
await chatSessionManager.closeSession(sessionId);
|
|
562
|
+
logger.info(`Chat session closed: ${sessionId}`);
|
|
563
|
+
res.json({ data: { success: true } });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
logger.error(`Error closing chat session: ${error.message}`);
|
|
566
|
+
res.status(500).json({ error: 'Failed to close chat session' });
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* List chat sessions for a review (with message counts and live state annotations)
|
|
572
|
+
*/
|
|
573
|
+
router.get('/api/review/:reviewId/chat/sessions', (req, res) => {
|
|
574
|
+
try {
|
|
575
|
+
const { reviewId } = req.params;
|
|
576
|
+
const chatSessionManager = req.app.chatSessionManager;
|
|
577
|
+
|
|
578
|
+
const sessions = chatSessionManager.getSessionsWithMessageCount(parseInt(reviewId, 10));
|
|
579
|
+
|
|
580
|
+
// Annotate each session with live state
|
|
581
|
+
const annotated = sessions.map((s) => ({
|
|
582
|
+
...s,
|
|
583
|
+
isActive: chatSessionManager.isSessionActive(s.id),
|
|
584
|
+
isResumable: !chatSessionManager.isSessionActive(s.id) && !!s.agent_session_id
|
|
585
|
+
}));
|
|
586
|
+
|
|
587
|
+
res.json({ data: { sessions: annotated } });
|
|
588
|
+
} catch (error) {
|
|
589
|
+
logger.error(`Error fetching chat sessions: ${error.message}`);
|
|
590
|
+
res.status(500).json({ error: 'Failed to fetch chat sessions' });
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get formatted analysis context for a specific run.
|
|
596
|
+
* Returns context text and run metadata so the chat panel can add it as pending context.
|
|
597
|
+
*/
|
|
598
|
+
router.get('/api/chat/analysis-context/:runId', async (req, res) => {
|
|
599
|
+
try {
|
|
600
|
+
const { runId } = req.params;
|
|
601
|
+
const reviewId = parseInt(req.query.reviewId, 10);
|
|
602
|
+
|
|
603
|
+
if (!runId || !reviewId || isNaN(reviewId)) {
|
|
604
|
+
return res.status(400).json({ error: 'Missing required params: runId (path) and reviewId (query)' });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const db = req.app.get('db');
|
|
608
|
+
|
|
609
|
+
// Fetch AI suggestions for this specific run (top-level only: ai_level IS NULL)
|
|
610
|
+
const suggestions = await query(db, `
|
|
611
|
+
SELECT
|
|
612
|
+
id, ai_run_id, ai_level, ai_confidence,
|
|
613
|
+
file, line_start, line_end, type, title, body,
|
|
614
|
+
reasoning, status, is_file_level
|
|
615
|
+
FROM comments
|
|
616
|
+
WHERE review_id = ?
|
|
617
|
+
AND source = 'ai'
|
|
618
|
+
AND ai_level IS NULL
|
|
619
|
+
AND (is_raw = 0 OR is_raw IS NULL)
|
|
620
|
+
AND ai_run_id = ?
|
|
621
|
+
ORDER BY file, line_start
|
|
622
|
+
`, [reviewId, runId]);
|
|
623
|
+
|
|
624
|
+
// Fetch the analysis run record for metadata
|
|
625
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
626
|
+
const analysisRun = await analysisRunRepo.getById(runId);
|
|
627
|
+
|
|
628
|
+
const text = buildInitialContext({ suggestions, analysisRun });
|
|
629
|
+
|
|
630
|
+
res.json({
|
|
631
|
+
data: {
|
|
632
|
+
text,
|
|
633
|
+
suggestionCount: suggestions ? suggestions.length : 0,
|
|
634
|
+
run: analysisRun ? {
|
|
635
|
+
id: analysisRun.id,
|
|
636
|
+
provider: analysisRun.provider || null,
|
|
637
|
+
model: analysisRun.model || null,
|
|
638
|
+
summary: analysisRun.summary || null,
|
|
639
|
+
completedAt: analysisRun.completed_at || null,
|
|
640
|
+
configType: analysisRun.config_type || null
|
|
641
|
+
} : null
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
} catch (error) {
|
|
645
|
+
logger.error(`Error fetching analysis context: ${error.message}`);
|
|
646
|
+
res.status(500).json({ error: 'Failed to fetch analysis context' });
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
module.exports = router;
|
|
651
|
+
|
|
652
|
+
// Expose internals for testing
|
|
653
|
+
module.exports._sseClients = sseClients;
|
|
654
|
+
module.exports._sseUnsubscribers = sseUnsubscribers;
|
|
655
|
+
module.exports._buildPairReviewApiRe = buildPairReviewApiRe;
|