@fungi.computer/knapsack 0.1.0 → 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/README.md +37 -4
- package/dist/files-patch.d.ts +16 -2
- package/dist/files-patch.js +8 -1
- package/dist/files-read.d.ts +108 -3
- package/dist/files-read.js +77 -7
- package/dist/files-search.d.ts +14 -0
- package/dist/files-search.js +52 -0
- package/dist/files.d.ts +40 -46
- package/dist/files.js +171 -85
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/ui/browser.d.ts +44 -0
- package/dist/ui/browser.js +171 -0
- package/dist/ui/effect.d.ts +106 -0
- package/dist/ui/effect.js +208 -0
- package/dist/ui/host.d.ts +9 -0
- package/dist/ui/host.js +158 -0
- package/dist/ui/sessions.d.ts +32 -0
- package/dist/ui/sessions.js +172 -0
- package/dist/ui/wire.d.ts +290 -0
- package/dist/ui/wire.js +136 -0
- package/dist/ui-browser.d.ts +6 -0
- package/dist/ui-browser.js +6 -0
- package/dist/ui.d.ts +29 -0
- package/dist/ui.js +35 -0
- package/docs/api/README.md +1 -0
- package/docs/api/files-patch.md +39 -43
- package/docs/api/files-read.md +263 -27
- package/docs/api/index.md +207 -107
- package/docs/api/ui-browser.md +94 -0
- package/package.json +16 -5
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,70 +28,45 @@ export class FilesEditError extends Error {
|
|
|
25
28
|
this.name = "FilesEditError";
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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(),
|
|
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,
|
|
44
42
|
}));
|
|
45
|
-
/**
|
|
46
|
-
export const listOutputSchema = z.lazy(() => z.strictObject({
|
|
47
|
-
path: pathSchema,
|
|
48
|
-
entries: z.array(listEntrySchema).max(maximumResultEntries),
|
|
49
|
-
truncated: z.boolean(),
|
|
50
|
-
}));
|
|
51
|
-
/** Bounded path-pattern search request. */
|
|
43
|
+
/** Match paths below a directory by glob. */
|
|
52
44
|
export const globInputSchema = z.lazy(() => z.strictObject({
|
|
53
|
-
path: pathSchema,
|
|
54
|
-
pattern:
|
|
55
|
-
limit:
|
|
56
|
-
.number()
|
|
57
|
-
.int()
|
|
58
|
-
.min(1)
|
|
59
|
-
.max(maximumResultEntries)
|
|
60
|
-
.default(maximumResultEntries),
|
|
45
|
+
path: pathSchema.default("."),
|
|
46
|
+
pattern: searchPatternSchema,
|
|
47
|
+
limit: searchLimitSchema,
|
|
61
48
|
}));
|
|
62
|
-
/**
|
|
63
|
-
export const globOutputSchema = z.lazy(() => 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. */
|
|
49
|
+
/** Search file content, optionally restricted to matching paths. */
|
|
69
50
|
export const grepInputSchema = z.lazy(() => z.strictObject({
|
|
70
|
-
path: pathSchema,
|
|
71
|
-
pattern:
|
|
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:
|
|
75
|
-
.number()
|
|
76
|
-
.int()
|
|
77
|
-
.min(1)
|
|
78
|
-
.max(maximumResultEntries)
|
|
79
|
-
.default(maximumResultEntries),
|
|
56
|
+
limit: searchLimitSchema,
|
|
80
57
|
}));
|
|
81
|
-
/**
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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(),
|
|
92
70
|
}));
|
|
93
71
|
/** Text replacement request decoded before the captured Files port is called. */
|
|
94
72
|
export const editInputSchema = z.lazy(() => z.strictObject({
|
|
@@ -116,6 +94,7 @@ export const filesEditFailureSchema = z.lazy(() => 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
|
]),
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
450
|
-
description: describe(
|
|
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:
|
|
458
|
-
description: describe(
|
|
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:
|
|
466
|
-
description: describe(
|
|
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:
|
|
474
|
-
description: describe(
|
|
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(
|
|
500
|
-
readOnlyTool("list", describe(
|
|
501
|
-
readOnlyTool("glob", describe(
|
|
502
|
-
readOnlyTool("grep", describe(
|
|
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,
|
|
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";
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as Result from "effect/Result";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { files } from "./files.js";
|
|
6
6
|
import { mcpServers } from "./mcp.js";
|
|
7
|
+
import { ui } from "./ui.js";
|
|
7
8
|
const maximumCapabilities = 256;
|
|
8
9
|
const describeConcurrency = 8;
|
|
9
10
|
const defaultDescribeTimeoutMillis = 10_000;
|
|
@@ -173,6 +174,8 @@ export const Knapsack = Object.freeze({
|
|
|
173
174
|
files,
|
|
174
175
|
/** Connect a changing list of remote MCP servers, reporting unavailable ones independently. */
|
|
175
176
|
mcpServers,
|
|
177
|
+
/** Connect the open App's live Whistle commands to each Agent Session. */
|
|
178
|
+
ui,
|
|
176
179
|
});
|
|
177
180
|
export { McpServerUnavailableError, } from "./mcp.js";
|
|
178
|
-
export { FilesEditError, filesEditFailureSchema, edit, readInputSchema, readOutputSchema, listInputSchema,
|
|
181
|
+
export { FilesEditError, filesEditFailureSchema, edit, readInputSchema, readOutputSchema, listInputSchema, globInputSchema, grepInputSchema, searchOutputSchema, readGuestOutputSchema, presentReadForGuest, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, } from "./files.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { WhistleRuntime } from "@fungi.computer/whistle";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Scope from "effect/Scope";
|
|
4
|
+
import { type BrowserRequestEnvelope, type BrowserResultEnvelope } from "./wire.js";
|
|
5
|
+
/** Browser-side platform failure before a correlated result is accepted. */
|
|
6
|
+
export declare class UiBrowserError extends Error {
|
|
7
|
+
constructor(message: string, options?: ErrorOptions);
|
|
8
|
+
}
|
|
9
|
+
/** One Agent Session's browser transport and the App's command runtime. */
|
|
10
|
+
export type BrowserConnectionOptions = Readonly<{
|
|
11
|
+
/** Session route that serves `/browser/{events,claims,results}`. */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/** The App shell's Whistle runtime; its agent projection is what the Agent sees. */
|
|
14
|
+
whistle: Pick<WhistleRuntime, "snapshot" | "execute">;
|
|
15
|
+
/** Host-authorized I/O; omitted for ordinary browser Fetch and EventSource. */
|
|
16
|
+
transport?: Readonly<{
|
|
17
|
+
/** Send claims and results with the host's existing admission. */
|
|
18
|
+
fetch: typeof globalThis.fetch;
|
|
19
|
+
/** Open the event stream with that same admission and a maintained SSE client. */
|
|
20
|
+
openEvents: (url: string) => EventTarget & {
|
|
21
|
+
/** Stop this connection's event stream when its owning scope closes. */
|
|
22
|
+
close(): void;
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
25
|
+
}>;
|
|
26
|
+
/** A live browser connection; close it when the selected Session changes. */
|
|
27
|
+
export type BrowserConnection = Readonly<{
|
|
28
|
+
/** Settles when the connection ends; rejects with {@link UiBrowserError} on failure. */
|
|
29
|
+
closed: Promise<void>;
|
|
30
|
+
/** Stop serving the Agent's requests. */
|
|
31
|
+
close: () => void;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Execute one claimed browser request through Whistle's single door.
|
|
35
|
+
*
|
|
36
|
+
* Claim is the dispatch point: the browser request carries no cancellation, so
|
|
37
|
+
* Whistle supplies its own never-aborted signal, and cancellation after this
|
|
38
|
+
* function starts cannot promise to undo a side effect.
|
|
39
|
+
*/
|
|
40
|
+
export declare function handleBrowserRequest(options: BrowserConnectionOptions, envelope: BrowserRequestEnvelope): Effect.Effect<Omit<BrowserResultEnvelope, "claimId">, UiBrowserError>;
|
|
41
|
+
/** Run one scope-owned EventSource connection and its Whistle request loop. */
|
|
42
|
+
export declare function runBrowserConnection(options: BrowserConnectionOptions): Effect.Effect<void, UiBrowserError, Scope.Scope>;
|
|
43
|
+
/** Start the named browser Promise facade over the Effect connection program. */
|
|
44
|
+
export declare function connectBrowser(options: BrowserConnectionOptions): BrowserConnection;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Scope from "effect/Scope";
|
|
3
|
+
import * as Stream from "effect/Stream";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { BrowserClaimResultSchema, BrowserCommandRowSchema, BrowserRequestDeliverySchema, BrowserSettlementResultSchema, } from "./wire.js";
|
|
6
|
+
import { uiWireIds, BrowserExecutionOutcomeSchema } from "./wire.js";
|
|
7
|
+
/** Browser-side platform failure before a correlated result is accepted. */
|
|
8
|
+
export class UiBrowserError extends Error {
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = "UiBrowserError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const decodeRequestEvent = (input) => Effect.try(() => BrowserRequestDeliverySchema.parse(JSON.parse(z.string().parse(input))));
|
|
15
|
+
const decodeClaimResult = (input) => Effect.try(() => BrowserClaimResultSchema.parse(input));
|
|
16
|
+
const decodeSettlementResult = (input) => Effect.try(() => BrowserSettlementResultSchema.parse(input));
|
|
17
|
+
function requestEventData(event) {
|
|
18
|
+
return event instanceof MessageEvent
|
|
19
|
+
? Effect.try(() => z.string().parse(event.data)).pipe(Effect.mapError(() => new UiBrowserError("Browser request event is invalid")))
|
|
20
|
+
: Effect.fail(new UiBrowserError("Browser request event is invalid"));
|
|
21
|
+
}
|
|
22
|
+
function projectCommands(whistle) {
|
|
23
|
+
return Effect.sync(() => BrowserCommandRowSchema.array().parse(whistle.snapshot().agent.map((row) => {
|
|
24
|
+
const sourceAction = row.action;
|
|
25
|
+
// Focus scope belongs to the local command graph, not the browser wire.
|
|
26
|
+
const projected = {
|
|
27
|
+
commandId: row.commandId,
|
|
28
|
+
sourceId: row.sourceId,
|
|
29
|
+
title: row.title,
|
|
30
|
+
category: row.category,
|
|
31
|
+
description: row.description ?? null,
|
|
32
|
+
order: row.order,
|
|
33
|
+
keybind: row.keybind ?? null,
|
|
34
|
+
availability: row.availability,
|
|
35
|
+
};
|
|
36
|
+
if (sourceAction === undefined)
|
|
37
|
+
return projected;
|
|
38
|
+
const action = {};
|
|
39
|
+
if (sourceAction.inputSchema !== undefined)
|
|
40
|
+
action.inputSchema = sourceAction.inputSchema;
|
|
41
|
+
if (sourceAction.outputSchema !== undefined)
|
|
42
|
+
action.outputSchema = sourceAction.outputSchema;
|
|
43
|
+
if (action.inputSchema === undefined &&
|
|
44
|
+
action.outputSchema === undefined)
|
|
45
|
+
return projected;
|
|
46
|
+
return { ...projected, action };
|
|
47
|
+
})));
|
|
48
|
+
}
|
|
49
|
+
function projectOutcome(outcome) {
|
|
50
|
+
const parsed = BrowserExecutionOutcomeSchema.safeParse(outcome);
|
|
51
|
+
return {
|
|
52
|
+
type: "execute",
|
|
53
|
+
outcome: parsed.success
|
|
54
|
+
? parsed.data
|
|
55
|
+
: {
|
|
56
|
+
status: "failed",
|
|
57
|
+
error: {
|
|
58
|
+
code: "invalid_command_result",
|
|
59
|
+
message: "Command result is not valid JSON data",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Execute one claimed browser request through Whistle's single door.
|
|
66
|
+
*
|
|
67
|
+
* Claim is the dispatch point: the browser request carries no cancellation, so
|
|
68
|
+
* Whistle supplies its own never-aborted signal, and cancellation after this
|
|
69
|
+
* function starts cannot promise to undo a side effect.
|
|
70
|
+
*/
|
|
71
|
+
export function handleBrowserRequest(options, envelope) {
|
|
72
|
+
if (envelope.request.type === "discover")
|
|
73
|
+
return projectCommands(options.whistle).pipe(Effect.map((commands) => ({
|
|
74
|
+
requestId: envelope.requestId,
|
|
75
|
+
result: { type: "discover", commands },
|
|
76
|
+
})));
|
|
77
|
+
const commandId = envelope.request.commandId;
|
|
78
|
+
const argumentsValue = "arguments" in envelope.request ? envelope.request.arguments : undefined;
|
|
79
|
+
return Effect.tryPromise({
|
|
80
|
+
try: () => options.whistle.execute(commandId, argumentsValue === undefined
|
|
81
|
+
? { origin: "agent" }
|
|
82
|
+
: { origin: "agent", arguments: argumentsValue }),
|
|
83
|
+
catch: () => new UiBrowserError("Whistle execution rejected"),
|
|
84
|
+
}).pipe(Effect.map((outcome) => ({
|
|
85
|
+
requestId: envelope.requestId,
|
|
86
|
+
result: projectOutcome(outcome),
|
|
87
|
+
})));
|
|
88
|
+
}
|
|
89
|
+
function claimRequest(options, delivery) {
|
|
90
|
+
const attemptId = uiWireIds.attempt.generate();
|
|
91
|
+
const claimOnce = () => Effect.tryPromise({
|
|
92
|
+
try: (signal) => (options.transport?.fetch ?? globalThis.fetch)(`${options.baseUrl}/browser/claims`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
attemptId,
|
|
97
|
+
connectionId: delivery.connectionId,
|
|
98
|
+
requestId: delivery.requestId,
|
|
99
|
+
}),
|
|
100
|
+
signal,
|
|
101
|
+
}),
|
|
102
|
+
catch: () => new UiBrowserError("Browser claim request failed"),
|
|
103
|
+
}).pipe(Effect.flatMap((response) => response.ok
|
|
104
|
+
? Effect.tryPromise({
|
|
105
|
+
try: () => response.json(),
|
|
106
|
+
catch: () => new UiBrowserError("Browser claim response is invalid"),
|
|
107
|
+
})
|
|
108
|
+
: Effect.fail(new UiBrowserError(`Browser claim was rejected with status ${String(response.status)}`))), Effect.flatMap((input) => Effect.try(() => z.record(z.string(), z.unknown()).parse(input)).pipe(Effect.flatMap(decodeClaimResult))), Effect.mapError(() => new UiBrowserError("Browser claim response is invalid")));
|
|
109
|
+
const waitForClaim = () => claimOnce().pipe(Effect.flatMap((claim) => claim.status === "waiting"
|
|
110
|
+
? Effect.sleep(claim.retryAfterMs).pipe(Effect.andThen(Effect.suspend(waitForClaim)))
|
|
111
|
+
: Effect.succeed(claim)));
|
|
112
|
+
return Effect.suspend(waitForClaim);
|
|
113
|
+
}
|
|
114
|
+
function postResult(options, result) {
|
|
115
|
+
return Effect.tryPromise({
|
|
116
|
+
try: (signal) => (options.transport?.fetch ?? globalThis.fetch)(`${options.baseUrl}/browser/results`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(result),
|
|
120
|
+
signal,
|
|
121
|
+
}),
|
|
122
|
+
catch: () => new UiBrowserError("Browser result request failed"),
|
|
123
|
+
}).pipe(Effect.flatMap((response) => response.ok
|
|
124
|
+
? Effect.tryPromise({
|
|
125
|
+
try: () => response.json(),
|
|
126
|
+
catch: () => new UiBrowserError("Browser settlement response is invalid"),
|
|
127
|
+
}).pipe(Effect.flatMap((input) => Effect.try(() => z.record(z.string(), z.unknown()).parse(input)).pipe(Effect.flatMap(decodeSettlementResult))), Effect.mapError(() => new UiBrowserError("Browser settlement response is invalid")), Effect.asVoid)
|
|
128
|
+
: Effect.fail(new UiBrowserError(`Browser result was rejected with status ${String(response.status)}`))));
|
|
129
|
+
}
|
|
130
|
+
function handleRequestEvent(options, event) {
|
|
131
|
+
return requestEventData(event).pipe(Effect.flatMap(decodeRequestEvent), Effect.mapError(() => new UiBrowserError("Browser request payload is invalid")), Effect.flatMap((delivery) => claimRequest(options, delivery).pipe(Effect.flatMap((claim) => claim.status !== "claimed"
|
|
132
|
+
? Effect.void
|
|
133
|
+
: handleBrowserRequest(options, delivery).pipe(Effect.flatMap((result) => postResult(options, {
|
|
134
|
+
...result,
|
|
135
|
+
claimId: claim.claimId,
|
|
136
|
+
})))))));
|
|
137
|
+
}
|
|
138
|
+
/** Run one scope-owned EventSource connection and its Whistle request loop. */
|
|
139
|
+
export function runBrowserConnection(options) {
|
|
140
|
+
return Effect.acquireRelease(Effect.try({
|
|
141
|
+
try: () => {
|
|
142
|
+
const url = `${options.baseUrl}/browser/events`;
|
|
143
|
+
return options.transport === undefined
|
|
144
|
+
? new EventSource(url)
|
|
145
|
+
: options.transport.openEvents(url);
|
|
146
|
+
},
|
|
147
|
+
catch: () => new UiBrowserError("Browser event stream could not open"),
|
|
148
|
+
}), (source) => Effect.sync(() => {
|
|
149
|
+
source.close();
|
|
150
|
+
})).pipe(Effect.flatMap((source) => Stream.fromEventListener(source, "browser-request", {
|
|
151
|
+
bufferSize: 1,
|
|
152
|
+
}).pipe(Stream.mapEffect((event) => handleRequestEvent(options, event)), Stream.runDrain)));
|
|
153
|
+
}
|
|
154
|
+
/** Start the named browser Promise facade over the Effect connection program. */
|
|
155
|
+
export function connectBrowser(options) {
|
|
156
|
+
const cancellation = new AbortController();
|
|
157
|
+
const closed = Effect.runPromise(Effect.scoped(runBrowserConnection(options)), {
|
|
158
|
+
signal: cancellation.signal,
|
|
159
|
+
}).catch((cause) => {
|
|
160
|
+
if (!cancellation.signal.aborted)
|
|
161
|
+
throw new UiBrowserError("Browser connection failed", {
|
|
162
|
+
cause,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
closed,
|
|
167
|
+
close: () => {
|
|
168
|
+
cancellation.abort();
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|