@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/session.ts
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
Context,
|
|
5
|
+
ToolResultMessage,
|
|
6
|
+
UserMessage,
|
|
7
|
+
} from "@earendil-works/pi-ai";
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionContext,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { DeliveryRecord } from "./delivery.ts";
|
|
13
|
+
import {
|
|
14
|
+
type BrokerMessage,
|
|
15
|
+
IpcClient,
|
|
16
|
+
type SessionDescription,
|
|
17
|
+
type SessionInput,
|
|
18
|
+
type SessionInspection,
|
|
19
|
+
type SessionStatus,
|
|
20
|
+
} from "./ipc.ts";
|
|
21
|
+
import type { ProviderOutput } from "./provider.ts";
|
|
22
|
+
import { readSessionResource, rememberImages } from "./resources.ts";
|
|
23
|
+
|
|
24
|
+
type RemoteRequest = Extract<BrokerMessage, { type: "chat" | "call" }>;
|
|
25
|
+
|
|
26
|
+
interface SyncRequest {
|
|
27
|
+
resolve(): void;
|
|
28
|
+
reject(error: Error): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface StoreRequest {
|
|
32
|
+
resolve(): void;
|
|
33
|
+
reject(error: Error): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActiveRequest {
|
|
37
|
+
request: RemoteRequest;
|
|
38
|
+
message: AssistantMessage;
|
|
39
|
+
completed: boolean;
|
|
40
|
+
cancelled: boolean;
|
|
41
|
+
toolResults: ToolResultMessage[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class LocalSession {
|
|
45
|
+
readonly #pi: ExtensionAPI;
|
|
46
|
+
readonly #agentDir: string;
|
|
47
|
+
readonly #syncs = new Map<number, SyncRequest>();
|
|
48
|
+
readonly #stores = new Map<string, StoreRequest>();
|
|
49
|
+
readonly #queue: RemoteRequest[] = [];
|
|
50
|
+
readonly #seenInputs = new Set<string>();
|
|
51
|
+
readonly #pendingInputs = new Map<string, SessionInput>();
|
|
52
|
+
readonly #deliveries = new Map<string, DeliveryRecord>();
|
|
53
|
+
#context: ExtensionContext | undefined;
|
|
54
|
+
#connection: IpcClient | undefined;
|
|
55
|
+
#output: ProviderOutput | undefined;
|
|
56
|
+
#active: ActiveRequest | undefined;
|
|
57
|
+
#status: SessionStatus = "idle";
|
|
58
|
+
#nextSyncId = 1;
|
|
59
|
+
#starting = false;
|
|
60
|
+
#sessionId: string | undefined;
|
|
61
|
+
#flushing = Promise.resolve();
|
|
62
|
+
|
|
63
|
+
constructor(pi: ExtensionAPI, agentDir: string) {
|
|
64
|
+
this.#pi = pi;
|
|
65
|
+
this.#agentDir = agentDir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
install(): void {
|
|
69
|
+
this.#pi.on("session_start", (_event, context) => this.#update(context));
|
|
70
|
+
this.#pi.on("model_select", (event, context) =>
|
|
71
|
+
this.#update(context, event.model.provider === "chappie"),
|
|
72
|
+
);
|
|
73
|
+
this.#pi.on("session_info_changed", (_event, context) => {
|
|
74
|
+
this.#context = context;
|
|
75
|
+
void this.#sync().catch(() => {});
|
|
76
|
+
});
|
|
77
|
+
this.#pi.on("context", (event) => ({
|
|
78
|
+
messages: event.messages.filter(
|
|
79
|
+
(message) =>
|
|
80
|
+
message.role !== "custom" || message.customType !== "chappie.request",
|
|
81
|
+
),
|
|
82
|
+
}));
|
|
83
|
+
this.#pi.on("turn_end", (event, context) =>
|
|
84
|
+
this.#turnEnd(event.message, event.toolResults, context),
|
|
85
|
+
);
|
|
86
|
+
this.#pi.on("agent_settled", async (_event, context) => {
|
|
87
|
+
this.#context = context;
|
|
88
|
+
this.#starting = false;
|
|
89
|
+
this.#collectInputs();
|
|
90
|
+
await this.#completeActive();
|
|
91
|
+
this.#dispatch();
|
|
92
|
+
});
|
|
93
|
+
this.#pi.on("session_shutdown", () => this.close());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async start(
|
|
97
|
+
output: ProviderOutput,
|
|
98
|
+
_providerContext: Context,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const context = this.#context;
|
|
101
|
+
const connection = this.#connection;
|
|
102
|
+
if (context?.model?.provider !== "chappie" || !connection) {
|
|
103
|
+
throw new Error("Chappie is not active for this session");
|
|
104
|
+
}
|
|
105
|
+
if (this.#output && !this.#output.closed) {
|
|
106
|
+
throw new Error("Chappie already has an active provider request");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.#starting = false;
|
|
110
|
+
this.#output = output;
|
|
111
|
+
try {
|
|
112
|
+
await connection.connect();
|
|
113
|
+
if (output.closed) return;
|
|
114
|
+
this.#collectInputs();
|
|
115
|
+
await this.#completeActive();
|
|
116
|
+
this.#status = "ready";
|
|
117
|
+
await this.#sync();
|
|
118
|
+
if (output.closed) return;
|
|
119
|
+
output.begin();
|
|
120
|
+
this.#dispatch();
|
|
121
|
+
await output.finished;
|
|
122
|
+
} finally {
|
|
123
|
+
if (this.#output === output) this.#output = undefined;
|
|
124
|
+
this.#status = this.#active ? "executing" : "idle";
|
|
125
|
+
void this.#sync().catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
close(): void {
|
|
130
|
+
const sessionId = this.#context?.sessionManager.getSessionId();
|
|
131
|
+
if (sessionId && this.#connection?.connected) {
|
|
132
|
+
void this.#connection
|
|
133
|
+
.send({ type: "unregister", sessionId })
|
|
134
|
+
.catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
this.#output?.fail(new Error("Chappie session ended"), true);
|
|
137
|
+
this.#output = undefined;
|
|
138
|
+
this.#active = undefined;
|
|
139
|
+
this.#queue.length = 0;
|
|
140
|
+
this.#starting = false;
|
|
141
|
+
this.#connection?.close();
|
|
142
|
+
this.#connection = undefined;
|
|
143
|
+
this.#context = undefined;
|
|
144
|
+
this.#rejectSyncs(new Error("Chappie session ended"));
|
|
145
|
+
this.#rejectStores(new Error("Chappie session ended"));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#update(
|
|
149
|
+
context: ExtensionContext,
|
|
150
|
+
active = context.model?.provider === "chappie",
|
|
151
|
+
): void {
|
|
152
|
+
this.#context = context;
|
|
153
|
+
if (!active) {
|
|
154
|
+
this.close();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!this.#connection) {
|
|
158
|
+
this.#connection = new IpcClient(this.#agentDir, {
|
|
159
|
+
onOpen: async () => {
|
|
160
|
+
await this.#sync();
|
|
161
|
+
await this.#flushDeliveries();
|
|
162
|
+
},
|
|
163
|
+
onMessage: (message) => this.#receive(message),
|
|
164
|
+
onClose: (error) => {
|
|
165
|
+
this.#rejectSyncs(error);
|
|
166
|
+
this.#rejectStores(error);
|
|
167
|
+
this.#output?.fail(error);
|
|
168
|
+
this.#active = undefined;
|
|
169
|
+
this.#queue.length = 0;
|
|
170
|
+
this.#starting = false;
|
|
171
|
+
this.#status = "idle";
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
this.#connection.start();
|
|
175
|
+
} else {
|
|
176
|
+
void this.#sync().catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#description(): SessionDescription {
|
|
181
|
+
const context = this.#context;
|
|
182
|
+
if (!context) throw new Error("Chappie session is not available");
|
|
183
|
+
const name = this.#pi.getSessionName();
|
|
184
|
+
const sessionFile = context.sessionManager.getSessionFile();
|
|
185
|
+
return {
|
|
186
|
+
id: context.sessionManager.getSessionId(),
|
|
187
|
+
cwd: context.cwd,
|
|
188
|
+
status: this.#status,
|
|
189
|
+
...(name ? { name } : {}),
|
|
190
|
+
...(sessionFile ? { sessionFile } : {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async #sync(): Promise<void> {
|
|
195
|
+
const connection = this.#connection;
|
|
196
|
+
if (
|
|
197
|
+
!connection?.connected ||
|
|
198
|
+
!this.#context ||
|
|
199
|
+
this.#context.model?.provider !== "chappie"
|
|
200
|
+
)
|
|
201
|
+
return;
|
|
202
|
+
const id = this.#nextSyncId++;
|
|
203
|
+
const completion = Promise.withResolvers<void>();
|
|
204
|
+
this.#syncs.set(id, completion);
|
|
205
|
+
try {
|
|
206
|
+
await connection.send({ type: "sync", id, session: this.#description() });
|
|
207
|
+
await completion.promise;
|
|
208
|
+
} finally {
|
|
209
|
+
this.#syncs.delete(id);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #receive(message: BrokerMessage): Promise<void> {
|
|
214
|
+
switch (message.type) {
|
|
215
|
+
case "synced":
|
|
216
|
+
this.#syncs.get(message.id)?.resolve();
|
|
217
|
+
break;
|
|
218
|
+
case "stored":
|
|
219
|
+
this.#stores.get(message.id)?.resolve();
|
|
220
|
+
break;
|
|
221
|
+
case "inspect":
|
|
222
|
+
await this.#reply(message.id, message.sessionId, () => ({
|
|
223
|
+
type: "result",
|
|
224
|
+
id: message.id,
|
|
225
|
+
inspection: this.#inspection(),
|
|
226
|
+
inputs: this.#inputs(),
|
|
227
|
+
}));
|
|
228
|
+
break;
|
|
229
|
+
case "ackInputs":
|
|
230
|
+
if (
|
|
231
|
+
message.sessionId === this.#context?.sessionManager.getSessionId()
|
|
232
|
+
) {
|
|
233
|
+
for (const id of message.ids) this.#pendingInputs.delete(id);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
case "readResource":
|
|
237
|
+
await this.#reply(message.id, message.sessionId, async () => ({
|
|
238
|
+
type: "result",
|
|
239
|
+
id: message.id,
|
|
240
|
+
resource: await readSessionResource(message.sessionId, message.uri),
|
|
241
|
+
}));
|
|
242
|
+
break;
|
|
243
|
+
case "cancel": {
|
|
244
|
+
const queued = this.#queue.findIndex(
|
|
245
|
+
(request) => request.id === message.id,
|
|
246
|
+
);
|
|
247
|
+
if (queued !== -1) {
|
|
248
|
+
this.#queue.splice(queued, 1);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (this.#active?.request.id !== message.id) break;
|
|
252
|
+
this.#active.cancelled = true;
|
|
253
|
+
if (this.#active.completed) await this.#completeActive();
|
|
254
|
+
else this.#context?.abort();
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case "chat":
|
|
258
|
+
case "call":
|
|
259
|
+
if (
|
|
260
|
+
message.sessionId !== this.#context?.sessionManager.getSessionId()
|
|
261
|
+
) {
|
|
262
|
+
await this.#sendError(
|
|
263
|
+
message.id,
|
|
264
|
+
"The requested Pi session is no longer active",
|
|
265
|
+
);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
this.#queue.push(message);
|
|
269
|
+
this.#dispatch();
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#inspection(): SessionInspection {
|
|
275
|
+
const activeTools = new Set(this.#pi.getActiveTools());
|
|
276
|
+
this.#collectInputs();
|
|
277
|
+
return {
|
|
278
|
+
session: this.#description(),
|
|
279
|
+
tools: this.#pi
|
|
280
|
+
.getAllTools()
|
|
281
|
+
.filter((tool) => activeTools.has(tool.name)),
|
|
282
|
+
skills: this.#pi
|
|
283
|
+
.getCommands()
|
|
284
|
+
.filter((command) => command.source === "skill"),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#dispatch(): void {
|
|
289
|
+
if (this.#active) return;
|
|
290
|
+
const request = this.#queue[0];
|
|
291
|
+
if (!request) return;
|
|
292
|
+
const output = this.#output;
|
|
293
|
+
if (!output || output.closed) {
|
|
294
|
+
this.#wake();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.#queue.shift();
|
|
299
|
+
this.#active = {
|
|
300
|
+
request,
|
|
301
|
+
message: output.message,
|
|
302
|
+
completed: false,
|
|
303
|
+
cancelled: false,
|
|
304
|
+
toolResults: [],
|
|
305
|
+
};
|
|
306
|
+
this.#status = "executing";
|
|
307
|
+
void this.#sync().catch(() => {});
|
|
308
|
+
if (request.type === "chat") {
|
|
309
|
+
output.text(request.text);
|
|
310
|
+
output.done();
|
|
311
|
+
} else {
|
|
312
|
+
output.toolCalls(request.calls);
|
|
313
|
+
output.done("toolUse");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#wake(): void {
|
|
318
|
+
if (
|
|
319
|
+
this.#starting ||
|
|
320
|
+
this.#output ||
|
|
321
|
+
this.#active ||
|
|
322
|
+
this.#queue.length === 0 ||
|
|
323
|
+
!this.#context?.isIdle()
|
|
324
|
+
)
|
|
325
|
+
return;
|
|
326
|
+
this.#starting = true;
|
|
327
|
+
this.#pi.sendMessage(
|
|
328
|
+
{
|
|
329
|
+
customType: "chappie.request",
|
|
330
|
+
content: "",
|
|
331
|
+
display: false,
|
|
332
|
+
},
|
|
333
|
+
{ triggerTurn: true },
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async #turnEnd(
|
|
338
|
+
message: unknown,
|
|
339
|
+
toolResults: ToolResultMessage[],
|
|
340
|
+
context: ExtensionContext,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
this.#context = context;
|
|
343
|
+
const active = this.#active;
|
|
344
|
+
if (!active || message !== active.message) return;
|
|
345
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
346
|
+
for (const result of toolResults) rememberImages(sessionId, result.content);
|
|
347
|
+
active.completed = true;
|
|
348
|
+
active.toolResults = toolResults;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async #completeActive(): Promise<void> {
|
|
352
|
+
const active = this.#active;
|
|
353
|
+
if (!active?.completed) return;
|
|
354
|
+
this.#collectInputs();
|
|
355
|
+
const inputs = this.#inputs();
|
|
356
|
+
if (active.cancelled) {
|
|
357
|
+
const context = this.#context;
|
|
358
|
+
if (context) {
|
|
359
|
+
const sessionFile = context.sessionManager.getSessionFile();
|
|
360
|
+
const delivery: DeliveryRecord = {
|
|
361
|
+
id: randomUUID(),
|
|
362
|
+
chatId: active.request.chatId,
|
|
363
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
364
|
+
toolCallIds:
|
|
365
|
+
active.request.type === "call"
|
|
366
|
+
? active.request.calls.map(({ id }) => id)
|
|
367
|
+
: [],
|
|
368
|
+
...(sessionFile
|
|
369
|
+
? { sessionFile }
|
|
370
|
+
: { inlineResults: active.toolResults }),
|
|
371
|
+
...(active.message.errorMessage
|
|
372
|
+
? { error: active.message.errorMessage }
|
|
373
|
+
: { error: "Request cancelled" }),
|
|
374
|
+
};
|
|
375
|
+
this.#deliveries.set(delivery.id, delivery);
|
|
376
|
+
await this.#flushDeliveries().catch(() => {});
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
await this.#connection?.send(
|
|
380
|
+
active.request.type === "call"
|
|
381
|
+
? {
|
|
382
|
+
type: "result",
|
|
383
|
+
id: active.request.id,
|
|
384
|
+
message: active.message,
|
|
385
|
+
toolResults: active.toolResults,
|
|
386
|
+
inputs,
|
|
387
|
+
}
|
|
388
|
+
: {
|
|
389
|
+
type: "result",
|
|
390
|
+
id: active.request.id,
|
|
391
|
+
message: active.message,
|
|
392
|
+
inputs,
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
this.#active = undefined;
|
|
397
|
+
this.#status = "idle";
|
|
398
|
+
void this.#sync().catch(() => {});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#collectInputs(): void {
|
|
402
|
+
const context = this.#context;
|
|
403
|
+
if (!context) return;
|
|
404
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
405
|
+
if (this.#sessionId !== sessionId) {
|
|
406
|
+
this.#sessionId = sessionId;
|
|
407
|
+
this.#seenInputs.clear();
|
|
408
|
+
this.#pendingInputs.clear();
|
|
409
|
+
}
|
|
410
|
+
for (const entry of context.sessionManager.getBranch()) {
|
|
411
|
+
if (
|
|
412
|
+
entry.type !== "message" ||
|
|
413
|
+
entry.message.role !== "user" ||
|
|
414
|
+
this.#seenInputs.has(entry.id)
|
|
415
|
+
)
|
|
416
|
+
continue;
|
|
417
|
+
const message = entry.message as UserMessage;
|
|
418
|
+
if (typeof message.content !== "string") {
|
|
419
|
+
rememberImages(sessionId, message.content);
|
|
420
|
+
}
|
|
421
|
+
this.#seenInputs.add(entry.id);
|
|
422
|
+
this.#pendingInputs.set(entry.id, { id: entry.id, sessionId, message });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#inputs(): SessionInput[] {
|
|
427
|
+
this.#collectInputs();
|
|
428
|
+
return [...this.#pendingInputs.values()];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async #flushDeliveries(): Promise<void> {
|
|
432
|
+
const flushed = this.#flushing.then(async () => {
|
|
433
|
+
const connection = this.#connection;
|
|
434
|
+
if (!connection?.connected) return;
|
|
435
|
+
for (const delivery of this.#deliveries.values()) {
|
|
436
|
+
const completion = Promise.withResolvers<void>();
|
|
437
|
+
this.#stores.set(delivery.id, completion);
|
|
438
|
+
try {
|
|
439
|
+
await connection.send({ type: "delivery", delivery });
|
|
440
|
+
await completion.promise;
|
|
441
|
+
this.#deliveries.delete(delivery.id);
|
|
442
|
+
} finally {
|
|
443
|
+
this.#stores.delete(delivery.id);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
this.#flushing = flushed.catch(() => {});
|
|
448
|
+
return flushed;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async #reply(
|
|
452
|
+
id: number,
|
|
453
|
+
sessionId: string,
|
|
454
|
+
response: () =>
|
|
455
|
+
| Parameters<IpcClient["send"]>[0]
|
|
456
|
+
| Promise<Parameters<IpcClient["send"]>[0]>,
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
try {
|
|
459
|
+
if (sessionId !== this.#context?.sessionManager.getSessionId()) {
|
|
460
|
+
throw new Error("The requested Pi session is no longer active");
|
|
461
|
+
}
|
|
462
|
+
await this.#connection?.send(await response());
|
|
463
|
+
} catch (error) {
|
|
464
|
+
await this.#sendError(
|
|
465
|
+
id,
|
|
466
|
+
error instanceof Error ? error.message : String(error),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async #sendError(id: number, error: string): Promise<void> {
|
|
472
|
+
await this.#connection?.send({ type: "result", id, error });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#rejectSyncs(error: Error): void {
|
|
476
|
+
for (const sync of this.#syncs.values()) sync.reject(error);
|
|
477
|
+
this.#syncs.clear();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#rejectStores(error: Error): void {
|
|
481
|
+
for (const store of this.#stores.values()) store.reject(error);
|
|
482
|
+
this.#stores.clear();
|
|
483
|
+
}
|
|
484
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { DeliveryRecord } from "./delivery.ts";
|
|
4
|
+
|
|
5
|
+
interface StateFile {
|
|
6
|
+
bindings?: Record<string, string>;
|
|
7
|
+
deliveries?: DeliveryRecord[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class State {
|
|
11
|
+
readonly #path: string;
|
|
12
|
+
readonly #temporaryPath: string;
|
|
13
|
+
readonly #bindings = new Map<string, string>();
|
|
14
|
+
readonly #deliveries = new Map<string, DeliveryRecord>();
|
|
15
|
+
#writes = Promise.resolve();
|
|
16
|
+
|
|
17
|
+
constructor(agentDir: string) {
|
|
18
|
+
this.#path = join(agentDir, "chappie.state.json");
|
|
19
|
+
this.#temporaryPath = `${this.#path}.tmp`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async load(): Promise<void> {
|
|
23
|
+
let contents: string;
|
|
24
|
+
try {
|
|
25
|
+
contents = await readFile(this.#path, "utf8");
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const state = JSON.parse(contents) as StateFile;
|
|
31
|
+
for (const [chatId, sessionId] of Object.entries(state.bindings ?? {})) {
|
|
32
|
+
if (typeof sessionId === "string") this.#bindings.set(chatId, sessionId);
|
|
33
|
+
}
|
|
34
|
+
for (const delivery of state.deliveries ?? []) {
|
|
35
|
+
if (delivery?.id) this.#deliveries.set(delivery.id, delivery);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
binding(chatId: string): string | undefined {
|
|
40
|
+
return this.#bindings.get(chatId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
boundSessions(): Set<string> {
|
|
44
|
+
return new Set(this.#bindings.values());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setBinding(chatId: string, sessionId: string): Promise<void> {
|
|
48
|
+
this.#bindings.set(chatId, sessionId);
|
|
49
|
+
return this.#save();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
deliveries(chatId: string): DeliveryRecord[] {
|
|
53
|
+
return [...this.#deliveries.values()].filter(
|
|
54
|
+
(delivery) => delivery.chatId === chatId,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
addDelivery(delivery: DeliveryRecord): Promise<void> {
|
|
59
|
+
this.#deliveries.set(delivery.id, delivery);
|
|
60
|
+
return this.#save();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
removeDeliveries(ids: string[]): Promise<void> {
|
|
64
|
+
for (const id of ids) this.#deliveries.delete(id);
|
|
65
|
+
return this.#save();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async restoreDeliveries(deliveries: DeliveryRecord[]): Promise<void> {
|
|
69
|
+
for (const delivery of deliveries)
|
|
70
|
+
this.#deliveries.set(delivery.id, delivery);
|
|
71
|
+
await this.#save();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#save(): Promise<void> {
|
|
75
|
+
const saved = this.#writes.then(async () => {
|
|
76
|
+
const bindings = Object.fromEntries(this.#bindings);
|
|
77
|
+
await writeFile(
|
|
78
|
+
this.#temporaryPath,
|
|
79
|
+
`${JSON.stringify({ bindings, deliveries: [...this.#deliveries.values()] }, null, 2)}\n`,
|
|
80
|
+
{
|
|
81
|
+
mode: 0o600,
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
await rename(this.#temporaryPath, this.#path);
|
|
85
|
+
});
|
|
86
|
+
this.#writes = saved.catch(() => {});
|
|
87
|
+
return saved;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/stdio.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
StdioServerTransport,
|
|
5
|
+
serveStdio,
|
|
6
|
+
} from "@modelcontextprotocol/server/stdio";
|
|
7
|
+
import { Broker } from "./broker.ts";
|
|
8
|
+
import { createServer } from "./server.ts";
|
|
9
|
+
|
|
10
|
+
const terminationSignals =
|
|
11
|
+
process.platform === "win32"
|
|
12
|
+
? ["SIGINT", "SIGTERM"]
|
|
13
|
+
: ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
14
|
+
|
|
15
|
+
export async function serveChappie(): Promise<never> {
|
|
16
|
+
const broker = new Broker(getAgentDir());
|
|
17
|
+
await broker.start();
|
|
18
|
+
const output = createWriteStream("", { fd: 1, autoClose: false });
|
|
19
|
+
const transport = new StdioServerTransport(process.stdin, output);
|
|
20
|
+
let stop: (() => void) | undefined;
|
|
21
|
+
const stopped = new Promise<void>((resolve) => {
|
|
22
|
+
stop = resolve;
|
|
23
|
+
});
|
|
24
|
+
const requestStop = (): void => stop?.();
|
|
25
|
+
|
|
26
|
+
process.stdin.once("end", requestStop);
|
|
27
|
+
process.stdin.once("close", requestStop);
|
|
28
|
+
for (const signal of terminationSignals) {
|
|
29
|
+
process.once(signal, requestStop);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const handle = serveStdio(() => createServer(broker), {
|
|
33
|
+
transport,
|
|
34
|
+
onerror(error) {
|
|
35
|
+
console.error(error);
|
|
36
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
37
|
+
if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") requestStop();
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await stopped;
|
|
43
|
+
await handle.close();
|
|
44
|
+
} finally {
|
|
45
|
+
await broker.close();
|
|
46
|
+
}
|
|
47
|
+
await new Promise<void>((resolve) => output.end(resolve));
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
createBashToolDefinition,
|
|
4
|
+
createEditToolDefinition,
|
|
5
|
+
createReadToolDefinition,
|
|
6
|
+
createWriteToolDefinition,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { fromJsonSchema } from "@modelcontextprotocol/server";
|
|
9
|
+
import type { SessionInput } from "./ipc.ts";
|
|
10
|
+
import {
|
|
11
|
+
contentWithImageReferences,
|
|
12
|
+
resourceDescriptors,
|
|
13
|
+
} from "./resources.ts";
|
|
14
|
+
import { transfer } from "./transfer.ts";
|
|
15
|
+
|
|
16
|
+
export interface ToolInput {
|
|
17
|
+
name: string;
|
|
18
|
+
arguments: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const definitions = [
|
|
22
|
+
createReadToolDefinition("."),
|
|
23
|
+
createBashToolDefinition("."),
|
|
24
|
+
createEditToolDefinition("."),
|
|
25
|
+
createWriteToolDefinition("."),
|
|
26
|
+
transfer,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const directTools = definitions.map((definition) => ({
|
|
30
|
+
name: definition.name,
|
|
31
|
+
description: definition.description,
|
|
32
|
+
inputSchema: fromJsonSchema<Record<string, unknown>>(
|
|
33
|
+
withSessionId(definition.parameters as unknown as Record<string, unknown>),
|
|
34
|
+
),
|
|
35
|
+
fileParams: definition.name === "transfer" ? ["files"] : undefined,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
export function toolResult(
|
|
39
|
+
toolResults: ToolResultMessage[],
|
|
40
|
+
sessionId: string,
|
|
41
|
+
inputs: SessionInput[] = [],
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
...toolResults.flatMap((result) => [
|
|
46
|
+
{
|
|
47
|
+
type: "text" as const,
|
|
48
|
+
text: JSON.stringify({
|
|
49
|
+
toolCallId: result.toolCallId,
|
|
50
|
+
toolName: result.toolName,
|
|
51
|
+
isError: result.isError,
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
...contentWithImageReferences(sessionId, result.content),
|
|
55
|
+
...resourceDescriptors(result.details).map((resource) => ({
|
|
56
|
+
type: "resource_link" as const,
|
|
57
|
+
uri: resource.uri,
|
|
58
|
+
name: resource.name,
|
|
59
|
+
mimeType: resource.mimeType,
|
|
60
|
+
size: resource.size,
|
|
61
|
+
})),
|
|
62
|
+
]),
|
|
63
|
+
...inputContent(inputs),
|
|
64
|
+
],
|
|
65
|
+
isError: toolResults.some((result) => result.isError),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function inputContent(inputs: SessionInput[]) {
|
|
70
|
+
return inputs.flatMap(({ id, sessionId, message }) => [
|
|
71
|
+
{
|
|
72
|
+
type: "text" as const,
|
|
73
|
+
text: JSON.stringify({ piInput: id }),
|
|
74
|
+
},
|
|
75
|
+
...(typeof message.content === "string"
|
|
76
|
+
? [{ type: "text" as const, text: message.content }]
|
|
77
|
+
: contentWithImageReferences(sessionId, message.content)),
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function withSessionId(
|
|
82
|
+
schema: Record<string, unknown>,
|
|
83
|
+
): Record<string, unknown> {
|
|
84
|
+
const copy = structuredClone(schema) as {
|
|
85
|
+
properties?: Record<string, unknown>;
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
...copy,
|
|
89
|
+
properties: {
|
|
90
|
+
...copy.properties,
|
|
91
|
+
sessionId: {
|
|
92
|
+
type: "string",
|
|
93
|
+
description: "Pi session for this operation only",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
};
|
|
98
|
+
}
|