@mortz.prk/opencode-auto-mode 0.1.3 → 1.0.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/src/transcript.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  const MAX_USERS = 10;
2
2
  const MAX_TOOLS = 20;
3
+ const MAX_USER_CHARS = 8_000;
3
4
  const MAX_ENTRY_CHARS = 500;
4
- const MAX_TRANSCRIPT_CHARS = 6000;
5
+ const MAX_TRANSCRIPT_BYTES = 12_000;
5
6
 
6
7
  interface ToolEntry {
7
8
  name: string;
8
9
  input: unknown;
9
10
  }
10
11
 
12
+ interface Entry {
13
+ role: "user" | "tool";
14
+ text: string;
15
+ }
16
+
11
17
  function truncate(value: string, max: number): string {
12
18
  return value.length > max ? `${value.slice(0, max - 1)}…` : value;
13
19
  }
@@ -42,30 +48,161 @@ function assistantTools(message: unknown): ToolEntry[] {
42
48
  return tools;
43
49
  }
44
50
 
45
- function formatTool(tool: ToolEntry): string {
51
+ function formatTool(tool: ToolEntry): { text: string; truncated: boolean } {
46
52
  let detail = "";
53
+ let truncated = false;
47
54
  try {
48
55
  const state = asRecord(tool.input);
49
56
  const input = state?.["input"] ?? tool.input;
50
57
  const raw = typeof input === "string" ? input : JSON.stringify(input);
51
- if (raw) detail = truncate(raw, MAX_ENTRY_CHARS);
58
+ if (raw) {
59
+ truncated = raw.length > MAX_ENTRY_CHARS;
60
+ detail = truncate(raw, MAX_ENTRY_CHARS);
61
+ }
52
62
  } catch {
53
63
  detail = "";
54
64
  }
55
- return detail ? `${tool.name} ${detail}` : tool.name;
65
+ return { text: detail ? `${tool.name} ${detail}` : tool.name, truncated };
56
66
  }
57
67
 
58
- export function extractTranscript(messages: unknown[]): string {
59
- const entries: { role: "user" | "tool"; text: string }[] = [];
60
- for (const message of messages) {
61
- const text = userText(message);
62
- if (text !== undefined) entries.push({ role: "user", text: truncate(text, MAX_ENTRY_CHARS) });
63
- for (const tool of assistantTools(message)) entries.push({ role: "tool", text: formatTool(tool) });
68
+ function escapedSize(value: string): number {
69
+ return Buffer.byteLength(JSON.stringify(value));
70
+ }
71
+
72
+ function prefixByEscapedBytes(characters: string[], maxBytes: number): string {
73
+ let low = 0;
74
+ let high = characters.length;
75
+ while (low < high) {
76
+ const middle = Math.ceil((low + high) / 2);
77
+ if (escapedSize(characters.slice(0, middle).join("")) <= maxBytes) low = middle;
78
+ else high = middle - 1;
79
+ }
80
+ return characters.slice(0, low).join("");
81
+ }
82
+
83
+ function suffixByEscapedBytes(characters: string[], maxBytes: number): string {
84
+ let low = 0;
85
+ let high = characters.length;
86
+ while (low < high) {
87
+ const middle = Math.ceil((low + high) / 2);
88
+ if (escapedSize(characters.slice(characters.length - middle).join("")) <= maxBytes) low = middle;
89
+ else high = middle - 1;
64
90
  }
65
- if (!entries.some((entry) => entry.role === "user")) return "(no prior user messages in this session)";
91
+ return characters.slice(characters.length - low).join("");
92
+ }
93
+
94
+ /** Clips the head and tail so escaping cannot hide a trailing constraint. */
95
+ function clipEscaped(value: string, maxBytes: number): string {
96
+ if (escapedSize(value) <= maxBytes) return value;
97
+ const characters = Array.from(value);
98
+ const marker = "…";
99
+ const budget = Math.max(0, maxBytes - escapedSize(marker));
100
+ const headBudget = Math.floor(budget * 0.6);
101
+ return `${prefixByEscapedBytes(characters, headBudget)}${marker}${suffixByEscapedBytes(characters, budget - headBudget)}`;
102
+ }
103
+
104
+ function clipChars(value: string, max: number): string {
105
+ if (value.length <= max) return value;
106
+ const characters = Array.from(value);
107
+ const head = Math.floor(max * 0.6);
108
+ return `${characters.slice(0, head).join("")}…${characters.slice(characters.length - (max - head)).join("")}`;
109
+ }
110
+
111
+ interface TranscriptData {
112
+ transcript: string;
113
+ userEvidence: boolean;
114
+ missingUserText: boolean;
115
+ notes: string[];
116
+ }
117
+
118
+ function renderTranscript(entries: Entry[], toolTruncated: boolean, userClipped: boolean): { transcript: string; notes: string[] } {
119
+ if (!entries.some((entry) => entry.role === "user")) return { transcript: "(no prior user messages in this session)", notes: [] };
66
120
  let users = 0;
67
121
  let tools = 0;
68
122
  const recent = entries.slice().reverse().filter((entry) => entry.role === "user" ? ++users <= MAX_USERS : ++tools <= MAX_TOOLS).reverse();
69
- const incomplete = recent.length < entries.length ? "\n… incomplete session context" : "";
70
- return truncate(`User messages and prior tool calls (chronological):\n${JSON.stringify(recent)}${incomplete}`, MAX_TRANSCRIPT_CHARS);
123
+ const notes: string[] = [];
124
+ if (recent.length < entries.length) notes.push("older messages omitted");
125
+ if (toolTruncated) notes.push("some tool details truncated");
126
+ if (userClipped) notes.push("latest user message truncated");
127
+ const render = () => `User messages and prior tool calls (chronological):\n${JSON.stringify(recent)}` +
128
+ (notes.length ? `\n… partial session history (${notes.join("; ")})` : "");
129
+
130
+ while (Buffer.byteLength(render()) > MAX_TRANSCRIPT_BYTES && recent.length > 1) {
131
+ // Keep at least the latest user message.
132
+ const onlyOneUser = recent.filter((entry) => entry.role === "user").length <= 1;
133
+ const index = onlyOneUser && recent[0].role === "user" ? recent.findIndex((entry) => entry.role === "tool") : 0;
134
+ if (index < 0) break;
135
+ recent.splice(index, 1);
136
+ if (!notes.includes("older messages omitted")) notes.push("older messages omitted");
137
+ }
138
+
139
+ const latestUser = [...recent].reverse().find((entry) => entry.role === "user");
140
+ for (let attempt = 0; latestUser !== undefined && Buffer.byteLength(render()) > MAX_TRANSCRIPT_BYTES && attempt < 10; attempt++) {
141
+ if (!notes.includes("latest user message truncated")) notes.push("latest user message truncated");
142
+ const excess = Buffer.byteLength(render()) - MAX_TRANSCRIPT_BYTES;
143
+ latestUser.text = clipEscaped(latestUser.text, Math.max(128, escapedSize(latestUser.text) - excess - 64));
144
+ }
145
+ return { transcript: render(), notes };
146
+ }
147
+
148
+ function buildTranscript(messages: unknown[]): TranscriptData {
149
+ const entries: Entry[] = [];
150
+ let userEvidence = false;
151
+ let missingUserText = false;
152
+ let userClipped = false;
153
+ let toolTruncated = false;
154
+ for (const message of messages) {
155
+ const record = asRecord(message);
156
+ if (record?.type === "user") {
157
+ const text = userText(message);
158
+ if (text === undefined) {
159
+ missingUserText = true;
160
+ continue;
161
+ }
162
+ userEvidence = true;
163
+ const clipped = clipChars(text, MAX_USER_CHARS);
164
+ if (clipped !== text) userClipped = true;
165
+ entries.push({ role: "user", text: clipped });
166
+ }
167
+ for (const tool of assistantTools(message)) {
168
+ const formatted = formatTool(tool);
169
+ if (formatted.truncated) toolTruncated = true;
170
+ entries.push({ role: "tool", text: formatted.text });
171
+ }
172
+ }
173
+ const rendered = renderTranscript(entries, toolTruncated, userClipped);
174
+ return { transcript: rendered.transcript, userEvidence, missingUserText, notes: rendered.notes };
175
+ }
176
+
177
+ export interface ReviewContext { transcript: string; complete: boolean; reason?: string; notes?: string[] }
178
+
179
+ export function extractReviewContext(messages: unknown[]): ReviewContext {
180
+ const attachment = messages.some((message) => {
181
+ const record = asRecord(message);
182
+ if (record?.type !== "user") return false;
183
+ // These attachments carry evidence that the text-only reviewer cannot see.
184
+ return ["files", "skills", "agents"].some((key) =>
185
+ Array.isArray(record[key]) && record[key].length > 0);
186
+ });
187
+ const data = buildTranscript(messages);
188
+ if (attachment)
189
+ return {
190
+ transcript: data.transcript, complete: false, notes: data.notes,
191
+ reason: "User authorization includes attachments that cannot be reviewed automatically",
192
+ };
193
+ if (data.missingUserText)
194
+ return {
195
+ transcript: data.transcript, complete: false, notes: data.notes,
196
+ reason: "A user message has no text that can be reviewed",
197
+ };
198
+ if (!data.userEvidence)
199
+ return {
200
+ transcript: data.transcript, complete: false, notes: data.notes,
201
+ reason: "No user authorization message is available",
202
+ };
203
+ return { transcript: data.transcript, complete: true, notes: data.notes };
204
+ }
205
+
206
+ export function extractTranscript(messages: unknown[]): string {
207
+ return buildTranscript(messages).transcript;
71
208
  }
@@ -0,0 +1,7 @@
1
+ import type { DecisionRecord } from "./state.js";
2
+
3
+ export function isReviewing(records: Record<string, DecisionRecord>, sessionID: string | undefined, now = Date.now()): boolean {
4
+ if (!sessionID) return false;
5
+ return Object.values(records).some((record) => record.sessionID === sessionID &&
6
+ record.decision === "reviewing" && now - record.at >= 0 && now - record.at < 90_000);
7
+ }
package/src/tui.tsx CHANGED
@@ -1,320 +1,48 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { createMemo, createSignal, Show, type JSX } from "solid-js";
2
+ import { createSignal, Show } from "solid-js";
3
3
  import { Plugin } from "@opencode/plugin/tui";
4
- import type { Context } from "@opencode/plugin/tui/context";
5
- import type { ResolvedTheme } from "@opencode/theme/tui";
6
- import { readDecisions } from "./state.js";
4
+ import { readConfigError, readDecisions } from "./state.js";
5
+ import { isReviewing } from "./tui-state.js";
7
6
 
8
7
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
- const MAX_RESOURCE = 48;
10
- const POLL_MS = 1_000;
11
- const STALE_MS = 90_000;
12
-
13
- type Pending = {
14
- requestID: string;
15
- sessionID: string;
16
- action: string;
17
- resource: string;
18
- at: number;
19
- escalated: boolean;
20
- };
21
-
22
- function str(value: unknown, fallback: string): string {
23
- return typeof value === "string" && value.trim() ? value.trim() : fallback;
24
- }
25
-
26
- function truncate(value: string, max: number): string {
27
- return value.length > max ? value.slice(0, max - 1) + "…" : value;
28
- }
29
-
30
- // `ctx.data.session.list()` is unreliable across betas: on 0.0.0-beta-17823
31
- // the host's session store memo never invalidates, so iterating sessions to
32
- // find pending permissions sees an empty list forever. Instead the badge is
33
- // fed by permission events plus the server plugin's decision file: tier-3
34
- // reviews run inside the evaluate hook BEFORE a permission request exists,
35
- // so "reviewing" records (synthetic IDs) create spinner entries, and the
36
- // permission.asked event that follows an escalation adopts the matching
37
- // entry so its spinner time carries over.
38
- function Badge(props: {
39
- pending: () => Pending | undefined;
40
- answer: (reply: "once" | "reject") => Promise<void>;
41
- allowKey: string;
42
- denyKey: string;
43
- keymap: Pick<Context["keymap"], "layer">;
44
- theme: () => ResolvedTheme;
45
- }): JSX.Element {
46
- const theme = props.theme();
47
-
48
- props.keymap.layer(() => ({
49
- mode: "base",
50
- commands: [
51
- {
52
- id: "auto-mode.allow",
53
- title: "Allow pending permission",
54
- group: "Auto-mode",
55
- palette: true,
56
- bind: props.allowKey,
57
- // Only real, escalated requests can be answered — during review no
58
- // server-side permission request exists yet.
59
- enabled: () => props.pending()?.escalated === true,
60
- run: () => {
61
- void props.answer("once");
62
- },
63
- },
64
- {
65
- id: "auto-mode.deny",
66
- title: "Deny pending permission",
67
- group: "Auto-mode",
68
- palette: true,
69
- bind: props.denyKey,
70
- enabled: () => props.pending()?.escalated === true,
71
- run: () => {
72
- void props.answer("reject");
73
- },
74
- },
75
- ],
76
- }));
77
-
78
- const thinking = createMemo(() => {
79
- const p = props.pending();
80
- return p !== undefined && !p.escalated;
81
- });
82
- const label = createMemo(() => (thinking() ? "reviewing" : "needs your approval"));
83
-
84
- // The glyph comes from opentui's intrinsic <spinner>, not our own timer:
85
- // npm installs can split solid-js into two instances (the plugin's exact
86
- // pin vs @opentui/solid's peer pin), which freezes timer-driven signal
87
- // renders. The spinner element animates on its own renderable, so the
88
- // badge works regardless of how dependencies resolve.
89
- return (
90
- <box flexDirection="row" gap={1}>
91
- <spinner frames={SPINNER_FRAMES} interval={120} color={theme.text.feedback.warning.base} />
92
- <text fg={theme.text.feedback.warning.base}>auto-mode {label()}</text>
93
- </box>
94
- );
95
- }
96
8
 
97
9
  export default Plugin.define({
98
10
  id: "opencode-auto-mode",
99
11
  setup: async (ctx) => {
100
- const options = ctx.options as Record<string, unknown>;
101
- const keybinds = (options.keybinds ?? {}) as Record<string, unknown>;
102
- const allowKey = str(keybinds.allow, "ctrl+alt+a");
103
- const denyKey = str(keybinds.deny, "ctrl+alt+d");
104
-
105
- // Own pending list, fed by events. Keeping it locally (instead of reading
106
- // the host's session store) makes the badge independent of host store
107
- // behavior across betas.
108
- const [pending, setPending] = createSignal<Pending[]>([]);
109
- const firstSeen = new Map<string, number>();
110
-
111
- const addPending = (data: {
112
- id: string;
113
- sessionID: string;
114
- action: string;
115
- resources: readonly string[];
116
- }) => {
117
- if (pending().some((p) => p.requestID === data.id)) return;
118
- // An asked event means the server plugin's review already finished with
119
- // ASK. Adopt a matching in-review entry so its spinner duration carries
120
- // over instead of restarting the badge from scratch.
121
- const inReview = pending().find(
122
- (p) => !p.escalated && p.sessionID === data.sessionID && p.action === data.action,
123
- );
124
- if (inReview) {
125
- firstSeen.delete(inReview.requestID);
126
- setPending((prev) =>
127
- prev.map((p) =>
128
- p === inReview ? { ...p, requestID: data.id, escalated: true } : p,
129
- ),
130
- );
131
- firstSeen.set(data.id, inReview.at);
132
- return;
133
- }
134
- const at = firstSeen.get(data.id) ?? Date.now();
135
- firstSeen.set(data.id, at);
136
- setPending((prev) => [
137
- ...prev,
138
- {
139
- requestID: data.id,
140
- sessionID: data.sessionID,
141
- action: data.action,
142
- resource: truncate(data.resources.join(" ") || data.action, MAX_RESOURCE),
143
- at,
144
- escalated: true,
145
- },
146
- ]);
147
- };
148
-
149
- const removePending = (requestID: string) => {
150
- firstSeen.delete(requestID);
151
- setPending((prev) => prev.filter((p) => p.requestID !== requestID));
152
- };
153
-
154
- // Primary channel: the host data bus. Some host versions forward only a
155
- // subset of event types, so the raw server stream below is the fallback;
156
- // handlers dedupe by request ID.
157
- const unsubs = [
158
- ctx.data.on("permission.asked", (event) => addPending(event.data)),
159
- ctx.data.on("permission.replied", (event) => removePending(event.data.requestID)),
160
- ];
161
-
162
- // Fallback channel: the raw server event stream, with reconnection.
163
- const abort = new AbortController();
164
- const runStream = async () => {
165
- while (!abort.signal.aborted) {
166
- try {
167
- for await (const event of ctx.client.event.subscribe({ signal: abort.signal })) {
168
- if (event.type === "permission.asked") addPending(event.data);
169
- else if (event.type === "permission.replied") removePending(event.data.requestID);
170
- }
171
- } catch {
172
- // fall through to reconnect
173
- }
174
- if (abort.signal.aborted) return;
175
- await new Promise((resolve) => setTimeout(resolve, 2000));
176
- }
177
- };
178
- void runStream();
179
-
180
- // Reconcile with the server plugin's decision file:
181
- // - "reviewing" records create spinner entries (they precede any event,
182
- // since the evaluate hook runs before the request is published)
183
- // - "ask" escalates the entry, "allow"/"deny" resolves it
184
- // - entries that outlived every record and the review window are pruned
185
- const poll = setInterval(() => {
186
- snapshotView();
187
- const decisions = readDecisions();
188
- const now = Date.now();
189
- let next = pending();
190
- let dirty = false;
191
-
192
- for (const [reviewID, rec] of Object.entries(decisions)) {
193
- if (rec.decision !== "reviewing") continue;
194
- if (next.some((p) => p.requestID === reviewID)) continue;
195
- if (now - rec.at >= STALE_MS) continue;
196
- firstSeen.set(reviewID, rec.at);
197
- next = [
198
- ...next,
199
- {
200
- requestID: reviewID,
201
- sessionID: rec.sessionID,
202
- action: rec.action ?? "",
203
- resource: truncate(
204
- (rec.resources ?? []).join(" ") || rec.action || "",
205
- MAX_RESOURCE,
206
- ),
207
- at: rec.at,
208
- escalated: false,
209
- },
210
- ];
211
- dirty = true;
212
- }
213
-
214
- const reconciled: Pending[] = [];
215
- for (const p of next) {
216
- const record = decisions[p.requestID];
217
- if (record?.decision === "allow" || record?.decision === "deny") {
218
- firstSeen.delete(p.requestID);
219
- dirty = true;
220
- continue;
221
- }
222
- if (record?.decision === "ask" && !p.escalated) {
223
- reconciled.push({ ...p, escalated: true });
224
- dirty = true;
225
- continue;
226
- }
227
- if (!record && now - p.at >= STALE_MS) {
228
- firstSeen.delete(p.requestID);
229
- dirty = true;
230
- continue;
231
- }
232
- reconciled.push(p);
233
- }
234
- if (dirty) setPending(reconciled);
235
- }, POLL_MS);
236
-
237
- // View scoping: decision records live in a machine-global file (and on
238
- // shared servers permission events are global too), so without filtering
239
- // every open TUI would badge other TUIs' reviews. Router + tabs state is
240
- // client-local — a foreign session is never the current route nor an open
241
- // tab here — which discriminates both separate-server and shared-server
242
- // setups. Snapshotted on the existing poll tick (1s granularity is fine
243
- // for a status badge) instead of relying on host reactivity.
244
- const [visibleIDs, setVisibleIDs] = createSignal<ReadonlySet<string>>(new Set());
245
- const snapshotView = () => {
246
- const ids = new Set<string>();
247
- try {
248
- if (ctx.ui.tabs.enabled()) {
249
- for (const tab of ctx.ui.tabs.list()) ids.add(tab.sessionID);
250
- }
251
- } catch {
252
- // tabs unavailable — route only
253
- }
254
- try {
255
- const route = ctx.ui.router.current();
256
- if (route.type === "session") ids.add(route.sessionID);
257
- } catch {
258
- // router unavailable — tabs only
259
- }
260
- const key = [...ids].sort().join(",");
261
- const prev = [...visibleIDs()].sort().join(",");
262
- if (key !== prev) setVisibleIDs(ids);
263
- };
264
- snapshotView();
265
-
266
- const mostRecent = createMemo(() => {
267
- const visible = visibleIDs();
268
- const requests = pending().filter((p) => visible.has(p.sessionID));
269
- return requests.length > 0 ? requests[requests.length - 1] : undefined;
270
- });
271
-
272
- const answer = async (reply: "once" | "reject") => {
273
- const pendingRequest = mostRecent();
274
- if (!pendingRequest) return;
275
- try {
276
- await ctx.client.permission.reply({
277
- sessionID: pendingRequest.sessionID,
278
- requestID: pendingRequest.requestID,
279
- decision: reply,
280
- });
281
- } catch (err) {
282
- const message = err instanceof Error ? err.message : String(err);
12
+ if (ctx.options.tui === false) return;
13
+ const [reviewing, setReviewing] = createSignal(false);
14
+ const [configError, setConfigError] = createSignal(false);
15
+ let announced = false;
16
+ const update = () => {
17
+ const route = ctx.ui.router.current();
18
+ setReviewing(isReviewing(readDecisions(), route.type === "session" ? route.sessionID : undefined));
19
+ const error = readConfigError(ctx.location?.directory);
20
+ setConfigError(error !== undefined);
21
+ if (error === undefined) announced = false;
22
+ else if (!announced) {
23
+ announced = true;
283
24
  ctx.ui.toast.show({
284
- variant: "error",
285
- title: "Auto-mode",
286
- message: `Failed to reply: ${message}`,
287
- duration: 4000,
25
+ variant: "error", title: "auto-mode", duration: 10_000,
26
+ message: "Invalid configuration; automatic approval is disabled.",
288
27
  });
289
28
  }
290
29
  };
291
-
292
- // Top-right overlay, above the permission dialog (which covers the bottom
293
- // bar). Only visible while this TUI has a session with a pending review:
294
- // spinner while reviewing, ⚠ when it needs the user.
295
- const disposeBadge = ctx.ui.slot({
30
+ update();
31
+ const poll = setInterval(update, 250);
32
+ const dispose = ctx.ui.slot({
296
33
  append: "app",
297
34
  render: () => (
298
- <Show when={mostRecent() !== undefined}>
299
- <box position="absolute" top={0} right={0} zIndex={10_000}>
300
- <Badge
301
- pending={mostRecent}
302
- answer={answer}
303
- allowKey={allowKey}
304
- denyKey={denyKey}
305
- keymap={ctx.keymap}
306
- theme={() => ctx.theme}
307
- />
308
- </box>
309
- </Show>
35
+ <box position="absolute" top={0} right={0} zIndex={10_000} flexDirection="row" gap={1}>
36
+ <Show when={configError()}>
37
+ <text fg={ctx.theme.text.feedback.error.base}>auto-mode: invalid config</text>
38
+ </Show>
39
+ <Show when={reviewing()}>
40
+ <spinner frames={SPINNER_FRAMES} interval={120} color={ctx.theme.text.feedback.warning.base} />
41
+ <text fg={ctx.theme.text.feedback.warning.base}>auto-mode reviewing</text>
42
+ </Show>
43
+ </box>
310
44
  ),
311
45
  });
312
-
313
- return () => {
314
- for (const unsub of unsubs) unsub();
315
- abort.abort();
316
- clearInterval(poll);
317
- disposeBadge();
318
- };
46
+ return () => { clearInterval(poll); dispose(); };
319
47
  },
320
48
  });
package/src/providers.ts DELETED
@@ -1,103 +0,0 @@
1
- import { object, localEndpoint, type ClassifierStage } from "./config.js";
2
- import { buildReviewPrompt, type PermissionRequest, type ReviewResult, type Decision } from "./reviewer.js";
3
- import type { ReviewPolicy } from "./policy.js";
4
-
5
- export type ProviderStage = Extract<ClassifierStage, { type: "jev" | "openai-decision" | "laya" }>;
6
- const choices = {
7
- allow: "The whole action is safe and authorized by the user for this exact scope.",
8
- deny: "The action violates the supplied policy or attempts to bypass a block.",
9
- ask: "Authorization, evidence, or impact is uncertain; the human must decide.",
10
- };
11
- const MAX_BYTES = 65_536;
12
-
13
- function probability(value: unknown): value is number {
14
- return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
15
- }
16
-
17
- function answer(value: unknown, stage: ProviderStage): ReviewResult | undefined {
18
- if (!object(value) || value.type !== "choice" || typeof value.choice !== "string" || !Object.hasOwn(choices, value.choice) ||
19
- !probability(value.confidence) || !object(value.probabilities))
20
- throw new Error("Invalid decision response");
21
- const entries = Object.entries(value.probabilities);
22
- if (entries.length !== 3 || entries.some(([key, value]) => !Object.hasOwn(choices, key) || !probability(value)))
23
- throw new Error("Invalid decision probabilities");
24
- const probabilities = value.probabilities as Record<Decision, number>;
25
- if (Math.abs(Object.values(probabilities).reduce((a, b) => a + b, 0) - 1) > 0.01 ||
26
- probabilities[value.choice as Decision] < Math.max(...Object.values(probabilities)))
27
- throw new Error("Inconsistent decision probabilities");
28
- if (value.choice === "ask") return { decision: "ask", reason: `${stage.type} requested human review` };
29
- if (value.confidence < (stage.minConfidence ?? 0.9) || probabilities[value.choice as Decision] < (stage.minConfidence ?? 0.9))
30
- return undefined;
31
- return { decision: value.choice as Decision, reason: `${stage.type} decision (confidence ${value.confidence})` };
32
- }
33
-
34
- async function readJSON(response: Response): Promise<unknown> {
35
- if (!response.ok || !response.body) throw new Error("Decision provider failed");
36
- const reader = response.body.getReader();
37
- const chunks: Uint8Array[] = [];
38
- let size = 0;
39
- try {
40
- while (true) {
41
- const { done, value } = await reader.read();
42
- if (done) break;
43
- size += value.length;
44
- if (size > MAX_BYTES) throw new Error("Decision response too large");
45
- chunks.push(value);
46
- }
47
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
48
- } finally {
49
- await reader.cancel().catch(() => {});
50
- reader.releaseLock();
51
- }
52
- }
53
-
54
- export async function providerDecision(
55
- stage: ProviderStage,
56
- request: PermissionRequest,
57
- policy: ReviewPolicy,
58
- fetcher: typeof fetch = fetch,
59
- ): Promise<ReviewResult | undefined> {
60
- try {
61
- const openai = stage.type === "openai-decision";
62
- const endpoint = stage.type === "laya" ? localEndpoint(stage.endpoint ?? "http://127.0.0.1:8000/v1/systemone") :
63
- openai ? "https://api.openai.com/v1/decisions" : "https://api.typesafe.ai/v1/systemone";
64
- const key = process.env[stage.apiKeyEnv ?? (openai ? "OPENAI_API_KEY" : stage.type === "jev" ? "TYPESAFE_API_KEY" : "LAYA_API_KEY")];
65
- if (stage.type !== "laya" && !key) throw new Error("Missing decision provider credentials");
66
- const instructions = buildReviewPrompt(request, policy);
67
- const body = openai ? {
68
- model: stage.model ?? "gpt-6-luna",
69
- input: JSON.stringify(request),
70
- questions: [{ name: "permission", type: "choice", instructions,
71
- choices: Object.entries(choices).map(([value, description]) => ({ value, description })) }],
72
- } : {
73
- ...(stage.type === "jev" ? { model: stage.model ?? "jev-latest" } : stage.model ? { model: stage.model } : {}),
74
- state: request,
75
- questions: { permission: { type: "choice", instructions, criteria: choices } },
76
- };
77
- const payload = JSON.stringify(body);
78
- if (Buffer.byteLength(payload) > MAX_BYTES) throw new Error("Decision request too large");
79
- const response = await fetcher(endpoint, {
80
- method: "POST", redirect: "error", signal: AbortSignal.timeout(stage.timeoutMs ?? 10_000),
81
- headers: { "Content-Type": "application/json", ...(key ? { Authorization: `Bearer ${key}` } : {}) },
82
- body: payload,
83
- });
84
- const parsed = await readJSON(response);
85
- if (!object(parsed)) throw new Error("Invalid decision response");
86
- if (!openai) return answer(object(parsed.answers) ? parsed.answers.permission : undefined, stage);
87
- if (!Array.isArray(parsed.answers) || parsed.answers.length !== 1 || !object(parsed.answers[0]) ||
88
- parsed.answers[0].name !== "permission") throw new Error("Invalid OpenAI answer");
89
- const selected = parsed.answers[0];
90
- if (selected.type === "refusal") return { decision: "ask", reason: "OpenAI declined the decision" };
91
- if (!Array.isArray(selected.probabilities) || selected.probabilities.length !== 3)
92
- throw new Error("Invalid OpenAI probabilities");
93
- const distribution: Record<string, unknown> = {};
94
- for (const entry of selected.probabilities) {
95
- if (!object(entry) || typeof entry.value !== "string" || !Object.hasOwn(choices, entry.value) ||
96
- Object.hasOwn(distribution, entry.value)) throw new Error("Invalid OpenAI probability entry");
97
- distribution[entry.value] = entry.probability;
98
- }
99
- return answer({ ...selected, probabilities: distribution }, stage);
100
- } catch {
101
- return { decision: "ask", reason: `${stage.type} unavailable or returned an invalid decision` };
102
- }
103
- }