@zetaloop/chappie 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/LICENSE +21 -0
- package/README.md +34 -0
- package/docs/tools.md +133 -0
- package/package.json +54 -0
- package/src/broker.ts +448 -0
- package/src/delivery.ts +82 -0
- package/src/index.ts +34 -0
- package/src/instructions.md +7 -0
- package/src/ipc.ts +361 -0
- package/src/provider.ts +195 -0
- package/src/resources.ts +189 -0
- package/src/server.ts +296 -0
- package/src/session.ts +484 -0
- package/src/state.ts +89 -0
- package/src/stdio.ts +49 -0
- package/src/tools.ts +98 -0
- package/src/transfer.ts +152 -0
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import {
|
|
4
|
+
contentWithImageReferences,
|
|
5
|
+
resourceDescriptors,
|
|
6
|
+
} from "./resources.ts";
|
|
7
|
+
|
|
8
|
+
export interface DeliveryRecord {
|
|
9
|
+
id: string;
|
|
10
|
+
chatId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
sessionFile?: string;
|
|
13
|
+
toolCallIds: string[];
|
|
14
|
+
inlineResults?: ToolResultMessage[];
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolvedDelivery extends DeliveryRecord {
|
|
19
|
+
toolResults: ToolResultMessage[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolveDelivery(
|
|
23
|
+
delivery: DeliveryRecord,
|
|
24
|
+
): Promise<ResolvedDelivery> {
|
|
25
|
+
let toolResults = delivery.inlineResults ?? [];
|
|
26
|
+
if (delivery.sessionFile && delivery.toolCallIds.length > 0) {
|
|
27
|
+
const expected = new Set(delivery.toolCallIds);
|
|
28
|
+
const found = new Map<string, ToolResultMessage>();
|
|
29
|
+
for (const line of (await readFile(delivery.sessionFile, "utf8")).split(
|
|
30
|
+
"\n",
|
|
31
|
+
)) {
|
|
32
|
+
if (!line) continue;
|
|
33
|
+
const entry = JSON.parse(line) as {
|
|
34
|
+
type?: string;
|
|
35
|
+
message?: ToolResultMessage;
|
|
36
|
+
};
|
|
37
|
+
if (
|
|
38
|
+
entry.type === "message" &&
|
|
39
|
+
entry.message?.role === "toolResult" &&
|
|
40
|
+
expected.has(entry.message.toolCallId)
|
|
41
|
+
) {
|
|
42
|
+
found.set(entry.message.toolCallId, entry.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
toolResults = delivery.toolCallIds.flatMap((id) => {
|
|
46
|
+
const result = found.get(id);
|
|
47
|
+
return result ? [result] : [];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return { ...delivery, toolResults };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function deliveryContent(deliveries: ResolvedDelivery[]) {
|
|
54
|
+
return deliveries.flatMap((delivery) => [
|
|
55
|
+
{
|
|
56
|
+
type: "text" as const,
|
|
57
|
+
text: JSON.stringify({
|
|
58
|
+
deferredResult: delivery.id,
|
|
59
|
+
sessionId: delivery.sessionId,
|
|
60
|
+
error: delivery.error,
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
...delivery.toolResults.flatMap((result) => [
|
|
64
|
+
{
|
|
65
|
+
type: "text" as const,
|
|
66
|
+
text: JSON.stringify({
|
|
67
|
+
toolCallId: result.toolCallId,
|
|
68
|
+
toolName: result.toolName,
|
|
69
|
+
isError: result.isError,
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
...contentWithImageReferences(delivery.sessionId, result.content),
|
|
73
|
+
...resourceDescriptors(result.details).map((resource) => ({
|
|
74
|
+
type: "resource_link" as const,
|
|
75
|
+
uri: resource.uri,
|
|
76
|
+
name: resource.name,
|
|
77
|
+
mimeType: resource.mimeType,
|
|
78
|
+
size: resource.size,
|
|
79
|
+
})),
|
|
80
|
+
]),
|
|
81
|
+
]);
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
getAgentDir,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export default async function chappie(pi: ExtensionAPI): Promise<void> {
|
|
7
|
+
pi.registerFlag("chappie", {
|
|
8
|
+
description: "Serve Chappie over MCP",
|
|
9
|
+
type: "boolean",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
if (process.argv.includes("--chappie")) {
|
|
13
|
+
try {
|
|
14
|
+
const { serveChappie } = await import("./stdio.ts");
|
|
15
|
+
await serveChappie();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const [{ createChappieProvider }, { LocalSession }, { transfer }] =
|
|
23
|
+
await Promise.all([
|
|
24
|
+
import("./provider.ts"),
|
|
25
|
+
import("./session.ts"),
|
|
26
|
+
import("./transfer.ts"),
|
|
27
|
+
]);
|
|
28
|
+
const session = new LocalSession(pi, getAgentDir());
|
|
29
|
+
session.install();
|
|
30
|
+
pi.registerTool(transfer);
|
|
31
|
+
pi.registerProvider(
|
|
32
|
+
createChappieProvider((output, context) => session.start(output, context)),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Chappie connects this ChatGPT conversation to local Pi sessions. Call init before starting work. Omitting sessionId reuses the current binding or pairs with the next Pi session waiting for ChatGPT; specifying sessionId on init selects that Pi session as the new default. A sessionId on any other tool affects only that operation. Use sessions to inspect connected sessions without changing the binding.
|
|
2
|
+
|
|
3
|
+
Use chat to send one complete assistant message to Pi. Use read, bash, edit, and write directly. Use tools to inspect other active Pi tools and call to execute one or more of them as one native Pi batch. Separate calls remain separate Pi turns; use a call array when tools should share a batch.
|
|
4
|
+
|
|
5
|
+
Use transfer with paths and files to copy ChatGPT files into Pi. Omit files to expose existing Pi paths or chappie:// image references as MCP resources. Relative paths use the Pi working directory, existing targets require overwrite: true, and resource materialization may require host confirmation.
|
|
6
|
+
|
|
7
|
+
Tool replies may contain user input consumed by Pi or results from an earlier explicitly cancelled request. Continue from those results instead of repeating completed work. Use the local persistent-process facilities for operations that need to outlive one tool request.
|
package/src/ipc.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createConnection,
|
|
5
|
+
createServer,
|
|
6
|
+
type Server,
|
|
7
|
+
type Socket,
|
|
8
|
+
} from "node:net";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import type {
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
ToolCall,
|
|
13
|
+
ToolResultMessage,
|
|
14
|
+
UserMessage,
|
|
15
|
+
} from "@earendil-works/pi-ai";
|
|
16
|
+
import type {
|
|
17
|
+
SlashCommandInfo,
|
|
18
|
+
ToolInfo,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { DeliveryRecord } from "./delivery.ts";
|
|
21
|
+
import type { ResourceData } from "./resources.ts";
|
|
22
|
+
|
|
23
|
+
export type SessionStatus = "idle" | "ready" | "executing";
|
|
24
|
+
|
|
25
|
+
export interface SessionDescription {
|
|
26
|
+
id: string;
|
|
27
|
+
cwd: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
sessionFile?: string;
|
|
30
|
+
status: SessionStatus;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionInspection {
|
|
34
|
+
session: SessionDescription;
|
|
35
|
+
tools: ToolInfo[];
|
|
36
|
+
skills: SlashCommandInfo[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionInput {
|
|
40
|
+
id: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
message: UserMessage;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SessionResult =
|
|
46
|
+
| { inspection: SessionInspection; inputs: SessionInput[] }
|
|
47
|
+
| { message: AssistantMessage; inputs: SessionInput[] }
|
|
48
|
+
| {
|
|
49
|
+
message: AssistantMessage;
|
|
50
|
+
toolResults: ToolResultMessage[];
|
|
51
|
+
inputs: SessionInput[];
|
|
52
|
+
}
|
|
53
|
+
| { resource: ResourceData }
|
|
54
|
+
| { error: string };
|
|
55
|
+
|
|
56
|
+
export type SessionMessage =
|
|
57
|
+
| { type: "sync"; id: number; session: SessionDescription }
|
|
58
|
+
| { type: "unregister"; sessionId: string }
|
|
59
|
+
| { type: "delivery"; delivery: DeliveryRecord }
|
|
60
|
+
| ({ type: "result"; id: number } & SessionResult);
|
|
61
|
+
|
|
62
|
+
export type BrokerMessage =
|
|
63
|
+
| { type: "synced"; id: number; sessionId: string }
|
|
64
|
+
| { type: "stored"; id: string }
|
|
65
|
+
| { type: "inspect"; id: number; sessionId: string }
|
|
66
|
+
| {
|
|
67
|
+
type: "chat";
|
|
68
|
+
id: number;
|
|
69
|
+
chatId: string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
text: string;
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
type: "call";
|
|
75
|
+
id: number;
|
|
76
|
+
chatId: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
calls: ToolCall[];
|
|
79
|
+
}
|
|
80
|
+
| { type: "cancel"; id: number; sessionId: string }
|
|
81
|
+
| { type: "readResource"; id: number; sessionId: string; uri: string }
|
|
82
|
+
| { type: "ackInputs"; sessionId: string; ids: string[] };
|
|
83
|
+
|
|
84
|
+
export function ipcEndpoint(agentDir: string): string {
|
|
85
|
+
const directory = resolve(agentDir);
|
|
86
|
+
if (process.platform !== "win32") return join(directory, "chappie.sock");
|
|
87
|
+
const identity = directory.replaceAll("\\", "/").toLowerCase();
|
|
88
|
+
return String.raw`\\.\pipe\chappie-${createHash("sha256").update(identity).digest("hex").slice(0, 16)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class JsonLinePeer<Incoming, Outgoing> {
|
|
92
|
+
readonly #socket: Socket;
|
|
93
|
+
readonly #onMessage: (message: Incoming) => Promise<void> | void;
|
|
94
|
+
readonly #onClose: () => void;
|
|
95
|
+
#buffer = "";
|
|
96
|
+
#messages = Promise.resolve();
|
|
97
|
+
#writes = Promise.resolve();
|
|
98
|
+
#closed = false;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
socket: Socket,
|
|
102
|
+
onMessage: (message: Incoming) => Promise<void> | void,
|
|
103
|
+
onClose: () => void,
|
|
104
|
+
) {
|
|
105
|
+
this.#socket = socket;
|
|
106
|
+
this.#onMessage = onMessage;
|
|
107
|
+
this.#onClose = onClose;
|
|
108
|
+
socket.setEncoding("utf8");
|
|
109
|
+
socket.on("data", (chunk: string) => this.#receive(chunk));
|
|
110
|
+
socket.once("close", () => {
|
|
111
|
+
this.#closed = true;
|
|
112
|
+
this.#onClose();
|
|
113
|
+
});
|
|
114
|
+
socket.on("error", () => {});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get closed(): boolean {
|
|
118
|
+
return this.#closed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
send(message: Outgoing): Promise<void> {
|
|
122
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
123
|
+
const sent = this.#writes.then(
|
|
124
|
+
() =>
|
|
125
|
+
new Promise<void>((resolveWrite, rejectWrite) => {
|
|
126
|
+
if (this.#closed) {
|
|
127
|
+
rejectWrite(new Error("Chappie IPC connection is closed"));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.#socket.write(line, (error) => {
|
|
131
|
+
if (error) rejectWrite(error);
|
|
132
|
+
else resolveWrite();
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
this.#writes = sent.catch(() => {});
|
|
137
|
+
return sent;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
close(): void {
|
|
141
|
+
if (!this.#closed) this.#socket.end();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#receive(chunk: string): void {
|
|
145
|
+
this.#buffer += chunk;
|
|
146
|
+
let end = this.#buffer.indexOf("\n");
|
|
147
|
+
while (end !== -1) {
|
|
148
|
+
const line = this.#buffer.slice(0, end);
|
|
149
|
+
this.#buffer = this.#buffer.slice(end + 1);
|
|
150
|
+
if (line.length > 0) {
|
|
151
|
+
this.#messages = this.#messages
|
|
152
|
+
.then(() => this.#onMessage(JSON.parse(line) as Incoming))
|
|
153
|
+
.catch((error: unknown) => {
|
|
154
|
+
this.#socket.destroy(
|
|
155
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
end = this.#buffer.indexOf("\n");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export class IpcServer {
|
|
165
|
+
readonly #endpoint: string;
|
|
166
|
+
readonly #onMessage: (
|
|
167
|
+
peer: JsonLinePeer<SessionMessage, BrokerMessage>,
|
|
168
|
+
message: SessionMessage,
|
|
169
|
+
) => Promise<void> | void;
|
|
170
|
+
readonly #onClose: (
|
|
171
|
+
peer: JsonLinePeer<SessionMessage, BrokerMessage>,
|
|
172
|
+
) => void;
|
|
173
|
+
readonly #peers = new Set<JsonLinePeer<SessionMessage, BrokerMessage>>();
|
|
174
|
+
#server: Server | undefined;
|
|
175
|
+
|
|
176
|
+
constructor(
|
|
177
|
+
agentDir: string,
|
|
178
|
+
onMessage: (
|
|
179
|
+
peer: JsonLinePeer<SessionMessage, BrokerMessage>,
|
|
180
|
+
message: SessionMessage,
|
|
181
|
+
) => Promise<void> | void,
|
|
182
|
+
onClose: (peer: JsonLinePeer<SessionMessage, BrokerMessage>) => void,
|
|
183
|
+
) {
|
|
184
|
+
this.#endpoint = ipcEndpoint(agentDir);
|
|
185
|
+
this.#onMessage = onMessage;
|
|
186
|
+
this.#onClose = onClose;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async start(): Promise<void> {
|
|
190
|
+
if (this.#server) return;
|
|
191
|
+
if (process.platform !== "win32") await prepareUnixSocket(this.#endpoint);
|
|
192
|
+
const server = createServer((socket) => {
|
|
193
|
+
let peer: JsonLinePeer<SessionMessage, BrokerMessage>;
|
|
194
|
+
peer = new JsonLinePeer(
|
|
195
|
+
socket,
|
|
196
|
+
(message) => this.#onMessage(peer, message),
|
|
197
|
+
() => {
|
|
198
|
+
this.#peers.delete(peer);
|
|
199
|
+
this.#onClose(peer);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
this.#peers.add(peer);
|
|
203
|
+
});
|
|
204
|
+
await new Promise<void>((resolveListen, rejectListen) => {
|
|
205
|
+
server.once("error", rejectListen);
|
|
206
|
+
server.listen(this.#endpoint, () => {
|
|
207
|
+
server.off("error", rejectListen);
|
|
208
|
+
resolveListen();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
this.#server = server;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async close(): Promise<void> {
|
|
215
|
+
for (const peer of this.#peers) peer.close();
|
|
216
|
+
this.#peers.clear();
|
|
217
|
+
const server = this.#server;
|
|
218
|
+
this.#server = undefined;
|
|
219
|
+
if (!server) return;
|
|
220
|
+
await new Promise<void>((resolveClose, rejectClose) =>
|
|
221
|
+
server.close((error) => (error ? rejectClose(error) : resolveClose())),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface ConnectionCallbacks {
|
|
227
|
+
onOpen(): Promise<void> | void;
|
|
228
|
+
onMessage(message: BrokerMessage): Promise<void> | void;
|
|
229
|
+
onClose(error: Error): void;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export class IpcClient {
|
|
233
|
+
readonly #endpoint: string;
|
|
234
|
+
readonly #callbacks: ConnectionCallbacks;
|
|
235
|
+
#peer: JsonLinePeer<BrokerMessage, SessionMessage> | undefined;
|
|
236
|
+
#opening: Promise<void> | undefined;
|
|
237
|
+
#retry: NodeJS.Timeout | undefined;
|
|
238
|
+
#closed = false;
|
|
239
|
+
|
|
240
|
+
constructor(agentDir: string, callbacks: ConnectionCallbacks) {
|
|
241
|
+
this.#endpoint = ipcEndpoint(agentDir);
|
|
242
|
+
this.#callbacks = callbacks;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get connected(): boolean {
|
|
246
|
+
return this.#peer !== undefined && !this.#peer.closed;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
start(): void {
|
|
250
|
+
void this.connect().catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
connect(): Promise<void> {
|
|
254
|
+
if (this.#closed)
|
|
255
|
+
return Promise.reject(new Error("Chappie IPC client is closed"));
|
|
256
|
+
if (this.connected) return Promise.resolve();
|
|
257
|
+
if (this.#opening) return this.#opening;
|
|
258
|
+
clearTimeout(this.#retry);
|
|
259
|
+
this.#retry = undefined;
|
|
260
|
+
this.#opening = this.#open().finally(() => {
|
|
261
|
+
this.#opening = undefined;
|
|
262
|
+
});
|
|
263
|
+
return this.#opening;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
send(message: SessionMessage): Promise<void> {
|
|
267
|
+
const peer = this.#peer;
|
|
268
|
+
if (!peer || peer.closed)
|
|
269
|
+
return Promise.reject(new Error("Chappie broker is not running"));
|
|
270
|
+
return peer.send(message);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
close(): void {
|
|
274
|
+
this.#closed = true;
|
|
275
|
+
clearTimeout(this.#retry);
|
|
276
|
+
this.#retry = undefined;
|
|
277
|
+
this.#peer?.close();
|
|
278
|
+
this.#peer = undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async #open(): Promise<void> {
|
|
282
|
+
const socket = createConnection(this.#endpoint);
|
|
283
|
+
let failure = new Error("Chappie broker connection ended");
|
|
284
|
+
await new Promise<void>((resolveOpen, rejectOpen) => {
|
|
285
|
+
socket.once("connect", resolveOpen);
|
|
286
|
+
socket.once("error", (error) => {
|
|
287
|
+
failure = error;
|
|
288
|
+
rejectOpen(error);
|
|
289
|
+
});
|
|
290
|
+
}).catch((error: unknown) => {
|
|
291
|
+
socket.destroy();
|
|
292
|
+
this.#scheduleReconnect();
|
|
293
|
+
throw error;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
let peer: JsonLinePeer<BrokerMessage, SessionMessage>;
|
|
297
|
+
peer = new JsonLinePeer(
|
|
298
|
+
socket,
|
|
299
|
+
(message) => this.#callbacks.onMessage(message),
|
|
300
|
+
() => {
|
|
301
|
+
if (this.#peer !== peer) return;
|
|
302
|
+
this.#peer = undefined;
|
|
303
|
+
if (!this.#closed) {
|
|
304
|
+
this.#callbacks.onClose(failure);
|
|
305
|
+
this.#scheduleReconnect();
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
this.#peer = peer;
|
|
310
|
+
try {
|
|
311
|
+
await this.#callbacks.onOpen();
|
|
312
|
+
} catch (error) {
|
|
313
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
314
|
+
peer.close();
|
|
315
|
+
throw failure;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#scheduleReconnect(): void {
|
|
320
|
+
if (this.#closed || this.#retry) return;
|
|
321
|
+
this.#retry = setTimeout(() => {
|
|
322
|
+
this.#retry = undefined;
|
|
323
|
+
void this.connect().catch(() => {});
|
|
324
|
+
}, 500);
|
|
325
|
+
this.#retry.unref();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function prepareUnixSocket(endpoint: string): Promise<void> {
|
|
330
|
+
try {
|
|
331
|
+
await lstat(endpoint);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (await endpointAcceptsConnections(endpoint)) {
|
|
338
|
+
const error = new Error(
|
|
339
|
+
`Chappie broker is already listening at ${endpoint}`,
|
|
340
|
+
) as NodeJS.ErrnoException;
|
|
341
|
+
error.code = "EADDRINUSE";
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
await unlink(endpoint);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function endpointAcceptsConnections(endpoint: string): Promise<boolean> {
|
|
348
|
+
return new Promise<boolean>((resolveProbe, rejectProbe) => {
|
|
349
|
+
const socket = createConnection(endpoint);
|
|
350
|
+
socket.once("connect", () => {
|
|
351
|
+
socket.end();
|
|
352
|
+
resolveProbe(true);
|
|
353
|
+
});
|
|
354
|
+
socket.once("error", (error: NodeJS.ErrnoException) => {
|
|
355
|
+
socket.destroy();
|
|
356
|
+
if (error.code === "ECONNREFUSED" || error.code === "ENOENT")
|
|
357
|
+
resolveProbe(false);
|
|
358
|
+
else rejectProbe(error);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Api,
|
|
3
|
+
type AssistantMessage,
|
|
4
|
+
type AssistantMessageEventStream,
|
|
5
|
+
type Context,
|
|
6
|
+
createAssistantMessageEventStream,
|
|
7
|
+
createProvider,
|
|
8
|
+
type Model,
|
|
9
|
+
type StreamOptions,
|
|
10
|
+
type ToolCall,
|
|
11
|
+
} from "@earendil-works/pi-ai";
|
|
12
|
+
|
|
13
|
+
export class ProviderOutput {
|
|
14
|
+
readonly stream: AssistantMessageEventStream;
|
|
15
|
+
readonly message: AssistantMessage;
|
|
16
|
+
readonly finished: Promise<void>;
|
|
17
|
+
#resolveFinished: () => void;
|
|
18
|
+
#removeAbort?: () => void;
|
|
19
|
+
#started = false;
|
|
20
|
+
#closed = false;
|
|
21
|
+
|
|
22
|
+
constructor(model: Model<Api>, signal?: AbortSignal) {
|
|
23
|
+
this.stream = createAssistantMessageEventStream();
|
|
24
|
+
this.message = {
|
|
25
|
+
role: "assistant",
|
|
26
|
+
content: [],
|
|
27
|
+
api: model.api,
|
|
28
|
+
provider: model.provider,
|
|
29
|
+
model: model.id,
|
|
30
|
+
usage: {
|
|
31
|
+
input: 0,
|
|
32
|
+
output: 0,
|
|
33
|
+
cacheRead: 0,
|
|
34
|
+
cacheWrite: 0,
|
|
35
|
+
totalTokens: 0,
|
|
36
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
37
|
+
},
|
|
38
|
+
stopReason: "pending",
|
|
39
|
+
timestamp: Date.now(),
|
|
40
|
+
};
|
|
41
|
+
const completion = Promise.withResolvers<void>();
|
|
42
|
+
this.finished = completion.promise;
|
|
43
|
+
this.#resolveFinished = completion.resolve;
|
|
44
|
+
if (signal) {
|
|
45
|
+
const abort = (): void =>
|
|
46
|
+
this.fail(new Error("Chappie provider request was cancelled"), true);
|
|
47
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
48
|
+
this.#removeAbort = () => signal.removeEventListener("abort", abort);
|
|
49
|
+
if (signal.aborted) abort();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get closed(): boolean {
|
|
54
|
+
return this.#closed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
begin(): void {
|
|
58
|
+
if (this.#started || this.#closed) return;
|
|
59
|
+
this.#started = true;
|
|
60
|
+
this.stream.push({ type: "start", partial: this.message });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
text(text: string): void {
|
|
64
|
+
if (this.#closed)
|
|
65
|
+
throw new Error("Chappie provider response is already complete");
|
|
66
|
+
this.begin();
|
|
67
|
+
const contentIndex = this.message.content.length;
|
|
68
|
+
const block = { type: "text" as const, text: "" };
|
|
69
|
+
this.message.content.push(block);
|
|
70
|
+
this.stream.push({
|
|
71
|
+
type: "text_start",
|
|
72
|
+
contentIndex,
|
|
73
|
+
partial: this.message,
|
|
74
|
+
});
|
|
75
|
+
block.text = text;
|
|
76
|
+
this.stream.push({
|
|
77
|
+
type: "text_delta",
|
|
78
|
+
contentIndex,
|
|
79
|
+
delta: text,
|
|
80
|
+
partial: this.message,
|
|
81
|
+
});
|
|
82
|
+
this.stream.push({
|
|
83
|
+
type: "text_end",
|
|
84
|
+
contentIndex,
|
|
85
|
+
content: text,
|
|
86
|
+
partial: this.message,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
toolCalls(calls: ToolCall[]): void {
|
|
91
|
+
if (this.#closed)
|
|
92
|
+
throw new Error("Chappie provider response is already complete");
|
|
93
|
+
this.begin();
|
|
94
|
+
for (const call of calls) {
|
|
95
|
+
const contentIndex = this.message.content.length;
|
|
96
|
+
const block: ToolCall = { ...call, arguments: {} };
|
|
97
|
+
this.message.content.push(block);
|
|
98
|
+
this.stream.push({
|
|
99
|
+
type: "toolcall_start",
|
|
100
|
+
contentIndex,
|
|
101
|
+
partial: this.message,
|
|
102
|
+
});
|
|
103
|
+
block.arguments = call.arguments;
|
|
104
|
+
this.stream.push({
|
|
105
|
+
type: "toolcall_delta",
|
|
106
|
+
contentIndex,
|
|
107
|
+
delta: JSON.stringify(call.arguments),
|
|
108
|
+
partial: this.message,
|
|
109
|
+
});
|
|
110
|
+
this.stream.push({
|
|
111
|
+
type: "toolcall_end",
|
|
112
|
+
contentIndex,
|
|
113
|
+
toolCall: block,
|
|
114
|
+
partial: this.message,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
done(reason: "stop" | "toolUse" = "stop"): void {
|
|
120
|
+
if (this.#closed) return;
|
|
121
|
+
this.begin();
|
|
122
|
+
this.message.stopReason = reason;
|
|
123
|
+
this.stream.push({ type: "done", reason, message: this.message });
|
|
124
|
+
this.#finish();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fail(error: unknown, aborted = false): void {
|
|
128
|
+
if (this.#closed) return;
|
|
129
|
+
this.begin();
|
|
130
|
+
this.message.stopReason = aborted ? "aborted" : "error";
|
|
131
|
+
this.message.errorMessage =
|
|
132
|
+
error instanceof Error ? error.message : String(error);
|
|
133
|
+
this.stream.push({
|
|
134
|
+
type: "error",
|
|
135
|
+
reason: this.message.stopReason,
|
|
136
|
+
error: this.message,
|
|
137
|
+
});
|
|
138
|
+
this.#finish();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#finish(): void {
|
|
142
|
+
this.#closed = true;
|
|
143
|
+
this.#removeAbort?.();
|
|
144
|
+
this.stream.end();
|
|
145
|
+
this.#resolveFinished();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createChappieProvider(
|
|
150
|
+
start: (output: ProviderOutput, context: Context) => Promise<void>,
|
|
151
|
+
) {
|
|
152
|
+
const stream = (
|
|
153
|
+
model: Model<Api>,
|
|
154
|
+
context: Context,
|
|
155
|
+
options?: StreamOptions,
|
|
156
|
+
) => {
|
|
157
|
+
const output = new ProviderOutput(model, options?.signal);
|
|
158
|
+
if (!output.closed) {
|
|
159
|
+
queueMicrotask(() => {
|
|
160
|
+
void start(output, context).catch((error: unknown) =>
|
|
161
|
+
output.fail(error),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return output.stream;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return createProvider({
|
|
169
|
+
id: "chappie",
|
|
170
|
+
name: "Chappie",
|
|
171
|
+
auth: {
|
|
172
|
+
apiKey: {
|
|
173
|
+
name: "Local Chappie",
|
|
174
|
+
async resolve() {
|
|
175
|
+
return { auth: { headers: {} }, source: "local" };
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
models: [
|
|
180
|
+
{
|
|
181
|
+
id: "chatgpt",
|
|
182
|
+
name: "ChatGPT",
|
|
183
|
+
api: "chappie",
|
|
184
|
+
provider: "chappie",
|
|
185
|
+
baseUrl: "",
|
|
186
|
+
reasoning: false,
|
|
187
|
+
input: ["text", "image"],
|
|
188
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
189
|
+
contextWindow: 1_000_000_000,
|
|
190
|
+
maxTokens: 1_000_000_000,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
api: { stream, streamSimple: stream },
|
|
194
|
+
});
|
|
195
|
+
}
|