antechamber 0.1.1 → 0.2.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/CHANGELOG.md +45 -0
- package/README.md +24 -14
- package/bin/opencode-antechamber +14 -1
- package/bin/opencode-pi +11 -1
- package/dist/approvals.d.ts +4 -1
- package/dist/approvals.js +51 -33
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +1 -1
- package/dist/commands/context.d.ts +0 -1
- package/dist/commands/context.js +1 -1
- package/dist/commands/doctor.d.ts +3 -2
- package/dist/commands/serve.d.ts +2 -1
- package/dist/commands/setup.d.ts +2 -3
- package/dist/commands/setup.js +16 -4
- package/dist/http.d.ts +8 -1
- package/dist/http.js +42 -19
- package/dist/pi-event-translation.d.ts +10 -6
- package/dist/pi-event-translation.js +237 -80
- package/dist/pi-rpc.d.ts +20 -2
- package/dist/pi-rpc.js +96 -29
- package/dist/prompt-parts.d.ts +40 -0
- package/dist/prompt-parts.js +157 -0
- package/dist/route-services.d.ts +45 -2
- package/dist/route-services.js +70 -3
- package/dist/routes-v1.js +113 -65
- package/dist/routes-v2.js +407 -95
- package/dist/server.d.ts +9 -1
- package/dist/server.js +109 -9
- package/dist/session-commands.d.ts +12 -15
- package/dist/session-commands.js +54 -13
- package/dist/session-diff.d.ts +1 -1
- package/dist/session-diff.js +10 -2
- package/dist/session-history.d.ts +1 -1
- package/dist/session-history.js +110 -44
- package/dist/session-hosts.js +2 -2
- package/dist/session-store.d.ts +8 -4
- package/dist/session-store.js +191 -38
- package/dist/session-tree.d.ts +10 -2
- package/dist/session-tree.js +55 -26
- package/dist/sessions.d.ts +32 -5
- package/dist/sessions.js +331 -120
- package/dist/sse.d.ts +16 -6
- package/dist/sse.js +77 -21
- package/dist/subagents.d.ts +5 -0
- package/dist/subagents.js +84 -71
- package/dist/tool-map.d.ts +3 -2
- package/dist/types.d.ts +152 -1
- package/dist/v2.d.ts +18 -4
- package/dist/v2.js +128 -44
- package/dist/vcs.d.ts +5 -0
- package/dist/vcs.js +199 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/omo-dag/README.md +2 -0
- package/extensions/omo-dag/docs/DESIGN.md +10 -1
- package/extensions/omo-dag/panel/index.html +1 -1
- package/extensions/omo-dag/panel/main.js +60 -60
- package/extensions/omo-dag/service/main.js +72 -8
- package/package.json +1 -1
package/dist/pi-rpc.js
CHANGED
|
@@ -11,6 +11,7 @@ import { spawn } from "node:child_process";
|
|
|
11
11
|
import { StringDecoder } from "node:string_decoder";
|
|
12
12
|
import { EventEmitter } from "node:events";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
14
15
|
import { systemClock } from "./lifecycle.js";
|
|
15
16
|
import { agentBinary, envSetting } from "./config.js";
|
|
16
17
|
export const RPC_COMMAND_TIMEOUT_MS = 60_000;
|
|
@@ -20,8 +21,42 @@ export const RPC_COMMAND_TIMEOUT_MS = 60_000;
|
|
|
20
21
|
* long before a large session finished, leaving the run orphaned.
|
|
21
22
|
*/
|
|
22
23
|
export const COMPACT_RPC_TIMEOUT_MS = 31 * 60_000;
|
|
23
|
-
|
|
24
|
+
const MAX_JSONL_FRAME_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
export class UpstreamProtocolError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "UpstreamProtocolError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
24
31
|
const PROCESS_KILL_GRACE_MS = 5_000;
|
|
32
|
+
// Linux /proc. Any read failure (gone, no permission, no /proc) means the identity cannot be
|
|
33
|
+
// verified, and an unverifiable process is never signalled: fail closed.
|
|
34
|
+
const procTable = {
|
|
35
|
+
identity(pid) {
|
|
36
|
+
let stat;
|
|
37
|
+
try {
|
|
38
|
+
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
44
|
+
return { pid, group: Number(fields[2]), start: fields[19] ?? "" };
|
|
45
|
+
},
|
|
46
|
+
groupMembers(group) {
|
|
47
|
+
let names;
|
|
48
|
+
try {
|
|
49
|
+
names = readdirSync("/proc");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return names
|
|
55
|
+
.filter((name) => /^\d+$/.test(name))
|
|
56
|
+
.flatMap((name) => procTable.identity(Number(name)) ?? [])
|
|
57
|
+
.filter((member) => member.group === group);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
25
60
|
export class PiRpcClient extends EventEmitter {
|
|
26
61
|
options;
|
|
27
62
|
proc;
|
|
@@ -75,6 +110,9 @@ export class PiRpcClient extends EventEmitter {
|
|
|
75
110
|
this.proc.stderr.on("data", (chunk) => {
|
|
76
111
|
this.stderrTail = (this.stderrTail + chunk.toString()).slice(-8192);
|
|
77
112
|
});
|
|
113
|
+
this.proc.stdin?.on("error", (error) => {
|
|
114
|
+
this.closeForTransportFailure(error);
|
|
115
|
+
});
|
|
78
116
|
this.proc.stdout.on("data", (chunk) => {
|
|
79
117
|
const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
|
|
80
118
|
this.buffer += text;
|
|
@@ -86,11 +124,10 @@ export class PiRpcClient extends EventEmitter {
|
|
|
86
124
|
this.buffer += text;
|
|
87
125
|
this.bufferBytes += Buffer.byteLength(text);
|
|
88
126
|
this.drain();
|
|
89
|
-
this.
|
|
127
|
+
this.closeForTransportFailure(new Error("pi process closed stdout"));
|
|
90
128
|
});
|
|
91
129
|
this.proc.on("error", (err) => {
|
|
92
|
-
this.
|
|
93
|
-
this.failAll(err);
|
|
130
|
+
this.closeForTransportFailure(err);
|
|
94
131
|
});
|
|
95
132
|
this.proc.on("exit", (code, signal) => {
|
|
96
133
|
this.closed = true;
|
|
@@ -132,9 +169,23 @@ export class PiRpcClient extends EventEmitter {
|
|
|
132
169
|
this.proc.stdout?.destroy();
|
|
133
170
|
if (this.proc.pid === undefined)
|
|
134
171
|
this.proc.kill("SIGKILL");
|
|
135
|
-
else
|
|
172
|
+
else if (!this.reaped())
|
|
136
173
|
process.kill(-this.proc.pid, "SIGKILL");
|
|
137
174
|
}
|
|
175
|
+
/** Node has reaped the leader, so its PID (and group ID) may already belong to another process. */
|
|
176
|
+
reaped() {
|
|
177
|
+
return this.proc.exitCode !== null || this.proc.signalCode !== null;
|
|
178
|
+
}
|
|
179
|
+
closeForTransportFailure(error) {
|
|
180
|
+
if (this.closed)
|
|
181
|
+
return;
|
|
182
|
+
this.closed = true;
|
|
183
|
+
this.failAll(error);
|
|
184
|
+
void this.dispose().catch((disposeError) => {
|
|
185
|
+
const message = disposeError instanceof Error ? disposeError.message : String(disposeError);
|
|
186
|
+
console.error(`[pi-rpc] failed to dispose after transport failure: ${message}`);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
138
189
|
handleLine(line) {
|
|
139
190
|
let value;
|
|
140
191
|
try {
|
|
@@ -158,6 +209,12 @@ export class PiRpcClient extends EventEmitter {
|
|
|
158
209
|
pending?.reject(error);
|
|
159
210
|
return;
|
|
160
211
|
}
|
|
212
|
+
if (pending !== undefined && pending.command !== msg.command) {
|
|
213
|
+
const error = new Error(`pi returned mismatched RPC response command "${msg.command}" for pending command "${pending.command}"`);
|
|
214
|
+
console.error(`[pi-rpc] ${error.message}`);
|
|
215
|
+
pending.reject(error);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
161
218
|
const response = msg.success
|
|
162
219
|
? { type: "response", id: msg.id, command: msg.command, success: true, data: msg.data }
|
|
163
220
|
: {
|
|
@@ -206,30 +263,31 @@ export class PiRpcClient extends EventEmitter {
|
|
|
206
263
|
if (signal?.aborted)
|
|
207
264
|
return Promise.reject(signal.reason);
|
|
208
265
|
const id = randomUUID();
|
|
266
|
+
const expectedCommand = String(cmd.type);
|
|
209
267
|
return new Promise((resolve, reject) => {
|
|
210
268
|
const cleanup = () => {
|
|
211
269
|
clearTimeout(timer);
|
|
212
270
|
signal?.removeEventListener("abort", onAbort);
|
|
213
271
|
this.pending.delete(id);
|
|
214
272
|
};
|
|
215
|
-
const
|
|
273
|
+
const rejectCommand = (reason) => {
|
|
216
274
|
cleanup();
|
|
217
|
-
reject(
|
|
275
|
+
reject(reason);
|
|
276
|
+
};
|
|
277
|
+
const onAbort = () => {
|
|
278
|
+
rejectCommand(signal?.reason);
|
|
218
279
|
};
|
|
219
280
|
const timer = setTimeout(() => {
|
|
220
|
-
|
|
221
|
-
reject(new Error(`pi command "${cmd.type}" timed out after ${timeoutMs}ms`));
|
|
281
|
+
this.closeForTransportFailure(new Error(`pi command "${expectedCommand}" timed out after ${timeoutMs}ms`));
|
|
222
282
|
}, timeoutMs);
|
|
223
283
|
this.pending.set(id, {
|
|
284
|
+
command: expectedCommand,
|
|
224
285
|
resolve: (r) => {
|
|
225
286
|
cleanup();
|
|
226
287
|
// The envelope is checked at the transport boundary; callers own data schemas.
|
|
227
288
|
resolve(r.success ? { ...r, data: r.data } : r);
|
|
228
289
|
},
|
|
229
|
-
reject:
|
|
230
|
-
cleanup();
|
|
231
|
-
reject(e);
|
|
232
|
-
},
|
|
290
|
+
reject: rejectCommand,
|
|
233
291
|
});
|
|
234
292
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
235
293
|
this.write({ ...cmd, id });
|
|
@@ -246,45 +304,54 @@ export class PiRpcClient extends EventEmitter {
|
|
|
246
304
|
this.closed = true;
|
|
247
305
|
this.failAll(new Error("pi process disposed"));
|
|
248
306
|
const pid = this.proc.pid;
|
|
249
|
-
if (pid === undefined)
|
|
307
|
+
if (pid === undefined || this.reaped())
|
|
250
308
|
return;
|
|
251
309
|
const clock = this.options.clock ?? systemClock;
|
|
252
|
-
const
|
|
310
|
+
const table = this.options.processTable ?? procTable;
|
|
311
|
+
const signal = (target, sig) => {
|
|
253
312
|
try {
|
|
254
|
-
process.kill(
|
|
255
|
-
return true;
|
|
313
|
+
process.kill(target, sig);
|
|
256
314
|
}
|
|
257
315
|
catch (error) {
|
|
258
316
|
if (error instanceof Error && "code" in error && error.code === "ESRCH")
|
|
259
|
-
return
|
|
317
|
+
return;
|
|
260
318
|
throw error;
|
|
261
319
|
}
|
|
262
320
|
};
|
|
321
|
+
// Until Node reaps the leader its PID, and therefore the group ID, cannot be reused, so group
|
|
322
|
+
// signals are safe only before "exit". Record the members now; after the leader exits, a
|
|
323
|
+
// grandchild is killed only when its PID still has the same group and start time.
|
|
324
|
+
const owned = table.groupMembers(pid).filter((member) => member.pid !== pid);
|
|
325
|
+
const survivors = () => owned
|
|
326
|
+
.filter((member) => {
|
|
327
|
+
const now = table.identity(member.pid);
|
|
328
|
+
return now !== undefined && now.group === member.group && now.start === member.start;
|
|
329
|
+
})
|
|
330
|
+
.map((member) => member.pid);
|
|
263
331
|
this.disposing = new Promise((resolve) => {
|
|
264
|
-
let exited = this.proc.exitCode !== null || this.proc.signalCode !== null;
|
|
265
332
|
const finish = () => {
|
|
266
333
|
clock.clearTimeout(timer);
|
|
267
334
|
this.proc.off("exit", onExit);
|
|
268
335
|
resolve();
|
|
269
336
|
};
|
|
270
337
|
const onExit = () => {
|
|
271
|
-
|
|
272
|
-
// A grandchild may outlive pi. Keep the escalation armed for its group.
|
|
273
|
-
if (!signalGroup(0))
|
|
338
|
+
if (survivors().length === 0)
|
|
274
339
|
finish();
|
|
275
340
|
};
|
|
276
341
|
this.proc.once("exit", onExit);
|
|
277
342
|
const timer = clock.setTimeout(() => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
else
|
|
343
|
+
if (!this.reaped()) {
|
|
344
|
+
signal(-pid, "SIGKILL");
|
|
345
|
+
this.proc.off("exit", onExit);
|
|
282
346
|
this.proc.once("exit", finish);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
for (const member of survivors())
|
|
350
|
+
signal(member, "SIGKILL");
|
|
351
|
+
finish();
|
|
283
352
|
}, PROCESS_KILL_GRACE_MS);
|
|
284
|
-
|
|
353
|
+
signal(-pid, "SIGTERM");
|
|
285
354
|
this.proc.stdin?.end();
|
|
286
|
-
if (!alive && exited)
|
|
287
|
-
finish();
|
|
288
355
|
});
|
|
289
356
|
return this.disposing;
|
|
290
357
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { OCPromptPart } from "./types.js";
|
|
2
|
+
export declare function isPromptPart(value: unknown): value is OCPromptPart;
|
|
3
|
+
export declare function parsePromptParts(value: unknown): OCPromptPart[];
|
|
4
|
+
/** OpenChamber 2.x attachments (`files: [{uri, name?}]`, `agents: [{name, mention?}]`) as prompt parts. */
|
|
5
|
+
export declare function v2PromptParts(text: string, files: unknown, agents: unknown): OCPromptPart[];
|
|
6
|
+
/** The v2 wire form of a prompt's attachments, as OpenChamber 2.x projects them back into parts. */
|
|
7
|
+
export declare function v2PromptAttachments(parts: readonly OCPromptPart[]): {
|
|
8
|
+
files?: {
|
|
9
|
+
mime: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
source: {
|
|
12
|
+
type: string;
|
|
13
|
+
uri: string;
|
|
14
|
+
};
|
|
15
|
+
}[];
|
|
16
|
+
agents?: {
|
|
17
|
+
name: string;
|
|
18
|
+
mention?: {
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
text: string;
|
|
22
|
+
};
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
/** Native custom-message type for no-reply context (v2 synthetic, v1 noReply) admitted without a turn. */
|
|
26
|
+
export declare const CONTEXT_MESSAGE_TYPE = "antechamber.context";
|
|
27
|
+
/** The OpenChamber message id of a native context entry, if the entry is one. */
|
|
28
|
+
export declare function contextMessageID(entry: {
|
|
29
|
+
type?: string;
|
|
30
|
+
customType?: string;
|
|
31
|
+
details?: unknown;
|
|
32
|
+
}): string | undefined;
|
|
33
|
+
export declare function nativePromptInput(text: string, parts: readonly OCPromptPart[]): {
|
|
34
|
+
message: string;
|
|
35
|
+
images?: {
|
|
36
|
+
type: "image";
|
|
37
|
+
data: string;
|
|
38
|
+
mimeType: string;
|
|
39
|
+
}[];
|
|
40
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Lossless user parts on the wire, adapted to the native text/images RPC. */
|
|
2
|
+
import { RequestBodyError } from "./http.js";
|
|
3
|
+
export function isPromptPart(value) {
|
|
4
|
+
if (typeof value !== "object" || value === null || !("type" in value))
|
|
5
|
+
return false;
|
|
6
|
+
if ("id" in value && typeof value.id !== "string")
|
|
7
|
+
return false;
|
|
8
|
+
switch (value.type) {
|
|
9
|
+
case "text":
|
|
10
|
+
return ("text" in value &&
|
|
11
|
+
typeof value.text === "string" &&
|
|
12
|
+
(!("synthetic" in value) || typeof value.synthetic === "boolean"));
|
|
13
|
+
case "file":
|
|
14
|
+
return ("mime" in value &&
|
|
15
|
+
typeof value.mime === "string" &&
|
|
16
|
+
"url" in value &&
|
|
17
|
+
typeof value.url === "string" &&
|
|
18
|
+
(!("filename" in value) || typeof value.filename === "string"));
|
|
19
|
+
case "agent":
|
|
20
|
+
return ("name" in value &&
|
|
21
|
+
typeof value.name === "string" &&
|
|
22
|
+
(!("source" in value) ||
|
|
23
|
+
(typeof value.source === "object" &&
|
|
24
|
+
value.source !== null &&
|
|
25
|
+
"value" in value.source &&
|
|
26
|
+
typeof value.source.value === "string" &&
|
|
27
|
+
"start" in value.source &&
|
|
28
|
+
typeof value.source.start === "number" &&
|
|
29
|
+
"end" in value.source &&
|
|
30
|
+
typeof value.source.end === "number")));
|
|
31
|
+
default:
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function parsePromptParts(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return [];
|
|
38
|
+
if (!Array.isArray(value) || !value.every(isPromptPart))
|
|
39
|
+
throw new RequestBodyError(400, "parts must contain supported text, file, or agent inputs");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function plainRecord(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
/** OpenChamber 2.x attachments (`files: [{uri, name?}]`, `agents: [{name, mention?}]`) as prompt parts. */
|
|
46
|
+
export function v2PromptParts(text, files, agents) {
|
|
47
|
+
const parts = text === "" ? [] : [{ type: "text", text }];
|
|
48
|
+
if (files !== undefined) {
|
|
49
|
+
if (!Array.isArray(files))
|
|
50
|
+
throw new RequestBodyError(400, "files must be an array");
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
if (!plainRecord(file) ||
|
|
53
|
+
typeof file.uri !== "string" ||
|
|
54
|
+
(file.name !== undefined && typeof file.name !== "string"))
|
|
55
|
+
throw new RequestBodyError(400, "files must contain {uri, name?} attachments");
|
|
56
|
+
// v2 carries no MIME type; an inline data URI still names its own.
|
|
57
|
+
const mime = /^data:([^;,]+)/.exec(file.uri)?.[1] ?? "application/octet-stream";
|
|
58
|
+
parts.push({
|
|
59
|
+
type: "file",
|
|
60
|
+
mime,
|
|
61
|
+
url: file.uri,
|
|
62
|
+
...(typeof file.name === "string" ? { filename: file.name } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (agents !== undefined) {
|
|
67
|
+
if (!Array.isArray(agents))
|
|
68
|
+
throw new RequestBodyError(400, "agents must be an array");
|
|
69
|
+
for (const agent of agents) {
|
|
70
|
+
const mention = plainRecord(agent) ? agent.mention : undefined;
|
|
71
|
+
if (!plainRecord(agent) ||
|
|
72
|
+
typeof agent.name !== "string" ||
|
|
73
|
+
(mention !== undefined &&
|
|
74
|
+
(!plainRecord(mention) ||
|
|
75
|
+
typeof mention.start !== "number" ||
|
|
76
|
+
typeof mention.end !== "number" ||
|
|
77
|
+
typeof mention.text !== "string")))
|
|
78
|
+
throw new RequestBodyError(400, "agents must contain {name, mention?} references");
|
|
79
|
+
parts.push({
|
|
80
|
+
type: "agent",
|
|
81
|
+
name: agent.name,
|
|
82
|
+
...(plainRecord(mention) &&
|
|
83
|
+
typeof mention.text === "string" &&
|
|
84
|
+
typeof mention.start === "number" &&
|
|
85
|
+
typeof mention.end === "number"
|
|
86
|
+
? { source: { value: mention.text, start: mention.start, end: mention.end } }
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return parts;
|
|
92
|
+
}
|
|
93
|
+
/** The v2 wire form of a prompt's attachments, as OpenChamber 2.x projects them back into parts. */
|
|
94
|
+
export function v2PromptAttachments(parts) {
|
|
95
|
+
const files = parts.flatMap((part) => part.type === "file"
|
|
96
|
+
? [
|
|
97
|
+
{
|
|
98
|
+
mime: part.mime,
|
|
99
|
+
...(part.filename !== undefined ? { name: part.filename } : {}),
|
|
100
|
+
source: { type: "uri", uri: part.url },
|
|
101
|
+
},
|
|
102
|
+
]
|
|
103
|
+
: []);
|
|
104
|
+
const agents = parts.flatMap((part) => part.type === "agent"
|
|
105
|
+
? [
|
|
106
|
+
{
|
|
107
|
+
name: part.name,
|
|
108
|
+
...(part.source !== undefined
|
|
109
|
+
? { mention: { start: part.source.start, end: part.source.end, text: part.source.value } }
|
|
110
|
+
: {}),
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
: []);
|
|
114
|
+
return { ...(files.length > 0 ? { files } : {}), ...(agents.length > 0 ? { agents } : {}) };
|
|
115
|
+
}
|
|
116
|
+
/** Native custom-message type for no-reply context (v2 synthetic, v1 noReply) admitted without a turn. */
|
|
117
|
+
export const CONTEXT_MESSAGE_TYPE = "antechamber.context";
|
|
118
|
+
/** The OpenChamber message id of a native context entry, if the entry is one. */
|
|
119
|
+
export function contextMessageID(entry) {
|
|
120
|
+
if (entry.type !== "custom_message" || entry.customType !== CONTEXT_MESSAGE_TYPE || !plainRecord(entry.details))
|
|
121
|
+
return undefined;
|
|
122
|
+
return typeof entry.details.messageID === "string" ? entry.details.messageID : undefined;
|
|
123
|
+
}
|
|
124
|
+
export function nativePromptInput(text, parts) {
|
|
125
|
+
const chunks = [text];
|
|
126
|
+
const images = [];
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
switch (part.type) {
|
|
129
|
+
case "text":
|
|
130
|
+
break;
|
|
131
|
+
case "agent":
|
|
132
|
+
chunks.push(`@${part.name}`);
|
|
133
|
+
break;
|
|
134
|
+
case "file": {
|
|
135
|
+
const data = /^data:([^;,]*)(;base64)?,([\s\S]*)$/.exec(part.url);
|
|
136
|
+
if (data !== null) {
|
|
137
|
+
const content = data[2]
|
|
138
|
+
? Buffer.from(data[3] ?? "", "base64")
|
|
139
|
+
: Buffer.from(decodeURIComponent(data[3] ?? ""));
|
|
140
|
+
if (part.mime.startsWith("image/"))
|
|
141
|
+
images.push({ type: "image", data: content.toString("base64"), mimeType: part.mime });
|
|
142
|
+
else if (part.mime.startsWith("text/"))
|
|
143
|
+
chunks.push(`${part.filename ?? "attachment"} (${part.mime}):\n${content.toString("utf8")}`);
|
|
144
|
+
else
|
|
145
|
+
throw new RequestBodyError(400, `Unsupported inline attachment MIME type: ${part.mime}`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Native RPC has no generic FilePart; retain the reference in model
|
|
149
|
+
// context so the agent can read the selected workspace file.
|
|
150
|
+
chunks.push(JSON.stringify({ filename: part.filename, mime: part.mime, url: part.url }));
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { message: chunks.join("\n\n"), ...(images.length > 0 ? { images } : {}) };
|
|
157
|
+
}
|
package/dist/route-services.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
|
|
|
3
3
|
import type { SessionManager, ManagedSession } from "./sessions.js";
|
|
4
4
|
import type { Catalog } from "./catalog.js";
|
|
5
5
|
import type { ModelSelection } from "./thinking-levels.js";
|
|
6
|
-
import type { OCEvent } from "./types.js";
|
|
6
|
+
import type { OCEvent, OCMessageWithParts, OCPromptPart } from "./types.js";
|
|
7
7
|
import type { TodoUpdated } from "./todo-bridge.js";
|
|
8
8
|
export interface RouteServices {
|
|
9
9
|
readonly sessions: SessionManager;
|
|
@@ -15,6 +15,8 @@ export interface RouteServices {
|
|
|
15
15
|
}
|
|
16
16
|
export declare const OPENCODE_COMPAT_VERSION: string | null;
|
|
17
17
|
export declare function numberParam(value: string | null): number | undefined;
|
|
18
|
+
export declare function sessionDirectory(value: unknown): string;
|
|
19
|
+
export declare function sessionMetadata(value: unknown): Record<string, unknown> | undefined;
|
|
18
20
|
/** v2 prompt bodies carry `model:{providerID, id|modelID}`; adapt to OCModelRef. */
|
|
19
21
|
export declare function modelRefFromBody(model: {
|
|
20
22
|
providerID?: string;
|
|
@@ -23,4 +25,45 @@ export declare function modelRefFromBody(model: {
|
|
|
23
25
|
variant?: string;
|
|
24
26
|
} | undefined, variant?: string): ModelSelection | undefined;
|
|
25
27
|
/** Both async APIs acknowledge admission; failures must reach SSE and history. */
|
|
26
|
-
export declare function promptAsync(services: RouteServices, session: ManagedSession, text: string, model?: ModelSelection, noReply?: boolean, messageID?: string, delivery?: "steer" | "queue"): void;
|
|
28
|
+
export declare function promptAsync(services: RouteServices, session: ManagedSession, text: string, model?: ModelSelection, noReply?: boolean, messageID?: string, delivery?: "steer" | "queue", parts?: readonly OCPromptPart[], metadata?: Record<string, unknown>): void;
|
|
29
|
+
export type ShellRouteResult = {
|
|
30
|
+
kind: "input";
|
|
31
|
+
} | {
|
|
32
|
+
kind: "busy";
|
|
33
|
+
} | {
|
|
34
|
+
kind: "error";
|
|
35
|
+
message: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "success";
|
|
38
|
+
message: OCMessageWithParts;
|
|
39
|
+
};
|
|
40
|
+
/** Run a shell command consistently while leaving each API version to shape its response envelope. */
|
|
41
|
+
export declare function runSessionShell(services: RouteServices, session: ManagedSession, body: unknown): Promise<ShellRouteResult>;
|
|
42
|
+
export type SlashRouteResult = {
|
|
43
|
+
kind: "input";
|
|
44
|
+
} | {
|
|
45
|
+
kind: "success";
|
|
46
|
+
message: OCMessageWithParts | undefined;
|
|
47
|
+
};
|
|
48
|
+
/** Parse and dispatch slash commands once for both protocol versions. */
|
|
49
|
+
export declare function runSessionSlash(services: RouteServices, sessionID: string, body: unknown): Promise<SlashRouteResult>;
|
|
50
|
+
export declare function agentDescriptors(services: RouteServices): Promise<{
|
|
51
|
+
model?: {
|
|
52
|
+
providerID: string;
|
|
53
|
+
modelID: string;
|
|
54
|
+
};
|
|
55
|
+
variant?: string;
|
|
56
|
+
name: string;
|
|
57
|
+
description: string;
|
|
58
|
+
mode: string;
|
|
59
|
+
hidden: boolean;
|
|
60
|
+
options: {};
|
|
61
|
+
native: boolean;
|
|
62
|
+
permission: {
|
|
63
|
+
permission: string;
|
|
64
|
+
pattern: string;
|
|
65
|
+
action: string;
|
|
66
|
+
}[];
|
|
67
|
+
prompt: string;
|
|
68
|
+
temperature: number;
|
|
69
|
+
}[]>;
|
package/dist/route-services.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** Shared services and prompt/model adaptation for both API versions. */
|
|
2
|
-
import {
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { RequestBodyError } from "./http.js";
|
|
4
|
+
import { getAgentName, getExposedAgentNames } from "./agent-name.js";
|
|
5
|
+
import { agentDefaults } from "./catalog.js";
|
|
6
|
+
import { announceSessionError, parseShellCommand, parseSlashCommand, runShellCommand, runSlashCommand, } from "./session-commands.js";
|
|
3
7
|
import { envSetting } from "./config.js";
|
|
4
8
|
// This connector is not opencode — it only implements an OpenCode API subset.
|
|
5
9
|
// OpenChamber reads /global/health's `version` as the installed opencode version
|
|
@@ -15,6 +19,27 @@ export function numberParam(value) {
|
|
|
15
19
|
const n = Number(value);
|
|
16
20
|
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
17
21
|
}
|
|
22
|
+
export function sessionDirectory(value) {
|
|
23
|
+
if (typeof value !== "string" || value.trim() === "" || value.includes("\0"))
|
|
24
|
+
throw new RequestBodyError(400, "directory must be a nonempty path");
|
|
25
|
+
try {
|
|
26
|
+
if (!statSync(value).isDirectory())
|
|
27
|
+
throw new RequestBodyError(400, "directory does not exist or is not a directory");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"))
|
|
31
|
+
throw new RequestBodyError(400, "directory does not exist or is not a directory");
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
export function sessionMetadata(value) {
|
|
37
|
+
if (value === undefined)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
40
|
+
throw new RequestBodyError(400, "metadata must be an object");
|
|
41
|
+
return Object.fromEntries(Object.entries(value));
|
|
42
|
+
}
|
|
18
43
|
/** v2 prompt bodies carry `model:{providerID, id|modelID}`; adapt to OCModelRef. */
|
|
19
44
|
export function modelRefFromBody(model, variant) {
|
|
20
45
|
if (!model || typeof model.providerID !== "string")
|
|
@@ -26,8 +51,50 @@ export function modelRefFromBody(model, variant) {
|
|
|
26
51
|
return { providerID: model.providerID, modelID, ...(typeof level === "string" ? { variant: level } : {}) };
|
|
27
52
|
}
|
|
28
53
|
/** Both async APIs acknowledge admission; failures must reach SSE and history. */
|
|
29
|
-
export function promptAsync(services, session, text, model, noReply = false, messageID, delivery = "steer") {
|
|
30
|
-
void services.sessions
|
|
54
|
+
export function promptAsync(services, session, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
|
|
55
|
+
void services.sessions
|
|
56
|
+
.prompt(session.info.id, text, model, noReply, messageID, delivery, parts, metadata)
|
|
57
|
+
.catch((error) => {
|
|
31
58
|
announceSessionError({ sessions: services.sessions, session, emit: services.broadcast }, error instanceof Error ? error.name : "UnknownError", error instanceof Error ? error.message : String(error));
|
|
32
59
|
});
|
|
33
60
|
}
|
|
61
|
+
/** Run a shell command consistently while leaving each API version to shape its response envelope. */
|
|
62
|
+
export async function runSessionShell(services, session, body) {
|
|
63
|
+
const command = parseShellCommand(body);
|
|
64
|
+
if (command === undefined)
|
|
65
|
+
return { kind: "input" };
|
|
66
|
+
if (session.status.type !== "idle")
|
|
67
|
+
return { kind: "busy" };
|
|
68
|
+
const result = await runShellCommand({
|
|
69
|
+
sessions: services.sessions,
|
|
70
|
+
session,
|
|
71
|
+
command,
|
|
72
|
+
emit: services.broadcast,
|
|
73
|
+
});
|
|
74
|
+
return result.kind === "error" ? result : { kind: "success", message: result.message };
|
|
75
|
+
}
|
|
76
|
+
/** Parse and dispatch slash commands once for both protocol versions. */
|
|
77
|
+
export async function runSessionSlash(services, sessionID, body) {
|
|
78
|
+
const input = parseSlashCommand(body);
|
|
79
|
+
if (input === undefined)
|
|
80
|
+
return { kind: "input" };
|
|
81
|
+
return { kind: "success", message: await runSlashCommand(services.sessions, sessionID, input) };
|
|
82
|
+
}
|
|
83
|
+
export async function agentDescriptors(services) {
|
|
84
|
+
const piName = getAgentName();
|
|
85
|
+
const defaults = agentDefaults(await services.catalog());
|
|
86
|
+
return getExposedAgentNames().map((name) => ({
|
|
87
|
+
name,
|
|
88
|
+
description: name === piName
|
|
89
|
+
? `${piName} (pi coding agent via antechamber)`
|
|
90
|
+
: `${name} — ${piName} (pi coding agent via antechamber)`,
|
|
91
|
+
mode: "primary",
|
|
92
|
+
hidden: name === "build" && piName !== "build",
|
|
93
|
+
options: {},
|
|
94
|
+
native: true,
|
|
95
|
+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
96
|
+
prompt: "",
|
|
97
|
+
temperature: 0,
|
|
98
|
+
...defaults,
|
|
99
|
+
}));
|
|
100
|
+
}
|