@mortz.prk/opencode-auto-mode 1.0.0 → 1.0.2
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 +143 -10
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +23 -13
- package/src/audit.ts +24 -7
- package/src/classifier.ts +19 -3
- package/src/config.ts +10 -1
- package/src/decisions.ts +38 -15
- package/src/errors.ts +39 -0
- package/src/gui-state.ts +160 -0
- package/src/gui.tsx +129 -0
- package/src/index.ts +377 -98
- package/src/otlp.ts +18 -15
- package/src/reviewer.ts +11 -1
- package/src/rpc.ts +117 -0
- package/src/shell-policy.ts +1337 -0
- package/src/shell.ts +379 -18
- package/src/state.ts +75 -0
- package/src/status.ts +72 -0
- package/src/tiers.ts +2 -20
- package/src/timeout.ts +30 -0
- package/src/traces.ts +13 -7
- package/src/transcript.ts +145 -29
- package/src/tui-state.ts +7 -1
- package/src/tui.tsx +20 -5
package/src/traces.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import type { AuditRecord } from "./audit.js";
|
|
3
|
+
import { sanitizeRecord } from "./audit.js";
|
|
3
4
|
import type { OtlpOptions } from "./otlp.js";
|
|
4
5
|
import type { StageResult } from "./reviewer.js";
|
|
5
6
|
|
|
@@ -55,20 +56,25 @@ export function createTraceExporter(setting: boolean | OtlpOptions | undefined,
|
|
|
55
56
|
return {
|
|
56
57
|
traceID, spanID, attempts,
|
|
57
58
|
finish(record) {
|
|
58
|
-
|
|
59
|
+
// Both log sinks sanitize before serialising; traces are a third sink
|
|
60
|
+
// and must not export unbounded record fields.
|
|
61
|
+
const safe = sanitizeRecord(record);
|
|
62
|
+
const shared = [attribute("review.id", safe.reviewID), attribute("session.id", safe.sessionID)];
|
|
59
63
|
const spans = [{
|
|
60
64
|
traceId: traceID, spanId: spanID, name: "auto_mode.review", kind: 1,
|
|
61
|
-
startTimeUnixNano: nano(startedAt), endTimeUnixNano: nano(startedAt +
|
|
62
|
-
attributes: [...shared, attribute("action",
|
|
63
|
-
attribute("decision",
|
|
64
|
-
|
|
65
|
+
startTimeUnixNano: nano(startedAt), endTimeUnixNano: nano(startedAt + safe.durationMs),
|
|
66
|
+
attributes: [...shared, attribute("action", safe.action), attribute("agent", safe.agent ?? ""),
|
|
67
|
+
attribute("decision", safe.decision), attribute("stage", safe.stage),
|
|
68
|
+
...(safe.errorKind === undefined ? [] : [attribute("error.kind", safe.errorKind)])],
|
|
69
|
+
status: { code: safe.error ? 2 : 1 },
|
|
65
70
|
}, ...attempts.map((attempt) => ({
|
|
66
71
|
traceId: traceID, spanId: randomBytes(8).toString("hex"), parentSpanId: spanID,
|
|
67
72
|
name: "auto_mode.classifier", kind: 1,
|
|
68
73
|
startTimeUnixNano: nano(attempt.startedAt), endTimeUnixNano: nano(attempt.startedAt + attempt.durationMs),
|
|
69
74
|
attributes: [...shared, attribute("classifier.index", attempt.index), attribute("classifier.type", attempt.type),
|
|
70
|
-
attribute("model", attempt.model ?? ""), attribute("decision", attempt.result.decision)
|
|
71
|
-
|
|
75
|
+
attribute("model", attempt.model ?? ""), attribute("decision", attempt.result.decision),
|
|
76
|
+
...(attempt.result.errorKind === undefined ? [] : [attribute("error.kind", attempt.result.errorKind)])],
|
|
77
|
+
status: { code: attempt.result.errorKind === undefined ? 1 : 2 },
|
|
72
78
|
}))];
|
|
73
79
|
const body = JSON.stringify({ resourceSpans: [{
|
|
74
80
|
resource: { attributes: [attribute("service.name", "opencode-auto-mode")] },
|
package/src/transcript.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
const MAX_USERS = 10;
|
|
2
2
|
const MAX_TOOLS = 20;
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const MAX_USER_CHARS = 8_000;
|
|
4
|
+
const MAX_ENTRY_BYTES = 500;
|
|
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
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
interface Entry {
|
|
13
|
+
role: "user" | "tool";
|
|
14
|
+
text: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -42,47 +44,161 @@ function assistantTools(message: unknown): ToolEntry[] {
|
|
|
42
44
|
return tools;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function formatTool(tool: ToolEntry): string {
|
|
47
|
+
function formatTool(tool: ToolEntry): { text: string; truncated: boolean } {
|
|
46
48
|
let detail = "";
|
|
49
|
+
let truncated = false;
|
|
47
50
|
try {
|
|
48
51
|
const state = asRecord(tool.input);
|
|
49
52
|
const input = state?.["input"] ?? tool.input;
|
|
50
53
|
const raw = typeof input === "string" ? input : JSON.stringify(input);
|
|
51
|
-
if (raw)
|
|
54
|
+
if (raw) {
|
|
55
|
+
// Keep the tail as well as the head: written payloads and patches often
|
|
56
|
+
// carry their constraints at the end. Clip once, on escaped bytes, so a
|
|
57
|
+
// later UTF-16 length check cannot cut the preserved tail back off.
|
|
58
|
+
detail = clipEscaped(raw, MAX_ENTRY_BYTES);
|
|
59
|
+
truncated = detail !== raw;
|
|
60
|
+
}
|
|
52
61
|
} catch {
|
|
53
62
|
detail = "";
|
|
54
63
|
}
|
|
55
|
-
return detail ? `${tool.name} ${detail}` : tool.name;
|
|
64
|
+
return { text: detail ? `${tool.name} ${detail}` : tool.name, truncated };
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
function escapedSize(value: string): number {
|
|
68
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function prefixByEscapedBytes(characters: string[], maxBytes: number): string {
|
|
72
|
+
let low = 0;
|
|
73
|
+
let high = characters.length;
|
|
74
|
+
while (low < high) {
|
|
75
|
+
const middle = Math.ceil((low + high) / 2);
|
|
76
|
+
if (escapedSize(characters.slice(0, middle).join("")) <= maxBytes) low = middle;
|
|
77
|
+
else high = middle - 1;
|
|
78
|
+
}
|
|
79
|
+
return characters.slice(0, low).join("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function suffixByEscapedBytes(characters: string[], maxBytes: number): string {
|
|
83
|
+
let low = 0;
|
|
84
|
+
let high = characters.length;
|
|
85
|
+
while (low < high) {
|
|
86
|
+
const middle = Math.ceil((low + high) / 2);
|
|
87
|
+
if (escapedSize(characters.slice(characters.length - middle).join("")) <= maxBytes) low = middle;
|
|
88
|
+
else high = middle - 1;
|
|
89
|
+
}
|
|
90
|
+
return characters.slice(characters.length - low).join("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Clips the head and tail so escaping cannot hide a trailing constraint. */
|
|
94
|
+
function clipEscaped(value: string, maxBytes: number): string {
|
|
95
|
+
if (escapedSize(value) <= maxBytes) return value;
|
|
96
|
+
const characters = Array.from(value);
|
|
97
|
+
const marker = "…";
|
|
98
|
+
const budget = Math.max(0, maxBytes - escapedSize(marker));
|
|
99
|
+
const headBudget = Math.floor(budget * 0.6);
|
|
100
|
+
return `${prefixByEscapedBytes(characters, headBudget)}${marker}${suffixByEscapedBytes(characters, budget - headBudget)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Clips text to `max` code points, keeping both ends for review evidence. */
|
|
104
|
+
export function clipChars(value: string, max: number): string {
|
|
105
|
+
const characters = Array.from(value);
|
|
106
|
+
// Compare code points, not UTF-16 units, so astral characters cannot make
|
|
107
|
+
// this return a longer string than it received.
|
|
108
|
+
if (characters.length <= max) return value;
|
|
109
|
+
const head = Math.floor(max * 0.6);
|
|
110
|
+
return `${characters.slice(0, head).join("")}…${characters.slice(characters.length - (max - head)).join("")}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface TranscriptData {
|
|
114
|
+
transcript: string;
|
|
115
|
+
userEvidence: boolean;
|
|
116
|
+
notes: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderTranscript(entries: Entry[], toolTruncated: boolean, userClipped: boolean): { transcript: string; notes: string[] } {
|
|
120
|
+
if (!entries.some((entry) => entry.role === "user")) return { transcript: "(no prior user messages in this session)", notes: [] };
|
|
121
|
+
let users = 0;
|
|
122
|
+
let tools = 0;
|
|
123
|
+
const recent = entries.slice().reverse().filter((entry) => entry.role === "user" ? ++users <= MAX_USERS : ++tools <= MAX_TOOLS).reverse();
|
|
124
|
+
const notes: string[] = [];
|
|
125
|
+
if (recent.length < entries.length) notes.push("older messages omitted");
|
|
126
|
+
if (toolTruncated) notes.push("some tool details truncated");
|
|
127
|
+
if (userClipped) notes.push("latest user message truncated");
|
|
128
|
+
const render = () => `User messages and prior tool calls (chronological):\n${JSON.stringify(recent)}` +
|
|
129
|
+
(notes.length ? `\n… partial session history (${notes.join("; ")})` : "");
|
|
130
|
+
|
|
131
|
+
while (Buffer.byteLength(render()) > MAX_TRANSCRIPT_BYTES && recent.length > 1) {
|
|
132
|
+
// Keep at least the latest user message.
|
|
133
|
+
const onlyOneUser = recent.filter((entry) => entry.role === "user").length <= 1;
|
|
134
|
+
const index = onlyOneUser && recent[0].role === "user" ? recent.findIndex((entry) => entry.role === "tool") : 0;
|
|
135
|
+
if (index < 0) break;
|
|
136
|
+
recent.splice(index, 1);
|
|
137
|
+
if (!notes.includes("older messages omitted")) notes.push("older messages omitted");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const latestUser = [...recent].reverse().find((entry) => entry.role === "user");
|
|
141
|
+
for (let attempt = 0; latestUser !== undefined && Buffer.byteLength(render()) > MAX_TRANSCRIPT_BYTES && attempt < 10; attempt++) {
|
|
142
|
+
if (!notes.includes("latest user message truncated")) notes.push("latest user message truncated");
|
|
143
|
+
const excess = Buffer.byteLength(render()) - MAX_TRANSCRIPT_BYTES;
|
|
144
|
+
latestUser.text = clipEscaped(latestUser.text, Math.max(128, escapedSize(latestUser.text) - excess - 64));
|
|
145
|
+
}
|
|
146
|
+
return { transcript: render(), notes };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildTranscript(messages: unknown[]): TranscriptData {
|
|
150
|
+
const entries: Entry[] = [];
|
|
151
|
+
let userEvidence = 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
|
+
// An empty turn (attachments only, synthetic input) says nothing about
|
|
159
|
+
// authorization; it must not invalidate the rest of the session.
|
|
160
|
+
if (text === undefined) continue;
|
|
161
|
+
userEvidence = true;
|
|
162
|
+
const clipped = clipChars(text, MAX_USER_CHARS);
|
|
163
|
+
if (clipped !== text) userClipped = true;
|
|
164
|
+
entries.push({ role: "user", text: clipped });
|
|
165
|
+
}
|
|
166
|
+
for (const tool of assistantTools(message)) {
|
|
167
|
+
const formatted = formatTool(tool);
|
|
168
|
+
if (formatted.truncated) toolTruncated = true;
|
|
169
|
+
entries.push({ role: "tool", text: formatted.text });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const rendered = renderTranscript(entries, toolTruncated, userClipped);
|
|
173
|
+
return { transcript: rendered.transcript, userEvidence, notes: rendered.notes };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ReviewContext { transcript: string; complete: boolean; reason?: string; notes?: string[] }
|
|
59
177
|
|
|
60
178
|
export function extractReviewContext(messages: unknown[]): ReviewContext {
|
|
61
|
-
const
|
|
179
|
+
const attachment = messages.some((message) => {
|
|
62
180
|
const record = asRecord(message);
|
|
63
|
-
|
|
181
|
+
// Skill messages carry model-facing instructions the text-only reviewer cannot see.
|
|
182
|
+
if (record?.type === "skill") return true;
|
|
183
|
+
if (record?.type !== "user") return false;
|
|
64
184
|
// These attachments carry evidence that the text-only reviewer cannot see.
|
|
65
|
-
return
|
|
66
|
-
|
|
185
|
+
return ["files", "skills", "agents"].some((key) =>
|
|
186
|
+
Array.isArray(record[key]) && record[key].length > 0);
|
|
67
187
|
});
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
188
|
+
const data = buildTranscript(messages);
|
|
189
|
+
if (attachment)
|
|
190
|
+
return {
|
|
191
|
+
transcript: data.transcript, complete: false, notes: data.notes,
|
|
192
|
+
reason: "User authorization includes attachments that cannot be reviewed automatically",
|
|
193
|
+
};
|
|
194
|
+
if (!data.userEvidence)
|
|
195
|
+
return {
|
|
196
|
+
transcript: data.transcript, complete: false, notes: data.notes,
|
|
197
|
+
reason: "No user authorization message is available",
|
|
198
|
+
};
|
|
199
|
+
return { transcript: data.transcript, complete: true, notes: data.notes };
|
|
73
200
|
}
|
|
74
201
|
|
|
75
202
|
export function extractTranscript(messages: unknown[]): string {
|
|
76
|
-
|
|
77
|
-
for (const message of messages) {
|
|
78
|
-
const text = userText(message);
|
|
79
|
-
if (text !== undefined) entries.push({ role: "user", text: truncate(text, MAX_ENTRY_CHARS) });
|
|
80
|
-
for (const tool of assistantTools(message)) entries.push({ role: "tool", text: formatTool(tool) });
|
|
81
|
-
}
|
|
82
|
-
if (!entries.some((entry) => entry.role === "user")) return "(no prior user messages in this session)";
|
|
83
|
-
let users = 0;
|
|
84
|
-
let tools = 0;
|
|
85
|
-
const recent = entries.slice().reverse().filter((entry) => entry.role === "user" ? ++users <= MAX_USERS : ++tools <= MAX_TOOLS).reverse();
|
|
86
|
-
const incomplete = recent.length < entries.length ? "\n… incomplete session context" : "";
|
|
87
|
-
return truncate(`User messages and prior tool calls (chronological):\n${JSON.stringify(recent)}${incomplete}`, MAX_TRANSCRIPT_CHARS);
|
|
203
|
+
return buildTranscript(messages).transcript;
|
|
88
204
|
}
|
package/src/tui-state.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { DecisionRecord } from "./state.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* How long a "reviewing" record keeps the TUI indicator alive. It must exceed
|
|
5
|
+
* the largest configurable review deadline so a slow review stays visible.
|
|
6
|
+
*/
|
|
7
|
+
export const REVIEW_DISPLAY_WINDOW_MS = 180_000;
|
|
8
|
+
|
|
3
9
|
export function isReviewing(records: Record<string, DecisionRecord>, sessionID: string | undefined, now = Date.now()): boolean {
|
|
4
10
|
if (!sessionID) return false;
|
|
5
11
|
return Object.values(records).some((record) => record.sessionID === sessionID &&
|
|
6
|
-
record.decision === "reviewing" && now - record.at >= 0 && now - record.at <
|
|
12
|
+
record.decision === "reviewing" && now - record.at >= 0 && now - record.at < REVIEW_DISPLAY_WINDOW_MS);
|
|
7
13
|
}
|
package/src/tui.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { createSignal, Show } from "solid-js";
|
|
3
3
|
import { Plugin } from "@opencode/plugin/tui";
|
|
4
|
-
import { readDecisions } from "./state.js";
|
|
4
|
+
import { readConfigError, readDecisions } from "./state.js";
|
|
5
5
|
import { isReviewing } from "./tui-state.js";
|
|
6
6
|
|
|
7
7
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -11,21 +11,36 @@ export default Plugin.define({
|
|
|
11
11
|
setup: async (ctx) => {
|
|
12
12
|
if (ctx.options.tui === false) return;
|
|
13
13
|
const [reviewing, setReviewing] = createSignal(false);
|
|
14
|
+
const [configError, setConfigError] = createSignal(false);
|
|
15
|
+
let announced = false;
|
|
14
16
|
const update = () => {
|
|
15
17
|
const route = ctx.ui.router.current();
|
|
16
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;
|
|
24
|
+
ctx.ui.toast.show({
|
|
25
|
+
variant: "error", title: "auto-mode", duration: 10_000,
|
|
26
|
+
message: "Invalid configuration; automatic approval is disabled.",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
17
29
|
};
|
|
18
30
|
update();
|
|
19
31
|
const poll = setInterval(update, 250);
|
|
20
32
|
const dispose = ctx.ui.slot({
|
|
21
33
|
append: "app",
|
|
22
34
|
render: () => (
|
|
23
|
-
<
|
|
24
|
-
<
|
|
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()}>
|
|
25
40
|
<spinner frames={SPINNER_FRAMES} interval={120} color={ctx.theme.text.feedback.warning.base} />
|
|
26
41
|
<text fg={ctx.theme.text.feedback.warning.base}>auto-mode reviewing</text>
|
|
27
|
-
</
|
|
28
|
-
</
|
|
42
|
+
</Show>
|
|
43
|
+
</box>
|
|
29
44
|
),
|
|
30
45
|
});
|
|
31
46
|
return () => { clearInterval(poll); dispose(); };
|