@zetaloop/chappie 0.2.1 → 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/src/session.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { hostname } from "node:os";
4
+ import { join } from "node:path";
2
5
  import type {
3
6
  AssistantMessage,
4
- Context,
5
7
  ToolResultMessage,
6
8
  UserMessage,
7
9
  } from "@earendil-works/pi-ai";
8
10
  import type {
9
11
  ExtensionAPI,
10
12
  ExtensionContext,
13
+ SessionEntry,
11
14
  } from "@earendil-works/pi-coding-agent";
15
+ import { Text } from "@earendil-works/pi-tui";
12
16
  import type { DeliveryRecord } from "./delivery.ts";
13
17
  import {
14
18
  type BrokerMessage,
@@ -23,6 +27,11 @@ import { readSessionResource, rememberImages } from "./resources.ts";
23
27
 
24
28
  type RemoteRequest = Extract<BrokerMessage, { type: "chat" | "call" }>;
25
29
 
30
+ interface Notice {
31
+ message: string;
32
+ type: "info" | "warning" | "error";
33
+ }
34
+
26
35
  interface SyncRequest {
27
36
  resolve(): void;
28
37
  reject(error: Error): void;
@@ -38,13 +47,14 @@ interface ActiveRequest {
38
47
  session: SessionDescription;
39
48
  message: AssistantMessage;
40
49
  completed: boolean;
41
- cancelled: boolean;
50
+ cancelled: string | undefined;
42
51
  toolResults: ToolResultMessage[];
43
52
  }
44
53
 
45
54
  export class LocalSession {
46
55
  readonly #pi: ExtensionAPI;
47
56
  readonly #agentDir: string;
57
+ readonly #connect: string | undefined;
48
58
  readonly #syncs = new Map<number, SyncRequest>();
49
59
  readonly #stores = new Map<string, StoreRequest>();
50
60
  readonly #queue: RemoteRequest[] = [];
@@ -59,14 +69,27 @@ export class LocalSession {
59
69
  #nextSyncId = 1;
60
70
  #starting = false;
61
71
  #sessionId: string | undefined;
72
+ #inputCursor: string | null = null;
62
73
  #flushing = Promise.resolve();
63
74
 
64
- constructor(pi: ExtensionAPI, agentDir: string) {
75
+ constructor(pi: ExtensionAPI, agentDir: string, connect?: string) {
65
76
  this.#pi = pi;
66
77
  this.#agentDir = agentDir;
78
+ this.#connect = connect;
67
79
  }
68
80
 
69
81
  install(): void {
82
+ this.#pi.registerEntryRenderer<Notice>(
83
+ "chappie.notice",
84
+ ({ data }, _options, theme) => {
85
+ if (!data) return;
86
+ return new Text(
87
+ theme.fg(data.type === "info" ? "dim" : data.type, data.message),
88
+ 1,
89
+ 0,
90
+ );
91
+ },
92
+ );
70
93
  this.#pi.on("session_start", (_event, context) => this.#update(context));
71
94
  this.#pi.on("model_select", (event, context) =>
72
95
  this.#update(context, event.model.provider === "chappie"),
@@ -75,11 +98,15 @@ export class LocalSession {
75
98
  this.#context = context;
76
99
  void this.#sync().catch(() => {});
77
100
  });
78
- this.#pi.on("context", (event) => ({
79
- messages: event.messages.filter(
80
- (message) =>
81
- message.role !== "custom" || message.customType !== "chappie.request",
82
- ),
101
+ this.#pi.on("context", (event, context) => ({
102
+ messages:
103
+ context.model?.provider === "chappie"
104
+ ? []
105
+ : event.messages.filter(
106
+ (message) =>
107
+ message.role !== "custom" ||
108
+ message.customType !== "chappie.request",
109
+ ),
83
110
  }));
84
111
  this.#pi.on("turn_end", (event, context) =>
85
112
  this.#turnEnd(event.message, event.toolResults, context),
@@ -94,10 +121,12 @@ export class LocalSession {
94
121
  this.#pi.on("session_shutdown", () => this.close());
95
122
  }
96
123
 
97
- async start(
98
- output: ProviderOutput,
99
- _providerContext: Context,
100
- ): Promise<void> {
124
+ #notify(message: string, type: Notice["type"] = "info"): void {
125
+ if (this.#context)
126
+ this.#pi.appendEntry<Notice>("chappie.notice", { message, type });
127
+ }
128
+
129
+ async start(output: ProviderOutput): Promise<void> {
101
130
  const context = this.#context;
102
131
  const connection = this.#connection;
103
132
  if (context?.model?.provider !== "chappie" || !connection) {
@@ -156,7 +185,8 @@ export class LocalSession {
156
185
  return;
157
186
  }
158
187
  if (!this.#connection) {
159
- this.#connection = new IpcClient(this.#agentDir, {
188
+ this.#notify("Connecting to Chappie…");
189
+ this.#connection = new IpcClient(this.#agentDir, this.#connect, {
160
190
  onOpen: async () => {
161
191
  await this.#sync();
162
192
  await this.#flushDeliveries();
@@ -165,7 +195,8 @@ export class LocalSession {
165
195
  onClose: (error) => {
166
196
  this.#rejectSyncs(error);
167
197
  this.#rejectStores(error);
168
- this.#output?.fail(error);
198
+ if (this.#output && !this.#output.closed) this.#output.fail(error);
199
+ else this.#notify(error.message, "error");
169
200
  this.#active = undefined;
170
201
  this.#queue.length = 0;
171
202
  this.#starting = false;
@@ -182,13 +213,12 @@ export class LocalSession {
182
213
  const context = this.#context;
183
214
  if (!context) throw new Error("Chappie session is not available");
184
215
  const name = this.#pi.getSessionName();
185
- const sessionFile = context.sessionManager.getSessionFile();
186
216
  return {
187
217
  id: context.sessionManager.getSessionId(),
188
218
  cwd: context.cwd,
219
+ device: hostname(),
189
220
  status: this.#status,
190
221
  ...(name ? { name } : {}),
191
- ...(sessionFile ? { sessionFile } : {}),
192
222
  };
193
223
  }
194
224
 
@@ -219,13 +249,24 @@ export class LocalSession {
219
249
  case "stored":
220
250
  this.#stores.get(message.id)?.resolve();
221
251
  break;
252
+ case "notice":
253
+ if (
254
+ message.sessionId === this.#context?.sessionManager.getSessionId()
255
+ ) {
256
+ this.#notify(message.message);
257
+ }
258
+ break;
222
259
  case "inspect":
223
- await this.#reply(message.id, message.sessionId, () => ({
224
- type: "result",
225
- id: message.id,
226
- inspection: this.#inspection(),
227
- inputs: this.#inputs(),
228
- }));
260
+ await this.#reply(message.id, message.sessionId, async () => {
261
+ const globalAgents = await this.#readGlobalAgents();
262
+ return {
263
+ type: "result",
264
+ id: message.id,
265
+ inspection: this.#inspection(),
266
+ inputs: this.#inputs(),
267
+ ...(globalAgents ? { globalAgents } : {}),
268
+ };
269
+ });
229
270
  break;
230
271
  case "ackInputs":
231
272
  if (
@@ -245,13 +286,28 @@ export class LocalSession {
245
286
  const queued = this.#queue.findIndex(
246
287
  (request) => request.id === message.id,
247
288
  );
289
+ const request =
290
+ this.#queue[queued] ??
291
+ (this.#active?.request.id === message.id
292
+ ? this.#active.request
293
+ : undefined);
294
+ if (!request) break;
295
+ const name =
296
+ request.type === "call"
297
+ ? [...new Set(request.calls.map((call) => call.name))].join(", ")
298
+ : "chat";
299
+ this.#notify(
300
+ `${name} cancelled for ChatGPT ${request.chatId.slice(-4)}: ${message.reason}`,
301
+ "warning",
302
+ );
248
303
  if (queued !== -1) {
249
304
  this.#queue.splice(queued, 1);
250
305
  break;
251
306
  }
252
- if (this.#active?.request.id !== message.id) break;
253
- this.#active.cancelled = true;
254
- if (this.#active.completed) await this.#completeActive();
307
+ const active = this.#active;
308
+ if (!active) break;
309
+ active.cancelled = message.reason;
310
+ if (active.completed) await this.#completeActive();
255
311
  else this.#context?.abort();
256
312
  break;
257
313
  }
@@ -286,6 +342,15 @@ export class LocalSession {
286
342
  };
287
343
  }
288
344
 
345
+ async #readGlobalAgents(): Promise<string | undefined> {
346
+ try {
347
+ return await readFile(join(this.#agentDir, "AGENTS.md"), "utf8");
348
+ } catch (error) {
349
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
350
+ throw error;
351
+ }
352
+ }
353
+
289
354
  #dispatch(): void {
290
355
  if (this.#active) return;
291
356
  const request = this.#queue[0];
@@ -302,7 +367,7 @@ export class LocalSession {
302
367
  session: this.#description(),
303
368
  message: output.message,
304
369
  completed: false,
305
- cancelled: false,
370
+ cancelled: undefined,
306
371
  toolResults: [],
307
372
  };
308
373
  this.#status = "executing";
@@ -355,26 +420,17 @@ export class LocalSession {
355
420
  if (!active?.completed) return;
356
421
  this.#collectInputs();
357
422
  const inputs = this.#inputs();
358
- if (active.cancelled) {
359
- const sessionFile = active.session.sessionFile;
423
+ if (active.cancelled !== undefined) {
360
424
  const delivery: DeliveryRecord = {
361
425
  id: randomUUID(),
362
426
  chatId: active.request.chatId,
363
427
  sessionId: active.session.id,
364
428
  cwd: active.session.cwd,
365
- toolCallIds:
366
- active.request.type === "call"
367
- ? active.request.calls.map(({ id }) => id)
368
- : [],
369
- ...(sessionFile
370
- ? { sessionFile }
371
- : { inlineResults: active.toolResults }),
372
- ...(active.message.errorMessage
373
- ? { error: active.message.errorMessage }
374
- : { error: "Request cancelled" }),
429
+ toolResults: active.toolResults,
430
+ error: active.cancelled,
375
431
  };
376
432
  this.#deliveries.set(delivery.id, delivery);
377
- await this.#flushDeliveries().catch(() => {});
433
+ void this.#flushDeliveries().catch(() => {});
378
434
  } else {
379
435
  await this.#connection?.send({
380
436
  type: "result",
@@ -395,13 +451,25 @@ export class LocalSession {
395
451
  #collectInputs(): void {
396
452
  const context = this.#context;
397
453
  if (!context) return;
398
- const sessionId = context.sessionManager.getSessionId();
454
+ const sessionManager = context.sessionManager;
455
+ const sessionId = sessionManager.getSessionId();
399
456
  if (this.#sessionId !== sessionId) {
400
457
  this.#sessionId = sessionId;
458
+ this.#inputCursor = null;
401
459
  this.#seenInputs.clear();
402
460
  this.#pendingInputs.clear();
403
461
  }
404
- for (const entry of context.sessionManager.getBranch()) {
462
+ const leafId = sessionManager.getLeafId();
463
+ if (leafId === this.#inputCursor) return;
464
+ const entries: SessionEntry[] = [];
465
+ let current = sessionManager.getLeafEntry();
466
+ while (current && current.id !== this.#inputCursor) {
467
+ entries.push(current);
468
+ current = current.parentId
469
+ ? sessionManager.getEntry(current.parentId)
470
+ : undefined;
471
+ }
472
+ for (const entry of entries.reverse()) {
405
473
  if (
406
474
  entry.type !== "message" ||
407
475
  entry.message.role !== "user" ||
@@ -415,6 +483,7 @@ export class LocalSession {
415
483
  this.#seenInputs.add(entry.id);
416
484
  this.#pendingInputs.set(entry.id, { id: entry.id, sessionId, message });
417
485
  }
486
+ this.#inputCursor = leafId;
418
487
  }
419
488
 
420
489
  #inputs(): SessionInput[] {
@@ -433,6 +502,7 @@ export class LocalSession {
433
502
  await connection.send({ type: "delivery", delivery });
434
503
  await completion.promise;
435
504
  this.#deliveries.delete(delivery.id);
505
+ this.#notify(`Result saved for ChatGPT ${delivery.chatId.slice(-4)}`);
436
506
  } finally {
437
507
  this.#stores.delete(delivery.id);
438
508
  }
package/src/state.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  import { readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { DeliveryRecord } from "./delivery.ts";
4
+ import type { QuestionAnswer, QuestionRecord } from "./questions.ts";
4
5
 
5
6
  interface StateFile {
6
7
  bindings?: Record<string, string>;
8
+ workflows?: Record<string, string>;
7
9
  deliveries?: DeliveryRecord[];
10
+ questions?: QuestionRecord[];
8
11
  }
9
12
 
10
13
  export class State {
11
14
  readonly #path: string;
12
15
  readonly #temporaryPath: string;
13
16
  readonly #bindings = new Map<string, string>();
17
+ readonly #workflows = new Map<string, string>();
14
18
  readonly #deliveries = new Map<string, DeliveryRecord>();
19
+ readonly #questions = new Map<string, QuestionRecord>();
15
20
  #writes = Promise.resolve();
16
21
 
17
22
  constructor(agentDir: string) {
@@ -31,15 +36,35 @@ export class State {
31
36
  for (const [chatId, sessionId] of Object.entries(state.bindings ?? {})) {
32
37
  if (typeof sessionId === "string") this.#bindings.set(chatId, sessionId);
33
38
  }
39
+ for (const [chatId, id] of Object.entries(state.workflows ?? {})) {
40
+ this.#workflows.set(chatId, id);
41
+ }
34
42
  for (const delivery of state.deliveries ?? []) {
35
43
  if (delivery?.id) this.#deliveries.set(delivery.id, delivery);
36
44
  }
45
+ for (const question of state.questions ?? [])
46
+ this.#questions.set(question.id, question);
47
+ }
48
+
49
+ workflow(chatId: string): string | undefined {
50
+ return this.#workflows.get(chatId);
51
+ }
52
+
53
+ setWorkflow(chatId: string, id: string): Promise<void> {
54
+ this.#workflows.set(chatId, id);
55
+ return this.#save();
37
56
  }
38
57
 
39
58
  binding(chatId: string): string | undefined {
40
59
  return this.#bindings.get(chatId);
41
60
  }
42
61
 
62
+ chats(sessionId: string): string[] {
63
+ return [...this.#bindings]
64
+ .filter(([, target]) => target === sessionId)
65
+ .map(([chatId]) => chatId);
66
+ }
67
+
43
68
  bindingCounts(): Map<string, number> {
44
69
  const counts = new Map<string, number>();
45
70
  for (const sessionId of this.#bindings.values()) {
@@ -64,23 +89,89 @@ export class State {
64
89
  return this.#save();
65
90
  }
66
91
 
67
- removeDeliveries(ids: string[]): Promise<void> {
68
- for (const id of ids) this.#deliveries.delete(id);
69
- return this.#save();
92
+ question(chatId: string, id: string): QuestionRecord {
93
+ const question = this.#questions.get(id);
94
+ if (!question || question.chatId !== chatId)
95
+ throw new Error("Question not found in this ChatGPT conversation");
96
+ return question;
70
97
  }
71
98
 
72
- async restoreDeliveries(deliveries: DeliveryRecord[]): Promise<void> {
73
- for (const delivery of deliveries)
74
- this.#deliveries.set(delivery.id, delivery);
99
+ async addQuestion(question: QuestionRecord): Promise<void> {
100
+ this.#questions.set(question.id, question);
75
101
  await this.#save();
76
102
  }
77
103
 
104
+ async answer(
105
+ chatId: string,
106
+ id: string,
107
+ answer: QuestionAnswer,
108
+ ): Promise<QuestionRecord> {
109
+ const question = this.question(chatId, id);
110
+ const selections = [...new Set(answer.selections)].sort((a, b) => a - b);
111
+ if (selections.some((index) => !question.options[index]))
112
+ throw new Error("Unknown question option");
113
+ if (!question.allowMultiple && selections.length > 1)
114
+ throw new Error("Select one option");
115
+ if (answer.skipped && (selections.length > 0 || answer.text))
116
+ throw new Error("A skipped question cannot include an answer");
117
+ if (!answer.skipped && selections.length === 0 && !answer.text)
118
+ throw new Error("Select an option or enter an answer");
119
+ const value: QuestionAnswer = {
120
+ selections,
121
+ text: answer.text,
122
+ ...(answer.skipped ? { skipped: true } : {}),
123
+ };
124
+ if (JSON.stringify(question.answer) === JSON.stringify(value))
125
+ return question;
126
+ const updated = { ...question, answer: value, delivered: false };
127
+ await this.addQuestion(updated);
128
+ return updated;
129
+ }
130
+
131
+ answers(chatId: string): QuestionRecord[] {
132
+ return [...this.#questions.values()].filter(
133
+ (question) =>
134
+ question.chatId === chatId && question.answer && !question.delivered,
135
+ );
136
+ }
137
+
138
+ async acknowledge(
139
+ deliveries: DeliveryRecord[],
140
+ answers: QuestionRecord[],
141
+ signal: AbortSignal,
142
+ ): Promise<void> {
143
+ if (deliveries.length === 0 && answers.length === 0) return;
144
+ signal.throwIfAborted();
145
+ const delivered = new Map(
146
+ answers.map((question) => [question, { ...question, delivered: true }]),
147
+ );
148
+ for (const delivery of deliveries) this.#deliveries.delete(delivery.id);
149
+ for (const [question, updated] of delivered) {
150
+ if (this.#questions.get(question.id) === question)
151
+ this.#questions.set(question.id, updated);
152
+ }
153
+ try {
154
+ await this.#save();
155
+ signal.throwIfAborted();
156
+ } catch (error) {
157
+ for (const delivery of deliveries)
158
+ this.#deliveries.set(delivery.id, delivery);
159
+ for (const [question, updated] of delivered) {
160
+ if (this.#questions.get(question.id) === updated)
161
+ this.#questions.set(question.id, question);
162
+ }
163
+ await this.#save();
164
+ throw error;
165
+ }
166
+ }
167
+
78
168
  #save(): Promise<void> {
79
169
  const saved = this.#writes.then(async () => {
80
170
  const bindings = Object.fromEntries(this.#bindings);
171
+ const workflows = Object.fromEntries(this.#workflows);
81
172
  await writeFile(
82
173
  this.#temporaryPath,
83
- `${JSON.stringify({ bindings, deliveries: [...this.#deliveries.values()] }, null, 2)}\n`,
174
+ `${JSON.stringify({ bindings, workflows, deliveries: [...this.#deliveries.values()], questions: [...this.#questions.values()] }, null, 2)}\n`,
84
175
  {
85
176
  mode: 0o600,
86
177
  },
package/src/stdio.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createWriteStream } from "node:fs";
1
+ import { Writable } from "node:stream";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  StdioServerTransport,
@@ -15,7 +15,15 @@ const terminationSignals =
15
15
  export async function serveChappie(): Promise<never> {
16
16
  const broker = new Broker(getAgentDir());
17
17
  await broker.start();
18
- const output = createWriteStream("", { fd: 1, autoClose: false });
18
+ const output = new Writable({
19
+ write(chunk, encoding, callback) {
20
+ Reflect.apply(Writable.prototype.write, process.stdout, [
21
+ chunk,
22
+ encoding,
23
+ callback,
24
+ ]);
25
+ },
26
+ });
19
27
  const transport = new StdioServerTransport(process.stdin, output);
20
28
  let stop: (() => void) | undefined;
21
29
  const stopped = new Promise<void>((resolve) => {
@@ -23,6 +31,7 @@ export async function serveChappie(): Promise<never> {
23
31
  });
24
32
  const requestStop = (): void => stop?.();
25
33
 
34
+ output.once("error", requestStop);
26
35
  process.stdin.once("end", requestStop);
27
36
  process.stdin.once("close", requestStop);
28
37
  for (const signal of terminationSignals) {
package/src/tools.ts CHANGED
@@ -6,11 +6,9 @@ import {
6
6
  createWriteToolDefinition,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { fromJsonSchema } from "@modelcontextprotocol/server";
9
+ import { toolResultsContent } from "./delivery.ts";
9
10
  import type { SessionInput } from "./ipc.ts";
10
- import {
11
- contentWithImageReferences,
12
- resourceDescriptors,
13
- } from "./resources.ts";
11
+ import { contentWithImageReferences } from "./resources.ts";
14
12
  import { transfer } from "./transfer.ts";
15
13
 
16
14
  export interface ToolInput {
@@ -44,24 +42,7 @@ export function toolResult(
44
42
  return {
45
43
  content: [
46
44
  { type: "text" as const, text: JSON.stringify({ sessionId, cwd }) },
47
- ...toolResults.flatMap((result) => [
48
- {
49
- type: "text" as const,
50
- text: JSON.stringify({
51
- toolCallId: result.toolCallId,
52
- toolName: result.toolName,
53
- isError: result.isError,
54
- }),
55
- },
56
- ...contentWithImageReferences(sessionId, result.content),
57
- ...resourceDescriptors(result.details).map((resource) => ({
58
- type: "resource_link" as const,
59
- uri: resource.uri,
60
- name: resource.name,
61
- mimeType: resource.mimeType,
62
- size: resource.size,
63
- })),
64
- ]),
45
+ ...toolResultsContent(toolResults, sessionId),
65
46
  ...inputContent(inputs),
66
47
  ],
67
48
  isError: toolResults.some((result) => result.isError),
package/src/transfer.ts CHANGED
@@ -28,18 +28,18 @@ export const transfer = {
28
28
  name: "transfer",
29
29
  label: "transfer",
30
30
  description:
31
- "Copy ChatGPT files into Pi paths, or export Pi paths and Chappie image references as MCP resources. The paths field always names Pi-side sources or destinations.",
31
+ "Copy ChatGPT files into Pi paths when files is supplied. Otherwise, return resource links for Pi paths or Chappie image references.",
32
32
  parameters: Type.Object({
33
33
  paths: Type.Array(Type.String(), {
34
34
  minItems: 1,
35
35
  description:
36
- "Pi paths to import into or export from; chappie:// image references can be exported",
36
+ "Pi destinations for import; Pi paths or chappie:// image references for export",
37
37
  }),
38
38
  files: Type.Optional(
39
39
  Type.Array(transferFile, {
40
40
  minItems: 1,
41
41
  description:
42
- "ChatGPT files matched to paths by index; omit to export Pi paths",
42
+ "ChatGPT files paired with paths in order; omit for export",
43
43
  }),
44
44
  ),
45
45
  overwrite: Type.Optional(