opencode-reviewer 0.5.1 → 0.6.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.
- package/README.md +19 -9
- package/dist/tui.js +177 -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: ${safe} \xB7 Unsafe: ${unsafe}`,
|
|
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;
|
|
@@ -1779,28 +1864,32 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1779
1864
|
stream: config.stream,
|
|
1780
1865
|
...config.stream ? { stream_options: { include_usage: true } } : {}
|
|
1781
1866
|
});
|
|
1867
|
+
reviewStage(signal, "Reviewer response");
|
|
1782
1868
|
diagnose = onDiagnostics ? diagnosticAttempt(onDiagnostics, attempt) : void 0;
|
|
1783
1869
|
response = await abortable(fetcher(`${config.baseURL}/chat/completions`, {
|
|
1784
1870
|
method: "POST",
|
|
1785
1871
|
redirect: "error",
|
|
1786
|
-
signal,
|
|
1872
|
+
signal: AbortSignal.any([signal, requestAbort.signal]),
|
|
1787
1873
|
headers: { "Content-Type": "application/json", ...key ? { Authorization: `Bearer ${key}` } : {} },
|
|
1788
1874
|
body
|
|
1789
1875
|
}), signal, cancelBody);
|
|
1790
1876
|
diagnose?.("headers");
|
|
1791
|
-
} catch {
|
|
1877
|
+
} catch (error) {
|
|
1792
1878
|
signal.throwIfAborted();
|
|
1793
|
-
throw
|
|
1879
|
+
throw networkFailure(error, "Reviewer network request failed");
|
|
1794
1880
|
}
|
|
1795
1881
|
if (!response.ok) {
|
|
1796
1882
|
cancelBody(response);
|
|
1797
|
-
throw
|
|
1883
|
+
throw httpFailure(response);
|
|
1798
1884
|
}
|
|
1799
1885
|
let assessment;
|
|
1800
1886
|
if (config.stream) assessment = await streamedAssessment(response, signal, accounting, (preview) => {
|
|
1801
1887
|
if (typeof preview?.safe === "boolean") diagnose?.("first-rating");
|
|
1802
1888
|
progress("streaming", preview);
|
|
1803
|
-
},
|
|
1889
|
+
}, () => {
|
|
1890
|
+
contentStarted = true;
|
|
1891
|
+
diagnose?.("first-content");
|
|
1892
|
+
});
|
|
1804
1893
|
else {
|
|
1805
1894
|
let envelope;
|
|
1806
1895
|
try {
|
|
@@ -1818,14 +1907,17 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1818
1907
|
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
1908
|
throw new Error("Reviewer API did not return a text assessment");
|
|
1820
1909
|
}
|
|
1821
|
-
if (message.content)
|
|
1910
|
+
if (message.content) {
|
|
1911
|
+
contentStarted = true;
|
|
1912
|
+
diagnose?.("first-content");
|
|
1913
|
+
}
|
|
1822
1914
|
assessment = new StreamingAssessment();
|
|
1823
1915
|
assessment.push(message.content);
|
|
1824
1916
|
if (diagnose && typeof assessment.preview()?.safe === "boolean") diagnose("first-rating");
|
|
1825
1917
|
}
|
|
1826
1918
|
reviewStage(signal, "Assessment validation");
|
|
1827
1919
|
const currentUsage = accounting.current();
|
|
1828
|
-
|
|
1920
|
+
const reportUsage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
|
|
1829
1921
|
try {
|
|
1830
1922
|
let result;
|
|
1831
1923
|
try {
|
|
@@ -1835,20 +1927,26 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
|
|
|
1835
1927
|
}
|
|
1836
1928
|
if (config.stream) progress("streaming", result);
|
|
1837
1929
|
signal.throwIfAborted();
|
|
1838
|
-
return { ...result, ...
|
|
1930
|
+
return { ...result, ...reportUsage ? { usage: reportUsage } : {} };
|
|
1839
1931
|
} catch (error) {
|
|
1840
1932
|
if (!(error instanceof AssessmentFormatError)) throw error;
|
|
1841
|
-
if (
|
|
1933
|
+
if (corrections++ === config.formatRetries) throw new Error("Reviewer response format invalid after configured attempts");
|
|
1842
1934
|
messages.push(
|
|
1843
1935
|
{ role: "assistant", content: assessment.content },
|
|
1844
1936
|
{ role: "user", content: correctionPrompt(error.message) }
|
|
1845
1937
|
);
|
|
1846
1938
|
}
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
requestAbort.abort();
|
|
1941
|
+
if (contentStarted) throw error;
|
|
1942
|
+
failed = true;
|
|
1943
|
+
failure = error;
|
|
1847
1944
|
} finally {
|
|
1848
|
-
accounting.finalize();
|
|
1945
|
+
const currentUsage = accounting.finalize();
|
|
1946
|
+
usage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
|
|
1849
1947
|
}
|
|
1948
|
+
if (failed && !await retries.wait(failure, signal)) throw failure;
|
|
1850
1949
|
}
|
|
1851
|
-
throw new Error("Reviewer exhausted attempts");
|
|
1852
1950
|
}
|
|
1853
1951
|
|
|
1854
1952
|
// src/classification.ts
|
|
@@ -1980,7 +2078,7 @@ var clock = {
|
|
|
1980
2078
|
};
|
|
1981
2079
|
var permissionResolved = Object.freeze({ reason: "permission resolved" });
|
|
1982
2080
|
var Controller = class {
|
|
1983
|
-
constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver) {
|
|
2081
|
+
constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver, ratingObserver) {
|
|
1984
2082
|
this.evaluate = evaluate;
|
|
1985
2083
|
this.changed = changed;
|
|
1986
2084
|
this.options = options;
|
|
@@ -1988,6 +2086,7 @@ var Controller = class {
|
|
|
1988
2086
|
this.time = time;
|
|
1989
2087
|
this.modes = modes;
|
|
1990
2088
|
this.approvalObserver = approvalObserver;
|
|
2089
|
+
this.ratingObserver = ratingObserver;
|
|
1991
2090
|
}
|
|
1992
2091
|
evaluate;
|
|
1993
2092
|
changed;
|
|
@@ -1996,6 +2095,7 @@ var Controller = class {
|
|
|
1996
2095
|
time;
|
|
1997
2096
|
modes;
|
|
1998
2097
|
approvalObserver;
|
|
2098
|
+
ratingObserver;
|
|
1999
2099
|
entries = /* @__PURE__ */ new Map();
|
|
2000
2100
|
stopped = false;
|
|
2001
2101
|
version = 0;
|
|
@@ -2288,6 +2388,13 @@ var Controller = class {
|
|
|
2288
2388
|
this.clearProgress(entry);
|
|
2289
2389
|
if (active()) {
|
|
2290
2390
|
entry.view = assessment ? { ...entry.view, status: "complete", assessment, progress: void 0 } : { ...entry.view, status: "unrelated", progress: void 0 };
|
|
2391
|
+
if (assessment) {
|
|
2392
|
+
try {
|
|
2393
|
+
void Promise.resolve(this.ratingObserver?.(assessment.safe)).catch(() => {
|
|
2394
|
+
});
|
|
2395
|
+
} catch {
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2291
2398
|
this.publish();
|
|
2292
2399
|
}
|
|
2293
2400
|
}, (error) => {
|
|
@@ -3538,12 +3645,25 @@ import { constants as constants3 } from "node:fs";
|
|
|
3538
3645
|
import { mkdir, open as open3, readdir as readdir2, rename, unlink } from "node:fs/promises";
|
|
3539
3646
|
import { randomUUID } from "node:crypto";
|
|
3540
3647
|
import path8 from "node:path";
|
|
3541
|
-
var empty = () => ({
|
|
3648
|
+
var empty = () => ({
|
|
3649
|
+
requests: 0,
|
|
3650
|
+
tokenRequests: 0,
|
|
3651
|
+
input: 0,
|
|
3652
|
+
output: 0,
|
|
3653
|
+
priced: 0,
|
|
3654
|
+
cost: 0,
|
|
3655
|
+
since: null,
|
|
3656
|
+
safe: 0,
|
|
3657
|
+
unsafe: 0,
|
|
3658
|
+
ratingsSince: null
|
|
3659
|
+
});
|
|
3542
3660
|
var count2 = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
3543
3661
|
var amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
3544
3662
|
var snapshotName = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/;
|
|
3545
3663
|
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
|
|
3664
|
+
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)) {
|
|
3665
|
+
throw new Error("Invalid lifetime usage totals");
|
|
3666
|
+
}
|
|
3547
3667
|
return value;
|
|
3548
3668
|
}
|
|
3549
3669
|
function add(left, right) {
|
|
@@ -3554,28 +3674,32 @@ function add(left, right) {
|
|
|
3554
3674
|
tokenRequests: left.tokenRequests + right.tokenRequests,
|
|
3555
3675
|
priced: left.priced + right.priced,
|
|
3556
3676
|
cost: left.cost + right.cost,
|
|
3557
|
-
since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since)
|
|
3677
|
+
since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since),
|
|
3678
|
+
safe: left.safe + right.safe,
|
|
3679
|
+
unsafe: left.unsafe + right.unsafe,
|
|
3680
|
+
ratingsSince: left.ratingsSince === null ? right.ratingsSince : right.ratingsSince === null ? left.ratingsSince : Math.min(left.ratingsSince, right.ratingsSince)
|
|
3558
3681
|
});
|
|
3559
3682
|
}
|
|
3560
3683
|
var LifetimeUsage = class {
|
|
3561
|
-
constructor(directory,
|
|
3684
|
+
constructor(directory, ...legacyDirectories) {
|
|
3562
3685
|
this.directory = directory;
|
|
3563
|
-
|
|
3564
|
-
if (!path8.isAbsolute(directory) || legacyDirectory !== void 0 && !path8.isAbsolute(legacyDirectory)) {
|
|
3686
|
+
if (![directory, ...legacyDirectories].every((value) => path8.isAbsolute(value))) {
|
|
3565
3687
|
throw new Error("Lifetime usage directory must be absolute");
|
|
3566
3688
|
}
|
|
3689
|
+
this.legacyDirectories = legacyDirectories;
|
|
3567
3690
|
}
|
|
3568
3691
|
directory;
|
|
3569
|
-
legacyDirectory;
|
|
3570
3692
|
id = randomUUID();
|
|
3571
3693
|
local = empty();
|
|
3572
3694
|
pending = Promise.resolve();
|
|
3695
|
+
legacyDirectories;
|
|
3573
3696
|
record(usage) {
|
|
3574
3697
|
const tokens = count2(usage.input) && count2(usage.output);
|
|
3575
3698
|
if (!tokens && (usage.input !== void 0 || usage.output !== void 0 || usage.cost === void 0) || usage.cost !== void 0 && !amount(usage.cost)) {
|
|
3576
3699
|
return Promise.reject(new Error("Invalid request usage"));
|
|
3577
3700
|
}
|
|
3578
|
-
|
|
3701
|
+
return this.increment({
|
|
3702
|
+
...empty(),
|
|
3579
3703
|
requests: 1,
|
|
3580
3704
|
tokenRequests: tokens ? 1 : 0,
|
|
3581
3705
|
input: usage.input ?? 0,
|
|
@@ -3583,7 +3707,14 @@ var LifetimeUsage = class {
|
|
|
3583
3707
|
priced: usage.cost === void 0 ? 0 : 1,
|
|
3584
3708
|
cost: usage.cost ?? 0,
|
|
3585
3709
|
since: Date.now()
|
|
3586
|
-
};
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
/** One accepted final review, independent of POST count or received usage. */
|
|
3713
|
+
recordRating(safe) {
|
|
3714
|
+
if (typeof safe !== "boolean") return Promise.reject(new Error("Invalid review rating"));
|
|
3715
|
+
return this.increment({ ...empty(), safe: safe ? 1 : 0, unsafe: safe ? 0 : 1, ratingsSince: Date.now() });
|
|
3716
|
+
}
|
|
3717
|
+
increment(increment) {
|
|
3587
3718
|
this.pending = this.pending.catch(() => {
|
|
3588
3719
|
}).then(async () => {
|
|
3589
3720
|
this.local = add(this.local, increment);
|
|
@@ -3592,7 +3723,7 @@ var LifetimeUsage = class {
|
|
|
3592
3723
|
try {
|
|
3593
3724
|
const file = await open3(temporary, "wx", 384);
|
|
3594
3725
|
try {
|
|
3595
|
-
await file.writeFile(JSON.stringify({ version:
|
|
3726
|
+
await file.writeFile(JSON.stringify({ version: 3, ...this.local }));
|
|
3596
3727
|
await file.sync();
|
|
3597
3728
|
} finally {
|
|
3598
3729
|
await file.close();
|
|
@@ -3620,7 +3751,7 @@ var LifetimeUsage = class {
|
|
|
3620
3751
|
await this.flush();
|
|
3621
3752
|
signal?.throwIfAborted();
|
|
3622
3753
|
let total = empty();
|
|
3623
|
-
const directories = new Set([this.directory, ...this.
|
|
3754
|
+
const directories = new Set([this.directory, ...this.legacyDirectories].map((directory) => path8.resolve(directory)));
|
|
3624
3755
|
for (const directory of directories) total = add(total, await this.readDirectory(directory, signal));
|
|
3625
3756
|
signal?.throwIfAborted();
|
|
3626
3757
|
return total;
|
|
@@ -3652,7 +3783,7 @@ var LifetimeUsage = class {
|
|
|
3652
3783
|
}
|
|
3653
3784
|
if (size > 1024) throw new Error("Invalid lifetime usage snapshot");
|
|
3654
3785
|
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");
|
|
3786
|
+
if (value?.version !== 1 && value?.version !== 2 && value?.version !== 3) throw new Error("Unsupported lifetime usage snapshot");
|
|
3656
3787
|
total = add(total, validate({
|
|
3657
3788
|
requests: value.requests,
|
|
3658
3789
|
input: value.input,
|
|
@@ -3660,7 +3791,10 @@ var LifetimeUsage = class {
|
|
|
3660
3791
|
tokenRequests: value.version === 1 ? value.requests : value.tokenRequests,
|
|
3661
3792
|
priced: value.priced,
|
|
3662
3793
|
cost: value.cost,
|
|
3663
|
-
since: value.since
|
|
3794
|
+
since: value.since,
|
|
3795
|
+
safe: value.version === 3 ? value.safe : 0,
|
|
3796
|
+
unsafe: value.version === 3 ? value.unsafe : 0,
|
|
3797
|
+
ratingsSince: value.version === 3 ? value.ratingsSince : null
|
|
3664
3798
|
}));
|
|
3665
3799
|
} finally {
|
|
3666
3800
|
await file.close();
|
|
@@ -3679,13 +3813,17 @@ function lifetimeCost(totals) {
|
|
|
3679
3813
|
function lifetimeReport(totals) {
|
|
3680
3814
|
return [
|
|
3681
3815
|
lifetimeCost(totals),
|
|
3816
|
+
uiText.lifetime.ratings(totals.safe, totals.unsafe),
|
|
3682
3817
|
uiText.lifetime.requests(totals.requests),
|
|
3683
3818
|
totals.tokenRequests ? uiText.lifetime.tokens(totals.input, totals.output, totals.tokenRequests < totals.requests) : uiText.lifetime.tokensUnavailable,
|
|
3684
3819
|
uiText.lifetime.tokenCoverage(totals.tokenRequests, totals.requests),
|
|
3685
3820
|
uiText.lifetime.pricingCoverage(totals.priced, totals.requests),
|
|
3686
3821
|
...totals.since === null ? [] : [uiText.lifetime.since(new Date(totals.since).toISOString().slice(0, 10))],
|
|
3822
|
+
...totals.ratingsSince === null ? [] : [uiText.lifetime.ratingsSince(new Date(totals.ratingsSince).toISOString().slice(0, 10))],
|
|
3823
|
+
"",
|
|
3824
|
+
uiText.lifetime.explanation,
|
|
3687
3825
|
"",
|
|
3688
|
-
uiText.lifetime.
|
|
3826
|
+
uiText.lifetime.ratingsExplanation
|
|
3689
3827
|
].join("\n");
|
|
3690
3828
|
}
|
|
3691
3829
|
|
|
@@ -3771,7 +3909,7 @@ var LifetimeRefresh = class {
|
|
|
3771
3909
|
// src/lifetime-view.tsx
|
|
3772
3910
|
function lifetimeTracker(api) {
|
|
3773
3911
|
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"));
|
|
3912
|
+
const store = new LifetimeUsage(path9.join(directory, "usage-v3"), path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
|
|
3775
3913
|
const [totals, setTotals] = createSignal();
|
|
3776
3914
|
const [unavailable, setUnavailable] = createSignal(false);
|
|
3777
3915
|
const refresh = new LifetimeRefresh((signal) => store.totals(signal), (value) => {
|
|
@@ -3808,9 +3946,12 @@ function lifetimeTracker(api) {
|
|
|
3808
3946
|
// The controller must settle aborted review workers before this final queue drain.
|
|
3809
3947
|
flush: () => store.flush().catch(() => {
|
|
3810
3948
|
}),
|
|
3811
|
-
text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? lifetimeCost(totals()) : void 0,
|
|
3949
|
+
text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? [lifetimeCost(totals()), uiText.lifetime.ratings(totals().safe, totals().unsafe)].join("\n") : void 0,
|
|
3812
3950
|
record: (usage) => {
|
|
3813
3951
|
void store.record(usage).then(() => refresh.refresh(), () => refresh.failed());
|
|
3952
|
+
},
|
|
3953
|
+
recordRating: (safe) => {
|
|
3954
|
+
void store.recordRating(safe).then(() => refresh.refresh(), () => refresh.failed());
|
|
3814
3955
|
}
|
|
3815
3956
|
};
|
|
3816
3957
|
}
|
|
@@ -4066,16 +4207,14 @@ function notificationMarkup(value) {
|
|
|
4066
4207
|
|
|
4067
4208
|
// src/notification-policy.ts
|
|
4068
4209
|
var NotificationPolicy = class {
|
|
4069
|
-
constructor(config, auto,
|
|
4210
|
+
constructor(config, auto, backend, clock2 = notificationClock) {
|
|
4070
4211
|
this.config = config;
|
|
4071
4212
|
this.auto = auto;
|
|
4072
|
-
this.delay = delay;
|
|
4073
4213
|
this.backend = backend;
|
|
4074
4214
|
this.clock = clock2;
|
|
4075
4215
|
}
|
|
4076
4216
|
config;
|
|
4077
4217
|
auto;
|
|
4078
|
-
delay;
|
|
4079
4218
|
backend;
|
|
4080
4219
|
clock;
|
|
4081
4220
|
permissions = /* @__PURE__ */ new Map();
|
|
@@ -4176,7 +4315,7 @@ var NotificationPolicy = class {
|
|
|
4176
4315
|
if (entry.phase !== "automatic") {
|
|
4177
4316
|
this.withdraw(entry.banner);
|
|
4178
4317
|
entry.phase = "automatic";
|
|
4179
|
-
entry.banner =
|
|
4318
|
+
entry.banner = void 0;
|
|
4180
4319
|
}
|
|
4181
4320
|
return;
|
|
4182
4321
|
}
|
|
@@ -6188,7 +6327,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
|
|
|
6188
6327
|
try {
|
|
6189
6328
|
const click = (id) => notifications?.click(id);
|
|
6190
6329
|
const backend = notificationBackend ? notificationBackend(click, notificationConfig) : new LinuxNotifications(notificationConfig, click);
|
|
6191
|
-
notifications = new NotificationHost(api, new NotificationPolicy(notificationConfig, reviewOptions?.autoApprove === true,
|
|
6330
|
+
notifications = new NotificationHost(api, new NotificationPolicy(notificationConfig, reviewOptions?.autoApprove === true, backend), (id, signal) => modes.root(id, signal));
|
|
6192
6331
|
} catch {
|
|
6193
6332
|
}
|
|
6194
6333
|
}
|
|
@@ -6229,7 +6368,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
|
|
|
6229
6368
|
once: (request, signal) => measured(traces?.get(request), "approval-reply", () => approval.once(request, signal))
|
|
6230
6369
|
} : approval,
|
|
6231
6370
|
visibleID: () => visibleApproval()
|
|
6232
|
-
}, void 0, modes, (fact) => notifications?.fact(fact));
|
|
6371
|
+
}, void 0, modes, (fact) => notifications?.fact(fact), lifetime.recordRating);
|
|
6233
6372
|
api.event.on("permission.asked", (event) => controller.asked(event.properties));
|
|
6234
6373
|
api.event.on("permission.replied", (event) => controller.replied(event.properties.requestID));
|
|
6235
6374
|
api.event.on("session.deleted", (event) => {
|