@ikuma.cloud/pix-mcp 0.0.7 → 0.0.9

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/index.ts CHANGED
@@ -2,13 +2,39 @@ import { resolve } from "node:path";
2
2
  import { Type } from "@earendil-works/pi-ai";
3
3
  import type {
4
4
  ExtensionAPI,
5
+ ExtensionCommandContext,
5
6
  ExtensionContext,
6
7
  } from "@earendil-works/pi-coding-agent";
7
- import type { Prompt, Tool } from "@modelcontextprotocol/sdk/types.js";
8
+ import type {
9
+ Prompt,
10
+ Resource,
11
+ ResourceTemplate,
12
+ Tool,
13
+ } from "@modelcontextprotocol/sdk/types.js";
8
14
  import { compact, entry, search, summary, type Entry } from "./catalog.ts";
9
15
  import { Connection } from "./client.ts";
10
16
  import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
11
- import { formatResult } from "./output.ts";
17
+ import {
18
+ formatEditableContent,
19
+ formatResult,
20
+ removeEditablePromptGuard,
21
+ } from "./output.ts";
22
+ import { pickPrompt } from "./prompt-picker.ts";
23
+ import { pickResource } from "./resource-picker.ts";
24
+ import {
25
+ formatResourceList,
26
+ formatResourceResult,
27
+ parseResourceCommand,
28
+ resourceCompletions,
29
+ resourceEntryKey,
30
+ resourceKey,
31
+ resourceTemplateVariables,
32
+ resolveResourceUri,
33
+ type ResourceArgumentToken,
34
+ type ResourceCommand,
35
+ type ResourceEntry,
36
+ type TemplateResourceEntry,
37
+ } from "./resources.ts";
12
38
  import {
13
39
  formatPromptList,
14
40
  formatPromptResult,
@@ -16,6 +42,8 @@ import {
16
42
  promptCompletions,
17
43
  promptKey,
18
44
  resolvePromptArguments,
45
+ type PromptArgumentToken,
46
+ type PromptCommand,
19
47
  type PromptEntry,
20
48
  } from "./prompts.ts";
21
49
  import { prepareSchema } from "./schema.ts";
@@ -31,15 +59,28 @@ interface Rejections {
31
59
  }[];
32
60
  }
33
61
 
62
+ interface PromptSelection {
63
+ command: PromptCommand;
64
+ item: PromptEntry;
65
+ }
66
+
67
+ interface ResourceSelection {
68
+ command: ResourceCommand;
69
+ item: ResourceEntry;
70
+ }
71
+
34
72
  interface State {
35
73
  alive: boolean;
36
74
  status: string;
37
75
  entries: Map<string, Entry>;
38
76
  prompts: Map<string, PromptEntry>;
77
+ resources: Map<string, ResourceEntry>;
39
78
  loaded: Map<string, string>;
40
79
  connections: Map<string, Connection>;
41
80
  rejected: Map<string, Rejections>;
42
81
  configIssues: ConfigIssue[];
82
+ draftCleanups: Set<() => Promise<void>>;
83
+ draftGuards: Set<string>;
43
84
  }
44
85
 
45
86
  const discoveryParameters = Type.Object(
@@ -73,7 +114,7 @@ export default function mcp(pi: ExtensionAPI) {
73
114
  "Read this MCP config file for this session (default: .mcp.json).",
74
115
  });
75
116
  pi.registerCommand("mcp-prompt", {
76
- description: "List or run a user-selected MCP prompt",
117
+ description: "Select, list, or run a user-controlled MCP prompt",
77
118
  getArgumentCompletions: (prefix) =>
78
119
  promptCompletions(prefix, [...(state?.prompts.values() ?? [])]),
79
120
  async handler(input, ctx) {
@@ -81,7 +122,14 @@ export default function mcp(pi: ExtensionAPI) {
81
122
  const owner = state;
82
123
  if (!owner) throw new Error("MCP session has not started.");
83
124
  current(owner);
84
- const command = parsePromptCommand(input);
125
+ const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
126
+ const selection = editBeforeSending
127
+ ? await choosePrompt(owner, ctx)
128
+ : undefined;
129
+ const command =
130
+ selection?.command ??
131
+ (editBeforeSending ? undefined : parsePromptCommand(input));
132
+ if (!command) return;
85
133
  if (command.action === "list") {
86
134
  if (
87
135
  command.server &&
@@ -113,43 +161,319 @@ export default function mcp(pi: ExtensionAPI) {
113
161
  ctx.ui.notify(text, "info");
114
162
  return;
115
163
  }
116
- const item = owner.prompts.get(promptKey(command.server, command.name));
164
+ const key = promptKey(command.server, command.name);
165
+ const item = selection?.item ?? owner.prompts.get(key);
117
166
  if (!item)
118
167
  throw new Error(
119
168
  "Unknown or unavailable MCP prompt. List prompts again.",
120
169
  );
170
+ const ensureSelectedPromptCurrent = () => {
171
+ if (selection && owner.prompts.get(key) !== item)
172
+ throw new Error(
173
+ "MCP prompt catalog changed. Open the picker again.",
174
+ );
175
+ };
176
+ ensureSelectedPromptCurrent();
121
177
  const connection = owner.connections.get(command.server);
122
178
  if (!connection) throw new Error("MCP connection unavailable.");
123
179
  const args = resolvePromptArguments(
124
180
  item.prompt,
125
181
  command.argumentTokens,
126
182
  );
127
- const result = await connection.getPrompt(
128
- item.prompt.name,
129
- args,
130
- ctx.signal,
131
- );
183
+ if (ctx.mode === "tui")
184
+ ctx.ui.setStatus(
185
+ "mcp-prompt",
186
+ `Loading ${item.server} / ${item.prompt.name}…`,
187
+ );
188
+ try {
189
+ const result = await connection.getPrompt(
190
+ item.prompt.name,
191
+ args,
192
+ ctx.signal,
193
+ );
194
+ current(owner);
195
+ ensureSelectedPromptCurrent();
196
+ const formatted = await formatPromptResult(result);
197
+ current(owner);
198
+ ensureSelectedPromptCurrent();
199
+ ctx.signal?.throwIfAborted();
200
+ if (editBeforeSending) {
201
+ const draft = await formatEditableContent(formatted.content);
202
+ try {
203
+ current(owner);
204
+ ensureSelectedPromptCurrent();
205
+ ctx.signal?.throwIfAborted();
206
+ ctx.ui.pasteToEditor(draft.text);
207
+ owner.draftCleanups.add(draft.cleanup);
208
+ if (draft.guard) owner.draftGuards.add(draft.guard);
209
+ } catch (error) {
210
+ await draft.cleanup();
211
+ throw error;
212
+ }
213
+ } else {
214
+ pi.sendUserMessage(
215
+ formatted.content,
216
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" },
217
+ );
218
+ }
219
+ } finally {
220
+ if (ctx.mode === "tui") ctx.ui.setStatus("mcp-prompt", undefined);
221
+ }
222
+ } catch (error) {
223
+ const message =
224
+ error instanceof Error ? error.message : "MCP prompt failed.";
225
+ if (!ctx.hasUI) throw new Error(message);
226
+ ctx.ui.notify(message, "error");
227
+ }
228
+ },
229
+ });
230
+
231
+ pi.registerCommand("mcp-resource", {
232
+ description: "Select, list, or read a user-controlled MCP resource",
233
+ getArgumentCompletions: (prefix) =>
234
+ resourceCompletions(
235
+ prefix,
236
+ [...(state?.resources.values() ?? [])],
237
+ state?.connections.keys() ?? [],
238
+ ),
239
+ async handler(input, ctx) {
240
+ try {
241
+ const owner = state;
242
+ if (!owner) throw new Error("MCP session has not started.");
132
243
  current(owner);
133
- const formatted = await formatPromptResult(
134
- result,
135
- item.server,
136
- item.prompt.name,
244
+ const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
245
+ const selection = editBeforeSending
246
+ ? await chooseResource(owner, ctx)
247
+ : undefined;
248
+ const command =
249
+ selection?.command ??
250
+ (editBeforeSending ? undefined : parseResourceCommand(input));
251
+ if (!command) return;
252
+ if (command.action === "list") {
253
+ if (
254
+ command.server &&
255
+ !owner.connections.has(command.server) &&
256
+ !owner.configIssues.some((issue) => issue.name === command.server)
257
+ )
258
+ throw new Error("Unknown MCP server.");
259
+ const resourceStatus = [
260
+ ...owner.configIssues
261
+ .filter(
262
+ (issue) => !command.server || issue.name === command.server,
263
+ )
264
+ .map((issue) => `${issue.name}: ${issue.message}`),
265
+ ...[...owner.connections.values()]
266
+ .filter(
267
+ (connection) =>
268
+ !command.server || connection.config.name === command.server,
269
+ )
270
+ .map(
271
+ (connection) =>
272
+ `${connection.config.name}: ${connection.resourceStatus}`,
273
+ ),
274
+ ].join("\n");
275
+ const text = `${resourceStatus}${resourceStatus ? "\n\n" : ""}${formatResourceList(
276
+ [...owner.resources.values()],
277
+ command.server,
278
+ )}`;
279
+ if (!ctx.hasUI) throw new Error(text);
280
+ ctx.ui.notify(text, "info");
281
+ return;
282
+ }
283
+
284
+ const direct = owner.resources.get(
285
+ resourceKey(command.server, "resource", command.target),
137
286
  );
138
- current(owner);
139
- ctx.signal?.throwIfAborted();
140
- pi.sendUserMessage(
141
- formatted.content,
142
- ctx.isIdle() ? undefined : { deliverAs: "followUp" },
287
+ const template = owner.resources.get(
288
+ resourceKey(command.server, "template", command.target),
289
+ );
290
+ const item =
291
+ selection?.item ??
292
+ (command.argumentTokens.length > 0 ? template : (direct ?? template));
293
+ const selectedKey = item ? resourceEntryKey(item) : undefined;
294
+ const ensureSelectedResourceCurrent = () => {
295
+ if (
296
+ selection &&
297
+ selectedKey &&
298
+ owner.resources.get(selectedKey) !== item
299
+ )
300
+ throw new Error(
301
+ "MCP resource catalog changed. Open the picker again.",
302
+ );
303
+ };
304
+ ensureSelectedResourceCurrent();
305
+ const templateEntry =
306
+ item?.kind === "template"
307
+ ? item
308
+ : template?.kind === "template"
309
+ ? template
310
+ : undefined;
311
+ const uri = resolveResourceUri(
312
+ command.target,
313
+ templateEntry,
314
+ command.argumentTokens,
143
315
  );
316
+ const connection = owner.connections.get(command.server);
317
+ if (!connection) throw new Error("MCP connection unavailable.");
318
+ if (ctx.mode === "tui")
319
+ ctx.ui.setStatus(
320
+ "mcp-resource",
321
+ `Loading ${command.server} / ${compact(uri, 120)}…`,
322
+ );
323
+ try {
324
+ const result = await connection.readResource(uri, ctx.signal);
325
+ current(owner);
326
+ ensureSelectedResourceCurrent();
327
+ const formatted = await formatResourceResult(command.server, result);
328
+ current(owner);
329
+ ensureSelectedResourceCurrent();
330
+ ctx.signal?.throwIfAborted();
331
+ if (editBeforeSending) {
332
+ const draft = await formatEditableContent(formatted.content);
333
+ try {
334
+ current(owner);
335
+ ensureSelectedResourceCurrent();
336
+ ctx.signal?.throwIfAborted();
337
+ ctx.ui.pasteToEditor(draft.text);
338
+ owner.draftCleanups.add(draft.cleanup);
339
+ if (draft.guard) owner.draftGuards.add(draft.guard);
340
+ } catch (error) {
341
+ await draft.cleanup();
342
+ throw error;
343
+ }
344
+ } else {
345
+ pi.sendUserMessage(
346
+ formatted.content,
347
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" },
348
+ );
349
+ }
350
+ } finally {
351
+ if (ctx.mode === "tui") ctx.ui.setStatus("mcp-resource", undefined);
352
+ }
144
353
  } catch (error) {
145
354
  const message =
146
- error instanceof Error ? error.message : "MCP prompt failed.";
355
+ error instanceof Error ? error.message : "MCP resource failed.";
147
356
  if (!ctx.hasUI) throw new Error(message);
148
357
  ctx.ui.notify(message, "error");
149
358
  }
150
359
  },
151
360
  });
152
361
 
362
+ async function chooseResource(
363
+ owner: State,
364
+ ctx: ExtensionCommandContext,
365
+ ): Promise<ResourceSelection | undefined> {
366
+ const entries = [...owner.resources.values()].sort(
367
+ (a, b) =>
368
+ a.server.localeCompare(b.server, "en") ||
369
+ resourceEntryKey(a).localeCompare(resourceEntryKey(b), "en"),
370
+ );
371
+ if (entries.length === 0) {
372
+ ctx.ui.notify("No MCP resources are available.", "warning");
373
+ return;
374
+ }
375
+
376
+ const item = await pickResource(entries, ctx);
377
+ if (!item) return;
378
+ ctx.signal?.throwIfAborted();
379
+
380
+ const key = resourceEntryKey(item);
381
+ const ensureCurrent = () => {
382
+ current(owner);
383
+ if (owner.resources.get(key) !== item)
384
+ throw new Error("MCP resource catalog changed. Open the picker again.");
385
+ };
386
+ ensureCurrent();
387
+
388
+ const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
389
+ const argumentTokens: ResourceArgumentToken[] = [];
390
+ if (item.kind === "template") {
391
+ for (const variable of item.variables) {
392
+ const value = await ctx.ui.input(
393
+ `${variable} (optional)`,
394
+ "Leave empty to omit",
395
+ dialogOptions,
396
+ );
397
+ if (value === undefined) return;
398
+ ctx.signal?.throwIfAborted();
399
+ ensureCurrent();
400
+ if (value !== "")
401
+ argumentTokens.push({
402
+ value: `${variable}=${value}`,
403
+ separator: variable.length,
404
+ });
405
+ }
406
+ }
407
+
408
+ return {
409
+ command: {
410
+ action: "read",
411
+ server: item.server,
412
+ target:
413
+ item.kind === "resource"
414
+ ? item.resource.uri
415
+ : item.template.uriTemplate,
416
+ argumentTokens,
417
+ },
418
+ item,
419
+ };
420
+ }
421
+
422
+ async function choosePrompt(
423
+ owner: State,
424
+ ctx: ExtensionCommandContext,
425
+ ): Promise<PromptSelection | undefined> {
426
+ const entries = [...owner.prompts.values()].sort(
427
+ (a, b) =>
428
+ a.server.localeCompare(b.server, "en") ||
429
+ a.prompt.name.localeCompare(b.prompt.name, "en"),
430
+ );
431
+ if (entries.length === 0) {
432
+ ctx.ui.notify("No MCP prompts are available.", "warning");
433
+ return;
434
+ }
435
+
436
+ const item = await pickPrompt(entries, ctx);
437
+ if (!item) return;
438
+ ctx.signal?.throwIfAborted();
439
+
440
+ const key = promptKey(item.server, item.prompt.name);
441
+ const ensureCurrent = () => {
442
+ current(owner);
443
+ if (owner.prompts.get(key) !== item)
444
+ throw new Error("MCP prompt catalog changed. Open the picker again.");
445
+ };
446
+ ensureCurrent();
447
+
448
+ const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
449
+ const argumentTokens: PromptArgumentToken[] = [];
450
+ for (const argument of item.prompt.arguments ?? []) {
451
+ const value = await ctx.ui.input(
452
+ `${argument.name} (${argument.required ? "required" : "optional"})`,
453
+ argument.required ? "Enter a value" : "Leave empty to omit",
454
+ dialogOptions,
455
+ );
456
+ if (value === undefined) return;
457
+ ctx.signal?.throwIfAborted();
458
+ ensureCurrent();
459
+ if (argument.required || value !== "")
460
+ argumentTokens.push({
461
+ value: `${argument.name}=${value}`,
462
+ separator: argument.name.length,
463
+ });
464
+ }
465
+
466
+ return {
467
+ command: {
468
+ action: "run",
469
+ server: item.server,
470
+ name: item.prompt.name,
471
+ argumentTokens,
472
+ },
473
+ item,
474
+ };
475
+ }
476
+
153
477
  function deactivate(names: Iterable<string>) {
154
478
  const removed = new Set(names);
155
479
  pi.setActiveTools(pi.getActiveTools().filter((name) => !removed.has(name)));
@@ -334,6 +658,35 @@ export default function mcp(pi: ExtensionAPI) {
334
658
  }
335
659
  }
336
660
 
661
+ function synchronizeResources(
662
+ owner: State,
663
+ config: ServerConfig,
664
+ resources: Resource[],
665
+ templates: ResourceTemplate[],
666
+ ) {
667
+ if (!owner.alive || state !== owner) return;
668
+ for (const [key, item] of owner.resources) {
669
+ if (item.server === config.name) owner.resources.delete(key);
670
+ }
671
+ for (const resource of resources) {
672
+ const item: ResourceEntry = {
673
+ kind: "resource",
674
+ server: config.name,
675
+ resource,
676
+ };
677
+ owner.resources.set(resourceEntryKey(item), item);
678
+ }
679
+ for (const template of templates) {
680
+ const item: TemplateResourceEntry = {
681
+ kind: "template",
682
+ server: config.name,
683
+ template,
684
+ variables: resourceTemplateVariables(template.uriTemplate),
685
+ };
686
+ owner.resources.set(resourceEntryKey(item), item);
687
+ }
688
+ }
689
+
337
690
  function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
338
691
  if (!owner.alive || state !== owner) return;
339
692
  const previous = new Map(
@@ -444,9 +797,15 @@ export default function mcp(pi: ExtensionAPI) {
444
797
  owner.alive = false;
445
798
  state = undefined;
446
799
  deactivate(owned);
447
- await Promise.all(
448
- [...owner.connections.values()].map((connection) => connection.close()),
449
- );
800
+ const draftCleanups = [...owner.draftCleanups];
801
+ owner.draftCleanups.clear();
802
+ owner.draftGuards.clear();
803
+ await Promise.all([
804
+ ...[...owner.connections.values()].map((connection) =>
805
+ connection.close(),
806
+ ),
807
+ ...draftCleanups.map((cleanup) => cleanup()),
808
+ ]);
450
809
  }
451
810
 
452
811
  async function start(ctx: ExtensionContext) {
@@ -456,10 +815,13 @@ export default function mcp(pi: ExtensionAPI) {
456
815
  status: "No MCP servers configured.",
457
816
  entries: new Map(),
458
817
  prompts: new Map(),
818
+ resources: new Map(),
459
819
  loaded: new Map(),
460
820
  connections: new Map(),
461
821
  rejected: new Map(),
462
822
  configIssues: [],
823
+ draftCleanups: new Set(),
824
+ draftGuards: new Set(),
463
825
  };
464
826
  state = owner;
465
827
  try {
@@ -491,6 +853,8 @@ export default function mcp(pi: ExtensionAPI) {
491
853
  server,
492
854
  (tools) => synchronize(owner, server, tools),
493
855
  (prompts) => synchronizePrompts(owner, server, prompts),
856
+ (resources, templates) =>
857
+ synchronizeResources(owner, server, resources, templates),
494
858
  ),
495
859
  );
496
860
  }
@@ -514,6 +878,35 @@ export default function mcp(pi: ExtensionAPI) {
514
878
  }
515
879
  }
516
880
 
881
+ // Keep each invisible editor guard through Pi's command/template dispatch,
882
+ // then remove only guards created by this session before model context.
883
+ pi.on("message_end", (event) => {
884
+ const owner = state;
885
+ if (!owner || event.message.role !== "user") return;
886
+ const consumed = new Set<string>();
887
+ const removeKnownGuards = (text: string) => {
888
+ let next = text;
889
+ for (const guard of owner.draftGuards) {
890
+ if (!next.includes(guard)) continue;
891
+ next = removeEditablePromptGuard(next, guard);
892
+ consumed.add(guard);
893
+ }
894
+ return next;
895
+ };
896
+ let content: typeof event.message.content;
897
+ if (typeof event.message.content === "string") {
898
+ content = removeKnownGuards(event.message.content);
899
+ } else {
900
+ content = event.message.content.map((block) =>
901
+ block.type === "text"
902
+ ? { ...block, text: removeKnownGuards(block.text) }
903
+ : block,
904
+ );
905
+ }
906
+ for (const guard of consumed) owner.draftGuards.delete(guard);
907
+ if (consumed.size > 0) return { message: { ...event.message, content } };
908
+ });
909
+
517
910
  // Any extension's registerTool can reactivate allowlisted tools in Pi 0.87.
518
911
  // Independently track loaded fingerprints, enforce them in execute, and scrub
519
912
  // request-local declarations at the last provider-neutral context boundary.
package/src/output.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdtemp, writeFile } from "node:fs/promises";
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
@@ -28,6 +29,7 @@ interface ContentOptions {
28
29
  structuredContent?: unknown;
29
30
  preserveErrorImages?: boolean;
30
31
  preserveOrder?: boolean;
32
+ artifactRequired?: boolean;
31
33
  }
32
34
 
33
35
  export async function formatContent(
@@ -70,16 +72,19 @@ export async function formatContent(
70
72
  `Structured content:\n${JSON.stringify(options.structuredContent)}`,
71
73
  );
72
74
  }
73
- const fullText = text.join("\n\n") || "(No text output)";
75
+ const fullText =
76
+ text.join("\n\n") || (options.preserveOrder ? "" : "(No text output)");
74
77
  const bounded = preview(fullText);
75
78
  const structured = options.structuredContent;
76
79
  const largeDetails =
77
80
  structured !== undefined &&
78
81
  Buffer.byteLength(JSON.stringify(structured)) > MAX_DETAILS_BYTES;
82
+ const textTruncated = bounded !== fullText;
79
83
  const truncated =
80
- bounded !== fullText ||
84
+ textTruncated ||
81
85
  omitted ||
82
86
  largeDetails ||
87
+ options.artifactRequired === true ||
83
88
  (options.preserveErrorImages === true && images.length > 0);
84
89
  let fullOutputPath: string | undefined;
85
90
  if (truncated) {
@@ -99,7 +104,7 @@ export async function formatContent(
99
104
  let textIndex = 0;
100
105
  for (const block of ordered) {
101
106
  if (block.type === "image") {
102
- content.push(block);
107
+ if (!textTruncated || offset < bounded.length) content.push(block);
103
108
  continue;
104
109
  }
105
110
  const value = `${textIndex++ > 0 ? "\n\n" : ""}${block.text}`;
@@ -112,7 +117,8 @@ export async function formatContent(
112
117
  if (previous?.type === "text") previous.text += visible;
113
118
  else content.push({ type: "text", text: visible });
114
119
  }
115
- if (textIndex === 0) content.unshift({ type: "text", text: bounded });
120
+ if (textIndex === 0 && bounded)
121
+ content.unshift({ type: "text", text: bounded });
116
122
  if (artifactNotice)
117
123
  content.push({ type: "text", text: `\n\n${artifactNotice}` });
118
124
  } else {
@@ -137,6 +143,80 @@ export async function formatContent(
137
143
  };
138
144
  }
139
145
 
146
+ function createEditableContentGuard(): string {
147
+ const bytes = randomBytes(8);
148
+ let guard = "\u2060\u2063";
149
+ for (const byte of bytes)
150
+ for (let bit = 7; bit >= 0; bit--)
151
+ guard += byte & (1 << bit) ? "\u2063" : "\u2060";
152
+ return guard;
153
+ }
154
+
155
+ export function removeEditablePromptGuard(text: string, guard: string): string {
156
+ return text.replaceAll(guard, "");
157
+ }
158
+
159
+ function prepareEditableContent(text: string): {
160
+ text: string;
161
+ guard?: string;
162
+ } {
163
+ // The TUI interprets leading / and ! as commands. An invisible guard keeps
164
+ // untrusted staged text inert in the editor and is removed at message_end.
165
+ const sanitized = text
166
+ .replace(/\r\n?/gu, "\n")
167
+ .replace(/\p{Cc}/gu, (character) =>
168
+ character === "\n" || character === "\t" ? character : "",
169
+ )
170
+ .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "");
171
+ if (!/^[!/]/u.test(sanitized.trimStart())) return { text: sanitized };
172
+ const guard = createEditableContentGuard();
173
+ return { text: `${guard}${sanitized}`, guard };
174
+ }
175
+
176
+ const imageExtensions: Record<string, string> = {
177
+ "image/png": "png",
178
+ "image/jpeg": "jpg",
179
+ "image/gif": "gif",
180
+ "image/webp": "webp",
181
+ };
182
+
183
+ export async function formatEditableContent(
184
+ content: (TextContent | ImageContent)[],
185
+ ): Promise<{ text: string; guard?: string; cleanup: () => Promise<void> }> {
186
+ let text = "";
187
+ let imageDirectory: string | undefined;
188
+ let imageIndex = 0;
189
+ try {
190
+ for (const block of content) {
191
+ if (block.type === "text") {
192
+ text += block.text;
193
+ continue;
194
+ }
195
+ imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-draft-"));
196
+ const extension = imageExtensions[block.mimeType] ?? "img";
197
+ const path = join(imageDirectory, `image-${++imageIndex}.${extension}`);
198
+ await writeFile(path, Buffer.from(block.data, "base64"), { mode: 0o600 });
199
+ const separator = text.endsWith("\n\n")
200
+ ? ""
201
+ : text.endsWith("\n")
202
+ ? "\n"
203
+ : "\n\n";
204
+ text += `${separator}@${JSON.stringify(path.replaceAll("\\", "/"))}`;
205
+ }
206
+ } catch (error) {
207
+ if (imageDirectory)
208
+ await rm(imageDirectory, { recursive: true, force: true });
209
+ throw error;
210
+ }
211
+ return {
212
+ ...prepareEditableContent(text),
213
+ cleanup: async () => {
214
+ if (imageDirectory)
215
+ await rm(imageDirectory, { recursive: true, force: true });
216
+ },
217
+ };
218
+ }
219
+
140
220
  export function formatResult(result: CallToolResult) {
141
221
  // Pi 0.87 turns thrown tool errors into text only. Preserve error images in
142
222
  // the full-result artifact before the native execution wrapper throws.