opencode-reviewer 0.6.1 → 0.7.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 (3) hide show
  1. package/README.md +13 -5
  2. package/dist/tui.js +126 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -116,11 +116,19 @@ costs use reported charges; other endpoints use available catalog estimates.
116
116
  Received usage counts even if a review fails or is interrupted. Unreported charges
117
117
  are missing from the totals.
118
118
 
119
- Lifetime statistics also show **Safe** and **Unsafe** review counts. Each completed,
120
- validated review counts once, even without usage data. Previews, retries and failed
121
- or interrupted analyses do not count. Re-enabling a conversation can produce a new
122
- review, which counts again. The totals persist across restarts; earlier ratings
123
- were not recorded and cannot be reconstructed.
119
+ The lifetime dialog shows completed reviews, retries, tokens, cost, Safe/Unsafe
120
+ percentages, confirmed auto-approvals, and average time to a rating and full report.
121
+ Percentages use recorded completed reviews as their denominator. Each validated
122
+ review counts once, even without usage data; a fresh review after re-enabling counts
123
+ again. Retries count extra API attempts actually dispatched, including format
124
+ corrections and transport recovery. Manual approvals are not auto-approvals.
125
+ Timings run from evaluation start, including evidence and retries, to the accepted
126
+ attempt's first rating and final validated response, excluding rendering/countdown
127
+ time. Non-streaming reviews use the final response time for both measurements.
128
+ Only running averages and sample counts are stored, not individual timings.
129
+ Totals persist across restarts. New metrics show partial history when older records
130
+ lack them; averages include measured reviews only. Earlier unrecorded values cannot
131
+ be reconstructed.
124
132
 
125
133
  Reviews send the pending request, latest user prompt, project context, and relevant
126
134
  file snapshots, diffs, or tool arguments to your endpoint before approval. Shell
package/dist/tui.js CHANGED
@@ -1182,6 +1182,16 @@ var uiText = {
1182
1182
  pricingCoverage: (available, total) => `Pricing available: ${available}/${total} requests`,
1183
1183
  since: (date) => `Recorded since: ${date}`,
1184
1184
  ratings: (safe, unsafe) => `${safe} \u2713 ${unsafe} \u2717`,
1185
+ dialogReviews: (count3) => `Reviews: ${count3}`,
1186
+ dialogRetries: (count3, partial) => `Retries: ${count3}${partial ? " (partial history)" : ""}`,
1187
+ dialogTokens: (input, output, partial) => `Tokens: ${input} in ${output} out${partial ? " (partial coverage)" : ""}`,
1188
+ dialogTokensUnavailable: "Tokens: unavailable",
1189
+ dialogCost: (amount2, partial) => amount2 === void 0 ? "Cost: unavailable" : `Cost: $${amount2}${partial ? " (partial pricing)" : ""}`,
1190
+ dialogSafe: (count3, percentage) => `Safe: ${count3} (${percentage === void 0 ? "n/a" : `${percentage}%`})`,
1191
+ dialogUnsafe: (count3, percentage) => `Unsafe: ${count3} (${percentage === void 0 ? "n/a" : `${percentage}%`})`,
1192
+ dialogAutoApproved: (count3, percentage, partial) => `Auto-approved: ${count3} (${percentage === void 0 ? "n/a" : `${percentage}%`})${partial ? " (partial history)" : ""}`,
1193
+ averageFullReport: (seconds) => `Average time to full report: ${seconds === void 0 ? "unavailable" : `${seconds}s`}`,
1194
+ averageRating: (seconds) => `Average time to rating: ${seconds === void 0 ? "unavailable" : `${seconds}s`}`,
1185
1195
  ratingsSince: (date) => `Ratings recorded since: ${date}`,
1186
1196
  ratingsExplanation: "Ratings count completed, validated reviews, including those without usage data. Previews, retries and failed or interrupted analyses do not count. A fresh review after re-enabling counts again. Earlier ratings were not recorded and cannot be recovered.",
1187
1197
  explanation: "Received usage only, including interrupted or unsuccessful reviews. Unreported charges remain unknown. Costs combine endpoint-reported amounts and catalog estimates; legacy history retains its original estimates. Earlier unrecorded usage cannot be recovered."
@@ -1817,11 +1827,11 @@ async function streamedAssessment(response, signal, accounting, progress, firstC
1817
1827
  if (!done || !stopped) throw new Error("Reviewer stream ended without stop and DONE");
1818
1828
  return assessment;
1819
1829
  }
1820
- async function review(evidence, config, signal, fetcher = fetch, environment = process.env, prompts = BUILTIN_PROMPTS, pricing, onUsage, onProgress, onDiagnostics) {
1830
+ async function review(evidence, config, signal, fetcher = fetch, environment = process.env, prompts = BUILTIN_PROMPTS, pricing, onUsage, onProgress, onDiagnostics, onRetry) {
1821
1831
  if (remainingTime(signal) === Infinity) {
1822
1832
  let worker;
1823
1833
  try {
1824
- return await withDeadline(signal, config.timeoutMs, (bounded) => worker = review(evidence, config, bounded, fetcher, environment, prompts, pricing, onUsage, onProgress, onDiagnostics));
1834
+ return await withDeadline(signal, config.timeoutMs, (bounded) => worker = review(evidence, config, bounded, fetcher, environment, prompts, pricing, onUsage, onProgress, onDiagnostics, onRetry));
1825
1835
  } finally {
1826
1836
  await worker?.catch(() => {
1827
1837
  });
@@ -1867,13 +1877,26 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1867
1877
  });
1868
1878
  reviewStage(signal, "Reviewer response");
1869
1879
  diagnose = onDiagnostics ? diagnosticAttempt(onDiagnostics, attempt) : void 0;
1870
- response = await abortable(fetcher(`${config.baseURL}/chat/completions`, {
1871
- method: "POST",
1872
- redirect: "error",
1873
- signal: AbortSignal.any([signal, requestAbort.signal]),
1874
- headers: { "Content-Type": "application/json", ...key ? { Authorization: `Bearer ${key}` } : {} },
1875
- body
1876
- }), signal, cancelBody);
1880
+ signal.throwIfAborted();
1881
+ let pending;
1882
+ try {
1883
+ pending = fetcher(`${config.baseURL}/chat/completions`, {
1884
+ method: "POST",
1885
+ redirect: "error",
1886
+ signal: AbortSignal.any([signal, requestAbort.signal]),
1887
+ headers: { "Content-Type": "application/json", ...key ? { Authorization: `Bearer ${key}` } : {} },
1888
+ body
1889
+ });
1890
+ } finally {
1891
+ if (attempt > 0) {
1892
+ try {
1893
+ void Promise.resolve(onRetry?.()).catch(() => {
1894
+ });
1895
+ } catch {
1896
+ }
1897
+ }
1898
+ }
1899
+ response = await abortable(pending, signal, cancelBody);
1877
1900
  diagnose?.("headers");
1878
1901
  } catch (error) {
1879
1902
  signal.throwIfAborted();
@@ -2185,6 +2208,7 @@ var Controller = class {
2185
2208
  if (value.attempt !== entry.attempt + 1 || value.phase !== (value.attempt ? "retrying" : "evaluating")) return;
2186
2209
  this.clearProgress(entry);
2187
2210
  entry.attempt = value.attempt;
2211
+ entry.ratingAt = void 0;
2188
2212
  entry.view = { ...entry.view, progress: { attempt: value.attempt, phase: value.phase } };
2189
2213
  this.publish();
2190
2214
  return;
@@ -2200,6 +2224,7 @@ var Controller = class {
2200
2224
  ...typeof value.preview.safe === "boolean" ? { safe: value.preview.safe } : {},
2201
2225
  ...typeof value.preview.desc === "string" ? { desc: value.preview.desc } : {}
2202
2226
  };
2227
+ if (typeof preview.safe === "boolean" && entry.ratingAt === void 0) entry.ratingAt = this.time.now();
2203
2228
  entry.pendingProgress = { attempt: value.attempt, phase: "streaming", preview };
2204
2229
  if (preview.safe !== entry.view.progress?.preview?.safe) {
2205
2230
  const desc = entry.view.progress?.preview?.desc;
@@ -2379,6 +2404,7 @@ var Controller = class {
2379
2404
  return null;
2380
2405
  }
2381
2406
  }
2407
+ entry.startedAt = this.time.now();
2382
2408
  return this.evaluate(request, entry.abort.signal, () => {
2383
2409
  if (active() && (entry.view.status === "identifying" || entry.view.status === "analyzing")) {
2384
2410
  entry.view = { ...entry.view, status: "analyzing" };
@@ -2391,7 +2417,9 @@ var Controller = class {
2391
2417
  entry.view = assessment ? { ...entry.view, status: "complete", assessment, progress: void 0 } : { ...entry.view, status: "unrelated", progress: void 0 };
2392
2418
  if (assessment) {
2393
2419
  try {
2394
- void Promise.resolve(this.ratingObserver?.(assessment.safe)).catch(() => {
2420
+ const fullReportMs = Math.max(0, this.time.now() - entry.startedAt);
2421
+ const ratingMs = entry.ratingAt === void 0 ? fullReportMs : Math.max(0, entry.ratingAt - entry.startedAt);
2422
+ void Promise.resolve(this.ratingObserver?.(assessment.safe, { fullReportMs, ratingMs })).catch(() => {
2395
2423
  });
2396
2424
  } catch {
2397
2425
  }
@@ -3646,6 +3674,16 @@ import { constants as constants3 } from "node:fs";
3646
3674
  import { mkdir, open as open3, readdir as readdir2, rename, unlink } from "node:fs/promises";
3647
3675
  import { randomUUID } from "node:crypto";
3648
3676
  import path8 from "node:path";
3677
+ var emptyActivity = () => ({
3678
+ reviews: 0,
3679
+ usageRequests: 0,
3680
+ retries: 0,
3681
+ autoApproved: 0,
3682
+ timedReviews: 0,
3683
+ meanFullReportMs: 0,
3684
+ meanRatingMs: 0,
3685
+ since: null
3686
+ });
3649
3687
  var empty = () => ({
3650
3688
  requests: 0,
3651
3689
  tokenRequests: 0,
@@ -3656,7 +3694,8 @@ var empty = () => ({
3656
3694
  since: null,
3657
3695
  safe: 0,
3658
3696
  unsafe: 0,
3659
- ratingsSince: null
3697
+ ratingsSince: null,
3698
+ activity: emptyActivity()
3660
3699
  });
3661
3700
  var count2 = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3662
3701
  var amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -3665,8 +3704,15 @@ function validate(value) {
3665
3704
  if (!value || ![value.requests, value.tokenRequests, value.input, value.output, value.priced, value.safe, value.unsafe, value.safe + value.unsafe].every(count2) || !amount(value.cost) || value.priced > value.requests || value.tokenRequests > value.requests || value.requests > value.tokenRequests + value.priced || !value.tokenRequests && (value.input !== 0 || value.output !== 0) || (value.requests > 0 ? !count2(value.since) || value.since > 864e13 : value.since !== null || value.input !== 0 || value.output !== 0) || !value.priced && value.cost !== 0 || (value.safe + value.unsafe > 0 ? !count2(value.ratingsSince) || value.ratingsSince > 864e13 : value.ratingsSince !== null)) {
3666
3705
  throw new Error("Invalid lifetime usage totals");
3667
3706
  }
3707
+ const activity = value.activity;
3708
+ if (!activity || ![activity.reviews, activity.usageRequests, activity.retries, activity.autoApproved, activity.timedReviews].every(count2) || activity.reviews > value.safe + value.unsafe || activity.usageRequests > value.requests || activity.timedReviews > activity.reviews || ![activity.meanFullReportMs, activity.meanRatingMs].every(amount) || activity.meanRatingMs > activity.meanFullReportMs || !activity.timedReviews && (activity.meanFullReportMs !== 0 || activity.meanRatingMs !== 0) || (activity.reviews || activity.usageRequests || activity.retries || activity.autoApproved ? !count2(activity.since) || activity.since > 864e13 : activity.since !== null)) throw new Error("Invalid lifetime activity totals");
3668
3709
  return value;
3669
3710
  }
3711
+ function mean(left, leftCount, right, rightCount) {
3712
+ if (!leftCount) return right;
3713
+ if (!rightCount) return left;
3714
+ return left + (right - left) * (rightCount / (leftCount + rightCount));
3715
+ }
3670
3716
  function add(left, right) {
3671
3717
  return validate({
3672
3718
  requests: left.requests + right.requests,
@@ -3678,7 +3724,17 @@ function add(left, right) {
3678
3724
  since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since),
3679
3725
  safe: left.safe + right.safe,
3680
3726
  unsafe: left.unsafe + right.unsafe,
3681
- ratingsSince: left.ratingsSince === null ? right.ratingsSince : right.ratingsSince === null ? left.ratingsSince : Math.min(left.ratingsSince, right.ratingsSince)
3727
+ ratingsSince: left.ratingsSince === null ? right.ratingsSince : right.ratingsSince === null ? left.ratingsSince : Math.min(left.ratingsSince, right.ratingsSince),
3728
+ activity: {
3729
+ reviews: left.activity.reviews + right.activity.reviews,
3730
+ usageRequests: left.activity.usageRequests + right.activity.usageRequests,
3731
+ retries: left.activity.retries + right.activity.retries,
3732
+ autoApproved: left.activity.autoApproved + right.activity.autoApproved,
3733
+ timedReviews: left.activity.timedReviews + right.activity.timedReviews,
3734
+ meanFullReportMs: mean(left.activity.meanFullReportMs, left.activity.timedReviews, right.activity.meanFullReportMs, right.activity.timedReviews),
3735
+ meanRatingMs: mean(left.activity.meanRatingMs, left.activity.timedReviews, right.activity.meanRatingMs, right.activity.timedReviews),
3736
+ since: left.activity.since === null ? right.activity.since : right.activity.since === null ? left.activity.since : Math.min(left.activity.since, right.activity.since)
3737
+ }
3682
3738
  });
3683
3739
  }
3684
3740
  var LifetimeUsage = class {
@@ -3707,13 +3763,36 @@ var LifetimeUsage = class {
3707
3763
  output: usage.output ?? 0,
3708
3764
  priced: usage.cost === void 0 ? 0 : 1,
3709
3765
  cost: usage.cost ?? 0,
3710
- since: Date.now()
3766
+ since: Date.now(),
3767
+ activity: { ...emptyActivity(), usageRequests: 1, since: Date.now() }
3711
3768
  });
3712
3769
  }
3713
3770
  /** One accepted final review, independent of POST count or received usage. */
3714
- recordRating(safe) {
3771
+ recordRating(safe, timing) {
3715
3772
  if (typeof safe !== "boolean") return Promise.reject(new Error("Invalid review rating"));
3716
- return this.increment({ ...empty(), safe: safe ? 1 : 0, unsafe: safe ? 0 : 1, ratingsSince: Date.now() });
3773
+ if (timing && (![timing.fullReportMs, timing.ratingMs].every(amount) || timing.ratingMs > timing.fullReportMs)) {
3774
+ return Promise.reject(new Error("Invalid review timing"));
3775
+ }
3776
+ return this.increment({
3777
+ ...empty(),
3778
+ safe: safe ? 1 : 0,
3779
+ unsafe: safe ? 0 : 1,
3780
+ ratingsSince: Date.now(),
3781
+ activity: {
3782
+ ...emptyActivity(),
3783
+ reviews: 1,
3784
+ since: Date.now(),
3785
+ timedReviews: timing ? 1 : 0,
3786
+ meanFullReportMs: timing?.fullReportMs ?? 0,
3787
+ meanRatingMs: timing?.ratingMs ?? 0
3788
+ }
3789
+ });
3790
+ }
3791
+ recordRetry() {
3792
+ return this.increment({ ...empty(), activity: { ...emptyActivity(), retries: 1, since: Date.now() } });
3793
+ }
3794
+ recordAutoApproval() {
3795
+ return this.increment({ ...empty(), activity: { ...emptyActivity(), autoApproved: 1, since: Date.now() } });
3717
3796
  }
3718
3797
  increment(increment) {
3719
3798
  this.pending = this.pending.catch(() => {
@@ -3724,7 +3803,7 @@ var LifetimeUsage = class {
3724
3803
  try {
3725
3804
  const file = await open3(temporary, "wx", 384);
3726
3805
  try {
3727
- await file.writeFile(JSON.stringify({ version: 3, ...this.local }));
3806
+ await file.writeFile(JSON.stringify({ version: 4, ...this.local }));
3728
3807
  await file.sync();
3729
3808
  } finally {
3730
3809
  await file.close();
@@ -3784,7 +3863,7 @@ var LifetimeUsage = class {
3784
3863
  }
3785
3864
  if (size > 1024) throw new Error("Invalid lifetime usage snapshot");
3786
3865
  const value = JSON.parse(bytes.subarray(0, size).toString("utf8"));
3787
- if (value?.version !== 1 && value?.version !== 2 && value?.version !== 3) throw new Error("Unsupported lifetime usage snapshot");
3866
+ if (![1, 2, 3, 4].includes(value?.version)) throw new Error("Unsupported lifetime usage snapshot");
3788
3867
  total = add(total, validate({
3789
3868
  requests: value.requests,
3790
3869
  input: value.input,
@@ -3793,9 +3872,10 @@ var LifetimeUsage = class {
3793
3872
  priced: value.priced,
3794
3873
  cost: value.cost,
3795
3874
  since: value.since,
3796
- safe: value.version === 3 ? value.safe : 0,
3797
- unsafe: value.version === 3 ? value.unsafe : 0,
3798
- ratingsSince: value.version === 3 ? value.ratingsSince : null
3875
+ safe: value.version >= 3 ? value.safe : 0,
3876
+ unsafe: value.version >= 3 ? value.unsafe : 0,
3877
+ ratingsSince: value.version >= 3 ? value.ratingsSince : null,
3878
+ activity: value.version === 4 ? value.activity : emptyActivity()
3799
3879
  }));
3800
3880
  } finally {
3801
3881
  await file.close();
@@ -3812,19 +3892,21 @@ function lifetimeCost(totals) {
3812
3892
  return uiText.lifetime.cost(totals.cost.toFixed(4), totals.priced < totals.requests);
3813
3893
  }
3814
3894
  function lifetimeReport(totals) {
3895
+ const reviews = totals.safe + totals.unsafe, activity = totals.activity;
3896
+ const percentage = (count3) => reviews ? (count3 / reviews * 100).toFixed(1) : void 0;
3897
+ const partial = activity.reviews < reviews || activity.usageRequests < totals.requests;
3815
3898
  return [
3816
- lifetimeCost(totals),
3817
- uiText.lifetime.ratings(totals.safe, totals.unsafe),
3818
- uiText.lifetime.requests(totals.requests),
3819
- totals.tokenRequests ? uiText.lifetime.tokens(totals.input, totals.output, totals.tokenRequests < totals.requests) : uiText.lifetime.tokensUnavailable,
3820
- uiText.lifetime.tokenCoverage(totals.tokenRequests, totals.requests),
3821
- uiText.lifetime.pricingCoverage(totals.priced, totals.requests),
3822
- ...totals.since === null ? [] : [uiText.lifetime.since(new Date(totals.since).toISOString().slice(0, 10))],
3823
- ...totals.ratingsSince === null ? [] : [uiText.lifetime.ratingsSince(new Date(totals.ratingsSince).toISOString().slice(0, 10))],
3899
+ uiText.lifetime.dialogReviews(reviews),
3900
+ uiText.lifetime.dialogRetries(activity.retries, partial),
3901
+ totals.tokenRequests ? uiText.lifetime.dialogTokens(totals.input, totals.output, totals.tokenRequests < totals.requests) : uiText.lifetime.dialogTokensUnavailable,
3902
+ uiText.lifetime.dialogCost(totals.priced ? totals.cost.toFixed(4) : void 0, totals.priced < totals.requests),
3824
3903
  "",
3825
- uiText.lifetime.explanation,
3904
+ uiText.lifetime.dialogSafe(totals.safe, percentage(totals.safe)),
3905
+ uiText.lifetime.dialogUnsafe(totals.unsafe, percentage(totals.unsafe)),
3906
+ uiText.lifetime.dialogAutoApproved(activity.autoApproved, percentage(activity.autoApproved), partial),
3826
3907
  "",
3827
- uiText.lifetime.ratingsExplanation
3908
+ uiText.lifetime.averageFullReport(activity.timedReviews ? (activity.meanFullReportMs / 1e3).toFixed(2) : void 0),
3909
+ uiText.lifetime.averageRating(activity.timedReviews ? (activity.meanRatingMs / 1e3).toFixed(2) : void 0)
3828
3910
  ].join("\n");
3829
3911
  }
3830
3912
 
@@ -3910,7 +3992,7 @@ var LifetimeRefresh = class {
3910
3992
  // src/lifetime-view.tsx
3911
3993
  function lifetimeTracker(api) {
3912
3994
  const directory = path9.join(api.state.path.state, "opencode-reviewer");
3913
- const store = new LifetimeUsage(path9.join(directory, "usage-v3"), path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
3995
+ const store = new LifetimeUsage(path9.join(directory, "usage-v4"), path9.join(directory, "usage-v3"), path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
3914
3996
  const [totals, setTotals] = createSignal();
3915
3997
  const [unavailable, setUnavailable] = createSignal(false);
3916
3998
  const refresh = new LifetimeRefresh((signal) => store.totals(signal), (value) => {
@@ -3951,8 +4033,14 @@ function lifetimeTracker(api) {
3951
4033
  record: (usage) => {
3952
4034
  void store.record(usage).then(() => refresh.refresh(), () => refresh.failed());
3953
4035
  },
3954
- recordRating: (safe) => {
3955
- void store.recordRating(safe).then(() => refresh.refresh(), () => refresh.failed());
4036
+ recordRating: (safe, timing) => {
4037
+ void store.recordRating(safe, timing).then(() => refresh.refresh(), () => refresh.failed());
4038
+ },
4039
+ recordRetry: () => {
4040
+ void store.recordRetry().then(() => refresh.refresh(), () => refresh.failed());
4041
+ },
4042
+ recordAutoApproval: () => {
4043
+ void store.recordAutoApproval().then(() => refresh.refresh(), () => refresh.failed());
3956
4044
  }
3957
4045
  };
3958
4046
  }
@@ -6348,7 +6436,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
6348
6436
  }, config, configError, signal, onIdentified, files);
6349
6437
  if (!evidence) return null;
6350
6438
  signal.throwIfAborted();
6351
- const result = review(evidence, config, signal, void 0, void 0, prompts, (model) => modelPricing(api.state.provider, config.baseURL, model), lifetime.record, onProgress, trace?.forward);
6439
+ const result = review(evidence, config, signal, void 0, void 0, prompts, (model) => modelPricing(api.state.provider, config.baseURL, model), lifetime.record, onProgress, trace?.forward, lifetime.recordRetry);
6352
6440
  worker = result;
6353
6441
  return result;
6354
6442
  });
@@ -6369,7 +6457,10 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
6369
6457
  once: (request, signal) => measured(traces?.get(request), "approval-reply", () => approval.once(request, signal))
6370
6458
  } : approval,
6371
6459
  visibleID: () => visibleApproval()
6372
- }, void 0, modes, (fact) => notifications?.fact(fact), lifetime.recordRating);
6460
+ }, void 0, modes, (fact) => {
6461
+ if (fact.type === "confirmed" && fact.automatic) lifetime.recordAutoApproval();
6462
+ notifications?.fact(fact);
6463
+ }, lifetime.recordRating);
6373
6464
  api.event.on("permission.asked", (event) => controller.asked(event.properties));
6374
6465
  api.event.on("permission.replied", (event) => controller.replied(event.properties.requestID));
6375
6466
  api.event.on("session.deleted", (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-reviewer",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",