@in-the-loop-labs/pair-review 1.3.2 → 1.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.
@@ -494,6 +494,28 @@ function getRegisteredProviderIds() {
494
494
  return Array.from(providerRegistry.keys());
495
495
  }
496
496
 
497
+ /**
498
+ * Merge config-override models with a provider's built-in models.
499
+ * Config models with matching IDs replace built-ins; config models with new IDs
500
+ * are appended. If no config models exist, returns built-ins unchanged.
501
+ *
502
+ * @param {Array<Object>} builtInModels - Models from ProviderClass.getModels()
503
+ * @param {Array<Object>|undefined} configModels - Models from config overrides
504
+ * @returns {Array<Object>} Merged model list
505
+ */
506
+ function mergeModels(builtInModels, configModels) {
507
+ if (!configModels || configModels.length === 0) {
508
+ return builtInModels;
509
+ }
510
+ const configById = new Map(configModels.map(m => [m.id, m]));
511
+ // Replace overridden built-ins in-place to preserve display order
512
+ const merged = builtInModels.map(m => configById.get(m.id) || m);
513
+ // Append config models with new IDs not present in built-ins
514
+ const builtInIds = new Set(builtInModels.map(m => m.id));
515
+ const newModels = configModels.filter(m => !builtInIds.has(m.id));
516
+ return [...merged, ...newModels];
517
+ }
518
+
497
519
  /**
498
520
  * Get provider info for all registered providers
499
521
  * Uses config overrides for models/installInstructions if available
@@ -504,10 +526,10 @@ function getAllProvidersInfo() {
504
526
  for (const [id, ProviderClass] of providerRegistry) {
505
527
  const overrides = providerConfigOverrides.get(id);
506
528
 
507
- // Use overridden models if available, otherwise use built-in
508
- const models = overrides?.models || ProviderClass.getModels();
529
+ // Merge config models with built-in models (config wins on ID collision)
530
+ const models = mergeModels(ProviderClass.getModels(), overrides?.models);
509
531
 
510
- // Resolve default model from (potentially overridden) models array
532
+ // Resolve default model from merged models array
511
533
  const defaultModel = resolveDefaultModel(models) || ProviderClass.getDefaultModel();
512
534
 
513
535
  // Use overridden install instructions if available
@@ -545,9 +567,11 @@ function createProvider(providerId, model = null) {
545
567
  // Determine the actual model to use
546
568
  let actualModel = model;
547
569
  if (!actualModel) {
548
- // If models are overridden, resolve default from them
549
- if (overrides?.models) {
550
- actualModel = resolveDefaultModel(overrides.models);
570
+ // Resolve default from merged models (config + built-in).
571
+ // Checks both sources because some providers (e.g., Pi) define built-in
572
+ // modes with default:true that aren't in config overrides.
573
+ if (overrides?.models || ProviderClass.getModels().length > 0) {
574
+ actualModel = resolveDefaultModel(mergeModels(ProviderClass.getModels(), overrides?.models));
551
575
  }
552
576
  // Fall back to provider's built-in default
553
577
  if (!actualModel) {
@@ -572,9 +596,9 @@ function getTierForModel(providerId, modelId) {
572
596
  return null;
573
597
  }
574
598
 
575
- // Use overridden models if available
599
+ // Merge config models with built-in models
576
600
  const overrides = providerConfigOverrides.get(providerId);
577
- const models = overrides?.models || ProviderClass.getModels();
601
+ const models = mergeModels(ProviderClass.getModels(), overrides?.models);
578
602
 
579
603
  const model = models.find(m => m.id === modelId);
580
604
  return model?.tier || null;
@@ -71,8 +71,9 @@ function extractToolDetail(input, cwd) {
71
71
  // Command execution (bash, shell, etc.)
72
72
  if (parsed.command) return parsed.command;
73
73
 
74
- // Task/agent description (Claude Code Task tool, etc.)
74
+ // Task/agent description (Claude Code Task tool, Pi task extension, etc.)
75
75
  if (parsed.description) return parsed.description;
76
+ if (parsed.task) return parsed.task;
76
77
 
77
78
  // File path (various field naming conventions)
78
79
  const rawPath = parsed.file_path || parsed.filePath || parsed.path;
@@ -462,6 +463,172 @@ function parseCursorAgentLine(line, options = {}) {
462
463
  }
463
464
  }
464
465
 
466
+ /**
467
+ * Parse a single Pi JSONL line into a normalized event.
468
+ * Returns null if the line should not be emitted.
469
+ *
470
+ * Pi JSONL event types:
471
+ * - message_update (assistantMessageEvent.type === 'text_delta') → assistant_text
472
+ * - message_end (role: 'assistant', content blocks with text) → assistant_text
473
+ * - tool_execution_start (toolName, args) → tool_use
474
+ * - session, turn_start, turn_end, message_start, tool_execution_update,
475
+ * tool_execution_end, agent_start, agent_end → filtered out
476
+ *
477
+ * @param {string} line - A single JSONL line from Pi stdout
478
+ * @param {Object} [options] - Parse options
479
+ * @param {string} [options.cwd] - Working directory to strip from file paths
480
+ * @returns {{ type: string, text: string, timestamp: number } | null}
481
+ */
482
+ function parsePiLine(line, options = {}) {
483
+ if (!line || !line.trim()) return null;
484
+
485
+ const { cwd } = options;
486
+
487
+ try {
488
+ const event = JSON.parse(line);
489
+ const eventType = event.type;
490
+
491
+ // Streaming text deltas from message_update events
492
+ if (eventType === 'message_update') {
493
+ const assistantEvent = event.assistantMessageEvent;
494
+ if (assistantEvent?.type === 'text_delta' && assistantEvent?.delta?.trim()) {
495
+ return {
496
+ type: 'assistant_text',
497
+ text: truncateSnippet(assistantEvent.delta),
498
+ timestamp: Date.now()
499
+ };
500
+ }
501
+ return null;
502
+ }
503
+
504
+ // Complete assistant message from message_end
505
+ if (eventType === 'message_end' && event.message?.role === 'assistant') {
506
+ const content = event.message.content;
507
+ if (Array.isArray(content)) {
508
+ for (const block of content) {
509
+ if (block.type === 'text' && block.text?.trim()) {
510
+ return {
511
+ type: 'assistant_text',
512
+ text: truncateSnippet(block.text),
513
+ timestamp: Date.now()
514
+ };
515
+ }
516
+ }
517
+ } else if (typeof content === 'string' && content.trim()) {
518
+ return {
519
+ type: 'assistant_text',
520
+ text: truncateSnippet(content),
521
+ timestamp: Date.now()
522
+ };
523
+ }
524
+ return null;
525
+ }
526
+
527
+ // Tool execution start events
528
+ if (eventType === 'tool_execution_start') {
529
+ const toolName = event.toolName || 'unknown';
530
+ const detail = extractToolDetail(event.args, cwd);
531
+ const text = detail ? `${toolName}: ${detail}` : toolName;
532
+ return {
533
+ type: 'tool_use',
534
+ text: truncateSnippet(text),
535
+ timestamp: Date.now()
536
+ };
537
+ }
538
+
539
+ // Tool execution end — emit for task tools to show completion status
540
+ if (eventType === 'tool_execution_end') {
541
+ const toolName = event.toolName || '';
542
+ if (toolName === 'task') {
543
+ const isError = event.isError || false;
544
+ const result = event.result || '';
545
+ // Extract a short summary from the result
546
+ let summary = '';
547
+ if (typeof result === 'string') {
548
+ // First non-empty line as preview
549
+ const firstLine = result.split('\n').find(l => l.trim());
550
+ if (firstLine) summary = firstLine.trim();
551
+ }
552
+ const status = isError ? '✗' : '✓';
553
+ const text = summary
554
+ ? `task ${status}: ${summary}`
555
+ : `task ${status}`;
556
+ return {
557
+ type: 'tool_use',
558
+ text: truncateSnippet(text),
559
+ timestamp: Date.now()
560
+ };
561
+ }
562
+ }
563
+
564
+ // session, turn_start, turn_end, message_start, tool_execution_update,
565
+ // tool_execution_end (non-task), agent_start, agent_end — never emit
566
+ return null;
567
+ } catch {
568
+ // Best-effort side channel — silently ignore non-JSON or malformed lines.
569
+ return null;
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Create a stateful Pi line parser that accumulates text_delta fragments
575
+ * before emitting. This prevents flooding the UI with tiny text updates.
576
+ *
577
+ * Accumulates text_delta content and only emits an assistant_text event
578
+ * when enough text has been collected (>= 80 chars). When a non-text-delta
579
+ * event arrives (tool_execution_start, message_end, etc.), accumulated text
580
+ * is discarded in favor of the more informative event.
581
+ *
582
+ * The returned function has the same signature as parsePiLine and can be
583
+ * used as a drop-in replacement with StreamParser.
584
+ *
585
+ * @returns {Function} Stateful line parser with same signature as parsePiLine
586
+ */
587
+ function createPiLineParser() {
588
+ let accumulatedDelta = '';
589
+
590
+ return function parsePiLineBuffered(line, options = {}) {
591
+ if (!line || !line.trim()) return null;
592
+
593
+ try {
594
+ const event = JSON.parse(line);
595
+ const eventType = event.type;
596
+
597
+ // Accumulate text_delta fragments instead of emitting each one
598
+ if (eventType === 'message_update') {
599
+ const assistantEvent = event.assistantMessageEvent;
600
+ if (assistantEvent?.type === 'text_delta' && assistantEvent?.delta) {
601
+ accumulatedDelta += assistantEvent.delta;
602
+ // Only emit when we have accumulated a meaningful chunk
603
+ if (accumulatedDelta.length >= 80) {
604
+ const text = accumulatedDelta;
605
+ accumulatedDelta = '';
606
+ return {
607
+ type: 'assistant_text',
608
+ text: truncateSnippet(text),
609
+ timestamp: Date.now()
610
+ };
611
+ }
612
+ // Not enough accumulated yet — suppress this event
613
+ return null;
614
+ }
615
+ return null;
616
+ }
617
+
618
+ // Non-text-delta event: discard accumulated text (the real event is
619
+ // more informative) and reset the buffer. The accumulated deltas are
620
+ // only preview snippets; authoritative text extraction happens in
621
+ // parsePiResponse.
622
+ accumulatedDelta = '';
623
+
624
+ // Fall through to stateless parsing for non-text-delta events
625
+ return parsePiLine(line, options);
626
+ } catch {
627
+ return null;
628
+ }
629
+ };
630
+ }
631
+
465
632
  module.exports = {
466
633
  StreamParser,
467
634
  truncateSnippet,
@@ -471,5 +638,7 @@ module.exports = {
471
638
  parseCodexLine,
472
639
  parseGeminiLine,
473
640
  parseOpenCodeLine,
474
- parseCursorAgentLine
641
+ parseCursorAgentLine,
642
+ parsePiLine,
643
+ createPiLineParser
475
644
  };
package/src/config.js CHANGED
@@ -13,7 +13,7 @@ const DEFAULT_CONFIG = {
13
13
  github_token: "",
14
14
  port: 7247,
15
15
  theme: "light",
16
- default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode'
16
+ default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
17
17
  default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
18
18
  worktree_retention_days: 7,
19
19
  dev_mode: false, // When true, disables static file caching for development
package/src/database.js CHANGED
@@ -9,7 +9,7 @@ const DB_PATH = path.join(getConfigDir(), 'database.db');
9
9
  /**
10
10
  * Current schema version - increment this when adding new migrations
11
11
  */
12
- const CURRENT_SCHEMA_VERSION = 13;
12
+ const CURRENT_SCHEMA_VERSION = 14;
13
13
 
14
14
  /**
15
15
  * Database schema SQL statements
@@ -30,7 +30,8 @@ const SCHEMA_SQL = {
30
30
  review_type TEXT DEFAULT 'pr' CHECK(review_type IN ('pr', 'local')),
31
31
  local_path TEXT,
32
32
  local_head_sha TEXT,
33
- summary TEXT
33
+ summary TEXT,
34
+ name TEXT
34
35
  )
35
36
  `,
36
37
 
@@ -146,6 +147,17 @@ const SCHEMA_SQL = {
146
147
  )
147
148
  `,
148
149
 
150
+ local_diffs: `
151
+ CREATE TABLE IF NOT EXISTS local_diffs (
152
+ review_id INTEGER PRIMARY KEY,
153
+ diff_text TEXT,
154
+ stats TEXT,
155
+ digest TEXT,
156
+ captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,
157
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
158
+ )
159
+ `,
160
+
149
161
  github_reviews: `
150
162
  CREATE TABLE IF NOT EXISTS github_reviews (
151
163
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -212,6 +224,20 @@ function columnExists(db, table, column) {
212
224
  return rows ? rows.some(row => row.name === column) : false;
213
225
  }
214
226
 
227
+ /**
228
+ * Helper to check if a table exists in the database
229
+ * Used by migrations to safely create tables idempotently
230
+ * @param {Database} db - Database instance
231
+ * @param {string} tableName - Table name
232
+ * @returns {boolean} True if table exists
233
+ */
234
+ function tableExists(db, tableName) {
235
+ const row = db.prepare(
236
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`
237
+ ).get(tableName);
238
+ return !!row;
239
+ }
240
+
215
241
  const MIGRATIONS = {
216
242
  // Migration to version 1: handles all legacy column additions
217
243
  1: (db) => {
@@ -234,14 +260,6 @@ const MIGRATIONS = {
234
260
  }
235
261
  };
236
262
 
237
- // Helper to check if table exists
238
- const tableExists = (tableName) => {
239
- const row = db.prepare(
240
- `SELECT name FROM sqlite_master WHERE type='table' AND name=?`
241
- ).get(tableName);
242
- return !!row;
243
- };
244
-
245
263
  // Add columns to comments table
246
264
  addColumnIfNotExists('comments', 'diff_position', 'INTEGER');
247
265
  addColumnIfNotExists('comments', 'side', "TEXT DEFAULT 'RIGHT'");
@@ -252,7 +270,7 @@ const MIGRATIONS = {
252
270
  addColumnIfNotExists('reviews', 'custom_instructions', 'TEXT');
253
271
 
254
272
  // Create repo_settings table if not exists
255
- const hasRepoSettings = tableExists('repo_settings');
273
+ const hasRepoSettings = tableExists(db, 'repo_settings');
256
274
  if (!hasRepoSettings) {
257
275
  console.log(' Creating repo_settings table...');
258
276
  db.exec(SCHEMA_SQL.repo_settings);
@@ -280,16 +298,8 @@ const MIGRATIONS = {
280
298
  3: (db) => {
281
299
  console.log('Running migration to schema version 3...');
282
300
 
283
- // Helper to check if table exists
284
- const tableExists = (tableName) => {
285
- const row = db.prepare(
286
- `SELECT name FROM sqlite_master WHERE type='table' AND name=?`
287
- ).get(tableName);
288
- return !!row;
289
- };
290
-
291
301
  // First ensure pr_metadata table exists
292
- const hasPrMetadata = tableExists('pr_metadata');
302
+ const hasPrMetadata = tableExists(db, 'pr_metadata');
293
303
  if (!hasPrMetadata) {
294
304
  console.log(' Creating pr_metadata table...');
295
305
  db.exec(SCHEMA_SQL.pr_metadata);
@@ -451,16 +461,8 @@ const MIGRATIONS = {
451
461
  8: (db) => {
452
462
  console.log('Running migration to schema version 8...');
453
463
 
454
- // Helper to check if table exists
455
- const tableExists = (tableName) => {
456
- const row = db.prepare(
457
- `SELECT name FROM sqlite_master WHERE type='table' AND name=?`
458
- ).get(tableName);
459
- return !!row;
460
- };
461
-
462
464
  // Create analysis_runs table if it doesn't exist
463
- if (!tableExists('analysis_runs')) {
465
+ if (!tableExists(db, 'analysis_runs')) {
464
466
  db.exec(`
465
467
  CREATE TABLE analysis_runs (
466
468
  id TEXT PRIMARY KEY,
@@ -609,16 +611,8 @@ const MIGRATIONS = {
609
611
  13: (db) => {
610
612
  console.log('Running migration to schema version 13...');
611
613
 
612
- // Helper to check if table exists
613
- const tableExists = (tableName) => {
614
- const row = db.prepare(
615
- `SELECT name FROM sqlite_master WHERE type='table' AND name=?`
616
- ).get(tableName);
617
- return !!row;
618
- };
619
-
620
614
  // Create github_reviews table if it doesn't exist
621
- if (!tableExists('github_reviews')) {
615
+ if (!tableExists(db, 'github_reviews')) {
622
616
  db.exec(`
623
617
  CREATE TABLE github_reviews (
624
618
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -644,6 +638,51 @@ const MIGRATIONS = {
644
638
  }
645
639
 
646
640
  console.log('Migration to schema version 13 complete');
641
+ },
642
+
643
+ // Migration to version 14: adds name column to reviews and creates local_diffs table
644
+ 14: (db) => {
645
+ console.log('Running migration to schema version 14...');
646
+
647
+ // Add name column to reviews if it doesn't exist
648
+ const hasName = columnExists(db, 'reviews', 'name');
649
+ if (!hasName) {
650
+ try {
651
+ db.prepare(`ALTER TABLE reviews ADD COLUMN name TEXT`).run();
652
+ console.log(' Added name column to reviews');
653
+ } catch (error) {
654
+ // Ignore duplicate column errors (race condition protection)
655
+ if (!error.message.includes('duplicate column name')) {
656
+ throw error;
657
+ }
658
+ console.log(' Column name already exists (race condition)');
659
+ }
660
+ } else {
661
+ console.log(' Column name already exists');
662
+ }
663
+
664
+ // Create local_diffs table if it doesn't exist
665
+ if (!tableExists(db, 'local_diffs')) {
666
+ db.exec(`
667
+ CREATE TABLE local_diffs (
668
+ review_id INTEGER PRIMARY KEY,
669
+ diff_text TEXT,
670
+ stats TEXT,
671
+ digest TEXT,
672
+ captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,
673
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
674
+ )
675
+ `);
676
+ console.log(' Created local_diffs table');
677
+ } else {
678
+ console.log(' Table local_diffs already exists');
679
+ }
680
+
681
+ // Add index for listing local sessions (WHERE review_type = 'local' ORDER BY updated_at DESC)
682
+ db.exec('CREATE INDEX IF NOT EXISTS idx_reviews_type_updated ON reviews(review_type, updated_at DESC)');
683
+ console.log(' Created index idx_reviews_type_updated');
684
+
685
+ console.log('Migration to schema version 14 complete');
647
686
  }
648
687
  };
649
688
 
@@ -1808,6 +1847,11 @@ class ReviewRepository {
1808
1847
  params.push(updates.summary);
1809
1848
  }
1810
1849
 
1850
+ if (updates.name !== undefined) {
1851
+ setClauses.push('name = ?');
1852
+ params.push(updates.name);
1853
+ }
1854
+
1811
1855
  if (updates.submittedAt !== undefined) {
1812
1856
  setClauses.push('submitted_at = ?');
1813
1857
  const submittedAt = updates.submittedAt instanceof Date
@@ -2037,7 +2081,7 @@ class ReviewRepository {
2037
2081
  const row = await queryOne(this.db, `
2038
2082
  SELECT id, pr_number, repository, status, review_id,
2039
2083
  created_at, updated_at, submitted_at, review_data, custom_instructions,
2040
- review_type, local_path, local_head_sha, summary
2084
+ review_type, local_path, local_head_sha, summary, name
2041
2085
  FROM reviews
2042
2086
  WHERE review_type = 'local' AND local_path = ? AND local_head_sha = ?
2043
2087
  `, [localPath, localHeadSha]);
@@ -2059,7 +2103,7 @@ class ReviewRepository {
2059
2103
  const row = await queryOne(this.db, `
2060
2104
  SELECT id, pr_number, repository, status, review_id,
2061
2105
  created_at, updated_at, submitted_at, review_data, custom_instructions,
2062
- review_type, local_path, local_head_sha, summary
2106
+ review_type, local_path, local_head_sha, summary, name
2063
2107
  FROM reviews
2064
2108
  WHERE id = ? AND review_type = 'local'
2065
2109
  `, [id]);
@@ -2099,6 +2143,92 @@ class ReviewRepository {
2099
2143
 
2100
2144
  return this.createReview({ prNumber, repository, summary });
2101
2145
  }
2146
+
2147
+ /**
2148
+ * List local review sessions with cursor-based pagination
2149
+ * @param {Object} options - Pagination options
2150
+ * @param {number} [options.limit=10] - Maximum number of sessions to return
2151
+ * @param {string} [options.before] - ISO timestamp cursor (return sessions updated before this)
2152
+ * @returns {Promise<{sessions: Array<Object>, hasMore: boolean}>}
2153
+ */
2154
+ async listLocalSessions({ limit = 10, before } = {}) {
2155
+ const params = [];
2156
+ let whereClause = "WHERE review_type = 'local'";
2157
+
2158
+ if (before) {
2159
+ whereClause += ' AND updated_at < ?';
2160
+ params.push(before);
2161
+ }
2162
+
2163
+ // Fetch one extra to determine hasMore
2164
+ params.push(limit + 1);
2165
+
2166
+ const rows = await query(this.db, `
2167
+ SELECT id, name, repository, local_path, local_head_sha, created_at, updated_at
2168
+ FROM reviews
2169
+ ${whereClause}
2170
+ ORDER BY updated_at DESC
2171
+ LIMIT ?
2172
+ `, params);
2173
+
2174
+ const hasMore = rows.length > limit;
2175
+ const sessions = hasMore ? rows.slice(0, limit) : rows;
2176
+
2177
+ return { sessions, hasMore };
2178
+ }
2179
+
2180
+ /**
2181
+ * Save or update a local diff snapshot in the database
2182
+ * Uses INSERT OR REPLACE for upsert behavior
2183
+ * @param {number} reviewId - Review ID
2184
+ * @param {Object} diffData - Diff data to persist
2185
+ * @param {string} diffData.diff - The diff text content
2186
+ * @param {Object} diffData.stats - Stats object (will be JSON-stringified)
2187
+ * @param {string} [diffData.digest] - Content digest for staleness detection
2188
+ * @returns {Promise<void>}
2189
+ */
2190
+ async saveLocalDiff(reviewId, { diff, stats, digest }) {
2191
+ await run(this.db, `
2192
+ INSERT OR REPLACE INTO local_diffs (review_id, diff_text, stats, digest, captured_at)
2193
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
2194
+ `, [reviewId, diff || '', JSON.stringify(stats || {}), digest || null]);
2195
+ }
2196
+
2197
+ /**
2198
+ * Get a persisted local diff from the database
2199
+ * @param {number} reviewId - Review ID
2200
+ * @returns {Promise<{diff: string, stats: Object, digest: string|null}|null>}
2201
+ */
2202
+ async getLocalDiff(reviewId) {
2203
+ const row = await queryOne(this.db, `
2204
+ SELECT diff_text, stats, digest FROM local_diffs WHERE review_id = ?
2205
+ `, [reviewId]);
2206
+
2207
+ if (!row) return null;
2208
+
2209
+ return {
2210
+ diff: row.diff_text || '',
2211
+ stats: row.stats ? JSON.parse(row.stats) : {},
2212
+ digest: row.digest || null
2213
+ };
2214
+ }
2215
+
2216
+ /**
2217
+ * Delete a local review session and all associated data.
2218
+ * Only deletes DB records; does NOT remove files on disk.
2219
+ *
2220
+ * Because the schema uses ON DELETE CASCADE for foreign keys on local_diffs,
2221
+ * comments, and analysis_runs, deleting the review row cascades automatically.
2222
+ *
2223
+ * @param {number} reviewId - Review ID
2224
+ * @returns {Promise<boolean>} True if a record was deleted
2225
+ */
2226
+ async deleteLocalSession(reviewId) {
2227
+ const result = await run(this.db, `
2228
+ DELETE FROM reviews WHERE id = ? AND review_type = 'local'
2229
+ `, [reviewId]);
2230
+ return result.changes > 0;
2231
+ }
2102
2232
  }
2103
2233
 
2104
2234
  /**
@@ -5,6 +5,7 @@ const crypto = require('crypto');
5
5
  const path = require('path');
6
6
  const fs = require('fs').promises;
7
7
  const { loadConfig, showWelcomeMessage } = require('./config');
8
+ const logger = require('./utils/logger');
8
9
 
9
10
  const execAsync = promisify(exec);
10
11
  const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
@@ -594,6 +595,14 @@ async function handleLocalReview(targetPath, flags = {}) {
594
595
  // Store diff data in module-level Map (avoids process.env size limits and security concerns)
595
596
  localReviewDiffs.set(sessionId, { diff, stats, digest });
596
597
 
598
+ // Persist diff to database so past sessions remain viewable without the server running
599
+ try {
600
+ const reviewRepo = new ReviewRepository(db);
601
+ await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
602
+ } catch (persistError) {
603
+ logger.warn(`Could not persist diff to database: ${persistError.message}`);
604
+ }
605
+
597
606
  // Set model override if provided
598
607
  if (flags.model) {
599
608
  process.env.PAIR_REVIEW_MODEL = flags.model;