@fungi.computer/knapsack 0.0.2 → 0.2.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/dist/files.d.ts CHANGED
@@ -2,75 +2,62 @@ import { type Access, type InvocationContext, type LabelledTool, type ModuleRegi
2
2
  import { patchInputSchema, patchOutputSchema } from "./files-patch.js";
3
3
  export { patchInputSchema, patchOutputSchema } from "./files-patch.js";
4
4
  import { z } from "zod";
5
- import { readInputSchema, readOutputSchema } from "./files-read.js";
6
- export { readInputSchema, readOutputSchema };
5
+ import { readInputSchema, readGuestOutputSchema, readOutputSchema } from "./files-read.js";
6
+ export { readGuestOutputSchema, readInputSchema, readOutputSchema };
7
7
  /** Typed refusal from a bounded or conditional Files edit. */
8
- export type FilesEditErrorReason = "not-found" | "ambiguous" | "file-too-large" | "partial-character" | "unchanged" | "conflict";
8
+ export type FilesEditErrorReason = "not-found" | "ambiguous" | "file-too-large" | "partial-character" | "unchanged" | "not-text" | "conflict";
9
9
  /** Expected Files edit refusal, kept distinct from unexpected host failures. */
10
10
  export declare class FilesEditError extends Error {
11
11
  readonly reason: FilesEditErrorReason;
12
12
  readonly path: string;
13
13
  constructor(reason: FilesEditErrorReason, path: string, detail: string);
14
14
  }
15
- /** Bounded directory listing request. */
16
- export declare const listInputSchema: z.ZodObject<{
17
- path: z.ZodString;
15
+ /** List one directory level. */
16
+ export declare const listInputSchema: z.ZodLazy<z.ZodObject<{
17
+ path: z.ZodDefault<z.ZodLazy<z.ZodString>>;
18
18
  limit: z.ZodDefault<z.ZodNumber>;
19
- }, z.core.$strict>;
20
- /** Directory entries and an explicit truncation flag. */
21
- export declare const listOutputSchema: z.ZodObject<{
22
- path: z.ZodString;
23
- entries: z.ZodArray<z.ZodObject<{
24
- path: z.ZodString;
25
- name: z.ZodString;
26
- type: z.ZodEnum<{
27
- file: "file";
28
- directory: "directory";
29
- symlink: "symlink";
30
- }>;
31
- size: z.ZodNumber;
32
- modifiedAt: z.ZodNumber;
33
- }, z.core.$strict>>;
34
- truncated: z.ZodBoolean;
35
- }, z.core.$strict>;
36
- /** Bounded path-pattern search request. */
37
- export declare const globInputSchema: z.ZodObject<{
38
- path: z.ZodString;
19
+ }, z.core.$strict>>;
20
+ /** Match paths below a directory by glob. */
21
+ export declare const globInputSchema: z.ZodLazy<z.ZodObject<{
22
+ path: z.ZodDefault<z.ZodLazy<z.ZodString>>;
39
23
  pattern: z.ZodString;
40
24
  limit: z.ZodDefault<z.ZodNumber>;
41
- }, z.core.$strict>;
42
- /** Matching paths and an explicit truncation flag. */
43
- export declare const globOutputSchema: z.ZodObject<{
44
- path: z.ZodString;
45
- matches: z.ZodArray<z.ZodString>;
46
- truncated: z.ZodBoolean;
47
- }, z.core.$strict>;
48
- /** Bounded content-search request with optional regular-expression matching. */
49
- export declare const grepInputSchema: z.ZodObject<{
50
- path: z.ZodString;
25
+ }, z.core.$strict>>;
26
+ /** Search file content, optionally restricted to matching paths. */
27
+ export declare const grepInputSchema: z.ZodLazy<z.ZodObject<{
28
+ path: z.ZodDefault<z.ZodLazy<z.ZodString>>;
51
29
  pattern: z.ZodString;
30
+ include: z.ZodOptional<z.ZodString>;
52
31
  regex: z.ZodDefault<z.ZodBoolean>;
53
32
  ignoreCase: z.ZodDefault<z.ZodBoolean>;
54
33
  limit: z.ZodDefault<z.ZodNumber>;
55
- }, z.core.$strict>;
56
- /** Content matches and an explicit truncation flag. */
57
- export declare const grepOutputSchema: z.ZodObject<{
58
- path: z.ZodString;
59
- matches: z.ZodArray<z.ZodObject<{
60
- path: z.ZodString;
61
- line: z.ZodNumber;
62
- text: z.ZodString;
63
- }, z.core.$strict>>;
64
- truncated: z.ZodBoolean;
65
- }, z.core.$strict>;
34
+ }, z.core.$strict>>;
35
+ /** Found paths or matching lines, and which cap (if any) cut the search short. */
36
+ export declare const searchOutputSchema: z.ZodLazy<z.ZodObject<{
37
+ path: z.ZodLazy<z.ZodString>;
38
+ results: z.ZodArray<z.ZodLazy<z.ZodObject<{
39
+ path: z.ZodLazy<z.ZodString>;
40
+ type: z.ZodExactOptional<z.ZodEnum<{
41
+ file: "file";
42
+ directory: "directory";
43
+ symlink: "symlink";
44
+ }>>;
45
+ line: z.ZodExactOptional<z.ZodNumber>;
46
+ text: z.ZodExactOptional<z.ZodString>;
47
+ }, z.core.$strict>>>;
48
+ truncated: z.ZodNullable<z.ZodEnum<{
49
+ limit: "limit";
50
+ scan: "scan";
51
+ }>>;
52
+ }, z.core.$strict>>;
66
53
  /** Text replacement request decoded before the captured Files port is called. */
67
- export declare const editInputSchema: z.ZodObject<{
68
- path: z.ZodString;
54
+ export declare const editInputSchema: z.ZodLazy<z.ZodObject<{
55
+ path: z.ZodLazy<z.ZodString>;
69
56
  oldText: z.ZodString;
70
57
  newText: z.ZodString;
71
- }, z.core.$strict>;
58
+ }, z.core.$strict>>;
72
59
  /** Structured edit refusal, including its reason and affected path. */
73
- export declare const filesEditFailureSchema: z.ZodObject<{
60
+ export declare const filesEditFailureSchema: z.ZodLazy<z.ZodObject<{
74
61
  status: z.ZodLiteral<"failed">;
75
62
  error: z.ZodObject<{
76
63
  kind: z.ZodEnum<{
@@ -79,15 +66,16 @@ export declare const filesEditFailureSchema: z.ZodObject<{
79
66
  "file-too-large": "file-too-large";
80
67
  "partial-character": "partial-character";
81
68
  unchanged: "unchanged";
69
+ "not-text": "not-text";
82
70
  conflict: "conflict";
83
71
  }>;
84
- path: z.ZodString;
72
+ path: z.ZodLazy<z.ZodString>;
85
73
  message: z.ZodString;
86
74
  }, z.core.$strict>;
87
- }, z.core.$strict>;
75
+ }, z.core.$strict>>;
88
76
  /** Successful edit metadata or a typed edit refusal. */
89
- export declare const editOutputSchema: z.ZodUnion<readonly [z.ZodObject<{
90
- path: z.ZodString;
77
+ export declare const editOutputSchema: z.ZodUnion<readonly [z.ZodLazy<z.ZodObject<{
78
+ path: z.ZodLazy<z.ZodString>;
91
79
  bytes: z.ZodNumber;
92
80
  diff: z.ZodString;
93
81
  firstChangedLine: z.ZodNullable<z.ZodNumber>;
@@ -96,7 +84,7 @@ export declare const editOutputSchema: z.ZodUnion<readonly [z.ZodObject<{
96
84
  unifiedPatchAddedLines: z.ZodNumber;
97
85
  unifiedPatchRemovedLines: z.ZodNumber;
98
86
  unifiedPatchTruncated: z.ZodBoolean;
99
- }, z.core.$strict>, z.ZodObject<{
87
+ }, z.core.$strict>>, z.ZodLazy<z.ZodObject<{
100
88
  status: z.ZodLiteral<"failed">;
101
89
  error: z.ZodObject<{
102
90
  kind: z.ZodEnum<{
@@ -105,12 +93,13 @@ export declare const editOutputSchema: z.ZodUnion<readonly [z.ZodObject<{
105
93
  "file-too-large": "file-too-large";
106
94
  "partial-character": "partial-character";
107
95
  unchanged: "unchanged";
96
+ "not-text": "not-text";
108
97
  conflict: "conflict";
109
98
  }>;
110
- path: z.ZodString;
99
+ path: z.ZodLazy<z.ZodString>;
111
100
  message: z.ZodString;
112
101
  }, z.core.$strict>;
113
- }, z.core.$strict>]>;
102
+ }, z.core.$strict>>]>;
114
103
  /** Host-owned native patch operation receiving the invocation's deadline and signal. */
115
104
  export type FilesPatch = (input: z.output<typeof patchInputSchema>, context: InvocationContext) => Promise<z.output<typeof patchOutputSchema>>;
116
105
  /** Captured host file authority; the host owns path confinement and atomic replacement. */
@@ -119,12 +108,12 @@ export type FilesPort = Readonly<{
119
108
  patch?: FilesPatch;
120
109
  /** Read a bounded text page. */
121
110
  read: (input: z.output<typeof readInputSchema>, context: InvocationContext) => Promise<z.output<typeof readOutputSchema>>;
122
- /** List bounded directory entries. */
123
- list: (input: z.output<typeof listInputSchema>, context: InvocationContext) => Promise<z.output<typeof listOutputSchema>>;
124
- /** Match bounded paths under the admitted root. */
125
- glob: (input: z.output<typeof globInputSchema>, context: InvocationContext) => Promise<z.output<typeof globOutputSchema>>;
126
- /** Search bounded file content under the admitted root. */
127
- grep: (input: z.output<typeof grepInputSchema>, context: InvocationContext) => Promise<z.output<typeof grepOutputSchema>>;
111
+ /** List one directory level under the admitted root. */
112
+ list: (input: z.output<typeof listInputSchema>, context: InvocationContext) => Promise<z.output<typeof searchOutputSchema>>;
113
+ /** Match paths below the admitted root by glob. */
114
+ glob: (input: z.output<typeof globInputSchema>, context: InvocationContext) => Promise<z.output<typeof searchOutputSchema>>;
115
+ /** Search file content under the admitted root; the host owns the walk. */
116
+ grep: (input: z.output<typeof grepInputSchema>, context: InvocationContext) => Promise<z.output<typeof searchOutputSchema>>;
128
117
  /** Replace atomically only if the stored content still matches the baseline. */
129
118
  replace: (input: Readonly<{
130
119
  path: string;
@@ -135,7 +124,7 @@ export type FilesPort = Readonly<{
135
124
  bytes: number;
136
125
  }>>;
137
126
  }>;
138
- /** Closed set of direct, model-visible Files tools; widening one is an ADR. */
127
+ /** Closed set of direct, model-visible Files tools; changing it is an ADR. */
139
128
  export type FileToolName = "read" | "list" | "glob" | "grep" | "apply_patch";
140
129
  declare const fileToolBrand: unique symbol;
141
130
  /**
@@ -166,5 +155,10 @@ export type FilesConnectionOptions = Readonly<{
166
155
  }>;
167
156
  /** Apply the same edit behavior as the Files edit operation directly through a captured FilesPort. */
168
157
  export declare function edit(port: FilesPort, input: z.output<typeof editInputSchema>, context: InvocationContext): Promise<z.output<typeof editOutputSchema>>;
158
+ /**
159
+ * Guest code and MCP callers never receive base64: the image goes through the
160
+ * execution's image collector (the `images.view` path) and the value is a caption.
161
+ */
162
+ export declare function presentReadForGuest(result: z.output<typeof readOutputSchema>, context: InvocationContext): z.output<typeof readGuestOutputSchema>;
169
163
  /** Build both patch presentations around one injected Files operation. */
170
164
  export declare function files(input: FilesConnectionOptions): FilesSurface;
package/dist/files.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { nanoid } from "@fungi.computer/cairn";
2
- import { Mycelium, } from "@fungi.computer/mycelium";
2
+ import { MAX_EXECUTION_IMAGES, Mycelium, } from "@fungi.computer/mycelium";
3
3
  import * as Effect from "effect/Effect";
4
4
  import * as Stream from "effect/Stream";
5
5
  import { patchInputSchema, patchOutputSchema } from "./files-patch.js";
@@ -7,11 +7,14 @@ export { patchInputSchema, patchOutputSchema } from "./files-patch.js";
7
7
  import { patchGrammar } from "./patch-grammar.js";
8
8
  import { diffLines, formatPatch, structuredPatch } from "diff";
9
9
  import { z } from "zod";
10
- import { maximumReadBytes, pathSchema, boundedTextSchema, readInputSchema, readOutputSchema, } from "./files-read.js";
11
- export { readInputSchema, readOutputSchema };
10
+ import { maximumImageBytes, maximumReadBytes, pathSchema, boundedTextSchema, readInputSchema, readGuestOutputSchema, readOutputSchema, } from "./files-read.js";
11
+ export { readGuestOutputSchema, readInputSchema, readOutputSchema };
12
12
  const maximumPatternCharacters = 512;
13
13
  const maximumResultEntries = 256;
14
+ const defaultSearchResults = 100;
15
+ const maximumFoundLineCharacters = 500;
14
16
  const maximumReadBytesText = String(maximumReadBytes);
17
+ const readDescription = `Read one bounded UTF-8 file page, or a whole png, jpeg, gif or webp image returned as an image you can look at (sent at most ${String(maximumImageBytes / 1_024)} KiB; the host may shrink it and refuses an image it cannot read or fit).`;
15
18
  const maximumUnifiedPatchBytes = 65_536;
16
19
  const utf8 = new TextEncoder();
17
20
  /** Expected Files edit refusal, kept distinct from unexpected host failures. */
@@ -25,78 +28,53 @@ export class FilesEditError extends Error {
25
28
  this.name = "FilesEditError";
26
29
  }
27
30
  }
28
- /** Bounded directory listing request. */
29
- export const listInputSchema = z.strictObject({
30
- path: pathSchema,
31
- limit: z
32
- .number()
33
- .int()
34
- .min(1)
35
- .max(maximumResultEntries)
36
- .default(maximumResultEntries),
37
- });
38
- const listEntrySchema = z.strictObject({
39
- path: pathSchema,
40
- name: z.string().min(1),
41
- type: z.enum(["file", "directory", "symlink"]),
42
- size: z.number().int().nonnegative(),
43
- modifiedAt: z.number(),
44
- });
45
- /** Directory entries and an explicit truncation flag. */
46
- export const listOutputSchema = z.strictObject({
47
- path: pathSchema,
48
- entries: z.array(listEntrySchema).max(maximumResultEntries),
49
- truncated: z.boolean(),
50
- });
51
- /** Bounded path-pattern search request. */
52
- export const globInputSchema = z.strictObject({
53
- path: pathSchema,
54
- pattern: z.string().min(1).max(maximumPatternCharacters),
55
- limit: z
56
- .number()
57
- .int()
58
- .min(1)
59
- .max(maximumResultEntries)
60
- .default(maximumResultEntries),
61
- });
62
- /** Matching paths and an explicit truncation flag. */
63
- export const globOutputSchema = z.strictObject({
64
- path: pathSchema,
65
- matches: z.array(pathSchema).max(maximumResultEntries),
66
- truncated: z.boolean(),
67
- });
68
- /** Bounded content-search request with optional regular-expression matching. */
69
- export const grepInputSchema = z.strictObject({
70
- path: pathSchema,
71
- pattern: z.string().min(1).max(maximumPatternCharacters),
31
+ const searchLimitSchema = z
32
+ .number()
33
+ .int()
34
+ .min(1)
35
+ .max(maximumResultEntries)
36
+ .default(defaultSearchResults);
37
+ const searchPatternSchema = z.string().min(1).max(maximumPatternCharacters);
38
+ /** List one directory level. */
39
+ export const listInputSchema = z.lazy(() => z.strictObject({
40
+ path: pathSchema.default("."),
41
+ limit: searchLimitSchema,
42
+ }));
43
+ /** Match paths below a directory by glob. */
44
+ export const globInputSchema = z.lazy(() => z.strictObject({
45
+ path: pathSchema.default("."),
46
+ pattern: searchPatternSchema,
47
+ limit: searchLimitSchema,
48
+ }));
49
+ /** Search file content, optionally restricted to matching paths. */
50
+ export const grepInputSchema = z.lazy(() => z.strictObject({
51
+ path: pathSchema.default("."),
52
+ pattern: searchPatternSchema,
53
+ include: searchPatternSchema.optional(),
72
54
  regex: z.boolean().default(false),
73
55
  ignoreCase: z.boolean().default(false),
74
- limit: z
75
- .number()
76
- .int()
77
- .min(1)
78
- .max(maximumResultEntries)
79
- .default(maximumResultEntries),
80
- });
81
- /** Content matches and an explicit truncation flag. */
82
- export const grepOutputSchema = z.strictObject({
56
+ limit: searchLimitSchema,
57
+ }));
58
+ /** One found path, with the matching line when the search was by content. */
59
+ const foundSchema = z.lazy(() => z.strictObject({
83
60
  path: pathSchema,
84
- matches: z
85
- .array(z.strictObject({
86
- path: pathSchema,
87
- line: z.number().int().positive(),
88
- text: z.string(),
89
- }))
90
- .max(maximumResultEntries),
91
- truncated: z.boolean(),
92
- });
61
+ type: z.enum(["file", "directory", "symlink"]).exactOptional(),
62
+ line: z.number().int().positive().exactOptional(),
63
+ text: z.string().exactOptional(),
64
+ }));
65
+ /** Found paths or matching lines, and which cap (if any) cut the search short. */
66
+ export const searchOutputSchema = z.lazy(() => z.strictObject({
67
+ path: pathSchema,
68
+ results: z.array(foundSchema).max(maximumResultEntries),
69
+ truncated: z.enum(["limit", "scan"]).nullable(),
70
+ }));
93
71
  /** Text replacement request decoded before the captured Files port is called. */
94
- export const editInputSchema = z.strictObject({
72
+ export const editInputSchema = z.lazy(() => z.strictObject({
95
73
  path: pathSchema,
96
74
  oldText: boundedTextSchema(1),
97
75
  newText: boundedTextSchema(),
98
- });
99
- const editSuccessSchema = z.strictObject({
76
+ }));
77
+ const editSuccessSchema = z.lazy(() => z.strictObject({
100
78
  path: pathSchema,
101
79
  bytes: z.number().int().nonnegative(),
102
80
  diff: z.string(),
@@ -106,9 +84,9 @@ const editSuccessSchema = z.strictObject({
106
84
  unifiedPatchAddedLines: z.number().int().nonnegative(),
107
85
  unifiedPatchRemovedLines: z.number().int().nonnegative(),
108
86
  unifiedPatchTruncated: z.boolean(),
109
- });
87
+ }));
110
88
  /** Structured edit refusal, including its reason and affected path. */
111
- export const filesEditFailureSchema = z.strictObject({
89
+ export const filesEditFailureSchema = z.lazy(() => z.strictObject({
112
90
  status: z.literal("failed"),
113
91
  error: z.strictObject({
114
92
  kind: z.enum([
@@ -116,13 +94,14 @@ export const filesEditFailureSchema = z.strictObject({
116
94
  "ambiguous",
117
95
  "file-too-large",
118
96
  "partial-character",
97
+ "not-text",
119
98
  "unchanged",
120
99
  "conflict",
121
100
  ]),
122
101
  path: pathSchema,
123
102
  message: z.string(),
124
103
  }),
125
- });
104
+ }));
126
105
  /** Successful edit metadata or a typed edit refusal. */
127
106
  export const editOutputSchema = z.union([
128
107
  editSuccessSchema,
@@ -281,6 +260,9 @@ async function applyEdit(port, input, context) {
281
260
  offset: 0,
282
261
  limit: maximumReadBytes,
283
262
  }, context));
263
+ if ("image" in current) {
264
+ throw new FilesEditError("not-text", input.path, "file is an image, not editable text");
265
+ }
284
266
  if (current.truncated) {
285
267
  throw new FilesEditError("file-too-large", input.path, `File exceeds ${maximumReadBytesText} bytes; use files.patch for a small change`);
286
268
  }
@@ -357,6 +339,12 @@ function runFileOperation(invoke) {
357
339
  function runPatch(invoke, input) {
358
340
  return runFileOperation((context) => invoke(input, context));
359
341
  }
342
+ /** Keep model parameters while omitting the decoder's schema-document marker. */
343
+ function toolParameters(schema) {
344
+ const parameters = z.toJSONSchema(schema, { io: "input" });
345
+ delete parameters.$schema;
346
+ return parameters;
347
+ }
360
348
  /** Direct presentation of the same validated Files operation. */
361
349
  function patchTool(invokePatch) {
362
350
  const tool = {
@@ -368,7 +356,7 @@ function patchTool(invokePatch) {
368
356
  name: "apply_patch",
369
357
  description: "Apply a Codex patch to the selected Files root. Example:\n*** Begin Patch\n*** Update File: path/to/file\n@@\n context line\n-old line\n+new line\n*** End Patch\nUse @@ alone or followed by an existing context line; unified-diff @@ line numbers are not supported. Add/Delete File and optional Move to are also supported. Paths stay inside the root; symlinks and parent traversal are rejected. A failure returns recovery guidance and may follow earlier file writes; inspect the result before retrying. Prefer this to shell or Python replacement.",
370
358
  // Model-facing parameters derived from the one Zod decoder `prepare` uses.
371
- parameters: z.toJSONSchema(patchInputSchema),
359
+ parameters: toolParameters(patchInputSchema),
372
360
  constrainedSampling: {
373
361
  type: "grammar",
374
362
  variants: { openai_lark: patchGrammar },
@@ -397,8 +385,109 @@ function patchTool(invokePatch) {
397
385
  };
398
386
  return tool;
399
387
  }
388
+ // One explicit scope difference, shared by both projections of each search.
389
+ const searchScopeDescription = "Search skips .git and gitignored paths.";
390
+ const searchDescriptions = {
391
+ list: `List one directory level. ${searchScopeDescription}`,
392
+ glob: `Find paths by glob. ${searchScopeDescription}`,
393
+ grep: `Search file content. ${searchScopeDescription}`,
394
+ };
395
+ /** Long matching lines would flood the context; the cap is applied once, here. */
396
+ function capFoundLines(output) {
397
+ return {
398
+ ...output,
399
+ results: output.results.map((found) => found.text === undefined ||
400
+ found.text.length <= maximumFoundLineCharacters
401
+ ? found
402
+ : {
403
+ ...found,
404
+ text: `${found.text.slice(0, maximumFoundLineCharacters)}…`,
405
+ }),
406
+ };
407
+ }
408
+ /** Plain lines for the model: `path`, or `path:line: text`, then why it stopped early. */
409
+ function renderFound(output) {
410
+ const lines = output.results.map((found) => found.line === undefined
411
+ ? `${found.path}${found.type === "directory" ? "/" : ""}`
412
+ : `${found.path}:${String(found.line)}: ${found.text ?? ""}`);
413
+ if (lines.length === 0 && output.truncated === null)
414
+ lines.push("(nothing found)");
415
+ if (output.truncated === "limit")
416
+ lines.push("[truncated: result limit reached; raise limit or narrow path]");
417
+ if (output.truncated === "scan")
418
+ lines.push("[truncated: scan limit reached before the search finished; narrow path]");
419
+ return lines.join("\n");
420
+ }
421
+ function presentSearch(result) {
422
+ return {
423
+ content: [{ type: "text", text: renderFound(result) }],
424
+ isError: false,
425
+ };
426
+ }
427
+ /**
428
+ * Guest code and MCP callers never receive base64: the image goes through the
429
+ * execution's image collector (the `images.view` path) and the value is a caption.
430
+ */
431
+ export function presentReadForGuest(result, context) {
432
+ if (!("image" in result))
433
+ return result;
434
+ if (context.images === undefined)
435
+ throw new Error("Images can only be read inside execute or through the read tool.");
436
+ const { mimeType, data, bytes } = result.image;
437
+ const ref = context.images.push({
438
+ data,
439
+ mimeType,
440
+ path: result.path,
441
+ bytes,
442
+ });
443
+ return { path: result.path, image: { ref, mimeType, bytes } };
444
+ }
445
+ /**
446
+ * Images reach the model as an image part; text pages stay JSON text. The
447
+ * Files surface counts the images it shows and refuses past the run limit.
448
+ */
449
+ function presentRead(admit) {
450
+ return (result) => {
451
+ if (!("image" in result))
452
+ return {
453
+ content: [{ type: "text", text: JSON.stringify(result) }],
454
+ isError: false,
455
+ };
456
+ if (!admit())
457
+ return {
458
+ content: [
459
+ {
460
+ type: "text",
461
+ text: `Image limit reached for this run (max ${String(MAX_EXECUTION_IMAGES)} images). Work with the images already shown.`,
462
+ },
463
+ ],
464
+ isError: true,
465
+ };
466
+ return {
467
+ content: [
468
+ {
469
+ type: "text",
470
+ text: JSON.stringify({
471
+ path: result.path,
472
+ mimeType: result.image.mimeType,
473
+ bytes: result.image.bytes,
474
+ }),
475
+ },
476
+ {
477
+ type: "image",
478
+ data: result.image.data,
479
+ mimeType: result.image.mimeType,
480
+ },
481
+ ],
482
+ isError: false,
483
+ };
484
+ };
485
+ }
400
486
  /** Present the very same Files operation and decoder used behind execute. */
401
- function readOnlyTool(name, description, inputSchema, outputSchema, invoke) {
487
+ function readOnlyTool(name, description, inputSchema, outputSchema, invoke, present = (result) => ({
488
+ content: [{ type: "text", text: JSON.stringify(result) }],
489
+ isError: false,
490
+ })) {
402
491
  const readOnly = {
403
492
  [fileToolBrand]: true,
404
493
  module: filesModuleId,
@@ -407,7 +496,8 @@ function readOnlyTool(name, description, inputSchema, outputSchema, invoke) {
407
496
  descriptor: {
408
497
  name,
409
498
  description,
410
- parameters: z.toJSONSchema(inputSchema),
499
+ // Input side: defaulted fields are optional for the model.
500
+ parameters: toolParameters(inputSchema),
411
501
  },
412
502
  executionMode: "sequential",
413
503
  prepare: (call) => Effect.try({
@@ -419,13 +509,7 @@ function readOnlyTool(name, description, inputSchema, outputSchema, invoke) {
419
509
  execute: () => Stream.fromEffect(runFileOperation(async (context) => outputSchema.parse(await invoke(value, context))).pipe(Effect.map((result) => ({
420
510
  type: "result",
421
511
  result: {
422
- content: [
423
- {
424
- type: "text",
425
- text: JSON.stringify(result),
426
- },
427
- ],
428
- isError: false,
512
+ ...present(result),
429
513
  },
430
514
  })))),
431
515
  }))),
@@ -441,38 +525,40 @@ export function files(input) {
441
525
  const invokePatch = patch === undefined
442
526
  ? undefined
443
527
  : async (value, context) => patchOutputSchema.parse(await patch(patchInputSchema.parse(value), context));
528
+ /** Images the direct `read` tool has shown in this Files surface's run. */
529
+ let directImages = 0;
444
530
  const operations = {
445
531
  read: Mycelium.operation({
446
532
  replay: "safe",
447
533
  access: "read",
448
534
  input: readInputSchema,
449
- output: readOutputSchema,
450
- description: describe("Read one bounded UTF-8 file page."),
451
- execute: async (operationInput, context) => input.port.read(operationInput, context),
535
+ output: readGuestOutputSchema,
536
+ description: describe(readDescription),
537
+ execute: async (operationInput, context) => presentReadForGuest(await input.port.read(operationInput, context), context),
452
538
  }),
453
539
  list: Mycelium.operation({
454
540
  replay: "safe",
455
541
  access: "read",
456
542
  input: listInputSchema,
457
- output: listOutputSchema,
458
- description: describe("List one bounded directory level."),
459
- execute: async (operationInput, context) => input.port.list(operationInput, context),
543
+ output: searchOutputSchema,
544
+ description: describe(searchDescriptions.list),
545
+ execute: async (operationInput, context) => capFoundLines(await input.port.list(operationInput, context)),
460
546
  }),
461
547
  glob: Mycelium.operation({
462
548
  replay: "safe",
463
549
  access: "read",
464
550
  input: globInputSchema,
465
- output: globOutputSchema,
466
- description: describe("Find bounded paths by pattern."),
467
- execute: async (operationInput, context) => input.port.glob(operationInput, context),
551
+ output: searchOutputSchema,
552
+ description: describe(searchDescriptions.glob),
553
+ execute: async (operationInput, context) => capFoundLines(await input.port.glob(operationInput, context)),
468
554
  }),
469
555
  grep: Mycelium.operation({
470
556
  replay: "safe",
471
557
  access: "read",
472
558
  input: grepInputSchema,
473
- output: grepOutputSchema,
474
- description: describe("Search bounded file content."),
475
- execute: async (operationInput, context) => input.port.grep(operationInput, context),
559
+ output: searchOutputSchema,
560
+ description: describe(searchDescriptions.grep),
561
+ execute: async (operationInput, context) => capFoundLines(await input.port.grep(operationInput, context)),
476
562
  }),
477
563
  edit: Mycelium.operation({
478
564
  replay: "unsafe",
@@ -496,10 +582,10 @@ export function files(input) {
496
582
  return Object.freeze({
497
583
  tools: Object.freeze([
498
584
  ...(invokePatch === undefined ? [] : [patchTool(invokePatch)]),
499
- readOnlyTool("read", describe("Read one bounded UTF-8 file page."), readInputSchema, readOutputSchema, input.port.read),
500
- readOnlyTool("list", describe("List one bounded directory level."), listInputSchema, listOutputSchema, input.port.list),
501
- readOnlyTool("glob", describe("Find bounded paths by pattern."), globInputSchema, globOutputSchema, input.port.glob),
502
- readOnlyTool("grep", describe("Search bounded file content."), grepInputSchema, grepOutputSchema, input.port.grep),
585
+ readOnlyTool("read", describe(readDescription), readInputSchema, readOutputSchema, input.port.read, presentRead(() => (directImages += 1) <= MAX_EXECUTION_IMAGES)),
586
+ readOnlyTool("list", describe(searchDescriptions.list), listInputSchema, searchOutputSchema, async (value, context) => capFoundLines(await input.port.list(value, context)), presentSearch),
587
+ readOnlyTool("glob", describe(searchDescriptions.glob), globInputSchema, searchOutputSchema, async (value, context) => capFoundLines(await input.port.glob(value, context)), presentSearch),
588
+ readOnlyTool("grep", describe(searchDescriptions.grep), grepInputSchema, searchOutputSchema, async (value, context) => capFoundLines(await input.port.grep(value, context)), presentSearch),
503
589
  ]),
504
590
  module: Mycelium.module({
505
591
  id: filesModuleId,
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type CapabilitySource, type InvocationContext, type MyceliumJsonValue } from "@fungi.computer/mycelium";
2
2
  import { files, type FilesConnectionOptions } from "./files.js";
3
3
  import { mcpServers } from "./mcp.js";
4
+ import { ui } from "./ui.js";
4
5
  /** Generic advertised-call interface supplied by one Computer provider. */
5
6
  export type KnapsackComputer = Readonly<{
6
7
  /** Knapsack stops waiting when `signal` aborts, whether or not the provider honours it. */
@@ -65,6 +66,9 @@ export declare const Knapsack: Readonly<{
65
66
  files: typeof files;
66
67
  /** Connect a changing list of remote MCP servers, reporting unavailable ones independently. */
67
68
  mcpServers: typeof mcpServers;
69
+ /** Connect the open App's live Whistle commands to each Agent Session. */
70
+ ui: typeof ui;
68
71
  }>;
72
+ export type { UiConnection, UiConnectionOptions } from "./ui.js";
69
73
  export { McpServerUnavailableError, type McpServerConnection, type McpServerUnavailableReason, type McpServersConnectionOptions, } from "./mcp.js";
70
- export { FilesEditError, filesEditFailureSchema, type FilesEditErrorReason, type FilesConnectionOptions, type FilesPort, type FilesPatch, type FilesSurface, edit, readInputSchema, readOutputSchema, listInputSchema, listOutputSchema, globInputSchema, globOutputSchema, grepInputSchema, grepOutputSchema, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, type FileTool, type FileToolName, } from "./files.js";
74
+ export { FilesEditError, filesEditFailureSchema, type FilesEditErrorReason, type FilesConnectionOptions, type FilesPort, type FilesPatch, type FilesSurface, edit, readInputSchema, readOutputSchema, listInputSchema, globInputSchema, grepInputSchema, searchOutputSchema, readGuestOutputSchema, presentReadForGuest, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, type FileTool, type FileToolName, } from "./files.js";