@zetaloop/chappie 0.2.0 → 0.3.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/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,
@@ -16,6 +23,23 @@ const instructions = readFileSync(
16
23
  "utf8",
17
24
  ).trim();
18
25
 
26
+ const outputSchema = z.object({
27
+ text: z
28
+ .string()
29
+ .describe(
30
+ "Complete text output, including Pi user input, submitted webpage answers, and deferred results. Images and file resources accompany it as native content blocks.",
31
+ ),
32
+ });
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
+
19
43
  interface RequestContext {
20
44
  mcpReq: {
21
45
  _meta?: Record<string, unknown>;
@@ -29,27 +53,45 @@ export function createServer(broker: Broker): McpServer {
29
53
  name: "chappie",
30
54
  version: packageJson.version,
31
55
  },
32
- { instructions },
56
+ {
57
+ instructions: broker.askEnabled
58
+ ? `${instructions}\n\n${questionInstructions}`
59
+ : instructions,
60
+ },
33
61
  );
34
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
+
35
79
  server.registerTool(
36
80
  "init",
37
81
  {
38
82
  title: "Connect to Pi",
39
83
  description:
40
- "Connect this ChatGPT conversation to an online Pi session. A sessionId on init becomes the new default.",
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.",
85
+ outputSchema,
41
86
  inputSchema: z.object({
42
87
  sessionId: z
43
88
  .string()
44
89
  .optional()
45
- .describe("Pi session to select explicitly"),
90
+ .describe("Default Pi session ID; may be shared with other chats"),
46
91
  }),
47
- annotations: {
48
- destructiveHint: false,
49
- openWorldHint: false,
50
- },
92
+ annotations: toolAnnotations,
51
93
  },
52
- async (args, context) => {
94
+ handle(async (args, context) => {
53
95
  const chatId = requireChatId(context);
54
96
  const { inputs, ...initialized } = await broker.initialize(
55
97
  chatId,
@@ -57,44 +99,183 @@ export function createServer(broker: Broker): McpServer {
57
99
  context.mcpReq.signal,
58
100
  );
59
101
  return finishResult(broker, context, textResult(initialized, inputs));
60
- },
102
+ }),
61
103
  );
62
104
 
63
105
  server.registerTool(
64
106
  "chat",
65
107
  {
66
108
  title: "Reply in Pi",
67
- description: "Display one complete assistant message in Pi.",
109
+ description:
110
+ "Send an assistant message to Pi. Renders Markdown and saves the message in the session transcript.",
111
+ outputSchema,
68
112
  inputSchema: z.object({
69
- text: z.string().min(1).describe("Assistant message to display in Pi"),
113
+ text: z.string().min(1).describe("Assistant message in Markdown"),
70
114
  sessionId: z
71
115
  .string()
72
116
  .optional()
73
117
  .describe("Pi session for this operation only"),
74
118
  }),
75
- annotations: {
76
- destructiveHint: false,
77
- openWorldHint: false,
78
- },
119
+ annotations: toolAnnotations,
79
120
  },
80
- async (args, context) => {
121
+ handle(async (args, context) => {
81
122
  const chatId = requireChatId(context);
82
- const { sessionId, inputs } = await broker.chat(
123
+ const { sessionId, cwd, inputs } = await broker.chat(
83
124
  chatId,
84
125
  args.sessionId,
85
126
  args.text,
86
127
  context.mcpReq.signal,
87
128
  );
88
- return finishResult(broker, context, textResult({ sessionId }, inputs));
89
- },
129
+ return finishResult(
130
+ broker,
131
+ context,
132
+ textResult({ sessionId, cwd }, inputs),
133
+ );
134
+ }),
90
135
  );
91
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
+
92
272
  server.registerTool(
93
273
  "tools",
94
274
  {
95
275
  title: "Pi tools",
96
276
  description:
97
- "Return complete definitions for active Pi tools. Provide names to inspect only those tools.",
277
+ "Get full definitions of Pi tools for call. Filter by names, or omit names to list all active tools.",
278
+ outputSchema,
98
279
  inputSchema: z.object({
99
280
  names: z
100
281
  .array(z.string())
@@ -106,14 +287,9 @@ export function createServer(broker: Broker): McpServer {
106
287
  .optional()
107
288
  .describe("Pi session for this operation only"),
108
289
  }),
109
- annotations: {
110
- readOnlyHint: true,
111
- destructiveHint: false,
112
- idempotentHint: true,
113
- openWorldHint: false,
114
- },
290
+ annotations: toolAnnotations,
115
291
  },
116
- async (args, context) => {
292
+ handle(async (args, context) => {
117
293
  const { inputs, ...inspected } = await broker.tools(
118
294
  requireChatId(context),
119
295
  args.sessionId,
@@ -128,7 +304,7 @@ export function createServer(broker: Broker): McpServer {
128
304
  inputs,
129
305
  ),
130
306
  );
131
- },
307
+ }),
132
308
  );
133
309
 
134
310
  server.registerTool(
@@ -136,7 +312,8 @@ export function createServer(broker: Broker): McpServer {
136
312
  {
137
313
  title: "Call Pi tools",
138
314
  description:
139
- "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.",
316
+ outputSchema,
140
317
  inputSchema: z.object({
141
318
  calls: z
142
319
  .array(
@@ -151,12 +328,9 @@ export function createServer(broker: Broker): McpServer {
151
328
  .optional()
152
329
  .describe("Pi session for this operation only"),
153
330
  }),
154
- annotations: {
155
- destructiveHint: true,
156
- openWorldHint: true,
157
- },
331
+ annotations: toolAnnotations,
158
332
  },
159
- async (args, context) => {
333
+ handle(async (args, context) => {
160
334
  const result = await broker.call(
161
335
  requireChatId(context),
162
336
  args.sessionId,
@@ -166,9 +340,14 @@ export function createServer(broker: Broker): McpServer {
166
340
  return finishResult(
167
341
  broker,
168
342
  context,
169
- toolResult(result.toolResults, result.sessionId, result.inputs),
343
+ toolResult(
344
+ result.toolResults,
345
+ result.sessionId,
346
+ result.cwd,
347
+ result.inputs,
348
+ ),
170
349
  );
171
- },
350
+ }),
172
351
  );
173
352
 
174
353
  for (const tool of directTools) {
@@ -177,18 +356,14 @@ export function createServer(broker: Broker): McpServer {
177
356
  {
178
357
  title: tool.name,
179
358
  description: tool.description,
359
+ outputSchema,
180
360
  inputSchema: tool.inputSchema,
181
- annotations: {
182
- readOnlyHint: tool.name === "read",
183
- destructiveHint: tool.name !== "read",
184
- idempotentHint: tool.name === "read",
185
- openWorldHint: tool.name === "bash" || tool.name === "transfer",
186
- },
361
+ annotations: toolAnnotations,
187
362
  ...(tool.fileParams
188
363
  ? { _meta: { "openai/fileParams": tool.fileParams } }
189
364
  : {}),
190
365
  },
191
- async (args, context) => {
366
+ handle(async (args, context) => {
192
367
  const input = { ...args } as Record<string, unknown> & {
193
368
  sessionId?: string;
194
369
  };
@@ -204,9 +379,14 @@ export function createServer(broker: Broker): McpServer {
204
379
  return finishResult(
205
380
  broker,
206
381
  context,
207
- toolResult(result.toolResults, result.sessionId, result.inputs),
382
+ toolResult(
383
+ result.toolResults,
384
+ result.sessionId,
385
+ result.cwd,
386
+ result.inputs,
387
+ ),
208
388
  );
209
- },
389
+ }),
210
390
  );
211
391
  }
212
392
 
@@ -215,21 +395,17 @@ export function createServer(broker: Broker): McpServer {
215
395
  {
216
396
  title: "Local sessions",
217
397
  description:
218
- "List online Pi sessions and the current conversation binding without waiting for offline sessions.",
398
+ "List online Pi sessions with their IDs, devices, cwd, names, execution status, and saved binding counts. Also returns this chat's default.",
399
+ outputSchema,
219
400
  inputSchema: z.object({
220
401
  sessionId: z
221
402
  .string()
222
403
  .optional()
223
- .describe("Return this Pi session when it is online"),
404
+ .describe("Filter the online list to this Pi session"),
224
405
  }),
225
- annotations: {
226
- readOnlyHint: true,
227
- destructiveHint: false,
228
- idempotentHint: true,
229
- openWorldHint: false,
230
- },
406
+ annotations: toolAnnotations,
231
407
  },
232
- async (args, context) => {
408
+ handle(async (args, context) => {
233
409
  const chatId = requestChatId(context);
234
410
  const inputs = chatId
235
411
  ? await broker.inputs(chatId, args.sessionId, context.mcpReq.signal)
@@ -241,8 +417,8 @@ export function createServer(broker: Broker): McpServer {
241
417
  },
242
418
  inputs,
243
419
  );
244
- return chatId ? finishResult(broker, context, result) : result;
245
- },
420
+ return finishResult(broker, context, result);
421
+ }),
246
422
  );
247
423
 
248
424
  server.registerResource(
@@ -283,19 +459,24 @@ function textResult(
283
459
  };
284
460
  }
285
461
 
286
- async function finishResult<T extends { content: object[] }>(
287
- broker: Broker,
288
- context: RequestContext,
289
- result: T,
290
- ): Promise<T> {
291
- const chatId = requireChatId(context);
292
- const deliveries = await broker.deliveries(chatId);
293
- const completed = {
294
- ...result,
295
- content: [...result.content, ...deliveryContent(deliveries)],
296
- } as T;
297
- await broker.acknowledgeDeliveries(deliveries, context.mcpReq.signal);
298
- return completed;
462
+ async function finishResult<
463
+ T extends { content: ReturnType<typeof toolResult>["content"] },
464
+ >(broker: Broker, context: RequestContext, result: T) {
465
+ const chatId = requestChatId(context);
466
+ const deliveries = chatId ? await broker.deliveries(chatId) : [];
467
+ const answers = chatId ? broker.answers(chatId) : [];
468
+ const content = [
469
+ ...result.content,
470
+ ...deliveryContent(deliveries),
471
+ ...answerContent(answers),
472
+ ];
473
+ const structuredContent = {
474
+ text: content
475
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
476
+ .join("\n"),
477
+ };
478
+ await broker.acknowledge(deliveries, answers, context.mcpReq.signal);
479
+ return { ...result, content, structuredContent };
299
480
  }
300
481
 
301
482
  function requestChatId(context: RequestContext): string | undefined {