@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/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;
@@ -35,15 +44,17 @@ interface StoreRequest {
35
44
 
36
45
  interface ActiveRequest {
37
46
  request: RemoteRequest;
47
+ session: SessionDescription;
38
48
  message: AssistantMessage;
39
49
  completed: boolean;
40
- cancelled: boolean;
50
+ cancelled: string | undefined;
41
51
  toolResults: ToolResultMessage[];
42
52
  }
43
53
 
44
54
  export class LocalSession {
45
55
  readonly #pi: ExtensionAPI;
46
56
  readonly #agentDir: string;
57
+ readonly #connect: string | undefined;
47
58
  readonly #syncs = new Map<number, SyncRequest>();
48
59
  readonly #stores = new Map<string, StoreRequest>();
49
60
  readonly #queue: RemoteRequest[] = [];
@@ -58,14 +69,27 @@ export class LocalSession {
58
69
  #nextSyncId = 1;
59
70
  #starting = false;
60
71
  #sessionId: string | undefined;
72
+ #inputCursor: string | null = null;
61
73
  #flushing = Promise.resolve();
62
74
 
63
- constructor(pi: ExtensionAPI, agentDir: string) {
75
+ constructor(pi: ExtensionAPI, agentDir: string, connect?: string) {
64
76
  this.#pi = pi;
65
77
  this.#agentDir = agentDir;
78
+ this.#connect = connect;
66
79
  }
67
80
 
68
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
+ );
69
93
  this.#pi.on("session_start", (_event, context) => this.#update(context));
70
94
  this.#pi.on("model_select", (event, context) =>
71
95
  this.#update(context, event.model.provider === "chappie"),
@@ -74,11 +98,15 @@ export class LocalSession {
74
98
  this.#context = context;
75
99
  void this.#sync().catch(() => {});
76
100
  });
77
- this.#pi.on("context", (event) => ({
78
- messages: event.messages.filter(
79
- (message) =>
80
- message.role !== "custom" || message.customType !== "chappie.request",
81
- ),
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
+ ),
82
110
  }));
83
111
  this.#pi.on("turn_end", (event, context) =>
84
112
  this.#turnEnd(event.message, event.toolResults, context),
@@ -93,10 +121,12 @@ export class LocalSession {
93
121
  this.#pi.on("session_shutdown", () => this.close());
94
122
  }
95
123
 
96
- async start(
97
- output: ProviderOutput,
98
- _providerContext: Context,
99
- ): 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> {
100
130
  const context = this.#context;
101
131
  const connection = this.#connection;
102
132
  if (context?.model?.provider !== "chappie" || !connection) {
@@ -155,7 +185,8 @@ export class LocalSession {
155
185
  return;
156
186
  }
157
187
  if (!this.#connection) {
158
- this.#connection = new IpcClient(this.#agentDir, {
188
+ this.#notify("Connecting to Chappie…");
189
+ this.#connection = new IpcClient(this.#agentDir, this.#connect, {
159
190
  onOpen: async () => {
160
191
  await this.#sync();
161
192
  await this.#flushDeliveries();
@@ -164,7 +195,8 @@ export class LocalSession {
164
195
  onClose: (error) => {
165
196
  this.#rejectSyncs(error);
166
197
  this.#rejectStores(error);
167
- this.#output?.fail(error);
198
+ if (this.#output && !this.#output.closed) this.#output.fail(error);
199
+ else this.#notify(error.message, "error");
168
200
  this.#active = undefined;
169
201
  this.#queue.length = 0;
170
202
  this.#starting = false;
@@ -181,13 +213,12 @@ export class LocalSession {
181
213
  const context = this.#context;
182
214
  if (!context) throw new Error("Chappie session is not available");
183
215
  const name = this.#pi.getSessionName();
184
- const sessionFile = context.sessionManager.getSessionFile();
185
216
  return {
186
217
  id: context.sessionManager.getSessionId(),
187
218
  cwd: context.cwd,
219
+ device: hostname(),
188
220
  status: this.#status,
189
221
  ...(name ? { name } : {}),
190
- ...(sessionFile ? { sessionFile } : {}),
191
222
  };
192
223
  }
193
224
 
@@ -218,13 +249,24 @@ export class LocalSession {
218
249
  case "stored":
219
250
  this.#stores.get(message.id)?.resolve();
220
251
  break;
252
+ case "notice":
253
+ if (
254
+ message.sessionId === this.#context?.sessionManager.getSessionId()
255
+ ) {
256
+ this.#notify(message.message);
257
+ }
258
+ break;
221
259
  case "inspect":
222
- await this.#reply(message.id, message.sessionId, () => ({
223
- type: "result",
224
- id: message.id,
225
- inspection: this.#inspection(),
226
- inputs: this.#inputs(),
227
- }));
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
+ });
228
270
  break;
229
271
  case "ackInputs":
230
272
  if (
@@ -244,13 +286,28 @@ export class LocalSession {
244
286
  const queued = this.#queue.findIndex(
245
287
  (request) => request.id === message.id,
246
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
+ );
247
303
  if (queued !== -1) {
248
304
  this.#queue.splice(queued, 1);
249
305
  break;
250
306
  }
251
- if (this.#active?.request.id !== message.id) break;
252
- this.#active.cancelled = true;
253
- 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();
254
311
  else this.#context?.abort();
255
312
  break;
256
313
  }
@@ -285,6 +342,15 @@ export class LocalSession {
285
342
  };
286
343
  }
287
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
+
288
354
  #dispatch(): void {
289
355
  if (this.#active) return;
290
356
  const request = this.#queue[0];
@@ -298,9 +364,10 @@ export class LocalSession {
298
364
  this.#queue.shift();
299
365
  this.#active = {
300
366
  request,
367
+ session: this.#description(),
301
368
  message: output.message,
302
369
  completed: false,
303
- cancelled: false,
370
+ cancelled: undefined,
304
371
  toolResults: [],
305
372
  };
306
373
  this.#status = "executing";
@@ -342,7 +409,7 @@ export class LocalSession {
342
409
  this.#context = context;
343
410
  const active = this.#active;
344
411
  if (!active || message !== active.message) return;
345
- const sessionId = context.sessionManager.getSessionId();
412
+ const sessionId = active.session.id;
346
413
  for (const result of toolResults) rememberImages(sessionId, result.content);
347
414
  active.completed = true;
348
415
  active.toolResults = toolResults;
@@ -353,45 +420,28 @@ export class LocalSession {
353
420
  if (!active?.completed) return;
354
421
  this.#collectInputs();
355
422
  const inputs = this.#inputs();
356
- if (active.cancelled) {
357
- const context = this.#context;
358
- if (context) {
359
- const sessionFile = context.sessionManager.getSessionFile();
360
- const delivery: DeliveryRecord = {
361
- id: randomUUID(),
362
- chatId: active.request.chatId,
363
- sessionId: context.sessionManager.getSessionId(),
364
- toolCallIds:
365
- active.request.type === "call"
366
- ? active.request.calls.map(({ id }) => id)
367
- : [],
368
- ...(sessionFile
369
- ? { sessionFile }
370
- : { inlineResults: active.toolResults }),
371
- ...(active.message.errorMessage
372
- ? { error: active.message.errorMessage }
373
- : { error: "Request cancelled" }),
374
- };
375
- this.#deliveries.set(delivery.id, delivery);
376
- await this.#flushDeliveries().catch(() => {});
377
- }
423
+ if (active.cancelled !== undefined) {
424
+ const delivery: DeliveryRecord = {
425
+ id: randomUUID(),
426
+ chatId: active.request.chatId,
427
+ sessionId: active.session.id,
428
+ cwd: active.session.cwd,
429
+ toolResults: active.toolResults,
430
+ error: active.cancelled,
431
+ };
432
+ this.#deliveries.set(delivery.id, delivery);
433
+ void this.#flushDeliveries().catch(() => {});
378
434
  } else {
379
- await this.#connection?.send(
380
- active.request.type === "call"
381
- ? {
382
- type: "result",
383
- id: active.request.id,
384
- message: active.message,
385
- toolResults: active.toolResults,
386
- inputs,
387
- }
388
- : {
389
- type: "result",
390
- id: active.request.id,
391
- message: active.message,
392
- inputs,
393
- },
394
- );
435
+ await this.#connection?.send({
436
+ type: "result",
437
+ id: active.request.id,
438
+ cwd: active.session.cwd,
439
+ message: active.message,
440
+ inputs,
441
+ ...(active.request.type === "call"
442
+ ? { toolResults: active.toolResults }
443
+ : {}),
444
+ });
395
445
  }
396
446
  this.#active = undefined;
397
447
  this.#status = "idle";
@@ -401,13 +451,25 @@ export class LocalSession {
401
451
  #collectInputs(): void {
402
452
  const context = this.#context;
403
453
  if (!context) return;
404
- const sessionId = context.sessionManager.getSessionId();
454
+ const sessionManager = context.sessionManager;
455
+ const sessionId = sessionManager.getSessionId();
405
456
  if (this.#sessionId !== sessionId) {
406
457
  this.#sessionId = sessionId;
458
+ this.#inputCursor = null;
407
459
  this.#seenInputs.clear();
408
460
  this.#pendingInputs.clear();
409
461
  }
410
- 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()) {
411
473
  if (
412
474
  entry.type !== "message" ||
413
475
  entry.message.role !== "user" ||
@@ -421,6 +483,7 @@ export class LocalSession {
421
483
  this.#seenInputs.add(entry.id);
422
484
  this.#pendingInputs.set(entry.id, { id: entry.id, sessionId, message });
423
485
  }
486
+ this.#inputCursor = leafId;
424
487
  }
425
488
 
426
489
  #inputs(): SessionInput[] {
@@ -439,6 +502,7 @@ export class LocalSession {
439
502
  await connection.send({ type: "delivery", delivery });
440
503
  await completion.promise;
441
504
  this.#deliveries.delete(delivery.id);
505
+ this.#notify(`Result saved for ChatGPT ${delivery.chatId.slice(-4)}`);
442
506
  } finally {
443
507
  this.#stores.delete(delivery.id);
444
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,17 +36,41 @@ 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
 
43
- boundSessions(): Set<string> {
44
- return new Set(this.#bindings.values());
62
+ chats(sessionId: string): string[] {
63
+ return [...this.#bindings]
64
+ .filter(([, target]) => target === sessionId)
65
+ .map(([chatId]) => chatId);
66
+ }
67
+
68
+ bindingCounts(): Map<string, number> {
69
+ const counts = new Map<string, number>();
70
+ for (const sessionId of this.#bindings.values()) {
71
+ counts.set(sessionId, (counts.get(sessionId) ?? 0) + 1);
72
+ }
73
+ return counts;
45
74
  }
46
75
 
47
76
  setBinding(chatId: string, sessionId: string): Promise<void> {
@@ -60,23 +89,89 @@ export class State {
60
89
  return this.#save();
61
90
  }
62
91
 
63
- removeDeliveries(ids: string[]): Promise<void> {
64
- for (const id of ids) this.#deliveries.delete(id);
65
- 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;
66
97
  }
67
98
 
68
- async restoreDeliveries(deliveries: DeliveryRecord[]): Promise<void> {
69
- for (const delivery of deliveries)
70
- this.#deliveries.set(delivery.id, delivery);
99
+ async addQuestion(question: QuestionRecord): Promise<void> {
100
+ this.#questions.set(question.id, question);
71
101
  await this.#save();
72
102
  }
73
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
+
74
168
  #save(): Promise<void> {
75
169
  const saved = this.#writes.then(async () => {
76
170
  const bindings = Object.fromEntries(this.#bindings);
171
+ const workflows = Object.fromEntries(this.#workflows);
77
172
  await writeFile(
78
173
  this.#temporaryPath,
79
- `${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`,
80
175
  {
81
176
  mode: 0o600,
82
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 {
@@ -38,28 +36,13 @@ export const directTools = definitions.map((definition) => ({
38
36
  export function toolResult(
39
37
  toolResults: ToolResultMessage[],
40
38
  sessionId: string,
39
+ cwd: string,
41
40
  inputs: SessionInput[] = [],
42
41
  ) {
43
42
  return {
44
43
  content: [
45
- ...toolResults.flatMap((result) => [
46
- {
47
- type: "text" as const,
48
- text: JSON.stringify({
49
- toolCallId: result.toolCallId,
50
- toolName: result.toolName,
51
- isError: result.isError,
52
- }),
53
- },
54
- ...contentWithImageReferences(sessionId, result.content),
55
- ...resourceDescriptors(result.details).map((resource) => ({
56
- type: "resource_link" as const,
57
- uri: resource.uri,
58
- name: resource.name,
59
- mimeType: resource.mimeType,
60
- size: resource.size,
61
- })),
62
- ]),
44
+ { type: "text" as const, text: JSON.stringify({ sessionId, cwd }) },
45
+ ...toolResultsContent(toolResults, sessionId),
63
46
  ...inputContent(inputs),
64
47
  ],
65
48
  isError: toolResults.some((result) => result.isError),
@@ -70,7 +53,7 @@ export function inputContent(inputs: SessionInput[]) {
70
53
  return inputs.flatMap(({ id, sessionId, message }) => [
71
54
  {
72
55
  type: "text" as const,
73
- text: JSON.stringify({ piInput: id }),
56
+ text: JSON.stringify({ piInput: id, sessionId }),
74
57
  },
75
58
  ...(typeof message.content === "string"
76
59
  ? [{ type: "text" as const, text: message.content }]
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(