@zetaloop/chappie 0.2.0 → 0.3.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/README.md +27 -7
- package/docs/tools.md +55 -68
- package/package.json +3 -1
- package/src/broker.ts +240 -50
- package/src/config.ts +21 -0
- package/src/delivery.ts +27 -56
- package/src/index.ts +15 -10
- package/src/instructions.md +8 -4
- package/src/ipc.ts +144 -41
- package/src/provider.ts +3 -5
- package/src/question.html +384 -0
- package/src/questions.ts +95 -0
- package/src/server.ts +250 -69
- package/src/session.ts +131 -67
- package/src/state.ts +104 -9
- package/src/stdio.ts +11 -2
- package/src/tools.ts +6 -23
- package/src/transfer.ts +3 -3
package/src/broker.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
type ResolvedDelivery,
|
|
8
|
-
resolveDelivery,
|
|
9
|
-
} from "./delivery.ts";
|
|
3
|
+
import { readConfig } from "./config.ts";
|
|
4
|
+
import type { DeliveryRecord } from "./delivery.ts";
|
|
10
5
|
import {
|
|
11
6
|
type BrokerMessage,
|
|
12
7
|
IpcServer,
|
|
@@ -17,6 +12,13 @@ import {
|
|
|
17
12
|
type SessionMessage,
|
|
18
13
|
type SessionResult,
|
|
19
14
|
} from "./ipc.ts";
|
|
15
|
+
import {
|
|
16
|
+
type Question,
|
|
17
|
+
type QuestionAnswer,
|
|
18
|
+
type QuestionInput,
|
|
19
|
+
type QuestionRecord,
|
|
20
|
+
questionView,
|
|
21
|
+
} from "./questions.ts";
|
|
20
22
|
import { type ResourceData, resourceSessionId } from "./resources.ts";
|
|
21
23
|
import { State } from "./state.ts";
|
|
22
24
|
import type { ToolInput } from "./tools.ts";
|
|
@@ -34,6 +36,11 @@ interface PendingRequest {
|
|
|
34
36
|
onAbort(): void;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
interface Workflow {
|
|
40
|
+
id: string;
|
|
41
|
+
controller: AbortController;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
interface ChangeWaiter {
|
|
38
45
|
resolve(): void;
|
|
39
46
|
reject(error: Error): void;
|
|
@@ -42,6 +49,7 @@ interface ChangeWaiter {
|
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface InitializedSession extends Omit<SessionInspection, "tools"> {
|
|
52
|
+
selection: "existing" | "explicit" | "automatic";
|
|
45
53
|
globalAgents?: string;
|
|
46
54
|
inputs: SessionInput[];
|
|
47
55
|
tools: { name: string; description: string }[];
|
|
@@ -53,11 +61,13 @@ export interface InspectedSession extends SessionInspection {
|
|
|
53
61
|
|
|
54
62
|
export interface ChatResult {
|
|
55
63
|
sessionId: string;
|
|
64
|
+
cwd: string;
|
|
56
65
|
inputs: SessionInput[];
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
export interface CallResult {
|
|
60
69
|
sessionId: string;
|
|
70
|
+
cwd: string;
|
|
61
71
|
toolResults: ToolResultMessage[];
|
|
62
72
|
inputs: SessionInput[];
|
|
63
73
|
}
|
|
@@ -69,6 +79,9 @@ export class Broker {
|
|
|
69
79
|
readonly #sessions = new Map<string, RegisteredSession>();
|
|
70
80
|
readonly #pending = new Map<number, PendingRequest>();
|
|
71
81
|
readonly #waiters = new Set<ChangeWaiter>();
|
|
82
|
+
readonly #workflows = new Map<string, Workflow>();
|
|
83
|
+
#ask = true;
|
|
84
|
+
#latestWorkflow = false;
|
|
72
85
|
#nextRequestId = 1;
|
|
73
86
|
|
|
74
87
|
constructor(agentDir: string) {
|
|
@@ -82,12 +95,19 @@ export class Broker {
|
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
async start(): Promise<void> {
|
|
98
|
+
const config = await readConfig(this.#agentDir);
|
|
99
|
+
this.#ask = config.ask ?? true;
|
|
100
|
+
this.#latestWorkflow = config.latestWorkflow ?? false;
|
|
85
101
|
await this.#state.load();
|
|
86
|
-
await this.#ipc.start();
|
|
102
|
+
await this.#ipc.start(config.listen ?? false);
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
async close(): Promise<void> {
|
|
90
106
|
const error = new Error("Chappie broker ended");
|
|
107
|
+
for (const workflow of this.#workflows.values()) {
|
|
108
|
+
workflow.controller.abort(error);
|
|
109
|
+
}
|
|
110
|
+
this.#workflows.clear();
|
|
91
111
|
for (const [id, pending] of this.#pending) {
|
|
92
112
|
this.#finishRequest(id, pending);
|
|
93
113
|
pending.reject(error);
|
|
@@ -101,12 +121,53 @@ export class Broker {
|
|
|
101
121
|
await this.#ipc.close();
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
async workflow(
|
|
125
|
+
chatId: string | undefined,
|
|
126
|
+
requestId: unknown,
|
|
127
|
+
signal: AbortSignal,
|
|
128
|
+
): Promise<AbortSignal> {
|
|
129
|
+
if (!this.#latestWorkflow || !chatId) return signal;
|
|
130
|
+
const id =
|
|
131
|
+
typeof requestId === "string"
|
|
132
|
+
? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
|
|
133
|
+
.exec(requestId)?.[0]
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
: undefined;
|
|
136
|
+
if (!id) return signal;
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
const latest = this.#state.workflow(chatId);
|
|
139
|
+
const superseded = new Error(
|
|
140
|
+
"A newer workflow is handling this chat. Stop this workflow.",
|
|
141
|
+
);
|
|
142
|
+
// UUIDv7 puts the creation timestamp first, including across broker restarts.
|
|
143
|
+
if (latest && id < latest) throw superseded;
|
|
144
|
+
let workflow = this.#workflows.get(chatId);
|
|
145
|
+
if (!workflow || workflow.id !== id) {
|
|
146
|
+
const previous = workflow;
|
|
147
|
+
workflow = { id, controller: new AbortController() };
|
|
148
|
+
this.#workflows.set(chatId, workflow);
|
|
149
|
+
previous?.controller.abort(superseded);
|
|
150
|
+
if (latest !== id) await this.#state.setWorkflow(chatId, id);
|
|
108
151
|
}
|
|
109
|
-
|
|
152
|
+
const combined = AbortSignal.any([signal, workflow.controller.signal]);
|
|
153
|
+
combined.throwIfAborted();
|
|
154
|
+
return combined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
listSessions(
|
|
158
|
+
sessionId?: string,
|
|
159
|
+
): (SessionDescription & { bindingCount: number })[] {
|
|
160
|
+
const counts = this.#state.bindingCounts();
|
|
161
|
+
return [...this.#sessions.values()]
|
|
162
|
+
.filter(({ description }) => !sessionId || description.id === sessionId)
|
|
163
|
+
.map(({ description }) => ({
|
|
164
|
+
...description,
|
|
165
|
+
bindingCount: counts.get(description.id) ?? 0,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get askEnabled(): boolean {
|
|
170
|
+
return this.#ask;
|
|
110
171
|
}
|
|
111
172
|
|
|
112
173
|
binding(chatId: string): string | undefined {
|
|
@@ -118,11 +179,22 @@ export class Broker {
|
|
|
118
179
|
sessionId: string | undefined,
|
|
119
180
|
signal: AbortSignal,
|
|
120
181
|
): Promise<InitializedSession> {
|
|
121
|
-
const
|
|
122
|
-
const {
|
|
123
|
-
|
|
182
|
+
const previous = this.#state.binding(chatId);
|
|
183
|
+
const { sessionId: target, selection } = await this.#selectSession(
|
|
184
|
+
chatId,
|
|
185
|
+
sessionId,
|
|
186
|
+
signal,
|
|
187
|
+
true,
|
|
188
|
+
);
|
|
189
|
+
if (previous === target)
|
|
190
|
+
await this.#notify(target, `ChatGPT ${chatId.slice(-4)} joined`);
|
|
191
|
+
const { inspection, inputs, globalAgents } = await this.#inspect(
|
|
192
|
+
target,
|
|
193
|
+
signal,
|
|
194
|
+
);
|
|
124
195
|
await this.#ackInputs(target, inputs);
|
|
125
196
|
return {
|
|
197
|
+
selection,
|
|
126
198
|
...inspection,
|
|
127
199
|
tools: inspection.tools.map(({ name, description }) => ({
|
|
128
200
|
name,
|
|
@@ -139,7 +211,12 @@ export class Broker {
|
|
|
139
211
|
text: string,
|
|
140
212
|
signal: AbortSignal,
|
|
141
213
|
): Promise<ChatResult> {
|
|
142
|
-
const target = await this.#selectSession(
|
|
214
|
+
const { sessionId: target } = await this.#selectSession(
|
|
215
|
+
chatId,
|
|
216
|
+
sessionId,
|
|
217
|
+
signal,
|
|
218
|
+
false,
|
|
219
|
+
);
|
|
143
220
|
const result = await this.#request(
|
|
144
221
|
target,
|
|
145
222
|
(id) => ({ type: "chat", id, chatId, sessionId: target, text }),
|
|
@@ -147,7 +224,7 @@ export class Broker {
|
|
|
147
224
|
);
|
|
148
225
|
if ("message" in result) {
|
|
149
226
|
await this.#ackInputs(target, result.inputs);
|
|
150
|
-
return { sessionId: target, inputs: result.inputs };
|
|
227
|
+
return { sessionId: target, cwd: result.cwd, inputs: result.inputs };
|
|
151
228
|
}
|
|
152
229
|
throw new Error("Pi session returned no assistant message");
|
|
153
230
|
}
|
|
@@ -158,7 +235,12 @@ export class Broker {
|
|
|
158
235
|
names: string[] | undefined,
|
|
159
236
|
signal: AbortSignal,
|
|
160
237
|
): Promise<InspectedSession> {
|
|
161
|
-
const target = await this.#selectSession(
|
|
238
|
+
const { sessionId: target } = await this.#selectSession(
|
|
239
|
+
chatId,
|
|
240
|
+
sessionId,
|
|
241
|
+
signal,
|
|
242
|
+
false,
|
|
243
|
+
);
|
|
162
244
|
const { inspection, inputs } = await this.#inspect(target, signal);
|
|
163
245
|
await this.#ackInputs(target, inputs);
|
|
164
246
|
const selected = names ? new Set(names) : undefined;
|
|
@@ -177,7 +259,12 @@ export class Broker {
|
|
|
177
259
|
calls: ToolInput[],
|
|
178
260
|
signal: AbortSignal,
|
|
179
261
|
): Promise<CallResult> {
|
|
180
|
-
const target = await this.#selectSession(
|
|
262
|
+
const { sessionId: target } = await this.#selectSession(
|
|
263
|
+
chatId,
|
|
264
|
+
sessionId,
|
|
265
|
+
signal,
|
|
266
|
+
false,
|
|
267
|
+
);
|
|
181
268
|
const toolCalls: ToolCall[] = calls.map((call) => ({
|
|
182
269
|
type: "toolCall",
|
|
183
270
|
id: `chappie-${randomUUID()}`,
|
|
@@ -199,6 +286,7 @@ export class Broker {
|
|
|
199
286
|
await this.#ackInputs(target, result.inputs);
|
|
200
287
|
return {
|
|
201
288
|
sessionId: target,
|
|
289
|
+
cwd: result.cwd,
|
|
202
290
|
toolResults: result.toolResults,
|
|
203
291
|
inputs: result.inputs,
|
|
204
292
|
};
|
|
@@ -218,6 +306,88 @@ export class Broker {
|
|
|
218
306
|
return inputs;
|
|
219
307
|
}
|
|
220
308
|
|
|
309
|
+
async ask(
|
|
310
|
+
chatId: string,
|
|
311
|
+
sessionId: string | undefined,
|
|
312
|
+
input: QuestionInput,
|
|
313
|
+
signal: AbortSignal,
|
|
314
|
+
): Promise<Question> {
|
|
315
|
+
const { sessionId: target } = await this.#selectSession(
|
|
316
|
+
chatId,
|
|
317
|
+
sessionId,
|
|
318
|
+
signal,
|
|
319
|
+
false,
|
|
320
|
+
);
|
|
321
|
+
const session = this.#sessions.get(target);
|
|
322
|
+
if (!session) throw new Error(`Pi session ${target} is offline`);
|
|
323
|
+
const question: QuestionRecord = {
|
|
324
|
+
...input,
|
|
325
|
+
id: randomUUID(),
|
|
326
|
+
chatId,
|
|
327
|
+
sessionId: target,
|
|
328
|
+
cwd: session.description.cwd,
|
|
329
|
+
delivered: false,
|
|
330
|
+
};
|
|
331
|
+
await this.#state.addQuestion(question);
|
|
332
|
+
void this.#notify(
|
|
333
|
+
target,
|
|
334
|
+
`ChatGPT ${chatId.slice(-4)} asked: ${question.question}`,
|
|
335
|
+
).catch(() => {});
|
|
336
|
+
return questionView(question);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async assertQuestion(
|
|
340
|
+
chatId: string,
|
|
341
|
+
id: string,
|
|
342
|
+
signal: AbortSignal,
|
|
343
|
+
): Promise<Question> {
|
|
344
|
+
for (;;) {
|
|
345
|
+
signal.throwIfAborted();
|
|
346
|
+
const question = this.#state.question(chatId, id);
|
|
347
|
+
if (question.loaded) return questionView(question);
|
|
348
|
+
await this.#waitForChange(signal);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async answer(
|
|
353
|
+
chatId: string,
|
|
354
|
+
id: string,
|
|
355
|
+
answer?: QuestionAnswer,
|
|
356
|
+
loaded = false,
|
|
357
|
+
): Promise<Question> {
|
|
358
|
+
let question = this.#state.question(chatId, id);
|
|
359
|
+
if ((loaded || answer) && !question.loaded) {
|
|
360
|
+
question = { ...question, loaded: true };
|
|
361
|
+
await this.#state.addQuestion(question);
|
|
362
|
+
this.#notifyChange();
|
|
363
|
+
}
|
|
364
|
+
if (answer) {
|
|
365
|
+
const previous = question.answer;
|
|
366
|
+
question = await this.#state.answer(chatId, id, answer);
|
|
367
|
+
if (question.answer !== previous) {
|
|
368
|
+
const response = [
|
|
369
|
+
...(question.answer?.selections ?? []).map(
|
|
370
|
+
(index) => question.options[index]?.title,
|
|
371
|
+
),
|
|
372
|
+
question.answer?.text,
|
|
373
|
+
]
|
|
374
|
+
.filter(Boolean)
|
|
375
|
+
.join(", ");
|
|
376
|
+
const message = question.answer?.skipped
|
|
377
|
+
? `Skipped in ChatGPT ${chatId.slice(-4)}: ${question.question}`
|
|
378
|
+
: previous
|
|
379
|
+
? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
|
|
380
|
+
: `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
|
|
381
|
+
void this.#notify(question.sessionId, message).catch(() => {});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return questionView(question);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
answers(chatId: string): QuestionRecord[] {
|
|
388
|
+
return this.#state.answers(chatId);
|
|
389
|
+
}
|
|
390
|
+
|
|
221
391
|
async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
|
|
222
392
|
const sessionId = resourceSessionId(uri);
|
|
223
393
|
await this.#waitForSession(sessionId, signal);
|
|
@@ -230,20 +400,16 @@ export class Broker {
|
|
|
230
400
|
throw new Error("Pi session returned no resource");
|
|
231
401
|
}
|
|
232
402
|
|
|
233
|
-
|
|
234
|
-
return
|
|
403
|
+
deliveries(chatId: string): DeliveryRecord[] {
|
|
404
|
+
return this.#state.deliveries(chatId);
|
|
235
405
|
}
|
|
236
406
|
|
|
237
|
-
|
|
407
|
+
acknowledge(
|
|
238
408
|
deliveries: DeliveryRecord[],
|
|
409
|
+
answers: QuestionRecord[],
|
|
239
410
|
signal: AbortSignal,
|
|
240
411
|
): Promise<void> {
|
|
241
|
-
|
|
242
|
-
if (signal.aborted) throw abortError(signal);
|
|
243
|
-
await this.#state.removeDeliveries(deliveries.map(({ id }) => id));
|
|
244
|
-
if (!signal.aborted) return;
|
|
245
|
-
await this.#state.restoreDeliveries(deliveries);
|
|
246
|
-
throw abortError(signal);
|
|
412
|
+
return this.#state.acknowledge(deliveries, answers, signal);
|
|
247
413
|
}
|
|
248
414
|
|
|
249
415
|
async #receive(
|
|
@@ -252,16 +418,20 @@ export class Broker {
|
|
|
252
418
|
): Promise<void> {
|
|
253
419
|
switch (message.type) {
|
|
254
420
|
case "sync": {
|
|
421
|
+
const registered =
|
|
422
|
+
this.#sessions.get(message.session.id)?.peer === peer;
|
|
255
423
|
this.#sessions.set(message.session.id, {
|
|
256
424
|
description: message.session,
|
|
257
425
|
peer,
|
|
258
426
|
});
|
|
259
|
-
this.#notifyChange();
|
|
260
427
|
await peer.send({
|
|
261
428
|
type: "synced",
|
|
262
429
|
id: message.id,
|
|
263
430
|
sessionId: message.session.id,
|
|
264
431
|
});
|
|
432
|
+
if (!registered)
|
|
433
|
+
await this.#notify(message.session.id, "Chappie connected");
|
|
434
|
+
this.#notifyChange();
|
|
265
435
|
break;
|
|
266
436
|
}
|
|
267
437
|
case "unregister": {
|
|
@@ -292,36 +462,56 @@ export class Broker {
|
|
|
292
462
|
requestedId: string | undefined,
|
|
293
463
|
signal: AbortSignal,
|
|
294
464
|
bindRequested: boolean,
|
|
295
|
-
): Promise<
|
|
465
|
+
): Promise<{
|
|
466
|
+
sessionId: string;
|
|
467
|
+
selection: InitializedSession["selection"];
|
|
468
|
+
}> {
|
|
296
469
|
if (requestedId) {
|
|
297
470
|
await this.#waitForSession(requestedId, signal);
|
|
298
471
|
if (bindRequested && this.#state.binding(chatId) !== requestedId) {
|
|
299
|
-
await this.#
|
|
300
|
-
this.#notifyChange();
|
|
472
|
+
await this.#bind(chatId, requestedId);
|
|
301
473
|
}
|
|
302
|
-
return requestedId;
|
|
474
|
+
return { sessionId: requestedId, selection: "explicit" };
|
|
303
475
|
}
|
|
304
476
|
|
|
305
477
|
const boundId = this.#state.binding(chatId);
|
|
306
478
|
if (boundId) {
|
|
307
479
|
await this.#waitForSession(boundId, signal);
|
|
308
|
-
return boundId;
|
|
480
|
+
return { sessionId: boundId, selection: "existing" };
|
|
309
481
|
}
|
|
310
482
|
|
|
311
483
|
for (;;) {
|
|
312
|
-
const occupied = this.#state.
|
|
484
|
+
const occupied = this.#state.bindingCounts();
|
|
313
485
|
const candidate = [...this.#sessions.keys()].find(
|
|
314
486
|
(sessionId) => !occupied.has(sessionId),
|
|
315
487
|
);
|
|
316
488
|
if (candidate) {
|
|
317
|
-
await this.#
|
|
318
|
-
|
|
319
|
-
return candidate;
|
|
489
|
+
await this.#bind(chatId, candidate);
|
|
490
|
+
return { sessionId: candidate, selection: "automatic" };
|
|
320
491
|
}
|
|
321
492
|
await this.#waitForChange(signal);
|
|
322
493
|
}
|
|
323
494
|
}
|
|
324
495
|
|
|
496
|
+
async #bind(chatId: string, sessionId: string): Promise<void> {
|
|
497
|
+
const previous = this.#state.binding(chatId);
|
|
498
|
+
await this.#state.setBinding(chatId, sessionId);
|
|
499
|
+
this.#notifyChange();
|
|
500
|
+
if (previous) {
|
|
501
|
+
await this.#notify(previous, `ChatGPT ${chatId.slice(-4)} left`);
|
|
502
|
+
if (this.#state.chats(previous).length === 0) {
|
|
503
|
+
await this.#notify(previous, "Ready for ChatGPT");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await this.#notify(sessionId, `ChatGPT ${chatId.slice(-4)} joined`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async #notify(sessionId: string, message: string): Promise<void> {
|
|
510
|
+
await this.#sessions
|
|
511
|
+
.get(sessionId)
|
|
512
|
+
?.peer.send({ type: "notice", sessionId, message });
|
|
513
|
+
}
|
|
514
|
+
|
|
325
515
|
async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
|
|
326
516
|
while (!this.#sessions.has(sessionId)) await this.#waitForChange(signal);
|
|
327
517
|
}
|
|
@@ -363,7 +553,14 @@ export class Broker {
|
|
|
363
553
|
const onAbort = (): void => {
|
|
364
554
|
const pending = this.#pending.get(id);
|
|
365
555
|
if (!pending) return;
|
|
366
|
-
void pending.peer
|
|
556
|
+
void pending.peer
|
|
557
|
+
.send({
|
|
558
|
+
type: "cancel",
|
|
559
|
+
id,
|
|
560
|
+
sessionId,
|
|
561
|
+
reason: abortError(signal).message,
|
|
562
|
+
})
|
|
563
|
+
.catch(() => {});
|
|
367
564
|
this.#finishRequest(id, pending);
|
|
368
565
|
pending.reject(abortError(signal));
|
|
369
566
|
};
|
|
@@ -431,19 +628,12 @@ export class Broker {
|
|
|
431
628
|
this.#sessions.delete(sessionId);
|
|
432
629
|
this.#notifyChange();
|
|
433
630
|
}
|
|
434
|
-
|
|
435
|
-
async #readGlobalAgents(): Promise<string | undefined> {
|
|
436
|
-
try {
|
|
437
|
-
return await readFile(join(this.#agentDir, "AGENTS.md"), "utf8");
|
|
438
|
-
} catch (error) {
|
|
439
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
440
|
-
throw error;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
631
|
}
|
|
444
632
|
|
|
445
633
|
function abortError(signal: AbortSignal): Error {
|
|
446
634
|
return signal.reason instanceof Error
|
|
447
635
|
? signal.reason
|
|
448
|
-
: new Error(
|
|
636
|
+
: new Error(
|
|
637
|
+
typeof signal.reason === "string" ? signal.reason : "Request cancelled",
|
|
638
|
+
);
|
|
449
639
|
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
|
|
5
|
+
const configSchema = z.object({
|
|
6
|
+
ask: z.boolean().optional(),
|
|
7
|
+
connect: z.string().min(1).optional(),
|
|
8
|
+
latestWorkflow: z.boolean().optional(),
|
|
9
|
+
listen: z.union([z.boolean(), z.number().int().min(1).max(65535)]).optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export async function readConfig(agentDir: string) {
|
|
13
|
+
let contents: string;
|
|
14
|
+
try {
|
|
15
|
+
contents = await readFile(join(agentDir, "chappie.json"), "utf8");
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
return configSchema.parse(JSON.parse(contents));
|
|
21
|
+
}
|
package/src/delivery.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
1
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
3
2
|
import {
|
|
4
3
|
contentWithImageReferences,
|
|
@@ -9,74 +8,46 @@ export interface DeliveryRecord {
|
|
|
9
8
|
id: string;
|
|
10
9
|
chatId: string;
|
|
11
10
|
sessionId: string;
|
|
12
|
-
|
|
13
|
-
toolCallIds: string[];
|
|
14
|
-
inlineResults?: ToolResultMessage[];
|
|
15
|
-
error?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface ResolvedDelivery extends DeliveryRecord {
|
|
11
|
+
cwd: string;
|
|
19
12
|
toolResults: ToolResultMessage[];
|
|
13
|
+
error?: string;
|
|
20
14
|
}
|
|
21
15
|
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
toolResults = delivery.toolCallIds.flatMap((id) => {
|
|
46
|
-
const result = found.get(id);
|
|
47
|
-
return result ? [result] : [];
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return { ...delivery, toolResults };
|
|
16
|
+
export function toolResultsContent(
|
|
17
|
+
toolResults: ToolResultMessage[],
|
|
18
|
+
sessionId: string,
|
|
19
|
+
) {
|
|
20
|
+
return toolResults.flatMap((result) => [
|
|
21
|
+
{
|
|
22
|
+
type: "text" as const,
|
|
23
|
+
text: JSON.stringify({
|
|
24
|
+
toolCallId: result.toolCallId,
|
|
25
|
+
toolName: result.toolName,
|
|
26
|
+
isError: result.isError,
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
...contentWithImageReferences(sessionId, result.content),
|
|
30
|
+
...resourceDescriptors(result.details).map((resource) => ({
|
|
31
|
+
type: "resource_link" as const,
|
|
32
|
+
uri: resource.uri,
|
|
33
|
+
name: resource.name,
|
|
34
|
+
mimeType: resource.mimeType,
|
|
35
|
+
size: resource.size,
|
|
36
|
+
})),
|
|
37
|
+
]);
|
|
51
38
|
}
|
|
52
39
|
|
|
53
|
-
export function deliveryContent(deliveries:
|
|
40
|
+
export function deliveryContent(deliveries: DeliveryRecord[]) {
|
|
54
41
|
return deliveries.flatMap((delivery) => [
|
|
55
42
|
{
|
|
56
43
|
type: "text" as const,
|
|
57
44
|
text: JSON.stringify({
|
|
58
45
|
deferredResult: delivery.id,
|
|
59
46
|
sessionId: delivery.sessionId,
|
|
47
|
+
cwd: delivery.cwd,
|
|
60
48
|
error: delivery.error,
|
|
61
49
|
}),
|
|
62
50
|
},
|
|
63
|
-
...delivery.toolResults.
|
|
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
|
-
]),
|
|
51
|
+
...toolResultsContent(delivery.toolResults, delivery.sessionId),
|
|
81
52
|
]);
|
|
82
53
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,16 +19,21 @@ export default async function chappie(pi: ExtensionAPI): Promise<void> {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
const agentDir = getAgentDir();
|
|
23
|
+
const [
|
|
24
|
+
{ readConfig },
|
|
25
|
+
{ createChappieProvider },
|
|
26
|
+
{ LocalSession },
|
|
27
|
+
{ transfer },
|
|
28
|
+
] = await Promise.all([
|
|
29
|
+
import("./config.ts"),
|
|
30
|
+
import("./provider.ts"),
|
|
31
|
+
import("./session.ts"),
|
|
32
|
+
import("./transfer.ts"),
|
|
33
|
+
]);
|
|
34
|
+
const config = await readConfig(agentDir);
|
|
35
|
+
const session = new LocalSession(pi, agentDir, config.connect);
|
|
29
36
|
session.install();
|
|
30
37
|
pi.registerTool(transfer);
|
|
31
|
-
pi.registerProvider(
|
|
32
|
-
createChappieProvider((output, context) => session.start(output, context)),
|
|
33
|
-
);
|
|
38
|
+
pi.registerProvider(createChappieProvider((output) => session.start(output)));
|
|
34
39
|
}
|
package/src/instructions.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
Chappie connects this ChatGPT conversation to
|
|
1
|
+
Chappie connects this ChatGPT conversation to Pi sessions on one or more devices. Resume work with init using the task's Pi sessionId, including in a new chat or branch. When only a project or session name is known, find its device/cwd/name in sessions, then call init. If the target is absent or ambiguous, ask the user to resolve it. Pi sessions appear while the chappie/chatgpt provider is selected.
|
|
2
|
+
|
|
3
|
+
init sets this chat's default Pi session. For a task without a specified target, omitting sessionId reuses that default or selects the first online session with no saved bindings. These sessions may already contain work. Defaults survive broker restarts, and several chats can share one Pi session. Other tools' sessionId affects only that call.
|
|
2
4
|
|
|
3
5
|
The active model is this existing ChatGPT conversation. A Pi tool that starts another chappie/chatgpt agent has no ChatGPT conversation to attach to and will wait indefinitely. Subagents targeting another configured model keep that provider's normal behavior.
|
|
4
6
|
|
|
5
7
|
Prefer ChatGPT's web search, connectors, and cloud tools for remote research and cloud-side work. Use Chappie for local files, processes, Pi extensions, and Pi user interfaces. Pi project-memory tools operate on their local stores; Pi context-reduction tools do not change this ChatGPT conversation.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Respond promptly to new Pi user input with a substantive reply, interaction, or immediate action that makes the response apparent in Pi before continuing lengthy work. Address inputs received together in one response; an immediate answer or result serves as its own acknowledgment.
|
|
10
|
+
|
|
11
|
+
Use chat for assistant messages in Pi, including progress, explanations, and results. Use read, bash, edit, write, and transfer directly. init.tools is a Pi tool catalog; tools returns full definitions for call. Invoke Chappie's MCP tools directly. Each call array is one native Pi batch; separate calls are separate Pi turns. For Pi interaction, call an installed interactive tool.
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
transfer pairs files from ChatGPT with Pi destination paths in order. Omit files to return resource links for Pi paths or chappie:// image references. Relative paths use the Pi working directory; overwrite: true replaces existing targets. The host may request confirmation when retrieving exported bytes.
|
|
10
14
|
|
|
11
|
-
Tool
|
|
15
|
+
Tool results identify the executing Pi sessionId and cwd. A shell command can access another directory without changing its Pi session. structuredContent.text includes the complete text, new Pi input, webAnswer, and deferred results; images and resources are native content blocks. Continue from received results rather than repeating work. Host deadlines include queueing and execution; use local persistent processes for longer work.
|