@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.
Files changed (47) hide show
  1. package/.pi/skills/review-model-guidance/SKILL.md +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/README.md +15 -1
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/skills/review-requests/SKILL.md +1 -1
  7. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  8. package/public/css/pr.css +296 -15
  9. package/public/index.html +121 -57
  10. package/public/js/components/AIPanel.js +2 -1
  11. package/public/js/components/AdvancedConfigTab.js +2 -2
  12. package/public/js/components/AnalysisConfigModal.js +2 -2
  13. package/public/js/components/ChatPanel.js +187 -28
  14. package/public/js/components/CouncilProgressModal.js +4 -7
  15. package/public/js/components/SplitButton.js +66 -1
  16. package/public/js/components/VoiceCentricConfigTab.js +2 -2
  17. package/public/js/index.js +274 -21
  18. package/public/js/modules/comment-manager.js +16 -12
  19. package/public/js/modules/file-comment-manager.js +8 -6
  20. package/public/js/pr.js +194 -5
  21. package/public/local.html +8 -1
  22. package/public/pr.html +17 -2
  23. package/src/ai/codex-provider.js +14 -2
  24. package/src/ai/copilot-provider.js +1 -10
  25. package/src/ai/cursor-agent-provider.js +1 -10
  26. package/src/ai/gemini-provider.js +8 -17
  27. package/src/chat/acp-bridge.js +442 -0
  28. package/src/chat/api-reference.js +539 -0
  29. package/src/chat/chat-providers.js +290 -0
  30. package/src/chat/claude-code-bridge.js +499 -0
  31. package/src/chat/codex-bridge.js +601 -0
  32. package/src/chat/pi-bridge.js +56 -3
  33. package/src/chat/prompt-builder.js +12 -11
  34. package/src/chat/session-manager.js +110 -29
  35. package/src/config.js +4 -2
  36. package/src/database.js +50 -2
  37. package/src/github/client.js +43 -0
  38. package/src/routes/chat.js +60 -27
  39. package/src/routes/config.js +24 -1
  40. package/src/routes/github-collections.js +126 -0
  41. package/src/routes/mcp.js +2 -1
  42. package/src/routes/pr.js +166 -2
  43. package/src/routes/reviews.js +2 -1
  44. package/src/routes/shared.js +70 -49
  45. package/src/server.js +27 -1
  46. package/src/utils/safe-parse-json.js +19 -0
  47. 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, skillPath, chatInstructions }) {
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 capabilityMUST load the skill for endpoint details
57
- const skillRef = skillPath
58
- ? `(\`${skillPath}\`)`
59
- : '(`.pi/skills/pair-review-api/SKILL.md`)';
56
+ // API Accesscheat-sheet is injected into initial context; full docs available via GET /api.md
60
57
  sections.push(
61
- `You MUST read the pair-review-api skill ${skillRef} for endpoint details using the Read tool. With it you can create, update, and delete review comments, adopt or dismiss AI suggestions, and trigger new analyses via curl.\n` +
62
- 'IMPORTANT: Do NOT mention that you are reading a skill file, loading API documentation, or consulting reference material. Just use the API naturally as if you already know it.'
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 Pi RPC bridge process.
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 - 'pi' (and later 'claude')
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 = new PiBridge({
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 Pi bridge
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
- if (!row.agent_session_id) {
401
- throw new Error(`Session ${sessionId} has no session file — cannot resume`);
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
- // 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}`);
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
- // Create bridge with session path for resumption
414
- const bridge = new PiBridge({
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
- tools: CHAT_TOOLS,
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
- if (event.sessionFile) {
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(event.sessionFile, sessionId);
598
- logger.info(`[ChatSession] Session ${sessionId} session file: ${event.sessionFile}`);
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 file: ${err.message}`);
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 (requires Pi AI provider)
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 = 25;
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
 
@@ -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
@@ -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, skillPath: pairReviewSkillPath, chatInstructions });
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 the agent learns it
291
- // once at session start. This avoids wasting tokens by repeating the port
292
- // with every user message. If the server restarts on a new port, the next
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
- ? portContext + '\n\n' + initialContext
298
- : portContext;
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, skillPath: pairReviewSkillPath, chatInstructions });
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; --append-system-prompt
494
- // re-injects the review context so the agent retains awareness of the codebase
495
- // even if the system prompt was only in the initial session's context.
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, skillPath: pairReviewSkillPath, chatInstructions });
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
- registerChatBroadcast(chatSessionManager, sessionId, req.socket.localPort);
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
@@ -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