@xandreed/plugin-workspace 0.0.0-stage → 0.14.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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/bash-tool.adapter.d.ts +15 -0
- package/dist/bash-tool.adapter.js +72 -0
- package/dist/bash.adapter.d.ts +29 -0
- package/dist/bash.adapter.js +69 -0
- package/dist/bash.adapter.test.d.ts +1 -0
- package/dist/file-system.adapter.d.ts +20 -0
- package/dist/file-system.adapter.js +52 -0
- package/dist/file-system.adapter.test.d.ts +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +35 -0
- package/dist/local-system.entity.d.ts +40 -0
- package/dist/local-system.entity.functions.d.ts +52 -0
- package/dist/local-system.entity.functions.js +32 -0
- package/dist/local-system.entity.functions.test.d.ts +1 -0
- package/dist/local-system.entity.js +11 -0
- package/dist/plugin.adapter.d.ts +120 -0
- package/dist/plugin.adapter.js +27 -0
- package/dist/plugin.adapter.test.d.ts +1 -0
- package/dist/read-tools.adapter.d.ts +33 -0
- package/dist/read-tools.adapter.js +100 -0
- package/dist/skills/skills-directory.adapter.d.ts +62 -0
- package/dist/skills/skills-directory.adapter.js +30 -0
- package/dist/skills/skills-directory.adapter.test.d.ts +1 -0
- package/dist/skills/skills-directory.entity.d.ts +96 -0
- package/dist/skills/skills-directory.entity.functions.d.ts +33 -0
- package/dist/skills/skills-directory.entity.functions.js +45 -0
- package/dist/skills/skills-directory.entity.functions.test.d.ts +1 -0
- package/dist/skills/skills-directory.entity.js +35 -0
- package/dist/text-edit.entity.d.ts +32 -0
- package/dist/text-edit.entity.functions.d.ts +32 -0
- package/dist/text-edit.entity.functions.js +68 -0
- package/dist/text-edit.entity.functions.test.d.ts +1 -0
- package/dist/text-edit.entity.js +15 -0
- package/dist/text-search.entity.d.ts +23 -0
- package/dist/text-search.entity.functions.d.ts +51 -0
- package/dist/text-search.entity.functions.js +30 -0
- package/dist/text-search.entity.functions.test.d.ts +1 -0
- package/dist/text-search.entity.js +10 -0
- package/dist/text-window.entity.d.ts +46 -0
- package/dist/text-window.entity.functions.d.ts +54 -0
- package/dist/text-window.entity.functions.js +88 -0
- package/dist/text-window.entity.functions.test.d.ts +1 -0
- package/dist/text-window.entity.js +21 -0
- package/dist/workspace-path.entity.d.ts +22 -0
- package/dist/workspace-path.entity.functions.d.ts +60 -0
- package/dist/workspace-path.entity.functions.js +63 -0
- package/dist/workspace-path.entity.functions.test.d.ts +1 -0
- package/dist/workspace-path.entity.js +8 -0
- package/dist/workspace-tools.adapter.d.ts +21 -0
- package/dist/workspace-tools.adapter.js +46 -0
- package/dist/workspace-tools.adapter.test.d.ts +1 -0
- package/dist/workspace-tools.entity.d.ts +433 -0
- package/dist/workspace-tools.entity.functions.d.ts +51 -0
- package/dist/workspace-tools.entity.functions.js +124 -0
- package/dist/workspace-tools.entity.functions.test.d.ts +1 -0
- package/dist/workspace-tools.entity.js +133 -0
- package/dist/workspace-tools.journey.test.d.ts +1 -0
- package/dist/write-tools.adapter.d.ts +12 -0
- package/dist/write-tools.adapter.js +77 -0
- package/package.json +29 -4
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { Bash, Capabilities, definePlugin, FileSystem, SessionEnvironment } from "@xandreed/core";
|
|
3
|
+
import { BashLive } from "./bash.adapter.js";
|
|
4
|
+
import { FileSystemLive } from "./file-system.adapter.js";
|
|
5
|
+
import { LocalSystemConfig, localSystemDefaults } from "./local-system.entity.js";
|
|
6
|
+
import { workspaceTools } from "./workspace-tools.adapter.js";
|
|
7
|
+
import { WorkspaceToolsConfig, workspaceToolsDefaults } from "./workspace-tools.entity.js";
|
|
8
|
+
export const localSystemPlugin = definePlugin({
|
|
9
|
+
id: "@xandreed/plugin-workspace/local",
|
|
10
|
+
version: "0.14.0",
|
|
11
|
+
scope: "runtime",
|
|
12
|
+
config: LocalSystemConfig,
|
|
13
|
+
defaults: localSystemDefaults,
|
|
14
|
+
provides: [FileSystem, Bash],
|
|
15
|
+
layer: (config) => Layer.merge(FileSystemLive, BashLive(config))
|
|
16
|
+
}), LocalSystemPluginLive = localSystemPlugin.live, workspaceToolsPlugin = definePlugin({
|
|
17
|
+
id: "@xandreed/plugin-workspace/tools",
|
|
18
|
+
version: "0.14.0",
|
|
19
|
+
scope: "runtime",
|
|
20
|
+
config: WorkspaceToolsConfig,
|
|
21
|
+
defaults: workspaceToolsDefaults,
|
|
22
|
+
requires: [FileSystem, SessionEnvironment],
|
|
23
|
+
optional: [Bash],
|
|
24
|
+
provides: [],
|
|
25
|
+
contributes: [Capabilities],
|
|
26
|
+
layer: (config) => Layer.effect(Capabilities, workspaceTools(config).pipe(Effect.map((capability) => [capability])))
|
|
27
|
+
}), WorkspaceToolsPluginLive = workspaceToolsPlugin.live;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Path, RcMap, Semaphore } from "effect";
|
|
2
|
+
import { Failure } from "@xandreed/core";
|
|
3
|
+
import type { RegisteredTool, Subject } from "@xandreed/core";
|
|
4
|
+
import type { ProtectRule } from "./workspace-path.entity.functions.js";
|
|
5
|
+
import type { WorkspaceToolsConfig } from "./workspace-tools.entity.js";
|
|
6
|
+
/** What every workspace tool is built with: the configuration, the path functions, the compiled `protect` globs and the per-file write locks. */
|
|
7
|
+
export interface WorkspaceToolContext {
|
|
8
|
+
readonly config: WorkspaceToolsConfig;
|
|
9
|
+
readonly path: Path.Path;
|
|
10
|
+
readonly protect: ReadonlyArray<ProtectRule>;
|
|
11
|
+
readonly locks: RcMap.RcMap<string, Semaphore.Semaphore>;
|
|
12
|
+
}
|
|
13
|
+
export declare const failure: (error: string, message: string) => Failure;
|
|
14
|
+
/** The refusal of a path that is neither a regular file nor a directory: reading a pipe, socket or device could wait forever. */
|
|
15
|
+
export declare const notAFile: (display: string, tool: string) => Failure;
|
|
16
|
+
/** A file the result names, for memory to keep track of; none for a name memory could not store as an id. */
|
|
17
|
+
export declare const fileSubjects: (path: string) => ReadonlyArray<Subject>;
|
|
18
|
+
/** `read`: a window of a text file's lines, an image (`readImages`), or what a binary file is. */
|
|
19
|
+
export declare const readTool: ({ config, path }: WorkspaceToolContext) => RegisteredTool;
|
|
20
|
+
/** `ls`: a directory's entries by name, at most `lsEntries` of them. */
|
|
21
|
+
export declare const lsTool: ({ config, path }: WorkspaceToolContext) => RegisteredTool;
|
|
22
|
+
/** `find`: the files under a directory whose path matches a glob, as paths relative to the workspace root. */
|
|
23
|
+
export declare const findTool: ({ config, path }: WorkspaceToolContext) => RegisteredTool;
|
|
24
|
+
/**
|
|
25
|
+
* `grep`: the lines of the files under a path (or of one file) that match.
|
|
26
|
+
* Files come from a walk that never enters links or excluded names; each is
|
|
27
|
+
* resolved again before it is read and skipped when it leads outside the
|
|
28
|
+
* workspace, is not a regular file, is larger than `maxFileBytes` or is
|
|
29
|
+
* binary. Lines are cut to `grepLineChars` before matching. Searching a
|
|
30
|
+
* directory, one file gives at most a quarter of the limit, so one large
|
|
31
|
+
* file cannot fill the result.
|
|
32
|
+
*/
|
|
33
|
+
export declare const grepTool: ({ config, path }: WorkspaceToolContext) => RegisteredTool;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Tool } from "effect/ai";
|
|
2
|
+
import { Effect, Option, Result } from "effect";
|
|
3
|
+
import { defineTool, Failure } from "@xandreed/core";
|
|
4
|
+
import { compilePattern, searchText } from "./text-search.entity.functions.js";
|
|
5
|
+
import { decodeText, formatBytes, lineWindow, looksBinary } from "./text-window.entity.functions.js";
|
|
6
|
+
import { displayOf, excludesBelow, fsFailure, isWithin, openWorkspace, resolveTarget } from "./workspace-path.entity.functions.js";
|
|
7
|
+
import { FindParams, FindResult, GrepParams, GrepResult, LsParams, LsResult, ReadParams, ReadResult } from "./workspace-tools.entity.js";
|
|
8
|
+
import { binaryRead, compactFind, fileTooLargeHint, compactGrep, compactLs, compactRead, readMedia, renderFind, renderGrep, renderLs, renderRead, toolDescriptions } from "./workspace-tools.entity.functions.js";
|
|
9
|
+
export const failure = (error, message) => ({ error, message }), notAFile = (display, tool) => failure("NotAFile", `${display} is not a regular file (a pipe, socket or device); ${tool} works on regular files only, use bash for it`);
|
|
10
|
+
const walkExcludes = (access, target, excludes) => excludesBelow(excludes, isWithin(access.root, target.path) ? Option.some(access.path.relative(access.root, target.path)) : Option.none());
|
|
11
|
+
export const fileSubjects = (path) => path.length > 0 && path.trim() === path ? [{ kind: "file", id: path, label: Option.none(), data: Option.none() }] : [];
|
|
12
|
+
const ReadTool = (description) => Tool.make("read", { description, parameters: ReadParams, success: ReadResult, failure: Failure, failureMode: "return" }), LsTool = (description) => Tool.make("ls", { description, parameters: LsParams, success: LsResult, failure: Failure, failureMode: "return" }), FindTool = (description) => Tool.make("find", { description, parameters: FindParams, success: FindResult, failure: Failure, failureMode: "return" }), GrepTool = (description) => Tool.make("grep", { description, parameters: GrepParams, success: GrepResult, failure: Failure, failureMode: "return" });
|
|
13
|
+
export const readTool = ({ config, path }) => defineTool({
|
|
14
|
+
tool: ReadTool(toolDescriptions(config).read),
|
|
15
|
+
handler: (params) => Effect.gen(function* () {
|
|
16
|
+
const access = yield* openWorkspace(path), target = yield* resolveTarget(access, params.path, { confine: config.confine }), info = yield* access.fs.stat(target.path).pipe(Effect.mapError(fsFailure(target.display)));
|
|
17
|
+
if (info.kind === "directory")
|
|
18
|
+
return yield* Effect.fail(failure("IsDirectory", `${target.display} is a directory; use ls to list it`));
|
|
19
|
+
if (info.kind !== "file")
|
|
20
|
+
return yield* Effect.fail(notAFile(target.display, "read"));
|
|
21
|
+
if (info.size > config.maxFileBytes)
|
|
22
|
+
return yield* Effect.fail(failure("FileTooLarge", `${target.display} is ${formatBytes(info.size)}, more than the ${formatBytes(config.maxFileBytes)} read loads; ${fileTooLargeHint(target.display, config.readImages)}`));
|
|
23
|
+
const bytes = yield* access.fs.readBytes(target.path).pipe(Effect.mapError(fsFailure(target.display)));
|
|
24
|
+
if (looksBinary(bytes))
|
|
25
|
+
return yield* binaryRead(target.display, bytes, config.readImages);
|
|
26
|
+
const decoded = decodeText(bytes), window = lineWindow(decoded.text, { offset: params.offset ?? 1, limit: Math.min(params.limit ?? config.maxLines, config.maxLines), maxBytes: config.maxBytes });
|
|
27
|
+
return yield* Result.match(window, {
|
|
28
|
+
onFailure: (total) => Effect.fail(failure("OffsetPastEnd", `${target.display} has ${total} line${total === 1 ? "" : "s"}; offset must be at most ${Math.max(1, total)}`)),
|
|
29
|
+
onSuccess: (lines) => Effect.succeed({
|
|
30
|
+
_tag: "Text",
|
|
31
|
+
path: target.display,
|
|
32
|
+
text: lines.lines.join(`
|
|
33
|
+
`),
|
|
34
|
+
firstLine: lines.firstLine,
|
|
35
|
+
lastLine: lines.lastLine,
|
|
36
|
+
totalLines: lines.totalLines,
|
|
37
|
+
next: Option.getOrNull(lines.next),
|
|
38
|
+
clipped: lines.clipped,
|
|
39
|
+
lossy: decoded.lossy
|
|
40
|
+
})
|
|
41
|
+
});
|
|
42
|
+
}),
|
|
43
|
+
view: { version: "1", render: renderRead, compact: compactRead, subjects: (result) => fileSubjects(result.path), media: readMedia },
|
|
44
|
+
annotations: { readOnly: !0 }
|
|
45
|
+
}), lsTool = ({ config, path }) => defineTool({
|
|
46
|
+
tool: LsTool(toolDescriptions(config).ls),
|
|
47
|
+
handler: (params) => Effect.gen(function* () {
|
|
48
|
+
const access = yield* openWorkspace(path), target = yield* resolveTarget(access, params.path ?? ".", { confine: config.confine }), entries = yield* access.fs.list(target.path).pipe(Effect.mapError((error) => error.reason === "NotDirectory" ? failure("NotDirectory", `${target.display} is not a directory; use read to see a file`) : fsFailure(target.display)(error))), limit = Math.min(params.limit ?? config.lsEntries, config.lsEntries);
|
|
49
|
+
return { path: target.display, entries: entries.slice(0, limit).map((entry) => ({ name: entry.name, kind: entry.kind })), total: entries.length };
|
|
50
|
+
}),
|
|
51
|
+
view: { version: "1", render: renderLs, compact: compactLs },
|
|
52
|
+
annotations: { readOnly: !0 }
|
|
53
|
+
}), findTool = ({ config, path }) => defineTool({
|
|
54
|
+
tool: FindTool(toolDescriptions(config).find),
|
|
55
|
+
handler: (params) => Effect.gen(function* () {
|
|
56
|
+
const access = yield* openWorkspace(path), target = yield* resolveTarget(access, params.path ?? ".", { confine: config.confine }), limit = Math.min(params.limit ?? config.findResults, config.findResults), found = yield* access.fs.glob(params.pattern, { cwd: target.path, exclude: walkExcludes(access, target, config.exclude), dot: !0, onlyFiles: !0, limit }).pipe(Effect.mapError((error) => error.reason === "Invalid" ? failure("InvalidPattern", `${params.pattern}: ${error.message}`) : fsFailure(target.display)(error)));
|
|
57
|
+
return { pattern: params.pattern, path: target.display, paths: found.paths.map((relative) => displayOf(access, access.path.join(target.path, relative))), truncated: found.truncated };
|
|
58
|
+
}),
|
|
59
|
+
view: { version: "1", render: (result) => renderFind(result, config.maxLines, config.maxBytes), compact: compactFind },
|
|
60
|
+
annotations: { readOnly: !0 }
|
|
61
|
+
}), grepTool = ({ config, path }) => defineTool({
|
|
62
|
+
tool: GrepTool(toolDescriptions(config).grep),
|
|
63
|
+
handler: (params) => Effect.gen(function* () {
|
|
64
|
+
const access = yield* openWorkspace(path), target = yield* resolveTarget(access, params.path ?? ".", { confine: config.confine }), pattern = yield* Effect.fromResult(compilePattern(params.pattern, { literal: params.literal ?? !1, ignoreCase: params.ignoreCase ?? !1 })), info = yield* access.fs.stat(target.path).pipe(Effect.mapError(fsFailure(target.display)));
|
|
65
|
+
if (info.kind !== "file" && info.kind !== "directory")
|
|
66
|
+
return yield* Effect.fail(notAFile(target.display, "grep"));
|
|
67
|
+
const glob = params.glob === void 0 ? "**/*" : params.glob.includes("/") ? params.glob : `**/${params.glob}`, files = info.kind === "file" ? [{ path: target.path, display: target.display }] : yield* access.fs.glob(glob, { cwd: target.path, exclude: walkExcludes(access, target, config.exclude), dot: !0, onlyFiles: !0 }).pipe(Effect.map((found) => found.paths.map((relative) => {
|
|
68
|
+
const absolute = access.path.join(target.path, relative);
|
|
69
|
+
return { path: absolute, display: displayOf(access, absolute) };
|
|
70
|
+
})), Effect.mapError((error) => error.reason === "Invalid" ? failure("InvalidPattern", `glob ${glob}: ${error.message}`) : fsFailure(target.display)(error))), limit = Math.min(params.limit ?? config.grepMatches, config.grepMatches), perFile = files.length > 1 ? Math.max(1, Math.ceil(limit / 4)) : limit, context = params.context ?? 0, skip = (state) => ({ ...state, skipped: state.skipped + 1 }), searched = yield* Effect.reduce(files, () => ({ lines: [], matches: 0, files: 0, skipped: 0, truncated: !1 }), (state, file) => {
|
|
71
|
+
if (state.matches >= limit)
|
|
72
|
+
return Effect.succeed({ ...state, truncated: !0 });
|
|
73
|
+
return Effect.gen(function* () {
|
|
74
|
+
const real = yield* access.fs.realPath(file.path);
|
|
75
|
+
if (config.confine && !isWithin(access.root, real))
|
|
76
|
+
return Option.none();
|
|
77
|
+
const found = yield* access.fs.stat(real);
|
|
78
|
+
if (found.kind !== "file" || found.size > config.maxFileBytes)
|
|
79
|
+
return Option.none();
|
|
80
|
+
const bytes = yield* access.fs.readBytes(real);
|
|
81
|
+
return looksBinary(bytes) ? Option.none() : Option.some(bytes);
|
|
82
|
+
}).pipe(Effect.option, Effect.map((bytes) => Option.match(Option.flatten(bytes), {
|
|
83
|
+
onNone: () => skip(state),
|
|
84
|
+
onSome: (content) => {
|
|
85
|
+
const found = searchText(decodeText(content).text, pattern, { lineChars: config.grepLineChars, context, cap: Math.min(perFile, limit - state.matches) });
|
|
86
|
+
return {
|
|
87
|
+
lines: [...state.lines, ...found.lines.map((line) => ({ path: file.display, ...line }))],
|
|
88
|
+
matches: state.matches + found.matches,
|
|
89
|
+
files: state.files + (found.matches > 0 ? 1 : 0),
|
|
90
|
+
skipped: state.skipped,
|
|
91
|
+
truncated: state.truncated || found.capped
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
})));
|
|
95
|
+
});
|
|
96
|
+
return { pattern: params.pattern, path: target.display, ...searched };
|
|
97
|
+
}),
|
|
98
|
+
view: { version: "1", render: (result, params) => renderGrep(result, (params.context ?? 0) > 0, config.maxLines, config.maxBytes), compact: compactGrep },
|
|
99
|
+
annotations: { readOnly: !0 }
|
|
100
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { Capabilities, FileSystem, HarnessError } from "@xandreed/core";
|
|
3
|
+
import type { SkillDefinition } from "@xandreed/core";
|
|
4
|
+
import { SkillsDirectoryConfig } from "./skills-directory.entity.js";
|
|
5
|
+
/**
|
|
6
|
+
* The skills in `config.roots`, each resolved against `workspace` (an
|
|
7
|
+
* absolute directory) unless absolute, read through the `FileSystem` port:
|
|
8
|
+
* the ids prefixed, the first root winning a clash, issues failing
|
|
9
|
+
* (`strict`) or logged, and the limits checked. What `skillsDirectoryPlugin`
|
|
10
|
+
* contributes; a host that composes capabilities itself can call it.
|
|
11
|
+
*/
|
|
12
|
+
export declare const loadSkillsDirectory: (config: SkillsDirectoryConfig, workspace: string) => Effect.Effect<ReadonlyArray<SkillDefinition>, HarnessError, FileSystem>;
|
|
13
|
+
/**
|
|
14
|
+
* Skills from directories of `SKILL.md` files (the Agent Skills layout,
|
|
15
|
+
* `.agents/skills/<name>/SKILL.md` by default), read once per agent through
|
|
16
|
+
* the `FileSystem` port and contributed as one capability. Tool discovery
|
|
17
|
+
* lists them in its catalogue, `load_skill` returns a skill's instructions
|
|
18
|
+
* and `read_skill_reference` the other files of its directory. A skill id
|
|
19
|
+
* the host or another plugin also defines fails `Agent.define` with
|
|
20
|
+
* `skills.duplicate`: set `idPrefix`. Relative roots resolve against the
|
|
21
|
+
* agent's workspace (`SessionEnvironment`), else the process's working
|
|
22
|
+
* directory.
|
|
23
|
+
*/
|
|
24
|
+
export declare const skillsDirectoryPlugin: import("@xandreed/core").TypedPlugin<{
|
|
25
|
+
readonly roots: readonly string[];
|
|
26
|
+
readonly required: boolean;
|
|
27
|
+
readonly strict: boolean;
|
|
28
|
+
readonly tools: "ignore" | "declared";
|
|
29
|
+
readonly idPrefix: string;
|
|
30
|
+
readonly maxReferenceBytes: number;
|
|
31
|
+
readonly maxSkills: number;
|
|
32
|
+
readonly maxFiles: number;
|
|
33
|
+
readonly maxBytes: number;
|
|
34
|
+
readonly alwaysSection: boolean;
|
|
35
|
+
readonly sectionOrder: number;
|
|
36
|
+
}, {
|
|
37
|
+
readonly roots: readonly string[];
|
|
38
|
+
readonly required: boolean;
|
|
39
|
+
readonly strict: boolean;
|
|
40
|
+
readonly tools: "ignore" | "declared";
|
|
41
|
+
readonly idPrefix: string;
|
|
42
|
+
readonly maxReferenceBytes: number;
|
|
43
|
+
readonly maxSkills: number;
|
|
44
|
+
readonly maxFiles: number;
|
|
45
|
+
readonly maxBytes: number;
|
|
46
|
+
readonly alwaysSection: boolean;
|
|
47
|
+
readonly sectionOrder: number;
|
|
48
|
+
}, Capabilities, HarnessError, FileSystem>;
|
|
49
|
+
/** The skills directory as a typed layer: contributes `Capabilities`; requires `FileSystem` (and reads `SessionEnvironment` when present). */
|
|
50
|
+
export declare const SkillsDirectoryPluginLive: (options?: Partial<{
|
|
51
|
+
readonly roots: readonly string[];
|
|
52
|
+
readonly required: boolean;
|
|
53
|
+
readonly strict: boolean;
|
|
54
|
+
readonly tools: "ignore" | "declared";
|
|
55
|
+
readonly idPrefix: string;
|
|
56
|
+
readonly maxReferenceBytes: number;
|
|
57
|
+
readonly maxSkills: number;
|
|
58
|
+
readonly maxFiles: number;
|
|
59
|
+
readonly maxBytes: number;
|
|
60
|
+
readonly alwaysSection: boolean;
|
|
61
|
+
readonly sectionOrder: number;
|
|
62
|
+
}> | undefined) => Layer.Layer<Capabilities, HarnessError, FileSystem>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import { Effect, Layer, Option } from "effect";
|
|
3
|
+
import { Capabilities, collectSkillFiles, definePlugin, FileSystem, HarnessError, readSkillFiles, SessionEnvironment } from "@xandreed/core";
|
|
4
|
+
import { SkillsDirectoryConfig, skillsDirectoryDefaults } from "./skills-directory.entity.js";
|
|
5
|
+
import { mergeSkillRoots, settleSkills, skillsCapability } from "./skills-directory.entity.functions.js";
|
|
6
|
+
const rootFailure = (message) => new HarnessError({ code: "skills.root", message }), readRoot = (root, config) => Effect.gen(function* () {
|
|
7
|
+
if (!(yield* (yield* FileSystem).exists(root))) {
|
|
8
|
+
if (config.required)
|
|
9
|
+
return yield* Effect.fail(rootFailure(`the skills root ${root} does not exist (required)`));
|
|
10
|
+
yield* Effect.logDebug(`skills: no directory at ${root}; skipped`);
|
|
11
|
+
return Option.none();
|
|
12
|
+
}
|
|
13
|
+
const files = yield* collectSkillFiles(root, { maxFiles: config.maxFiles, maxBytes: config.maxBytes, maxFileBytes: config.maxReferenceBytes }), tree = readSkillFiles(files, { tools: config.tools, maxReferenceBytes: config.maxReferenceBytes, rootName: basename(root) });
|
|
14
|
+
return Option.some({ root, tree });
|
|
15
|
+
}).pipe(Effect.catchTag("FsError", (error) => Effect.fail(rootFailure(`cannot read the skills root ${root}: ${error.path}: ${error.message}`))));
|
|
16
|
+
export const loadSkillsDirectory = (config, workspace) => Effect.forEach(config.roots, (root) => readRoot(resolve(workspace, root), config)).pipe(Effect.map((roots) => mergeSkillRoots(roots.flatMap(Option.toArray), config.idPrefix)), Effect.flatMap((found) => settleSkills(found, config))), skillsDirectoryPlugin = definePlugin({
|
|
17
|
+
id: "@xandreed/plugin-workspace/skills",
|
|
18
|
+
version: "0.14.0",
|
|
19
|
+
scope: "runtime",
|
|
20
|
+
config: SkillsDirectoryConfig,
|
|
21
|
+
defaults: skillsDirectoryDefaults,
|
|
22
|
+
requires: [FileSystem],
|
|
23
|
+
optional: [SessionEnvironment],
|
|
24
|
+
provides: [],
|
|
25
|
+
contributes: [Capabilities],
|
|
26
|
+
layer: (config) => Layer.effect(Capabilities, Effect.gen(function* () {
|
|
27
|
+
const environment = yield* Effect.serviceOption(SessionEnvironment), workspace = resolve(Option.match(environment, { onNone: () => ".", onSome: (found) => found.workspace })), skills = yield* loadSkillsDirectory(config, workspace);
|
|
28
|
+
return [skillsCapability(skills, config)];
|
|
29
|
+
}))
|
|
30
|
+
}), SkillsDirectoryPluginLive = skillsDirectoryPlugin.live;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
/** One root as read: its absolute path and the tree of skills in it. */
|
|
3
|
+
export declare const SkillRoot: Schema.Struct<{
|
|
4
|
+
readonly root: Schema.String;
|
|
5
|
+
readonly tree: Schema.Struct<{
|
|
6
|
+
readonly skills: Schema.$Array<Schema.Struct<{
|
|
7
|
+
readonly directory: Schema.String;
|
|
8
|
+
readonly skill: Schema.Struct<{
|
|
9
|
+
readonly id: Schema.Trimmed;
|
|
10
|
+
readonly version: Schema.Trimmed;
|
|
11
|
+
readonly summary: Schema.Trimmed;
|
|
12
|
+
readonly instructions: Schema.String;
|
|
13
|
+
readonly tools: Schema.$Array<Schema.Trimmed>;
|
|
14
|
+
readonly always: Schema.Boolean;
|
|
15
|
+
readonly permissions: Schema.$Array<Schema.String>;
|
|
16
|
+
readonly references: Schema.$Array<Schema.Struct<{
|
|
17
|
+
readonly id: Schema.Trimmed;
|
|
18
|
+
readonly title: Schema.Trimmed;
|
|
19
|
+
readonly text: Schema.String;
|
|
20
|
+
}>>;
|
|
21
|
+
}>;
|
|
22
|
+
}>>;
|
|
23
|
+
readonly issues: Schema.$Array<Schema.Struct<{
|
|
24
|
+
readonly path: Schema.String;
|
|
25
|
+
readonly severity: Schema.Literals<readonly ["error", "warning"]>;
|
|
26
|
+
readonly message: Schema.String;
|
|
27
|
+
}>>;
|
|
28
|
+
}>;
|
|
29
|
+
}>;
|
|
30
|
+
export type SkillRoot = typeof SkillRoot.Type;
|
|
31
|
+
/** The skills of every root together, and every issue, its path absolute. */
|
|
32
|
+
export declare const SkillsFound: Schema.Struct<{
|
|
33
|
+
readonly skills: Schema.$Array<Schema.Struct<{
|
|
34
|
+
readonly id: Schema.Trimmed;
|
|
35
|
+
readonly version: Schema.Trimmed;
|
|
36
|
+
readonly summary: Schema.Trimmed;
|
|
37
|
+
readonly instructions: Schema.String;
|
|
38
|
+
readonly tools: Schema.$Array<Schema.Trimmed>;
|
|
39
|
+
readonly always: Schema.Boolean;
|
|
40
|
+
readonly permissions: Schema.$Array<Schema.String>;
|
|
41
|
+
readonly references: Schema.$Array<Schema.Struct<{
|
|
42
|
+
readonly id: Schema.Trimmed;
|
|
43
|
+
readonly title: Schema.Trimmed;
|
|
44
|
+
readonly text: Schema.String;
|
|
45
|
+
}>>;
|
|
46
|
+
}>>;
|
|
47
|
+
readonly issues: Schema.$Array<Schema.Struct<{
|
|
48
|
+
readonly path: Schema.String;
|
|
49
|
+
readonly severity: Schema.Literals<readonly ["error", "warning"]>;
|
|
50
|
+
readonly message: Schema.String;
|
|
51
|
+
}>>;
|
|
52
|
+
}>;
|
|
53
|
+
export type SkillsFound = typeof SkillsFound.Type;
|
|
54
|
+
/**
|
|
55
|
+
* How `skillsDirectoryPlugin` loads skills from directories of `SKILL.md`
|
|
56
|
+
* files. Flat: plugin defaults merge one level deep.
|
|
57
|
+
*
|
|
58
|
+
* - `roots`: the directories, absolute or relative to the agent's workspace
|
|
59
|
+
* (`SessionEnvironment`, else the process's working directory). A skill
|
|
60
|
+
* whose id an earlier root already loaded is left out (an issue). One
|
|
61
|
+
* instance with several roots is how to read several directories: the
|
|
62
|
+
* capability and section ids are fixed, so two instances would share them.
|
|
63
|
+
* - `required`: a missing root fails `Agent.define` (`skills.root`); when
|
|
64
|
+
* false, a missing root is skipped.
|
|
65
|
+
* - `strict`: any issue (a `SKILL.md` without frontmatter or description, an
|
|
66
|
+
* id taken, a reference over `maxReferenceBytes`, an unreadable value)
|
|
67
|
+
* fails `Agent.define` with `skills.files`; when false, each is logged as a
|
|
68
|
+
* warning and what it names is left out.
|
|
69
|
+
* - `tools`: `"ignore"` drops the `tools` each `SKILL.md` declares, so a file
|
|
70
|
+
* skill is instructions and references only; `"declared"` keeps them, and
|
|
71
|
+
* a tool no capability registers fails `Agent.define` with `skills.tools`.
|
|
72
|
+
* - `idPrefix`: put before every skill id (`"project."`), for skills whose
|
|
73
|
+
* ids clash with the host's or another plugin's (`skills.duplicate`).
|
|
74
|
+
* - `maxReferenceBytes`: a reference larger than this is left out (it is
|
|
75
|
+
* not read, and does not count toward `maxBytes`).
|
|
76
|
+
* - `maxSkills` (all roots together), `maxFiles` and `maxBytes` (each root's
|
|
77
|
+
* files, and bytes of text): more fails `Agent.define` with `skills.limit`.
|
|
78
|
+
* - `alwaysSection`: render the instructions of the skills marked `always`
|
|
79
|
+
* in a static system-prompt section (tool discovery activates always-on
|
|
80
|
+
* skills but never shows their instructions), at `sectionOrder`.
|
|
81
|
+
*/
|
|
82
|
+
export declare const SkillsDirectoryConfig: Schema.Struct<{
|
|
83
|
+
readonly roots: Schema.$Array<Schema.NonEmptyString>;
|
|
84
|
+
readonly required: Schema.Boolean;
|
|
85
|
+
readonly strict: Schema.Boolean;
|
|
86
|
+
readonly tools: Schema.Literals<readonly ["declared", "ignore"]>;
|
|
87
|
+
readonly idPrefix: Schema.String;
|
|
88
|
+
readonly maxReferenceBytes: Schema.Int;
|
|
89
|
+
readonly maxSkills: Schema.Int;
|
|
90
|
+
readonly maxFiles: Schema.Int;
|
|
91
|
+
readonly maxBytes: Schema.Int;
|
|
92
|
+
readonly alwaysSection: Schema.Boolean;
|
|
93
|
+
readonly sectionOrder: Schema.Int;
|
|
94
|
+
}>;
|
|
95
|
+
export type SkillsDirectoryConfig = typeof SkillsDirectoryConfig.Type;
|
|
96
|
+
export declare const skillsDirectoryDefaults: SkillsDirectoryConfig;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { HarnessError } from "@xandreed/core";
|
|
3
|
+
import type { Capability, SkillDefinition } from "@xandreed/core";
|
|
4
|
+
import type { SkillRoot, SkillsDirectoryConfig, SkillsFound } from "./skills-directory.entity.js";
|
|
5
|
+
/**
|
|
6
|
+
* The id of the capability the skills are contributed in. It is fixed, as
|
|
7
|
+
* is `ALWAYS_SECTION_ID`: read several directories with one plugin instance
|
|
8
|
+
* and several `roots`. Two instances would contribute two capabilities, and
|
|
9
|
+
* two sections, with the same id in `context.built` and turn records.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SKILLS_CAPABILITY_ID = "@xandreed/plugin-workspace/skills";
|
|
12
|
+
/** The static section with the always-on skills' instructions. */
|
|
13
|
+
export declare const ALWAYS_SECTION_ID = "workspace.skills.always";
|
|
14
|
+
/**
|
|
15
|
+
* Every root's skills together, in root order: each id with `idPrefix`
|
|
16
|
+
* before it, a skill whose id an earlier root (or an earlier skill) already
|
|
17
|
+
* loaded left out with an error, and every issue's path made absolute.
|
|
18
|
+
*/
|
|
19
|
+
export declare const mergeSkillRoots: (roots: ReadonlyArray<SkillRoot>, idPrefix: string) => SkillsFound;
|
|
20
|
+
/**
|
|
21
|
+
* The skills to contribute: with `strict`, any issue fails with
|
|
22
|
+
* `skills.files` naming each one; otherwise each is logged as a warning.
|
|
23
|
+
* More than `maxSkills` fails with `skills.limit`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const settleSkills: (found: SkillsFound, config: Pick<SkillsDirectoryConfig, "strict" | "maxSkills">) => Effect.Effect<ReadonlyArray<SkillDefinition>, HarnessError>;
|
|
26
|
+
/**
|
|
27
|
+
* The always-on skills' instructions as one system-prompt section: tool
|
|
28
|
+
* discovery activates them at every turn's start but lists only the skills
|
|
29
|
+
* that are loaded on demand.
|
|
30
|
+
*/
|
|
31
|
+
export declare const alwaysSkillsText: (skills: ReadonlyArray<SkillDefinition>) => Option.Option<string>;
|
|
32
|
+
/** The skills as one capability, with the always-on section when `alwaysSection` is set and some skill is always on. */
|
|
33
|
+
export declare const skillsCapability: (skills: ReadonlyArray<SkillDefinition>, config: Pick<SkillsDirectoryConfig, "alwaysSection" | "sectionOrder">) => Capability;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { canonicalJson, defineCapability, fingerprintOf, HarnessError, skillIssuesText } from "@xandreed/core";
|
|
3
|
+
export const SKILLS_CAPABILITY_ID = "@xandreed/plugin-workspace/skills", ALWAYS_SECTION_ID = "workspace.skills.always";
|
|
4
|
+
const inRoot = (root, path) => root.endsWith("/") ? `${root}${path}` : `${root}/${path}`, skillFileOf = (root, directory) => inRoot(root, directory === "" ? "SKILL.md" : `${directory}/SKILL.md`);
|
|
5
|
+
export const mergeSkillRoots = (roots, idPrefix) => {
|
|
6
|
+
const merged = roots.reduce((sofar, { root, tree }) => tree.skills.reduce((state, entry) => {
|
|
7
|
+
const skill = { ...entry.skill, id: `${idPrefix}${entry.skill.id}` }, from = skillFileOf(root, entry.directory), taken = state.skills.find((other) => other.skill.id === skill.id);
|
|
8
|
+
return taken === void 0 ? { ...state, skills: [...state.skills, { skill, from }] } : { ...state, issues: [...state.issues, { path: from, severity: "error", message: `the skill id ${skill.id} is already loaded from ${taken.from}` }] };
|
|
9
|
+
}, { ...sofar, issues: [...sofar.issues, ...tree.issues.map((found) => ({ ...found, path: inRoot(root, found.path) }))] }), { skills: [], issues: [] });
|
|
10
|
+
return { skills: merged.skills.map((item) => item.skill), issues: merged.issues };
|
|
11
|
+
}, settleSkills = (found, config) => Effect.gen(function* () {
|
|
12
|
+
if (config.strict && found.issues.length > 0)
|
|
13
|
+
return yield* Effect.fail(new HarnessError({ code: "skills.files", message: skillIssuesText(found.issues) }));
|
|
14
|
+
yield* Effect.forEach(found.issues, (issue) => Effect.logWarning(`skills: ${issue.path}: ${issue.message} (${issue.severity === "error" ? "skill left out" : "read without it"})`), { discard: !0 });
|
|
15
|
+
if (found.skills.length > config.maxSkills)
|
|
16
|
+
return yield* Effect.fail(new HarnessError({ code: "skills.limit", message: `found ${found.skills.length} skills, more than ${config.maxSkills} (maxSkills)` }));
|
|
17
|
+
return found.skills;
|
|
18
|
+
}), alwaysSkillsText = (skills) => {
|
|
19
|
+
const always = skills.filter((skill) => skill.always);
|
|
20
|
+
return always.length === 0 ? Option.none() : Option.some([
|
|
21
|
+
"## Always-on skills",
|
|
22
|
+
...always.map((skill) => [
|
|
23
|
+
`### ${skill.id}`,
|
|
24
|
+
skill.instructions.length > 0 ? skill.instructions : skill.summary,
|
|
25
|
+
skill.references.length === 0 ? "" : `References (read_skill_reference): ${skill.references.map((reference) => `${reference.id} \u2014 ${reference.title}`).join("; ")}`
|
|
26
|
+
].filter((line) => line.length > 0).join(`
|
|
27
|
+
`))
|
|
28
|
+
].join(`
|
|
29
|
+
|
|
30
|
+
`));
|
|
31
|
+
}, skillsCapability = (skills, config) => {
|
|
32
|
+
const section = Option.filter(alwaysSkillsText(skills), () => config.alwaysSection).pipe(Option.map((text) => ({
|
|
33
|
+
id: ALWAYS_SECTION_ID,
|
|
34
|
+
version: fingerprintOf(text),
|
|
35
|
+
tier: "static",
|
|
36
|
+
order: config.sectionOrder,
|
|
37
|
+
render: () => Effect.succeed(Option.some(text))
|
|
38
|
+
})));
|
|
39
|
+
return defineCapability({
|
|
40
|
+
id: SKILLS_CAPABILITY_ID,
|
|
41
|
+
version: fingerprintOf(canonicalJson(skills.map((skill) => [skill.id, skill.version]))),
|
|
42
|
+
skills,
|
|
43
|
+
promptSections: Option.toArray(section)
|
|
44
|
+
});
|
|
45
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { SkillDefinition, SkillFileIssue, SkillTree } from "@xandreed/core";
|
|
3
|
+
export const SkillRoot = Schema.Struct({
|
|
4
|
+
root: Schema.String,
|
|
5
|
+
tree: SkillTree
|
|
6
|
+
}), SkillsFound = Schema.Struct({
|
|
7
|
+
skills: Schema.Array(SkillDefinition),
|
|
8
|
+
issues: Schema.Array(SkillFileIssue)
|
|
9
|
+
});
|
|
10
|
+
const count = (maximum) => Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1, maximum })));
|
|
11
|
+
export const SkillsDirectoryConfig = Schema.Struct({
|
|
12
|
+
roots: Schema.Array(Schema.NonEmptyString),
|
|
13
|
+
required: Schema.Boolean,
|
|
14
|
+
strict: Schema.Boolean,
|
|
15
|
+
tools: Schema.Literals(["declared", "ignore"]),
|
|
16
|
+
idPrefix: Schema.String.check(Schema.isPattern(/^\S*$/)).annotate({ description: "Put before every skill id; no whitespace." }),
|
|
17
|
+
maxReferenceBytes: count(16777216),
|
|
18
|
+
maxSkills: count(1e4),
|
|
19
|
+
maxFiles: count(1e6),
|
|
20
|
+
maxBytes: count(1073741824),
|
|
21
|
+
alwaysSection: Schema.Boolean,
|
|
22
|
+
sectionOrder: Schema.Int
|
|
23
|
+
}), skillsDirectoryDefaults = {
|
|
24
|
+
roots: [".agents/skills"],
|
|
25
|
+
required: !1,
|
|
26
|
+
strict: !1,
|
|
27
|
+
tools: "ignore",
|
|
28
|
+
idPrefix: "",
|
|
29
|
+
maxReferenceBytes: 65536,
|
|
30
|
+
maxSkills: 200,
|
|
31
|
+
maxFiles: 2000,
|
|
32
|
+
maxBytes: 8388608,
|
|
33
|
+
alwaysSection: !0,
|
|
34
|
+
sectionOrder: 850
|
|
35
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* How a text ends its lines: every break is `\r\n` (`crlf`), none has a
|
|
4
|
+
* `\r` (`lf`), or both kinds appear (`mixed`, matched as it is).
|
|
5
|
+
*/
|
|
6
|
+
export declare const LineEnding: Schema.Literals<readonly ["lf", "crlf", "mixed"]>;
|
|
7
|
+
export type LineEnding = typeof LineEnding.Type;
|
|
8
|
+
/**
|
|
9
|
+
* Edits applied to a text: `text` is the new text, in the original's line
|
|
10
|
+
* endings; `before` and `after` are the old and new text with `\n` line
|
|
11
|
+
* breaks (what a diff compares); `replaced` counts the edits, `normalized`
|
|
12
|
+
* says whether any was found only after normalising, and
|
|
13
|
+
* `firstChangedLine` is the 1-based line of the first change.
|
|
14
|
+
*/
|
|
15
|
+
export declare const EditPlan: Schema.Struct<{
|
|
16
|
+
readonly text: Schema.String;
|
|
17
|
+
readonly before: Schema.String;
|
|
18
|
+
readonly after: Schema.String;
|
|
19
|
+
readonly replaced: Schema.Int;
|
|
20
|
+
readonly normalized: Schema.Boolean;
|
|
21
|
+
readonly firstChangedLine: Schema.Int;
|
|
22
|
+
}>;
|
|
23
|
+
export type EditPlan = typeof EditPlan.Type;
|
|
24
|
+
/** Where one edit applies in the original: the span `[start, end)` and what replaces it. */
|
|
25
|
+
export declare const EditSpan: Schema.Struct<{
|
|
26
|
+
readonly index: Schema.Int;
|
|
27
|
+
readonly start: Schema.Int;
|
|
28
|
+
readonly end: Schema.Int;
|
|
29
|
+
readonly newText: Schema.String;
|
|
30
|
+
readonly normalized: Schema.Boolean;
|
|
31
|
+
}>;
|
|
32
|
+
export type EditSpan = typeof EditSpan.Type;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Result } from "effect";
|
|
2
|
+
import type { Failure } from "@xandreed/core";
|
|
3
|
+
import type { EditPlan, LineEnding } from "./text-edit.entity.js";
|
|
4
|
+
import type { TextEdit } from "./workspace-tools.entity.js";
|
|
5
|
+
/** How `text` ends its lines (see `LineEnding`). */
|
|
6
|
+
export declare const lineEndingOf: (text: string) => LineEnding;
|
|
7
|
+
/**
|
|
8
|
+
* A line as the fallback compares it: Unicode compatibility forms folded
|
|
9
|
+
* (NFKC, which also makes a no-break space a space), curly quotes and
|
|
10
|
+
* primes made straight, dashes and the minus sign made `-`, runs of
|
|
11
|
+
* whitespace made one space, and the ends trimmed.
|
|
12
|
+
*/
|
|
13
|
+
export declare const normalizeLine: (line: string) => string;
|
|
14
|
+
/**
|
|
15
|
+
* Apply edits to a file's text. Every edit is matched against the original,
|
|
16
|
+
* never against another edit's result: its `oldText` must appear exactly
|
|
17
|
+
* once (an empty one is refused), or, with `fuzzy`, once over whole lines
|
|
18
|
+
* after `normalizeLine`. The spans must not overlap. A text whose every
|
|
19
|
+
* line break is `\r\n` is matched with `\n` breaks (in it and in the edits)
|
|
20
|
+
* and written back with `\r\n`; a text without `\r` gets the edits' `\r\n`
|
|
21
|
+
* as `\n`. Edits that leave the text as it was fail with `NoChange`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const applyTextEdits: (original: string, edits: ReadonlyArray<TextEdit>, options: {
|
|
24
|
+
readonly fuzzy: boolean;
|
|
25
|
+
}) => Result.Result<EditPlan, Failure>;
|
|
26
|
+
/**
|
|
27
|
+
* The change as a unified diff (`--- path` / `+++ path`), from effect's
|
|
28
|
+
* `TextDiff` with at most 10 000 edits searched per changed region (beyond
|
|
29
|
+
* that the diff stays valid, just not minimal), cut to `maxBytes` characters
|
|
30
|
+
* with a note when longer.
|
|
31
|
+
*/
|
|
32
|
+
export declare const unifiedDiff: (path: string, before: string, after: string, maxChars: number) => string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Option, Result, TextDiff } from "effect";
|
|
2
|
+
const failure = (error, message) => ({ error, message });
|
|
3
|
+
export const lineEndingOf = (text) => !text.includes("\r") ? "lf" : text.includes(`\r
|
|
4
|
+
`) && !/(^|[^\r])\n/.test(text) ? "crlf" : "mixed", normalizeLine = (line) => line.normalize("NFKC").replace(/[\u2018\u2019\u201A\u201B\u2032\u2035]/g, "'").replace(/[\u201C\u201D\u201E\u201F\u2033\u2036]/g, '"').replace(/[\u2010-\u2015\u2212]/g, "-").replace(/\s+/g, " ").trim();
|
|
5
|
+
const label = (index, count) => count === 1 ? "oldText" : `edit ${index + 1}'s oldText`, exactSpan = (base, edit, index, count) => {
|
|
6
|
+
const first = base.indexOf(edit.oldText);
|
|
7
|
+
if (first < 0)
|
|
8
|
+
return Result.succeed(Option.none());
|
|
9
|
+
if (base.indexOf(edit.oldText, first + 1) >= 0)
|
|
10
|
+
return Result.fail(failure("Ambiguous", `${label(index, count)} appears ${Math.max(2, base.split(edit.oldText).length - 1)} times; add surrounding lines so it appears exactly once`));
|
|
11
|
+
return Result.succeed(Option.some({ index, start: first, end: first + edit.oldText.length, newText: edit.newText, normalized: !1 }));
|
|
12
|
+
}, normalizedSpan = (base, edit, index, count) => {
|
|
13
|
+
const withBreak = edit.oldText.endsWith(`
|
|
14
|
+
`), wantedLines = edit.oldText.split(`
|
|
15
|
+
`), wanted = (withBreak ? wantedLines.slice(0, -1) : wantedLines).map(normalizeLine);
|
|
16
|
+
if (wanted.every((line) => line.length === 0))
|
|
17
|
+
return Result.succeed(Option.none());
|
|
18
|
+
const lines = base.split(`
|
|
19
|
+
`), normal = lines.map(normalizeLine), starts = Array.from({ length: Math.max(0, lines.length - wanted.length + 1) }, (_, at) => at).filter((at) => wanted.every((line, offset) => normal[at + offset] === line)), [start, ...others] = starts;
|
|
20
|
+
if (start === void 0)
|
|
21
|
+
return Result.succeed(Option.none());
|
|
22
|
+
if (others.length > 0)
|
|
23
|
+
return Result.fail(failure("Ambiguous", `${label(index, count)} matches ${starts.length} places once whitespace and quotes are normalised; add surrounding lines so it matches exactly once`));
|
|
24
|
+
const lineStart = (line) => lines.slice(0, line).reduce((total, text) => total + text.length + 1, 0), after = start + wanted.length, lastLine = lines[after - 1] ?? "", end = withBreak ? Math.min(base.length, lineStart(after)) : lineStart(after) - 1 - (lastLine.endsWith("\r") ? 1 : 0);
|
|
25
|
+
return Result.succeed(Option.some({ index, start: lineStart(start), end, newText: edit.newText, normalized: !0 }));
|
|
26
|
+
}, spanOf = (base, edit, index, count, fuzzy) => {
|
|
27
|
+
if (edit.oldText.length === 0)
|
|
28
|
+
return Result.fail(failure("EmptyOldText", `${label(index, count)} is empty; to create a file or replace all of it, use write`));
|
|
29
|
+
return Result.flatMap(exactSpan(base, edit, index, count), Option.match({
|
|
30
|
+
onSome: Result.succeed,
|
|
31
|
+
onNone: () => Result.flatMap(fuzzy ? normalizedSpan(base, edit, index, count) : Result.succeed(Option.none()), Option.match({
|
|
32
|
+
onSome: Result.succeed,
|
|
33
|
+
onNone: () => Result.fail(failure("NoMatch", `${label(index, count)} was not found in the file; read it again and copy the text exactly, whitespace included`))
|
|
34
|
+
}))
|
|
35
|
+
}));
|
|
36
|
+
};
|
|
37
|
+
export const applyTextEdits = (original, edits, options) => {
|
|
38
|
+
const ending = lineEndingOf(original), plain = (text) => ending === "mixed" ? text : text.replaceAll(`\r
|
|
39
|
+
`, `
|
|
40
|
+
`), base = plain(original), found = Result.all(edits.map((edit, index) => spanOf(base, { oldText: plain(edit.oldText), newText: plain(edit.newText) }, index, edits.length, options.fuzzy)));
|
|
41
|
+
return Result.flatMap(found, (spans) => {
|
|
42
|
+
const ordered = [...spans].sort((left, right) => left.start - right.start), clash = ordered.findIndex((span, at) => at > 0 && span.start < (ordered[at - 1]?.end ?? 0));
|
|
43
|
+
if (clash > 0) {
|
|
44
|
+
const [first, second] = [ordered[clash - 1]?.index ?? 0, ordered[clash]?.index ?? 0].sort((left, right) => left - right);
|
|
45
|
+
return Result.fail(failure("Overlap", `edits ${(first ?? 0) + 1} and ${(second ?? 0) + 1} change overlapping text; merge them into one edit`));
|
|
46
|
+
}
|
|
47
|
+
const built = ordered.reduce((state, span) => ({ text: state.text + base.slice(state.at, span.start) + span.newText, at: span.end }), { text: "", at: 0 }), after = built.text + base.slice(built.at);
|
|
48
|
+
if (after === base)
|
|
49
|
+
return Result.fail(failure("NoChange", "the edits leave the file as it is"));
|
|
50
|
+
const firstStart = ordered[0]?.start ?? 0;
|
|
51
|
+
return Result.succeed({
|
|
52
|
+
text: ending === "crlf" ? after.replaceAll(`
|
|
53
|
+
`, `\r
|
|
54
|
+
`) : after,
|
|
55
|
+
before: base,
|
|
56
|
+
after,
|
|
57
|
+
replaced: ordered.length,
|
|
58
|
+
normalized: ordered.some((span) => span.normalized),
|
|
59
|
+
firstChangedLine: base.slice(0, firstStart).split(`
|
|
60
|
+
`).length
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}, unifiedDiff = (path, before, after, maxChars) => {
|
|
64
|
+
const diff = TextDiff.unified(TextDiff.lines(before, after, { maxEdits: 1e4 }), { fromFile: path, toFile: path });
|
|
65
|
+
return diff.length <= maxChars ? diff : `${diff.slice(0, maxChars)}
|
|
66
|
+
[diff cut at ${maxChars} characters]
|
|
67
|
+
`;
|
|
68
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export const LineEnding = Schema.Literals(["lf", "crlf", "mixed"]), EditPlan = Schema.Struct({
|
|
3
|
+
text: Schema.String,
|
|
4
|
+
before: Schema.String,
|
|
5
|
+
after: Schema.String,
|
|
6
|
+
replaced: Schema.Int,
|
|
7
|
+
normalized: Schema.Boolean,
|
|
8
|
+
firstChangedLine: Schema.Int
|
|
9
|
+
}), EditSpan = Schema.Struct({
|
|
10
|
+
index: Schema.Int,
|
|
11
|
+
start: Schema.Int,
|
|
12
|
+
end: Schema.Int,
|
|
13
|
+
newText: Schema.String,
|
|
14
|
+
normalized: Schema.Boolean
|
|
15
|
+
});
|