@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/.codex-plugin/plugin.json +13 -0
- package/README.md +26 -29
- package/dist/chunk-5CKVVDZ2.js +1042 -0
- package/dist/chunk-DM523QRR.js +148 -0
- package/dist/chunk-DN7OHZK6.js +110 -0
- package/dist/chunk-DQWC5GFN.js +1745 -0
- package/dist/chunk-EIPDEA3I.js +286 -0
- package/dist/chunk-GWYP7B66.js +8479 -0
- package/dist/chunk-GX4JWLHH.js +98 -0
- package/dist/chunk-K2VE2MDU.js +14916 -0
- package/dist/chunk-OWAAWZNT.js +56 -0
- package/dist/chunk-UOFCCHBS.js +94 -0
- package/dist/cli.js +47 -0
- package/dist/codex-RI2XL4HV.js +5144 -0
- package/dist/codex-plugin-NSKWB3WS.js +179 -0
- package/dist/codex.json +23 -0
- package/dist/index.js +4752 -0
- package/dist/instructions.md +13 -0
- package/dist/omp.js +240 -0
- package/dist/opencode.js +904 -0
- package/dist/promise-OHH5J44X.js +72 -0
- package/dist/stdio-KJVCYPXJ.js +1279 -0
- package/docs/setup.md +82 -0
- package/docs/tools.md +106 -96
- package/package.json +100 -56
- package/src/activity.ts +0 -21
- package/src/broker.ts +0 -780
- package/src/config.ts +0 -20
- package/src/delivery.ts +0 -54
- package/src/history.ts +0 -136
- package/src/index.ts +0 -42
- package/src/instructions.md +0 -17
- package/src/ipc.ts +0 -492
- package/src/provider.ts +0 -194
- package/src/questions.ts +0 -87
- package/src/resources.ts +0 -209
- package/src/server.ts +0 -566
- package/src/session.ts +0 -848
- package/src/state.ts +0 -163
- package/src/stdio.ts +0 -58
- package/src/tools.ts +0 -91
- package/src/transfer.ts +0 -313
- /package/{src → dist}/question.html +0 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
import { createRequire } from "node:module"; const require = createRequire(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
IpcServer,
|
|
4
|
+
source,
|
|
5
|
+
sourceLabel
|
|
6
|
+
} from "./chunk-EIPDEA3I.js";
|
|
7
|
+
import {
|
|
8
|
+
McpServer,
|
|
9
|
+
ResourceTemplate,
|
|
10
|
+
StdioServerTransport,
|
|
11
|
+
serveStdio
|
|
12
|
+
} from "./chunk-K2VE2MDU.js";
|
|
13
|
+
import "./chunk-GX4JWLHH.js";
|
|
14
|
+
import {
|
|
15
|
+
package_default
|
|
16
|
+
} from "./chunk-DN7OHZK6.js";
|
|
17
|
+
import {
|
|
18
|
+
array,
|
|
19
|
+
boolean,
|
|
20
|
+
callInput,
|
|
21
|
+
callsInput,
|
|
22
|
+
chatInput,
|
|
23
|
+
deliveryContent,
|
|
24
|
+
getDirectory,
|
|
25
|
+
historyInput,
|
|
26
|
+
historyInstructions,
|
|
27
|
+
inputContent,
|
|
28
|
+
literal,
|
|
29
|
+
number,
|
|
30
|
+
object,
|
|
31
|
+
parseCalls,
|
|
32
|
+
readConfig,
|
|
33
|
+
resourceSessionId,
|
|
34
|
+
string,
|
|
35
|
+
toolResult,
|
|
36
|
+
transferDescription,
|
|
37
|
+
transferInput
|
|
38
|
+
} from "./chunk-GWYP7B66.js";
|
|
39
|
+
import "./chunk-UOFCCHBS.js";
|
|
40
|
+
|
|
41
|
+
// src/broker.ts
|
|
42
|
+
import { randomUUID } from "node:crypto";
|
|
43
|
+
import { mkdir } from "node:fs/promises";
|
|
44
|
+
|
|
45
|
+
// src/questions.ts
|
|
46
|
+
var questionInstructions = "Use ask for questions in ChatGPT. Answers and revisions arrive as webAnswer with their target sessionId. Apply them to that task; a user skip means proceed with available information.";
|
|
47
|
+
var questionInput = object({
|
|
48
|
+
header: string().trim().min(1).optional().describe("Short topic label"),
|
|
49
|
+
question: string().trim().min(1).describe("One focused question for the user"),
|
|
50
|
+
context: string().optional().describe("Background needed to answer the question"),
|
|
51
|
+
options: array(
|
|
52
|
+
object({
|
|
53
|
+
title: string().trim().min(1).describe("Short choice label"),
|
|
54
|
+
description: string().optional().describe("Brief consequence or tradeoff of this choice"),
|
|
55
|
+
recommended: boolean().optional().describe("Preferred choice; place it first")
|
|
56
|
+
})
|
|
57
|
+
).default([]).describe(
|
|
58
|
+
"Distinct choices, usually two or three. Freeform answers and skipping are available separately."
|
|
59
|
+
),
|
|
60
|
+
allowMultiple: boolean().default(false).describe("Allow selecting several options")
|
|
61
|
+
});
|
|
62
|
+
var answerInput = object({
|
|
63
|
+
selections: array(number().int().nonnegative()).default([]),
|
|
64
|
+
text: string().trim().default(""),
|
|
65
|
+
skipped: literal(true).optional().describe("The question was skipped")
|
|
66
|
+
});
|
|
67
|
+
var questionOutput = questionInput.extend({
|
|
68
|
+
id: string().describe("Generated question ID for ask_assert"),
|
|
69
|
+
sessionId: string(),
|
|
70
|
+
cwd: string(),
|
|
71
|
+
answer: answerInput.optional(),
|
|
72
|
+
loaded: boolean().optional().describe("The widget has reported loading")
|
|
73
|
+
});
|
|
74
|
+
function questionView(record) {
|
|
75
|
+
const { chatId: _chatId, delivered: _delivered, ...question } = record;
|
|
76
|
+
return question;
|
|
77
|
+
}
|
|
78
|
+
function answerContent(records) {
|
|
79
|
+
return records.map((record) => ({
|
|
80
|
+
type: "text",
|
|
81
|
+
text: JSON.stringify({
|
|
82
|
+
webAnswer: {
|
|
83
|
+
questionId: record.id,
|
|
84
|
+
sessionId: record.sessionId,
|
|
85
|
+
cwd: record.cwd,
|
|
86
|
+
header: record.header,
|
|
87
|
+
question: record.question,
|
|
88
|
+
skipped: record.answer?.skipped,
|
|
89
|
+
choices: record.answer?.selections.map(
|
|
90
|
+
(index) => record.options[index]
|
|
91
|
+
),
|
|
92
|
+
text: record.answer?.text
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/state.ts
|
|
99
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
100
|
+
import { join } from "node:path";
|
|
101
|
+
var State = class {
|
|
102
|
+
#path;
|
|
103
|
+
#temporaryPath;
|
|
104
|
+
#bindings = /* @__PURE__ */ new Map();
|
|
105
|
+
#deliveries = /* @__PURE__ */ new Map();
|
|
106
|
+
#questions = /* @__PURE__ */ new Map();
|
|
107
|
+
#writes = Promise.resolve();
|
|
108
|
+
constructor(directory) {
|
|
109
|
+
this.#path = join(directory, "state.json");
|
|
110
|
+
this.#temporaryPath = `${this.#path}.tmp`;
|
|
111
|
+
}
|
|
112
|
+
async load() {
|
|
113
|
+
let contents;
|
|
114
|
+
try {
|
|
115
|
+
contents = await readFile(this.#path, "utf8");
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error.code === "ENOENT") return;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
const state = JSON.parse(contents);
|
|
121
|
+
for (const [chatId, sessionId] of Object.entries(state.bindings ?? {})) {
|
|
122
|
+
if (typeof sessionId === "string") this.#bindings.set(chatId, sessionId);
|
|
123
|
+
}
|
|
124
|
+
for (const delivery of state.deliveries ?? []) {
|
|
125
|
+
if (delivery?.id) this.#deliveries.set(delivery.id, delivery);
|
|
126
|
+
}
|
|
127
|
+
for (const question of state.questions ?? [])
|
|
128
|
+
this.#questions.set(question.id, question);
|
|
129
|
+
}
|
|
130
|
+
binding(chatId) {
|
|
131
|
+
return this.#bindings.get(chatId);
|
|
132
|
+
}
|
|
133
|
+
bindingCounts() {
|
|
134
|
+
const counts = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const sessionId of this.#bindings.values()) {
|
|
136
|
+
counts.set(sessionId, (counts.get(sessionId) ?? 0) + 1);
|
|
137
|
+
}
|
|
138
|
+
return counts;
|
|
139
|
+
}
|
|
140
|
+
bind(chatId, sessionId) {
|
|
141
|
+
this.#bindings.set(chatId, sessionId);
|
|
142
|
+
return this.#save();
|
|
143
|
+
}
|
|
144
|
+
deliveries(chatId) {
|
|
145
|
+
return [...this.#deliveries.values()].filter(
|
|
146
|
+
(delivery) => delivery.clientId === chatId
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
addDelivery(delivery) {
|
|
150
|
+
this.#deliveries.set(delivery.id, delivery);
|
|
151
|
+
return this.#save();
|
|
152
|
+
}
|
|
153
|
+
question(chatId, id) {
|
|
154
|
+
const question = this.#questions.get(id);
|
|
155
|
+
if (!question || question.chatId !== chatId)
|
|
156
|
+
throw new Error("Question not found in this ChatGPT conversation");
|
|
157
|
+
return question;
|
|
158
|
+
}
|
|
159
|
+
async addQuestion(question) {
|
|
160
|
+
this.#questions.set(question.id, question);
|
|
161
|
+
await this.#save();
|
|
162
|
+
}
|
|
163
|
+
async answer(chatId, id, answer) {
|
|
164
|
+
const question = this.question(chatId, id);
|
|
165
|
+
const selections = [...new Set(answer.selections)].sort((a, b) => a - b);
|
|
166
|
+
if (selections.some((index) => !question.options[index]))
|
|
167
|
+
throw new Error("Unknown question option");
|
|
168
|
+
if (!question.allowMultiple && selections.length > 1)
|
|
169
|
+
throw new Error("Select one option");
|
|
170
|
+
if (answer.skipped && (selections.length > 0 || answer.text))
|
|
171
|
+
throw new Error("A skipped question cannot include an answer");
|
|
172
|
+
if (!answer.skipped && selections.length === 0 && !answer.text)
|
|
173
|
+
throw new Error("Select an option or enter an answer");
|
|
174
|
+
const value = {
|
|
175
|
+
selections,
|
|
176
|
+
text: answer.text,
|
|
177
|
+
...answer.skipped ? { skipped: true } : {}
|
|
178
|
+
};
|
|
179
|
+
if (JSON.stringify(question.answer) === JSON.stringify(value))
|
|
180
|
+
return question;
|
|
181
|
+
const updated = { ...question, answer: value, delivered: false };
|
|
182
|
+
await this.addQuestion(updated);
|
|
183
|
+
return updated;
|
|
184
|
+
}
|
|
185
|
+
answers(chatId) {
|
|
186
|
+
return [...this.#questions.values()].filter(
|
|
187
|
+
(question) => question.chatId === chatId && question.answer && !question.delivered
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
async acknowledge(deliveries, answers, signal) {
|
|
191
|
+
if (deliveries.length === 0 && answers.length === 0) return;
|
|
192
|
+
signal.throwIfAborted();
|
|
193
|
+
const delivered = new Map(
|
|
194
|
+
answers.map((question) => [question, { ...question, delivered: true }])
|
|
195
|
+
);
|
|
196
|
+
for (const delivery of deliveries) this.#deliveries.delete(delivery.id);
|
|
197
|
+
for (const [question, updated] of delivered) {
|
|
198
|
+
if (this.#questions.get(question.id) === question)
|
|
199
|
+
this.#questions.set(question.id, updated);
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await this.#save();
|
|
203
|
+
signal.throwIfAborted();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
for (const delivery of deliveries)
|
|
206
|
+
this.#deliveries.set(delivery.id, delivery);
|
|
207
|
+
for (const [question, updated] of delivered) {
|
|
208
|
+
if (this.#questions.get(question.id) === updated)
|
|
209
|
+
this.#questions.set(question.id, question);
|
|
210
|
+
}
|
|
211
|
+
await this.#save();
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
#save() {
|
|
216
|
+
const saved = this.#writes.then(async () => {
|
|
217
|
+
const bindings = Object.fromEntries(this.#bindings);
|
|
218
|
+
await writeFile(
|
|
219
|
+
this.#temporaryPath,
|
|
220
|
+
`${JSON.stringify({ bindings, deliveries: [...this.#deliveries.values()], questions: [...this.#questions.values()] }, null, 2)}
|
|
221
|
+
`,
|
|
222
|
+
{
|
|
223
|
+
mode: 384
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
await rename(this.#temporaryPath, this.#path);
|
|
227
|
+
});
|
|
228
|
+
this.#writes = saved.catch(() => {
|
|
229
|
+
});
|
|
230
|
+
return saved;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/broker.ts
|
|
235
|
+
var observerInstructions = "This conversation recently interacted with this session. Participate as an observer for this task: follow progress through history with observer: true, then explain the recorded outcome in ChatGPT.";
|
|
236
|
+
var Broker = class {
|
|
237
|
+
#directory;
|
|
238
|
+
#ipc;
|
|
239
|
+
#state;
|
|
240
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
241
|
+
#resources = /* @__PURE__ */ new Map();
|
|
242
|
+
#pending = /* @__PURE__ */ new Map();
|
|
243
|
+
#waiters = /* @__PURE__ */ new Set();
|
|
244
|
+
#cooldowns = /* @__PURE__ */ new Map();
|
|
245
|
+
#relays = /* @__PURE__ */ new Map();
|
|
246
|
+
#config = {};
|
|
247
|
+
#nextRequestId = 1;
|
|
248
|
+
constructor(directory) {
|
|
249
|
+
this.#directory = directory;
|
|
250
|
+
this.#state = new State(directory);
|
|
251
|
+
this.#ipc = new IpcServer(
|
|
252
|
+
directory,
|
|
253
|
+
(peer, message) => this.#receive(peer, message),
|
|
254
|
+
(peer) => this.#removePeer(peer)
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
async start() {
|
|
258
|
+
this.#config = await readConfig(this.#directory);
|
|
259
|
+
await mkdir(this.#directory, { recursive: true });
|
|
260
|
+
await this.#state.load();
|
|
261
|
+
await this.#ipc.start(this.#config.listen ?? false);
|
|
262
|
+
}
|
|
263
|
+
async close() {
|
|
264
|
+
const error = new Error("Chappie broker ended");
|
|
265
|
+
this.#cooldowns.clear();
|
|
266
|
+
for (const relays of this.#relays.values()) {
|
|
267
|
+
for (const controller of relays.values()) controller.abort(error);
|
|
268
|
+
}
|
|
269
|
+
this.#relays.clear();
|
|
270
|
+
for (const [id, pending] of this.#pending) {
|
|
271
|
+
this.#finishRequest(id, pending);
|
|
272
|
+
pending.reject(error);
|
|
273
|
+
}
|
|
274
|
+
for (const waiter of this.#waiters) {
|
|
275
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
276
|
+
waiter.reject(error);
|
|
277
|
+
}
|
|
278
|
+
this.#waiters.clear();
|
|
279
|
+
this.#sessions.clear();
|
|
280
|
+
this.#resources.clear();
|
|
281
|
+
await this.#ipc.close();
|
|
282
|
+
}
|
|
283
|
+
listSessions(sessionId) {
|
|
284
|
+
const counts = this.#state.bindingCounts();
|
|
285
|
+
return [...this.#sessions.values()].filter(({ description }) => !sessionId || description.id === sessionId).map(({ description }) => ({
|
|
286
|
+
...description,
|
|
287
|
+
bindingCount: counts.get(description.id) ?? 0
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
get askEnabled() {
|
|
291
|
+
return this.#config.ask ?? true;
|
|
292
|
+
}
|
|
293
|
+
binding(chatId) {
|
|
294
|
+
return this.#state.binding(chatId);
|
|
295
|
+
}
|
|
296
|
+
async initialize(chatId, sessionId, requestId, signal) {
|
|
297
|
+
const {
|
|
298
|
+
sessionId: target,
|
|
299
|
+
selection,
|
|
300
|
+
initialization
|
|
301
|
+
} = await this.#selectSession(chatId, sessionId, requestId, signal, true);
|
|
302
|
+
const { inspection, inputs, globalAgents } = await this.#inspect(
|
|
303
|
+
target,
|
|
304
|
+
signal
|
|
305
|
+
);
|
|
306
|
+
await this.#ackInputs(target, inputs, signal);
|
|
307
|
+
return {
|
|
308
|
+
selection,
|
|
309
|
+
...initialization ? { initialization } : {},
|
|
310
|
+
...inspection,
|
|
311
|
+
tools: inspection.tools.map(({ name, description }) => ({
|
|
312
|
+
name,
|
|
313
|
+
description: description.split("\n", 1)[0] ?? description
|
|
314
|
+
})),
|
|
315
|
+
inputs,
|
|
316
|
+
...globalAgents ? { globalAgents } : {}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
async chat(chatId, sessionId, text, requestId, signal, replyTo) {
|
|
320
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
321
|
+
chatId,
|
|
322
|
+
sessionId,
|
|
323
|
+
requestId,
|
|
324
|
+
signal
|
|
325
|
+
);
|
|
326
|
+
const result = await this.#request(
|
|
327
|
+
target,
|
|
328
|
+
(id) => ({
|
|
329
|
+
type: "chat",
|
|
330
|
+
id,
|
|
331
|
+
...source(chatId, requestId),
|
|
332
|
+
sessionId: target,
|
|
333
|
+
text,
|
|
334
|
+
...replyTo ? { replyTo } : {}
|
|
335
|
+
}),
|
|
336
|
+
signal
|
|
337
|
+
);
|
|
338
|
+
if ("message" in result || "cancelled" in result) {
|
|
339
|
+
const inputs = result.inputs;
|
|
340
|
+
await this.#ackInputs(target, inputs, signal);
|
|
341
|
+
return {
|
|
342
|
+
sessionId: target,
|
|
343
|
+
cwd: result.cwd,
|
|
344
|
+
inputs,
|
|
345
|
+
...initialization ? { initialization } : {},
|
|
346
|
+
..."cancelled" in result ? { cancelled: result.cancelled } : {}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
throw new Error("Session returned no assistant message");
|
|
350
|
+
}
|
|
351
|
+
async tools(chatId, sessionId, names, requestId, signal) {
|
|
352
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
353
|
+
chatId,
|
|
354
|
+
sessionId,
|
|
355
|
+
requestId,
|
|
356
|
+
signal
|
|
357
|
+
);
|
|
358
|
+
const { inspection, inputs } = await this.#inspect(target, signal);
|
|
359
|
+
await this.#ackInputs(target, inputs, signal);
|
|
360
|
+
const selected = names ? new Set(names) : void 0;
|
|
361
|
+
return {
|
|
362
|
+
...inspection,
|
|
363
|
+
...initialization ? { initialization } : {},
|
|
364
|
+
tools: selected ? inspection.tools.filter(({ name }) => selected.has(name)) : inspection.tools,
|
|
365
|
+
inputs
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
async call(chatId, sessionId, calls, requestId, signal) {
|
|
369
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
370
|
+
chatId,
|
|
371
|
+
sessionId,
|
|
372
|
+
requestId,
|
|
373
|
+
signal
|
|
374
|
+
);
|
|
375
|
+
const toolCalls = calls.map((call) => ({
|
|
376
|
+
type: "toolCall",
|
|
377
|
+
id: `chappie-${randomUUID()}`,
|
|
378
|
+
name: call.name,
|
|
379
|
+
arguments: call.arguments
|
|
380
|
+
}));
|
|
381
|
+
const result = await this.#request(
|
|
382
|
+
target,
|
|
383
|
+
(id) => ({
|
|
384
|
+
type: "call",
|
|
385
|
+
id,
|
|
386
|
+
...source(chatId, requestId),
|
|
387
|
+
sessionId: target,
|
|
388
|
+
calls: toolCalls
|
|
389
|
+
}),
|
|
390
|
+
signal
|
|
391
|
+
);
|
|
392
|
+
if ("toolResults" in result) {
|
|
393
|
+
await this.#ackInputs(target, result.inputs, signal);
|
|
394
|
+
return {
|
|
395
|
+
sessionId: target,
|
|
396
|
+
...initialization ? { initialization } : {},
|
|
397
|
+
cwd: result.cwd,
|
|
398
|
+
toolResults: result.toolResults,
|
|
399
|
+
inputs: result.inputs,
|
|
400
|
+
..."cancelled" in result ? { cancelled: result.cancelled } : {}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
throw new Error("Session returned no tool results");
|
|
404
|
+
}
|
|
405
|
+
async history(chatId, sessionId, range, requestId, signal) {
|
|
406
|
+
const target = sessionId ?? this.#state.binding(chatId);
|
|
407
|
+
if (!target) throw new Error("Specify a sessionId to read history");
|
|
408
|
+
await this.#waitForSession(target, signal);
|
|
409
|
+
const result = await this.#request(
|
|
410
|
+
target,
|
|
411
|
+
(id) => ({
|
|
412
|
+
type: "history",
|
|
413
|
+
id,
|
|
414
|
+
sessionId: target,
|
|
415
|
+
range,
|
|
416
|
+
...source(chatId, requestId)
|
|
417
|
+
}),
|
|
418
|
+
signal
|
|
419
|
+
);
|
|
420
|
+
if ("history" in result) return { sessionId: target, ...result };
|
|
421
|
+
throw new Error("Session returned no history");
|
|
422
|
+
}
|
|
423
|
+
async inputs(chatId, sessionId, signal) {
|
|
424
|
+
const target = sessionId ?? this.#state.binding(chatId);
|
|
425
|
+
if (!target || !this.#sessions.has(target)) return [];
|
|
426
|
+
const result = await this.#request(
|
|
427
|
+
target,
|
|
428
|
+
(id) => ({ type: "inputs", id, sessionId: target }),
|
|
429
|
+
signal
|
|
430
|
+
);
|
|
431
|
+
if (!("inputs" in result)) throw new Error("Session returned no inputs");
|
|
432
|
+
await this.#ackInputs(target, result.inputs, signal);
|
|
433
|
+
return result.inputs;
|
|
434
|
+
}
|
|
435
|
+
async ask(chatId, sessionId, input, requestId, signal) {
|
|
436
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
437
|
+
chatId,
|
|
438
|
+
sessionId,
|
|
439
|
+
requestId,
|
|
440
|
+
signal
|
|
441
|
+
);
|
|
442
|
+
signal.throwIfAborted();
|
|
443
|
+
const session = this.#sessions.get(target);
|
|
444
|
+
if (!session) throw new Error(`Session ${target} is offline`);
|
|
445
|
+
const question = {
|
|
446
|
+
...input,
|
|
447
|
+
id: randomUUID(),
|
|
448
|
+
chatId,
|
|
449
|
+
sessionId: target,
|
|
450
|
+
cwd: session.description.cwd,
|
|
451
|
+
delivered: false
|
|
452
|
+
};
|
|
453
|
+
await this.#state.addQuestion(question);
|
|
454
|
+
const activity = source(chatId, requestId);
|
|
455
|
+
void this.#notify(
|
|
456
|
+
target,
|
|
457
|
+
`${sourceLabel(activity)} asked: ${question.question}`,
|
|
458
|
+
{
|
|
459
|
+
event: "asked",
|
|
460
|
+
...activity
|
|
461
|
+
}
|
|
462
|
+
).catch(() => {
|
|
463
|
+
});
|
|
464
|
+
return {
|
|
465
|
+
...questionView(question),
|
|
466
|
+
...initialization ? { initialization } : {}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
async assertQuestion(chatId, id, signal) {
|
|
470
|
+
const timeout = AbortSignal.timeout(1e4);
|
|
471
|
+
const combined = AbortSignal.any([signal, timeout]);
|
|
472
|
+
try {
|
|
473
|
+
for (; ; ) {
|
|
474
|
+
signal.throwIfAborted();
|
|
475
|
+
const question = this.#state.question(chatId, id);
|
|
476
|
+
if (question.loaded) return questionView(question);
|
|
477
|
+
await this.#waitForChange(combined);
|
|
478
|
+
}
|
|
479
|
+
} catch (error) {
|
|
480
|
+
signal.throwIfAborted();
|
|
481
|
+
if (!timeout.aborted) throw error;
|
|
482
|
+
const question = this.#state.question(chatId, id);
|
|
483
|
+
if (question.loaded) return questionView(question);
|
|
484
|
+
if (!question.answer) {
|
|
485
|
+
await this.#state.answer(chatId, id, {
|
|
486
|
+
selections: [],
|
|
487
|
+
text: "",
|
|
488
|
+
skipped: true
|
|
489
|
+
});
|
|
490
|
+
void this.#notify(
|
|
491
|
+
question.sessionId,
|
|
492
|
+
`Question skipped after display timeout: ${question.question}`,
|
|
493
|
+
{ event: "skipped", clientId: chatId }
|
|
494
|
+
).catch(() => {
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
throw new Error(
|
|
498
|
+
"Question widget did not load within 10 seconds. The question was automatically skipped. Use an installed native interactive tool through call if an answer is needed."
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async answer(chatId, id, answer, loaded = false) {
|
|
503
|
+
let question = this.#state.question(chatId, id);
|
|
504
|
+
if ((loaded || answer) && !question.loaded) {
|
|
505
|
+
this.#cooldown(chatId, question.sessionId);
|
|
506
|
+
question = { ...question, loaded: true };
|
|
507
|
+
await this.#state.addQuestion(question);
|
|
508
|
+
this.#notifyChange();
|
|
509
|
+
}
|
|
510
|
+
if (answer) {
|
|
511
|
+
const previous = question.answer;
|
|
512
|
+
question = await this.#state.answer(chatId, id, answer);
|
|
513
|
+
if (question.answer !== previous) {
|
|
514
|
+
const response = [
|
|
515
|
+
...(question.answer?.selections ?? []).map(
|
|
516
|
+
(index) => question.options[index]?.title
|
|
517
|
+
),
|
|
518
|
+
question.answer?.text
|
|
519
|
+
].filter(Boolean).join(", ");
|
|
520
|
+
const message = question.answer?.skipped ? `Skipped in ChatGPT ${chatId.slice(-4)}: ${question.question}` : previous ? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} \u2014 ${response}` : `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} \u2014 ${response}`;
|
|
521
|
+
void this.#notify(question.sessionId, message, {
|
|
522
|
+
event: question.answer?.skipped ? "skipped" : "answered",
|
|
523
|
+
clientId: chatId
|
|
524
|
+
}).catch(() => {
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return questionView(question);
|
|
529
|
+
}
|
|
530
|
+
answers(chatId) {
|
|
531
|
+
return this.#state.answers(chatId);
|
|
532
|
+
}
|
|
533
|
+
async readResource(uri, signal) {
|
|
534
|
+
const requested = new URL(uri);
|
|
535
|
+
const chatId = requested.searchParams.get("chatId");
|
|
536
|
+
requested.search = "";
|
|
537
|
+
const sessionId = resourceSessionId(requested.href);
|
|
538
|
+
if (chatId) this.#cooldown(chatId, sessionId);
|
|
539
|
+
const result = await this.#request(
|
|
540
|
+
sessionId,
|
|
541
|
+
(id) => ({ type: "readResource", id, sessionId, uri: requested.href }),
|
|
542
|
+
signal
|
|
543
|
+
);
|
|
544
|
+
if ("resource" in result) {
|
|
545
|
+
if (chatId) this.#cooldown(chatId, sessionId);
|
|
546
|
+
return { ...result.resource, uri };
|
|
547
|
+
}
|
|
548
|
+
throw new Error("Session returned no resource");
|
|
549
|
+
}
|
|
550
|
+
deliveries(chatId) {
|
|
551
|
+
return this.#state.deliveries(chatId);
|
|
552
|
+
}
|
|
553
|
+
acknowledge(deliveries, answers, signal) {
|
|
554
|
+
return this.#state.acknowledge(deliveries, answers, signal);
|
|
555
|
+
}
|
|
556
|
+
async request(request, signal) {
|
|
557
|
+
if (request.type === "sessions")
|
|
558
|
+
return { sessions: this.listSessions(request.sessionId) };
|
|
559
|
+
const result = await this.#request(
|
|
560
|
+
request.sessionId,
|
|
561
|
+
(id) => ({ ...request, id }),
|
|
562
|
+
signal
|
|
563
|
+
);
|
|
564
|
+
if ("inputs" in result)
|
|
565
|
+
await this.#ackInputs(request.sessionId, result.inputs, signal);
|
|
566
|
+
return result;
|
|
567
|
+
}
|
|
568
|
+
async #receive(peer, message) {
|
|
569
|
+
switch (message.type) {
|
|
570
|
+
case "request": {
|
|
571
|
+
let relays = this.#relays.get(peer);
|
|
572
|
+
if (!relays) {
|
|
573
|
+
relays = /* @__PURE__ */ new Map();
|
|
574
|
+
this.#relays.set(peer, relays);
|
|
575
|
+
}
|
|
576
|
+
const controller = new AbortController();
|
|
577
|
+
relays.set(message.id, controller);
|
|
578
|
+
void this.request(message.request, controller.signal).then(
|
|
579
|
+
async (result) => {
|
|
580
|
+
const deliveries = this.#state.deliveries(message.clientId);
|
|
581
|
+
await peer.send({
|
|
582
|
+
...result,
|
|
583
|
+
type: "response",
|
|
584
|
+
id: message.id,
|
|
585
|
+
...deliveries.length ? { deliveries } : {}
|
|
586
|
+
});
|
|
587
|
+
await this.#state.acknowledge(deliveries, [], controller.signal);
|
|
588
|
+
},
|
|
589
|
+
(error) => peer.send({
|
|
590
|
+
type: "response",
|
|
591
|
+
id: message.id,
|
|
592
|
+
error: error instanceof Error ? error.message : String(error)
|
|
593
|
+
})
|
|
594
|
+
).finally(() => relays.delete(message.id)).catch(() => {
|
|
595
|
+
});
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
case "resources":
|
|
599
|
+
for (const resource of message.resources)
|
|
600
|
+
this.#resources.set(resource.uri, peer);
|
|
601
|
+
break;
|
|
602
|
+
case "cancelRequest":
|
|
603
|
+
this.#relays.get(peer)?.get(message.id)?.abort(new Error("Request cancelled"));
|
|
604
|
+
break;
|
|
605
|
+
case "sync": {
|
|
606
|
+
const registered = this.#sessions.get(message.session.id)?.peer === peer;
|
|
607
|
+
this.#sessions.set(message.session.id, {
|
|
608
|
+
description: message.session,
|
|
609
|
+
peer
|
|
610
|
+
});
|
|
611
|
+
await peer.send({
|
|
612
|
+
type: "synced",
|
|
613
|
+
id: message.id,
|
|
614
|
+
sessionId: message.session.id
|
|
615
|
+
});
|
|
616
|
+
if (!registered)
|
|
617
|
+
await this.#notify(message.session.id, "Chappie connected", {
|
|
618
|
+
event: "connected"
|
|
619
|
+
});
|
|
620
|
+
this.#notifyChange();
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
case "unregister": {
|
|
624
|
+
const session = this.#sessions.get(message.sessionId);
|
|
625
|
+
if (session?.peer === peer) this.#removeSession(message.sessionId);
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
case "delivery":
|
|
629
|
+
await this.#state.addDelivery(message.delivery);
|
|
630
|
+
await peer.send({ type: "stored", id: message.delivery.id });
|
|
631
|
+
break;
|
|
632
|
+
case "result": {
|
|
633
|
+
const pending = this.#pending.get(message.id);
|
|
634
|
+
if (!pending || pending.peer !== peer) break;
|
|
635
|
+
this.#finishRequest(message.id, pending);
|
|
636
|
+
if ("error" in message) pending.reject(new Error(message.error));
|
|
637
|
+
else {
|
|
638
|
+
const { type: _type, id: _id, ...result } = message;
|
|
639
|
+
pending.resolve(result);
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async #selectSession(chatId, requestedId, requestId, signal, bindRequested = false) {
|
|
646
|
+
for (; ; ) {
|
|
647
|
+
signal.throwIfAborted();
|
|
648
|
+
const boundId = this.#state.binding(chatId);
|
|
649
|
+
let target = requestedId ?? boundId;
|
|
650
|
+
if (!target) {
|
|
651
|
+
const occupied = this.#state.bindingCounts();
|
|
652
|
+
target = [...this.#sessions.keys()].find((id) => !occupied.has(id));
|
|
653
|
+
}
|
|
654
|
+
if (!target) {
|
|
655
|
+
await this.#waitForChange(signal);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
await this.#waitForSession(target, signal);
|
|
659
|
+
signal.throwIfAborted();
|
|
660
|
+
const initialization = bindRequested || !this.#state.binding(chatId) ? await this.#join(chatId, target, requestId, signal, bindRequested) : void 0;
|
|
661
|
+
return {
|
|
662
|
+
sessionId: target,
|
|
663
|
+
selection: requestedId ? "explicit" : boundId ? "existing" : "automatic",
|
|
664
|
+
...initialization ? { initialization } : {}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async #join(chatId, sessionId, requestId, signal, explicit = false) {
|
|
669
|
+
signal.throwIfAborted();
|
|
670
|
+
const previous = this.#state.binding(chatId);
|
|
671
|
+
const key = JSON.stringify([chatId, sessionId]);
|
|
672
|
+
const observer = (this.#cooldowns.get(key) ?? 0) > Date.now();
|
|
673
|
+
if (!observer) this.#cooldown(chatId, sessionId);
|
|
674
|
+
await this.#state.bind(chatId, sessionId);
|
|
675
|
+
signal.throwIfAborted();
|
|
676
|
+
const activity = source(chatId, requestId);
|
|
677
|
+
if (previous !== sessionId) {
|
|
678
|
+
this.#notifyChange();
|
|
679
|
+
if (previous)
|
|
680
|
+
await this.#notify(previous, `${sourceLabel(activity)} left`, {
|
|
681
|
+
...activity,
|
|
682
|
+
event: "left"
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
signal.throwIfAborted();
|
|
686
|
+
await this.#notify(sessionId, `${sourceLabel(activity)} joined`, {
|
|
687
|
+
...activity,
|
|
688
|
+
event: "joined",
|
|
689
|
+
initialization: explicit ? "explicit" : "implicit"
|
|
690
|
+
});
|
|
691
|
+
return {
|
|
692
|
+
sessionId,
|
|
693
|
+
instructions: observer ? observerInstructions : historyInstructions
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
#cooldown(chatId, sessionId) {
|
|
697
|
+
const now = Date.now();
|
|
698
|
+
for (const [key, expires] of this.#cooldowns) {
|
|
699
|
+
if (expires <= now) this.#cooldowns.delete(key);
|
|
700
|
+
}
|
|
701
|
+
this.#cooldowns.set(
|
|
702
|
+
JSON.stringify([chatId, sessionId]),
|
|
703
|
+
now + (this.#config.cooldown ?? 20) * 1e3
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
async #notify(sessionId, message, activity = {}) {
|
|
707
|
+
await this.#sessions.get(sessionId)?.peer.send({ type: "notice", sessionId, message, activity });
|
|
708
|
+
}
|
|
709
|
+
async #waitForSession(sessionId, signal) {
|
|
710
|
+
while (!this.#sessions.has(sessionId)) await this.#waitForChange(signal);
|
|
711
|
+
}
|
|
712
|
+
async #inspect(sessionId, signal) {
|
|
713
|
+
const result = await this.#request(
|
|
714
|
+
sessionId,
|
|
715
|
+
(id) => ({ type: "inspect", id, sessionId }),
|
|
716
|
+
signal
|
|
717
|
+
);
|
|
718
|
+
if ("inspection" in result) return result;
|
|
719
|
+
throw new Error("Session returned no inspection");
|
|
720
|
+
}
|
|
721
|
+
async #ackInputs(sessionId, inputs, signal) {
|
|
722
|
+
signal.throwIfAborted();
|
|
723
|
+
if (inputs.length === 0) return;
|
|
724
|
+
const session = this.#sessions.get(sessionId);
|
|
725
|
+
if (!session) throw new Error(`Session ${sessionId} is offline`);
|
|
726
|
+
await session.peer.send({
|
|
727
|
+
type: "ackInputs",
|
|
728
|
+
sessionId,
|
|
729
|
+
ids: inputs.map(({ id }) => id)
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
async #request(sessionId, message, signal) {
|
|
733
|
+
if (signal.aborted) throw abortError(signal);
|
|
734
|
+
const id = this.#nextRequestId++;
|
|
735
|
+
const request = message(id);
|
|
736
|
+
const peer = request.type === "readResource" ? this.#resources.get(request.uri) : this.#sessions.get(sessionId)?.peer;
|
|
737
|
+
if (!peer)
|
|
738
|
+
throw new Error(
|
|
739
|
+
`Session resource or target is unavailable: ${sessionId}`
|
|
740
|
+
);
|
|
741
|
+
const completion = Promise.withResolvers();
|
|
742
|
+
const onAbort = () => {
|
|
743
|
+
const pending2 = this.#pending.get(id);
|
|
744
|
+
if (!pending2) return;
|
|
745
|
+
void pending2.peer.send({
|
|
746
|
+
type: "cancel",
|
|
747
|
+
id,
|
|
748
|
+
sessionId,
|
|
749
|
+
reason: abortError(signal).message
|
|
750
|
+
}).catch(() => {
|
|
751
|
+
});
|
|
752
|
+
this.#finishRequest(id, pending2);
|
|
753
|
+
pending2.reject(abortError(signal));
|
|
754
|
+
};
|
|
755
|
+
const pending = {
|
|
756
|
+
peer,
|
|
757
|
+
resolve: completion.resolve,
|
|
758
|
+
reject: completion.reject,
|
|
759
|
+
signal,
|
|
760
|
+
onAbort
|
|
761
|
+
};
|
|
762
|
+
this.#pending.set(id, pending);
|
|
763
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
764
|
+
void peer.send(request).catch((error) => {
|
|
765
|
+
this.#finishRequest(id, pending);
|
|
766
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
767
|
+
});
|
|
768
|
+
return completion.promise;
|
|
769
|
+
}
|
|
770
|
+
#waitForChange(signal) {
|
|
771
|
+
if (signal.aborted) return Promise.reject(abortError(signal));
|
|
772
|
+
const completion = Promise.withResolvers();
|
|
773
|
+
const onAbort = () => {
|
|
774
|
+
this.#waiters.delete(waiter);
|
|
775
|
+
waiter.reject(abortError(signal));
|
|
776
|
+
};
|
|
777
|
+
const waiter = {
|
|
778
|
+
resolve: completion.resolve,
|
|
779
|
+
reject: completion.reject,
|
|
780
|
+
signal,
|
|
781
|
+
onAbort
|
|
782
|
+
};
|
|
783
|
+
this.#waiters.add(waiter);
|
|
784
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
785
|
+
return completion.promise;
|
|
786
|
+
}
|
|
787
|
+
#notifyChange() {
|
|
788
|
+
for (const waiter of this.#waiters) {
|
|
789
|
+
this.#waiters.delete(waiter);
|
|
790
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
791
|
+
waiter.resolve();
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
#finishRequest(id, pending) {
|
|
795
|
+
this.#pending.delete(id);
|
|
796
|
+
pending.signal.removeEventListener("abort", pending.onAbort);
|
|
797
|
+
}
|
|
798
|
+
#removePeer(peer) {
|
|
799
|
+
for (const controller of this.#relays.get(peer)?.values() ?? []) {
|
|
800
|
+
controller.abort(new Error("Session disconnected"));
|
|
801
|
+
}
|
|
802
|
+
this.#relays.delete(peer);
|
|
803
|
+
for (const [uri, owner] of this.#resources) {
|
|
804
|
+
if (owner === peer) this.#resources.delete(uri);
|
|
805
|
+
}
|
|
806
|
+
for (const [sessionId, session] of this.#sessions) {
|
|
807
|
+
if (session.peer === peer) this.#removeSession(sessionId);
|
|
808
|
+
}
|
|
809
|
+
for (const [id, pending] of this.#pending) {
|
|
810
|
+
if (pending.peer !== peer) continue;
|
|
811
|
+
this.#finishRequest(id, pending);
|
|
812
|
+
pending.reject(new Error("Session disconnected"));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
#removeSession(sessionId) {
|
|
816
|
+
this.#sessions.delete(sessionId);
|
|
817
|
+
this.#notifyChange();
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
function abortError(signal) {
|
|
821
|
+
return signal.reason instanceof Error ? signal.reason : new Error(
|
|
822
|
+
typeof signal.reason === "string" ? signal.reason : "Request cancelled"
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/server.ts
|
|
827
|
+
import { readFileSync } from "node:fs";
|
|
828
|
+
var instructions = readFileSync(
|
|
829
|
+
new URL("./instructions.md", import.meta.url),
|
|
830
|
+
"utf8"
|
|
831
|
+
).trim();
|
|
832
|
+
var outputSchema = object({
|
|
833
|
+
text: string().describe(
|
|
834
|
+
"Tool output and session updates. Images and file resources accompany the text as content blocks."
|
|
835
|
+
)
|
|
836
|
+
});
|
|
837
|
+
var questionTemplate = "ui://chappie/question.html";
|
|
838
|
+
var questionSchema = outputSchema.extend({ question: questionOutput });
|
|
839
|
+
var toolAnnotations = {
|
|
840
|
+
readOnlyHint: true,
|
|
841
|
+
destructiveHint: false,
|
|
842
|
+
idempotentHint: true,
|
|
843
|
+
openWorldHint: false
|
|
844
|
+
};
|
|
845
|
+
function createServer(broker) {
|
|
846
|
+
const server = new McpServer(
|
|
847
|
+
{
|
|
848
|
+
name: "chappie",
|
|
849
|
+
version: package_default.version
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
instructions: [
|
|
853
|
+
instructions,
|
|
854
|
+
...broker.askEnabled ? [questionInstructions] : []
|
|
855
|
+
].join("\n\n")
|
|
856
|
+
}
|
|
857
|
+
);
|
|
858
|
+
function handle(callback) {
|
|
859
|
+
return (args, context) => {
|
|
860
|
+
const chatId = requireChatId(context);
|
|
861
|
+
context.mcpReq.signal.throwIfAborted();
|
|
862
|
+
return callback(args, { chatId, mcpReq: context.mcpReq });
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
server.registerTool(
|
|
866
|
+
"init",
|
|
867
|
+
{
|
|
868
|
+
title: "Connect to a session",
|
|
869
|
+
description: "Select the default session for this conversation and return its environment, tool catalog, and participation instructions.",
|
|
870
|
+
outputSchema,
|
|
871
|
+
inputSchema: object({
|
|
872
|
+
sessionId: string().optional().describe(
|
|
873
|
+
"Session to select; omit to reuse the default or choose an online unbound session"
|
|
874
|
+
)
|
|
875
|
+
}),
|
|
876
|
+
annotations: toolAnnotations
|
|
877
|
+
},
|
|
878
|
+
handle(async (args, context) => {
|
|
879
|
+
const { inputs, ...initialized } = await broker.initialize(
|
|
880
|
+
context.chatId,
|
|
881
|
+
args.sessionId,
|
|
882
|
+
context.mcpReq._meta?.["otunnel/requestId"],
|
|
883
|
+
context.mcpReq.signal
|
|
884
|
+
);
|
|
885
|
+
return finishResult(broker, context, textResult(initialized, inputs));
|
|
886
|
+
})
|
|
887
|
+
);
|
|
888
|
+
server.registerTool(
|
|
889
|
+
"chat",
|
|
890
|
+
{
|
|
891
|
+
title: "Reply in the session",
|
|
892
|
+
description: "Send a Markdown assistant message, or reply to a model request using its request ID as replyTo.",
|
|
893
|
+
outputSchema,
|
|
894
|
+
inputSchema: chatInput.extend({
|
|
895
|
+
sessionId: string().optional().describe(
|
|
896
|
+
"session for this operation; becomes the default if none is set"
|
|
897
|
+
)
|
|
898
|
+
}),
|
|
899
|
+
annotations: toolAnnotations
|
|
900
|
+
},
|
|
901
|
+
handle(async (args, context) => {
|
|
902
|
+
const result = await broker.chat(
|
|
903
|
+
context.chatId,
|
|
904
|
+
args.sessionId,
|
|
905
|
+
args.text,
|
|
906
|
+
context.mcpReq._meta?.["otunnel/requestId"],
|
|
907
|
+
context.mcpReq.signal,
|
|
908
|
+
args.replyTo
|
|
909
|
+
);
|
|
910
|
+
return finishResult(broker, context, toolResult(result));
|
|
911
|
+
})
|
|
912
|
+
);
|
|
913
|
+
if (broker.askEnabled) {
|
|
914
|
+
server.registerTool(
|
|
915
|
+
"ask",
|
|
916
|
+
{
|
|
917
|
+
title: "Ask in ChatGPT",
|
|
918
|
+
description: "Ask a question in ChatGPT. Call ask_assert next with question.id to confirm display; answers arrive as webAnswer.",
|
|
919
|
+
inputSchema: questionInput.extend({
|
|
920
|
+
sessionId: string().optional().describe(
|
|
921
|
+
"session for this question; defaults to this chat's session"
|
|
922
|
+
)
|
|
923
|
+
}),
|
|
924
|
+
outputSchema: questionSchema,
|
|
925
|
+
annotations: toolAnnotations,
|
|
926
|
+
_meta: { ui: { resourceUri: questionTemplate } }
|
|
927
|
+
},
|
|
928
|
+
handle(async ({ sessionId, ...input }, context) => {
|
|
929
|
+
const { initialization, ...question } = await broker.ask(
|
|
930
|
+
context.chatId,
|
|
931
|
+
sessionId,
|
|
932
|
+
input,
|
|
933
|
+
context.mcpReq._meta?.["otunnel/requestId"],
|
|
934
|
+
context.mcpReq.signal
|
|
935
|
+
);
|
|
936
|
+
const result = await finishResult(broker, context, {
|
|
937
|
+
content: [
|
|
938
|
+
...initialization ? textResult({ initialization }).content : [],
|
|
939
|
+
{
|
|
940
|
+
type: "text",
|
|
941
|
+
text: `Question widget requested. Call ask_assert({"questionId":"${question.id}"}) next.`
|
|
942
|
+
}
|
|
943
|
+
]
|
|
944
|
+
});
|
|
945
|
+
return {
|
|
946
|
+
...result,
|
|
947
|
+
structuredContent: { ...result.structuredContent, question }
|
|
948
|
+
};
|
|
949
|
+
})
|
|
950
|
+
);
|
|
951
|
+
server.registerTool(
|
|
952
|
+
"ask_assert",
|
|
953
|
+
{
|
|
954
|
+
title: "Confirm question display",
|
|
955
|
+
description: "Confirm that an ask widget loaded. Call immediately after ask with question.id. Fails after 10 seconds and marks an unanswered question skipped; answers arrive separately as webAnswer.",
|
|
956
|
+
inputSchema: object({
|
|
957
|
+
questionId: string().describe("question.id returned by ask")
|
|
958
|
+
}),
|
|
959
|
+
outputSchema: questionSchema,
|
|
960
|
+
annotations: toolAnnotations
|
|
961
|
+
},
|
|
962
|
+
handle(async ({ questionId }, context) => {
|
|
963
|
+
const question = await broker.assertQuestion(
|
|
964
|
+
context.chatId,
|
|
965
|
+
questionId,
|
|
966
|
+
context.mcpReq.signal
|
|
967
|
+
);
|
|
968
|
+
const result = await finishResult(broker, context, {
|
|
969
|
+
content: [{ type: "text", text: "Question widget loaded." }]
|
|
970
|
+
});
|
|
971
|
+
return {
|
|
972
|
+
...result,
|
|
973
|
+
structuredContent: { ...result.structuredContent, question }
|
|
974
|
+
};
|
|
975
|
+
})
|
|
976
|
+
);
|
|
977
|
+
server.registerTool(
|
|
978
|
+
"answer",
|
|
979
|
+
{
|
|
980
|
+
title: "Question state",
|
|
981
|
+
description: "Read a saved question, report widget loading, or save an answer, revision, or skip.",
|
|
982
|
+
inputSchema: object({
|
|
983
|
+
questionId: string(),
|
|
984
|
+
answer: answerInput.optional(),
|
|
985
|
+
loaded: literal(true).optional().describe("The question widget has loaded")
|
|
986
|
+
}),
|
|
987
|
+
outputSchema: questionSchema,
|
|
988
|
+
annotations: toolAnnotations,
|
|
989
|
+
_meta: { ui: { visibility: ["app"] }, "openai/widgetAccessible": true }
|
|
990
|
+
},
|
|
991
|
+
async ({ questionId, answer, loaded = false }, context) => {
|
|
992
|
+
const question = await broker.answer(
|
|
993
|
+
requireChatId(context),
|
|
994
|
+
questionId,
|
|
995
|
+
answer,
|
|
996
|
+
loaded
|
|
997
|
+
);
|
|
998
|
+
const text = answer ? answer.skipped ? "Question skipped." : "Answer saved." : loaded ? "Question widget loaded." : "Question state.";
|
|
999
|
+
return {
|
|
1000
|
+
content: [{ type: "text", text }],
|
|
1001
|
+
structuredContent: { text, question }
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
);
|
|
1005
|
+
server.registerResource(
|
|
1006
|
+
"question",
|
|
1007
|
+
questionTemplate,
|
|
1008
|
+
{ title: "Chappie question", mimeType: "text/html;profile=mcp-app" },
|
|
1009
|
+
async () => ({
|
|
1010
|
+
contents: [
|
|
1011
|
+
{
|
|
1012
|
+
uri: questionTemplate,
|
|
1013
|
+
mimeType: "text/html;profile=mcp-app",
|
|
1014
|
+
text: readFileSync(
|
|
1015
|
+
new URL("./question.html", import.meta.url),
|
|
1016
|
+
"utf8"
|
|
1017
|
+
),
|
|
1018
|
+
_meta: {
|
|
1019
|
+
ui: {
|
|
1020
|
+
prefersBorder: true,
|
|
1021
|
+
csp: { connectDomains: [], resourceDomains: [] }
|
|
1022
|
+
},
|
|
1023
|
+
"openai/widgetDescription": "A question the user can answer or revise."
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
]
|
|
1027
|
+
})
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
server.registerTool(
|
|
1031
|
+
"tools",
|
|
1032
|
+
{
|
|
1033
|
+
title: "Native tools",
|
|
1034
|
+
description: "Get full definitions of native tools for call. Filter by names, or omit names to list all active tools.",
|
|
1035
|
+
outputSchema,
|
|
1036
|
+
inputSchema: object({
|
|
1037
|
+
names: array(string()).min(1).optional().describe("Tool names to describe; omit to return every active tool"),
|
|
1038
|
+
sessionId: string().optional().describe(
|
|
1039
|
+
"session for this operation; becomes the default if none is set"
|
|
1040
|
+
)
|
|
1041
|
+
}),
|
|
1042
|
+
annotations: toolAnnotations
|
|
1043
|
+
},
|
|
1044
|
+
handle(async (args, context) => {
|
|
1045
|
+
const { inputs, ...inspected } = await broker.tools(
|
|
1046
|
+
context.chatId,
|
|
1047
|
+
args.sessionId,
|
|
1048
|
+
args.names,
|
|
1049
|
+
context.mcpReq._meta?.["otunnel/requestId"],
|
|
1050
|
+
context.mcpReq.signal
|
|
1051
|
+
);
|
|
1052
|
+
return finishResult(
|
|
1053
|
+
broker,
|
|
1054
|
+
context,
|
|
1055
|
+
textResult(
|
|
1056
|
+
{
|
|
1057
|
+
session: inspected.session,
|
|
1058
|
+
tools: inspected.tools,
|
|
1059
|
+
...inspected.initialization ? { initialization: inspected.initialization } : {}
|
|
1060
|
+
},
|
|
1061
|
+
inputs
|
|
1062
|
+
)
|
|
1063
|
+
);
|
|
1064
|
+
})
|
|
1065
|
+
);
|
|
1066
|
+
server.registerTool(
|
|
1067
|
+
"call",
|
|
1068
|
+
{
|
|
1069
|
+
title: "Run native tools",
|
|
1070
|
+
description: "Run one native tool batch using the definitions returned by tools.",
|
|
1071
|
+
outputSchema,
|
|
1072
|
+
inputSchema: callInput.safeExtend({
|
|
1073
|
+
sessionId: string().optional().describe(
|
|
1074
|
+
"session for this operation; becomes the default if none is set"
|
|
1075
|
+
)
|
|
1076
|
+
}),
|
|
1077
|
+
annotations: toolAnnotations
|
|
1078
|
+
},
|
|
1079
|
+
handle(async (args, context) => {
|
|
1080
|
+
const result = await broker.call(
|
|
1081
|
+
context.chatId,
|
|
1082
|
+
args.sessionId,
|
|
1083
|
+
parseCalls(args),
|
|
1084
|
+
context.mcpReq._meta?.["otunnel/requestId"],
|
|
1085
|
+
context.mcpReq.signal
|
|
1086
|
+
);
|
|
1087
|
+
return finishResult(broker, context, toolResult(result));
|
|
1088
|
+
})
|
|
1089
|
+
);
|
|
1090
|
+
server.registerTool(
|
|
1091
|
+
"transfer",
|
|
1092
|
+
{
|
|
1093
|
+
title: "Transfer files",
|
|
1094
|
+
description: transferDescription,
|
|
1095
|
+
outputSchema,
|
|
1096
|
+
inputSchema: transferInput.extend({
|
|
1097
|
+
sessionId: string().optional().describe(
|
|
1098
|
+
"Session for this operation; defaults to this chat's session"
|
|
1099
|
+
)
|
|
1100
|
+
}),
|
|
1101
|
+
annotations: toolAnnotations,
|
|
1102
|
+
_meta: { "openai/fileParams": ["files"] }
|
|
1103
|
+
},
|
|
1104
|
+
handle(async ({ sessionId, ...input }, context) => {
|
|
1105
|
+
const result = await broker.call(
|
|
1106
|
+
context.chatId,
|
|
1107
|
+
sessionId,
|
|
1108
|
+
callsInput.parse([{ name: "transfer", arguments: input }]),
|
|
1109
|
+
context.mcpReq._meta?.["otunnel/requestId"],
|
|
1110
|
+
context.mcpReq.signal
|
|
1111
|
+
);
|
|
1112
|
+
return finishResult(broker, context, toolResult(result));
|
|
1113
|
+
})
|
|
1114
|
+
);
|
|
1115
|
+
server.registerTool(
|
|
1116
|
+
"history",
|
|
1117
|
+
{
|
|
1118
|
+
title: "Session history",
|
|
1119
|
+
description: "Read the current native transcript, including ongoing work. Re-read a range to see updates to existing entries.",
|
|
1120
|
+
inputSchema: historyInput.extend({
|
|
1121
|
+
sessionId: string().optional().describe("session to read; defaults to this chat's session")
|
|
1122
|
+
}),
|
|
1123
|
+
outputSchema,
|
|
1124
|
+
annotations: toolAnnotations
|
|
1125
|
+
},
|
|
1126
|
+
handle(async ({ sessionId, ...range }, context) => {
|
|
1127
|
+
const { history, ...session } = await broker.history(
|
|
1128
|
+
context.chatId,
|
|
1129
|
+
sessionId,
|
|
1130
|
+
range,
|
|
1131
|
+
context.mcpReq._meta?.["otunnel/requestId"],
|
|
1132
|
+
context.mcpReq.signal
|
|
1133
|
+
);
|
|
1134
|
+
const { content, ...page } = history;
|
|
1135
|
+
return formatResult(
|
|
1136
|
+
{
|
|
1137
|
+
content: [
|
|
1138
|
+
...textResult({ ...session, history: page }).content,
|
|
1139
|
+
...content
|
|
1140
|
+
]
|
|
1141
|
+
},
|
|
1142
|
+
context.chatId
|
|
1143
|
+
);
|
|
1144
|
+
})
|
|
1145
|
+
);
|
|
1146
|
+
server.registerTool(
|
|
1147
|
+
"sessions",
|
|
1148
|
+
{
|
|
1149
|
+
title: "Sessions",
|
|
1150
|
+
description: "List online Chappie sessions and this conversation's default target.",
|
|
1151
|
+
outputSchema,
|
|
1152
|
+
inputSchema: object({
|
|
1153
|
+
sessionId: string().optional().describe("Filter the online list to this session")
|
|
1154
|
+
}),
|
|
1155
|
+
annotations: toolAnnotations
|
|
1156
|
+
},
|
|
1157
|
+
handle(async (args, context) => {
|
|
1158
|
+
const { chatId } = context;
|
|
1159
|
+
const inputs = await broker.inputs(
|
|
1160
|
+
chatId,
|
|
1161
|
+
args.sessionId,
|
|
1162
|
+
context.mcpReq.signal
|
|
1163
|
+
);
|
|
1164
|
+
const result = textResult(
|
|
1165
|
+
{
|
|
1166
|
+
binding: broker.binding(chatId) ?? null,
|
|
1167
|
+
sessions: broker.listSessions(args.sessionId)
|
|
1168
|
+
},
|
|
1169
|
+
inputs
|
|
1170
|
+
);
|
|
1171
|
+
return finishResult(broker, context, result);
|
|
1172
|
+
})
|
|
1173
|
+
);
|
|
1174
|
+
server.registerResource(
|
|
1175
|
+
"Session resource",
|
|
1176
|
+
new ResourceTemplate(
|
|
1177
|
+
"chappie://session/{sessionId}/{kind}/{id}/{name}{?chatId}",
|
|
1178
|
+
{
|
|
1179
|
+
list: void 0
|
|
1180
|
+
}
|
|
1181
|
+
),
|
|
1182
|
+
{ title: "Session resource" },
|
|
1183
|
+
async (uri, _variables, context) => {
|
|
1184
|
+
const resource = await broker.readResource(
|
|
1185
|
+
uri.href,
|
|
1186
|
+
context.mcpReq.signal
|
|
1187
|
+
);
|
|
1188
|
+
return {
|
|
1189
|
+
contents: [
|
|
1190
|
+
{
|
|
1191
|
+
uri: resource.uri,
|
|
1192
|
+
mimeType: resource.mimeType,
|
|
1193
|
+
blob: resource.blob
|
|
1194
|
+
}
|
|
1195
|
+
]
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
);
|
|
1199
|
+
return server;
|
|
1200
|
+
}
|
|
1201
|
+
function textResult(value, inputs = []) {
|
|
1202
|
+
return {
|
|
1203
|
+
content: [
|
|
1204
|
+
{ type: "text", text: JSON.stringify(value) },
|
|
1205
|
+
...inputContent(inputs)
|
|
1206
|
+
]
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
async function finishResult(broker, context, result) {
|
|
1210
|
+
context.mcpReq.signal.throwIfAborted();
|
|
1211
|
+
const { chatId } = context;
|
|
1212
|
+
const deliveries = broker.deliveries(chatId);
|
|
1213
|
+
const answers = broker.answers(chatId);
|
|
1214
|
+
const content = [
|
|
1215
|
+
...result.content,
|
|
1216
|
+
...deliveryContent(deliveries),
|
|
1217
|
+
...answerContent(answers)
|
|
1218
|
+
];
|
|
1219
|
+
await broker.acknowledge(deliveries, answers, context.mcpReq.signal);
|
|
1220
|
+
return formatResult({ ...result, content }, chatId);
|
|
1221
|
+
}
|
|
1222
|
+
function formatResult(result, chatId) {
|
|
1223
|
+
const content = result.content.map((block) => {
|
|
1224
|
+
if (block.type !== "resource_link") return block;
|
|
1225
|
+
const uri = new URL(block.uri);
|
|
1226
|
+
uri.searchParams.set("chatId", chatId);
|
|
1227
|
+
return { ...block, uri: uri.href };
|
|
1228
|
+
});
|
|
1229
|
+
return {
|
|
1230
|
+
...result,
|
|
1231
|
+
content,
|
|
1232
|
+
structuredContent: {
|
|
1233
|
+
text: content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n")
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
function requireChatId(context) {
|
|
1238
|
+
const chatId = context.mcpReq._meta?.["openai/session"];
|
|
1239
|
+
if (typeof chatId !== "string" || !chatId)
|
|
1240
|
+
throw new Error("ChatGPT did not provide openai/session metadata");
|
|
1241
|
+
return chatId;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// src/stdio.ts
|
|
1245
|
+
var terminationSignals = process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
1246
|
+
async function serve() {
|
|
1247
|
+
const broker = new Broker(getDirectory());
|
|
1248
|
+
await broker.start();
|
|
1249
|
+
const output = process.stdout;
|
|
1250
|
+
const transport = new StdioServerTransport(process.stdin, output);
|
|
1251
|
+
let stop;
|
|
1252
|
+
const stopped = new Promise((resolve) => {
|
|
1253
|
+
stop = resolve;
|
|
1254
|
+
});
|
|
1255
|
+
const requestStop = () => stop?.();
|
|
1256
|
+
output.once("error", requestStop);
|
|
1257
|
+
process.stdin.once("end", requestStop);
|
|
1258
|
+
process.stdin.once("close", requestStop);
|
|
1259
|
+
for (const signal of terminationSignals) {
|
|
1260
|
+
process.once(signal, requestStop);
|
|
1261
|
+
}
|
|
1262
|
+
const handle = serveStdio(() => createServer(broker), {
|
|
1263
|
+
transport,
|
|
1264
|
+
onerror(error) {
|
|
1265
|
+
console.error(error);
|
|
1266
|
+
const code = error.code;
|
|
1267
|
+
if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") requestStop();
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
try {
|
|
1271
|
+
await stopped;
|
|
1272
|
+
await handle.close();
|
|
1273
|
+
} finally {
|
|
1274
|
+
await broker.close();
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
export {
|
|
1278
|
+
serve
|
|
1279
|
+
};
|