@fungi.computer/knapsack 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/LICENSE.codex +201 -0
- package/README.md +140 -2
- package/dist/files-patch.d.ts +30 -0
- package/dist/files-patch.js +22 -0
- package/dist/files-read-codec.d.ts +10 -0
- package/dist/files-read-codec.js +26 -0
- package/dist/files-read.d.ts +25 -0
- package/dist/files-read.js +43 -0
- package/dist/files.d.ts +170 -0
- package/dist/files.js +513 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +178 -0
- package/dist/mcp.d.ts +54 -0
- package/dist/mcp.js +237 -0
- package/dist/patch-grammar.d.ts +1 -0
- package/dist/patch-grammar.js +3 -0
- package/dist/patch.d.ts +27 -0
- package/dist/patch.js +56 -0
- package/docs/api/README.md +17 -0
- package/docs/api/files-patch.md +72 -0
- package/docs/api/files-read-codec.md +74 -0
- package/docs/api/files-read.md +94 -0
- package/docs/api/index.md +733 -0
- package/docs/api/patch.md +84 -0
- package/package.json +84 -3
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { type Access, type InvocationContext, type LabelledTool, type ModuleRegistration } from "@fungi.computer/mycelium";
|
|
2
|
+
import { patchInputSchema, patchOutputSchema } from "./files-patch.js";
|
|
3
|
+
export { patchInputSchema, patchOutputSchema } from "./files-patch.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { readInputSchema, readOutputSchema } from "./files-read.js";
|
|
6
|
+
export { readInputSchema, readOutputSchema };
|
|
7
|
+
/** Typed refusal from a bounded or conditional Files edit. */
|
|
8
|
+
export type FilesEditErrorReason = "not-found" | "ambiguous" | "file-too-large" | "partial-character" | "unchanged" | "conflict";
|
|
9
|
+
/** Expected Files edit refusal, kept distinct from unexpected host failures. */
|
|
10
|
+
export declare class FilesEditError extends Error {
|
|
11
|
+
readonly reason: FilesEditErrorReason;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
constructor(reason: FilesEditErrorReason, path: string, detail: string);
|
|
14
|
+
}
|
|
15
|
+
/** Bounded directory listing request. */
|
|
16
|
+
export declare const listInputSchema: z.ZodObject<{
|
|
17
|
+
path: z.ZodString;
|
|
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;
|
|
39
|
+
pattern: z.ZodString;
|
|
40
|
+
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;
|
|
51
|
+
pattern: z.ZodString;
|
|
52
|
+
regex: z.ZodDefault<z.ZodBoolean>;
|
|
53
|
+
ignoreCase: z.ZodDefault<z.ZodBoolean>;
|
|
54
|
+
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>;
|
|
66
|
+
/** Text replacement request decoded before the captured Files port is called. */
|
|
67
|
+
export declare const editInputSchema: z.ZodObject<{
|
|
68
|
+
path: z.ZodString;
|
|
69
|
+
oldText: z.ZodString;
|
|
70
|
+
newText: z.ZodString;
|
|
71
|
+
}, z.core.$strict>;
|
|
72
|
+
/** Structured edit refusal, including its reason and affected path. */
|
|
73
|
+
export declare const filesEditFailureSchema: z.ZodObject<{
|
|
74
|
+
status: z.ZodLiteral<"failed">;
|
|
75
|
+
error: z.ZodObject<{
|
|
76
|
+
kind: z.ZodEnum<{
|
|
77
|
+
"not-found": "not-found";
|
|
78
|
+
ambiguous: "ambiguous";
|
|
79
|
+
"file-too-large": "file-too-large";
|
|
80
|
+
"partial-character": "partial-character";
|
|
81
|
+
unchanged: "unchanged";
|
|
82
|
+
conflict: "conflict";
|
|
83
|
+
}>;
|
|
84
|
+
path: z.ZodString;
|
|
85
|
+
message: z.ZodString;
|
|
86
|
+
}, z.core.$strict>;
|
|
87
|
+
}, z.core.$strict>;
|
|
88
|
+
/** Successful edit metadata or a typed edit refusal. */
|
|
89
|
+
export declare const editOutputSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
90
|
+
path: z.ZodString;
|
|
91
|
+
bytes: z.ZodNumber;
|
|
92
|
+
diff: z.ZodString;
|
|
93
|
+
firstChangedLine: z.ZodNullable<z.ZodNumber>;
|
|
94
|
+
unifiedPatch: z.ZodNullable<z.ZodString>;
|
|
95
|
+
unifiedPatchBytes: z.ZodNumber;
|
|
96
|
+
unifiedPatchAddedLines: z.ZodNumber;
|
|
97
|
+
unifiedPatchRemovedLines: z.ZodNumber;
|
|
98
|
+
unifiedPatchTruncated: z.ZodBoolean;
|
|
99
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
100
|
+
status: z.ZodLiteral<"failed">;
|
|
101
|
+
error: z.ZodObject<{
|
|
102
|
+
kind: z.ZodEnum<{
|
|
103
|
+
"not-found": "not-found";
|
|
104
|
+
ambiguous: "ambiguous";
|
|
105
|
+
"file-too-large": "file-too-large";
|
|
106
|
+
"partial-character": "partial-character";
|
|
107
|
+
unchanged: "unchanged";
|
|
108
|
+
conflict: "conflict";
|
|
109
|
+
}>;
|
|
110
|
+
path: z.ZodString;
|
|
111
|
+
message: z.ZodString;
|
|
112
|
+
}, z.core.$strict>;
|
|
113
|
+
}, z.core.$strict>]>;
|
|
114
|
+
/** Host-owned native patch operation receiving the invocation's deadline and signal. */
|
|
115
|
+
export type FilesPatch = (input: z.output<typeof patchInputSchema>, context: InvocationContext) => Promise<z.output<typeof patchOutputSchema>>;
|
|
116
|
+
/** Captured host file authority; the host owns path confinement and atomic replacement. */
|
|
117
|
+
export type FilesPort = Readonly<{
|
|
118
|
+
/** Omitted when this Files host has no qualified native patch engine. */
|
|
119
|
+
patch?: FilesPatch;
|
|
120
|
+
/** Read a bounded text page. */
|
|
121
|
+
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>>;
|
|
128
|
+
/** Replace atomically only if the stored content still matches the baseline. */
|
|
129
|
+
replace: (input: Readonly<{
|
|
130
|
+
path: string;
|
|
131
|
+
expectedContent: string;
|
|
132
|
+
content: string;
|
|
133
|
+
}>, context: InvocationContext) => Promise<Readonly<{
|
|
134
|
+
path: string;
|
|
135
|
+
bytes: number;
|
|
136
|
+
}>>;
|
|
137
|
+
}>;
|
|
138
|
+
/** Closed set of direct, model-visible Files tools; widening one is an ADR. */
|
|
139
|
+
export type FileToolName = "read" | "list" | "glob" | "grep" | "apply_patch";
|
|
140
|
+
declare const fileToolBrand: unique symbol;
|
|
141
|
+
/**
|
|
142
|
+
* A `MuleTool` minted only by this module, named from the closed
|
|
143
|
+
* `FileToolName` set. No host can construct one: the brand has no public
|
|
144
|
+
* constructor, so a host-made `MuleTool` fails typecheck against `files?`.
|
|
145
|
+
*/
|
|
146
|
+
export type FileTool = LabelledTool & Readonly<{
|
|
147
|
+
[fileToolBrand]: true;
|
|
148
|
+
}> & Readonly<{
|
|
149
|
+
descriptor: Readonly<{
|
|
150
|
+
name: FileToolName;
|
|
151
|
+
}>;
|
|
152
|
+
/** Direct tools share the Files module's grant. */
|
|
153
|
+
module: "files";
|
|
154
|
+
access: Access;
|
|
155
|
+
}>;
|
|
156
|
+
/** One Mycelium module and the direct Files tools built from the same port. */
|
|
157
|
+
export type FilesSurface = Readonly<{
|
|
158
|
+
module: ModuleRegistration;
|
|
159
|
+
tools: readonly FileTool[];
|
|
160
|
+
}>;
|
|
161
|
+
/** Host inputs for a captured Files connection. */
|
|
162
|
+
export type FilesConnectionOptions = Readonly<{
|
|
163
|
+
port: FilesPort;
|
|
164
|
+
/** Session-granted root and path convention published by the host owner. */
|
|
165
|
+
pathInstructions?: string;
|
|
166
|
+
}>;
|
|
167
|
+
/** Apply the same edit behavior as the Files edit operation directly through a captured FilesPort. */
|
|
168
|
+
export declare function edit(port: FilesPort, input: z.output<typeof editInputSchema>, context: InvocationContext): Promise<z.output<typeof editOutputSchema>>;
|
|
169
|
+
/** Build both patch presentations around one injected Files operation. */
|
|
170
|
+
export declare function files(input: FilesConnectionOptions): FilesSurface;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { nanoid } from "@fungi.computer/cairn";
|
|
2
|
+
import { Mycelium, } from "@fungi.computer/mycelium";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Stream from "effect/Stream";
|
|
5
|
+
import { patchInputSchema, patchOutputSchema } from "./files-patch.js";
|
|
6
|
+
export { patchInputSchema, patchOutputSchema } from "./files-patch.js";
|
|
7
|
+
import { patchGrammar } from "./patch-grammar.js";
|
|
8
|
+
import { diffLines, formatPatch, structuredPatch } from "diff";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { maximumReadBytes, pathSchema, boundedTextSchema, readInputSchema, readOutputSchema, } from "./files-read.js";
|
|
11
|
+
export { readInputSchema, readOutputSchema };
|
|
12
|
+
const maximumPatternCharacters = 512;
|
|
13
|
+
const maximumResultEntries = 256;
|
|
14
|
+
const maximumReadBytesText = String(maximumReadBytes);
|
|
15
|
+
const maximumUnifiedPatchBytes = 65_536;
|
|
16
|
+
const utf8 = new TextEncoder();
|
|
17
|
+
/** Expected Files edit refusal, kept distinct from unexpected host failures. */
|
|
18
|
+
export class FilesEditError extends Error {
|
|
19
|
+
reason;
|
|
20
|
+
path;
|
|
21
|
+
constructor(reason, path, detail) {
|
|
22
|
+
super(`Files edit ${reason}: ${detail} (${path})`);
|
|
23
|
+
this.reason = reason;
|
|
24
|
+
this.path = path;
|
|
25
|
+
this.name = "FilesEditError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
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),
|
|
72
|
+
regex: z.boolean().default(false),
|
|
73
|
+
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({
|
|
83
|
+
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
|
+
});
|
|
93
|
+
/** Text replacement request decoded before the captured Files port is called. */
|
|
94
|
+
export const editInputSchema = z.strictObject({
|
|
95
|
+
path: pathSchema,
|
|
96
|
+
oldText: boundedTextSchema(1),
|
|
97
|
+
newText: boundedTextSchema(),
|
|
98
|
+
});
|
|
99
|
+
const editSuccessSchema = z.strictObject({
|
|
100
|
+
path: pathSchema,
|
|
101
|
+
bytes: z.number().int().nonnegative(),
|
|
102
|
+
diff: z.string(),
|
|
103
|
+
firstChangedLine: z.number().int().positive().nullable(),
|
|
104
|
+
unifiedPatch: z.string().nullable(),
|
|
105
|
+
unifiedPatchBytes: z.number().int().nonnegative(),
|
|
106
|
+
unifiedPatchAddedLines: z.number().int().nonnegative(),
|
|
107
|
+
unifiedPatchRemovedLines: z.number().int().nonnegative(),
|
|
108
|
+
unifiedPatchTruncated: z.boolean(),
|
|
109
|
+
});
|
|
110
|
+
/** Structured edit refusal, including its reason and affected path. */
|
|
111
|
+
export const filesEditFailureSchema = z.strictObject({
|
|
112
|
+
status: z.literal("failed"),
|
|
113
|
+
error: z.strictObject({
|
|
114
|
+
kind: z.enum([
|
|
115
|
+
"not-found",
|
|
116
|
+
"ambiguous",
|
|
117
|
+
"file-too-large",
|
|
118
|
+
"partial-character",
|
|
119
|
+
"unchanged",
|
|
120
|
+
"conflict",
|
|
121
|
+
]),
|
|
122
|
+
path: pathSchema,
|
|
123
|
+
message: z.string(),
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
/** Successful edit metadata or a typed edit refusal. */
|
|
127
|
+
export const editOutputSchema = z.union([
|
|
128
|
+
editSuccessSchema,
|
|
129
|
+
filesEditFailureSchema,
|
|
130
|
+
]);
|
|
131
|
+
const fileToolBrand = Symbol("KnapsackFileTool");
|
|
132
|
+
/** The one module id that a host's grant for Files names. */
|
|
133
|
+
const filesModuleId = "files";
|
|
134
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
135
|
+
const lineTerminator = /\r\n|\r|\n/gu;
|
|
136
|
+
function stripBom(content) {
|
|
137
|
+
return content.startsWith("\uFEFF")
|
|
138
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
139
|
+
: { bom: "", text: content };
|
|
140
|
+
}
|
|
141
|
+
function detectLineEnding(content) {
|
|
142
|
+
const crlf = content.indexOf("\r\n");
|
|
143
|
+
const lf = content.indexOf("\n");
|
|
144
|
+
return lf === -1 || crlf === -1 ? "\n" : crlf < lf ? "\r\n" : "\n";
|
|
145
|
+
}
|
|
146
|
+
function normalizeToLf(content) {
|
|
147
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
148
|
+
}
|
|
149
|
+
function restoreLineEndings(content, ending) {
|
|
150
|
+
return ending === "\r\n" ? content.replace(/\n/g, "\r\n") : content;
|
|
151
|
+
}
|
|
152
|
+
function foldGrapheme(grapheme) {
|
|
153
|
+
return grapheme
|
|
154
|
+
.normalize("NFKC")
|
|
155
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
156
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
157
|
+
.replace(/[\u2010-\u2015\u2212]/g, "-")
|
|
158
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
159
|
+
}
|
|
160
|
+
/** Fold per grapheme and per line (NFKC, typography, trailing space, LF). */
|
|
161
|
+
function fuzzyView(content) {
|
|
162
|
+
let text = "";
|
|
163
|
+
const starts = [];
|
|
164
|
+
const ends = [];
|
|
165
|
+
const appendLine = (lineStart, lineEnd) => {
|
|
166
|
+
const lineOffset = text.length;
|
|
167
|
+
for (const { segment, index } of graphemes.segment(content.slice(lineStart, lineEnd))) {
|
|
168
|
+
const folded = foldGrapheme(segment);
|
|
169
|
+
starts.push(...Array(folded.length).fill(lineStart + index));
|
|
170
|
+
ends.push(...Array(folded.length).fill(lineStart + index + segment.length));
|
|
171
|
+
text += folded;
|
|
172
|
+
}
|
|
173
|
+
const kept = lineOffset + text.slice(lineOffset).trimEnd().length;
|
|
174
|
+
text = text.slice(0, kept);
|
|
175
|
+
starts.length = kept;
|
|
176
|
+
ends.length = kept;
|
|
177
|
+
};
|
|
178
|
+
let lineStart = 0;
|
|
179
|
+
for (const terminator of content.matchAll(lineTerminator)) {
|
|
180
|
+
appendLine(lineStart, terminator.index);
|
|
181
|
+
text += "\n";
|
|
182
|
+
starts.push(terminator.index);
|
|
183
|
+
lineStart = terminator.index + terminator[0].length;
|
|
184
|
+
ends.push(lineStart);
|
|
185
|
+
}
|
|
186
|
+
appendLine(lineStart, content.length);
|
|
187
|
+
return { text, starts, ends };
|
|
188
|
+
}
|
|
189
|
+
function countOccurrences(haystack, needle) {
|
|
190
|
+
let count = 0;
|
|
191
|
+
for (let index = haystack.indexOf(needle); index !== -1; index = haystack.indexOf(needle, index + 1)) {
|
|
192
|
+
count += 1;
|
|
193
|
+
}
|
|
194
|
+
return count;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Locate one exact match, else one retained-fuzzy match mapped back to its
|
|
198
|
+
* original span. A fuzzy match that begins or ends inside one folded grapheme
|
|
199
|
+
* has no original span and is refused.
|
|
200
|
+
*/
|
|
201
|
+
function findEditSpan(path, content, oldText) {
|
|
202
|
+
const view = fuzzyView(content);
|
|
203
|
+
const needle = fuzzyView(oldText).text;
|
|
204
|
+
const exact = content.indexOf(oldText);
|
|
205
|
+
const fuzzy = needle === "" ? -1 : view.text.indexOf(needle);
|
|
206
|
+
if (exact === -1 && fuzzy === -1) {
|
|
207
|
+
throw new FilesEditError("not-found", path, "Old text was not found; read the current file and include exact nearby lines");
|
|
208
|
+
}
|
|
209
|
+
if (needle !== "" && countOccurrences(view.text, needle) > 1) {
|
|
210
|
+
throw new FilesEditError("ambiguous", path, "Old text matches more than once; include more surrounding context");
|
|
211
|
+
}
|
|
212
|
+
if (exact !== -1)
|
|
213
|
+
return { start: exact, end: exact + oldText.length };
|
|
214
|
+
const last = fuzzy + needle.length - 1;
|
|
215
|
+
const start = view.starts[fuzzy];
|
|
216
|
+
const end = view.ends[last];
|
|
217
|
+
if (start === undefined ||
|
|
218
|
+
end === undefined ||
|
|
219
|
+
view.starts[fuzzy - 1] === start ||
|
|
220
|
+
view.ends[last + 1] === end) {
|
|
221
|
+
throw new FilesEditError("partial-character", path, "Old text matches only part of a character; copy the exact text from the file");
|
|
222
|
+
}
|
|
223
|
+
return { start, end };
|
|
224
|
+
}
|
|
225
|
+
function generateDiffString(oldContent, newContent, context = 4) {
|
|
226
|
+
const parts = diffLines(oldContent, newContent);
|
|
227
|
+
const output = [];
|
|
228
|
+
const width = String(Math.max(oldContent.split("\n").length, newContent.split("\n").length)).length;
|
|
229
|
+
let firstChangedLine;
|
|
230
|
+
let previousWasChange = false;
|
|
231
|
+
let newLine = 1;
|
|
232
|
+
let oldLine = 1;
|
|
233
|
+
function appendContext(lines, nextIsChange) {
|
|
234
|
+
if (!previousWasChange && !nextIsChange)
|
|
235
|
+
return;
|
|
236
|
+
const start = previousWasChange ? 0 : Math.max(0, lines.length - context);
|
|
237
|
+
const end = nextIsChange
|
|
238
|
+
? lines.length
|
|
239
|
+
: Math.min(lines.length, start + context);
|
|
240
|
+
if (start > 0)
|
|
241
|
+
output.push(` ${"".padStart(width)} ...`);
|
|
242
|
+
for (const [offset, line] of lines.slice(start, end).entries()) {
|
|
243
|
+
output.push(` ${String(oldLine + start + offset).padStart(width)} ${line}`);
|
|
244
|
+
}
|
|
245
|
+
if (end < lines.length)
|
|
246
|
+
output.push(` ${"".padStart(width)} ...`);
|
|
247
|
+
}
|
|
248
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
249
|
+
const part = parts[index];
|
|
250
|
+
if (part === undefined)
|
|
251
|
+
continue;
|
|
252
|
+
const lines = part.value.split("\n");
|
|
253
|
+
if (lines.at(-1) === "")
|
|
254
|
+
lines.pop();
|
|
255
|
+
if (part.added || part.removed) {
|
|
256
|
+
firstChangedLine ??= newLine;
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
if (part.added) {
|
|
259
|
+
output.push(`+${String(newLine).padStart(width)} ${line}`);
|
|
260
|
+
newLine += 1;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
output.push(`-${String(oldLine).padStart(width)} ${line}`);
|
|
264
|
+
oldLine += 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
previousWasChange = true;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const following = parts[index + 1];
|
|
271
|
+
appendContext(lines, following?.added === true || following?.removed === true);
|
|
272
|
+
oldLine += lines.length;
|
|
273
|
+
newLine += lines.length;
|
|
274
|
+
previousWasChange = false;
|
|
275
|
+
}
|
|
276
|
+
return { diff: output.join("\n"), firstChangedLine };
|
|
277
|
+
}
|
|
278
|
+
async function applyEdit(port, input, context) {
|
|
279
|
+
const current = readOutputSchema.parse(await port.read({
|
|
280
|
+
path: input.path,
|
|
281
|
+
offset: 0,
|
|
282
|
+
limit: maximumReadBytes,
|
|
283
|
+
}, context));
|
|
284
|
+
if (current.truncated) {
|
|
285
|
+
throw new FilesEditError("file-too-large", input.path, `File exceeds ${maximumReadBytesText} bytes; use files.patch for a small change`);
|
|
286
|
+
}
|
|
287
|
+
const { bom, text: original } = stripBom(current.content);
|
|
288
|
+
const span = findEditSpan(input.path, original, input.oldText);
|
|
289
|
+
const result = original.slice(0, span.start) +
|
|
290
|
+
restoreLineEndings(normalizeToLf(input.newText), detectLineEnding(original)) +
|
|
291
|
+
original.slice(span.end);
|
|
292
|
+
if (original === result)
|
|
293
|
+
throw new FilesEditError("unchanged", input.path, "Replacement makes no change");
|
|
294
|
+
const replacementContent = bom + result;
|
|
295
|
+
if (utf8.encode(replacementContent).byteLength > maximumReadBytes) {
|
|
296
|
+
throw new FilesEditError("file-too-large", input.path, `Result exceeds ${maximumReadBytesText} bytes; use files.patch for a small change`);
|
|
297
|
+
}
|
|
298
|
+
const written = await port.replace({
|
|
299
|
+
path: input.path,
|
|
300
|
+
expectedContent: current.content,
|
|
301
|
+
content: replacementContent,
|
|
302
|
+
}, context);
|
|
303
|
+
// Only the matched span changed; line terminators are presentation here.
|
|
304
|
+
const details = generateDiffString(normalizeToLf(original), normalizeToLf(result));
|
|
305
|
+
const patch = structuredPatch(input.path, input.path, normalizeToLf(original), normalizeToLf(result), "", "", { context: 3 });
|
|
306
|
+
const unified = formatPatch(patch);
|
|
307
|
+
const unifiedPatchBytes = utf8.encode(unified).byteLength;
|
|
308
|
+
const unifiedPatchTruncated = unifiedPatchBytes > maximumUnifiedPatchBytes;
|
|
309
|
+
const lines = patch.hunks.flatMap((hunk) => hunk.lines);
|
|
310
|
+
return {
|
|
311
|
+
path: input.path,
|
|
312
|
+
bytes: written.bytes,
|
|
313
|
+
diff: details.diff,
|
|
314
|
+
firstChangedLine: details.firstChangedLine ?? null,
|
|
315
|
+
unifiedPatch: unifiedPatchTruncated ? null : unified,
|
|
316
|
+
unifiedPatchBytes,
|
|
317
|
+
unifiedPatchAddedLines: lines.filter((line) => line.startsWith("+")).length,
|
|
318
|
+
unifiedPatchRemovedLines: lines.filter((line) => line.startsWith("-"))
|
|
319
|
+
.length,
|
|
320
|
+
unifiedPatchTruncated,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Apply the same edit behavior as the Files edit operation directly through a captured FilesPort. */
|
|
324
|
+
export async function edit(port, input, context) {
|
|
325
|
+
try {
|
|
326
|
+
return await applyEdit(port, input, context);
|
|
327
|
+
}
|
|
328
|
+
catch (cause) {
|
|
329
|
+
if (!(cause instanceof FilesEditError))
|
|
330
|
+
throw cause;
|
|
331
|
+
return {
|
|
332
|
+
status: "failed",
|
|
333
|
+
error: {
|
|
334
|
+
kind: cause.reason,
|
|
335
|
+
path: cause.path,
|
|
336
|
+
message: cause.message,
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Cancellation does not finish until the Files owner has acknowledged cleanup. */
|
|
342
|
+
function runFileOperation(invoke) {
|
|
343
|
+
return Effect.callback((resume, signal) => {
|
|
344
|
+
const pending = invoke({ executionId: nanoid(), deadlineAt: null, signal });
|
|
345
|
+
void pending.then((result) => {
|
|
346
|
+
resume(Effect.succeed(result));
|
|
347
|
+
}, (cause) => {
|
|
348
|
+
resume(Effect.fail({
|
|
349
|
+
_tag: "MuleError",
|
|
350
|
+
phase: "tool",
|
|
351
|
+
message: cause instanceof Error ? cause.message : "Files operation failed",
|
|
352
|
+
}));
|
|
353
|
+
});
|
|
354
|
+
return Effect.promise(() => pending.then(() => undefined, () => undefined));
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function runPatch(invoke, input) {
|
|
358
|
+
return runFileOperation((context) => invoke(input, context));
|
|
359
|
+
}
|
|
360
|
+
/** Direct presentation of the same validated Files operation. */
|
|
361
|
+
function patchTool(invokePatch) {
|
|
362
|
+
const tool = {
|
|
363
|
+
[fileToolBrand]: true,
|
|
364
|
+
module: filesModuleId,
|
|
365
|
+
access: "write",
|
|
366
|
+
replay: "unsafe",
|
|
367
|
+
descriptor: {
|
|
368
|
+
name: "apply_patch",
|
|
369
|
+
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
|
+
// Model-facing parameters derived from the one Zod decoder `prepare` uses.
|
|
371
|
+
parameters: z.toJSONSchema(patchInputSchema),
|
|
372
|
+
constrainedSampling: {
|
|
373
|
+
type: "grammar",
|
|
374
|
+
variants: { openai_lark: patchGrammar },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
executionMode: "sequential",
|
|
378
|
+
prepare: (call) => Effect.try({
|
|
379
|
+
try: () => patchInputSchema.parse(call.arguments),
|
|
380
|
+
catch: (cause) => ({
|
|
381
|
+
message: cause instanceof Error ? cause.message : "Invalid patch input",
|
|
382
|
+
}),
|
|
383
|
+
}).pipe(Effect.map((value) => ({
|
|
384
|
+
execute: () => Stream.fromEffect(runPatch(invokePatch, value).pipe(Effect.map((result) => ({
|
|
385
|
+
type: "result",
|
|
386
|
+
result: {
|
|
387
|
+
content: [
|
|
388
|
+
{
|
|
389
|
+
type: "text",
|
|
390
|
+
text: JSON.stringify(result),
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
isError: result.status !== "completed",
|
|
394
|
+
},
|
|
395
|
+
})))),
|
|
396
|
+
}))),
|
|
397
|
+
};
|
|
398
|
+
return tool;
|
|
399
|
+
}
|
|
400
|
+
/** Present the very same Files operation and decoder used behind execute. */
|
|
401
|
+
function readOnlyTool(name, description, inputSchema, outputSchema, invoke) {
|
|
402
|
+
const readOnly = {
|
|
403
|
+
[fileToolBrand]: true,
|
|
404
|
+
module: filesModuleId,
|
|
405
|
+
access: "read",
|
|
406
|
+
replay: "safe",
|
|
407
|
+
descriptor: {
|
|
408
|
+
name,
|
|
409
|
+
description,
|
|
410
|
+
parameters: z.toJSONSchema(inputSchema),
|
|
411
|
+
},
|
|
412
|
+
executionMode: "sequential",
|
|
413
|
+
prepare: (call) => Effect.try({
|
|
414
|
+
try: () => inputSchema.parse(call.arguments),
|
|
415
|
+
catch: (cause) => ({
|
|
416
|
+
message: cause instanceof Error ? cause.message : "Invalid Files input",
|
|
417
|
+
}),
|
|
418
|
+
}).pipe(Effect.map((value) => ({
|
|
419
|
+
execute: () => Stream.fromEffect(runFileOperation(async (context) => outputSchema.parse(await invoke(value, context))).pipe(Effect.map((result) => ({
|
|
420
|
+
type: "result",
|
|
421
|
+
result: {
|
|
422
|
+
content: [
|
|
423
|
+
{
|
|
424
|
+
type: "text",
|
|
425
|
+
text: JSON.stringify(result),
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
isError: false,
|
|
429
|
+
},
|
|
430
|
+
})))),
|
|
431
|
+
}))),
|
|
432
|
+
};
|
|
433
|
+
return readOnly;
|
|
434
|
+
}
|
|
435
|
+
/** Build both patch presentations around one injected Files operation. */
|
|
436
|
+
export function files(input) {
|
|
437
|
+
const describe = (description) => input.pathInstructions === undefined
|
|
438
|
+
? description
|
|
439
|
+
: `${description} ${input.pathInstructions}`;
|
|
440
|
+
const patch = input.port.patch;
|
|
441
|
+
const invokePatch = patch === undefined
|
|
442
|
+
? undefined
|
|
443
|
+
: async (value, context) => patchOutputSchema.parse(await patch(patchInputSchema.parse(value), context));
|
|
444
|
+
const operations = {
|
|
445
|
+
read: Mycelium.operation({
|
|
446
|
+
replay: "safe",
|
|
447
|
+
access: "read",
|
|
448
|
+
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),
|
|
452
|
+
}),
|
|
453
|
+
list: Mycelium.operation({
|
|
454
|
+
replay: "safe",
|
|
455
|
+
access: "read",
|
|
456
|
+
input: listInputSchema,
|
|
457
|
+
output: listOutputSchema,
|
|
458
|
+
description: describe("List one bounded directory level."),
|
|
459
|
+
execute: async (operationInput, context) => input.port.list(operationInput, context),
|
|
460
|
+
}),
|
|
461
|
+
glob: Mycelium.operation({
|
|
462
|
+
replay: "safe",
|
|
463
|
+
access: "read",
|
|
464
|
+
input: globInputSchema,
|
|
465
|
+
output: globOutputSchema,
|
|
466
|
+
description: describe("Find bounded paths by pattern."),
|
|
467
|
+
execute: async (operationInput, context) => input.port.glob(operationInput, context),
|
|
468
|
+
}),
|
|
469
|
+
grep: Mycelium.operation({
|
|
470
|
+
replay: "safe",
|
|
471
|
+
access: "read",
|
|
472
|
+
input: grepInputSchema,
|
|
473
|
+
output: grepOutputSchema,
|
|
474
|
+
description: describe("Search bounded file content."),
|
|
475
|
+
execute: async (operationInput, context) => input.port.grep(operationInput, context),
|
|
476
|
+
}),
|
|
477
|
+
edit: Mycelium.operation({
|
|
478
|
+
replay: "unsafe",
|
|
479
|
+
access: "write",
|
|
480
|
+
input: editInputSchema,
|
|
481
|
+
output: editOutputSchema,
|
|
482
|
+
description: describe("Replace one exact or retained-fuzzy text match."),
|
|
483
|
+
execute: async (operationInput, context) => edit(input.port, operationInput, context),
|
|
484
|
+
}),
|
|
485
|
+
};
|
|
486
|
+
const patchOperation = invokePatch === undefined
|
|
487
|
+
? undefined
|
|
488
|
+
: Mycelium.operation({
|
|
489
|
+
replay: "unsafe",
|
|
490
|
+
access: "write",
|
|
491
|
+
input: patchInputSchema,
|
|
492
|
+
output: patchOutputSchema,
|
|
493
|
+
description: describe("Apply a bounded Codex-format patch through the same Files owner as apply_patch."),
|
|
494
|
+
execute: invokePatch,
|
|
495
|
+
});
|
|
496
|
+
return Object.freeze({
|
|
497
|
+
tools: Object.freeze([
|
|
498
|
+
...(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),
|
|
503
|
+
]),
|
|
504
|
+
module: Mycelium.module({
|
|
505
|
+
id: filesModuleId,
|
|
506
|
+
name: "files",
|
|
507
|
+
description: "Bounded file operations over one authorized target.",
|
|
508
|
+
operations: patchOperation === undefined
|
|
509
|
+
? operations
|
|
510
|
+
: { ...operations, patch: patchOperation },
|
|
511
|
+
}),
|
|
512
|
+
});
|
|
513
|
+
}
|