@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/README.md +3 -9
- package/docs/tools.md +36 -36
- package/package.json +1 -1
- package/src/broker.ts +106 -328
- package/src/config.ts +0 -1
- package/src/history.ts +15 -2
- package/src/index.ts +4 -1
- package/src/instructions.md +5 -5
- package/src/ipc.ts +19 -4
- package/src/questions.ts +6 -14
- package/src/resources.ts +26 -6
- package/src/server.ts +40 -89
- package/src/session.ts +272 -27
- package/src/state.ts +1 -17
- package/src/transfer.ts +200 -49
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 {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
"
|
|
52
|
+
"ChatGPT files paired with paths in order; omit for Pi sources",
|
|
37
53
|
}),
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
args
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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();
|