pierre-review 0.1.38 → 0.1.39

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.
@@ -7,6 +7,7 @@ import { hasUserAnthropicKey, setUserAnthropicKey, } from '../../review/local-se
7
7
  import { buildAnchorIndex, buildReview, fallbackAnchor, fetchCurrentHeadSha, fetchPrDiff, findingCommentBody, prLevelFindingBody, stripNoiseFromDiff, submitGithubComment, submitGithubIssueComment, submitGithubReview, } from '../../review/post-review.js';
8
8
  import { isNoiseFile } from '../../review/prompt.js';
9
9
  import { accountIdOf } from '../plugins/auth.js';
10
+ import { reviewEvents } from '../../review/events.js';
10
11
  const MODELS = ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
11
12
  const VERDICTS = ['COMMENT', 'REQUEST_CHANGES', 'APPROVE'];
12
13
  const REVIEW_MODES = ['auto', 'diff_only', 'worktree'];
@@ -158,6 +159,13 @@ export async function claudeReviewRoutes(app) {
158
159
  : 'The review queue is full; try again once some finish.',
159
160
  };
160
161
  }
162
+ reviewEvents.emit({
163
+ type: 'review.requested',
164
+ accountId: accountIdOf(req),
165
+ prId: id,
166
+ model,
167
+ requestedMode: mode ?? 'auto',
168
+ });
161
169
  reply.status(202);
162
170
  return { reviewId: result.reviewId, status: 'queued' };
163
171
  });
@@ -218,6 +226,12 @@ export async function claudeReviewRoutes(app) {
218
226
  reply.status(404);
219
227
  return { error: 'NotFound', message: `Review ${reviewId} not found` };
220
228
  }
229
+ reviewEvents.emit({
230
+ type: 'review.draftUpdated',
231
+ accountId: accountIdOf(req),
232
+ reviewId,
233
+ change: { userBody: body.userBody, userVerdict: body.userVerdict },
234
+ });
221
235
  return { status: 'ok' };
222
236
  });
223
237
  // Tick a finding for inline posting and/or save the user's reworded body.
@@ -231,6 +245,12 @@ export async function claudeReviewRoutes(app) {
231
245
  reply.status(404);
232
246
  return { error: 'NotFound', message: `Finding ${findingId} not found` };
233
247
  }
248
+ reviewEvents.emit({
249
+ type: 'finding.updated',
250
+ accountId: accountIdOf(req),
251
+ findingId,
252
+ change: { included: body.included, editedBody: body.editedBody },
253
+ });
234
254
  return { status: 'ok' };
235
255
  });
236
256
  // Post a single finding as a standalone comment (no review submitted). The
@@ -243,7 +263,8 @@ export async function claudeReviewRoutes(app) {
243
263
  if (!config.claudeReviewEnabled)
244
264
  return featureOff(reply);
245
265
  const { findingId } = req.params;
246
- const ctx = await getFindingPostContext(findingId, accountIdOf(req));
266
+ const accountId = accountIdOf(req);
267
+ const ctx = await getFindingPostContext(findingId, accountId);
247
268
  if (!ctx) {
248
269
  reply.status(404);
249
270
  return { error: 'NotFound', message: `Finding ${findingId} not found` };
@@ -276,6 +297,12 @@ export async function claudeReviewRoutes(app) {
276
297
  }),
277
298
  });
278
299
  await markFindingPosted(findingId, commentId, 'inline');
300
+ reviewEvents.emit({
301
+ type: 'finding.posted',
302
+ accountId,
303
+ findingId,
304
+ postedCommentKind: 'inline',
305
+ });
279
306
  const result = { githubCommentId: commentId, postedAt };
280
307
  return result;
281
308
  }
@@ -295,6 +322,12 @@ export async function claudeReviewRoutes(app) {
295
322
  body: findingCommentBody({ body: f.body, editedBody: f.editedBody, suggestion: f.suggestion }, { fallbackNote: true }),
296
323
  });
297
324
  await markFindingPosted(findingId, commentId, 'inline');
325
+ reviewEvents.emit({
326
+ type: 'finding.posted',
327
+ accountId,
328
+ findingId,
329
+ postedCommentKind: 'inline',
330
+ });
298
331
  const result = { githubCommentId: commentId, postedAt };
299
332
  return result;
300
333
  }
@@ -312,6 +345,12 @@ export async function claudeReviewRoutes(app) {
312
345
  }),
313
346
  });
314
347
  await markFindingPosted(findingId, commentId, 'pr_comment');
348
+ reviewEvents.emit({
349
+ type: 'finding.posted',
350
+ accountId,
351
+ findingId,
352
+ postedCommentKind: 'pr_comment',
353
+ });
315
354
  const result = { githubCommentId: commentId, postedAt };
316
355
  return result;
317
356
  }
@@ -398,6 +437,14 @@ export async function claudeReviewRoutes(app) {
398
437
  }
399
438
  }
400
439
  await markReviewPosted(reviewId, ghReviewId, built.inlineFindingIds, prCommentResults);
440
+ reviewEvents.emit({
441
+ type: 'review.posted',
442
+ accountId,
443
+ reviewId,
444
+ userVerdict,
445
+ inlineFindingIds: built.inlineFindingIds,
446
+ prCommentFindingIds: prCommentResults.map((r) => r.findingId),
447
+ });
401
448
  const result = {
402
449
  postedReviewId: ghReviewId,
403
450
  postedAt: new Date().toISOString(),
@@ -0,0 +1,28 @@
1
+ import { getInbox, listClaudeReviewsByRepo } from '../../db/queries.js';
2
+ import { accountIdOf } from '../plugins/auth.js';
3
+ function parseIntList(raw) {
4
+ if (!raw)
5
+ return null;
6
+ const ids = raw
7
+ .split(',')
8
+ .map((s) => Number.parseInt(s.trim(), 10))
9
+ .filter((n) => Number.isFinite(n));
10
+ return ids.length > 0 ? ids : null;
11
+ }
12
+ export async function inboxRoutes(app) {
13
+ // The Inbox aggregate: per watched repo, current-state stats + thread totals +
14
+ // attention/unread flags + open PRs. Scoped to the account; `repoIds` narrows to
15
+ // the active watched-repo selection. Pure DB read — no GitHub sync, no AI.
16
+ app.get('/api/inbox', async (req) => {
17
+ const q = req.query;
18
+ return getInbox(accountIdOf(req), parseIntList(q.repoIds));
19
+ });
20
+ // Repo-scoped Claude-review history for the Inbox single-repo console. Ownership +
21
+ // feature-gating are handled inside the query (an unowned repo / disabled feature
22
+ // both return an empty list), so the caller never leaks another account's data.
23
+ app.get('/api/repos/:id/claude-reviews', async (req) => {
24
+ const { id } = req.params;
25
+ return listClaudeReviewsByRepo(Number.parseInt(id, 10), accountIdOf(req));
26
+ });
27
+ }
28
+ //# sourceMappingURL=inbox.js.map
@@ -1,6 +1,7 @@
1
1
  import { config } from '../../config.js';
2
2
  import { accountToLocalUser } from '../../auth/account.js';
3
3
  import { accountIdOf } from '../plugins/auth.js';
4
+ import { getProCapabilities } from '../../pro/contract.js';
4
5
  import { dismissMyTurn, getCompletedDismissals, getMyTurn, undismissMyTurn, } from '../../db/queries.js';
5
6
  const dismissSchema = {
6
7
  body: {
@@ -32,6 +33,7 @@ export async function meRoutes(app) {
32
33
  },
33
34
  claudeReviewEnabled: config.claudeReviewEnabled,
34
35
  deploymentMode: config.deploymentMode,
36
+ pro: getProCapabilities(),
35
37
  };
36
38
  });
37
39
  app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
package/dist/app.js CHANGED
@@ -18,6 +18,7 @@ import { openPrsRoutes } from './api/routes/open-prs.js';
18
18
  import { feedRoutes } from './api/routes/feed.js';
19
19
  import { mergersRoutes } from './api/routes/mergers.js';
20
20
  import { insightsRoutes } from './api/routes/insights.js';
21
+ import { inboxRoutes } from './api/routes/inbox.js';
21
22
  import { claudeReviewRoutes } from './api/routes/claude-review.js';
22
23
  export async function buildApp() {
23
24
  const app = Fastify({
@@ -130,6 +131,7 @@ export async function buildApp() {
130
131
  await app.register(feedRoutes);
131
132
  await app.register(mergersRoutes);
132
133
  await app.register(insightsRoutes);
134
+ await app.register(inboxRoutes);
133
135
  // Claude Review is local-only + opt-in. Only register its routes when enabled,
134
136
  // so the clone-manager / gh-CLI dependency is unreachable in cloud mode.
135
137
  if (config.claudeReviewEnabled)
package/dist/config.js CHANGED
@@ -118,6 +118,19 @@ export const config = {
118
118
  // HSTS_MAX_AGE=0 as a kill switch. `preload` is intentionally NOT sent — it is
119
119
  // hard to undo; opt in manually once the domain is proven.
120
120
  hstsMaxAge: intFromEnv('HSTS_MAX_AGE', 31536000),
121
+ // Pro plugin master gate. Pro is local-only for now; bind.ts skips the dynamic
122
+ // import entirely when false.
123
+ proEnabled: !isCloud,
124
+ // Per-repo digest (Pro, Workstream 2) config — consumed by @pierre/pro, kept in
125
+ // core so the model id / budgets live in one place and aren't hardcoded at call
126
+ // sites. Inert until the plugin is present AND digestEnabled.
127
+ pro: {
128
+ digestModel: process.env.PRO_DIGEST_MODEL ?? 'claude-haiku-4-5',
129
+ digestEnabled: process.env.PRO_DIGEST_ENABLED === 'true',
130
+ digestMaxUsdPerRefresh: floatFromEnv('PRO_DIGEST_MAX_USD', 0.5),
131
+ digestMaxReposPerRefresh: intFromEnv('PRO_DIGEST_MAX_REPOS', 30),
132
+ digestMinIntervalSec: intFromEnv('PRO_DIGEST_MIN_INTERVAL_SEC', 60),
133
+ },
121
134
  // ---- Claude Review (agentic PR review; opt-in, LOCAL-ONLY) ----
122
135
  // OFF by default: the feature spends real money / Agent-SDK credits per run.
123
136
  // Enable with ENABLE_CLAUDE_REVIEW=true. FORCE-DISABLED in cloud mode (it
@@ -1015,6 +1015,86 @@ export async function getMergers(accountId) {
1015
1015
  }
1016
1016
  return [...byRepo.entries()].map(([repoId, userIds]) => ({ repoId, userIds }));
1017
1017
  }
1018
+ // ---- inbox (CORE, always-on, no AI) ----
1019
+ // Reason tags that, on an open PR, count as "needs attention" for the Inbox rail
1020
+ // badge (a my-turn-shaped triage reason). Stalled + untouched threads are folded
1021
+ // in separately via the per-PR flags. Keep in sync with REASON_PRIORITY values.
1022
+ const INBOX_ATTENTION_REASONS = new Set([
1023
+ 'awaiting_your_review',
1024
+ 'your_pr_new_comments',
1025
+ 'ci_failing',
1026
+ 'merge_conflicts',
1027
+ 'untouched_threads',
1028
+ ]);
1029
+ // The Inbox aggregate: per watched repo, current-state stats + a per-repo thread
1030
+ // total + maintainer ids + attention/unread flags + the open PRs (caller groups by
1031
+ // author). Composes EXISTING accountId-scoped readers — getInsights / getOpenPrs /
1032
+ // getMergers / listRepos — so isolation + triage logic stay single-sourced. The
1033
+ // one genuinely new aggregation is `threadTotals` (sum each open PR's threadCounts
1034
+ // per repo). Every watched repo is included (a quiet repo → empty prs, zeroed stats).
1035
+ export async function getInbox(accountId, repoIds) {
1036
+ const reposAll = await listRepos(accountId);
1037
+ const reposScoped = repoIds ? reposAll.filter((r) => repoIds.includes(r.id)) : reposAll;
1038
+ const [insights, openPrs, mergers] = await Promise.all([
1039
+ getInsights({ accountId, repoIds }),
1040
+ getOpenPrs({ accountId, repoIds, userIds: null }),
1041
+ getMergers(accountId),
1042
+ ]);
1043
+ const insightsByRepo = new Map(insights.repos.map((r) => [r.repoId, r]));
1044
+ const mergersByRepo = new Map(mergers.map((m) => [m.repoId, m.userIds]));
1045
+ const prsByRepo = new Map();
1046
+ for (const pr of openPrs) {
1047
+ const arr = prsByRepo.get(pr.repoId);
1048
+ if (arr)
1049
+ arr.push(pr);
1050
+ else
1051
+ prsByRepo.set(pr.repoId, [pr]);
1052
+ }
1053
+ // Preserve listRepos order (stable, not jumpy across loads).
1054
+ const inboxRepos = reposScoped.map((repo) => {
1055
+ const repoPrs = prsByRepo.get(repo.id) ?? [];
1056
+ const ins = insightsByRepo.get(repo.id);
1057
+ const stats = ins
1058
+ ? {
1059
+ openPrs: ins.openPrs,
1060
+ draftPrs: ins.draftPrs,
1061
+ mergedLast7d: ins.mergedLast7d,
1062
+ stalledPrs: ins.stalledPrs,
1063
+ medianHoursToFirstReview: ins.medianHoursToFirstReview,
1064
+ oldestUnreviewed: ins.oldestUnreviewed,
1065
+ }
1066
+ : {
1067
+ openPrs: 0,
1068
+ draftPrs: 0,
1069
+ mergedLast7d: 0,
1070
+ stalledPrs: 0,
1071
+ medianHoursToFirstReview: null,
1072
+ oldestUnreviewed: null,
1073
+ };
1074
+ const threadTotals = emptyCounts();
1075
+ for (const pr of repoPrs) {
1076
+ threadTotals.untouched += pr.threadCounts.untouched;
1077
+ threadTotals.replied_unresolved += pr.threadCounts.replied_unresolved;
1078
+ threadTotals.likely_addressed += pr.threadCounts.likely_addressed;
1079
+ threadTotals.resolved += pr.threadCounts.resolved;
1080
+ }
1081
+ const attentionCount = repoPrs.filter((pr) => pr.isStalled ||
1082
+ pr.threadCounts.untouched > 0 ||
1083
+ INBOX_ATTENTION_REASONS.has(pr.reasonTag)).length;
1084
+ const hasUnread = repoPrs.some((pr) => pr.newSinceLastViewed != null);
1085
+ return {
1086
+ repoId: repo.id,
1087
+ repoFullName: repo.fullName,
1088
+ stats,
1089
+ threadTotals,
1090
+ maintainerIds: mergersByRepo.get(repo.id) ?? [],
1091
+ attentionCount,
1092
+ hasUnread,
1093
+ prs: repoPrs,
1094
+ };
1095
+ });
1096
+ return { repos: inboxRepos, generatedAt: new Date().toISOString() };
1097
+ }
1018
1098
  // ---- incremental review: pr_views ----
1019
1099
  export async function markPrViewed(prId, accountId, sha) {
1020
1100
  const prRows = await db
@@ -2213,6 +2293,65 @@ export async function listClaudeReviewHistory(prId, accountId) {
2213
2293
  finishedAt: iso(r.finishedAt),
2214
2294
  }));
2215
2295
  }
2296
+ // Repo-oriented Claude-review retrieval for the Inbox single-repo console: ALL runs
2297
+ // for a repo's PRs, grouped by PR (newest run first within each), PRs ordered by
2298
+ // most-recent run desc. Richer than listAllClaudeReviews (which keeps only one
2299
+ // latest-succeeded run per PR). IDOR-sensitive id getter: scoped by accountId, and
2300
+ // gated on config.claudeReviewEnabled. An unowned repo (cross-account) → empty list.
2301
+ export async function listClaudeReviewsByRepo(repoId, accountId) {
2302
+ if (!config.claudeReviewEnabled)
2303
+ return { enabled: false, prs: [] };
2304
+ // Ownership: a repo not owned by this account leaks nothing (404-equivalent).
2305
+ const owned = await getRepo(repoId, accountId);
2306
+ if (!owned)
2307
+ return { enabled: config.claudeReviewEnabled, prs: [] };
2308
+ const rows = await db
2309
+ .select({
2310
+ review: claudeReviews,
2311
+ prNumber: pullRequests.number,
2312
+ prTitle: pullRequests.title,
2313
+ prState: pullRequests.state,
2314
+ authorId: pullRequests.authorId,
2315
+ })
2316
+ .from(claudeReviews)
2317
+ .innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
2318
+ .where(and(eq(claudeReviews.accountId, accountId), eq(pullRequests.repoId, repoId)))
2319
+ .orderBy(desc(claudeReviews.id))
2320
+ .execute();
2321
+ // Rows are globally id-desc (newest-first). The first row seen for each PR is its
2322
+ // newest run, so map-insertion order = PRs by most-recent run desc, and each PR's
2323
+ // runs[] accumulate newest-first — no extra sort needed.
2324
+ const byPr = new Map();
2325
+ for (const { review: r, prNumber, prTitle, prState, authorId } of rows) {
2326
+ const summary = {
2327
+ id: r.id,
2328
+ headSha: r.headSha,
2329
+ status: r.status,
2330
+ model: r.model,
2331
+ scope: r.scope,
2332
+ reviewMode: r.reviewMode,
2333
+ verdict: r.verdict,
2334
+ userVerdict: r.userVerdict,
2335
+ costUsd: r.costUsd,
2336
+ postedAt: iso(r.postedAt),
2337
+ createdAt: r.createdAt.toISOString(),
2338
+ finishedAt: iso(r.finishedAt),
2339
+ };
2340
+ const existing = byPr.get(r.prId);
2341
+ if (existing)
2342
+ existing.runs.push(summary);
2343
+ else
2344
+ byPr.set(r.prId, {
2345
+ prId: r.prId,
2346
+ prNumber,
2347
+ prTitle,
2348
+ prState,
2349
+ authorId,
2350
+ runs: [summary],
2351
+ });
2352
+ }
2353
+ return { enabled: true, prs: [...byPr.values()] };
2354
+ }
2216
2355
  // Cross-PR list of prior Claude reviews: ONE entry per PR = that PR's most-recent
2217
2356
  // SUCCEEDED run, accountId-scoped, restricted to PRs still within the timeline
2218
2357
  // window (open, or last touched within `backfillDays`), newest-first by finish
package/dist/index.js CHANGED
@@ -34,6 +34,12 @@ export async function start() {
34
34
  const { reconcileReviewsOnStartup } = await import('./review/review-manager.js');
35
35
  await reconcileReviewsOnStartup(app.log);
36
36
  }
37
+ // Bind the optional Pro plugin (dynamic import; no-ops in OSS mode). Same
38
+ // "optional subsystem, degrade gracefully" posture as the scheduler below.
39
+ {
40
+ const { bindProPlugin } = await import('./pro/bind.js');
41
+ await bindProPlugin(app);
42
+ }
37
43
  // Scheduler is wired in Phase 3; guarded so the skeleton runs without it.
38
44
  if (!config.disableScheduler) {
39
45
  try {
@@ -0,0 +1,77 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { config } from '../config.js';
5
+ import { accountIdOf } from '../api/plugins/auth.js';
6
+ import { db, schema, runTransaction, isPg } from '../db/client.js';
7
+ import * as hostQueries from '../db/queries.js';
8
+ import { reviewEvents, registerLearningsProvider } from '../review/events.js';
9
+ import { cheapComplete } from '../review/llm.js';
10
+ import { runPluginMigrations } from './migrate.js';
11
+ import { setProCapabilities } from './contract.js';
12
+ // Boot binding for the optional @pierre/pro plugin. The plugin lives in a PRIVATE
13
+ // git submodule at packages/pro (often absent — a pure-OSS checkout never has it).
14
+ // It is deliberately NOT a declared dependency, so `pnpm install` succeeds without
15
+ // it; we resolve it by FILESYSTEM PATH rather than a bare specifier (no package.json
16
+ // coupling). Submodule absent → no entry file on disk → clean OSS no-op.
17
+ export async function bindProPlugin(app) {
18
+ if (!config.proEnabled)
19
+ return; // master gate (Pro is local-only for now)
20
+ // here = apps/backend/{src|dist}/pro/bind.{ts|js} → repo root is four levels up.
21
+ const here = fileURLToPath(import.meta.url);
22
+ const repoRoot = resolve(dirname(here), '../../../..');
23
+ const entry = [
24
+ join(repoRoot, 'packages/pro/dist/index.js'), // built plugin (node)
25
+ join(repoRoot, 'packages/pro/src/index.ts'), // source (tsx dev)
26
+ ].find(existsSync);
27
+ if (!entry) {
28
+ app.log.debug('pro plugin submodule absent — OSS mode');
29
+ return;
30
+ }
31
+ const mod = await import(pathToFileURL(entry).href).catch((err) => {
32
+ app.log.warn({ err }, 'pro plugin present but failed to load — OSS mode');
33
+ return null;
34
+ });
35
+ if (!mod)
36
+ return;
37
+ const plugin = (mod.default ?? mod);
38
+ if (plugin?.apiVersion !== 1 || typeof plugin.register !== 'function') {
39
+ app.log.warn({ apiVersion: plugin?.apiVersion }, 'pro contract mismatch — skipped');
40
+ return;
41
+ }
42
+ const ctx = {
43
+ log: app.log,
44
+ host: {
45
+ version: process.env.npm_package_version ?? '0.0.0',
46
+ deploymentMode: config.deploymentMode,
47
+ isCloud: config.isCloud,
48
+ },
49
+ accountIdOf,
50
+ db,
51
+ schema,
52
+ runTransaction,
53
+ isPg,
54
+ registerMigrations: (sqliteFolder, pgFolder) => runPluginMigrations(sqliteFolder, pgFolder),
55
+ llm: { complete: cheapComplete },
56
+ queries: {
57
+ getInsights: (accountId, repoIds) => hostQueries.getInsights({ accountId, repoIds: repoIds ?? null }),
58
+ getRepoAnalytics: (accountId, repoId) => hostQueries.getRepoAnalytics(accountId, repoId),
59
+ getOpenPrs: (args) => hostQueries.getOpenPrs({
60
+ accountId: args.accountId,
61
+ repoIds: args.repoIds ?? null,
62
+ userIds: null,
63
+ }),
64
+ getInbox: (accountId, repoIds) => hostQueries.getInbox(accountId, repoIds ?? null),
65
+ },
66
+ reviewEvents,
67
+ registerLearningsProvider,
68
+ };
69
+ try {
70
+ setProCapabilities(await plugin.register(app, ctx));
71
+ app.log.info('pro plugin active');
72
+ }
73
+ catch (err) {
74
+ app.log.warn({ err }, 'pro register() failed — OSS mode');
75
+ }
76
+ }
77
+ //# sourceMappingURL=bind.js.map
@@ -0,0 +1,12 @@
1
+ // The live capability singleton, mirrored to the frontend via /api/me exactly
2
+ // like claudeReviewEnabled. All-false in OSS mode (no plugin ever calls the
3
+ // setter).
4
+ const EMPTY = { inboxDigest: false, reviewMemory: false };
5
+ let active = EMPTY;
6
+ export function setProCapabilities(c) {
7
+ active = c;
8
+ }
9
+ export function getProCapabilities() {
10
+ return active;
11
+ }
12
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1,64 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { db, isPg } from '../db/client.js';
4
+ // Plugin migration runner (core-owned). The optional @pierre/pro plugin ships its
5
+ // OWN dual-dialect *.sql migrations and calls ctx.registerMigrations(sqliteFolder,
6
+ // pgFolder); core executes them here against the live connection.
7
+ //
8
+ // ⚠ THIS IS THE ONLY PLACE the raw DB driver / non-portable terminals are used,
9
+ // and it is strictly DDL + bookkeeping (CREATE TABLE / INSERT into pro_migrations)
10
+ // — never a data query. Everything else in the codebase MUST go through the
11
+ // portable async drizzle surface (.execute()/.returning().execute()).
12
+ export async function runPluginMigrations(sqliteFolder, pgFolder) {
13
+ const folder = isPg ? pgFolder : sqliteFolder;
14
+ // The raw driver behind drizzle: a node-postgres Pool (pg) or a better-sqlite3
15
+ // Database (sqlite). drizzle exposes it as `$client`.
16
+ const client = db.$client;
17
+ // Run a raw SQL string (possibly several statements). pg's Pool.query and
18
+ // better-sqlite3's Database.exec both accept multiple semicolon-separated
19
+ // statements in a single call.
20
+ const execRaw = async (sqlText) => {
21
+ if (isPg)
22
+ await client.query(sqlText);
23
+ else
24
+ client.exec(sqlText);
25
+ };
26
+ // Bookkeeping table — which plugin migrations have been applied.
27
+ await execRaw('CREATE TABLE IF NOT EXISTS pro_migrations (name text PRIMARY KEY, applied_at ' +
28
+ (isPg ? 'timestamptz' : 'integer') +
29
+ ' NOT NULL)');
30
+ const files = readdirSync(folder)
31
+ .filter((f) => f.endsWith('.sql'))
32
+ .sort();
33
+ for (const name of files) {
34
+ try {
35
+ let applied;
36
+ if (isPg) {
37
+ const r = await client.query('SELECT 1 FROM pro_migrations WHERE name=$1', [name]);
38
+ applied = r.rowCount > 0;
39
+ }
40
+ else {
41
+ const row = client
42
+ .prepare('SELECT 1 FROM pro_migrations WHERE name=?')
43
+ .get(name);
44
+ applied = !!row;
45
+ }
46
+ if (applied)
47
+ continue;
48
+ const text = readFileSync(join(folder, name), 'utf8');
49
+ await execRaw(text);
50
+ if (isPg) {
51
+ await client.query('INSERT INTO pro_migrations(name,applied_at) VALUES($1,$2)', [name, new Date()]);
52
+ }
53
+ else {
54
+ client
55
+ .prepare('INSERT INTO pro_migrations(name,applied_at) VALUES(?,?)')
56
+ .run(name, Date.now());
57
+ }
58
+ }
59
+ catch (err) {
60
+ throw new Error(`pro migration '${name}' failed: ${err instanceof Error ? err.message : String(err)}`);
61
+ }
62
+ }
63
+ }
64
+ //# sourceMappingURL=migrate.js.map
@@ -197,6 +197,7 @@ export async function runReview(args) {
197
197
  diff: promptDiff,
198
198
  mode,
199
199
  omittedFiles,
200
+ priorReviewContext: args.priorReviewContext,
200
201
  }),
201
202
  options: {
202
203
  model: args.model,
@@ -0,0 +1,31 @@
1
+ const listeners = new Set();
2
+ export const reviewEvents = {
3
+ on(listener) {
4
+ listeners.add(listener);
5
+ return () => {
6
+ listeners.delete(listener);
7
+ };
8
+ },
9
+ emit(e) {
10
+ // A subscriber error must NEVER break the review route that emitted, so each
11
+ // call is isolated; we deliberately swallow (log nothing) rather than throw.
12
+ for (const listener of listeners) {
13
+ try {
14
+ listener(e);
15
+ }
16
+ catch {
17
+ /* a subscriber failure must not surface to the emitter */
18
+ }
19
+ }
20
+ },
21
+ };
22
+ // Single nullable provider registry. Null in OSS mode ⇒ the review prompt is
23
+ // byte-identical to today (review-manager passes priorReviewContext: undefined).
24
+ let _provider = null;
25
+ export function registerLearningsProvider(p) {
26
+ _provider = p;
27
+ }
28
+ export function getLearningsProvider() {
29
+ return _provider;
30
+ }
31
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,92 @@
1
+ import { getUserAnthropicKey } from './local-settings.js';
2
+ const DEFAULT_MODEL = 'claude-haiku-4-5';
3
+ export async function cheapComplete(opts) {
4
+ const model = opts.model ?? DEFAULT_MODEL;
5
+ // A real API key (user-supplied local key wins, then ANTHROPIC_API_KEY) takes the
6
+ // cheap, metered, exact-cost raw path.
7
+ const apiKey = getUserAnthropicKey() ?? process.env.ANTHROPIC_API_KEY;
8
+ if (apiKey)
9
+ return rawComplete(apiKey, model, opts);
10
+ // No explicit key → use the Agent SDK, which resolves a CLAUDE_CODE_OAUTH_TOKEN
11
+ // or an ambient logged-in Claude session exactly as Claude Review does.
12
+ return agentComplete(model, opts);
13
+ }
14
+ // ---- raw @anthropic-ai/sdk path (explicit API key) ----------------------------
15
+ async function rawComplete(apiKey, model, opts) {
16
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
17
+ const client = new Anthropic({ apiKey });
18
+ const resp = await client.messages.create({
19
+ model,
20
+ max_tokens: opts.maxTokens ?? 700,
21
+ system: opts.system,
22
+ messages: [{ role: 'user', content: opts.prompt }],
23
+ });
24
+ const blocks = resp.content;
25
+ const text = (Array.isArray(blocks) ? blocks : [])
26
+ .map((b) => {
27
+ const block = b;
28
+ return block?.type === 'text' && typeof block.text === 'string'
29
+ ? block.text
30
+ : '';
31
+ })
32
+ .join('');
33
+ return {
34
+ text: text.trim(),
35
+ usage: {
36
+ inputTokens: resp.usage.input_tokens,
37
+ outputTokens: resp.usage.output_tokens,
38
+ },
39
+ };
40
+ }
41
+ // ---- Claude Agent SDK path (OAuth token / ambient session) --------------------
42
+ async function agentComplete(model, opts) {
43
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
44
+ // A single-turn, tool-less completion: the system prompt is folded into the user
45
+ // prompt (the SDK's systemPrompt is also passed for models that honour it). No
46
+ // tools, no settings, one turn — just text out.
47
+ const prompt = opts.system
48
+ ? `${opts.system}\n\n---\n\n${opts.prompt}`
49
+ : opts.prompt;
50
+ const q = query({
51
+ prompt,
52
+ options: {
53
+ model,
54
+ systemPrompt: opts.system,
55
+ allowedTools: [],
56
+ maxTurns: 1,
57
+ permissionMode: 'bypassPermissions',
58
+ // Don't load the user's ~/.claude settings / MCP servers for a plain
59
+ // completion (mirrors review/agent.ts).
60
+ settingSources: [],
61
+ },
62
+ });
63
+ let text = '';
64
+ let inputTokens = 0;
65
+ let outputTokens = 0;
66
+ let errored = null;
67
+ for await (const message of q) {
68
+ const type = message.type;
69
+ if (type === 'assistant') {
70
+ const inner = (message.message ?? {});
71
+ for (const b of inner.content ?? []) {
72
+ if (b?.type === 'text' && typeof b.text === 'string')
73
+ text += b.text;
74
+ }
75
+ }
76
+ else if (type === 'result') {
77
+ const usage = (message.usage ?? {});
78
+ inputTokens = usage.input_tokens ?? inputTokens;
79
+ outputTokens = usage.output_tokens ?? outputTokens;
80
+ const subtype = String(message.subtype ?? '');
81
+ if (subtype && subtype !== 'success' && !text) {
82
+ errored = subtype;
83
+ }
84
+ }
85
+ }
86
+ const trimmed = text.trim();
87
+ if (!trimmed) {
88
+ throw new Error(`Agent SDK completion returned no text${errored ? ` (${errored})` : ''} — check Claude auth (ANTHROPIC_API_KEY, or run \`claude\` to sign in).`);
89
+ }
90
+ return { text: trimmed, usage: { inputTokens, outputTokens } };
91
+ }
92
+ //# sourceMappingURL=llm.js.map