@in-the-loop-labs/pair-review 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/skills/review-model-guidance/SKILL.md +1 -1
- package/.pi/skills/review-roulette/SKILL.md +1 -1
- package/README.md +15 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +296 -15
- package/public/index.html +121 -57
- package/public/js/components/AIPanel.js +2 -1
- package/public/js/components/AdvancedConfigTab.js +2 -2
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +187 -28
- package/public/js/components/CouncilProgressModal.js +4 -7
- package/public/js/components/SplitButton.js +66 -1
- package/public/js/components/VoiceCentricConfigTab.js +2 -2
- package/public/js/index.js +274 -21
- package/public/js/modules/comment-manager.js +16 -12
- package/public/js/modules/file-comment-manager.js +8 -6
- package/public/js/pr.js +194 -5
- package/public/local.html +8 -1
- package/public/pr.html +17 -2
- package/src/ai/codex-provider.js +14 -2
- package/src/ai/copilot-provider.js +1 -10
- package/src/ai/cursor-agent-provider.js +1 -10
- package/src/ai/gemini-provider.js +8 -17
- package/src/chat/acp-bridge.js +442 -0
- package/src/chat/api-reference.js +539 -0
- package/src/chat/chat-providers.js +290 -0
- package/src/chat/claude-code-bridge.js +499 -0
- package/src/chat/codex-bridge.js +601 -0
- package/src/chat/pi-bridge.js +56 -3
- package/src/chat/prompt-builder.js +12 -11
- package/src/chat/session-manager.js +110 -29
- package/src/config.js +4 -2
- package/src/database.js +50 -2
- package/src/github/client.js +43 -0
- package/src/routes/chat.js +60 -27
- package/src/routes/config.js +24 -1
- package/src/routes/github-collections.js +126 -0
- package/src/routes/mcp.js +2 -1
- package/src/routes/pr.js +166 -2
- package/src/routes/reviews.js +2 -1
- package/src/routes/shared.js +70 -49
- package/src/server.js +27 -1
- package/src/utils/safe-parse-json.js +19 -0
- package/.pi/skills/pair-review-api/SKILL.md +0 -448
|
@@ -17,11 +17,10 @@ const logger = require('../utils/logger');
|
|
|
17
17
|
* @param {Object} options
|
|
18
18
|
* @param {Object} options.review - Review metadata {id, pr_number, repository, review_type, local_path, name}
|
|
19
19
|
* @param {Object} [options.prData] - PR data with base_sha/head_sha (for PR reviews)
|
|
20
|
-
* @param {string} [options.skillPath] - Absolute path to the pair-review-api SKILL.md file
|
|
21
20
|
* @param {string} [options.chatInstructions] - Custom instructions from repo settings to append to system prompt
|
|
22
21
|
* @returns {string} System prompt for the chat agent
|
|
23
22
|
*/
|
|
24
|
-
function buildChatPrompt({ review, prData,
|
|
23
|
+
function buildChatPrompt({ review, prData, chatInstructions }) {
|
|
25
24
|
const sections = [];
|
|
26
25
|
|
|
27
26
|
// Role
|
|
@@ -46,20 +45,22 @@ function buildChatPrompt({ review, prData, skillPath, chatInstructions }) {
|
|
|
46
45
|
'- **Workflow**: AI generates suggestions → reviewer triages (adopt, edit, or dismiss) → adopted suggestions become comments.',
|
|
47
46
|
'- **IMPORTANT**: Do NOT adopt, dismiss, or modify suggestions or comments unless the user explicitly asks you to. Your role is to discuss and explain — the reviewer decides what action to take.',
|
|
48
47
|
'- **Analysis runs** are the process that produces suggestions. Each run has a provider, model, tier, and status.',
|
|
49
|
-
'- **Review ID** is a stable integer identifying this review session, used in all API calls.'
|
|
48
|
+
'- **Review ID** is a stable integer identifying this review session, used in all API calls.',
|
|
49
|
+
'- **IMPORTANT**: Never mention internal IDs (comment IDs, suggestion IDs, run IDs) in your responses to the user. These are meaningless to the user and not shown in the UI. Refer to comments and suggestions by their content, title, file location, or line number instead.'
|
|
50
50
|
];
|
|
51
51
|
if (review && review.id) {
|
|
52
|
-
domainLines.push(`- The review ID for this session is: ${review.id} (e.g. \`/api/reviews/${review.id}/comments\`).`);
|
|
52
|
+
domainLines.push(`- The internal review ID for this session to use with API requests is: ${review.id} (e.g. \`/api/reviews/${review.id}/comments\`).`);
|
|
53
53
|
}
|
|
54
54
|
sections.push(domainLines.join('\n'));
|
|
55
55
|
|
|
56
|
-
// API
|
|
57
|
-
const skillRef = skillPath
|
|
58
|
-
? `(\`${skillPath}\`)`
|
|
59
|
-
: '(`.pi/skills/pair-review-api/SKILL.md`)';
|
|
56
|
+
// API Access — cheat-sheet is injected into initial context; full docs available via GET /api.md
|
|
60
57
|
sections.push(
|
|
61
|
-
|
|
62
|
-
'
|
|
58
|
+
'## API Access\n\n' +
|
|
59
|
+
'You have **read-only access to the code**. To modify the review (create comments, adopt suggestions, trigger analysis) or interact with the pair-review app, you MUST use the pair-review API via `curl`. ' +
|
|
60
|
+
'All endpoints accept and return JSON.\n\n' +
|
|
61
|
+
'A compact API reference and the server URL are provided in the initial context of each session. ' +
|
|
62
|
+
`For the full API reference, fetch it with \`curl http://localhost:<port>/api.md?reviewId=${review?.id || '<id>'}\` (use the real port from your context).\n\n` +
|
|
63
|
+
'IMPORTANT: Do NOT mention that you are reading API documentation or consulting reference material. Just use the API naturally as if you already know it.'
|
|
63
64
|
);
|
|
64
65
|
|
|
65
66
|
// File reference syntax and context files
|
|
@@ -82,7 +83,7 @@ function buildChatPrompt({ review, prData, skillPath, chatInstructions }) {
|
|
|
82
83
|
'## Tool usage\n\n' +
|
|
83
84
|
'Prefer answering from context you already have (the diff, suggestions, and prior conversation). ' +
|
|
84
85
|
'For simple lookups — reading a file, searching for a symbol, running a git command — use the basic tools directly. ' +
|
|
85
|
-
'Only use the Task tool for genuinely large, multi-step operations that would consume significant context ' +
|
|
86
|
+
'Only use the Task/Agent tool for genuinely large, multi-step operations that would consume significant context ' +
|
|
86
87
|
'(e.g., tracing a complex call chain across many files, or broad codebase exploration). ' +
|
|
87
88
|
'Most chat questions should not require a Task.'
|
|
88
89
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Chat Session Manager
|
|
4
4
|
*
|
|
5
|
-
* Manages active chat sessions, each backed by a
|
|
5
|
+
* Manages active chat sessions, each backed by a provider-specific bridge process.
|
|
6
6
|
* Handles session lifecycle (create, message, close), persistence to SQLite,
|
|
7
7
|
* and event dispatch (delta, complete, tool_use) to registered listeners.
|
|
8
8
|
*/
|
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const PiBridge = require('./pi-bridge');
|
|
13
|
+
const AcpBridge = require('./acp-bridge');
|
|
14
|
+
const ClaudeCodeBridge = require('./claude-code-bridge');
|
|
15
|
+
const CodexBridge = require('./codex-bridge');
|
|
16
|
+
const { getChatProvider, isAcpProvider, isClaudeCodeProvider, isCodexProvider, applyConfigOverrides: applyChatConfigOverrides } = require('./chat-providers');
|
|
13
17
|
const logger = require('../utils/logger');
|
|
14
18
|
|
|
15
|
-
const pairReviewSkillPath = path.resolve(__dirname, '../../.pi/skills/pair-review-api/SKILL.md');
|
|
16
19
|
const taskExtensionDir = path.resolve(__dirname, '../../.pi/extensions/task');
|
|
17
20
|
|
|
18
21
|
const CHAT_TOOLS = 'read,bash,grep,find,ls';
|
|
@@ -20,16 +23,18 @@ const CHAT_TOOLS = 'read,bash,grep,find,ls';
|
|
|
20
23
|
class ChatSessionManager {
|
|
21
24
|
/**
|
|
22
25
|
* @param {Database} db - better-sqlite3 database instance
|
|
26
|
+
* @param {Object} [configOverrides] - Provider config overrides from config.chat_providers
|
|
23
27
|
*/
|
|
24
|
-
constructor(db) {
|
|
28
|
+
constructor(db, configOverrides = {}) {
|
|
25
29
|
this._db = db;
|
|
26
30
|
this._sessions = new Map(); // sessionId -> { bridge, listeners }
|
|
31
|
+
applyChatConfigOverrides(configOverrides);
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
35
|
* Create a new chat session and spawn the agent process.
|
|
31
36
|
* @param {Object} options
|
|
32
|
-
* @param {string} options.provider -
|
|
37
|
+
* @param {string} options.provider - any configured chat provider
|
|
33
38
|
* @param {string} [options.model] - Model ID
|
|
34
39
|
* @param {number} options.reviewId - Review ID
|
|
35
40
|
* @param {number} [options.contextCommentId] - Optional suggestion ID that triggered chat
|
|
@@ -56,14 +61,11 @@ class ChatSessionManager {
|
|
|
56
61
|
|
|
57
62
|
// Create and start the bridge
|
|
58
63
|
// Chat sessions get bash for git commands; review analysis uses the safe default
|
|
59
|
-
const bridge =
|
|
64
|
+
const bridge = this._createBridge(provider, {
|
|
60
65
|
provider,
|
|
61
66
|
model,
|
|
62
67
|
cwd,
|
|
63
68
|
systemPrompt,
|
|
64
|
-
tools: CHAT_TOOLS,
|
|
65
|
-
skills: [pairReviewSkillPath],
|
|
66
|
-
extensions: [taskExtensionDir]
|
|
67
69
|
});
|
|
68
70
|
|
|
69
71
|
const listeners = {
|
|
@@ -134,6 +136,12 @@ class ChatSessionManager {
|
|
|
134
136
|
messageForAgent = context + '\n\n---\n\n' + messageForAgent;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
// Then prepend resume context (port correction after session resume — middle layer)
|
|
140
|
+
if (session.resumeContext) {
|
|
141
|
+
messageForAgent = session.resumeContext + '\n\n---\n\n' + messageForAgent;
|
|
142
|
+
session.resumeContext = null; // Only prepend once
|
|
143
|
+
}
|
|
144
|
+
|
|
137
145
|
// Then prepend initial session context (all suggestions — outermost)
|
|
138
146
|
if (session.initialContext) {
|
|
139
147
|
messageForAgent = session.initialContext + '\n\n---\n\n' + messageForAgent;
|
|
@@ -308,6 +316,22 @@ class ChatSessionManager {
|
|
|
308
316
|
logger.info(`[ChatSession] Session ${sessionId} closed`);
|
|
309
317
|
}
|
|
310
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Set context to prepend on the next sendMessage call for a resumed session.
|
|
321
|
+
* Similar to initialContext but set after resume rather than at creation.
|
|
322
|
+
* The context is consumed (prepended to the agent message, then cleared) on
|
|
323
|
+
* the next sendMessage call.
|
|
324
|
+
* @param {number} sessionId
|
|
325
|
+
* @param {string} context - Context string to prepend on next message
|
|
326
|
+
*/
|
|
327
|
+
setResumeContext(sessionId, context) {
|
|
328
|
+
const session = this._sessions.get(sessionId);
|
|
329
|
+
if (!session) {
|
|
330
|
+
throw new Error(`Session ${sessionId} not found or not active`);
|
|
331
|
+
}
|
|
332
|
+
session.resumeContext = context || null;
|
|
333
|
+
}
|
|
334
|
+
|
|
311
335
|
/**
|
|
312
336
|
* Get session info from the database.
|
|
313
337
|
* @param {number} sessionId
|
|
@@ -377,7 +401,7 @@ class ChatSessionManager {
|
|
|
377
401
|
}
|
|
378
402
|
|
|
379
403
|
/**
|
|
380
|
-
* Resume a previously closed chat session by re-spawning the
|
|
404
|
+
* Resume a previously closed chat session by re-spawning the chat bridge
|
|
381
405
|
* with the stored session file path.
|
|
382
406
|
* @param {number} sessionId
|
|
383
407
|
* @param {Object} options
|
|
@@ -397,29 +421,39 @@ class ChatSessionManager {
|
|
|
397
421
|
throw new Error(`Session ${sessionId} not found`);
|
|
398
422
|
}
|
|
399
423
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
424
|
+
const isAcp = isAcpProvider(row.provider);
|
|
425
|
+
const isClaudeCode = isClaudeCodeProvider(row.provider);
|
|
426
|
+
const isCodex = isCodexProvider(row.provider);
|
|
427
|
+
const usesOpaqueSessionId = isAcp || isClaudeCode;
|
|
403
428
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
429
|
+
if (!usesOpaqueSessionId && !isCodex) {
|
|
430
|
+
// Pi sessions require a session file on disk
|
|
431
|
+
if (!row.agent_session_id) {
|
|
432
|
+
throw new Error(`Session ${sessionId} has no session file — cannot resume`);
|
|
433
|
+
}
|
|
434
|
+
if (!fs.existsSync(row.agent_session_id)) {
|
|
435
|
+
this._db.prepare('UPDATE chat_sessions SET agent_session_id = NULL WHERE id = ?').run(sessionId);
|
|
436
|
+
throw new Error(`Session file not found on disk: ${row.agent_session_id}`);
|
|
437
|
+
}
|
|
409
438
|
}
|
|
410
439
|
|
|
411
|
-
logger.info(`[ChatSession] Resuming session ${sessionId} from ${row.agent_session_id}`);
|
|
440
|
+
logger.info(`[ChatSession] Resuming session ${sessionId}${usesOpaqueSessionId ? ` (session ${row.agent_session_id || 'new'})` : isCodex ? ` (Codex thread ${row.agent_session_id || 'new'})` : ` from ${row.agent_session_id}`}`);
|
|
412
441
|
|
|
413
|
-
|
|
414
|
-
|
|
442
|
+
let resumeOptions = {};
|
|
443
|
+
if (isCodex) {
|
|
444
|
+
resumeOptions = row.agent_session_id ? { resumeThreadId: row.agent_session_id } : {};
|
|
445
|
+
} else if (usesOpaqueSessionId) {
|
|
446
|
+
resumeOptions = row.agent_session_id ? { resumeSessionId: row.agent_session_id } : {};
|
|
447
|
+
} else {
|
|
448
|
+
resumeOptions = { sessionPath: row.agent_session_id };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const bridge = this._createBridge(row.provider, {
|
|
415
452
|
provider: row.provider,
|
|
416
453
|
model: row.model,
|
|
417
454
|
cwd,
|
|
418
455
|
systemPrompt,
|
|
419
|
-
|
|
420
|
-
skills: [pairReviewSkillPath],
|
|
421
|
-
extensions: [taskExtensionDir],
|
|
422
|
-
sessionPath: row.agent_session_id
|
|
456
|
+
...resumeOptions,
|
|
423
457
|
});
|
|
424
458
|
|
|
425
459
|
const listeners = {
|
|
@@ -491,11 +525,57 @@ class ChatSessionManager {
|
|
|
491
525
|
// Private methods
|
|
492
526
|
// ---------------------------------------------------------------------------
|
|
493
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Create the appropriate bridge instance for a provider.
|
|
530
|
+
* ACP providers get an AcpBridge; everything else gets a PiBridge with tools/skills.
|
|
531
|
+
* @param {string} provider
|
|
532
|
+
* @param {Object} options - Bridge constructor options
|
|
533
|
+
* @returns {PiBridge|AcpBridge}
|
|
534
|
+
*/
|
|
535
|
+
_createBridge(provider, options) {
|
|
536
|
+
if (isAcpProvider(provider)) {
|
|
537
|
+
const providerDef = getChatProvider(provider);
|
|
538
|
+
return new AcpBridge({
|
|
539
|
+
...options,
|
|
540
|
+
model: options.model || providerDef?.model,
|
|
541
|
+
acpCommand: providerDef?.command,
|
|
542
|
+
acpArgs: providerDef?.args,
|
|
543
|
+
env: providerDef?.env,
|
|
544
|
+
useShell: providerDef?.useShell,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
if (isClaudeCodeProvider(provider)) {
|
|
548
|
+
const providerDef = getChatProvider(provider);
|
|
549
|
+
return new ClaudeCodeBridge({
|
|
550
|
+
...options,
|
|
551
|
+
claudeCommand: providerDef?.command,
|
|
552
|
+
env: providerDef?.env,
|
|
553
|
+
useShell: providerDef?.useShell,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (isCodexProvider(provider)) {
|
|
557
|
+
const providerDef = getChatProvider(provider);
|
|
558
|
+
return new CodexBridge({
|
|
559
|
+
...options,
|
|
560
|
+
model: options.model || providerDef?.model,
|
|
561
|
+
codexCommand: providerDef?.command,
|
|
562
|
+
codexArgs: providerDef?.args,
|
|
563
|
+
env: providerDef?.env,
|
|
564
|
+
useShell: providerDef?.useShell,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return new PiBridge({
|
|
568
|
+
...options,
|
|
569
|
+
tools: CHAT_TOOLS,
|
|
570
|
+
extensions: [taskExtensionDir],
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
494
574
|
/**
|
|
495
575
|
* Wire up bridge event handlers that dispatch to the session's listener sets
|
|
496
576
|
* and handle DB persistence (e.g., storing assistant messages on completion).
|
|
497
577
|
* @param {number} sessionId
|
|
498
|
-
* @param {PiBridge} bridge
|
|
578
|
+
* @param {PiBridge|AcpBridge} bridge
|
|
499
579
|
* @param {Object} listeners - Listener sets keyed by event type
|
|
500
580
|
*/
|
|
501
581
|
_wireBridgeEvents(sessionId, bridge, listeners) {
|
|
@@ -591,13 +671,14 @@ class ChatSessionManager {
|
|
|
591
671
|
});
|
|
592
672
|
|
|
593
673
|
bridge.on('session', (event) => {
|
|
594
|
-
|
|
674
|
+
const sessionRef = event.sessionFile || event.sessionId || event.threadId;
|
|
675
|
+
if (sessionRef) {
|
|
595
676
|
try {
|
|
596
677
|
this._db.prepare('UPDATE chat_sessions SET agent_session_id = ? WHERE id = ?')
|
|
597
|
-
.run(
|
|
598
|
-
logger.info(`[ChatSession] Session ${sessionId}
|
|
678
|
+
.run(sessionRef, sessionId);
|
|
679
|
+
logger.info(`[ChatSession] Session ${sessionId} agent ref: ${sessionRef}`);
|
|
599
680
|
} catch (err) {
|
|
600
|
-
logger.warn(`[ChatSession] Failed to store session
|
|
681
|
+
logger.warn(`[ChatSession] Failed to store session ref: ${err.message}`);
|
|
601
682
|
}
|
|
602
683
|
}
|
|
603
684
|
});
|
package/src/config.js
CHANGED
|
@@ -22,10 +22,12 @@ const DEFAULT_CONFIG = {
|
|
|
22
22
|
debug_stream: false, // When true, logs AI provider streaming events (equivalent to --debug-stream CLI flag)
|
|
23
23
|
db_name: "", // Custom database filename (default: database.db). Useful for per-worktree isolation.
|
|
24
24
|
yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
|
|
25
|
-
enable_chat: true, // When true, enables the chat panel feature (
|
|
25
|
+
enable_chat: true, // When true, enables the chat panel feature (uses chat_provider)
|
|
26
|
+
chat_provider: "pi", // Chat provider: 'pi', 'copilot-acp', 'gemini-acp', 'opencode-acp', 'cursor-acp', 'codex'
|
|
26
27
|
comment_format: "legacy", // Comment format preset or custom template for adopted suggestions
|
|
27
28
|
chat: { enable_shortcuts: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons)
|
|
28
|
-
providers: {}, // Custom provider configurations (overrides built-in defaults)
|
|
29
|
+
providers: {}, // Custom AI analysis provider configurations (overrides built-in defaults)
|
|
30
|
+
chat_providers: {}, // Custom chat provider configurations (overrides built-in defaults)
|
|
29
31
|
monorepos: {}, // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
|
|
30
32
|
assisted_by_url: "https://github.com/in-the-loop-labs/pair-review" // URL for "Review assisted by" footer link
|
|
31
33
|
};
|
package/src/database.js
CHANGED
|
@@ -20,7 +20,7 @@ function getDbPath() {
|
|
|
20
20
|
/**
|
|
21
21
|
* Current schema version - increment this when adding new migrations
|
|
22
22
|
*/
|
|
23
|
-
const CURRENT_SCHEMA_VERSION =
|
|
23
|
+
const CURRENT_SCHEMA_VERSION = 26;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Database schema SQL statements
|
|
@@ -247,6 +247,22 @@ const SCHEMA_SQL = {
|
|
|
247
247
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
248
248
|
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
|
|
249
249
|
)
|
|
250
|
+
`,
|
|
251
|
+
|
|
252
|
+
github_pr_cache: `
|
|
253
|
+
CREATE TABLE IF NOT EXISTS github_pr_cache (
|
|
254
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
255
|
+
owner TEXT NOT NULL,
|
|
256
|
+
repo TEXT NOT NULL,
|
|
257
|
+
number INTEGER NOT NULL,
|
|
258
|
+
title TEXT,
|
|
259
|
+
author TEXT,
|
|
260
|
+
updated_at TEXT,
|
|
261
|
+
html_url TEXT,
|
|
262
|
+
state TEXT DEFAULT 'open',
|
|
263
|
+
collection TEXT NOT NULL,
|
|
264
|
+
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
265
|
+
)
|
|
250
266
|
`
|
|
251
267
|
};
|
|
252
268
|
|
|
@@ -282,7 +298,9 @@ const INDEX_SQL = [
|
|
|
282
298
|
'CREATE INDEX IF NOT EXISTS idx_chat_sessions_review ON chat_sessions(review_id)',
|
|
283
299
|
'CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id)',
|
|
284
300
|
// Context files indexes
|
|
285
|
-
'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)'
|
|
301
|
+
'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)',
|
|
302
|
+
// GitHub PR cache indexes
|
|
303
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)'
|
|
286
304
|
];
|
|
287
305
|
|
|
288
306
|
/**
|
|
@@ -1158,6 +1176,36 @@ const MIGRATIONS = {
|
|
|
1158
1176
|
}
|
|
1159
1177
|
|
|
1160
1178
|
console.log('Migration to schema version 25 complete');
|
|
1179
|
+
},
|
|
1180
|
+
|
|
1181
|
+
// Migration to version 26: adds github_pr_cache table for PR collections
|
|
1182
|
+
26: (db) => {
|
|
1183
|
+
console.log('Migrating to schema version 26: Add github_pr_cache table');
|
|
1184
|
+
|
|
1185
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='github_pr_cache'").all();
|
|
1186
|
+
if (tables.length === 0) {
|
|
1187
|
+
db.exec(`
|
|
1188
|
+
CREATE TABLE IF NOT EXISTS github_pr_cache (
|
|
1189
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1190
|
+
owner TEXT NOT NULL,
|
|
1191
|
+
repo TEXT NOT NULL,
|
|
1192
|
+
number INTEGER NOT NULL,
|
|
1193
|
+
title TEXT,
|
|
1194
|
+
author TEXT,
|
|
1195
|
+
updated_at TEXT,
|
|
1196
|
+
html_url TEXT,
|
|
1197
|
+
state TEXT DEFAULT 'open',
|
|
1198
|
+
collection TEXT NOT NULL,
|
|
1199
|
+
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
1200
|
+
)
|
|
1201
|
+
`);
|
|
1202
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)');
|
|
1203
|
+
console.log(' Created github_pr_cache table');
|
|
1204
|
+
} else {
|
|
1205
|
+
console.log(' Table github_pr_cache already exists');
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
console.log('Migration to schema version 26 complete');
|
|
1161
1209
|
}
|
|
1162
1210
|
};
|
|
1163
1211
|
|
package/src/github/client.js
CHANGED
|
@@ -1145,6 +1145,49 @@ class GitHubClient {
|
|
|
1145
1145
|
throw new Error(`Failed to submit review: ${error.message}`);
|
|
1146
1146
|
}
|
|
1147
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* Search GitHub pull requests using the search API.
|
|
1150
|
+
* @param {string} searchQuery - Search query string (e.g., "is:pr is:open user-review-requested:USERNAME")
|
|
1151
|
+
* @returns {Promise<Array<{owner: string, repo: string, number: number, title: string, author: string, updated_at: string, html_url: string, state: string}>>}
|
|
1152
|
+
*/
|
|
1153
|
+
async searchPullRequests(searchQuery) {
|
|
1154
|
+
const items = await this.octokit.paginate(
|
|
1155
|
+
this.octokit.rest.search.issuesAndPullRequests,
|
|
1156
|
+
{ q: searchQuery, per_page: 100 }
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
return items.map(item => {
|
|
1160
|
+
// repository_url format: https://api.github.com/repos/OWNER/REPO
|
|
1161
|
+
const parts = item.repository_url.split('/');
|
|
1162
|
+
const repo = parts.pop();
|
|
1163
|
+
const owner = parts.pop();
|
|
1164
|
+
|
|
1165
|
+
return {
|
|
1166
|
+
owner,
|
|
1167
|
+
repo,
|
|
1168
|
+
number: item.number,
|
|
1169
|
+
title: item.title,
|
|
1170
|
+
author: item.user?.login || null,
|
|
1171
|
+
updated_at: item.updated_at,
|
|
1172
|
+
html_url: item.html_url,
|
|
1173
|
+
state: item.state
|
|
1174
|
+
};
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Get the authenticated user's information.
|
|
1180
|
+
* @returns {Promise<{login: string, name: string, avatar_url: string}>}
|
|
1181
|
+
*/
|
|
1182
|
+
async getAuthenticatedUser() {
|
|
1183
|
+
const { data } = await this.octokit.rest.users.getAuthenticated();
|
|
1184
|
+
return {
|
|
1185
|
+
login: data.login,
|
|
1186
|
+
name: data.name,
|
|
1187
|
+
avatar_url: data.avatar_url
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1148
1191
|
/**
|
|
1149
1192
|
* Retry API calls with exponential backoff
|
|
1150
1193
|
* @param {Function} apiCall - The API call function
|
package/src/routes/chat.js
CHANGED
|
@@ -12,15 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const express = require('express');
|
|
15
|
-
const path = require('path');
|
|
16
15
|
const { queryOne, query, AnalysisRunRepository, RepoSettingsRepository } = require('../database');
|
|
17
16
|
const { buildChatPrompt, buildInitialContext } = require('../chat/prompt-builder');
|
|
17
|
+
const { renderApiDocs, buildApiCheatSheet } = require('../chat/api-reference');
|
|
18
18
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
19
19
|
const logger = require('../utils/logger');
|
|
20
20
|
const ws = require('../ws');
|
|
21
21
|
|
|
22
|
-
const pairReviewSkillPath = path.resolve(__dirname, '../../.pi/skills/pair-review-api/SKILL.md');
|
|
23
|
-
|
|
24
22
|
const router = express.Router();
|
|
25
23
|
|
|
26
24
|
/**
|
|
@@ -90,7 +88,7 @@ const broadcastUnsubscribers = new Map();
|
|
|
90
88
|
* @returns {RegExp}
|
|
91
89
|
*/
|
|
92
90
|
function buildPairReviewApiRe(port) {
|
|
93
|
-
return new RegExp(`\\bcurl\\b.*\\bhttps?://(?:localhost|127\\.0\\.0\\.1):${port}/api
|
|
91
|
+
return new RegExp(`\\bcurl\\b.*\\bhttps?://(?:localhost|127\\.0\\.0\\.1):${port}/api`);
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
/**
|
|
@@ -139,15 +137,6 @@ function registerChatBroadcast(chatSessionManager, sessionId, port) {
|
|
|
139
137
|
}
|
|
140
138
|
}
|
|
141
139
|
|
|
142
|
-
// Suppress tool badges for reading the API skill file
|
|
143
|
-
if (data.toolName?.toLowerCase() === 'read') {
|
|
144
|
-
const readPath = data.args?.path || data.args?.file_path || '';
|
|
145
|
-
if (readPath.endsWith('pair-review-api/SKILL.md')) {
|
|
146
|
-
hiddenToolCallIds.add(data.toolCallId);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
140
|
// Suppress follow-up events (update/end) for any hidden tool call
|
|
152
141
|
if (hiddenToolCallIds.has(data.toolCallId)) {
|
|
153
142
|
if (data.status === 'end') hiddenToolCallIds.delete(data.toolCallId);
|
|
@@ -245,7 +234,7 @@ router.post('/api/chat/session', async (req, res) => {
|
|
|
245
234
|
const chatInstructions = await getChatInstructions(db, review);
|
|
246
235
|
const prData = await fetchPrData(db, review);
|
|
247
236
|
|
|
248
|
-
finalSystemPrompt = buildChatPrompt({ review, prData,
|
|
237
|
+
finalSystemPrompt = buildChatPrompt({ review, prData, chatInstructions });
|
|
249
238
|
|
|
250
239
|
if (!skipAnalysisContext) {
|
|
251
240
|
// Fetch all AI suggestions from the latest analysis run
|
|
@@ -287,15 +276,16 @@ router.post('/api/chat/session', async (req, res) => {
|
|
|
287
276
|
// Resolve cwd: explicit from request body, or the review's code directory
|
|
288
277
|
const resolvedCwd = cwd || await resolveReviewCwd(db, review);
|
|
289
278
|
|
|
290
|
-
// Inject the server port into the initial context so
|
|
291
|
-
// once at session start.
|
|
292
|
-
//
|
|
293
|
-
// session will pick up the new value automatically.
|
|
279
|
+
// Inject the server port and API cheat-sheet into the initial context so
|
|
280
|
+
// the agent learns it once at session start. If the server restarts on a
|
|
281
|
+
// new port, the next session will pick up the new value automatically.
|
|
294
282
|
const serverPort = req.socket.localPort;
|
|
295
283
|
const portContext = `[Server port: ${serverPort}] The pair-review API is at http://localhost:${serverPort}`;
|
|
284
|
+
const cheatSheet = buildApiCheatSheet({ port: serverPort, reviewId: review.id });
|
|
285
|
+
const sessionPreamble = portContext + '\n\n' + cheatSheet;
|
|
296
286
|
const initialContextWithPort = initialContext
|
|
297
|
-
?
|
|
298
|
-
:
|
|
287
|
+
? sessionPreamble + '\n\n' + initialContext
|
|
288
|
+
: sessionPreamble;
|
|
299
289
|
|
|
300
290
|
const session = await chatSessionManager.createSession({
|
|
301
291
|
provider,
|
|
@@ -354,6 +344,7 @@ router.post('/api/chat/session/:id/message', async (req, res) => {
|
|
|
354
344
|
const db = req.app.get('db');
|
|
355
345
|
|
|
356
346
|
// Auto-resume: if session is not active in memory, try to resume it
|
|
347
|
+
let portCorrectionContext = null;
|
|
357
348
|
if (!chatSessionManager.isSessionActive(sessionId)) {
|
|
358
349
|
const session = chatSessionManager.getSession(sessionId);
|
|
359
350
|
if (!session) {
|
|
@@ -372,7 +363,7 @@ router.post('/api/chat/session/:id/message', async (req, res) => {
|
|
|
372
363
|
const chatInstructions = await getChatInstructions(db, review);
|
|
373
364
|
const prData = await fetchPrData(db, review);
|
|
374
365
|
|
|
375
|
-
const systemPrompt = buildChatPrompt({ review, prData,
|
|
366
|
+
const systemPrompt = buildChatPrompt({ review, prData, chatInstructions });
|
|
376
367
|
const cwd = await resolveReviewCwd(db, review);
|
|
377
368
|
|
|
378
369
|
try {
|
|
@@ -380,14 +371,27 @@ router.post('/api/chat/session/:id/message', async (req, res) => {
|
|
|
380
371
|
unregisterChatBroadcast(sessionId);
|
|
381
372
|
registerChatBroadcast(chatSessionManager, sessionId, req.socket.localPort);
|
|
382
373
|
logger.info(`[ChatRoute] Auto-resumed session ${sessionId} for message delivery`);
|
|
374
|
+
|
|
375
|
+
// Inject port correction so the agent knows the current server address,
|
|
376
|
+
// even if the conversational history has a stale port from session creation.
|
|
377
|
+
const serverPort = req.socket.localPort;
|
|
378
|
+
portCorrectionContext = `[Server port: ${serverPort}] The pair-review API is at http://localhost:${serverPort}`;
|
|
379
|
+
// Note: we intentionally do NOT re-inject the API cheat sheet on resume.
|
|
380
|
+
// The agent already has the endpoint shapes from the original session context —
|
|
381
|
+
// it only needs the updated port to adjust its curl calls.
|
|
383
382
|
} catch (err) {
|
|
384
383
|
logger.error(`[ChatRoute] Failed to auto-resume session ${sessionId}: ${err.message}`);
|
|
385
384
|
return res.status(410).json({ error: 'Failed to resume session: ' + err.message });
|
|
386
385
|
}
|
|
387
386
|
}
|
|
388
387
|
|
|
388
|
+
// Merge port correction context (from auto-resume) with any request-body context
|
|
389
|
+
const mergedContext = portCorrectionContext
|
|
390
|
+
? (context ? portCorrectionContext + '\n\n' + context : portCorrectionContext)
|
|
391
|
+
: context;
|
|
392
|
+
|
|
389
393
|
logger.debug(`[ChatRoute] Forwarding message to session ${sessionId} (${content.length} chars)`);
|
|
390
|
-
const result = await chatSessionManager.sendMessage(sessionId, content, { context, contextData, actionContext });
|
|
394
|
+
const result = await chatSessionManager.sendMessage(sessionId, content, { context: mergedContext, contextData, actionContext });
|
|
391
395
|
logger.debug(`[ChatRoute] Message stored as ID ${result.id}, awaiting agent response via WebSocket`);
|
|
392
396
|
res.json({ data: { messageId: result.id } });
|
|
393
397
|
} catch (error) {
|
|
@@ -490,18 +494,28 @@ router.post('/api/chat/session/:id/resume', async (req, res) => {
|
|
|
490
494
|
return res.status(404).json({ error: 'Review not found for session' });
|
|
491
495
|
}
|
|
492
496
|
|
|
493
|
-
// Pi's --session replays the original conversation;
|
|
494
|
-
// re-injects the review context so the agent retains
|
|
495
|
-
// even if the system prompt was only in the
|
|
497
|
+
// Pi's --session replays the original conversation;
|
|
498
|
+
// --append-system-prompt re-injects the review context so the agent retains
|
|
499
|
+
// awareness of the codebase even if the system prompt was only in the
|
|
500
|
+
// initial session's context.
|
|
496
501
|
const chatInstructions = await getChatInstructions(db, review);
|
|
497
502
|
const prData = await fetchPrData(db, review);
|
|
498
503
|
|
|
499
|
-
const systemPrompt = buildChatPrompt({ review, prData,
|
|
504
|
+
const systemPrompt = buildChatPrompt({ review, prData, chatInstructions });
|
|
500
505
|
const cwd = await resolveReviewCwd(db, review);
|
|
501
506
|
|
|
502
507
|
await chatSessionManager.resumeSession(sessionId, { systemPrompt, cwd });
|
|
503
508
|
unregisterChatBroadcast(sessionId);
|
|
504
|
-
|
|
509
|
+
const serverPort = req.socket.localPort;
|
|
510
|
+
registerChatBroadcast(chatSessionManager, sessionId, serverPort);
|
|
511
|
+
|
|
512
|
+
// Inject port correction so the agent knows the current server address,
|
|
513
|
+
// even if the conversational history has a stale port from session creation.
|
|
514
|
+
// Uses resumeContext (consumed on next sendMessage) instead of saveContextMessage
|
|
515
|
+
// (which only writes to DB and never reaches the agent process).
|
|
516
|
+
chatSessionManager.setResumeContext(sessionId,
|
|
517
|
+
`[Server port: ${serverPort}] The pair-review API is at http://localhost:${serverPort}`
|
|
518
|
+
);
|
|
505
519
|
|
|
506
520
|
logger.info(`[ChatRoute] Explicitly resumed session ${sessionId}`);
|
|
507
521
|
res.json({ data: { id: sessionId, status: 'active' } });
|
|
@@ -611,6 +625,25 @@ router.get('/api/chat/analysis-context/:runId', async (req, res) => {
|
|
|
611
625
|
}
|
|
612
626
|
});
|
|
613
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Serve the full API reference as rendered markdown with real values baked in.
|
|
630
|
+
* Requires ?reviewId=N so the docs contain the correct review ID.
|
|
631
|
+
*/
|
|
632
|
+
router.get('/api.md', (req, res) => {
|
|
633
|
+
try {
|
|
634
|
+
const reviewId = parseInt(req.query.reviewId, 10);
|
|
635
|
+
if (!reviewId || !Number.isInteger(reviewId)) {
|
|
636
|
+
return res.status(400).json({ error: 'Missing required query parameter: reviewId' });
|
|
637
|
+
}
|
|
638
|
+
const port = req.socket.localPort;
|
|
639
|
+
const md = renderApiDocs({ port, reviewId });
|
|
640
|
+
res.type('text/markdown').send(md);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
logger.error(`Error serving API docs: ${error.message}`);
|
|
643
|
+
res.status(500).json({ error: 'Failed to render API docs' });
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
614
647
|
module.exports = router;
|
|
615
648
|
|
|
616
649
|
// Expose internals for testing
|
package/src/routes/config.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
} = require('../ai');
|
|
22
22
|
const { normalizeRepository } = require('../utils/paths');
|
|
23
23
|
const { isRunningViaNpx, saveConfig } = require('../config');
|
|
24
|
+
const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
|
|
24
25
|
const { PRESETS } = require('../utils/comment-formatter');
|
|
25
26
|
const logger = require('../utils/logger');
|
|
26
27
|
|
|
@@ -33,6 +34,12 @@ const router = express.Router();
|
|
|
33
34
|
router.get('/api/config', (req, res) => {
|
|
34
35
|
const config = req.app.get('config') || {};
|
|
35
36
|
|
|
37
|
+
// Build chat_providers array with availability
|
|
38
|
+
const chatAvailability = getAllCachedChatAvailability();
|
|
39
|
+
const chatProviders = getAllChatProviders().map(p => ({
|
|
40
|
+
id: p.id, name: p.name, available: chatAvailability[p.id]?.available || false
|
|
41
|
+
}));
|
|
42
|
+
|
|
36
43
|
// Only return safe configuration values (not secrets like github_token)
|
|
37
44
|
res.json({
|
|
38
45
|
theme: config.theme || 'light',
|
|
@@ -41,9 +48,25 @@ router.get('/api/config', (req, res) => {
|
|
|
41
48
|
// Include npx detection for frontend command examples
|
|
42
49
|
is_running_via_npx: isRunningViaNpx(),
|
|
43
50
|
enable_chat: config.enable_chat !== false,
|
|
51
|
+
chat_provider: config.chat_provider || 'pi',
|
|
52
|
+
chat_providers: chatProviders,
|
|
44
53
|
chat_enable_shortcuts: config.chat?.enable_shortcuts !== false,
|
|
45
54
|
pi_available: getCachedAvailability('pi')?.available || false,
|
|
46
|
-
assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review'
|
|
55
|
+
assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review',
|
|
56
|
+
// Share configuration for external review viewers.
|
|
57
|
+
// - url: The base URL of the external share site
|
|
58
|
+
// - method: Plumbed through for future use (e.g., POST-based share flows).
|
|
59
|
+
// The current implementation only uses GET via window.open().
|
|
60
|
+
// - icon: Optional custom SVG icon for the share button
|
|
61
|
+
// - label: Optional custom label for the share menu item (e.g., "Share to Acme")
|
|
62
|
+
// - description: Optional tooltip text shown on hover (e.g., "Share to Acme review board")
|
|
63
|
+
share: config.share ? {
|
|
64
|
+
url: config.share.url || null,
|
|
65
|
+
method: config.share.method || 'GET',
|
|
66
|
+
icon: config.share.icon || null,
|
|
67
|
+
label: config.share.label || null,
|
|
68
|
+
description: config.share.description || null
|
|
69
|
+
} : null
|
|
47
70
|
});
|
|
48
71
|
});
|
|
49
72
|
|