@zetaloop/chappie 0.5.0 → 1.0.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/state.ts DELETED
@@ -1,163 +0,0 @@
1
- import { readFile, rename, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import type { DeliveryRecord } from "./delivery.ts";
4
- import type { QuestionAnswer, QuestionRecord } from "./questions.ts";
5
-
6
- interface StateFile {
7
- bindings?: Record<string, string>;
8
- deliveries?: DeliveryRecord[];
9
- questions?: QuestionRecord[];
10
- }
11
-
12
- export class State {
13
- readonly #path: string;
14
- readonly #temporaryPath: string;
15
- readonly #bindings = new Map<string, string>();
16
- readonly #deliveries = new Map<string, DeliveryRecord>();
17
- readonly #questions = new Map<string, QuestionRecord>();
18
- #writes = Promise.resolve();
19
-
20
- constructor(agentDir: string) {
21
- this.#path = join(agentDir, "chappie.state.json");
22
- this.#temporaryPath = `${this.#path}.tmp`;
23
- }
24
-
25
- async load(): Promise<void> {
26
- let contents: string;
27
- try {
28
- contents = await readFile(this.#path, "utf8");
29
- } catch (error) {
30
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
31
- throw error;
32
- }
33
- const state = JSON.parse(contents) as StateFile;
34
- for (const [chatId, sessionId] of Object.entries(state.bindings ?? {})) {
35
- if (typeof sessionId === "string") this.#bindings.set(chatId, sessionId);
36
- }
37
- for (const delivery of state.deliveries ?? []) {
38
- if (delivery?.id) this.#deliveries.set(delivery.id, delivery);
39
- }
40
- for (const question of state.questions ?? [])
41
- this.#questions.set(question.id, question);
42
- }
43
-
44
- binding(chatId: string): string | undefined {
45
- return this.#bindings.get(chatId);
46
- }
47
-
48
- bindingCounts(): Map<string, number> {
49
- const counts = new Map<string, number>();
50
- for (const sessionId of this.#bindings.values()) {
51
- counts.set(sessionId, (counts.get(sessionId) ?? 0) + 1);
52
- }
53
- return counts;
54
- }
55
-
56
- bind(chatId: string, sessionId: string): Promise<void> {
57
- this.#bindings.set(chatId, sessionId);
58
- return this.#save();
59
- }
60
-
61
- deliveries(chatId: string): DeliveryRecord[] {
62
- return [...this.#deliveries.values()].filter(
63
- (delivery) => delivery.chatId === chatId,
64
- );
65
- }
66
-
67
- addDelivery(delivery: DeliveryRecord): Promise<void> {
68
- this.#deliveries.set(delivery.id, delivery);
69
- return this.#save();
70
- }
71
-
72
- question(chatId: string, id: string): QuestionRecord {
73
- const question = this.#questions.get(id);
74
- if (!question || question.chatId !== chatId)
75
- throw new Error("Question not found in this ChatGPT conversation");
76
- return question;
77
- }
78
-
79
- async addQuestion(question: QuestionRecord): Promise<void> {
80
- this.#questions.set(question.id, question);
81
- await this.#save();
82
- }
83
-
84
- async answer(
85
- chatId: string,
86
- id: string,
87
- answer: QuestionAnswer,
88
- ): Promise<QuestionRecord> {
89
- const question = this.question(chatId, id);
90
- const selections = [...new Set(answer.selections)].sort((a, b) => a - b);
91
- if (selections.some((index) => !question.options[index]))
92
- throw new Error("Unknown question option");
93
- if (!question.allowMultiple && selections.length > 1)
94
- throw new Error("Select one option");
95
- if (answer.skipped && (selections.length > 0 || answer.text))
96
- throw new Error("A skipped question cannot include an answer");
97
- if (!answer.skipped && selections.length === 0 && !answer.text)
98
- throw new Error("Select an option or enter an answer");
99
- const value: QuestionAnswer = {
100
- selections,
101
- text: answer.text,
102
- ...(answer.skipped ? { skipped: true } : {}),
103
- };
104
- if (JSON.stringify(question.answer) === JSON.stringify(value))
105
- return question;
106
- const updated = { ...question, answer: value, delivered: false };
107
- await this.addQuestion(updated);
108
- return updated;
109
- }
110
-
111
- answers(chatId: string): QuestionRecord[] {
112
- return [...this.#questions.values()].filter(
113
- (question) =>
114
- question.chatId === chatId && question.answer && !question.delivered,
115
- );
116
- }
117
-
118
- async acknowledge(
119
- deliveries: DeliveryRecord[],
120
- answers: QuestionRecord[],
121
- signal: AbortSignal,
122
- ): Promise<void> {
123
- if (deliveries.length === 0 && answers.length === 0) return;
124
- signal.throwIfAborted();
125
- const delivered = new Map(
126
- answers.map((question) => [question, { ...question, delivered: true }]),
127
- );
128
- for (const delivery of deliveries) this.#deliveries.delete(delivery.id);
129
- for (const [question, updated] of delivered) {
130
- if (this.#questions.get(question.id) === question)
131
- this.#questions.set(question.id, updated);
132
- }
133
- try {
134
- await this.#save();
135
- signal.throwIfAborted();
136
- } catch (error) {
137
- for (const delivery of deliveries)
138
- this.#deliveries.set(delivery.id, delivery);
139
- for (const [question, updated] of delivered) {
140
- if (this.#questions.get(question.id) === updated)
141
- this.#questions.set(question.id, question);
142
- }
143
- await this.#save();
144
- throw error;
145
- }
146
- }
147
-
148
- #save(): Promise<void> {
149
- const saved = this.#writes.then(async () => {
150
- const bindings = Object.fromEntries(this.#bindings);
151
- await writeFile(
152
- this.#temporaryPath,
153
- `${JSON.stringify({ bindings, deliveries: [...this.#deliveries.values()], questions: [...this.#questions.values()] }, null, 2)}\n`,
154
- {
155
- mode: 0o600,
156
- },
157
- );
158
- await rename(this.#temporaryPath, this.#path);
159
- });
160
- this.#writes = saved.catch(() => {});
161
- return saved;
162
- }
163
- }
package/src/stdio.ts DELETED
@@ -1,58 +0,0 @@
1
- import { Writable } from "node:stream";
2
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import {
4
- StdioServerTransport,
5
- serveStdio,
6
- } from "@modelcontextprotocol/server/stdio";
7
- import { Broker } from "./broker.ts";
8
- import { createServer } from "./server.ts";
9
-
10
- const terminationSignals =
11
- process.platform === "win32"
12
- ? ["SIGINT", "SIGTERM"]
13
- : ["SIGHUP", "SIGINT", "SIGTERM"];
14
-
15
- export async function serveChappie(): Promise<never> {
16
- const broker = new Broker(getAgentDir());
17
- await broker.start();
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
- });
27
- const transport = new StdioServerTransport(process.stdin, output);
28
- let stop: (() => void) | undefined;
29
- const stopped = new Promise<void>((resolve) => {
30
- stop = resolve;
31
- });
32
- const requestStop = (): void => stop?.();
33
-
34
- output.once("error", requestStop);
35
- process.stdin.once("end", requestStop);
36
- process.stdin.once("close", requestStop);
37
- for (const signal of terminationSignals) {
38
- process.once(signal, requestStop);
39
- }
40
-
41
- const handle = serveStdio(() => createServer(broker), {
42
- transport,
43
- onerror(error) {
44
- console.error(error);
45
- const code = (error as NodeJS.ErrnoException).code;
46
- if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") requestStop();
47
- },
48
- });
49
-
50
- try {
51
- await stopped;
52
- await handle.close();
53
- } finally {
54
- await broker.close();
55
- }
56
- await new Promise<void>((resolve) => output.end(resolve));
57
- process.exit(0);
58
- }
package/src/tools.ts DELETED
@@ -1,91 +0,0 @@
1
- import type { ToolResultMessage } from "@earendil-works/pi-ai";
2
- import {
3
- createBashToolDefinition,
4
- createEditToolDefinition,
5
- createReadToolDefinition,
6
- createWriteToolDefinition,
7
- } from "@earendil-works/pi-coding-agent";
8
- import { fromJsonSchema } from "@modelcontextprotocol/server";
9
- import type { Initialization } from "./broker.ts";
10
- import { toolResultsContent } from "./delivery.ts";
11
- import type { SessionInput } from "./ipc.ts";
12
- import { contentWithImageReferences } from "./resources.ts";
13
- import { transfer } from "./transfer.ts";
14
-
15
- export interface ToolInput {
16
- name: string;
17
- arguments: Record<string, unknown>;
18
- }
19
-
20
- const definitions = [
21
- createReadToolDefinition("."),
22
- createBashToolDefinition("."),
23
- createEditToolDefinition("."),
24
- createWriteToolDefinition("."),
25
- transfer,
26
- ];
27
-
28
- export const directTools = definitions.map((definition) => ({
29
- name: definition.name,
30
- description: definition.description,
31
- inputSchema: fromJsonSchema<Record<string, unknown>>(
32
- withSessionId(definition.parameters as unknown as Record<string, unknown>),
33
- ),
34
- fileParams: definition.name === "transfer" ? ["files"] : undefined,
35
- }));
36
-
37
- export function toolResult(
38
- toolResults: ToolResultMessage[],
39
- sessionId: string,
40
- cwd: string,
41
- inputs: SessionInput[] = [],
42
- initialization?: Initialization,
43
- ) {
44
- return {
45
- content: [
46
- {
47
- type: "text" as const,
48
- text: JSON.stringify({
49
- sessionId,
50
- cwd,
51
- ...(initialization ? { initialization } : {}),
52
- }),
53
- },
54
- ...toolResultsContent(toolResults, sessionId),
55
- ...inputContent(inputs),
56
- ],
57
- isError: toolResults.some((result) => result.isError),
58
- };
59
- }
60
-
61
- export function inputContent(inputs: SessionInput[]) {
62
- return inputs.flatMap(({ id, sessionId, message }) => [
63
- {
64
- type: "text" as const,
65
- text: JSON.stringify({ piInput: id, sessionId }),
66
- },
67
- ...(typeof message.content === "string"
68
- ? [{ type: "text" as const, text: message.content }]
69
- : contentWithImageReferences(sessionId, message.content)),
70
- ]);
71
- }
72
-
73
- function withSessionId(
74
- schema: Record<string, unknown>,
75
- ): Record<string, unknown> {
76
- const copy = structuredClone(schema) as {
77
- properties?: Record<string, unknown>;
78
- };
79
- return {
80
- ...copy,
81
- properties: {
82
- ...copy.properties,
83
- sessionId: {
84
- type: "string",
85
- description:
86
- "Pi session for this operation; becomes the default if none is set",
87
- },
88
- },
89
- additionalProperties: false,
90
- };
91
- }
package/src/transfer.ts DELETED
@@ -1,313 +0,0 @@
1
- import { once } from "node:events";
2
- import { createWriteStream } from "node:fs";
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";
13
- import { Readable } from "node:stream";
14
- import { pipeline } from "node:stream/promises";
15
- import {
16
- formatSize,
17
- type ToolDefinition,
18
- withFileMutationQueue,
19
- } from "@earendil-works/pi-coding-agent";
20
- import { Text } from "@earendil-works/pi-tui";
21
- import { Type } from "typebox";
22
- import {
23
- describeResource,
24
- type ResourceDescriptor,
25
- registerFile,
26
- } from "./resources.ts";
27
-
28
- export interface TransferDetails {
29
- device: string;
30
- files: ({ path: string; bytes: number } | { path: string; error: string })[];
31
- resources: ResourceDescriptor[];
32
- to?: { sessionId: string; device: string };
33
- }
34
-
35
- export const transferFile = Type.Object({
36
- file_id: Type.String({ description: "Host file identifier" }),
37
- download_url: Type.String({ description: "Host-provided download URL" }),
38
- file_name: Type.Optional(Type.String()),
39
- mime_type: Type.Optional(Type.String()),
40
- });
41
-
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, {
50
- minItems: 1,
51
- description:
52
- "ChatGPT files paired with paths in order; omit for Pi sources",
53
- }),
54
- ),
55
- to: Type.Optional(
56
- Type.Object({
57
- sessionId: Type.String({ description: "Destination Pi session" }),
58
- paths: Type.Array(Type.String(), {
59
- minItems: 1,
60
- description: "Destinations paired with source paths in order",
61
- }),
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,
75
- async execute(
76
- _id,
77
- args,
78
- signal,
79
- update,
80
- context,
81
- ): Promise<{
82
- content: { type: "text"; text: string }[];
83
- details: TransferDetails;
84
- }> {
85
- const sessionId = context.sessionManager.getSessionId();
86
- const device = hostname();
87
- update?.({ content: [], details: { device, files: [], resources: [] } });
88
- if (!args.files) {
89
- const resources = await Promise.all(
90
- args.paths.map((requested) =>
91
- requested.startsWith("chappie://")
92
- ? describeResource(sessionId, requested)
93
- : registerFile(sessionId, localPath(requested, context.cwd)),
94
- ),
95
- );
96
- return {
97
- content: [
98
- { type: "text" as const, text: JSON.stringify({ resources }) },
99
- ],
100
- details: { device, files: [], resources },
101
- };
102
- }
103
-
104
- if (args.files.length !== args.paths.length) {
105
- throw new Error(
106
- "files and paths must contain the same number of entries",
107
- );
108
- }
109
- const files = await Promise.all(
110
- args.paths.map(async (requested, index) => {
111
- const path = localPath(requested, context.cwd);
112
- const source = args.files?.[index];
113
- if (!source)
114
- throw new Error("files and paths must correspond by index");
115
- try {
116
- const bytes = await importFile(
117
- path,
118
- source.download_url,
119
- args.overwrite === true,
120
- signal,
121
- );
122
- return { path, bytes };
123
- } catch (error) {
124
- return {
125
- path: requested,
126
- error: error instanceof Error ? error.message : String(error),
127
- };
128
- }
129
- }),
130
- );
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
- );
158
- }
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);
192
- },
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
- }
265
-
266
- function localPath(path: string, cwd: string): string {
267
- if (path === "~") return homedir();
268
- if (path.startsWith("~/") || path.startsWith("~\\")) {
269
- return resolve(homedir(), path.slice(2));
270
- }
271
- return resolve(cwd, path);
272
- }
273
-
274
- async function importFile(
275
- path: string,
276
- source: string | AsyncIterable<Uint8Array>,
277
- overwrite: boolean,
278
- signal?: AbortSignal,
279
- ): Promise<number> {
280
- return withFileMutationQueue(path, async () => {
281
- signal?.throwIfAborted();
282
- await mkdir(dirname(path), { recursive: true });
283
- const writable = createWriteStream(path, {
284
- flags: overwrite ? "w" : "wx",
285
- });
286
- let opened = false;
287
- try {
288
- await once(writable, "open");
289
- opened = true;
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 });
299
- if (signal) await pipeline(readable, writable, { signal });
300
- else await pipeline(readable, writable);
301
- signal?.throwIfAborted();
302
- return (await stat(path)).size;
303
- } catch (error) {
304
- if (!writable.closed) {
305
- const closed = once(writable, "close");
306
- writable.destroy();
307
- await closed.catch(() => {});
308
- }
309
- if (opened) await unlink(path).catch(() => {});
310
- throw error;
311
- }
312
- });
313
- }
File without changes