@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,619 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Chat Session Manager
|
|
4
|
+
*
|
|
5
|
+
* Manages active chat sessions, each backed by a Pi RPC bridge process.
|
|
6
|
+
* Handles session lifecycle (create, message, close), persistence to SQLite,
|
|
7
|
+
* and event dispatch (delta, complete, tool_use) to registered listeners.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const PiBridge = require('./pi-bridge');
|
|
13
|
+
const logger = require('../utils/logger');
|
|
14
|
+
|
|
15
|
+
const pairReviewSkillPath = path.resolve(__dirname, '../../.pi/skills/pair-review-api/SKILL.md');
|
|
16
|
+
const taskExtensionDir = path.resolve(__dirname, '../../.pi/extensions/task');
|
|
17
|
+
|
|
18
|
+
const CHAT_TOOLS = 'read,bash,grep,find,ls';
|
|
19
|
+
|
|
20
|
+
class ChatSessionManager {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Database} db - better-sqlite3 database instance
|
|
23
|
+
*/
|
|
24
|
+
constructor(db) {
|
|
25
|
+
this._db = db;
|
|
26
|
+
this._sessions = new Map(); // sessionId -> { bridge, listeners }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a new chat session and spawn the agent process.
|
|
31
|
+
* @param {Object} options
|
|
32
|
+
* @param {string} options.provider - 'pi' (and later 'claude')
|
|
33
|
+
* @param {string} [options.model] - Model ID
|
|
34
|
+
* @param {number} options.reviewId - Review ID
|
|
35
|
+
* @param {number} [options.contextCommentId] - Optional suggestion ID that triggered chat
|
|
36
|
+
* @param {string} [options.systemPrompt] - System prompt text
|
|
37
|
+
* @param {string} [options.cwd] - Working directory for agent
|
|
38
|
+
* @param {string} [options.initialContext] - Initial context to prepend to the first user message
|
|
39
|
+
* @returns {Promise<{id: number, status: string}>}
|
|
40
|
+
*/
|
|
41
|
+
async createSession({ provider, model, reviewId, contextCommentId, systemPrompt, cwd, initialContext }) {
|
|
42
|
+
// Insert session record into DB
|
|
43
|
+
const stmt = this._db.prepare(`
|
|
44
|
+
INSERT INTO chat_sessions (review_id, context_comment_id, provider, model, status)
|
|
45
|
+
VALUES (?, ?, ?, ?, 'active')
|
|
46
|
+
`);
|
|
47
|
+
const result = stmt.run(
|
|
48
|
+
reviewId,
|
|
49
|
+
contextCommentId || null,
|
|
50
|
+
provider,
|
|
51
|
+
model || null
|
|
52
|
+
);
|
|
53
|
+
const sessionId = Number(result.lastInsertRowid);
|
|
54
|
+
|
|
55
|
+
logger.info(`[ChatSession] Creating session ${sessionId} (provider=${provider}, review=${reviewId})`);
|
|
56
|
+
|
|
57
|
+
// Create and start the bridge
|
|
58
|
+
// Chat sessions get bash for git commands; review analysis uses the safe default
|
|
59
|
+
const bridge = new PiBridge({
|
|
60
|
+
provider,
|
|
61
|
+
model,
|
|
62
|
+
cwd,
|
|
63
|
+
systemPrompt,
|
|
64
|
+
tools: CHAT_TOOLS,
|
|
65
|
+
skills: [pairReviewSkillPath],
|
|
66
|
+
extensions: [taskExtensionDir]
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const listeners = {
|
|
70
|
+
delta: new Set(),
|
|
71
|
+
complete: new Set(),
|
|
72
|
+
toolUse: new Set(),
|
|
73
|
+
status: new Set(),
|
|
74
|
+
error: new Set()
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Store in map before starting so event handlers can find it
|
|
78
|
+
this._sessions.set(sessionId, { bridge, listeners, initialContext: initialContext || null });
|
|
79
|
+
|
|
80
|
+
// Wire up bridge events
|
|
81
|
+
this._wireBridgeEvents(sessionId, bridge, listeners);
|
|
82
|
+
|
|
83
|
+
// Start the bridge process
|
|
84
|
+
try {
|
|
85
|
+
await bridge.start();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// Bridge failed to start — clean up
|
|
88
|
+
this._sessions.delete(sessionId);
|
|
89
|
+
this._db.prepare(`
|
|
90
|
+
UPDATE chat_sessions SET status = 'error', updated_at = CURRENT_TIMESTAMP
|
|
91
|
+
WHERE id = ?
|
|
92
|
+
`).run(sessionId);
|
|
93
|
+
logger.error(`[ChatSession] Failed to start bridge for session ${sessionId}: ${err.message}`);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
logger.info(`[ChatSession] Session ${sessionId} active`);
|
|
98
|
+
return { id: sessionId, status: 'active' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Send a user message to an active session.
|
|
103
|
+
* Stores the user message in DB, forwards to bridge, and returns.
|
|
104
|
+
* Bridge will emit 'delta' and 'complete' events that route handlers listen to.
|
|
105
|
+
* @param {number} sessionId - Chat session ID
|
|
106
|
+
* @param {string} content - User message text
|
|
107
|
+
* @param {Object} [options]
|
|
108
|
+
* @param {string} [options.context] - Per-message context to prepend (e.g., focused suggestion details).
|
|
109
|
+
* Sent to the agent but NOT stored in DB as part of the user message.
|
|
110
|
+
* @param {Object} [options.contextData] - Structured context data (JSON-serializable) to persist
|
|
111
|
+
* in DB as a separate 'context' type message for session resumption UI reconstruction.
|
|
112
|
+
* @returns {Promise<{id: number}>} The stored message ID
|
|
113
|
+
*/
|
|
114
|
+
async sendMessage(sessionId, content, { context, contextData, actionContext } = {}) {
|
|
115
|
+
const session = this._sessions.get(sessionId);
|
|
116
|
+
if (!session) {
|
|
117
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!session.bridge.isReady()) {
|
|
121
|
+
throw new Error(`Session ${sessionId} bridge is not ready`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (session.bridge.isBusy()) {
|
|
125
|
+
throw new Error(`Session ${sessionId} is currently processing a message`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Build the message for the agent: initialContext > context > userMessage
|
|
129
|
+
// (broad session context first, then narrow per-message context, then user text)
|
|
130
|
+
let messageForAgent = content;
|
|
131
|
+
|
|
132
|
+
// Prepend per-message context first (focused suggestion — closer to user message)
|
|
133
|
+
if (context) {
|
|
134
|
+
messageForAgent = context + '\n\n---\n\n' + messageForAgent;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Then prepend initial session context (all suggestions — outermost)
|
|
138
|
+
if (session.initialContext) {
|
|
139
|
+
messageForAgent = session.initialContext + '\n\n---\n\n' + messageForAgent;
|
|
140
|
+
session.initialContext = null; // Only prepend once
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Inject action context (from action bar buttons: adopt, update, dismiss).
|
|
144
|
+
// IMPORTANT: Item IDs are passed here as structured metadata for the agent only.
|
|
145
|
+
// They must NEVER appear in user-visible message text (inputEl.value).
|
|
146
|
+
// The frontend sets _pendingActionContext synchronously before sendMessage(),
|
|
147
|
+
// which consumes and clears it before the async fetch — no race window.
|
|
148
|
+
if (actionContext && actionContext.type && actionContext.itemId) {
|
|
149
|
+
const actionHint = `[Action: ${actionContext.type}, target ID: ${actionContext.itemId}]`;
|
|
150
|
+
messageForAgent = messageForAgent + '\n\n' + actionHint;
|
|
151
|
+
} else if (actionContext && actionContext.type === 'create-comment' && actionContext.file) {
|
|
152
|
+
const lineSpec = (!actionContext.line_end || actionContext.line_start === actionContext.line_end)
|
|
153
|
+
? `${actionContext.line_start}`
|
|
154
|
+
: `${actionContext.line_start}-${actionContext.line_end}`;
|
|
155
|
+
const actionHint = `[Action: create-comment, file: ${actionContext.file}, lines: ${lineSpec}]`;
|
|
156
|
+
messageForAgent = messageForAgent + '\n\n' + actionHint;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Store context + user message atomically
|
|
160
|
+
const insertAll = this._db.transaction(() => {
|
|
161
|
+
// 1. Insert context rows (for session resumption UI)
|
|
162
|
+
if (contextData) {
|
|
163
|
+
const ctxStmt = this._db.prepare(`
|
|
164
|
+
INSERT INTO chat_messages (session_id, role, type, content)
|
|
165
|
+
VALUES (?, 'user', 'context', ?)
|
|
166
|
+
`);
|
|
167
|
+
const items = Array.isArray(contextData) ? contextData : [contextData];
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
ctxStmt.run(sessionId, typeof item === 'string' ? item : JSON.stringify(item));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. Insert user message
|
|
174
|
+
const stmt = this._db.prepare(`
|
|
175
|
+
INSERT INTO chat_messages (session_id, role, type, content)
|
|
176
|
+
VALUES (?, 'user', 'message', ?)
|
|
177
|
+
`);
|
|
178
|
+
return stmt.run(sessionId, content);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = insertAll();
|
|
182
|
+
const messageId = Number(result.lastInsertRowid);
|
|
183
|
+
|
|
184
|
+
// Forward to bridge
|
|
185
|
+
logger.debug(`[ChatSession] Session ${sessionId}: forwarding message to bridge (${messageForAgent.length} chars, delta listeners: ${session.listeners.delta.size})`);
|
|
186
|
+
await session.bridge.sendMessage(messageForAgent);
|
|
187
|
+
|
|
188
|
+
return { id: messageId };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register a callback for streaming text deltas from a session.
|
|
193
|
+
* @param {number} sessionId
|
|
194
|
+
* @param {function} callback - Called with {text} on each delta
|
|
195
|
+
* @returns {function} Unsubscribe function
|
|
196
|
+
*/
|
|
197
|
+
onDelta(sessionId, callback) {
|
|
198
|
+
const session = this._sessions.get(sessionId);
|
|
199
|
+
if (!session) {
|
|
200
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
201
|
+
}
|
|
202
|
+
session.listeners.delta.add(callback);
|
|
203
|
+
return () => session.listeners.delta.delete(callback);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Register a callback for turn completion.
|
|
208
|
+
* When the agent completes, the assistant message is already stored in DB.
|
|
209
|
+
* @param {number} sessionId
|
|
210
|
+
* @param {function} callback - Called with {fullText, messageId}
|
|
211
|
+
* @returns {function} Unsubscribe function
|
|
212
|
+
*/
|
|
213
|
+
onComplete(sessionId, callback) {
|
|
214
|
+
const session = this._sessions.get(sessionId);
|
|
215
|
+
if (!session) {
|
|
216
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
217
|
+
}
|
|
218
|
+
session.listeners.complete.add(callback);
|
|
219
|
+
return () => session.listeners.complete.delete(callback);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Register a callback for tool use events.
|
|
224
|
+
* @param {number} sessionId
|
|
225
|
+
* @param {function} callback - Called with {toolCallId, toolName, status, ...}
|
|
226
|
+
* @returns {function} Unsubscribe function
|
|
227
|
+
*/
|
|
228
|
+
onToolUse(sessionId, callback) {
|
|
229
|
+
const session = this._sessions.get(sessionId);
|
|
230
|
+
if (!session) {
|
|
231
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
232
|
+
}
|
|
233
|
+
session.listeners.toolUse.add(callback);
|
|
234
|
+
return () => session.listeners.toolUse.delete(callback);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Register a callback for agent status events (working, turn_complete).
|
|
239
|
+
* @param {number} sessionId
|
|
240
|
+
* @param {function} callback - Called with {status}
|
|
241
|
+
* @returns {function} Unsubscribe function
|
|
242
|
+
*/
|
|
243
|
+
onStatus(sessionId, callback) {
|
|
244
|
+
const session = this._sessions.get(sessionId);
|
|
245
|
+
if (!session) {
|
|
246
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
247
|
+
}
|
|
248
|
+
session.listeners.status.add(callback);
|
|
249
|
+
return () => session.listeners.status.delete(callback);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Register a callback for error events from the bridge.
|
|
254
|
+
* @param {number} sessionId
|
|
255
|
+
* @param {function} callback - Called with {message}
|
|
256
|
+
* @returns {function} Unsubscribe function
|
|
257
|
+
*/
|
|
258
|
+
onError(sessionId, callback) {
|
|
259
|
+
const session = this._sessions.get(sessionId);
|
|
260
|
+
if (!session) {
|
|
261
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
262
|
+
}
|
|
263
|
+
session.listeners.error.add(callback);
|
|
264
|
+
return () => session.listeners.error.delete(callback);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Abort the current turn in an active session.
|
|
269
|
+
* @param {number} sessionId
|
|
270
|
+
*/
|
|
271
|
+
abortSession(sessionId) {
|
|
272
|
+
const session = this._sessions.get(sessionId);
|
|
273
|
+
if (!session) {
|
|
274
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
275
|
+
}
|
|
276
|
+
session.bridge.abort();
|
|
277
|
+
logger.info(`[ChatSession] Aborted session ${sessionId}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Close a session and kill the agent process.
|
|
282
|
+
* @param {number} sessionId
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
async closeSession(sessionId) {
|
|
286
|
+
const session = this._sessions.get(sessionId);
|
|
287
|
+
if (!session) {
|
|
288
|
+
// Session may already be closed — just update DB to be safe
|
|
289
|
+
this._db.prepare(`
|
|
290
|
+
UPDATE chat_sessions SET status = 'closed', updated_at = CURRENT_TIMESTAMP
|
|
291
|
+
WHERE id = ? AND status = 'active'
|
|
292
|
+
`).run(sessionId);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Remove from map first so the 'close' event handler doesn't double-update
|
|
297
|
+
this._sessions.delete(sessionId);
|
|
298
|
+
|
|
299
|
+
// Close the bridge process (PiBridge.close() handles listener cleanup internally)
|
|
300
|
+
await session.bridge.close();
|
|
301
|
+
|
|
302
|
+
// Update DB status
|
|
303
|
+
this._db.prepare(`
|
|
304
|
+
UPDATE chat_sessions SET status = 'closed', updated_at = CURRENT_TIMESTAMP
|
|
305
|
+
WHERE id = ?
|
|
306
|
+
`).run(sessionId);
|
|
307
|
+
|
|
308
|
+
logger.info(`[ChatSession] Session ${sessionId} closed`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get session info from the database.
|
|
313
|
+
* @param {number} sessionId
|
|
314
|
+
* @returns {Object|null}
|
|
315
|
+
*/
|
|
316
|
+
getSession(sessionId) {
|
|
317
|
+
return this._db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(sessionId) || null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check if a session has an active in-memory bridge.
|
|
322
|
+
* Unlike getSession() which queries the DB, this checks the live session map.
|
|
323
|
+
* @param {number} sessionId
|
|
324
|
+
* @returns {boolean}
|
|
325
|
+
*/
|
|
326
|
+
isSessionActive(sessionId) {
|
|
327
|
+
return this._sessions.has(sessionId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* List sessions for a review.
|
|
332
|
+
* @param {number} reviewId
|
|
333
|
+
* @returns {Array}
|
|
334
|
+
*/
|
|
335
|
+
getSessionsForReview(reviewId) {
|
|
336
|
+
return this._db.prepare(
|
|
337
|
+
'SELECT * FROM chat_sessions WHERE review_id = ? ORDER BY created_at DESC'
|
|
338
|
+
).all(reviewId);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get message history for a session.
|
|
343
|
+
* Returns all message types including rows with type='context' (used to
|
|
344
|
+
* reconstruct context cards in the UI for session resumption). Consumers
|
|
345
|
+
* building session replay should dispatch on the `type` field to distinguish
|
|
346
|
+
* 'message' rows from 'context' rows.
|
|
347
|
+
* @param {number} sessionId
|
|
348
|
+
* @returns {Array}
|
|
349
|
+
*/
|
|
350
|
+
getMessages(sessionId) {
|
|
351
|
+
return this._db.prepare(
|
|
352
|
+
'SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC'
|
|
353
|
+
).all(sessionId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Save a context message to an existing session.
|
|
358
|
+
* Used to persist analysis context cards (and potentially other context types)
|
|
359
|
+
* immediately, without waiting for the next user message.
|
|
360
|
+
* @param {number} sessionId
|
|
361
|
+
* @param {Object|string} contextData - Context data to persist (will be JSON-stringified if object)
|
|
362
|
+
* @returns {{ id: number }} The stored message ID
|
|
363
|
+
*/
|
|
364
|
+
saveContextMessage(sessionId, contextData) {
|
|
365
|
+
const session = this._db.prepare('SELECT id FROM chat_sessions WHERE id = ?').get(sessionId);
|
|
366
|
+
if (!session) {
|
|
367
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const content = typeof contextData === 'string' ? contextData : JSON.stringify(contextData);
|
|
371
|
+
const stmt = this._db.prepare(`
|
|
372
|
+
INSERT INTO chat_messages (session_id, role, type, content)
|
|
373
|
+
VALUES (?, 'user', 'context', ?)
|
|
374
|
+
`);
|
|
375
|
+
const result = stmt.run(sessionId, content);
|
|
376
|
+
return { id: Number(result.lastInsertRowid) };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Resume a previously closed chat session by re-spawning the Pi bridge
|
|
381
|
+
* with the stored session file path.
|
|
382
|
+
* @param {number} sessionId
|
|
383
|
+
* @param {Object} options
|
|
384
|
+
* @param {string} [options.systemPrompt] - System prompt text
|
|
385
|
+
* @param {string} [options.cwd] - Working directory for agent
|
|
386
|
+
* @returns {Promise<{id: number, status: string}>}
|
|
387
|
+
*/
|
|
388
|
+
async resumeSession(sessionId, { systemPrompt, cwd } = {}) {
|
|
389
|
+
// Already active — return immediately
|
|
390
|
+
if (this._sessions.has(sessionId)) {
|
|
391
|
+
return { id: sessionId, status: 'active' };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Load session row from DB
|
|
395
|
+
const row = this._db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(sessionId);
|
|
396
|
+
if (!row) {
|
|
397
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!row.agent_session_id) {
|
|
401
|
+
throw new Error(`Session ${sessionId} has no session file — cannot resume`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Verify file exists on disk
|
|
405
|
+
if (!fs.existsSync(row.agent_session_id)) {
|
|
406
|
+
// Null out the stale path
|
|
407
|
+
this._db.prepare('UPDATE chat_sessions SET agent_session_id = NULL WHERE id = ?').run(sessionId);
|
|
408
|
+
throw new Error(`Session file not found on disk: ${row.agent_session_id}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
logger.info(`[ChatSession] Resuming session ${sessionId} from ${row.agent_session_id}`);
|
|
412
|
+
|
|
413
|
+
// Create bridge with session path for resumption
|
|
414
|
+
const bridge = new PiBridge({
|
|
415
|
+
provider: row.provider,
|
|
416
|
+
model: row.model,
|
|
417
|
+
cwd,
|
|
418
|
+
systemPrompt,
|
|
419
|
+
tools: CHAT_TOOLS,
|
|
420
|
+
skills: [pairReviewSkillPath],
|
|
421
|
+
extensions: [taskExtensionDir],
|
|
422
|
+
sessionPath: row.agent_session_id
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const listeners = {
|
|
426
|
+
delta: new Set(),
|
|
427
|
+
complete: new Set(),
|
|
428
|
+
toolUse: new Set(),
|
|
429
|
+
status: new Set(),
|
|
430
|
+
error: new Set()
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
this._sessions.set(sessionId, { bridge, listeners, initialContext: null });
|
|
434
|
+
this._wireBridgeEvents(sessionId, bridge, listeners);
|
|
435
|
+
|
|
436
|
+
// Start the bridge process
|
|
437
|
+
try {
|
|
438
|
+
await bridge.start();
|
|
439
|
+
} catch (err) {
|
|
440
|
+
this._sessions.delete(sessionId);
|
|
441
|
+
this._db.prepare(`
|
|
442
|
+
UPDATE chat_sessions SET status = 'error', updated_at = CURRENT_TIMESTAMP
|
|
443
|
+
WHERE id = ?
|
|
444
|
+
`).run(sessionId);
|
|
445
|
+
logger.error(`[ChatSession] Failed to resume bridge for session ${sessionId}: ${err.message}`);
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Update DB status back to active
|
|
450
|
+
this._db.prepare(`
|
|
451
|
+
UPDATE chat_sessions SET status = 'active', updated_at = CURRENT_TIMESTAMP
|
|
452
|
+
WHERE id = ?
|
|
453
|
+
`).run(sessionId);
|
|
454
|
+
|
|
455
|
+
logger.info(`[ChatSession] Session ${sessionId} resumed`);
|
|
456
|
+
return { id: sessionId, status: 'active' };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Get the most recently updated session for a review.
|
|
461
|
+
* @param {number} reviewId
|
|
462
|
+
* @returns {Object|null}
|
|
463
|
+
*/
|
|
464
|
+
getMRUSession(reviewId) {
|
|
465
|
+
return this._db.prepare(
|
|
466
|
+
'SELECT * FROM chat_sessions WHERE review_id = ? ORDER BY updated_at DESC LIMIT 1'
|
|
467
|
+
).get(reviewId) || null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Get sessions for a review with message counts (for session list UI).
|
|
472
|
+
* @param {number} reviewId
|
|
473
|
+
* @returns {Array<Object>}
|
|
474
|
+
*/
|
|
475
|
+
getSessionsWithMessageCount(reviewId) {
|
|
476
|
+
return this._db.prepare(`
|
|
477
|
+
SELECT s.*, COUNT(m.id) AS message_count,
|
|
478
|
+
(SELECT content FROM chat_messages
|
|
479
|
+
WHERE session_id = s.id AND role = 'user' AND type = 'message'
|
|
480
|
+
ORDER BY id ASC LIMIT 1
|
|
481
|
+
) AS first_message
|
|
482
|
+
FROM chat_sessions s
|
|
483
|
+
LEFT JOIN chat_messages m ON m.session_id = s.id AND m.type = 'message'
|
|
484
|
+
WHERE s.review_id = ?
|
|
485
|
+
GROUP BY s.id
|
|
486
|
+
ORDER BY s.updated_at DESC
|
|
487
|
+
`).all(reviewId);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Private methods
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Wire up bridge event handlers that dispatch to the session's listener sets
|
|
496
|
+
* and handle DB persistence (e.g., storing assistant messages on completion).
|
|
497
|
+
* @param {number} sessionId
|
|
498
|
+
* @param {PiBridge} bridge
|
|
499
|
+
* @param {Object} listeners - Listener sets keyed by event type
|
|
500
|
+
*/
|
|
501
|
+
_wireBridgeEvents(sessionId, bridge, listeners) {
|
|
502
|
+
bridge.on('delta', (data) => {
|
|
503
|
+
for (const cb of listeners.delta) {
|
|
504
|
+
try {
|
|
505
|
+
cb(data);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logger.error(`[ChatSession] Delta listener error: ${err.message}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
bridge.on('complete', (data) => {
|
|
513
|
+
logger.debug(`[ChatSession] Session ${sessionId} complete: ${(data.fullText || '').length} chars, ${listeners.complete.size} listener(s)`);
|
|
514
|
+
// Store assistant message in DB
|
|
515
|
+
const fullText = data.fullText || '';
|
|
516
|
+
let messageId = null;
|
|
517
|
+
if (fullText) {
|
|
518
|
+
try {
|
|
519
|
+
const msgStmt = this._db.prepare(`
|
|
520
|
+
INSERT INTO chat_messages (session_id, role, type, content)
|
|
521
|
+
VALUES (?, 'assistant', 'message', ?)
|
|
522
|
+
`);
|
|
523
|
+
const msgResult = msgStmt.run(sessionId, fullText);
|
|
524
|
+
messageId = Number(msgResult.lastInsertRowid);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
logger.error(`[ChatSession] Failed to store assistant message: ${err.message}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
for (const cb of listeners.complete) {
|
|
531
|
+
try {
|
|
532
|
+
cb({ fullText, messageId });
|
|
533
|
+
} catch (err) {
|
|
534
|
+
logger.error(`[ChatSession] Complete listener error: ${err.message}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
bridge.on('tool_use', (data) => {
|
|
540
|
+
for (const cb of listeners.toolUse) {
|
|
541
|
+
try {
|
|
542
|
+
cb(data);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
logger.error(`[ChatSession] ToolUse listener error: ${err.message}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
bridge.on('status', (data) => {
|
|
550
|
+
for (const cb of listeners.status) {
|
|
551
|
+
try {
|
|
552
|
+
cb(data);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
logger.error(`[ChatSession] Status listener error: ${err.message}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
bridge.on('error', (data) => {
|
|
560
|
+
logger.error(`[ChatSession] Bridge error for session ${sessionId}: ${data.error?.message || 'unknown'}`);
|
|
561
|
+
for (const cb of listeners.error) {
|
|
562
|
+
try {
|
|
563
|
+
cb({ message: data.error?.message || 'Agent encountered an error' });
|
|
564
|
+
} catch (err) {
|
|
565
|
+
logger.error(`[ChatSession] Error listener error: ${err.message}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
bridge.on('close', () => {
|
|
571
|
+
// If the bridge closes unexpectedly (not via closeSession), update DB
|
|
572
|
+
if (this._sessions.has(sessionId)) {
|
|
573
|
+
for (const cb of listeners.error) {
|
|
574
|
+
try {
|
|
575
|
+
cb({ message: 'Agent process ended unexpectedly' });
|
|
576
|
+
} catch (err) {
|
|
577
|
+
logger.error(`[ChatSession] Error listener error: ${err.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
this._db.prepare(`
|
|
582
|
+
UPDATE chat_sessions SET status = 'closed', updated_at = CURRENT_TIMESTAMP
|
|
583
|
+
WHERE id = ? AND status = 'active'
|
|
584
|
+
`).run(sessionId);
|
|
585
|
+
} catch (err) {
|
|
586
|
+
logger.error(`[ChatSession] Failed to update session status on close: ${err.message}`);
|
|
587
|
+
}
|
|
588
|
+
this._sessions.delete(sessionId);
|
|
589
|
+
logger.warn(`[ChatSession] Session ${sessionId} closed unexpectedly`);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
bridge.on('session', (event) => {
|
|
594
|
+
if (event.sessionFile) {
|
|
595
|
+
try {
|
|
596
|
+
this._db.prepare('UPDATE chat_sessions SET agent_session_id = ? WHERE id = ?')
|
|
597
|
+
.run(event.sessionFile, sessionId);
|
|
598
|
+
logger.info(`[ChatSession] Session ${sessionId} session file: ${event.sessionFile}`);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
logger.warn(`[ChatSession] Failed to store session file: ${err.message}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Close all active sessions (for cleanup on server shutdown).
|
|
608
|
+
* @returns {Promise<void>}
|
|
609
|
+
*/
|
|
610
|
+
async closeAll() {
|
|
611
|
+
const sessionIds = [...this._sessions.keys()];
|
|
612
|
+
if (sessionIds.length === 0) return;
|
|
613
|
+
|
|
614
|
+
logger.info(`[ChatSession] Closing ${sessionIds.length} active session(s)`);
|
|
615
|
+
await Promise.all(sessionIds.map((id) => this.closeSession(id)));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
module.exports = ChatSessionManager;
|
package/src/config.js
CHANGED
|
@@ -20,6 +20,8 @@ const DEFAULT_CONFIG = {
|
|
|
20
20
|
debug_stream: false, // When true, logs AI provider streaming events (equivalent to --debug-stream CLI flag)
|
|
21
21
|
db_name: "", // Custom database filename (default: database.db). Useful for per-worktree isolation.
|
|
22
22
|
yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
|
|
23
|
+
enable_chat: true, // When true, enables the chat panel feature (requires Pi AI provider)
|
|
24
|
+
chat: { enable_shortcuts: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons)
|
|
23
25
|
providers: {}, // Custom provider configurations (overrides built-in defaults)
|
|
24
26
|
monorepos: {} // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
|
|
25
27
|
};
|
|
@@ -138,6 +140,12 @@ async function loadConfig() {
|
|
|
138
140
|
// Merge with defaults to ensure all keys exist
|
|
139
141
|
// Legacy keys ('provider', 'model') are handled lazily via getDefaultProvider/getDefaultModel
|
|
140
142
|
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
143
|
+
// Deep-merge one level for object-valued defaults (e.g. chat, providers, monorepos)
|
|
144
|
+
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
|
145
|
+
if (typeof DEFAULT_CONFIG[key] === 'object' && DEFAULT_CONFIG[key] !== null && !Array.isArray(DEFAULT_CONFIG[key])) {
|
|
146
|
+
mergedConfig[key] = { ...DEFAULT_CONFIG[key], ...config[key] };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
141
149
|
|
|
142
150
|
// Merge local config (CWD/.pair-review/config.json) on top of global config
|
|
143
151
|
try {
|
|
@@ -145,6 +153,12 @@ async function loadConfig() {
|
|
|
145
153
|
const localConfigData = await fs.readFile(localConfigPath, 'utf8');
|
|
146
154
|
const localConfig = JSON.parse(localConfigData);
|
|
147
155
|
Object.assign(mergedConfig, localConfig);
|
|
156
|
+
// Deep-merge one level for object-valued local config overrides
|
|
157
|
+
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
|
158
|
+
if (typeof DEFAULT_CONFIG[key] === 'object' && DEFAULT_CONFIG[key] !== null && !Array.isArray(DEFAULT_CONFIG[key])) {
|
|
159
|
+
mergedConfig[key] = { ...DEFAULT_CONFIG[key], ...config[key], ...localConfig[key] };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
148
162
|
} catch (localError) {
|
|
149
163
|
if (localError.code !== 'ENOENT') {
|
|
150
164
|
if (localError instanceof SyntaxError) {
|