@zetaloop/chappie 0.3.2 → 0.4.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 +11 -3
- package/docs/tools.md +49 -3
- package/package.json +1 -1
- package/src/activity.ts +21 -0
- package/src/broker.ts +453 -116
- package/src/config.ts +1 -1
- package/src/delivery.ts +3 -2
- package/src/history.ts +123 -0
- package/src/instructions.md +3 -1
- package/src/ipc.ts +14 -1
- package/src/provider.ts +2 -1
- package/src/server.ts +140 -34
- package/src/session.ts +49 -7
- package/src/state.ts +13 -32
- package/src/tools.ts +10 -1
package/src/broker.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import { type Activity, chatLabel, source } from "./activity.ts";
|
|
3
4
|
import { readConfig } from "./config.ts";
|
|
4
5
|
import type { DeliveryRecord } from "./delivery.ts";
|
|
6
|
+
import { type HistoryRange, historyInstructions } from "./history.ts";
|
|
5
7
|
import {
|
|
6
8
|
type BrokerMessage,
|
|
7
9
|
IpcServer,
|
|
@@ -23,6 +25,9 @@ import { type ResourceData, resourceSessionId } from "./resources.ts";
|
|
|
23
25
|
import { State } from "./state.ts";
|
|
24
26
|
import type { ToolInput } from "./tools.ts";
|
|
25
27
|
|
|
28
|
+
const exitInstructions =
|
|
29
|
+
"Send one final chat message stating your task, entry time, and that this execution is ending; keep all codes private. Then end this ChatGPT response immediately with no further tool calls. Do not wait for unlock, poll history, reinitialize, or resume this task after release.";
|
|
30
|
+
|
|
26
31
|
interface RegisteredSession {
|
|
27
32
|
description: SessionDescription;
|
|
28
33
|
peer: JsonLinePeer<SessionMessage, BrokerMessage>;
|
|
@@ -36,9 +41,21 @@ interface PendingRequest {
|
|
|
36
41
|
onAbort(): void;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
interface
|
|
40
|
-
|
|
44
|
+
interface InitializationGrant {
|
|
45
|
+
sessionId: string;
|
|
46
|
+
code: string;
|
|
47
|
+
previousCode: string | undefined;
|
|
48
|
+
committed: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Operation {
|
|
52
|
+
chatId: string;
|
|
53
|
+
sessionId: string | undefined;
|
|
41
54
|
controller: AbortController;
|
|
55
|
+
communication: boolean;
|
|
56
|
+
coordinating: boolean;
|
|
57
|
+
revisions: Map<string, number>;
|
|
58
|
+
initialization?: InitializationGrant;
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
interface ChangeWaiter {
|
|
@@ -48,28 +65,34 @@ interface ChangeWaiter {
|
|
|
48
65
|
onAbort(): void;
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
export interface Initialization {
|
|
69
|
+
code?: string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
instructions: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
export interface InitializedSession extends Omit<SessionInspection, "tools"> {
|
|
52
75
|
selection: "existing" | "explicit" | "automatic";
|
|
76
|
+
initialization?: Initialization;
|
|
53
77
|
globalAgents?: string;
|
|
54
78
|
inputs: SessionInput[];
|
|
55
79
|
tools: { name: string; description: string }[];
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
export interface InspectedSession extends SessionInspection {
|
|
83
|
+
initialization?: Initialization;
|
|
59
84
|
inputs: SessionInput[];
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
export interface ChatResult {
|
|
88
|
+
initialization?: Initialization;
|
|
63
89
|
sessionId: string;
|
|
64
90
|
cwd: string;
|
|
65
91
|
inputs: SessionInput[];
|
|
66
92
|
}
|
|
67
93
|
|
|
68
|
-
export interface CallResult {
|
|
69
|
-
sessionId: string;
|
|
70
|
-
cwd: string;
|
|
94
|
+
export interface CallResult extends ChatResult {
|
|
71
95
|
toolResults: ToolResultMessage[];
|
|
72
|
-
inputs: SessionInput[];
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
export class Broker {
|
|
@@ -79,9 +102,12 @@ export class Broker {
|
|
|
79
102
|
readonly #sessions = new Map<string, RegisteredSession>();
|
|
80
103
|
readonly #pending = new Map<number, PendingRequest>();
|
|
81
104
|
readonly #waiters = new Set<ChangeWaiter>();
|
|
82
|
-
readonly #
|
|
105
|
+
readonly #operations = new Map<AbortSignal, Operation>();
|
|
106
|
+
readonly #locks = new Map<string, boolean>();
|
|
107
|
+
readonly #startingLocks = new Set<string>();
|
|
108
|
+
readonly #syncRevisions = new Map<string, number>();
|
|
83
109
|
#ask = true;
|
|
84
|
-
#
|
|
110
|
+
#sync = false;
|
|
85
111
|
#nextRequestId = 1;
|
|
86
112
|
|
|
87
113
|
constructor(agentDir: string) {
|
|
@@ -97,17 +123,20 @@ export class Broker {
|
|
|
97
123
|
async start(): Promise<void> {
|
|
98
124
|
const config = await readConfig(this.#agentDir);
|
|
99
125
|
this.#ask = config.ask ?? true;
|
|
100
|
-
this.#
|
|
126
|
+
this.#sync = config.sync ?? false;
|
|
101
127
|
await this.#state.load();
|
|
102
128
|
await this.#ipc.start(config.listen ?? false);
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
async close(): Promise<void> {
|
|
106
132
|
const error = new Error("Chappie broker ended");
|
|
107
|
-
for (const
|
|
108
|
-
|
|
133
|
+
for (const operation of this.#operations.values()) {
|
|
134
|
+
operation.controller.abort(error);
|
|
109
135
|
}
|
|
110
|
-
this.#
|
|
136
|
+
this.#operations.clear();
|
|
137
|
+
this.#locks.clear();
|
|
138
|
+
this.#startingLocks.clear();
|
|
139
|
+
this.#syncRevisions.clear();
|
|
111
140
|
for (const [id, pending] of this.#pending) {
|
|
112
141
|
this.#finishRequest(id, pending);
|
|
113
142
|
pending.reject(error);
|
|
@@ -121,43 +150,213 @@ export class Broker {
|
|
|
121
150
|
await this.#ipc.close();
|
|
122
151
|
}
|
|
123
152
|
|
|
124
|
-
async
|
|
125
|
-
chatId: string
|
|
153
|
+
async run<T>(
|
|
154
|
+
chatId: string,
|
|
155
|
+
sessionId: string | undefined,
|
|
156
|
+
signal: AbortSignal,
|
|
157
|
+
callback: (signal: AbortSignal) => Promise<T>,
|
|
158
|
+
communication = false,
|
|
159
|
+
): Promise<T> {
|
|
160
|
+
if (!communication) this.#requireUnlocked(chatId, sessionId, signal);
|
|
161
|
+
if (!this.#sync) return callback(signal);
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const combined = AbortSignal.any([signal, controller.signal]);
|
|
164
|
+
const operation: Operation = {
|
|
165
|
+
chatId,
|
|
166
|
+
sessionId,
|
|
167
|
+
controller,
|
|
168
|
+
communication,
|
|
169
|
+
coordinating: false,
|
|
170
|
+
revisions: new Map(),
|
|
171
|
+
};
|
|
172
|
+
this.#operations.set(combined, operation);
|
|
173
|
+
this.#observe(operation, this.#state.binding(chatId));
|
|
174
|
+
this.#observe(operation, sessionId);
|
|
175
|
+
try {
|
|
176
|
+
const result = await callback(combined);
|
|
177
|
+
combined.throwIfAborted();
|
|
178
|
+
const initialization = operation.initialization;
|
|
179
|
+
if (initialization) {
|
|
180
|
+
initialization.previousCode = this.#state.code(
|
|
181
|
+
initialization.sessionId,
|
|
182
|
+
);
|
|
183
|
+
initialization.committed = true;
|
|
184
|
+
await this.#state.setCode(
|
|
185
|
+
initialization.sessionId,
|
|
186
|
+
initialization.code,
|
|
187
|
+
);
|
|
188
|
+
combined.throwIfAborted();
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await this.#rollbackInitialization(operation);
|
|
193
|
+
throw error;
|
|
194
|
+
} finally {
|
|
195
|
+
this.#operations.delete(combined);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
deliveryAllowed(signal: AbortSignal): boolean {
|
|
200
|
+
const operation = this.#operations.get(signal);
|
|
201
|
+
if (!operation?.communication) return true;
|
|
202
|
+
if (operation.coordinating) return false;
|
|
203
|
+
for (const [sessionId, revision] of operation.revisions) {
|
|
204
|
+
if (
|
|
205
|
+
this.#locks.has(sessionId) ||
|
|
206
|
+
(this.#syncRevisions.get(sessionId) ?? 0) !== revision
|
|
207
|
+
)
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get syncEnabled(): boolean {
|
|
214
|
+
return this.#sync;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
syncState(sessionId: string) {
|
|
218
|
+
return {
|
|
219
|
+
locked: this.#locks.has(sessionId),
|
|
220
|
+
verified: this.#locks.get(sessionId) ?? false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
synchronizing(chatId?: string, sessionId?: string): string | undefined {
|
|
225
|
+
const boundId = chatId ? this.#state.binding(chatId) : undefined;
|
|
226
|
+
if (boundId && this.#locks.has(boundId)) return boundId;
|
|
227
|
+
if (sessionId && this.#locks.has(sessionId)) return sessionId;
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async sync(
|
|
232
|
+
chatId: string,
|
|
233
|
+
sessionId: string | undefined,
|
|
234
|
+
action: "start" | "verify" | "release",
|
|
235
|
+
code: string | undefined,
|
|
126
236
|
requestId: unknown,
|
|
127
237
|
signal: AbortSignal,
|
|
128
|
-
)
|
|
129
|
-
if (!this.#
|
|
130
|
-
const id = workflowId(requestId);
|
|
131
|
-
if (!id) return signal;
|
|
238
|
+
) {
|
|
239
|
+
if (!this.#sync) throw new Error("Session synchronization is disabled");
|
|
132
240
|
signal.throwIfAborted();
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
241
|
+
const target = sessionId ?? this.#state.binding(chatId);
|
|
242
|
+
if (!target) throw new Error("Specify a Pi sessionId to synchronize");
|
|
243
|
+
const locked = this.synchronizing(chatId);
|
|
244
|
+
if (locked && locked !== target) throw new Error(syncMessage(locked));
|
|
245
|
+
const activity = source(chatId, requestId);
|
|
246
|
+
const result = { sessionId: target };
|
|
247
|
+
if (action === "start") {
|
|
248
|
+
if (!this.#locks.has(target)) {
|
|
249
|
+
this.#locks.set(target, false);
|
|
250
|
+
this.#startingLocks.add(target);
|
|
251
|
+
this.#syncRevisions.set(
|
|
252
|
+
target,
|
|
253
|
+
(this.#syncRevisions.get(target) ?? 0) + 1,
|
|
254
|
+
);
|
|
255
|
+
const error = new Error(syncMessage(target));
|
|
256
|
+
const rollbacks: Promise<void>[] = [];
|
|
257
|
+
for (const operation of this.#operations.values()) {
|
|
258
|
+
if (
|
|
259
|
+
operation.sessionId !== target &&
|
|
260
|
+
this.#state.binding(operation.chatId) !== target
|
|
261
|
+
)
|
|
262
|
+
continue;
|
|
263
|
+
if (operation.initialization)
|
|
264
|
+
rollbacks.push(this.#rollbackInitialization(operation));
|
|
265
|
+
if (!operation.communication || operation.initialization)
|
|
266
|
+
operation.controller.abort(error);
|
|
267
|
+
}
|
|
268
|
+
this.#notifyChange();
|
|
269
|
+
try {
|
|
270
|
+
await Promise.all(rollbacks);
|
|
271
|
+
if (!this.#state.code(target)) {
|
|
272
|
+
this.#locks.delete(target);
|
|
273
|
+
this.#notifyChange();
|
|
274
|
+
throw new Error(
|
|
275
|
+
"Initialize this Pi session before starting synchronization",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
await this.#notify(
|
|
279
|
+
target,
|
|
280
|
+
`${chatLabel(activity)} started synchronization`,
|
|
281
|
+
{ ...activity, event: "sync_started" },
|
|
282
|
+
);
|
|
283
|
+
} finally {
|
|
284
|
+
this.#startingLocks.delete(target);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
...result,
|
|
289
|
+
...this.syncState(target),
|
|
290
|
+
instructions: syncMessage(target),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (this.#startingLocks.has(target))
|
|
294
|
+
throw new Error("Synchronization is still starting; retry this action");
|
|
295
|
+
if (!this.#locks.has(target))
|
|
296
|
+
throw new Error("This Pi session is not synchronizing");
|
|
297
|
+
const current = this.#state.code(target);
|
|
298
|
+
if (!current)
|
|
299
|
+
throw new Error("This Pi session has no synchronization code");
|
|
300
|
+
if (code !== current) {
|
|
301
|
+
await this.#notify(
|
|
302
|
+
target,
|
|
303
|
+
`${chatLabel(activity)} could not verify synchronization`,
|
|
304
|
+
{ ...activity, event: "sync_rejected" },
|
|
305
|
+
);
|
|
306
|
+
throw new Error(
|
|
307
|
+
`The sync code is missing or outdated. This execution is a stale, accidental duplicate. ${exitInstructions}`,
|
|
308
|
+
);
|
|
146
309
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
310
|
+
if (action === "verify") {
|
|
311
|
+
let renewed: string | undefined;
|
|
312
|
+
if (!this.#locks.get(target)) {
|
|
313
|
+
this.#locks.set(target, true);
|
|
314
|
+
renewed = randomBytes(6).toString("hex");
|
|
315
|
+
await this.#state.setCode(target, renewed);
|
|
316
|
+
await this.#notify(
|
|
317
|
+
target,
|
|
318
|
+
`${chatLabel(activity)} verified synchronization`,
|
|
319
|
+
{ ...activity, event: "sync_verified" },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
...result,
|
|
324
|
+
...this.syncState(target),
|
|
325
|
+
...(renewed ? { code: renewed } : {}),
|
|
326
|
+
instructions:
|
|
327
|
+
"This is the surviving execution. Keep the verified code private in this ChatGPT context. Use chat and history to identify every observed conflicting execution and require its final exit. Confirm they have ended their responses, rather than only paused ordinary tools. Verification, idle Pi status, or a quiet history page alone does not establish their exit. Release with this code only after explicit final exit statements and resolution of conflicting activity; ordinary tools remain locked until then.",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (!this.#locks.get(target))
|
|
331
|
+
throw new Error(
|
|
332
|
+
"Verify the initialization code before releasing synchronization",
|
|
333
|
+
);
|
|
334
|
+
this.#locks.delete(target);
|
|
335
|
+
this.#notifyChange();
|
|
336
|
+
await this.#notify(
|
|
337
|
+
target,
|
|
338
|
+
`${chatLabel(activity)} released synchronization`,
|
|
339
|
+
{ ...activity, event: "sync_released" },
|
|
340
|
+
);
|
|
341
|
+
return {
|
|
342
|
+
...result,
|
|
343
|
+
...this.syncState(target),
|
|
344
|
+
instructions:
|
|
345
|
+
"Synchronization released. The surviving execution may continue the current task. Executions with rejected or unavailable codes remain finished.",
|
|
346
|
+
};
|
|
150
347
|
}
|
|
151
348
|
|
|
152
|
-
listSessions(
|
|
153
|
-
|
|
154
|
-
|
|
349
|
+
listSessions(sessionId?: string): (SessionDescription & {
|
|
350
|
+
bindingCount: number;
|
|
351
|
+
sync?: { locked: boolean; verified: boolean };
|
|
352
|
+
})[] {
|
|
155
353
|
const counts = this.#state.bindingCounts();
|
|
156
354
|
return [...this.#sessions.values()]
|
|
157
355
|
.filter(({ description }) => !sessionId || description.id === sessionId)
|
|
158
356
|
.map(({ description }) => ({
|
|
159
357
|
...description,
|
|
160
358
|
bindingCount: counts.get(description.id) ?? 0,
|
|
359
|
+
...(this.#sync ? { sync: this.syncState(description.id) } : {}),
|
|
161
360
|
}));
|
|
162
361
|
}
|
|
163
362
|
|
|
@@ -175,20 +374,19 @@ export class Broker {
|
|
|
175
374
|
requestId: unknown,
|
|
176
375
|
signal: AbortSignal,
|
|
177
376
|
): Promise<InitializedSession> {
|
|
178
|
-
const {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
true,
|
|
184
|
-
);
|
|
377
|
+
const {
|
|
378
|
+
sessionId: target,
|
|
379
|
+
selection,
|
|
380
|
+
initialization,
|
|
381
|
+
} = await this.#selectSession(chatId, sessionId, requestId, signal, true);
|
|
185
382
|
const { inspection, inputs, globalAgents } = await this.#inspect(
|
|
186
383
|
target,
|
|
187
384
|
signal,
|
|
188
385
|
);
|
|
189
|
-
await this.#ackInputs(target, inputs);
|
|
386
|
+
await this.#ackInputs(target, inputs, signal);
|
|
190
387
|
return {
|
|
191
388
|
selection,
|
|
389
|
+
...(initialization ? { initialization } : {}),
|
|
192
390
|
...inspection,
|
|
193
391
|
tools: inspection.tools.map(({ name, description }) => ({
|
|
194
392
|
name,
|
|
@@ -206,20 +404,34 @@ export class Broker {
|
|
|
206
404
|
requestId: unknown,
|
|
207
405
|
signal: AbortSignal,
|
|
208
406
|
): Promise<ChatResult> {
|
|
209
|
-
const { sessionId: target } = await this.#selectSession(
|
|
407
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
210
408
|
chatId,
|
|
211
409
|
sessionId,
|
|
212
410
|
requestId,
|
|
213
411
|
signal,
|
|
412
|
+
false,
|
|
413
|
+
true,
|
|
214
414
|
);
|
|
215
415
|
const result = await this.#request(
|
|
216
416
|
target,
|
|
217
|
-
(id) => ({
|
|
417
|
+
(id) => ({
|
|
418
|
+
type: "chat",
|
|
419
|
+
id,
|
|
420
|
+
...source(chatId, requestId),
|
|
421
|
+
sessionId: target,
|
|
422
|
+
text,
|
|
423
|
+
}),
|
|
218
424
|
signal,
|
|
219
425
|
);
|
|
220
426
|
if ("message" in result) {
|
|
221
|
-
|
|
222
|
-
|
|
427
|
+
const inputs = this.deliveryAllowed(signal) ? result.inputs : [];
|
|
428
|
+
await this.#ackInputs(target, inputs, signal);
|
|
429
|
+
return {
|
|
430
|
+
sessionId: target,
|
|
431
|
+
cwd: result.cwd,
|
|
432
|
+
inputs,
|
|
433
|
+
...(initialization ? { initialization } : {}),
|
|
434
|
+
};
|
|
223
435
|
}
|
|
224
436
|
throw new Error("Pi session returned no assistant message");
|
|
225
437
|
}
|
|
@@ -231,17 +443,18 @@ export class Broker {
|
|
|
231
443
|
requestId: unknown,
|
|
232
444
|
signal: AbortSignal,
|
|
233
445
|
): Promise<InspectedSession> {
|
|
234
|
-
const { sessionId: target } = await this.#selectSession(
|
|
446
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
235
447
|
chatId,
|
|
236
448
|
sessionId,
|
|
237
449
|
requestId,
|
|
238
450
|
signal,
|
|
239
451
|
);
|
|
240
452
|
const { inspection, inputs } = await this.#inspect(target, signal);
|
|
241
|
-
await this.#ackInputs(target, inputs);
|
|
453
|
+
await this.#ackInputs(target, inputs, signal);
|
|
242
454
|
const selected = names ? new Set(names) : undefined;
|
|
243
455
|
return {
|
|
244
456
|
...inspection,
|
|
457
|
+
...(initialization ? { initialization } : {}),
|
|
245
458
|
tools: selected
|
|
246
459
|
? inspection.tools.filter(({ name }) => selected.has(name))
|
|
247
460
|
: inspection.tools,
|
|
@@ -256,7 +469,7 @@ export class Broker {
|
|
|
256
469
|
requestId: unknown,
|
|
257
470
|
signal: AbortSignal,
|
|
258
471
|
): Promise<CallResult> {
|
|
259
|
-
const { sessionId: target } = await this.#selectSession(
|
|
472
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
260
473
|
chatId,
|
|
261
474
|
sessionId,
|
|
262
475
|
requestId,
|
|
@@ -273,16 +486,17 @@ export class Broker {
|
|
|
273
486
|
(id) => ({
|
|
274
487
|
type: "call",
|
|
275
488
|
id,
|
|
276
|
-
chatId,
|
|
489
|
+
...source(chatId, requestId),
|
|
277
490
|
sessionId: target,
|
|
278
491
|
calls: toolCalls,
|
|
279
492
|
}),
|
|
280
493
|
signal,
|
|
281
494
|
);
|
|
282
495
|
if ("toolResults" in result) {
|
|
283
|
-
await this.#ackInputs(target, result.inputs);
|
|
496
|
+
await this.#ackInputs(target, result.inputs, signal);
|
|
284
497
|
return {
|
|
285
498
|
sessionId: target,
|
|
499
|
+
...(initialization ? { initialization } : {}),
|
|
286
500
|
cwd: result.cwd,
|
|
287
501
|
toolResults: result.toolResults,
|
|
288
502
|
inputs: result.inputs,
|
|
@@ -291,19 +505,42 @@ export class Broker {
|
|
|
291
505
|
throw new Error("Pi session returned no tool results");
|
|
292
506
|
}
|
|
293
507
|
|
|
294
|
-
async
|
|
508
|
+
async history(
|
|
295
509
|
chatId: string,
|
|
296
510
|
sessionId: string | undefined,
|
|
511
|
+
range: HistoryRange,
|
|
297
512
|
requestId: unknown,
|
|
298
513
|
signal: AbortSignal,
|
|
514
|
+
) {
|
|
515
|
+
const target = sessionId ?? this.#state.binding(chatId);
|
|
516
|
+
if (!target) throw new Error("Specify a Pi sessionId to read history");
|
|
517
|
+
await this.#waitForSession(target, signal);
|
|
518
|
+
const result = await this.#request(
|
|
519
|
+
target,
|
|
520
|
+
(id) => ({
|
|
521
|
+
type: "history",
|
|
522
|
+
id,
|
|
523
|
+
sessionId: target,
|
|
524
|
+
range,
|
|
525
|
+
...source(chatId, requestId),
|
|
526
|
+
}),
|
|
527
|
+
signal,
|
|
528
|
+
);
|
|
529
|
+
if ("history" in result) return { sessionId: target, ...result };
|
|
530
|
+
throw new Error("Pi session returned no history");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async inputs(
|
|
534
|
+
chatId: string,
|
|
535
|
+
sessionId: string | undefined,
|
|
536
|
+
signal: AbortSignal,
|
|
299
537
|
): Promise<SessionInput[]> {
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
signal.throwIfAborted();
|
|
304
|
-
if (target === boundId) await this.#join(chatId, target, requestId);
|
|
538
|
+
const target = sessionId ?? this.#state.binding(chatId);
|
|
539
|
+
if (!target || !this.#sessions.has(target) || !this.deliveryAllowed(signal))
|
|
540
|
+
return [];
|
|
305
541
|
const { inputs } = await this.#inspect(target, signal);
|
|
306
|
-
|
|
542
|
+
if (!this.deliveryAllowed(signal)) return [];
|
|
543
|
+
await this.#ackInputs(target, inputs, signal);
|
|
307
544
|
return inputs;
|
|
308
545
|
}
|
|
309
546
|
|
|
@@ -313,13 +550,14 @@ export class Broker {
|
|
|
313
550
|
input: QuestionInput,
|
|
314
551
|
requestId: unknown,
|
|
315
552
|
signal: AbortSignal,
|
|
316
|
-
): Promise<Question> {
|
|
317
|
-
const { sessionId: target } = await this.#selectSession(
|
|
553
|
+
): Promise<Question & { initialization?: Initialization }> {
|
|
554
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
318
555
|
chatId,
|
|
319
556
|
sessionId,
|
|
320
557
|
requestId,
|
|
321
558
|
signal,
|
|
322
559
|
);
|
|
560
|
+
this.#requireUnlocked(chatId, target, signal);
|
|
323
561
|
const session = this.#sessions.get(target);
|
|
324
562
|
if (!session) throw new Error(`Pi session ${target} is offline`);
|
|
325
563
|
const question: QuestionRecord = {
|
|
@@ -331,11 +569,19 @@ export class Broker {
|
|
|
331
569
|
delivered: false,
|
|
332
570
|
};
|
|
333
571
|
await this.#state.addQuestion(question);
|
|
572
|
+
const activity = source(chatId, requestId);
|
|
334
573
|
void this.#notify(
|
|
335
574
|
target,
|
|
336
|
-
|
|
575
|
+
`${chatLabel(activity)} asked: ${question.question}`,
|
|
576
|
+
{
|
|
577
|
+
event: "asked",
|
|
578
|
+
...activity,
|
|
579
|
+
},
|
|
337
580
|
).catch(() => {});
|
|
338
|
-
return
|
|
581
|
+
return {
|
|
582
|
+
...questionView(question),
|
|
583
|
+
...(initialization ? { initialization } : {}),
|
|
584
|
+
};
|
|
339
585
|
}
|
|
340
586
|
|
|
341
587
|
async assertQuestion(
|
|
@@ -346,6 +592,7 @@ export class Broker {
|
|
|
346
592
|
for (;;) {
|
|
347
593
|
signal.throwIfAborted();
|
|
348
594
|
const question = this.#state.question(chatId, id);
|
|
595
|
+
this.#requireUnlocked(chatId, question.sessionId, signal);
|
|
349
596
|
if (question.loaded) return questionView(question);
|
|
350
597
|
await this.#waitForChange(signal);
|
|
351
598
|
}
|
|
@@ -380,14 +627,20 @@ export class Broker {
|
|
|
380
627
|
: previous
|
|
381
628
|
? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
|
|
382
629
|
: `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
|
|
383
|
-
void this.#notify(question.sessionId, message
|
|
630
|
+
void this.#notify(question.sessionId, message, {
|
|
631
|
+
event: question.answer?.skipped ? "skipped" : "answered",
|
|
632
|
+
chatId,
|
|
633
|
+
}).catch(() => {});
|
|
384
634
|
}
|
|
385
635
|
}
|
|
386
636
|
return questionView(question);
|
|
387
637
|
}
|
|
388
638
|
|
|
389
639
|
answers(chatId: string): QuestionRecord[] {
|
|
390
|
-
|
|
640
|
+
if (this.synchronizing(chatId)) return [];
|
|
641
|
+
return this.#state
|
|
642
|
+
.answers(chatId)
|
|
643
|
+
.filter((question) => !this.#locks.has(question.sessionId));
|
|
391
644
|
}
|
|
392
645
|
|
|
393
646
|
async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
|
|
@@ -403,7 +656,10 @@ export class Broker {
|
|
|
403
656
|
}
|
|
404
657
|
|
|
405
658
|
deliveries(chatId: string): DeliveryRecord[] {
|
|
406
|
-
|
|
659
|
+
if (this.synchronizing(chatId)) return [];
|
|
660
|
+
return this.#state
|
|
661
|
+
.deliveries(chatId)
|
|
662
|
+
.filter((delivery) => !this.#locks.has(delivery.sessionId));
|
|
407
663
|
}
|
|
408
664
|
|
|
409
665
|
acknowledge(
|
|
@@ -432,7 +688,9 @@ export class Broker {
|
|
|
432
688
|
sessionId: message.session.id,
|
|
433
689
|
});
|
|
434
690
|
if (!registered)
|
|
435
|
-
await this.#notify(message.session.id, "Chappie connected"
|
|
691
|
+
await this.#notify(message.session.id, "Chappie connected", {
|
|
692
|
+
event: "connected",
|
|
693
|
+
});
|
|
436
694
|
this.#notifyChange();
|
|
437
695
|
break;
|
|
438
696
|
}
|
|
@@ -465,40 +723,48 @@ export class Broker {
|
|
|
465
723
|
requestId: unknown,
|
|
466
724
|
signal: AbortSignal,
|
|
467
725
|
bindRequested = false,
|
|
726
|
+
communication = false,
|
|
468
727
|
): Promise<{
|
|
469
728
|
sessionId: string;
|
|
470
729
|
selection: InitializedSession["selection"];
|
|
730
|
+
initialization?: Initialization;
|
|
471
731
|
}> {
|
|
472
|
-
if (requestedId) {
|
|
473
|
-
await this.#waitForSession(requestedId, signal);
|
|
474
|
-
signal.throwIfAborted();
|
|
475
|
-
const boundId = this.#state.binding(chatId);
|
|
476
|
-
if (bindRequested || !boundId || boundId === requestedId) {
|
|
477
|
-
await this.#join(chatId, requestedId, requestId, bindRequested);
|
|
478
|
-
}
|
|
479
|
-
return { sessionId: requestedId, selection: "explicit" };
|
|
480
|
-
}
|
|
481
|
-
|
|
482
732
|
for (;;) {
|
|
483
733
|
signal.throwIfAborted();
|
|
484
734
|
const boundId = this.#state.binding(chatId);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
return { sessionId: boundId, selection: "existing" };
|
|
735
|
+
let target = requestedId ?? boundId;
|
|
736
|
+
if (!target) {
|
|
737
|
+
const occupied = this.#state.bindingCounts();
|
|
738
|
+
target = [...this.#sessions.keys()].find(
|
|
739
|
+
(id) => !occupied.has(id) && !this.#locks.has(id),
|
|
740
|
+
);
|
|
492
741
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (candidate) {
|
|
498
|
-
await this.#join(chatId, candidate, requestId, bindRequested);
|
|
499
|
-
return { sessionId: candidate, selection: "automatic" };
|
|
742
|
+
if (!communication) this.#requireUnlocked(chatId, target, signal);
|
|
743
|
+
if (!target) {
|
|
744
|
+
await this.#waitForChange(signal);
|
|
745
|
+
continue;
|
|
500
746
|
}
|
|
501
|
-
await this.#
|
|
747
|
+
await this.#waitForSession(target, signal);
|
|
748
|
+
signal.throwIfAborted();
|
|
749
|
+
if (!communication) this.#requireUnlocked(chatId, target, signal);
|
|
750
|
+
const current = this.#state.binding(chatId);
|
|
751
|
+
const initialize =
|
|
752
|
+
bindRequested ||
|
|
753
|
+
!current ||
|
|
754
|
+
(current === target && this.#sync && !this.#state.code(target));
|
|
755
|
+
const initialization =
|
|
756
|
+
initialize && !this.synchronizing(chatId, target)
|
|
757
|
+
? await this.#join(chatId, target, requestId, signal, bindRequested)
|
|
758
|
+
: undefined;
|
|
759
|
+
return {
|
|
760
|
+
sessionId: target,
|
|
761
|
+
selection: requestedId
|
|
762
|
+
? "explicit"
|
|
763
|
+
: boundId
|
|
764
|
+
? "existing"
|
|
765
|
+
: "automatic",
|
|
766
|
+
...(initialization ? { initialization } : {}),
|
|
767
|
+
};
|
|
502
768
|
}
|
|
503
769
|
}
|
|
504
770
|
|
|
@@ -506,28 +772,94 @@ export class Broker {
|
|
|
506
772
|
chatId: string,
|
|
507
773
|
sessionId: string,
|
|
508
774
|
requestId: unknown,
|
|
775
|
+
signal: AbortSignal,
|
|
509
776
|
explicit = false,
|
|
510
|
-
): Promise<
|
|
777
|
+
): Promise<Initialization> {
|
|
778
|
+
this.#requireUnlocked(chatId, sessionId, signal);
|
|
779
|
+
const operation = this.#operations.get(signal);
|
|
780
|
+
if (operation) this.#observe(operation, sessionId);
|
|
511
781
|
const previous = this.#state.binding(chatId);
|
|
512
|
-
const
|
|
513
|
-
|
|
782
|
+
const code = this.#sync ? randomBytes(6).toString("hex") : undefined;
|
|
783
|
+
await this.#state.bind(chatId, sessionId);
|
|
784
|
+
this.#requireUnlocked(chatId, sessionId, signal);
|
|
785
|
+
if (code) {
|
|
786
|
+
if (!operation)
|
|
787
|
+
throw new Error(
|
|
788
|
+
"Initialization is outside a tracked Chappie operation",
|
|
789
|
+
);
|
|
790
|
+
operation.initialization = {
|
|
791
|
+
sessionId,
|
|
792
|
+
code,
|
|
793
|
+
previousCode: undefined,
|
|
794
|
+
committed: false,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const activity = source(chatId, requestId);
|
|
514
798
|
if (previous !== sessionId) {
|
|
515
799
|
this.#notifyChange();
|
|
516
800
|
if (previous)
|
|
517
|
-
await this.#notify(previous,
|
|
801
|
+
await this.#notify(previous, `${chatLabel(activity)} left`, {
|
|
802
|
+
...activity,
|
|
803
|
+
event: "left",
|
|
804
|
+
});
|
|
518
805
|
}
|
|
519
|
-
|
|
520
|
-
|
|
806
|
+
this.#requireUnlocked(chatId, sessionId, signal);
|
|
807
|
+
await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
|
|
808
|
+
...activity,
|
|
809
|
+
event: "joined",
|
|
810
|
+
initialization: explicit ? "explicit" : "implicit",
|
|
811
|
+
});
|
|
812
|
+
return {
|
|
813
|
+
sessionId,
|
|
814
|
+
...(code ? { code } : {}),
|
|
815
|
+
instructions: code
|
|
816
|
+
? `${historyInstructions} Retain this initialization code in this ChatGPT context for sync verification; Pi history and chat messages contain only coordination details.`
|
|
817
|
+
: historyInstructions,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
#requireUnlocked(
|
|
822
|
+
chatId: string,
|
|
823
|
+
sessionId: string | undefined,
|
|
824
|
+
signal: AbortSignal,
|
|
825
|
+
): void {
|
|
826
|
+
signal.throwIfAborted();
|
|
827
|
+
const operation = this.#operations.get(signal);
|
|
828
|
+
if (operation) this.#observe(operation, sessionId);
|
|
829
|
+
const locked = this.synchronizing(chatId, sessionId);
|
|
830
|
+
if (locked) throw new Error(syncMessage(locked));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#observe(operation: Operation, sessionId: string | undefined): void {
|
|
834
|
+
if (!sessionId) return;
|
|
835
|
+
operation.sessionId ??= sessionId;
|
|
836
|
+
if (!operation.revisions.has(sessionId)) {
|
|
837
|
+
operation.revisions.set(
|
|
521
838
|
sessionId,
|
|
522
|
-
|
|
839
|
+
this.#syncRevisions.get(sessionId) ?? 0,
|
|
523
840
|
);
|
|
524
841
|
}
|
|
842
|
+
if (this.#locks.has(sessionId)) operation.coordinating = true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async #rollbackInitialization(operation: Operation): Promise<void> {
|
|
846
|
+
const initialization = operation.initialization;
|
|
847
|
+
if (!initialization?.committed) return;
|
|
848
|
+
initialization.committed = false;
|
|
849
|
+
await this.#state.setCode(
|
|
850
|
+
initialization.sessionId,
|
|
851
|
+
initialization.previousCode,
|
|
852
|
+
);
|
|
525
853
|
}
|
|
526
854
|
|
|
527
|
-
async #notify(
|
|
855
|
+
async #notify(
|
|
856
|
+
sessionId: string,
|
|
857
|
+
message: string,
|
|
858
|
+
activity: Activity = {},
|
|
859
|
+
): Promise<void> {
|
|
528
860
|
await this.#sessions
|
|
529
861
|
.get(sessionId)
|
|
530
|
-
?.peer.send({ type: "notice", sessionId, message });
|
|
862
|
+
?.peer.send({ type: "notice", sessionId, message, activity });
|
|
531
863
|
}
|
|
532
864
|
|
|
533
865
|
async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
|
|
@@ -547,7 +879,12 @@ export class Broker {
|
|
|
547
879
|
throw new Error("Pi session returned no inspection");
|
|
548
880
|
}
|
|
549
881
|
|
|
550
|
-
async #ackInputs(
|
|
882
|
+
async #ackInputs(
|
|
883
|
+
sessionId: string,
|
|
884
|
+
inputs: SessionInput[],
|
|
885
|
+
signal: AbortSignal,
|
|
886
|
+
): Promise<void> {
|
|
887
|
+
signal.throwIfAborted();
|
|
551
888
|
if (inputs.length === 0) return;
|
|
552
889
|
const session = this.#sessions.get(sessionId);
|
|
553
890
|
if (!session) throw new Error(`Pi session ${sessionId} is offline`);
|
|
@@ -563,6 +900,12 @@ export class Broker {
|
|
|
563
900
|
message: (id: number) => BrokerMessage,
|
|
564
901
|
signal: AbortSignal,
|
|
565
902
|
): Promise<SessionResult> {
|
|
903
|
+
const operation = this.#operations.get(signal);
|
|
904
|
+
if (operation) {
|
|
905
|
+
this.#observe(operation, sessionId);
|
|
906
|
+
if (!operation.communication)
|
|
907
|
+
this.#requireUnlocked(operation.chatId, sessionId, signal);
|
|
908
|
+
}
|
|
566
909
|
const session = this.#sessions.get(sessionId);
|
|
567
910
|
if (!session) throw new Error(`Pi session ${sessionId} is offline`);
|
|
568
911
|
if (signal.aborted) throw abortError(signal);
|
|
@@ -591,12 +934,10 @@ export class Broker {
|
|
|
591
934
|
};
|
|
592
935
|
this.#pending.set(id, pending);
|
|
593
936
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
594
|
-
|
|
595
|
-
await session.peer.send(message(id));
|
|
596
|
-
} catch (error) {
|
|
937
|
+
void session.peer.send(message(id)).catch((error: unknown) => {
|
|
597
938
|
this.#finishRequest(id, pending);
|
|
598
|
-
|
|
599
|
-
}
|
|
939
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
940
|
+
});
|
|
600
941
|
return completion.promise;
|
|
601
942
|
}
|
|
602
943
|
|
|
@@ -648,12 +989,8 @@ export class Broker {
|
|
|
648
989
|
}
|
|
649
990
|
}
|
|
650
991
|
|
|
651
|
-
function
|
|
652
|
-
return
|
|
653
|
-
? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
|
|
654
|
-
.exec(requestId)?.[0]
|
|
655
|
-
.toLowerCase()
|
|
656
|
-
: undefined;
|
|
992
|
+
function syncMessage(sessionId: string): string {
|
|
993
|
+
return `Pi session ${sessionId} is synchronizing to end duplicate executions. Ordinary tools and initialization are locked for this session and its bound ChatGPT conversations. Verify with the code returned by your own most recent initialization. With a missing or rejected code, this execution is an accidental duplicate: ${exitInstructions} The verified execution must confirm the conflicting executions' final exits before releasing the lock.`;
|
|
657
994
|
}
|
|
658
995
|
|
|
659
996
|
function abortError(signal: AbortSignal): Error {
|