antechamber 0.1.0 → 0.2.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/CHANGELOG.md +51 -0
- package/README.md +17 -14
- package/bin/opencode-antechamber +10 -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 +21 -3
- package/dist/pi-event-translation.js +317 -65
- package/dist/pi-rpc.d.ts +26 -2
- package/dist/pi-rpc.js +102 -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 +110 -65
- package/dist/routes-v2.js +376 -73
- package/dist/server.d.ts +9 -1
- package/dist/server.js +109 -9
- package/dist/session-commands.d.ts +20 -15
- package/dist/session-commands.js +61 -11
- package/dist/session-diff.d.ts +1 -1
- package/dist/session-diff.js +10 -2
- package/dist/session-history.js +113 -42
- package/dist/session-hosts.js +2 -2
- package/dist/session-store.d.ts +8 -4
- package/dist/session-store.js +183 -33
- package/dist/session-tree.d.ts +9 -2
- package/dist/session-tree.js +51 -63
- package/dist/sessions.d.ts +34 -6
- package/dist/sessions.js +340 -146
- package/dist/sse.d.ts +16 -6
- package/dist/sse.js +77 -21
- package/dist/subagents.d.ts +5 -0
- package/dist/subagents.js +86 -69
- package/dist/tool-map.d.ts +3 -2
- package/dist/types.d.ts +164 -1
- package/dist/v2.d.ts +18 -4
- package/dist/v2.js +141 -50
- 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,11 +11,52 @@ 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;
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `compact` answers only when the summary is written. OmO bounds one compaction by
|
|
20
|
+
* its SUMMARIZATION_MAX_DURATION_CAP_MS (30 min); the generic 60 s budget gave up
|
|
21
|
+
* long before a large session finished, leaving the run orphaned.
|
|
22
|
+
*/
|
|
23
|
+
export const COMPACT_RPC_TIMEOUT_MS = 31 * 60_000;
|
|
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
|
+
}
|
|
18
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
|
+
};
|
|
19
60
|
export class PiRpcClient extends EventEmitter {
|
|
20
61
|
options;
|
|
21
62
|
proc;
|
|
@@ -69,6 +110,9 @@ export class PiRpcClient extends EventEmitter {
|
|
|
69
110
|
this.proc.stderr.on("data", (chunk) => {
|
|
70
111
|
this.stderrTail = (this.stderrTail + chunk.toString()).slice(-8192);
|
|
71
112
|
});
|
|
113
|
+
this.proc.stdin?.on("error", (error) => {
|
|
114
|
+
this.closeForTransportFailure(error);
|
|
115
|
+
});
|
|
72
116
|
this.proc.stdout.on("data", (chunk) => {
|
|
73
117
|
const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
|
|
74
118
|
this.buffer += text;
|
|
@@ -80,11 +124,10 @@ export class PiRpcClient extends EventEmitter {
|
|
|
80
124
|
this.buffer += text;
|
|
81
125
|
this.bufferBytes += Buffer.byteLength(text);
|
|
82
126
|
this.drain();
|
|
83
|
-
this.
|
|
127
|
+
this.closeForTransportFailure(new Error("pi process closed stdout"));
|
|
84
128
|
});
|
|
85
129
|
this.proc.on("error", (err) => {
|
|
86
|
-
this.
|
|
87
|
-
this.failAll(err);
|
|
130
|
+
this.closeForTransportFailure(err);
|
|
88
131
|
});
|
|
89
132
|
this.proc.on("exit", (code, signal) => {
|
|
90
133
|
this.closed = true;
|
|
@@ -126,9 +169,23 @@ export class PiRpcClient extends EventEmitter {
|
|
|
126
169
|
this.proc.stdout?.destroy();
|
|
127
170
|
if (this.proc.pid === undefined)
|
|
128
171
|
this.proc.kill("SIGKILL");
|
|
129
|
-
else
|
|
172
|
+
else if (!this.reaped())
|
|
130
173
|
process.kill(-this.proc.pid, "SIGKILL");
|
|
131
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
|
+
}
|
|
132
189
|
handleLine(line) {
|
|
133
190
|
let value;
|
|
134
191
|
try {
|
|
@@ -152,6 +209,12 @@ export class PiRpcClient extends EventEmitter {
|
|
|
152
209
|
pending?.reject(error);
|
|
153
210
|
return;
|
|
154
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
|
+
}
|
|
155
218
|
const response = msg.success
|
|
156
219
|
? { type: "response", id: msg.id, command: msg.command, success: true, data: msg.data }
|
|
157
220
|
: {
|
|
@@ -200,30 +263,31 @@ export class PiRpcClient extends EventEmitter {
|
|
|
200
263
|
if (signal?.aborted)
|
|
201
264
|
return Promise.reject(signal.reason);
|
|
202
265
|
const id = randomUUID();
|
|
266
|
+
const expectedCommand = String(cmd.type);
|
|
203
267
|
return new Promise((resolve, reject) => {
|
|
204
268
|
const cleanup = () => {
|
|
205
269
|
clearTimeout(timer);
|
|
206
270
|
signal?.removeEventListener("abort", onAbort);
|
|
207
271
|
this.pending.delete(id);
|
|
208
272
|
};
|
|
209
|
-
const
|
|
273
|
+
const rejectCommand = (reason) => {
|
|
210
274
|
cleanup();
|
|
211
|
-
reject(
|
|
275
|
+
reject(reason);
|
|
276
|
+
};
|
|
277
|
+
const onAbort = () => {
|
|
278
|
+
rejectCommand(signal?.reason);
|
|
212
279
|
};
|
|
213
280
|
const timer = setTimeout(() => {
|
|
214
|
-
|
|
215
|
-
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`));
|
|
216
282
|
}, timeoutMs);
|
|
217
283
|
this.pending.set(id, {
|
|
284
|
+
command: expectedCommand,
|
|
218
285
|
resolve: (r) => {
|
|
219
286
|
cleanup();
|
|
220
287
|
// The envelope is checked at the transport boundary; callers own data schemas.
|
|
221
288
|
resolve(r.success ? { ...r, data: r.data } : r);
|
|
222
289
|
},
|
|
223
|
-
reject:
|
|
224
|
-
cleanup();
|
|
225
|
-
reject(e);
|
|
226
|
-
},
|
|
290
|
+
reject: rejectCommand,
|
|
227
291
|
});
|
|
228
292
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
229
293
|
this.write({ ...cmd, id });
|
|
@@ -240,45 +304,54 @@ export class PiRpcClient extends EventEmitter {
|
|
|
240
304
|
this.closed = true;
|
|
241
305
|
this.failAll(new Error("pi process disposed"));
|
|
242
306
|
const pid = this.proc.pid;
|
|
243
|
-
if (pid === undefined)
|
|
307
|
+
if (pid === undefined || this.reaped())
|
|
244
308
|
return;
|
|
245
309
|
const clock = this.options.clock ?? systemClock;
|
|
246
|
-
const
|
|
310
|
+
const table = this.options.processTable ?? procTable;
|
|
311
|
+
const signal = (target, sig) => {
|
|
247
312
|
try {
|
|
248
|
-
process.kill(
|
|
249
|
-
return true;
|
|
313
|
+
process.kill(target, sig);
|
|
250
314
|
}
|
|
251
315
|
catch (error) {
|
|
252
316
|
if (error instanceof Error && "code" in error && error.code === "ESRCH")
|
|
253
|
-
return
|
|
317
|
+
return;
|
|
254
318
|
throw error;
|
|
255
319
|
}
|
|
256
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);
|
|
257
331
|
this.disposing = new Promise((resolve) => {
|
|
258
|
-
let exited = this.proc.exitCode !== null || this.proc.signalCode !== null;
|
|
259
332
|
const finish = () => {
|
|
260
333
|
clock.clearTimeout(timer);
|
|
261
334
|
this.proc.off("exit", onExit);
|
|
262
335
|
resolve();
|
|
263
336
|
};
|
|
264
337
|
const onExit = () => {
|
|
265
|
-
|
|
266
|
-
// A grandchild may outlive pi. Keep the escalation armed for its group.
|
|
267
|
-
if (!signalGroup(0))
|
|
338
|
+
if (survivors().length === 0)
|
|
268
339
|
finish();
|
|
269
340
|
};
|
|
270
341
|
this.proc.once("exit", onExit);
|
|
271
342
|
const timer = clock.setTimeout(() => {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
else
|
|
343
|
+
if (!this.reaped()) {
|
|
344
|
+
signal(-pid, "SIGKILL");
|
|
345
|
+
this.proc.off("exit", onExit);
|
|
276
346
|
this.proc.once("exit", finish);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
for (const member of survivors())
|
|
350
|
+
signal(member, "SIGKILL");
|
|
351
|
+
finish();
|
|
277
352
|
}, PROCESS_KILL_GRACE_MS);
|
|
278
|
-
|
|
353
|
+
signal(-pid, "SIGTERM");
|
|
279
354
|
this.proc.stdin?.end();
|
|
280
|
-
if (!alive && exited)
|
|
281
|
-
finish();
|
|
282
355
|
});
|
|
283
356
|
return this.disposing;
|
|
284
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
|
+
}
|