@zetaloop/chappie 0.2.1 → 0.3.1

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/server.ts CHANGED
@@ -4,6 +4,13 @@ import * as z from "zod";
4
4
  import packageJson from "../package.json" with { type: "json" };
5
5
  import type { Broker } from "./broker.ts";
6
6
  import { deliveryContent } from "./delivery.ts";
7
+ import {
8
+ answerContent,
9
+ answerInput,
10
+ questionInput,
11
+ questionInstructions,
12
+ questionOutput,
13
+ } from "./questions.ts";
7
14
  import {
8
15
  directTools,
9
16
  inputContent,
@@ -20,10 +27,19 @@ const outputSchema = z.object({
20
27
  text: z
21
28
  .string()
22
29
  .describe(
23
- "Complete text output, including Pi user input and deferred results. Images and file resources accompany it as native content blocks.",
30
+ "Complete text output, including Pi user input, submitted webpage answers, and deferred results. Images and file resources accompany it as native content blocks.",
24
31
  ),
25
32
  });
26
33
 
34
+ const questionTemplate = "ui://chappie/question.html";
35
+ const questionSchema = outputSchema.extend({ question: questionOutput });
36
+ const toolAnnotations = {
37
+ readOnlyHint: true,
38
+ destructiveHint: false,
39
+ idempotentHint: true,
40
+ openWorldHint: false,
41
+ } as const;
42
+
27
43
  interface RequestContext {
28
44
  mcpReq: {
29
45
  _meta?: Record<string, unknown>;
@@ -37,31 +53,45 @@ export function createServer(broker: Broker): McpServer {
37
53
  name: "chappie",
38
54
  version: packageJson.version,
39
55
  },
40
- { instructions },
56
+ {
57
+ instructions: broker.askEnabled
58
+ ? `${instructions}\n\n${questionInstructions}`
59
+ : instructions,
60
+ },
41
61
  );
42
62
 
63
+ function handle<Args, Result>(
64
+ callback: (args: Args, context: RequestContext) => Promise<Result>,
65
+ ) {
66
+ return async (args: Args, context: RequestContext): Promise<Result> => {
67
+ const signal = await broker.workflow(
68
+ requestChatId(context),
69
+ context.mcpReq._meta?.["otunnel/requestId"],
70
+ context.mcpReq.signal,
71
+ );
72
+ return callback(args, {
73
+ ...context,
74
+ mcpReq: { ...context.mcpReq, signal },
75
+ });
76
+ };
77
+ }
78
+
43
79
  server.registerTool(
44
80
  "init",
45
81
  {
46
82
  title: "Connect to Pi",
47
83
  description:
48
- "Bind this chat to a Pi session. To resume work or a ChatGPT branch, pass that task's Pi sessionId from context; use sessions to locate a requested target when its ID is unknown. Omit sessionId to reuse this chat's default or allocate the first online, unbound session. Returns the selection source.",
84
+ "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.",
49
85
  outputSchema,
50
86
  inputSchema: z.object({
51
87
  sessionId: z
52
88
  .string()
53
89
  .optional()
54
- .describe(
55
- "Pi session ID to set as this chat's default, including an already-bound session when resuming work",
56
- ),
90
+ .describe("Default Pi session ID; may be shared with other chats"),
57
91
  }),
58
- annotations: {
59
- readOnlyHint: false,
60
- destructiveHint: false,
61
- openWorldHint: false,
62
- },
92
+ annotations: toolAnnotations,
63
93
  },
64
- async (args, context) => {
94
+ handle(async (args, context) => {
65
95
  const chatId = requireChatId(context);
66
96
  const { inputs, ...initialized } = await broker.initialize(
67
97
  chatId,
@@ -69,7 +99,7 @@ export function createServer(broker: Broker): McpServer {
69
99
  context.mcpReq.signal,
70
100
  );
71
101
  return finishResult(broker, context, textResult(initialized, inputs));
72
- },
102
+ }),
73
103
  );
74
104
 
75
105
  server.registerTool(
@@ -77,25 +107,18 @@ export function createServer(broker: Broker): McpServer {
77
107
  {
78
108
  title: "Reply in Pi",
79
109
  description:
80
- "Display the supplied Markdown in Pi and append it to the session transcript. Code blocks are displayed as text.",
110
+ "Send an assistant message to Pi. Renders Markdown and saves the message in the session transcript.",
81
111
  outputSchema,
82
112
  inputSchema: z.object({
83
- text: z
84
- .string()
85
- .min(1)
86
- .describe("Markdown message, including prose and code examples"),
113
+ text: z.string().min(1).describe("Assistant message in Markdown"),
87
114
  sessionId: z
88
115
  .string()
89
116
  .optional()
90
117
  .describe("Pi session for this operation only"),
91
118
  }),
92
- annotations: {
93
- readOnlyHint: false,
94
- destructiveHint: false,
95
- openWorldHint: false,
96
- },
119
+ annotations: toolAnnotations,
97
120
  },
98
- async (args, context) => {
121
+ handle(async (args, context) => {
99
122
  const chatId = requireChatId(context);
100
123
  const { sessionId, cwd, inputs } = await broker.chat(
101
124
  chatId,
@@ -108,15 +131,150 @@ export function createServer(broker: Broker): McpServer {
108
131
  context,
109
132
  textResult({ sessionId, cwd }, inputs),
110
133
  );
111
- },
134
+ }),
112
135
  );
113
136
 
137
+ if (broker.askEnabled) {
138
+ server.registerTool(
139
+ "ask",
140
+ {
141
+ title: "Ask in ChatGPT",
142
+ description:
143
+ "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.",
144
+ inputSchema: questionInput.extend({
145
+ sessionId: z
146
+ .string()
147
+ .optional()
148
+ .describe(
149
+ "Pi session for this question; defaults to this chat's session",
150
+ ),
151
+ }),
152
+ outputSchema: questionSchema,
153
+ annotations: toolAnnotations,
154
+ _meta: { ui: { resourceUri: questionTemplate } },
155
+ },
156
+ handle(async ({ sessionId, ...input }, context) => {
157
+ const question = await broker.ask(
158
+ requireChatId(context),
159
+ sessionId,
160
+ input,
161
+ context.mcpReq.signal,
162
+ );
163
+ const result = await finishResult(broker, context, {
164
+ content: [
165
+ {
166
+ type: "text",
167
+ text: `Question created. Call ask_assert({"questionId":"${question.id}"}) next.`,
168
+ },
169
+ ],
170
+ });
171
+ return {
172
+ ...result,
173
+ structuredContent: { ...result.structuredContent, question },
174
+ };
175
+ }),
176
+ );
177
+
178
+ server.registerTool(
179
+ "ask_assert",
180
+ {
181
+ title: "Assert question display",
182
+ description:
183
+ "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.",
184
+ inputSchema: z.object({
185
+ questionId: z.string().describe("question.id returned by ask"),
186
+ }),
187
+ outputSchema: questionSchema,
188
+ annotations: toolAnnotations,
189
+ },
190
+ handle(async ({ questionId }, context) => {
191
+ const question = await broker.assertQuestion(
192
+ requireChatId(context),
193
+ questionId,
194
+ context.mcpReq.signal,
195
+ );
196
+ const result = await finishResult(broker, context, {
197
+ content: [{ type: "text", text: "Question widget loaded." }],
198
+ });
199
+ return {
200
+ ...result,
201
+ structuredContent: { ...result.structuredContent, question },
202
+ };
203
+ }),
204
+ );
205
+
206
+ server.registerTool(
207
+ "answer",
208
+ {
209
+ title: "Question state",
210
+ description:
211
+ "Read a saved question, report widget loading, or save an answer, revision, or skip.",
212
+ inputSchema: z.object({
213
+ questionId: z.string(),
214
+ answer: answerInput.optional(),
215
+ loaded: z
216
+ .literal(true)
217
+ .optional()
218
+ .describe("The question widget has loaded"),
219
+ }),
220
+ outputSchema: questionSchema,
221
+ annotations: toolAnnotations,
222
+ _meta: { ui: { visibility: ["app"] }, "openai/widgetAccessible": true },
223
+ },
224
+ handle(async ({ questionId, answer, loaded = false }, context) => {
225
+ const question = await broker.answer(
226
+ requireChatId(context),
227
+ questionId,
228
+ answer,
229
+ loaded,
230
+ );
231
+ const text = answer
232
+ ? answer.skipped
233
+ ? "Question skipped."
234
+ : "Answer saved."
235
+ : loaded
236
+ ? "Question widget loaded."
237
+ : "Question state.";
238
+ return {
239
+ content: [{ type: "text", text }],
240
+ structuredContent: { text, question },
241
+ };
242
+ }),
243
+ );
244
+
245
+ server.registerResource(
246
+ "question",
247
+ questionTemplate,
248
+ { title: "Chappie question", mimeType: "text/html;profile=mcp-app" },
249
+ async () => ({
250
+ contents: [
251
+ {
252
+ uri: questionTemplate,
253
+ mimeType: "text/html;profile=mcp-app",
254
+ text: readFileSync(
255
+ new URL("./question.html", import.meta.url),
256
+ "utf8",
257
+ ),
258
+ _meta: {
259
+ ui: {
260
+ prefersBorder: true,
261
+ csp: { connectDomains: [], resourceDomains: [] },
262
+ },
263
+ "openai/widgetDescription":
264
+ "A persistent question the user can answer while the assistant continues working.",
265
+ },
266
+ },
267
+ ],
268
+ }),
269
+ );
270
+ }
271
+
114
272
  server.registerTool(
115
273
  "tools",
116
274
  {
117
275
  title: "Pi tools",
118
276
  description:
119
- "Return complete definitions for active Pi tools invoked through call. Provide names to inspect selected tools. Chappie's own MCP controls, including init, sessions, and chat, are exposed separately.",
277
+ "Get full definitions of Pi tools for call. Filter by names, or omit names to list all active tools.",
120
278
  outputSchema,
121
279
  inputSchema: z.object({
122
280
  names: z
@@ -129,14 +287,9 @@ export function createServer(broker: Broker): McpServer {
129
287
  .optional()
130
288
  .describe("Pi session for this operation only"),
131
289
  }),
132
- annotations: {
133
- readOnlyHint: true,
134
- destructiveHint: false,
135
- idempotentHint: true,
136
- openWorldHint: false,
137
- },
290
+ annotations: toolAnnotations,
138
291
  },
139
- async (args, context) => {
292
+ handle(async (args, context) => {
140
293
  const { inputs, ...inspected } = await broker.tools(
141
294
  requireChatId(context),
142
295
  args.sessionId,
@@ -151,7 +304,7 @@ export function createServer(broker: Broker): McpServer {
151
304
  inputs,
152
305
  ),
153
306
  );
154
- },
307
+ }),
155
308
  );
156
309
 
157
310
  server.registerTool(
@@ -159,7 +312,7 @@ export function createServer(broker: Broker): McpServer {
159
312
  {
160
313
  title: "Call Pi tools",
161
314
  description:
162
- "Execute one or more active Pi tools as one native batch. Arguments must match definitions returned by tools.",
315
+ "Execute Pi tools using the definitions returned by tools. Each calls array is one native Pi batch.",
163
316
  outputSchema,
164
317
  inputSchema: z.object({
165
318
  calls: z
@@ -175,13 +328,9 @@ export function createServer(broker: Broker): McpServer {
175
328
  .optional()
176
329
  .describe("Pi session for this operation only"),
177
330
  }),
178
- annotations: {
179
- readOnlyHint: false,
180
- destructiveHint: true,
181
- openWorldHint: true,
182
- },
331
+ annotations: toolAnnotations,
183
332
  },
184
- async (args, context) => {
333
+ handle(async (args, context) => {
185
334
  const result = await broker.call(
186
335
  requireChatId(context),
187
336
  args.sessionId,
@@ -198,7 +347,7 @@ export function createServer(broker: Broker): McpServer {
198
347
  result.inputs,
199
348
  ),
200
349
  );
201
- },
350
+ }),
202
351
  );
203
352
 
204
353
  for (const tool of directTools) {
@@ -209,17 +358,12 @@ export function createServer(broker: Broker): McpServer {
209
358
  description: tool.description,
210
359
  outputSchema,
211
360
  inputSchema: tool.inputSchema,
212
- annotations: {
213
- readOnlyHint: tool.name === "read",
214
- destructiveHint: tool.name !== "read",
215
- idempotentHint: tool.name === "read",
216
- openWorldHint: tool.name === "bash" || tool.name === "transfer",
217
- },
361
+ annotations: toolAnnotations,
218
362
  ...(tool.fileParams
219
363
  ? { _meta: { "openai/fileParams": tool.fileParams } }
220
364
  : {}),
221
365
  },
222
- async (args, context) => {
366
+ handle(async (args, context) => {
223
367
  const input = { ...args } as Record<string, unknown> & {
224
368
  sessionId?: string;
225
369
  };
@@ -242,7 +386,7 @@ export function createServer(broker: Broker): McpServer {
242
386
  result.inputs,
243
387
  ),
244
388
  );
245
- },
389
+ }),
246
390
  );
247
391
  }
248
392
 
@@ -251,22 +395,17 @@ export function createServer(broker: Broker): McpServer {
251
395
  {
252
396
  title: "Local sessions",
253
397
  description:
254
- "List online Pi sessions, their saved binding counts, and this conversation's default. A zero bindingCount permits automatic pairing; status describes execution. Offline sessions do not delay the listing.",
398
+ "List online Pi sessions with their IDs, devices, cwd, names, execution status, and saved binding counts. Also returns this chat's default.",
255
399
  outputSchema,
256
400
  inputSchema: z.object({
257
401
  sessionId: z
258
402
  .string()
259
403
  .optional()
260
- .describe("Return this Pi session when it is online"),
404
+ .describe("Filter the online list to this Pi session"),
261
405
  }),
262
- annotations: {
263
- readOnlyHint: true,
264
- destructiveHint: false,
265
- idempotentHint: true,
266
- openWorldHint: false,
267
- },
406
+ annotations: toolAnnotations,
268
407
  },
269
- async (args, context) => {
408
+ handle(async (args, context) => {
270
409
  const chatId = requestChatId(context);
271
410
  const inputs = chatId
272
411
  ? await broker.inputs(chatId, args.sessionId, context.mcpReq.signal)
@@ -279,7 +418,7 @@ export function createServer(broker: Broker): McpServer {
279
418
  inputs,
280
419
  );
281
420
  return finishResult(broker, context, result);
282
- },
421
+ }),
283
422
  );
284
423
 
285
424
  server.registerResource(
@@ -325,13 +464,18 @@ async function finishResult<
325
464
  >(broker: Broker, context: RequestContext, result: T) {
326
465
  const chatId = requestChatId(context);
327
466
  const deliveries = chatId ? await broker.deliveries(chatId) : [];
328
- const content = [...result.content, ...deliveryContent(deliveries)];
467
+ const answers = chatId ? broker.answers(chatId) : [];
468
+ const content = [
469
+ ...result.content,
470
+ ...deliveryContent(deliveries),
471
+ ...answerContent(answers),
472
+ ];
329
473
  const structuredContent = {
330
474
  text: content
331
475
  .flatMap((block) => (block.type === "text" ? [block.text] : []))
332
476
  .join("\n"),
333
477
  };
334
- await broker.acknowledgeDeliveries(deliveries, context.mcpReq.signal);
478
+ await broker.acknowledge(deliveries, answers, context.mcpReq.signal);
335
479
  return { ...result, content, structuredContent };
336
480
  }
337
481