@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/dist/bash-tool.adapter.d.ts +15 -0
  4. package/dist/bash-tool.adapter.js +72 -0
  5. package/dist/bash.adapter.d.ts +29 -0
  6. package/dist/bash.adapter.js +69 -0
  7. package/dist/bash.adapter.test.d.ts +1 -0
  8. package/dist/file-system.adapter.d.ts +20 -0
  9. package/dist/file-system.adapter.js +52 -0
  10. package/dist/file-system.adapter.test.d.ts +1 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.js +35 -0
  13. package/dist/local-system.entity.d.ts +40 -0
  14. package/dist/local-system.entity.functions.d.ts +52 -0
  15. package/dist/local-system.entity.functions.js +32 -0
  16. package/dist/local-system.entity.functions.test.d.ts +1 -0
  17. package/dist/local-system.entity.js +11 -0
  18. package/dist/plugin.adapter.d.ts +120 -0
  19. package/dist/plugin.adapter.js +27 -0
  20. package/dist/plugin.adapter.test.d.ts +1 -0
  21. package/dist/read-tools.adapter.d.ts +33 -0
  22. package/dist/read-tools.adapter.js +100 -0
  23. package/dist/skills/skills-directory.adapter.d.ts +62 -0
  24. package/dist/skills/skills-directory.adapter.js +30 -0
  25. package/dist/skills/skills-directory.adapter.test.d.ts +1 -0
  26. package/dist/skills/skills-directory.entity.d.ts +96 -0
  27. package/dist/skills/skills-directory.entity.functions.d.ts +33 -0
  28. package/dist/skills/skills-directory.entity.functions.js +45 -0
  29. package/dist/skills/skills-directory.entity.functions.test.d.ts +1 -0
  30. package/dist/skills/skills-directory.entity.js +35 -0
  31. package/dist/text-edit.entity.d.ts +32 -0
  32. package/dist/text-edit.entity.functions.d.ts +32 -0
  33. package/dist/text-edit.entity.functions.js +68 -0
  34. package/dist/text-edit.entity.functions.test.d.ts +1 -0
  35. package/dist/text-edit.entity.js +15 -0
  36. package/dist/text-search.entity.d.ts +23 -0
  37. package/dist/text-search.entity.functions.d.ts +51 -0
  38. package/dist/text-search.entity.functions.js +30 -0
  39. package/dist/text-search.entity.functions.test.d.ts +1 -0
  40. package/dist/text-search.entity.js +10 -0
  41. package/dist/text-window.entity.d.ts +46 -0
  42. package/dist/text-window.entity.functions.d.ts +54 -0
  43. package/dist/text-window.entity.functions.js +88 -0
  44. package/dist/text-window.entity.functions.test.d.ts +1 -0
  45. package/dist/text-window.entity.js +21 -0
  46. package/dist/workspace-path.entity.d.ts +22 -0
  47. package/dist/workspace-path.entity.functions.d.ts +60 -0
  48. package/dist/workspace-path.entity.functions.js +63 -0
  49. package/dist/workspace-path.entity.functions.test.d.ts +1 -0
  50. package/dist/workspace-path.entity.js +8 -0
  51. package/dist/workspace-tools.adapter.d.ts +21 -0
  52. package/dist/workspace-tools.adapter.js +46 -0
  53. package/dist/workspace-tools.adapter.test.d.ts +1 -0
  54. package/dist/workspace-tools.entity.d.ts +433 -0
  55. package/dist/workspace-tools.entity.functions.d.ts +51 -0
  56. package/dist/workspace-tools.entity.functions.js +124 -0
  57. package/dist/workspace-tools.entity.functions.test.d.ts +1 -0
  58. package/dist/workspace-tools.entity.js +133 -0
  59. package/dist/workspace-tools.journey.test.d.ts +1 -0
  60. package/dist/write-tools.adapter.d.ts +12 -0
  61. package/dist/write-tools.adapter.js +77 -0
  62. 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,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
+ });