@zetaloop/chappie 0.4.0 → 0.5.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/src/session.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
2
+ import { access } from "node:fs/promises";
3
3
  import { hostname } from "node:os";
4
- import { join } from "node:path";
4
+ import { resolve } from "node:path";
5
5
  import type {
6
6
  AssistantMessage,
7
7
  ToolResultMessage,
@@ -22,10 +22,18 @@ import {
22
22
  type SessionDescription,
23
23
  type SessionInput,
24
24
  type SessionInspection,
25
+ type SessionRequest,
26
+ type SessionResult,
25
27
  type SessionStatus,
26
28
  } from "./ipc.ts";
27
29
  import type { ProviderOutput } from "./provider.ts";
28
- import { readSessionResource, rememberImages } from "./resources.ts";
30
+ import {
31
+ type ResourceDescriptor,
32
+ readSessionResource,
33
+ rememberImages,
34
+ resourceSessionId,
35
+ } from "./resources.ts";
36
+ import { copyFiles, transfer, transferResult } from "./transfer.ts";
29
37
 
30
38
  type RemoteRequest = Extract<BrokerMessage, { type: "chat" | "call" }>;
31
39
 
@@ -44,6 +52,16 @@ interface StoreRequest {
44
52
  reject(error: Error): void;
45
53
  }
46
54
 
55
+ interface PendingRequest {
56
+ resolve(result: SessionResult): void;
57
+ reject(error: Error): void;
58
+ }
59
+
60
+ interface HistoryRequest {
61
+ request: Extract<BrokerMessage, { type: "history" }>;
62
+ timeout: NodeJS.Timeout;
63
+ }
64
+
47
65
  interface ActiveRequest {
48
66
  request: RemoteRequest;
49
67
  session: SessionDescription;
@@ -62,12 +80,15 @@ export class LocalSession {
62
80
  readonly #queue: RemoteRequest[] = [];
63
81
  readonly #pendingInputs = new Map<string, SessionInput>();
64
82
  readonly #deliveries = new Map<string, DeliveryRecord>();
83
+ readonly #histories = new Map<number, HistoryRequest>();
84
+ readonly #requests = new Map<number, PendingRequest>();
85
+ readonly #copies = new Map<number, AbortController>();
65
86
  #context: ExtensionContext | undefined;
66
87
  #connection: IpcClient | undefined;
67
88
  #output: ProviderOutput | undefined;
68
89
  #active: ActiveRequest | undefined;
69
90
  #status: SessionStatus = "idle";
70
- #nextSyncId = 1;
91
+ #nextRequestId = 1;
71
92
  #starting = false;
72
93
  #sessionId: string | undefined;
73
94
  #inputCursor: string | null = null;
@@ -104,6 +125,20 @@ export class LocalSession {
104
125
  if (context.model?.provider === "chappie") {
105
126
  this.#resetInputs(context, event.newLeafId);
106
127
  }
128
+ this.#historyChanged();
129
+ });
130
+ // Pi persists messages after message_end handlers finish.
131
+ this.#pi.on("message_start", (_event, context) => {
132
+ this.#context = context;
133
+ this.#historyChanged();
134
+ });
135
+ this.#pi.on("tool_call", (_event, context) => {
136
+ this.#context = context;
137
+ this.#historyChanged();
138
+ });
139
+ this.#pi.on("session_compact", (_event, context) => {
140
+ this.#context = context;
141
+ this.#historyChanged();
107
142
  });
108
143
  this.#pi.on("context", (event, context) => ({
109
144
  messages:
@@ -122,6 +157,7 @@ export class LocalSession {
122
157
  this.#context = context;
123
158
  this.#starting = false;
124
159
  this.#collectInputs();
160
+ this.#historyChanged();
125
161
  await this.#completeActive();
126
162
  this.#dispatch();
127
163
  });
@@ -139,6 +175,7 @@ export class LocalSession {
139
175
  type,
140
176
  ...activity,
141
177
  });
178
+ if (activity.event !== "history") this.#historyChanged();
142
179
  }
143
180
 
144
181
  async start(output: ProviderOutput): Promise<void> {
@@ -157,6 +194,7 @@ export class LocalSession {
157
194
  await connection.connect();
158
195
  if (output.closed) return;
159
196
  this.#collectInputs();
197
+ this.#historyChanged();
160
198
  await this.#completeActive();
161
199
  this.#status = "ready";
162
200
  await this.#sync();
@@ -171,6 +209,52 @@ export class LocalSession {
171
209
  }
172
210
  }
173
211
 
212
+ async transfer(
213
+ ...parameters: Parameters<typeof transfer.execute>
214
+ ): ReturnType<typeof transfer.execute> {
215
+ const [id, args, signal, update, context] = parameters;
216
+ if (!args.to) return transfer.execute(...parameters);
217
+ if (args.files) throw new Error("files and to are mutually exclusive");
218
+ if (args.paths.length !== args.to.paths.length)
219
+ throw new Error("Source and destination counts must match");
220
+ const inspected = await this.#request(
221
+ { type: "inspect", sessionId: args.to.sessionId },
222
+ signal,
223
+ );
224
+ if (!("inspection" in inspected))
225
+ throw new Error("Pi session returned no environment");
226
+ const exported = await transfer.execute(
227
+ id,
228
+ { paths: args.paths },
229
+ signal,
230
+ undefined,
231
+ context,
232
+ );
233
+ update?.({
234
+ content: [],
235
+ details: {
236
+ ...exported.details,
237
+ to: {
238
+ sessionId: args.to.sessionId,
239
+ device: inspected.inspection.session.device,
240
+ },
241
+ },
242
+ });
243
+ const result = await this.#request(
244
+ {
245
+ type: "copy",
246
+ sessionId: args.to.sessionId,
247
+ paths: args.to.paths,
248
+ resources: exported.details.resources,
249
+ ...(args.overwrite !== undefined ? { overwrite: args.overwrite } : {}),
250
+ },
251
+ signal,
252
+ );
253
+ if (!("transfer" in result))
254
+ throw new Error("Pi session returned no transfer result");
255
+ return transferResult({ ...result.transfer, device: hostname() });
256
+ }
257
+
174
258
  close(): void {
175
259
  const sessionId = this.#context?.sessionManager.getSessionId();
176
260
  if (sessionId && this.#connection?.connected) {
@@ -187,8 +271,10 @@ export class LocalSession {
187
271
  this.#connection = undefined;
188
272
  this.#context = undefined;
189
273
  this.#resetInputs();
274
+ this.#cancelRequests(new Error("Chappie session ended"));
190
275
  this.#rejectSyncs(new Error("Chappie session ended"));
191
276
  this.#rejectStores(new Error("Chappie session ended"));
277
+ for (const id of this.#histories.keys()) this.#finishHistory(id);
192
278
  }
193
279
 
194
280
  #update(
@@ -211,6 +297,8 @@ export class LocalSession {
211
297
  },
212
298
  onMessage: (message) => this.#receive(message),
213
299
  onClose: (error) => {
300
+ this.#cancelRequests(error);
301
+ for (const id of this.#histories.keys()) this.#finishHistory(id);
214
302
  this.#rejectSyncs(error);
215
303
  this.#rejectStores(error);
216
304
  if (this.#output && !this.#output.closed) this.#output.fail(error);
@@ -248,7 +336,7 @@ export class LocalSession {
248
336
  this.#context.model?.provider !== "chappie"
249
337
  )
250
338
  return;
251
- const id = this.#nextSyncId++;
339
+ const id = this.#nextRequestId++;
252
340
  const completion = Promise.withResolvers<void>();
253
341
  this.#syncs.set(id, completion);
254
342
  try {
@@ -261,6 +349,16 @@ export class LocalSession {
261
349
 
262
350
  async #receive(message: BrokerMessage): Promise<void> {
263
351
  switch (message.type) {
352
+ case "response": {
353
+ const pending = this.#requests.get(message.id);
354
+ if (!pending) break;
355
+ if ("error" in message) pending.reject(new Error(message.error));
356
+ else {
357
+ const { type: _type, id: _id, ...result } = message;
358
+ pending.resolve(result);
359
+ }
360
+ break;
361
+ }
264
362
  case "synced":
265
363
  this.#syncs.get(message.id)?.resolve();
266
364
  break;
@@ -276,7 +374,7 @@ export class LocalSession {
276
374
  break;
277
375
  case "inspect":
278
376
  await this.#reply(message.id, message.sessionId, async () => {
279
- const globalAgents = await this.#readGlobalAgents();
377
+ const globalAgents = await this.#globalAgents();
280
378
  return {
281
379
  type: "result",
282
380
  id: message.id,
@@ -287,24 +385,7 @@ export class LocalSession {
287
385
  });
288
386
  break;
289
387
  case "history":
290
- await this.#reply(message.id, message.sessionId, () => {
291
- const context = this.#context;
292
- if (!context) throw new Error("Chappie session is not available");
293
- const history = historyResult(
294
- context.sessionManager.getBranch(),
295
- message.sessionId,
296
- message.range,
297
- );
298
- this.#notify(
299
- `${chatLabel(message)} read history: ${history.count} entries`,
300
- "info",
301
- {
302
- event: "history",
303
- ...source(message.chatId, message.requestId),
304
- },
305
- );
306
- return { type: "result", id: message.id, cwd: context.cwd, history };
307
- });
388
+ await this.#readHistory(message);
308
389
  break;
309
390
  case "ackInputs":
310
391
  if (
@@ -317,10 +398,52 @@ export class LocalSession {
317
398
  await this.#reply(message.id, message.sessionId, async () => ({
318
399
  type: "result",
319
400
  id: message.id,
320
- resource: await readSessionResource(message.sessionId, message.uri),
401
+ resource: await readSessionResource(
402
+ message.sessionId,
403
+ message.uri,
404
+ message.offset,
405
+ ),
321
406
  }));
322
407
  break;
408
+ case "copy": {
409
+ const controller = new AbortController();
410
+ this.#copies.set(message.id, controller);
411
+ void this.#reply(message.id, message.sessionId, async () => {
412
+ const context = this.#context;
413
+ if (!context) throw new Error("Chappie session is not available");
414
+ const files = await copyFiles(
415
+ message.paths,
416
+ message.resources,
417
+ context.cwd,
418
+ message.overwrite === true,
419
+ (resource) => this.#readChunks(resource, controller.signal),
420
+ controller.signal,
421
+ );
422
+ return {
423
+ type: "result",
424
+ id: message.id,
425
+ transfer: {
426
+ device: hostname(),
427
+ files,
428
+ resources: [],
429
+ to: { sessionId: message.sessionId, device: hostname() },
430
+ },
431
+ };
432
+ })
433
+ .finally(() => this.#copies.delete(message.id))
434
+ .catch(() => {});
435
+ break;
436
+ }
323
437
  case "cancel": {
438
+ const copying = this.#copies.get(message.id);
439
+ if (copying) {
440
+ copying.abort(new Error(message.reason));
441
+ break;
442
+ }
443
+ if (this.#histories.has(message.id)) {
444
+ this.#finishHistory(message.id);
445
+ break;
446
+ }
324
447
  const queued = this.#queue.findIndex(
325
448
  (request) => request.id === message.id,
326
449
  );
@@ -370,6 +493,125 @@ export class LocalSession {
370
493
  }
371
494
  }
372
495
 
496
+ async #request(
497
+ request: SessionRequest,
498
+ signal?: AbortSignal,
499
+ ): Promise<SessionResult> {
500
+ signal?.throwIfAborted();
501
+ const connection = this.#connection;
502
+ if (!connection?.connected) throw new Error("Chappie is not connected");
503
+ const id = this.#nextRequestId++;
504
+ const completion = Promise.withResolvers<SessionResult>();
505
+ const onAbort = (): void => {
506
+ completion.reject(signal?.reason);
507
+ void connection.send({ type: "cancelRequest", id }).catch(() => {});
508
+ };
509
+ this.#requests.set(id, completion);
510
+ signal?.addEventListener("abort", onAbort, { once: true });
511
+ void connection
512
+ .send({ type: "request", id, request })
513
+ .catch(completion.reject);
514
+ try {
515
+ return await completion.promise;
516
+ } finally {
517
+ this.#requests.delete(id);
518
+ signal?.removeEventListener("abort", onAbort);
519
+ }
520
+ }
521
+
522
+ async *#readChunks(
523
+ resource: ResourceDescriptor,
524
+ signal: AbortSignal,
525
+ ): AsyncGenerator<Uint8Array> {
526
+ for (let offset = 0; offset < resource.size; ) {
527
+ const result = await this.#request(
528
+ {
529
+ type: "readResource",
530
+ sessionId: resourceSessionId(resource.uri),
531
+ uri: resource.uri,
532
+ offset,
533
+ },
534
+ signal,
535
+ );
536
+ if (!("resource" in result))
537
+ throw new Error("Pi session returned no resource");
538
+ const data = Buffer.from(result.resource.blob, "base64");
539
+ if (data.length === 0)
540
+ throw new Error(
541
+ `Source ended before ${resource.size} bytes: ${resource.name}`,
542
+ );
543
+ yield data;
544
+ offset += data.length;
545
+ }
546
+ }
547
+
548
+ #cancelRequests(error: Error): void {
549
+ for (const pending of this.#requests.values()) pending.reject(error);
550
+ this.#requests.clear();
551
+ for (const controller of this.#copies.values()) controller.abort(error);
552
+ this.#copies.clear();
553
+ }
554
+
555
+ async #readHistory(
556
+ request: HistoryRequest["request"],
557
+ wait = request.range.wait,
558
+ ): Promise<void> {
559
+ try {
560
+ const context = this.#context;
561
+ if (context?.sessionManager.getSessionId() !== request.sessionId)
562
+ throw new Error("The requested Pi session is no longer active");
563
+ const history = historyResult(
564
+ context.sessionManager.getBranch(),
565
+ request.sessionId,
566
+ request.range,
567
+ );
568
+ if (wait && !request.range.before && history.count === 0) {
569
+ if (!this.#histories.has(request.id)) {
570
+ this.#histories.set(request.id, {
571
+ request,
572
+ timeout: setTimeout(() => {
573
+ void this.#readHistory(request, false).catch(() => {});
574
+ }, 30_000),
575
+ });
576
+ }
577
+ return;
578
+ }
579
+ this.#finishHistory(request.id);
580
+ if (!request.range.observer) {
581
+ this.#notify(
582
+ `${chatLabel(request)} read history: ${history.count} entries`,
583
+ "info",
584
+ { event: "history", ...source(request.chatId, request.requestId) },
585
+ );
586
+ }
587
+ await this.#connection?.send({
588
+ type: "result",
589
+ id: request.id,
590
+ cwd: context.cwd,
591
+ history,
592
+ });
593
+ } catch (error) {
594
+ this.#finishHistory(request.id);
595
+ await this.#sendError(
596
+ request.id,
597
+ error instanceof Error ? error.message : String(error),
598
+ );
599
+ }
600
+ }
601
+
602
+ #historyChanged(): void {
603
+ for (const { request } of this.#histories.values()) {
604
+ void this.#readHistory(request).catch(() => {});
605
+ }
606
+ }
607
+
608
+ #finishHistory(id: number): void {
609
+ const pending = this.#histories.get(id);
610
+ if (!pending) return;
611
+ clearTimeout(pending.timeout);
612
+ this.#histories.delete(id);
613
+ }
614
+
373
615
  #inspection(): SessionInspection {
374
616
  const activeTools = new Set(this.#pi.getActiveTools());
375
617
  this.#collectInputs();
@@ -384,9 +626,11 @@ export class LocalSession {
384
626
  };
385
627
  }
386
628
 
387
- async #readGlobalAgents(): Promise<string | undefined> {
629
+ async #globalAgents(): Promise<{ path: string } | undefined> {
630
+ const path = resolve(this.#agentDir, "AGENTS.md");
388
631
  try {
389
- return await readFile(join(this.#agentDir, "AGENTS.md"), "utf8");
632
+ await access(path);
633
+ return { path };
390
634
  } catch (error) {
391
635
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
392
636
  throw error;
@@ -450,6 +694,7 @@ export class LocalSession {
450
694
  context: ExtensionContext,
451
695
  ): Promise<void> {
452
696
  this.#context = context;
697
+ this.#historyChanged();
453
698
  const active = this.#active;
454
699
  if (!active || message !== active.message) return;
455
700
  const sessionId = active.session.id;
package/src/state.ts CHANGED
@@ -5,7 +5,6 @@ import type { QuestionAnswer, QuestionRecord } from "./questions.ts";
5
5
 
6
6
  interface StateFile {
7
7
  bindings?: Record<string, string>;
8
- codes?: Record<string, string>;
9
8
  deliveries?: DeliveryRecord[];
10
9
  questions?: QuestionRecord[];
11
10
  }
@@ -14,7 +13,6 @@ export class State {
14
13
  readonly #path: string;
15
14
  readonly #temporaryPath: string;
16
15
  readonly #bindings = new Map<string, string>();
17
- readonly #codes = new Map<string, string>();
18
16
  readonly #deliveries = new Map<string, DeliveryRecord>();
19
17
  readonly #questions = new Map<string, QuestionRecord>();
20
18
  #writes = Promise.resolve();
@@ -36,9 +34,6 @@ export class State {
36
34
  for (const [chatId, sessionId] of Object.entries(state.bindings ?? {})) {
37
35
  if (typeof sessionId === "string") this.#bindings.set(chatId, sessionId);
38
36
  }
39
- for (const [sessionId, code] of Object.entries(state.codes ?? {})) {
40
- this.#codes.set(sessionId, code);
41
- }
42
37
  for (const delivery of state.deliveries ?? []) {
43
38
  if (delivery?.id) this.#deliveries.set(delivery.id, delivery);
44
39
  }
@@ -46,16 +41,6 @@ export class State {
46
41
  this.#questions.set(question.id, question);
47
42
  }
48
43
 
49
- code(sessionId: string): string | undefined {
50
- return this.#codes.get(sessionId);
51
- }
52
-
53
- setCode(sessionId: string, code: string | undefined): Promise<void> {
54
- if (code === undefined) this.#codes.delete(sessionId);
55
- else this.#codes.set(sessionId, code);
56
- return this.#save();
57
- }
58
-
59
44
  binding(chatId: string): string | undefined {
60
45
  return this.#bindings.get(chatId);
61
46
  }
@@ -163,10 +148,9 @@ export class State {
163
148
  #save(): Promise<void> {
164
149
  const saved = this.#writes.then(async () => {
165
150
  const bindings = Object.fromEntries(this.#bindings);
166
- const codes = Object.fromEntries(this.#codes);
167
151
  await writeFile(
168
152
  this.#temporaryPath,
169
- `${JSON.stringify({ bindings, codes, deliveries: [...this.#deliveries.values()], questions: [...this.#questions.values()] }, null, 2)}\n`,
153
+ `${JSON.stringify({ bindings, deliveries: [...this.#deliveries.values()], questions: [...this.#questions.values()] }, null, 2)}\n`,
170
154
  {
171
155
  mode: 0o600,
172
156
  },