@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/transfer.ts CHANGED
@@ -1,20 +1,35 @@
1
1
  import { once } from "node:events";
2
2
  import { createWriteStream } from "node:fs";
3
- import { mkdir, stat, unlink } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { dirname, resolve } from "node:path";
3
+ import {
4
+ link,
5
+ mkdir,
6
+ mkdtempDisposable,
7
+ rename,
8
+ stat,
9
+ unlink,
10
+ } from "node:fs/promises";
11
+ import { homedir, hostname } from "node:os";
12
+ import { basename, dirname, join, resolve } from "node:path";
6
13
  import { Readable } from "node:stream";
7
14
  import { pipeline } from "node:stream/promises";
8
15
  import {
9
- type ExtensionContext,
16
+ formatSize,
17
+ type ToolDefinition,
10
18
  withFileMutationQueue,
11
19
  } from "@earendil-works/pi-coding-agent";
20
+ import { Text } from "@earendil-works/pi-tui";
12
21
  import { Type } from "typebox";
13
- import { describeResource, registerFile } from "./resources.ts";
22
+ import {
23
+ describeResource,
24
+ type ResourceDescriptor,
25
+ registerFile,
26
+ } from "./resources.ts";
14
27
 
15
- interface TransferDetails {
28
+ export interface TransferDetails {
29
+ device: string;
16
30
  files: ({ path: string; bytes: number } | { path: string; error: string })[];
17
- resources: import("./resources.ts").ResourceDescriptor[];
31
+ resources: ResourceDescriptor[];
32
+ to?: { sessionId: string; device: string };
18
33
  }
19
34
 
20
35
  export const transferFile = Type.Object({
@@ -24,43 +39,52 @@ export const transferFile = Type.Object({
24
39
  mime_type: Type.Optional(Type.String()),
25
40
  });
26
41
 
27
- export const transfer = {
28
- name: "transfer",
29
- label: "transfer",
30
- description:
31
- "Copy ChatGPT files into Pi paths when files is supplied. Otherwise, return resource links for Pi paths or Chappie image references.",
32
- parameters: Type.Object({
33
- paths: Type.Array(Type.String(), {
42
+ const parameters = Type.Object({
43
+ paths: Type.Array(Type.String(), {
44
+ minItems: 1,
45
+ description:
46
+ "Pi destinations for import; Pi source paths or chappie:// image references for export or session copies",
47
+ }),
48
+ files: Type.Optional(
49
+ Type.Array(transferFile, {
34
50
  minItems: 1,
35
51
  description:
36
- "Pi destinations for import; Pi paths or chappie:// image references for export",
52
+ "ChatGPT files paired with paths in order; omit for Pi sources",
37
53
  }),
38
- files: Type.Optional(
39
- Type.Array(transferFile, {
54
+ ),
55
+ to: Type.Optional(
56
+ Type.Object({
57
+ sessionId: Type.String({ description: "Destination Pi session" }),
58
+ paths: Type.Array(Type.String(), {
40
59
  minItems: 1,
41
- description:
42
- "ChatGPT files paired with paths in order; omit for export",
60
+ description: "Destinations paired with source paths in order",
43
61
  }),
44
- ),
45
- overwrite: Type.Optional(
46
- Type.Boolean({ description: "Overwrite existing target files" }),
47
- ),
48
- }),
62
+ }),
63
+ ),
64
+ overwrite: Type.Optional(
65
+ Type.Boolean({ description: "Overwrite existing target files" }),
66
+ ),
67
+ });
68
+
69
+ export const transfer = {
70
+ name: "transfer",
71
+ label: "transfer",
72
+ description:
73
+ "Copy ChatGPT files into Pi paths with files, or copy Pi files to another session with to. Otherwise, return resource links for Pi paths or Chappie image references.",
74
+ parameters,
49
75
  async execute(
50
- _id: string,
51
- args: {
52
- paths: string[];
53
- files?: { download_url: string }[];
54
- overwrite?: boolean;
55
- },
56
- signal: AbortSignal | undefined,
57
- _update: unknown,
58
- context: ExtensionContext,
76
+ _id,
77
+ args,
78
+ signal,
79
+ update,
80
+ context,
59
81
  ): Promise<{
60
82
  content: { type: "text"; text: string }[];
61
83
  details: TransferDetails;
62
84
  }> {
63
85
  const sessionId = context.sessionManager.getSessionId();
86
+ const device = hostname();
87
+ update?.({ content: [], details: { device, files: [], resources: [] } });
64
88
  if (!args.files) {
65
89
  const resources = await Promise.all(
66
90
  args.paths.map((requested) =>
@@ -73,7 +97,7 @@ export const transfer = {
73
97
  content: [
74
98
  { type: "text" as const, text: JSON.stringify({ resources }) },
75
99
  ],
76
- details: { files: [], resources },
100
+ details: { device, files: [], resources },
77
101
  };
78
102
  }
79
103
 
@@ -104,15 +128,140 @@ export const transfer = {
104
128
  }
105
129
  }),
106
130
  );
107
- if (files.some((file) => "error" in file)) {
108
- throw new Error(JSON.stringify({ files }));
131
+ return transferResult({ device, files, resources: [] });
132
+ },
133
+ renderCall(args, theme, context) {
134
+ const device = context.state.device ?? hostname();
135
+ const from = args.files ? "ChatGPT" : device;
136
+ const to = args.files
137
+ ? device
138
+ : (context.state.to ?? (args.to ? "Pi" : "ChatGPT"));
139
+ const header =
140
+ context.lastComponent instanceof Text
141
+ ? context.lastComponent
142
+ : new Text("", 0, 0);
143
+ header.setText(theme.fg("toolTitle", theme.bold(`${from} → ${to}`)));
144
+ context.state.header = header;
145
+ return header;
146
+ },
147
+ renderResult(result, _options, theme, context) {
148
+ const details = result.details;
149
+ if (!details) {
150
+ const text = result.content
151
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
152
+ .join("\n");
153
+ return new Text(
154
+ theme.fg(context.isError ? "error" : "toolOutput", text),
155
+ 0,
156
+ 0,
157
+ );
109
158
  }
110
- return {
111
- content: [{ type: "text" as const, text: JSON.stringify({ files }) }],
112
- details: { files, resources: [] },
113
- };
159
+ const args = context.args;
160
+ context.state.device = details.device;
161
+ context.state.to = details.to?.device ?? "ChatGPT";
162
+ const from = args.files ? "ChatGPT" : details.device;
163
+ const to = args.files ? details.device : (details.to?.device ?? "ChatGPT");
164
+ context.state.header?.setText(
165
+ theme.fg("toolTitle", theme.bold(`${from} → ${to}`)),
166
+ );
167
+ const lines =
168
+ details.resources.length > 0
169
+ ? details.resources.map((resource, index) => {
170
+ const source = displayPath(args.paths?.[index] ?? resource.name);
171
+ const destination = args.to?.paths[index];
172
+ return `${destination ? `${source} → ${destination}` : source} ${theme.fg("dim", formatSize(resource.size))}`;
173
+ })
174
+ : details.files.length > 0
175
+ ? details.files.map((file, index) => {
176
+ const source = args.to
177
+ ? args.paths?.[index]
178
+ : args.files?.[index]?.file_name;
179
+ const path = source
180
+ ? `${displayPath(source)} → ${file.path}`
181
+ : file.path;
182
+ return "error" in file
183
+ ? theme.fg("error", `${path}\n${file.error}`)
184
+ : `${path} ${theme.fg("dim", formatSize(file.bytes))}`;
185
+ })
186
+ : (args.paths ?? []).map((path, index) =>
187
+ args.to?.paths[index]
188
+ ? `${displayPath(path)} → ${args.to.paths[index]}`
189
+ : displayPath(path),
190
+ );
191
+ return new Text(lines.join("\n"), 0, 0);
114
192
  },
115
- };
193
+ } satisfies ToolDefinition<
194
+ typeof parameters,
195
+ TransferDetails,
196
+ { header?: Text; device?: string; to?: string }
197
+ >;
198
+
199
+ function displayPath(path: string): string {
200
+ return path.startsWith("chappie://")
201
+ ? decodeURIComponent(basename(new URL(path).pathname))
202
+ : path;
203
+ }
204
+
205
+ export function transferResult(details: TransferDetails): {
206
+ content: { type: "text"; text: string }[];
207
+ details: TransferDetails;
208
+ } {
209
+ if (details.files.some((file) => "error" in file)) {
210
+ throw new Error(
211
+ details.files
212
+ .map((file) =>
213
+ "error" in file
214
+ ? `${file.path}: ${file.error}`
215
+ : `${file.path} ${formatSize(file.bytes)}`,
216
+ )
217
+ .join("\n"),
218
+ );
219
+ }
220
+ return {
221
+ content: [{ type: "text", text: JSON.stringify(details) }],
222
+ details,
223
+ };
224
+ }
225
+
226
+ export async function copyFiles(
227
+ paths: string[],
228
+ resources: ResourceDescriptor[],
229
+ cwd: string,
230
+ overwrite: boolean,
231
+ read: (resource: ResourceDescriptor) => AsyncIterable<Uint8Array>,
232
+ signal: AbortSignal,
233
+ ): Promise<TransferDetails["files"]> {
234
+ if (paths.length !== resources.length)
235
+ throw new Error("Source and destination counts must match");
236
+ return Promise.all(
237
+ paths.map(async (requested, index) => {
238
+ const path = localPath(requested, cwd);
239
+ try {
240
+ const resource = resources[index];
241
+ if (!resource) throw new Error("Missing source resource");
242
+ const bytes = await withFileMutationQueue(path, async () => {
243
+ signal.throwIfAborted();
244
+ await mkdir(dirname(path), { recursive: true });
245
+ await using temporary = await mkdtempDisposable(
246
+ join(dirname(path), ".chappie-"),
247
+ );
248
+ const staged = join(temporary.path, "file");
249
+ const bytes = await importFile(staged, read(resource), false, signal);
250
+ signal.throwIfAborted();
251
+ if (overwrite) await rename(staged, path);
252
+ else await link(staged, path);
253
+ return bytes;
254
+ });
255
+ return { path, bytes };
256
+ } catch (error) {
257
+ return {
258
+ path: requested,
259
+ error: error instanceof Error ? error.message : String(error),
260
+ };
261
+ }
262
+ }),
263
+ );
264
+ }
116
265
 
117
266
  function localPath(path: string, cwd: string): string {
118
267
  if (path === "~") return homedir();
@@ -124,7 +273,7 @@ function localPath(path: string, cwd: string): string {
124
273
 
125
274
  async function importFile(
126
275
  path: string,
127
- url: string,
276
+ source: string | AsyncIterable<Uint8Array>,
128
277
  overwrite: boolean,
129
278
  signal?: AbortSignal,
130
279
  ): Promise<number> {
@@ -138,13 +287,15 @@ async function importFile(
138
287
  try {
139
288
  await once(writable, "open");
140
289
  opened = true;
141
- const response = await fetch(url, signal ? { signal } : {});
142
- if (!response.ok || !response.body) {
143
- throw new Error(`Download failed with HTTP ${response.status}`);
144
- }
145
- const readable = Readable.from(
146
- response.body as unknown as AsyncIterable<Uint8Array>,
147
- );
290
+ let content: AsyncIterable<Uint8Array>;
291
+ if (typeof source === "string") {
292
+ const response = await fetch(source, signal ? { signal } : {});
293
+ if (!response.ok || !response.body) {
294
+ throw new Error(`Download failed with HTTP ${response.status}`);
295
+ }
296
+ content = response.body as unknown as AsyncIterable<Uint8Array>;
297
+ } else content = source;
298
+ const readable = Readable.from(content, { objectMode: false });
148
299
  if (signal) await pipeline(readable, writable, { signal });
149
300
  else await pipeline(readable, writable);
150
301
  signal?.throwIfAborted();