opencode-reviewer 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -9
- package/dist/tui.js +178 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,12 @@ The rating remains provisional until the full response is validated. A format re
|
|
|
92
92
|
clears the preview and restores the loading indicator. Auto-approval
|
|
93
93
|
starts only after the full response is validated and rendered.
|
|
94
94
|
|
|
95
|
+
Transient connection failures and HTTP 408/429/500/502/503/504 responses get up to
|
|
96
|
+
two internal retries within `timeoutMs`, honoring server cooldowns. A stream that
|
|
97
|
+
has already delivered assessment text is not restarted. This is separate from
|
|
98
|
+
`formatRetries`; no additional setting is needed. Retried requests can incur
|
|
99
|
+
additional provider charges, and unreported usage remains unknown.
|
|
100
|
+
|
|
95
101
|
During an auto-approval countdown, click the countdown to allow once immediately,
|
|
96
102
|
or Cancel to leave the request manual. Hiding or covering the panel also cancels
|
|
97
103
|
that request's countdown. Cancel before using native Allow always or rejection
|
|
@@ -110,6 +116,12 @@ costs use reported charges; other endpoints use available catalog estimates.
|
|
|
110
116
|
Received usage counts even if a review fails or is interrupted. Unreported charges
|
|
111
117
|
are missing from the totals.
|
|
112
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.
|
|
124
|
+
|
|
113
125
|
Reviews send the pending request, latest user prompt, project context, and relevant
|
|
114
126
|
file snapshots, diffs, or tool arguments to your endpoint before approval. Shell
|
|
115
127
|
file snapshots can follow symlinks outside the project. Missing evidence is noted
|
|
@@ -117,21 +129,19 @@ in the report; ratings are advice based on the supplied evidence.
|
|
|
117
129
|
|
|
118
130
|
### Desktop notifications
|
|
119
131
|
|
|
120
|
-
Transient banners have an **Opencode** heading and a small status icon: green
|
|
121
|
-
checkmark for approvals, orange exclamation mark for attention
|
|
132
|
+
Transient banners have an **Opencode (Session name)** heading and a small status icon: green
|
|
133
|
+
checkmark for approvals, orange exclamation mark for attention, red X
|
|
122
134
|
for errors, and a neutral code mark for completed responses. GNOME controls the
|
|
123
|
-
heading's font weight. The event
|
|
124
|
-
|
|
125
|
-
An explicit separator keeps them distinct on desktops that flatten banner text:
|
|
135
|
+
heading's font weight. The event message appears beneath it, even while the
|
|
136
|
+
terminal is focused:
|
|
126
137
|
|
|
127
138
|
- **Session needs attention:** questions and manual permissions. Reviewed requests
|
|
128
139
|
wait for a final validated assessment; failures, canceled automation, and a Safe
|
|
129
140
|
review blocked from starting its countdown also notify. Unreviewed requests
|
|
130
141
|
notify immediately, including when conversation review is disabled.
|
|
131
|
-
- **Reviewer
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
sends only the confirmed approval notification. Approval sounds are limited to
|
|
142
|
+
- **Reviewer approved a permission:** sent with the approval sound after confirmed
|
|
143
|
+
automatic approval, for both positive and zero delays. The countdown is silent.
|
|
144
|
+
Approval sounds are limited to
|
|
135
145
|
one every two seconds; every eligible banner is retained for delivery.
|
|
136
146
|
- **Session error:** an unrecovered session/provider failure, not a review failure.
|
|
137
147
|
- **Session ended:** a completed root-agent response, not a question/permission
|
package/dist/tui.js
CHANGED
|
@@ -1181,6 +1181,9 @@ var uiText = {
|
|
|
1181
1181
|
tokenCoverage: (available, total) => `Token counts available: ${available}/${total} requests`,
|
|
1182
1182
|
pricingCoverage: (available, total) => `Pricing available: ${available}/${total} requests`,
|
|
1183
1183
|
since: (date) => `Recorded since: ${date}`,
|
|
1184
|
+
ratings: (safe, unsafe) => `${safe} \u2713 ${unsafe} \u2717`,
|
|
1185
|
+
ratingsSince: (date) => `Ratings recorded since: ${date}`,
|
|
1186
|
+
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.",
|
|
1184
1187
|
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."
|
|
1185
1188
|
},
|
|
1186
1189
|
notifications: {
|
|
@@ -1189,7 +1192,6 @@ var uiText = {
|
|
|
1189
1192
|
openSession: "Open session",
|
|
1190
1193
|
fallbackSession: "OpenCode session",
|
|
1191
1194
|
attention: "Session needs attention",
|
|
1192
|
-
countdown: (seconds) => `Reviewer will approve permission in ${seconds} s`,
|
|
1193
1195
|
approved: "Reviewer approved a permission",
|
|
1194
1196
|
error: "Session error",
|
|
1195
1197
|
ended: "Session ended",
|
|
@@ -1603,6 +1605,74 @@ function measured(trace, phase, run) {
|
|
|
1603
1605
|
return trace ? trace.measure(phase, run) : run();
|
|
1604
1606
|
}
|
|
1605
1607
|
|
|
1608
|
+
// src/transport-retry.ts
|
|
1609
|
+
init_define_REVIEW_PROMPTS();
|
|
1610
|
+
init_define_REVIEW_SOUNDS();
|
|
1611
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
1612
|
+
var statuses = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
|
|
1613
|
+
var networkCodes = /* @__PURE__ */ new Set([
|
|
1614
|
+
"EAI_AGAIN",
|
|
1615
|
+
"ECONNRESET",
|
|
1616
|
+
"ECONNREFUSED",
|
|
1617
|
+
"ECONNABORTED",
|
|
1618
|
+
"EPIPE",
|
|
1619
|
+
"ETIMEDOUT",
|
|
1620
|
+
"ENETUNREACH",
|
|
1621
|
+
"EHOSTUNREACH",
|
|
1622
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
1623
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
1624
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
1625
|
+
"UND_ERR_SOCKET"
|
|
1626
|
+
]);
|
|
1627
|
+
var TransientTransportError = class extends Error {
|
|
1628
|
+
constructor(message, notBefore = 0) {
|
|
1629
|
+
super(message);
|
|
1630
|
+
this.notBefore = notBefore;
|
|
1631
|
+
}
|
|
1632
|
+
notBefore;
|
|
1633
|
+
};
|
|
1634
|
+
function networkFailure(error, message) {
|
|
1635
|
+
for (let depth = 0; depth < 4 && error && typeof error === "object"; depth++) {
|
|
1636
|
+
const value = error;
|
|
1637
|
+
if (typeof value.code === "string") {
|
|
1638
|
+
return networkCodes.has(value.code) ? new TransientTransportError(message) : new Error(message);
|
|
1639
|
+
}
|
|
1640
|
+
error = value.cause;
|
|
1641
|
+
}
|
|
1642
|
+
return new Error(message);
|
|
1643
|
+
}
|
|
1644
|
+
function retryAfter(value, now = Date.now()) {
|
|
1645
|
+
if (value === null) return 0;
|
|
1646
|
+
value = value.trim();
|
|
1647
|
+
if (/^\d+$/.test(value)) return Number(value) * 1e3;
|
|
1648
|
+
if (!/^(?:[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT|[A-Za-z]+, \d{2}-[A-Za-z]{3}-\d{2} \d{2}:\d{2}:\d{2} GMT|[A-Za-z]{3} [A-Za-z]{3} [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/.test(value)) return 0;
|
|
1649
|
+
const date = Date.parse(value.endsWith("GMT") ? value : `${value} GMT`);
|
|
1650
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : 0;
|
|
1651
|
+
}
|
|
1652
|
+
function httpFailure(response) {
|
|
1653
|
+
const message = `Reviewer HTTP ${response.status}`;
|
|
1654
|
+
return statuses.has(response.status) ? new TransientTransportError(message, performance.now() + retryAfter(response.headers.get("retry-after"))) : new Error(message);
|
|
1655
|
+
}
|
|
1656
|
+
var TransportRetries = class {
|
|
1657
|
+
used = 0;
|
|
1658
|
+
async wait(error, signal) {
|
|
1659
|
+
reviewStage(signal, "Reviewer response");
|
|
1660
|
+
if (!(error instanceof TransientTransportError) || this.used >= 2) return false;
|
|
1661
|
+
const base = 250 * 2 ** this.used;
|
|
1662
|
+
const delay = Math.max(base + Math.random() * base, error.notBefore - performance.now());
|
|
1663
|
+
if (delay + 250 >= remainingTime(signal)) return false;
|
|
1664
|
+
this.used++;
|
|
1665
|
+
try {
|
|
1666
|
+
await sleep(Math.ceil(delay), void 0, { signal });
|
|
1667
|
+
} catch {
|
|
1668
|
+
signal.throwIfAborted();
|
|
1669
|
+
throw error;
|
|
1670
|
+
}
|
|
1671
|
+
reviewStage(signal, "Reviewer response");
|
|
1672
|
+
return remainingTime(signal) > 250;
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1606
1676
|
// src/reviewer.ts
|
|
1607
1677
|
function abortable(promise, signal, late) {
|
|
1608
1678
|
return new Promise((resolve, reject) => {
|
|
@@ -1647,9 +1717,9 @@ async function readBody(response, signal, consume) {
|
|
|
1647
1717
|
let part;
|
|
1648
1718
|
try {
|
|
1649
1719
|
part = await abortable(reader2.read(), signal);
|
|
1650
|
-
} catch {
|
|
1720
|
+
} catch (error) {
|
|
1651
1721
|
signal.throwIfAborted();
|
|
1652
|
-
throw
|
|
1722
|
+
throw networkFailure(error, "Reviewer response read failed");
|
|
1653
1723
|
}
|
|
1654
1724
|
signal.throwIfAborted();
|
|
1655
1725
|
if (part.done) break;
|
|
@@ -1748,6 +1818,15 @@ async function streamedAssessment(response, signal, accounting, progress, firstC
|
|
|
1748
1818
|
return assessment;
|
|
1749
1819
|
}
|
|
1750
1820
|
async function review(evidence, config, signal, fetcher = fetch, environment = process.env, prompts = BUILTIN_PROMPTS, pricing, onUsage, onProgress, onDiagnostics) {
|
|
1821
|
+
if (remainingTime(signal) === Infinity) {
|
|
1822
|
+
let worker;
|
|
1823
|
+
try {
|
|
1824
|
+
return await withDeadline(signal, config.timeoutMs, (bounded) => worker = review(evidence, config, bounded, fetcher, environment, prompts, pricing, onUsage, onProgress, onDiagnostics));
|
|
1825
|
+
} finally {
|
|
1826
|
+
await worker?.catch(() => {
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1751
1830
|
const key = config.apiKey ?? (config.apiKeyEnv ? environment[config.apiKeyEnv]?.trim() : void 0);
|
|
1752
1831
|
if (config.apiKeyEnv && !key) throw new Error(`API key environment variable ${config.apiKeyEnv} is unset or empty`);
|
|
1753
1832
|
const prompt2 = prompts[evidence.kind];
|
|
@@ -1756,7 +1835,9 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1756
1835
|
{ role: "user", content: JSON.stringify(evidence) }
|
|
1757
1836
|
];
|
|
1758
1837
|
let usage;
|
|
1759
|
-
|
|
1838
|
+
let corrections = 0;
|
|
1839
|
+
const retries = new TransportRetries();
|
|
1840
|
+
for (let attempt = 0; ; attempt++) {
|
|
1760
1841
|
signal.throwIfAborted();
|
|
1761
1842
|
const progress = (phase, preview) => {
|
|
1762
1843
|
if (signal.aborted) return;
|
|
@@ -1768,6 +1849,10 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1768
1849
|
};
|
|
1769
1850
|
progress(attempt ? "retrying" : "evaluating");
|
|
1770
1851
|
const accounting = usageAttempt(config.baseURL, config.model, pricing, onUsage);
|
|
1852
|
+
const requestAbort = new AbortController();
|
|
1853
|
+
let contentStarted = false;
|
|
1854
|
+
let failed = false;
|
|
1855
|
+
let failure;
|
|
1771
1856
|
try {
|
|
1772
1857
|
reviewStage(signal, "Reviewer response");
|
|
1773
1858
|
let response;
|
|
@@ -1776,31 +1861,36 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1776
1861
|
const body = JSON.stringify({
|
|
1777
1862
|
model: config.model,
|
|
1778
1863
|
messages,
|
|
1864
|
+
max_tokens: 2e3,
|
|
1779
1865
|
stream: config.stream,
|
|
1780
1866
|
...config.stream ? { stream_options: { include_usage: true } } : {}
|
|
1781
1867
|
});
|
|
1868
|
+
reviewStage(signal, "Reviewer response");
|
|
1782
1869
|
diagnose = onDiagnostics ? diagnosticAttempt(onDiagnostics, attempt) : void 0;
|
|
1783
1870
|
response = await abortable(fetcher(`${config.baseURL}/chat/completions`, {
|
|
1784
1871
|
method: "POST",
|
|
1785
1872
|
redirect: "error",
|
|
1786
|
-
signal,
|
|
1873
|
+
signal: AbortSignal.any([signal, requestAbort.signal]),
|
|
1787
1874
|
headers: { "Content-Type": "application/json", ...key ? { Authorization: `Bearer ${key}` } : {} },
|
|
1788
1875
|
body
|
|
1789
1876
|
}), signal, cancelBody);
|
|
1790
1877
|
diagnose?.("headers");
|
|
1791
|
-
} catch {
|
|
1878
|
+
} catch (error) {
|
|
1792
1879
|
signal.throwIfAborted();
|
|
1793
|
-
throw
|
|
1880
|
+
throw networkFailure(error, "Reviewer network request failed");
|
|
1794
1881
|
}
|
|
1795
1882
|
if (!response.ok) {
|
|
1796
1883
|
cancelBody(response);
|
|
1797
|
-
throw
|
|
1884
|
+
throw httpFailure(response);
|
|
1798
1885
|
}
|
|
1799
1886
|
let assessment;
|
|
1800
1887
|
if (config.stream) assessment = await streamedAssessment(response, signal, accounting, (preview) => {
|
|
1801
1888
|
if (typeof preview?.safe === "boolean") diagnose?.("first-rating");
|
|
1802
1889
|
progress("streaming", preview);
|
|
1803
|
-
},
|
|
1890
|
+
}, () => {
|
|
1891
|
+
contentStarted = true;
|
|
1892
|
+
diagnose?.("first-content");
|
|
1893
|
+
});
|
|
1804
1894
|
else {
|
|
1805
1895
|
let envelope;
|
|
1806
1896
|
try {
|
|
@@ -1818,14 +1908,17 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1818
1908
|
if (!object(choice) || !object(message) || typeof message.content !== "string" || !textMetadata(message) || object(envelope) && envelope.error != null || choice.error != null || choice.index !== void 0 && choice.index !== 0 || choice.finish_reason !== void 0 && choice.finish_reason !== "stop") {
|
|
1819
1909
|
throw new Error("Reviewer API did not return a text assessment");
|
|
1820
1910
|
}
|
|
1821
|
-
if (message.content)
|
|
1911
|
+
if (message.content) {
|
|
1912
|
+
contentStarted = true;
|
|
1913
|
+
diagnose?.("first-content");
|
|
1914
|
+
}
|
|
1822
1915
|
assessment = new StreamingAssessment();
|
|
1823
1916
|
assessment.push(message.content);
|
|
1824
1917
|
if (diagnose && typeof assessment.preview()?.safe === "boolean") diagnose("first-rating");
|
|
1825
1918
|
}
|
|
1826
1919
|
reviewStage(signal, "Assessment validation");
|
|
1827
1920
|
const currentUsage = accounting.current();
|
|
1828
|
-
|
|
1921
|
+
const reportUsage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
|
|
1829
1922
|
try {
|
|
1830
1923
|
let result;
|
|
1831
1924
|
try {
|
|
@@ -1835,20 +1928,26 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1835
1928
|
}
|
|
1836
1929
|
if (config.stream) progress("streaming", result);
|
|
1837
1930
|
signal.throwIfAborted();
|
|
1838
|
-
return { ...result, ...
|
|
1931
|
+
return { ...result, ...reportUsage ? { usage: reportUsage } : {} };
|
|
1839
1932
|
} catch (error) {
|
|
1840
1933
|
if (!(error instanceof AssessmentFormatError)) throw error;
|
|
1841
|
-
if (
|
|
1934
|
+
if (corrections++ === config.formatRetries) throw new Error("Reviewer response format invalid after configured attempts");
|
|
1842
1935
|
messages.push(
|
|
1843
1936
|
{ role: "assistant", content: assessment.content },
|
|
1844
1937
|
{ role: "user", content: correctionPrompt(error.message) }
|
|
1845
1938
|
);
|
|
1846
1939
|
}
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
requestAbort.abort();
|
|
1942
|
+
if (contentStarted) throw error;
|
|
1943
|
+
failed = true;
|
|
1944
|
+
failure = error;
|
|
1847
1945
|
} finally {
|
|
1848
|
-
accounting.finalize();
|
|
1946
|
+
const currentUsage = accounting.finalize();
|
|
1947
|
+
usage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
|
|
1849
1948
|
}
|
|
1949
|
+
if (failed && !await retries.wait(failure, signal)) throw failure;
|
|
1850
1950
|
}
|
|
1851
|
-
throw new Error("Reviewer exhausted attempts");
|
|
1852
1951
|
}
|
|
1853
1952
|
|
|
1854
1953
|
// src/classification.ts
|
|
@@ -1980,7 +2079,7 @@ var clock = {
|
|
|
1980
2079
|
};
|
|
1981
2080
|
var permissionResolved = Object.freeze({ reason: "permission resolved" });
|
|
1982
2081
|
var Controller = class {
|
|
1983
|
-
constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver) {
|
|
2082
|
+
constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver, ratingObserver) {
|
|
1984
2083
|
this.evaluate = evaluate;
|
|
1985
2084
|
this.changed = changed;
|
|
1986
2085
|
this.options = options;
|
|
@@ -1988,6 +2087,7 @@ var Controller = class {
|
|
|
1988
2087
|
this.time = time;
|
|
1989
2088
|
this.modes = modes;
|
|
1990
2089
|
this.approvalObserver = approvalObserver;
|
|
2090
|
+
this.ratingObserver = ratingObserver;
|
|
1991
2091
|
}
|
|
1992
2092
|
evaluate;
|
|
1993
2093
|
changed;
|
|
@@ -1996,6 +2096,7 @@ var Controller = class {
|
|
|
1996
2096
|
time;
|
|
1997
2097
|
modes;
|
|
1998
2098
|
approvalObserver;
|
|
2099
|
+
ratingObserver;
|
|
1999
2100
|
entries = /* @__PURE__ */ new Map();
|
|
2000
2101
|
stopped = false;
|
|
2001
2102
|
version = 0;
|
|
@@ -2288,6 +2389,13 @@ var Controller = class {
|
|
|
2288
2389
|
this.clearProgress(entry);
|
|
2289
2390
|
if (active()) {
|
|
2290
2391
|
entry.view = assessment ? { ...entry.view, status: "complete", assessment, progress: void 0 } : { ...entry.view, status: "unrelated", progress: void 0 };
|
|
2392
|
+
if (assessment) {
|
|
2393
|
+
try {
|
|
2394
|
+
void Promise.resolve(this.ratingObserver?.(assessment.safe)).catch(() => {
|
|
2395
|
+
});
|
|
2396
|
+
} catch {
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2291
2399
|
this.publish();
|
|
2292
2400
|
}
|
|
2293
2401
|
}, (error) => {
|
|
@@ -3538,12 +3646,25 @@ import { constants as constants3 } from "node:fs";
|
|
|
3538
3646
|
import { mkdir, open as open3, readdir as readdir2, rename, unlink } from "node:fs/promises";
|
|
3539
3647
|
import { randomUUID } from "node:crypto";
|
|
3540
3648
|
import path8 from "node:path";
|
|
3541
|
-
var empty = () => ({
|
|
3649
|
+
var empty = () => ({
|
|
3650
|
+
requests: 0,
|
|
3651
|
+
tokenRequests: 0,
|
|
3652
|
+
input: 0,
|
|
3653
|
+
output: 0,
|
|
3654
|
+
priced: 0,
|
|
3655
|
+
cost: 0,
|
|
3656
|
+
since: null,
|
|
3657
|
+
safe: 0,
|
|
3658
|
+
unsafe: 0,
|
|
3659
|
+
ratingsSince: null
|
|
3660
|
+
});
|
|
3542
3661
|
var count2 = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
3543
3662
|
var amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
3544
3663
|
var snapshotName = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/;
|
|
3545
3664
|
function validate(value) {
|
|
3546
|
-
if (!value || ![value.requests, value.tokenRequests, value.input, value.output, value.priced].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
|
|
3665
|
+
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
|
+
throw new Error("Invalid lifetime usage totals");
|
|
3667
|
+
}
|
|
3547
3668
|
return value;
|
|
3548
3669
|
}
|
|
3549
3670
|
function add(left, right) {
|
|
@@ -3554,28 +3675,32 @@ function add(left, right) {
|
|
|
3554
3675
|
tokenRequests: left.tokenRequests + right.tokenRequests,
|
|
3555
3676
|
priced: left.priced + right.priced,
|
|
3556
3677
|
cost: left.cost + right.cost,
|
|
3557
|
-
since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since)
|
|
3678
|
+
since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since),
|
|
3679
|
+
safe: left.safe + right.safe,
|
|
3680
|
+
unsafe: left.unsafe + right.unsafe,
|
|
3681
|
+
ratingsSince: left.ratingsSince === null ? right.ratingsSince : right.ratingsSince === null ? left.ratingsSince : Math.min(left.ratingsSince, right.ratingsSince)
|
|
3558
3682
|
});
|
|
3559
3683
|
}
|
|
3560
3684
|
var LifetimeUsage = class {
|
|
3561
|
-
constructor(directory,
|
|
3685
|
+
constructor(directory, ...legacyDirectories) {
|
|
3562
3686
|
this.directory = directory;
|
|
3563
|
-
|
|
3564
|
-
if (!path8.isAbsolute(directory) || legacyDirectory !== void 0 && !path8.isAbsolute(legacyDirectory)) {
|
|
3687
|
+
if (![directory, ...legacyDirectories].every((value) => path8.isAbsolute(value))) {
|
|
3565
3688
|
throw new Error("Lifetime usage directory must be absolute");
|
|
3566
3689
|
}
|
|
3690
|
+
this.legacyDirectories = legacyDirectories;
|
|
3567
3691
|
}
|
|
3568
3692
|
directory;
|
|
3569
|
-
legacyDirectory;
|
|
3570
3693
|
id = randomUUID();
|
|
3571
3694
|
local = empty();
|
|
3572
3695
|
pending = Promise.resolve();
|
|
3696
|
+
legacyDirectories;
|
|
3573
3697
|
record(usage) {
|
|
3574
3698
|
const tokens = count2(usage.input) && count2(usage.output);
|
|
3575
3699
|
if (!tokens && (usage.input !== void 0 || usage.output !== void 0 || usage.cost === void 0) || usage.cost !== void 0 && !amount(usage.cost)) {
|
|
3576
3700
|
return Promise.reject(new Error("Invalid request usage"));
|
|
3577
3701
|
}
|
|
3578
|
-
|
|
3702
|
+
return this.increment({
|
|
3703
|
+
...empty(),
|
|
3579
3704
|
requests: 1,
|
|
3580
3705
|
tokenRequests: tokens ? 1 : 0,
|
|
3581
3706
|
input: usage.input ?? 0,
|
|
@@ -3583,7 +3708,14 @@ var LifetimeUsage = class {
|
|
|
3583
3708
|
priced: usage.cost === void 0 ? 0 : 1,
|
|
3584
3709
|
cost: usage.cost ?? 0,
|
|
3585
3710
|
since: Date.now()
|
|
3586
|
-
};
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3713
|
+
/** One accepted final review, independent of POST count or received usage. */
|
|
3714
|
+
recordRating(safe) {
|
|
3715
|
+
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() });
|
|
3717
|
+
}
|
|
3718
|
+
increment(increment) {
|
|
3587
3719
|
this.pending = this.pending.catch(() => {
|
|
3588
3720
|
}).then(async () => {
|
|
3589
3721
|
this.local = add(this.local, increment);
|
|
@@ -3592,7 +3724,7 @@ var LifetimeUsage = class {
|
|
|
3592
3724
|
try {
|
|
3593
3725
|
const file = await open3(temporary, "wx", 384);
|
|
3594
3726
|
try {
|
|
3595
|
-
await file.writeFile(JSON.stringify({ version:
|
|
3727
|
+
await file.writeFile(JSON.stringify({ version: 3, ...this.local }));
|
|
3596
3728
|
await file.sync();
|
|
3597
3729
|
} finally {
|
|
3598
3730
|
await file.close();
|
|
@@ -3620,7 +3752,7 @@ var LifetimeUsage = class {
|
|
|
3620
3752
|
await this.flush();
|
|
3621
3753
|
signal?.throwIfAborted();
|
|
3622
3754
|
let total = empty();
|
|
3623
|
-
const directories = new Set([this.directory, ...this.
|
|
3755
|
+
const directories = new Set([this.directory, ...this.legacyDirectories].map((directory) => path8.resolve(directory)));
|
|
3624
3756
|
for (const directory of directories) total = add(total, await this.readDirectory(directory, signal));
|
|
3625
3757
|
signal?.throwIfAborted();
|
|
3626
3758
|
return total;
|
|
@@ -3652,7 +3784,7 @@ var LifetimeUsage = class {
|
|
|
3652
3784
|
}
|
|
3653
3785
|
if (size > 1024) throw new Error("Invalid lifetime usage snapshot");
|
|
3654
3786
|
const value = JSON.parse(bytes.subarray(0, size).toString("utf8"));
|
|
3655
|
-
if (value?.version !== 1 && value?.version !== 2) throw new Error("Unsupported lifetime usage snapshot");
|
|
3787
|
+
if (value?.version !== 1 && value?.version !== 2 && value?.version !== 3) throw new Error("Unsupported lifetime usage snapshot");
|
|
3656
3788
|
total = add(total, validate({
|
|
3657
3789
|
requests: value.requests,
|
|
3658
3790
|
input: value.input,
|
|
@@ -3660,7 +3792,10 @@ var LifetimeUsage = class {
|
|
|
3660
3792
|
tokenRequests: value.version === 1 ? value.requests : value.tokenRequests,
|
|
3661
3793
|
priced: value.priced,
|
|
3662
3794
|
cost: value.cost,
|
|
3663
|
-
since: value.since
|
|
3795
|
+
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
|
|
3664
3799
|
}));
|
|
3665
3800
|
} finally {
|
|
3666
3801
|
await file.close();
|
|
@@ -3679,13 +3814,17 @@ function lifetimeCost(totals) {
|
|
|
3679
3814
|
function lifetimeReport(totals) {
|
|
3680
3815
|
return [
|
|
3681
3816
|
lifetimeCost(totals),
|
|
3817
|
+
uiText.lifetime.ratings(totals.safe, totals.unsafe),
|
|
3682
3818
|
uiText.lifetime.requests(totals.requests),
|
|
3683
3819
|
totals.tokenRequests ? uiText.lifetime.tokens(totals.input, totals.output, totals.tokenRequests < totals.requests) : uiText.lifetime.tokensUnavailable,
|
|
3684
3820
|
uiText.lifetime.tokenCoverage(totals.tokenRequests, totals.requests),
|
|
3685
3821
|
uiText.lifetime.pricingCoverage(totals.priced, totals.requests),
|
|
3686
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))],
|
|
3824
|
+
"",
|
|
3825
|
+
uiText.lifetime.explanation,
|
|
3687
3826
|
"",
|
|
3688
|
-
uiText.lifetime.
|
|
3827
|
+
uiText.lifetime.ratingsExplanation
|
|
3689
3828
|
].join("\n");
|
|
3690
3829
|
}
|
|
3691
3830
|
|
|
@@ -3771,7 +3910,7 @@ var LifetimeRefresh = class {
|
|
|
3771
3910
|
// src/lifetime-view.tsx
|
|
3772
3911
|
function lifetimeTracker(api) {
|
|
3773
3912
|
const directory = path9.join(api.state.path.state, "opencode-reviewer");
|
|
3774
|
-
const store = new LifetimeUsage(path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
|
|
3913
|
+
const store = new LifetimeUsage(path9.join(directory, "usage-v3"), path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
|
|
3775
3914
|
const [totals, setTotals] = createSignal();
|
|
3776
3915
|
const [unavailable, setUnavailable] = createSignal(false);
|
|
3777
3916
|
const refresh = new LifetimeRefresh((signal) => store.totals(signal), (value) => {
|
|
@@ -3808,9 +3947,12 @@ function lifetimeTracker(api) {
|
|
|
3808
3947
|
// The controller must settle aborted review workers before this final queue drain.
|
|
3809
3948
|
flush: () => store.flush().catch(() => {
|
|
3810
3949
|
}),
|
|
3811
|
-
text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? lifetimeCost(totals()) : void 0,
|
|
3950
|
+
text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? [lifetimeCost(totals()), uiText.lifetime.ratings(totals().safe, totals().unsafe)].join("\n") : void 0,
|
|
3812
3951
|
record: (usage) => {
|
|
3813
3952
|
void store.record(usage).then(() => refresh.refresh(), () => refresh.failed());
|
|
3953
|
+
},
|
|
3954
|
+
recordRating: (safe) => {
|
|
3955
|
+
void store.recordRating(safe).then(() => refresh.refresh(), () => refresh.failed());
|
|
3814
3956
|
}
|
|
3815
3957
|
};
|
|
3816
3958
|
}
|
|
@@ -4066,16 +4208,14 @@ function notificationMarkup(value) {
|
|
|
4066
4208
|
|
|
4067
4209
|
// src/notification-policy.ts
|
|
4068
4210
|
var NotificationPolicy = class {
|
|
4069
|
-
constructor(config, auto,
|
|
4211
|
+
constructor(config, auto, backend, clock2 = notificationClock) {
|
|
4070
4212
|
this.config = config;
|
|
4071
4213
|
this.auto = auto;
|
|
4072
|
-
this.delay = delay;
|
|
4073
4214
|
this.backend = backend;
|
|
4074
4215
|
this.clock = clock2;
|
|
4075
4216
|
}
|
|
4076
4217
|
config;
|
|
4077
4218
|
auto;
|
|
4078
|
-
delay;
|
|
4079
4219
|
backend;
|
|
4080
4220
|
clock;
|
|
4081
4221
|
permissions = /* @__PURE__ */ new Map();
|
|
@@ -4176,7 +4316,7 @@ var NotificationPolicy = class {
|
|
|
4176
4316
|
if (entry.phase !== "automatic") {
|
|
4177
4317
|
this.withdraw(entry.banner);
|
|
4178
4318
|
entry.phase = "automatic";
|
|
4179
|
-
entry.banner =
|
|
4319
|
+
entry.banner = void 0;
|
|
4180
4320
|
}
|
|
4181
4321
|
return;
|
|
4182
4322
|
}
|
|
@@ -6188,7 +6328,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
|
|
|
6188
6328
|
try {
|
|
6189
6329
|
const click = (id) => notifications?.click(id);
|
|
6190
6330
|
const backend = notificationBackend ? notificationBackend(click, notificationConfig) : new LinuxNotifications(notificationConfig, click);
|
|
6191
|
-
notifications = new NotificationHost(api, new NotificationPolicy(notificationConfig, reviewOptions?.autoApprove === true,
|
|
6331
|
+
notifications = new NotificationHost(api, new NotificationPolicy(notificationConfig, reviewOptions?.autoApprove === true, backend), (id, signal) => modes.root(id, signal));
|
|
6192
6332
|
} catch {
|
|
6193
6333
|
}
|
|
6194
6334
|
}
|
|
@@ -6229,7 +6369,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
|
|
|
6229
6369
|
once: (request, signal) => measured(traces?.get(request), "approval-reply", () => approval.once(request, signal))
|
|
6230
6370
|
} : approval,
|
|
6231
6371
|
visibleID: () => visibleApproval()
|
|
6232
|
-
}, void 0, modes, (fact) => notifications?.fact(fact));
|
|
6372
|
+
}, void 0, modes, (fact) => notifications?.fact(fact), lifetime.recordRating);
|
|
6233
6373
|
api.event.on("permission.asked", (event) => controller.asked(event.properties));
|
|
6234
6374
|
api.event.on("permission.replied", (event) => controller.replied(event.properties.requestID));
|
|
6235
6375
|
api.event.on("session.deleted", (event) => {
|