pi-studio-opencode 0.1.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/ARCHITECTURE.md +122 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/demo-host-pi.d.ts +1 -0
- package/dist/demo-host-pi.js +71 -0
- package/dist/demo-host-pi.js.map +1 -0
- package/dist/demo-host.d.ts +1 -0
- package/dist/demo-host.js +154 -0
- package/dist/demo-host.js.map +1 -0
- package/dist/host-opencode-plugin.d.ts +52 -0
- package/dist/host-opencode-plugin.js +396 -0
- package/dist/host-opencode-plugin.js.map +1 -0
- package/dist/host-opencode.d.ts +154 -0
- package/dist/host-opencode.js +627 -0
- package/dist/host-opencode.js.map +1 -0
- package/dist/host-pi.d.ts +45 -0
- package/dist/host-pi.js +258 -0
- package/dist/host-pi.js.map +1 -0
- package/dist/install-config.d.ts +36 -0
- package/dist/install-config.js +136 -0
- package/dist/install-config.js.map +1 -0
- package/dist/install.d.ts +16 -0
- package/dist/install.js +168 -0
- package/dist/install.js.map +1 -0
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +124 -0
- package/dist/launcher.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +732 -0
- package/dist/main.js.map +1 -0
- package/dist/mock-pi-session.d.ts +27 -0
- package/dist/mock-pi-session.js +138 -0
- package/dist/mock-pi-session.js.map +1 -0
- package/dist/open-browser.d.ts +1 -0
- package/dist/open-browser.js +29 -0
- package/dist/open-browser.js.map +1 -0
- package/dist/opencode-plugin.d.ts +3 -0
- package/dist/opencode-plugin.js +326 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/prototype-pdf.d.ts +12 -0
- package/dist/prototype-pdf.js +991 -0
- package/dist/prototype-pdf.js.map +1 -0
- package/dist/prototype-server.d.ts +88 -0
- package/dist/prototype-server.js +1002 -0
- package/dist/prototype-server.js.map +1 -0
- package/dist/prototype-theme.d.ts +36 -0
- package/dist/prototype-theme.js +1471 -0
- package/dist/prototype-theme.js.map +1 -0
- package/dist/studio-core.d.ts +63 -0
- package/dist/studio-core.js +251 -0
- package/dist/studio-core.js.map +1 -0
- package/dist/studio-host-types.d.ts +50 -0
- package/dist/studio-host-types.js +14 -0
- package/dist/studio-host-types.js.map +1 -0
- package/examples/opencode/INSTALL.md +67 -0
- package/examples/opencode/opencode.local-path.jsonc +16 -0
- package/package.json +68 -0
- package/static/prototype.css +1277 -0
- package/static/prototype.html +173 -0
- package/static/prototype.js +3198 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
+
const DEFAULT_RUN_PROMPT = [
|
|
8
|
+
"Please reply in two short paragraphs.",
|
|
9
|
+
"In the first paragraph, say that the initial run started.",
|
|
10
|
+
"In the second paragraph, say you are waiting for any queued follow-up instructions.",
|
|
11
|
+
].join("\n");
|
|
12
|
+
const DEFAULT_QUEUE_PROMPT = [
|
|
13
|
+
"Queued instruction:",
|
|
14
|
+
"append one extra final paragraph that says exactly QUEUED_PROMPT_WORKED.",
|
|
15
|
+
].join("\n");
|
|
16
|
+
const DEFAULT_SECOND_QUEUE_PROMPT = [
|
|
17
|
+
"Queued instruction 2:",
|
|
18
|
+
"append one more final paragraph that says exactly SECOND_QUEUED_PROMPT_WORKED.",
|
|
19
|
+
].join("\n");
|
|
20
|
+
const DEFAULT_SECOND_RUN_PROMPT = [
|
|
21
|
+
"This is a fresh run after the earlier chain has already gone idle.",
|
|
22
|
+
"Reply with exactly this one line and nothing else:",
|
|
23
|
+
"SECOND_RUN_STARTED",
|
|
24
|
+
].join("\n");
|
|
25
|
+
const DEFAULT_ABORT_PROMPT = [
|
|
26
|
+
"Write 150 numbered bullet points.",
|
|
27
|
+
"Each point should be a complete sentence about why deterministic event logs are useful.",
|
|
28
|
+
"Do not summarize.",
|
|
29
|
+
].join("\n");
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const options = {
|
|
32
|
+
directory: process.cwd(),
|
|
33
|
+
title: `Studio spike ${new Date().toISOString()}`,
|
|
34
|
+
runPrompt: DEFAULT_RUN_PROMPT,
|
|
35
|
+
queuePrompt: DEFAULT_QUEUE_PROMPT,
|
|
36
|
+
secondQueuePrompt: DEFAULT_SECOND_QUEUE_PROMPT,
|
|
37
|
+
secondRunPrompt: DEFAULT_SECOND_RUN_PROMPT,
|
|
38
|
+
queueDelayMs: 1200,
|
|
39
|
+
secondQueueDelayMs: 300,
|
|
40
|
+
settleTimeoutMs: 120_000,
|
|
41
|
+
pollIntervalMs: 1000,
|
|
42
|
+
artifactsDir: resolve(process.cwd(), "artifacts", `pi-studio-opencode-${Date.now()}`),
|
|
43
|
+
multiSteerTest: false,
|
|
44
|
+
newRunAfterIdleTest: false,
|
|
45
|
+
abortTest: false,
|
|
46
|
+
abortDelayMs: 1500,
|
|
47
|
+
abortPrompt: DEFAULT_ABORT_PROMPT,
|
|
48
|
+
};
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
const next = argv[i + 1];
|
|
52
|
+
if (arg === "--base-url" && next) {
|
|
53
|
+
options.baseUrl = next;
|
|
54
|
+
i += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (arg === "--directory" && next) {
|
|
58
|
+
options.directory = resolve(next);
|
|
59
|
+
i += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (arg === "--session" && next) {
|
|
63
|
+
options.sessionId = next;
|
|
64
|
+
i += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg === "--title" && next) {
|
|
68
|
+
options.title = next;
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--run-prompt" && next) {
|
|
73
|
+
options.runPrompt = next;
|
|
74
|
+
i += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg === "--queue-prompt" && next) {
|
|
78
|
+
options.queuePrompt = next;
|
|
79
|
+
i += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg === "--second-queue-prompt" && next) {
|
|
83
|
+
options.secondQueuePrompt = next;
|
|
84
|
+
i += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg === "--second-run-prompt" && next) {
|
|
88
|
+
options.secondRunPrompt = next;
|
|
89
|
+
i += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (arg === "--queue-delay-ms" && next) {
|
|
93
|
+
options.queueDelayMs = parseIntegerFlag("--queue-delay-ms", next);
|
|
94
|
+
i += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (arg === "--second-queue-delay-ms" && next) {
|
|
98
|
+
options.secondQueueDelayMs = parseIntegerFlag("--second-queue-delay-ms", next);
|
|
99
|
+
i += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (arg === "--settle-timeout-ms" && next) {
|
|
103
|
+
options.settleTimeoutMs = parseIntegerFlag("--settle-timeout-ms", next);
|
|
104
|
+
i += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (arg === "--poll-interval-ms" && next) {
|
|
108
|
+
options.pollIntervalMs = parseIntegerFlag("--poll-interval-ms", next);
|
|
109
|
+
i += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (arg === "--artifacts-dir" && next) {
|
|
113
|
+
options.artifactsDir = resolve(next);
|
|
114
|
+
i += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (arg === "--multi-steer-test") {
|
|
118
|
+
options.multiSteerTest = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (arg === "--new-run-after-idle-test") {
|
|
122
|
+
options.newRunAfterIdleTest = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (arg === "--abort-test") {
|
|
126
|
+
options.abortTest = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (arg === "--abort-delay-ms" && next) {
|
|
130
|
+
options.abortDelayMs = parseIntegerFlag("--abort-delay-ms", next);
|
|
131
|
+
i += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (arg === "--abort-prompt" && next) {
|
|
135
|
+
options.abortPrompt = next;
|
|
136
|
+
i += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (arg === "--help" || arg === "-h") {
|
|
140
|
+
printUsageAndExit();
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
143
|
+
}
|
|
144
|
+
return options;
|
|
145
|
+
}
|
|
146
|
+
function parseIntegerFlag(flag, value) {
|
|
147
|
+
const parsed = Number.parseInt(value, 10);
|
|
148
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
149
|
+
throw new Error(`Invalid value for ${flag}: ${value}`);
|
|
150
|
+
}
|
|
151
|
+
return parsed;
|
|
152
|
+
}
|
|
153
|
+
function printUsageAndExit() {
|
|
154
|
+
console.log(`Usage: npm start -- [options]
|
|
155
|
+
|
|
156
|
+
Options:
|
|
157
|
+
--base-url <url> Connect to an existing opencode server instead of starting one
|
|
158
|
+
--directory <path> Working directory / project directory (default: current directory)
|
|
159
|
+
--session <id> Reuse an existing session instead of creating one
|
|
160
|
+
--title <title> Session title for a new session
|
|
161
|
+
--run-prompt <text> Initial run prompt text
|
|
162
|
+
--queue-prompt <text> First queued steering prompt
|
|
163
|
+
--second-queue-prompt <text> Second queued steering prompt for --multi-steer-test
|
|
164
|
+
--second-run-prompt <text> Fresh run prompt for --new-run-after-idle-test
|
|
165
|
+
--queue-delay-ms <n> Delay before queueing the first steer (default: 1200)
|
|
166
|
+
--second-queue-delay-ms <n> Delay before queueing the second steer (default: 300)
|
|
167
|
+
--settle-timeout-ms <n> Timeout waiting for idle / replies (default: 120000)
|
|
168
|
+
--poll-interval-ms <n> Poll interval for status/messages (default: 1000)
|
|
169
|
+
--artifacts-dir <path> Output directory for logs/artifacts
|
|
170
|
+
--multi-steer-test Queue a second steer while the first chain is still busy
|
|
171
|
+
--new-run-after-idle-test Start a fresh run in the same session after the first chain settles
|
|
172
|
+
--abort-test Start another fresh run and abort it after a short delay
|
|
173
|
+
--abort-delay-ms <n> Delay before aborting the extra run (default: 1500)
|
|
174
|
+
--abort-prompt <text> Prompt used for the optional abort test
|
|
175
|
+
`);
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
function summarizeEvent(event) {
|
|
179
|
+
const props = event.properties;
|
|
180
|
+
if (event.type === "session.status") {
|
|
181
|
+
const status = props?.status;
|
|
182
|
+
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : "?";
|
|
183
|
+
return `${event.type} session=${sessionID} status=${status?.type ?? "unknown"}`;
|
|
184
|
+
}
|
|
185
|
+
if (event.type === "session.idle") {
|
|
186
|
+
return `${event.type} session=${String(props?.sessionID ?? "?")}`;
|
|
187
|
+
}
|
|
188
|
+
if (event.type === "message.updated") {
|
|
189
|
+
const info = props?.info;
|
|
190
|
+
return `${event.type} role=${info?.role ?? "?"} message=${info?.id ?? "?"} session=${info?.sessionID ?? "?"}`;
|
|
191
|
+
}
|
|
192
|
+
if (event.type === "message.part.updated") {
|
|
193
|
+
const part = props?.part;
|
|
194
|
+
return `${event.type} partType=${part?.type ?? "?"} session=${part?.sessionID ?? "?"} message=${part?.messageID ?? "?"} part=${part?.id ?? "?"}`;
|
|
195
|
+
}
|
|
196
|
+
if (event.type === "permission.updated") {
|
|
197
|
+
return `${event.type} session=${String(props?.sessionID ?? "?")}`;
|
|
198
|
+
}
|
|
199
|
+
return event.type;
|
|
200
|
+
}
|
|
201
|
+
function normalizeMessage(record) {
|
|
202
|
+
const created = record.info.time.created;
|
|
203
|
+
const completed = record.info.role === "assistant" ? record.info.time.completed : undefined;
|
|
204
|
+
const error = record.info.role === "assistant" && record.info.error
|
|
205
|
+
? `${record.info.error.name}: ${record.info.error.data.message ?? "unknown error"}`
|
|
206
|
+
: undefined;
|
|
207
|
+
const text = record.parts
|
|
208
|
+
.filter((part) => part.type === "text")
|
|
209
|
+
.map((part) => part.text)
|
|
210
|
+
.join("\n\n")
|
|
211
|
+
.trim();
|
|
212
|
+
const reasoning = record.parts
|
|
213
|
+
.filter((part) => part.type === "reasoning")
|
|
214
|
+
.map((part) => part.text)
|
|
215
|
+
.join("\n\n")
|
|
216
|
+
.trim();
|
|
217
|
+
return {
|
|
218
|
+
id: record.info.id,
|
|
219
|
+
role: record.info.role,
|
|
220
|
+
created,
|
|
221
|
+
completed,
|
|
222
|
+
error,
|
|
223
|
+
text,
|
|
224
|
+
reasoning,
|
|
225
|
+
partTypes: record.parts.map((part) => part.type),
|
|
226
|
+
partCount: record.parts.length,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function buildEffectivePrompt(basePrompt, steeringPrompts) {
|
|
230
|
+
if (steeringPrompts.length === 0)
|
|
231
|
+
return basePrompt;
|
|
232
|
+
const blocks = [`## Original run prompt\n\n${basePrompt}`];
|
|
233
|
+
for (let i = 0; i < steeringPrompts.length; i++) {
|
|
234
|
+
blocks.push(`## Steering ${i + 1}\n\n${steeringPrompts[i]}`);
|
|
235
|
+
}
|
|
236
|
+
return blocks.join("\n\n");
|
|
237
|
+
}
|
|
238
|
+
function countAssistantMessages(messages) {
|
|
239
|
+
return messages.filter((entry) => entry.info.role === "assistant").length;
|
|
240
|
+
}
|
|
241
|
+
async function fetchSessionMessages(client, sessionID, directory) {
|
|
242
|
+
const response = await client.session.messages({
|
|
243
|
+
path: { id: sessionID },
|
|
244
|
+
query: { directory, limit: 200 },
|
|
245
|
+
throwOnError: true,
|
|
246
|
+
});
|
|
247
|
+
return response.data ?? [];
|
|
248
|
+
}
|
|
249
|
+
async function fetchSessionStatus(client, sessionID, directory) {
|
|
250
|
+
const response = await client.session.status({
|
|
251
|
+
query: { directory },
|
|
252
|
+
throwOnError: true,
|
|
253
|
+
});
|
|
254
|
+
const statusMap = response.data ?? {};
|
|
255
|
+
const status = statusMap[sessionID];
|
|
256
|
+
return status?.type ?? null;
|
|
257
|
+
}
|
|
258
|
+
async function waitForSessionToSettle(client, sessionID, directory, expectedAssistantCount, timeoutMs, pollIntervalMs) {
|
|
259
|
+
const started = Date.now();
|
|
260
|
+
while (Date.now() - started < timeoutMs) {
|
|
261
|
+
const [messages, status] = await Promise.all([
|
|
262
|
+
fetchSessionMessages(client, sessionID, directory),
|
|
263
|
+
fetchSessionStatus(client, sessionID, directory),
|
|
264
|
+
]);
|
|
265
|
+
const assistantCount = countAssistantMessages(messages);
|
|
266
|
+
const completedAssistantCount = messages.filter((entry) => entry.info.role === "assistant" && Boolean(entry.info.time.completed)).length;
|
|
267
|
+
if ((status === "idle" || status === null) && assistantCount >= expectedAssistantCount && completedAssistantCount >= expectedAssistantCount) {
|
|
268
|
+
return messages;
|
|
269
|
+
}
|
|
270
|
+
await sleep(pollIntervalMs);
|
|
271
|
+
}
|
|
272
|
+
throw new Error(`Timed out waiting for session ${sessionID} to settle.`);
|
|
273
|
+
}
|
|
274
|
+
async function waitForSessionToBecomeIdle(client, sessionID, directory, timeoutMs, pollIntervalMs) {
|
|
275
|
+
const started = Date.now();
|
|
276
|
+
while (Date.now() - started < timeoutMs) {
|
|
277
|
+
const [messages, status] = await Promise.all([
|
|
278
|
+
fetchSessionMessages(client, sessionID, directory),
|
|
279
|
+
fetchSessionStatus(client, sessionID, directory),
|
|
280
|
+
]);
|
|
281
|
+
if (status === "idle" || status === null) {
|
|
282
|
+
return messages;
|
|
283
|
+
}
|
|
284
|
+
await sleep(pollIntervalMs);
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`Timed out waiting for session ${sessionID} to become idle.`);
|
|
287
|
+
}
|
|
288
|
+
async function createOrReuseSession(client, options) {
|
|
289
|
+
if (options.sessionId) {
|
|
290
|
+
const response = await client.session.get({
|
|
291
|
+
path: { id: options.sessionId },
|
|
292
|
+
query: { directory: options.directory },
|
|
293
|
+
throwOnError: true,
|
|
294
|
+
});
|
|
295
|
+
if (!response.data)
|
|
296
|
+
throw new Error(`Session not found: ${options.sessionId}`);
|
|
297
|
+
return response.data;
|
|
298
|
+
}
|
|
299
|
+
const response = await client.session.create({
|
|
300
|
+
query: { directory: options.directory },
|
|
301
|
+
body: { title: options.title },
|
|
302
|
+
throwOnError: true,
|
|
303
|
+
});
|
|
304
|
+
if (!response.data) {
|
|
305
|
+
throw new Error("Session creation returned no data.");
|
|
306
|
+
}
|
|
307
|
+
return response.data;
|
|
308
|
+
}
|
|
309
|
+
async function submitPrompt(context, input) {
|
|
310
|
+
const sessionStatusAtSubmit = await fetchSessionStatus(context.client, context.session.id, context.options.directory);
|
|
311
|
+
const queuedWhileBusy = sessionStatusAtSubmit === "busy";
|
|
312
|
+
const submittedAt = Date.now();
|
|
313
|
+
let pendingChain = null;
|
|
314
|
+
let activeChain = context.currentChain;
|
|
315
|
+
let steeringPromptsForEffective = [];
|
|
316
|
+
if (input.promptMode === "run") {
|
|
317
|
+
pendingChain = {
|
|
318
|
+
chainId: `chain_${randomUUID()}`,
|
|
319
|
+
chainIndex: context.chains.length + 1,
|
|
320
|
+
sessionId: context.session.id,
|
|
321
|
+
startedAt: submittedAt,
|
|
322
|
+
basePromptText: input.promptText,
|
|
323
|
+
steeringPrompts: [],
|
|
324
|
+
submissionIds: [],
|
|
325
|
+
};
|
|
326
|
+
activeChain = pendingChain;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
if (!activeChain) {
|
|
330
|
+
throw new Error(`Cannot submit steer prompt without an active chain: ${input.stepLabel}`);
|
|
331
|
+
}
|
|
332
|
+
steeringPromptsForEffective = [...activeChain.steeringPrompts, input.promptText];
|
|
333
|
+
}
|
|
334
|
+
await context.client.session.promptAsync({
|
|
335
|
+
path: { id: context.session.id },
|
|
336
|
+
query: { directory: context.options.directory },
|
|
337
|
+
body: {
|
|
338
|
+
parts: [{ type: "text", text: input.promptText }],
|
|
339
|
+
},
|
|
340
|
+
throwOnError: true,
|
|
341
|
+
});
|
|
342
|
+
if (pendingChain) {
|
|
343
|
+
context.chains.push(pendingChain);
|
|
344
|
+
context.currentChain = pendingChain;
|
|
345
|
+
activeChain = pendingChain;
|
|
346
|
+
}
|
|
347
|
+
if (!activeChain) {
|
|
348
|
+
throw new Error(`Active chain missing after prompt submission: ${input.stepLabel}`);
|
|
349
|
+
}
|
|
350
|
+
const submission = {
|
|
351
|
+
localPromptId: `prompt_${randomUUID()}`,
|
|
352
|
+
submissionIndex: context.submissions.length + 1,
|
|
353
|
+
sessionId: context.session.id,
|
|
354
|
+
chainId: activeChain.chainId,
|
|
355
|
+
chainIndex: activeChain.chainIndex,
|
|
356
|
+
scenarioName: input.scenarioName,
|
|
357
|
+
stepLabel: input.stepLabel,
|
|
358
|
+
promptMode: input.promptMode,
|
|
359
|
+
triggerKind: input.promptMode,
|
|
360
|
+
submittedAt,
|
|
361
|
+
sessionStatusAtSubmit,
|
|
362
|
+
queuedWhileBusy,
|
|
363
|
+
promptText: input.promptText,
|
|
364
|
+
promptSteeringCount: input.promptMode === "run" ? 0 : steeringPromptsForEffective.length,
|
|
365
|
+
promptTriggerText: input.promptText,
|
|
366
|
+
effectivePrompt: input.promptMode === "run"
|
|
367
|
+
? buildEffectivePrompt(activeChain.basePromptText, [])
|
|
368
|
+
: buildEffectivePrompt(activeChain.basePromptText, steeringPromptsForEffective),
|
|
369
|
+
expectedReply: true,
|
|
370
|
+
};
|
|
371
|
+
context.submissions.push(submission);
|
|
372
|
+
activeChain.submissionIds.push(submission.localPromptId);
|
|
373
|
+
if (input.promptMode === "steer") {
|
|
374
|
+
activeChain.steeringPrompts.push(input.promptText);
|
|
375
|
+
}
|
|
376
|
+
return submission;
|
|
377
|
+
}
|
|
378
|
+
async function waitForIdleAndRefreshAssistantCount(context) {
|
|
379
|
+
const before = context.expectedAssistantCount;
|
|
380
|
+
const messages = await waitForSessionToBecomeIdle(context.client, context.session.id, context.options.directory, context.options.settleTimeoutMs, context.options.pollIntervalMs);
|
|
381
|
+
const after = countAssistantMessages(messages);
|
|
382
|
+
context.expectedAssistantCount = after;
|
|
383
|
+
return {
|
|
384
|
+
messages,
|
|
385
|
+
observedAssistantReplies: Math.max(0, after - before),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function closeActiveChain(context, observedAssistantReplies) {
|
|
389
|
+
if (!context.currentChain)
|
|
390
|
+
return;
|
|
391
|
+
context.currentChain.completedAt = Date.now();
|
|
392
|
+
if (observedAssistantReplies !== undefined) {
|
|
393
|
+
context.currentChain.observedAssistantReplies = observedAssistantReplies;
|
|
394
|
+
}
|
|
395
|
+
context.currentChain = null;
|
|
396
|
+
}
|
|
397
|
+
function attachMatchesToSubmissions(chains, submissions, normalizedMessages, initialMessageIds) {
|
|
398
|
+
const newMessages = normalizedMessages.filter((message) => !initialMessageIds.has(message.id));
|
|
399
|
+
const userMessages = newMessages.filter((message) => message.role === "user");
|
|
400
|
+
const assistantMessages = newMessages.filter((message) => message.role === "assistant");
|
|
401
|
+
const usedUserIds = new Set();
|
|
402
|
+
for (const submission of submissions) {
|
|
403
|
+
const exactMatch = userMessages.find((message) => (!usedUserIds.has(message.id)
|
|
404
|
+
&& message.text === submission.promptText
|
|
405
|
+
&& message.created >= submission.submittedAt - 10_000));
|
|
406
|
+
const fallbackMatch = exactMatch
|
|
407
|
+
? null
|
|
408
|
+
: userMessages.find((message) => !usedUserIds.has(message.id) && message.text === submission.promptText);
|
|
409
|
+
const matchedUser = exactMatch ?? fallbackMatch;
|
|
410
|
+
if (matchedUser) {
|
|
411
|
+
usedUserIds.add(matchedUser.id);
|
|
412
|
+
submission.userMessageId = matchedUser.id;
|
|
413
|
+
submission.userMessageCreated = matchedUser.created;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const usedAssistantIds = new Set();
|
|
417
|
+
let assistantCursor = 0;
|
|
418
|
+
for (const chain of chains.slice().sort((a, b) => a.chainIndex - b.chainIndex)) {
|
|
419
|
+
const chainSubmissions = submissions
|
|
420
|
+
.filter((submission) => submission.chainId === chain.chainId && submission.expectedReply)
|
|
421
|
+
.sort((a, b) => a.submissionIndex - b.submissionIndex);
|
|
422
|
+
const observedAssistantReplies = chain.observedAssistantReplies ?? chainSubmissions.length;
|
|
423
|
+
for (let i = 0; i < observedAssistantReplies && i < chainSubmissions.length; i++) {
|
|
424
|
+
while (assistantCursor < assistantMessages.length && usedAssistantIds.has(assistantMessages[assistantCursor].id)) {
|
|
425
|
+
assistantCursor += 1;
|
|
426
|
+
}
|
|
427
|
+
const matchedAssistant = assistantMessages[assistantCursor];
|
|
428
|
+
if (!matchedAssistant)
|
|
429
|
+
break;
|
|
430
|
+
assistantCursor += 1;
|
|
431
|
+
usedAssistantIds.add(matchedAssistant.id);
|
|
432
|
+
const submission = chainSubmissions[i];
|
|
433
|
+
submission.responseMessageId = matchedAssistant.id;
|
|
434
|
+
submission.responseCreated = matchedAssistant.created;
|
|
435
|
+
submission.responseCompleted = matchedAssistant.completed;
|
|
436
|
+
submission.responseText = matchedAssistant.text;
|
|
437
|
+
submission.responseError = matchedAssistant.error;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
initialMessageCount: initialMessageIds.size,
|
|
442
|
+
newMessageCount: newMessages.length,
|
|
443
|
+
newUserMessageCount: userMessages.length,
|
|
444
|
+
newAssistantMessageCount: assistantMessages.length,
|
|
445
|
+
missingUserMatches: submissions.filter((submission) => !submission.userMessageId).map((submission) => submission.localPromptId),
|
|
446
|
+
missingResponseMatches: submissions.filter((submission) => submission.expectedReply && !submission.responseMessageId).map((submission) => submission.localPromptId),
|
|
447
|
+
unassignedUserMessageIds: userMessages.filter((message) => !usedUserIds.has(message.id)).map((message) => message.id),
|
|
448
|
+
unassignedAssistantMessageIds: assistantMessages.filter((message) => !usedAssistantIds.has(message.id)).map((message) => message.id),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function buildResponseHistory(submissions) {
|
|
452
|
+
return submissions
|
|
453
|
+
.filter((submission) => submission.expectedReply)
|
|
454
|
+
.sort((a, b) => a.submissionIndex - b.submissionIndex)
|
|
455
|
+
.map((submission, index) => ({
|
|
456
|
+
responseIndex: index + 1,
|
|
457
|
+
localPromptId: submission.localPromptId,
|
|
458
|
+
chainId: submission.chainId,
|
|
459
|
+
chainIndex: submission.chainIndex,
|
|
460
|
+
scenarioName: submission.scenarioName,
|
|
461
|
+
stepLabel: submission.stepLabel,
|
|
462
|
+
responseMessageId: submission.responseMessageId ?? null,
|
|
463
|
+
responseText: submission.responseText ?? null,
|
|
464
|
+
responseError: submission.responseError,
|
|
465
|
+
promptMode: submission.promptMode === "run" ? "run" : "effective",
|
|
466
|
+
triggerKind: submission.triggerKind,
|
|
467
|
+
promptSteeringCount: submission.promptSteeringCount,
|
|
468
|
+
promptTriggerText: submission.promptTriggerText,
|
|
469
|
+
effectivePrompt: submission.effectivePrompt,
|
|
470
|
+
queuedWhileBusy: submission.queuedWhileBusy,
|
|
471
|
+
sessionStatusAtSubmit: submission.sessionStatusAtSubmit,
|
|
472
|
+
userMessageId: submission.userMessageId ?? null,
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
function buildChainSummaries(chains, submissions) {
|
|
476
|
+
return chains
|
|
477
|
+
.slice()
|
|
478
|
+
.sort((a, b) => a.chainIndex - b.chainIndex)
|
|
479
|
+
.map((chain) => {
|
|
480
|
+
const chainSubmissions = submissions.filter((submission) => submission.chainId === chain.chainId);
|
|
481
|
+
return {
|
|
482
|
+
chainId: chain.chainId,
|
|
483
|
+
chainIndex: chain.chainIndex,
|
|
484
|
+
sessionId: chain.sessionId,
|
|
485
|
+
startedAt: chain.startedAt,
|
|
486
|
+
completedAt: chain.completedAt,
|
|
487
|
+
observedAssistantReplies: chain.observedAssistantReplies,
|
|
488
|
+
basePromptText: chain.basePromptText,
|
|
489
|
+
steeringPrompts: [...chain.steeringPrompts],
|
|
490
|
+
submissionIds: [...chain.submissionIds],
|
|
491
|
+
responseMessageIds: chainSubmissions
|
|
492
|
+
.map((submission) => submission.responseMessageId)
|
|
493
|
+
.filter((value) => Boolean(value)),
|
|
494
|
+
responseCount: chainSubmissions.filter((submission) => Boolean(submission.responseMessageId)).length,
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
async function main() {
|
|
499
|
+
const options = parseArgs(process.argv.slice(2));
|
|
500
|
+
await mkdir(options.artifactsDir, { recursive: true });
|
|
501
|
+
const eventsPath = resolve(options.artifactsDir, "events.jsonl");
|
|
502
|
+
const messagesPath = resolve(options.artifactsDir, "messages-final.json");
|
|
503
|
+
const promptSubmissionsPath = resolve(options.artifactsDir, "prompt-submissions.json");
|
|
504
|
+
const historyPath = resolve(options.artifactsDir, "response-history.json");
|
|
505
|
+
const chainsPath = resolve(options.artifactsDir, "chains.json");
|
|
506
|
+
const matchingDiagnosticsPath = resolve(options.artifactsDir, "matching-diagnostics.json");
|
|
507
|
+
const summaryPath = resolve(options.artifactsDir, "summary.json");
|
|
508
|
+
const eventLogStream = createWriteStream(eventsPath, { flags: "a" });
|
|
509
|
+
const sseController = new AbortController();
|
|
510
|
+
let startedServer = null;
|
|
511
|
+
let client;
|
|
512
|
+
if (options.baseUrl) {
|
|
513
|
+
client = createOpencodeClient({
|
|
514
|
+
baseUrl: options.baseUrl,
|
|
515
|
+
directory: options.directory,
|
|
516
|
+
});
|
|
517
|
+
console.log(`Connected to existing opencode server at ${options.baseUrl}`);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
const runtime = await createOpencode({});
|
|
521
|
+
client = runtime.client;
|
|
522
|
+
startedServer = runtime.server;
|
|
523
|
+
console.log(`Started local opencode server at ${runtime.server.url}`);
|
|
524
|
+
}
|
|
525
|
+
let eventLoop = null;
|
|
526
|
+
try {
|
|
527
|
+
const events = await client.event.subscribe({
|
|
528
|
+
query: { directory: options.directory },
|
|
529
|
+
signal: sseController.signal,
|
|
530
|
+
onSseError: (error) => {
|
|
531
|
+
if (sseController.signal.aborted)
|
|
532
|
+
return;
|
|
533
|
+
console.error("[sse-error]", error);
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
eventLoop = (async () => {
|
|
537
|
+
for await (const event of events.stream) {
|
|
538
|
+
const line = JSON.stringify({ ts: Date.now(), event }) + "\n";
|
|
539
|
+
eventLogStream.write(line);
|
|
540
|
+
console.log(`[event] ${summarizeEvent(event)}`);
|
|
541
|
+
}
|
|
542
|
+
})().catch((error) => {
|
|
543
|
+
if (sseController.signal.aborted)
|
|
544
|
+
return;
|
|
545
|
+
console.error("Event stream failed:", error);
|
|
546
|
+
});
|
|
547
|
+
const session = await createOrReuseSession(client, options);
|
|
548
|
+
console.log(`Using session ${session.id} (${session.title})`);
|
|
549
|
+
const initialMessages = await fetchSessionMessages(client, session.id, options.directory);
|
|
550
|
+
const initialMessageIds = new Set(initialMessages.map((entry) => entry.info.id));
|
|
551
|
+
const context = {
|
|
552
|
+
client,
|
|
553
|
+
session,
|
|
554
|
+
options,
|
|
555
|
+
chains: [],
|
|
556
|
+
submissions: [],
|
|
557
|
+
currentChain: null,
|
|
558
|
+
expectedAssistantCount: countAssistantMessages(initialMessages),
|
|
559
|
+
};
|
|
560
|
+
const scenarioResults = [];
|
|
561
|
+
console.log(`Initial session message count: ${initialMessages.length}`);
|
|
562
|
+
console.log("Submitting initial run...");
|
|
563
|
+
await submitPrompt(context, {
|
|
564
|
+
scenarioName: "initial-queue",
|
|
565
|
+
stepLabel: "run-1",
|
|
566
|
+
promptMode: "run",
|
|
567
|
+
promptText: options.runPrompt,
|
|
568
|
+
});
|
|
569
|
+
await sleep(options.queueDelayMs);
|
|
570
|
+
console.log("Submitting first queued steer...");
|
|
571
|
+
await submitPrompt(context, {
|
|
572
|
+
scenarioName: "initial-queue",
|
|
573
|
+
stepLabel: "steer-1",
|
|
574
|
+
promptMode: "steer",
|
|
575
|
+
promptText: options.queuePrompt,
|
|
576
|
+
});
|
|
577
|
+
let initialScenarioPromptCount = 2;
|
|
578
|
+
if (options.multiSteerTest) {
|
|
579
|
+
await sleep(options.secondQueueDelayMs);
|
|
580
|
+
console.log("Submitting second queued steer...");
|
|
581
|
+
await submitPrompt(context, {
|
|
582
|
+
scenarioName: "multi-steer",
|
|
583
|
+
stepLabel: "steer-2",
|
|
584
|
+
promptMode: "steer",
|
|
585
|
+
promptText: options.secondQueuePrompt,
|
|
586
|
+
});
|
|
587
|
+
initialScenarioPromptCount += 1;
|
|
588
|
+
}
|
|
589
|
+
const initialScenarioSettle = await waitForIdleAndRefreshAssistantCount(context);
|
|
590
|
+
closeActiveChain(context, initialScenarioSettle.observedAssistantReplies);
|
|
591
|
+
scenarioResults.push({
|
|
592
|
+
scenarioName: "initial-queue",
|
|
593
|
+
description: options.multiSteerTest
|
|
594
|
+
? "Started a run and queued two steering prompts before the session went idle."
|
|
595
|
+
: "Started a run and queued one steering prompt before the session went idle.",
|
|
596
|
+
submittedPromptCount: initialScenarioPromptCount,
|
|
597
|
+
observedAssistantReplies: initialScenarioSettle.observedAssistantReplies,
|
|
598
|
+
});
|
|
599
|
+
if (options.newRunAfterIdleTest) {
|
|
600
|
+
console.log("Submitting fresh run after idle...");
|
|
601
|
+
await submitPrompt(context, {
|
|
602
|
+
scenarioName: "fresh-run-after-idle",
|
|
603
|
+
stepLabel: "run-2",
|
|
604
|
+
promptMode: "run",
|
|
605
|
+
promptText: options.secondRunPrompt,
|
|
606
|
+
});
|
|
607
|
+
const freshRunSettle = await waitForIdleAndRefreshAssistantCount(context);
|
|
608
|
+
closeActiveChain(context, freshRunSettle.observedAssistantReplies);
|
|
609
|
+
scenarioResults.push({
|
|
610
|
+
scenarioName: "fresh-run-after-idle",
|
|
611
|
+
description: "Started a fresh run in the same session after the previous chain had already settled.",
|
|
612
|
+
submittedPromptCount: 1,
|
|
613
|
+
observedAssistantReplies: freshRunSettle.observedAssistantReplies,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (options.abortTest) {
|
|
617
|
+
console.log("Submitting fresh run for abort test...");
|
|
618
|
+
const abortSubmission = await submitPrompt(context, {
|
|
619
|
+
scenarioName: "abort-run",
|
|
620
|
+
stepLabel: "run-abort",
|
|
621
|
+
promptMode: "run",
|
|
622
|
+
promptText: options.abortPrompt,
|
|
623
|
+
});
|
|
624
|
+
await sleep(options.abortDelayMs);
|
|
625
|
+
abortSubmission.abortRequestedAt = Date.now();
|
|
626
|
+
console.log("Aborting session...");
|
|
627
|
+
const assistantCountBeforeAbortWait = context.expectedAssistantCount;
|
|
628
|
+
await client.session.abort({
|
|
629
|
+
path: { id: session.id },
|
|
630
|
+
query: { directory: options.directory },
|
|
631
|
+
throwOnError: true,
|
|
632
|
+
});
|
|
633
|
+
const abortMessages = await waitForSessionToBecomeIdle(client, session.id, options.directory, options.settleTimeoutMs, options.pollIntervalMs);
|
|
634
|
+
const assistantCountAfterAbort = countAssistantMessages(abortMessages);
|
|
635
|
+
context.expectedAssistantCount = assistantCountAfterAbort;
|
|
636
|
+
closeActiveChain(context, Math.max(0, assistantCountAfterAbort - assistantCountBeforeAbortWait));
|
|
637
|
+
scenarioResults.push({
|
|
638
|
+
scenarioName: "abort-run",
|
|
639
|
+
description: "Started a fresh run after idle and then aborted it before completion.",
|
|
640
|
+
submittedPromptCount: 1,
|
|
641
|
+
observedAssistantReplies: Math.max(0, assistantCountAfterAbort - assistantCountBeforeAbortWait),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const finalMessages = await fetchSessionMessages(client, session.id, options.directory);
|
|
645
|
+
const normalizedMessages = finalMessages
|
|
646
|
+
.map(normalizeMessage)
|
|
647
|
+
.sort((a, b) => a.created - b.created);
|
|
648
|
+
const matchingDiagnostics = attachMatchesToSubmissions(context.chains, context.submissions, normalizedMessages, initialMessageIds);
|
|
649
|
+
const responseHistory = buildResponseHistory(context.submissions);
|
|
650
|
+
const chainSummaries = buildChainSummaries(context.chains, context.submissions);
|
|
651
|
+
await writeFile(messagesPath, JSON.stringify(normalizedMessages, null, 2));
|
|
652
|
+
await writeFile(promptSubmissionsPath, JSON.stringify(context.submissions, null, 2));
|
|
653
|
+
await writeFile(historyPath, JSON.stringify(responseHistory, null, 2));
|
|
654
|
+
await writeFile(chainsPath, JSON.stringify(chainSummaries, null, 2));
|
|
655
|
+
await writeFile(matchingDiagnosticsPath, JSON.stringify(matchingDiagnostics, null, 2));
|
|
656
|
+
await writeFile(summaryPath, JSON.stringify({
|
|
657
|
+
directory: options.directory,
|
|
658
|
+
artifactsDir: options.artifactsDir,
|
|
659
|
+
usedExistingServer: Boolean(options.baseUrl),
|
|
660
|
+
baseUrl: options.baseUrl ?? startedServer?.url ?? null,
|
|
661
|
+
session: {
|
|
662
|
+
id: session.id,
|
|
663
|
+
title: session.title,
|
|
664
|
+
},
|
|
665
|
+
counts: {
|
|
666
|
+
initialMessages: initialMessages.length,
|
|
667
|
+
finalMessages: finalMessages.length,
|
|
668
|
+
finalAssistantMessages: countAssistantMessages(finalMessages),
|
|
669
|
+
submittedPrompts: context.submissions.length,
|
|
670
|
+
matchedUserMessages: context.submissions.filter((submission) => Boolean(submission.userMessageId)).length,
|
|
671
|
+
matchedResponses: context.submissions.filter((submission) => Boolean(submission.responseMessageId)).length,
|
|
672
|
+
chains: context.chains.length,
|
|
673
|
+
},
|
|
674
|
+
tests: {
|
|
675
|
+
multiSteerTest: options.multiSteerTest,
|
|
676
|
+
newRunAfterIdleTest: options.newRunAfterIdleTest,
|
|
677
|
+
abortTest: options.abortTest,
|
|
678
|
+
},
|
|
679
|
+
scenarioResults,
|
|
680
|
+
matchingDiagnostics,
|
|
681
|
+
options,
|
|
682
|
+
}, null, 2));
|
|
683
|
+
console.log(`Wrote event log to ${eventsPath}`);
|
|
684
|
+
console.log(`Wrote normalized messages to ${messagesPath}`);
|
|
685
|
+
console.log(`Wrote prompt submissions to ${promptSubmissionsPath}`);
|
|
686
|
+
console.log(`Wrote reconstructed response history to ${historyPath}`);
|
|
687
|
+
console.log(`Wrote chain summaries to ${chainsPath}`);
|
|
688
|
+
console.log(`Wrote matching diagnostics to ${matchingDiagnosticsPath}`);
|
|
689
|
+
console.log(`Wrote summary to ${summaryPath}`);
|
|
690
|
+
console.log("\nExplicitly reconstructed response history:\n");
|
|
691
|
+
for (const item of responseHistory) {
|
|
692
|
+
console.log(`Response ${item.responseIndex}: chain=${item.chainIndex}, scenario=${item.scenarioName}, trigger=${item.triggerKind}, mode=${item.promptMode}, steeringCount=${item.promptSteeringCount}, queuedWhileBusy=${item.queuedWhileBusy}`);
|
|
693
|
+
console.log(` local prompt id: ${item.localPromptId}`);
|
|
694
|
+
console.log(` user message id: ${item.userMessageId ?? "(missing)"}`);
|
|
695
|
+
console.log(` response message id: ${item.responseMessageId ?? "(missing)"}`);
|
|
696
|
+
console.log(` trigger text: ${item.promptTriggerText.replace(/\s+/g, " ").slice(0, 140)}`);
|
|
697
|
+
if (item.responseError) {
|
|
698
|
+
console.log(` response error: ${item.responseError}`);
|
|
699
|
+
}
|
|
700
|
+
console.log(` response preview: ${(item.responseText || "").replace(/\s+/g, " ").slice(0, 140)}`);
|
|
701
|
+
console.log("");
|
|
702
|
+
}
|
|
703
|
+
if (matchingDiagnostics.missingUserMatches.length > 0
|
|
704
|
+
|| matchingDiagnostics.missingResponseMatches.length > 0
|
|
705
|
+
|| matchingDiagnostics.unassignedUserMessageIds.length > 0
|
|
706
|
+
|| matchingDiagnostics.unassignedAssistantMessageIds.length > 0) {
|
|
707
|
+
console.log("Matching diagnostics detected some gaps:");
|
|
708
|
+
console.log(JSON.stringify(matchingDiagnostics, null, 2));
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
console.log("Matching diagnostics: all submitted prompts matched cleanly to user and assistant messages.");
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
finally {
|
|
715
|
+
sseController.abort();
|
|
716
|
+
eventLogStream.end();
|
|
717
|
+
try {
|
|
718
|
+
await eventLoop;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// ignore shutdown race
|
|
722
|
+
}
|
|
723
|
+
if (startedServer) {
|
|
724
|
+
startedServer.close();
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
void main().catch((error) => {
|
|
729
|
+
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
|
730
|
+
process.exitCode = 1;
|
|
731
|
+
});
|
|
732
|
+
//# sourceMappingURL=main.js.map
|