@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.
@@ -1,6 +1,6 @@
1
1
  Chappie connects this ChatGPT conversation to Pi sessions on one or more devices. Resume work with init using the task's Pi sessionId, including in a new chat or branch. When only a project or session name is known, find its device/cwd/name in sessions, then call init. If the target is absent or ambiguous, ask the user to resolve it. Pi sessions appear while the chappie/chatgpt provider is selected.
2
2
 
3
- init sets this chat's default Pi session. An execution tool also establishes a default on first use: sessionId selects its target, or omitting it selects the first online session with no saved bindings. These sessions may already contain work. Once a default exists, another tool's sessionId selects only that call's target. Defaults survive broker restarts, and several chats can share one Pi session. When resuming work after explicit or implicit initialization, read recent history to recover progress, then continue from the current request.
3
+ init sets this chat's default Pi session. An execution tool also establishes a default on first use: sessionId selects its target, or omitting it selects the first online session with no saved bindings. These sessions may already contain work. Once a default exists, another tool's sessionId selects only that call's target. Defaults survive broker restarts, and several chats can share one Pi session. When globalAgents is present, read and follow the instructions at globalAgents.path. Follow initialization.instructions for your participation in the current task. When executing work, read recent history to recover progress, then continue from the current request.
4
4
 
5
5
  The active model is this existing ChatGPT conversation. A Pi tool that starts another chappie/chatgpt agent has no ChatGPT conversation to attach to and will wait indefinitely. Subagents targeting another configured model keep that provider's normal behavior.
6
6
 
@@ -8,10 +8,10 @@ Prefer ChatGPT's web search, connectors, and cloud tools for remote research and
8
8
 
9
9
  Respond promptly to new Pi user input with a substantive reply, interaction, or immediate action that makes the response apparent in Pi before continuing lengthy work. Address inputs received together in one response; an immediate answer or result serves as its own acknowledgment.
10
10
 
11
- Use chat for assistant messages in Pi, including progress, explanations, and results. Use read, bash, edit, write, and transfer directly. init.tools is a Pi tool catalog; tools returns full definitions for call. Invoke Chappie's MCP tools directly. Each call array is one native Pi batch; separate calls are separate Pi turns. For Pi interaction, call an installed interactive tool.
11
+ When executing work, use chat for assistant messages in Pi, including progress, explanations, and completion results. Use read, bash, edit, write, and transfer directly. init.tools is a Pi tool catalog; tools returns full definitions for call. Invoke Chappie's MCP tools directly. Each call array is one native Pi batch; separate calls are separate Pi turns. For Pi interaction, call an installed interactive tool.
12
12
 
13
- transfer pairs files from ChatGPT with Pi destination paths in order. Omit files to return resource links for Pi paths or chappie:// image references. Relative paths use the Pi working directory; overwrite: true replaces existing targets. The host may request confirmation when retrieving exported bytes.
13
+ transfer pairs files from ChatGPT with Pi destination paths in order. To copy between Pi sessions, use paths on the selected source session and to: { sessionId, paths } for the destination. Each side resolves paths in its own working directory. Omit files and to to return resource links for Pi paths or chappie:// image references. Relative paths use the Pi working directory; overwrite: true replaces existing targets. The host may request confirmation when retrieving exported bytes.
14
14
 
15
- Tool results identify the executing Pi sessionId and cwd. A shell command can access another directory without changing its Pi session. structuredContent.text includes the complete text, new Pi input, webAnswer, and deferred results; images and resources are native content blocks. Continue from received results rather than repeating work. Host deadlines include queueing and execution; use local persistent processes for longer work.
15
+ Tool results identify the executing Pi sessionId and cwd. A shell command can access another directory without changing its Pi session. structuredContent.text includes tool output, new Pi input, webAnswer, and deferred results; images and resources are native content blocks. ChatGPT truncates tool responses exceeding 10,000 tokens. Continue from received results rather than repeating work. Host deadlines include queueing and execution; use local persistent processes for longer work.
16
16
 
17
- Use history to read the current Pi branch with original entry IDs and timestamps, including chat activity records. Assistant messages and tool results expose their originating chatId and requestId in message.chappie; activity records include the same fields. Request IDs and the workflow suffix in notices describe recorded requests, and duplicate executions can share a workflow ID. It defaults to the last 20 entries; before pages backward and after pages forward. History is a record of past work, separate from new Pi input. It leaves a reading notice and returns its content only to the caller.
17
+ Use history to read the current Pi branch with entry IDs and timestamps. History defaults to the last 20 entries; before pages backward and after pages forward. Use after with wait: true to follow progress: available entries return immediately, otherwise the call waits up to 30 seconds. An empty result means no new entries. Set observer: true when reading as an observer. Read the work's completion message to form the final reply. History returns saved contents separately from new Pi input.
package/src/ipc.ts CHANGED
@@ -20,7 +20,8 @@ import type {
20
20
  import type { Activity } from "./activity.ts";
21
21
  import type { DeliveryRecord } from "./delivery.ts";
22
22
  import type { HistoryRange, HistoryResult } from "./history.ts";
23
- import type { ResourceData } from "./resources.ts";
23
+ import type { ResourceData, ResourceDescriptor } from "./resources.ts";
24
+ import type { TransferDetails } from "./transfer.ts";
24
25
 
25
26
  const defaultPort = 24274;
26
27
 
@@ -50,7 +51,7 @@ export type SessionResult =
50
51
  | {
51
52
  inspection: SessionInspection;
52
53
  inputs: SessionInput[];
53
- globalAgents?: string;
54
+ globalAgents?: { path: string };
54
55
  }
55
56
  | { message: AssistantMessage; cwd: string; inputs: SessionInput[] }
56
57
  | {
@@ -61,19 +62,32 @@ export type SessionResult =
61
62
  }
62
63
  | { history: HistoryResult; cwd: string }
63
64
  | { resource: ResourceData }
65
+ | { transfer: TransferDetails }
64
66
  | { error: string };
65
67
 
68
+ export type SessionRequest =
69
+ | { type: "inspect"; sessionId: string }
70
+ | { type: "readResource"; sessionId: string; uri: string; offset?: number }
71
+ | {
72
+ type: "copy";
73
+ sessionId: string;
74
+ resources: ResourceDescriptor[];
75
+ paths: string[];
76
+ overwrite?: boolean;
77
+ };
78
+
66
79
  export type SessionMessage =
67
80
  | { type: "sync"; id: number; session: SessionDescription }
68
81
  | { type: "unregister"; sessionId: string }
69
82
  | { type: "delivery"; delivery: DeliveryRecord }
83
+ | { type: "request"; id: number; request: SessionRequest }
84
+ | { type: "cancelRequest"; id: number }
70
85
  | ({ type: "result"; id: number } & SessionResult);
71
86
 
72
87
  export type BrokerMessage =
73
88
  | { type: "synced"; id: number; sessionId: string }
74
89
  | { type: "stored"; id: string }
75
90
  | { type: "notice"; sessionId: string; message: string; activity?: Activity }
76
- | { type: "inspect"; id: number; sessionId: string }
77
91
  | {
78
92
  type: "history";
79
93
  id: number;
@@ -99,7 +113,8 @@ export type BrokerMessage =
99
113
  calls: ToolCall[];
100
114
  }
101
115
  | { type: "cancel"; id: number; sessionId: string; reason: string }
102
- | { type: "readResource"; id: number; sessionId: string; uri: string }
116
+ | (SessionRequest & { id: number })
117
+ | ({ type: "response"; id: number } & SessionResult)
103
118
  | { type: "ackInputs"; sessionId: string; ids: string[] };
104
119
 
105
120
  export function ipcEndpoint(agentDir: string): string {
package/src/questions.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  import * as z from "zod";
2
2
 
3
3
  export const questionInstructions =
4
- "Use ask for a question in ChatGPT, then immediately call ask_assert with question.id from its result. The assertion returns when the widget reports loaded and times out if loading fails. User answers arrive separately as webAnswer in normal tool results. Apply answers and revisions promptly; a skip means proceed with available information. Supply header when useful and mark the preferred first option recommended: true. The widget provides custom input and skipping. If loading fails and input is needed, use an installed Pi interactive tool through call.";
4
+ "ask requests a question widget in ChatGPT; its result confirms creation of the request. Immediately call ask_assert with question.id to confirm loading. If the widget fails to load within 10 seconds, the assertion fails and records the question as skipped. Use an installed Pi interactive tool through call when an answer is needed. User answers arrive separately as webAnswer in normal tool results. Apply answers and revisions promptly; a user skip means proceed with available information.";
5
5
 
6
6
  export const questionInput = z.object({
7
- header: z
8
- .string()
9
- .trim()
10
- .min(1)
11
- .optional()
12
- .describe("Short topic label above the question, when useful"),
7
+ header: z.string().trim().min(1).optional().describe("Short topic label"),
13
8
  question: z
14
9
  .string()
15
10
  .trim()
@@ -18,7 +13,7 @@ export const questionInput = z.object({
18
13
  context: z
19
14
  .string()
20
15
  .optional()
21
- .describe("Context to display above the choices"),
16
+ .describe("Background needed to answer the question"),
22
17
  options: z
23
18
  .array(
24
19
  z.object({
@@ -30,12 +25,12 @@ export const questionInput = z.object({
30
25
  recommended: z
31
26
  .boolean()
32
27
  .optional()
33
- .describe("Show a Recommended badge; put this choice first"),
28
+ .describe("Preferred choice; place it first"),
34
29
  }),
35
30
  )
36
31
  .default([])
37
32
  .describe(
38
- "Distinct choices, usually two or three. The widget provides custom input and skipping separately.",
33
+ "Distinct choices, usually two or three. Freeform answers and skipping are available separately.",
39
34
  ),
40
35
  allowMultiple: z
41
36
  .boolean()
@@ -46,10 +41,7 @@ export const questionInput = z.object({
46
41
  export const answerInput = z.object({
47
42
  selections: z.array(z.number().int().nonnegative()).default([]),
48
43
  text: z.string().trim().default(""),
49
- skipped: z
50
- .literal(true)
51
- .optional()
52
- .describe("The user skipped this question"),
44
+ skipped: z.literal(true).optional().describe("The question was skipped"),
53
45
  });
54
46
 
55
47
  export const questionOutput = questionInput.extend({
package/src/resources.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { open, readFile, stat } from "node:fs/promises";
3
3
  import { basename } from "node:path";
4
4
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
5
5
  import mime from "mime";
@@ -124,15 +124,35 @@ export function describeResource(
124
124
  export async function readSessionResource(
125
125
  sessionId: string,
126
126
  uri: string,
127
+ offset?: number,
127
128
  ): Promise<ResourceData> {
128
129
  const descriptor = describeResource(sessionId, uri);
129
130
  const entry = store(sessionId).get(uri);
130
131
  if (!entry) throw new Error(`Unknown Chappie resource: ${uri}`);
131
- const blob =
132
- entry.type === "file"
133
- ? (await readFile(entry.path)).toString("base64")
134
- : entry.data;
135
- return { ...descriptor, blob };
132
+ if (offset === undefined) {
133
+ const blob =
134
+ entry.type === "file"
135
+ ? (await readFile(entry.path)).toString("base64")
136
+ : entry.data;
137
+ return { ...descriptor, blob };
138
+ }
139
+ if (!Number.isSafeInteger(offset) || offset < 0)
140
+ throw new Error("Resource offset must be a nonnegative integer");
141
+ const length = Math.min(1024 * 1024, Math.max(0, descriptor.size - offset));
142
+ let data: Buffer;
143
+ if (entry.type === "file") {
144
+ await using file = await open(entry.path, "r");
145
+ const { buffer, bytesRead } = await file.read(
146
+ Buffer.alloc(length),
147
+ 0,
148
+ length,
149
+ offset,
150
+ );
151
+ data = buffer.subarray(0, bytesRead);
152
+ } else {
153
+ data = Buffer.from(entry.data, "base64").subarray(offset, offset + length);
154
+ }
155
+ return { ...descriptor, blob: data.toString("base64") };
136
156
  }
137
157
 
138
158
  export function resourceSessionId(uri: string): string {
package/src/server.ts CHANGED
@@ -32,9 +32,6 @@ const outputSchema = z.object({
32
32
  ),
33
33
  });
34
34
 
35
- const syncInstructions =
36
- "Synchronization ends accidental duplicate executions so one execution continues. Retain each initialization code privately in this ChatGPT context for its Pi session. On conflicting activity, call sync with action start, then verify with the code returned by your own most recent initialization. A rejected or unavailable code means this execution must send one final chat message with its task, entry time, and explicit exit statement, then end this ChatGPT response immediately. It must not wait for unlock, poll, reinitialize, or resume after release. Verification returns a new code and leaves ordinary tools and initialization locked. The verified execution uses chat and history to identify conflicting executions, require their final exit messages, and confirm their responses have ended before releasing with that code. Idle Pi status or a quiet history page is not proof of exit. Codes belong only in initialization and sync exchanges, never in chat, history, or notes.";
37
-
38
35
  const questionTemplate = "ui://chappie/question.html";
39
36
  const questionSchema = outputSchema.extend({ question: questionOutput });
40
37
  const toolAnnotations = {
@@ -61,27 +58,18 @@ export function createServer(broker: Broker): McpServer {
61
58
  instructions: [
62
59
  instructions,
63
60
  ...(broker.askEnabled ? [questionInstructions] : []),
64
- ...(broker.syncEnabled ? [syncInstructions] : []),
65
61
  ].join("\n\n"),
66
62
  },
67
63
  );
68
64
 
69
65
  function handle<Args, Result>(
70
66
  callback: (args: Args, context: RequestContext) => Promise<Result>,
71
- communication = false,
72
67
  ) {
73
- return (args: Args, context: RequestContext): Promise<Result> =>
74
- broker.run(
75
- requireChatId(context),
76
- (args as { sessionId?: string }).sessionId,
77
- context.mcpReq.signal,
78
- (signal) =>
79
- callback(args, {
80
- ...context,
81
- mcpReq: { ...context.mcpReq, signal },
82
- }),
83
- communication,
84
- );
68
+ return (args: Args, context: RequestContext): Promise<Result> => {
69
+ requireChatId(context);
70
+ context.mcpReq.signal.throwIfAborted();
71
+ return callback(args, context);
72
+ };
85
73
  }
86
74
 
87
75
  server.registerTool(
@@ -89,7 +77,7 @@ export function createServer(broker: Broker): McpServer {
89
77
  {
90
78
  title: "Connect to Pi",
91
79
  description:
92
- "Select this chat's default Pi session and return its environment and tool catalog. Use the task's sessionId to resume, or find it by cwd/name with sessions. For a task without a specified target, omit sessionId to reuse the default or select the first online, unbound session. Read recent history when resuming work.",
80
+ "Select this chat's default Pi session and return its environment, tool catalog, and participation instructions. Use the task's sessionId to resume, or find it by cwd/name with sessions. For a task without a specified target, omit sessionId to reuse the default or select the first online, unbound session. Read recent history when resuming work.",
93
81
  outputSchema,
94
82
  inputSchema: z.object({
95
83
  sessionId: z
@@ -115,8 +103,7 @@ export function createServer(broker: Broker): McpServer {
115
103
  "chat",
116
104
  {
117
105
  title: "Reply in Pi",
118
- description:
119
- "Send an assistant message to Pi. Renders Markdown and saves the message in the session transcript.",
106
+ description: "Send a Markdown assistant message to Pi.",
120
107
  outputSchema,
121
108
  inputSchema: z.object({
122
109
  text: z.string().min(1).describe("Assistant message in Markdown"),
@@ -146,7 +133,7 @@ export function createServer(broker: Broker): McpServer {
146
133
  inputs,
147
134
  ),
148
135
  );
149
- }, true),
136
+ }),
150
137
  );
151
138
 
152
139
  if (broker.askEnabled) {
@@ -155,7 +142,7 @@ export function createServer(broker: Broker): McpServer {
155
142
  {
156
143
  title: "Ask in ChatGPT",
157
144
  description:
158
- "Create a question in ChatGPT and return its ID immediately. Call ask_assert next with question.id. Answers, revisions, and skips arrive as webAnswer in later tool results.",
145
+ "Request a question widget in ChatGPT and return its ID immediately. Display depends on the host; call ask_assert next with question.id to confirm loading. Answers, revisions, and skips arrive as webAnswer in later tool results.",
159
146
  inputSchema: questionInput.extend({
160
147
  sessionId: z
161
148
  .string()
@@ -181,7 +168,7 @@ export function createServer(broker: Broker): McpServer {
181
168
  ...(initialization ? textResult({ initialization }).content : []),
182
169
  {
183
170
  type: "text",
184
- text: `Question created. Call ask_assert({"questionId":"${question.id}"}) next.`,
171
+ text: `Question widget requested. Call ask_assert({"questionId":"${question.id}"}) next.`,
185
172
  },
186
173
  ],
187
174
  });
@@ -197,7 +184,7 @@ export function createServer(broker: Broker): McpServer {
197
184
  {
198
185
  title: "Assert question display",
199
186
  description:
200
- "Assert that an ask widget loaded in ChatGPT. Call immediately after ask with question.id. Returns when the widget reports loaded; times out if it fails to load. User answers arrive separately as webAnswer.",
187
+ "Confirm that an ask widget loaded in ChatGPT. Call immediately after ask with question.id. Fails after 10 seconds without loading and records the question as skipped. Use a Pi interactive tool if an answer is needed. User answers arrive separately as webAnswer.",
201
188
  inputSchema: z.object({
202
189
  questionId: z.string().describe("question.id returned by ask"),
203
190
  }),
@@ -278,7 +265,7 @@ export function createServer(broker: Broker): McpServer {
278
265
  csp: { connectDomains: [], resourceDomains: [] },
279
266
  },
280
267
  "openai/widgetDescription":
281
- "A persistent question the user can answer while the assistant continues working.",
268
+ "A question the user can answer or revise.",
282
269
  },
283
270
  },
284
271
  ],
@@ -427,7 +414,7 @@ export function createServer(broker: Broker): McpServer {
427
414
  {
428
415
  title: "Session history",
429
416
  description:
430
- "Read recent Pi history with entry IDs and timestamps. Use before/after to page the current branch. History provides context for the current task. An explicit sessionId applies only to this read.",
417
+ "Read Pi history with entry IDs and timestamps. Use before/after to page the current branch, and wait to follow new progress when caught up. Set observer when reading as an observer. An explicit sessionId applies only to this read.",
431
418
  inputSchema: historyInput.extend({
432
419
  sessionId: z
433
420
  .string()
@@ -446,62 +433,18 @@ export function createServer(broker: Broker): McpServer {
446
433
  context.mcpReq.signal,
447
434
  );
448
435
  const { content, ...page } = history;
449
- return formatResult({
450
- content: [
451
- ...textResult({
452
- ...session,
453
- history: page,
454
- ...(broker.syncEnabled
455
- ? { sync: broker.syncState(session.sessionId) }
456
- : {}),
457
- }).content,
458
- ...content,
459
- ],
460
- });
461
- }, true),
436
+ return formatResult(
437
+ {
438
+ content: [
439
+ ...textResult({ ...session, history: page }).content,
440
+ ...content,
441
+ ],
442
+ },
443
+ requireChatId(context),
444
+ );
445
+ }),
462
446
  );
463
447
 
464
- if (broker.syncEnabled) {
465
- server.registerTool(
466
- "sync",
467
- {
468
- title: "Synchronize Pi activity",
469
- description:
470
- "End duplicate executions: start synchronization, verify your own latest initialization code, and release after conflicting executions exit. Failed verification requires a final chat exit message followed by ending this response. Verification leaves the session locked; chat and history support exit coordination.",
471
- inputSchema: z.object({
472
- action: z.enum(["start", "verify", "release"]),
473
- code: z
474
- .string()
475
- .optional()
476
- .describe(
477
- "Initialization code for verification, or the verified code for release; omit when unavailable",
478
- ),
479
- sessionId: z
480
- .string()
481
- .optional()
482
- .describe(
483
- "Pi session to synchronize; defaults to this chat's session",
484
- ),
485
- }),
486
- outputSchema,
487
- annotations: toolAnnotations,
488
- },
489
- async ({ action, code, sessionId }, context) =>
490
- formatResult(
491
- textResult(
492
- await broker.sync(
493
- requireChatId(context),
494
- sessionId,
495
- action,
496
- code,
497
- context.mcpReq._meta?.["otunnel/requestId"],
498
- context.mcpReq.signal,
499
- ),
500
- ),
501
- ),
502
- );
503
- }
504
-
505
448
  server.registerTool(
506
449
  "sessions",
507
450
  {
@@ -530,14 +473,17 @@ export function createServer(broker: Broker): McpServer {
530
473
  inputs,
531
474
  );
532
475
  return finishResult(broker, context, result);
533
- }, true),
476
+ }),
534
477
  );
535
478
 
536
479
  server.registerResource(
537
480
  "Pi resource",
538
- new ResourceTemplate("chappie://session/{sessionId}/{kind}/{id}/{name}", {
539
- list: undefined,
540
- }),
481
+ new ResourceTemplate(
482
+ "chappie://session/{sessionId}/{kind}/{id}/{name}{?chatId}",
483
+ {
484
+ list: undefined,
485
+ },
486
+ ),
541
487
  { title: "Pi resource" },
542
488
  async (uri, _variables, context) => {
543
489
  const resource = await broker.readResource(
@@ -576,8 +522,6 @@ async function finishResult<
576
522
  >(broker: Broker, context: RequestContext, result: T) {
577
523
  context.mcpReq.signal.throwIfAborted();
578
524
  const chatId = requestChatId(context);
579
- if (!broker.deliveryAllowed(context.mcpReq.signal))
580
- return formatResult(result);
581
525
  const deliveries = chatId ? broker.deliveries(chatId) : [];
582
526
  const answers = chatId ? broker.answers(chatId) : [];
583
527
  const content = [
@@ -586,16 +530,23 @@ async function finishResult<
586
530
  ...answerContent(answers),
587
531
  ];
588
532
  await broker.acknowledge(deliveries, answers, context.mcpReq.signal);
589
- return formatResult({ ...result, content });
533
+ return formatResult({ ...result, content }, chatId);
590
534
  }
591
535
 
592
536
  function formatResult<
593
537
  T extends { content: ReturnType<typeof toolResult>["content"] },
594
- >(result: T) {
538
+ >(result: T, chatId?: string) {
539
+ const content = result.content.map((block) => {
540
+ if (block.type !== "resource_link" || !chatId) return block;
541
+ const uri = new URL(block.uri);
542
+ uri.searchParams.set("chatId", chatId);
543
+ return { ...block, uri: uri.href };
544
+ });
595
545
  return {
596
546
  ...result,
547
+ content,
597
548
  structuredContent: {
598
- text: result.content
549
+ text: content
599
550
  .flatMap((block) => (block.type === "text" ? [block.text] : []))
600
551
  .join("\n"),
601
552
  },