@xandreed/plugin-workspace 0.0.0-stage → 0.15.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 (63) 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 +73 -0
  5. package/dist/bash.adapter.d.ts +81 -0
  6. package/dist/bash.adapter.js +112 -0
  7. package/dist/file-system.adapter.d.ts +24 -0
  8. package/dist/file-system.adapter.js +52 -0
  9. package/dist/image-resizer.adapter.d.ts +5 -0
  10. package/dist/image-resizer.adapter.js +68 -0
  11. package/dist/image-resizer.entity.d.ts +23 -0
  12. package/dist/image-resizer.entity.functions.d.ts +15 -0
  13. package/dist/image-resizer.entity.functions.js +4 -0
  14. package/dist/image-resizer.entity.js +7 -0
  15. package/dist/index.d.ts +14 -0
  16. package/dist/index.js +38 -0
  17. package/dist/local-system.entity.d.ts +47 -0
  18. package/dist/local-system.entity.functions.d.ts +53 -0
  19. package/dist/local-system.entity.functions.js +46 -0
  20. package/dist/local-system.entity.js +12 -0
  21. package/dist/plugin.adapter.d.ts +183 -0
  22. package/dist/plugin.adapter.js +38 -0
  23. package/dist/read-tools.adapter.d.ts +39 -0
  24. package/dist/read-tools.adapter.js +109 -0
  25. package/dist/ripgrep.adapter.d.ts +74 -0
  26. package/dist/ripgrep.adapter.js +56 -0
  27. package/dist/ripgrep.entity.d.ts +63 -0
  28. package/dist/ripgrep.entity.functions.d.ts +87 -0
  29. package/dist/ripgrep.entity.functions.js +62 -0
  30. package/dist/ripgrep.entity.js +10 -0
  31. package/dist/shell.entity.functions.d.ts +2 -0
  32. package/dist/shell.entity.functions.js +1 -0
  33. package/dist/skills/skills-directory.adapter.d.ts +68 -0
  34. package/dist/skills/skills-directory.adapter.js +30 -0
  35. package/dist/skills/skills-directory.entity.d.ts +99 -0
  36. package/dist/skills/skills-directory.entity.functions.d.ts +88 -0
  37. package/dist/skills/skills-directory.entity.functions.js +45 -0
  38. package/dist/skills/skills-directory.entity.js +35 -0
  39. package/dist/text-edit.entity.d.ts +32 -0
  40. package/dist/text-edit.entity.functions.d.ts +32 -0
  41. package/dist/text-edit.entity.functions.js +68 -0
  42. package/dist/text-edit.entity.js +15 -0
  43. package/dist/text-search.entity.d.ts +23 -0
  44. package/dist/text-search.entity.functions.d.ts +58 -0
  45. package/dist/text-search.entity.functions.js +30 -0
  46. package/dist/text-search.entity.js +10 -0
  47. package/dist/text-window.entity.d.ts +46 -0
  48. package/dist/text-window.entity.functions.d.ts +54 -0
  49. package/dist/text-window.entity.functions.js +88 -0
  50. package/dist/text-window.entity.js +21 -0
  51. package/dist/workspace-path.entity.d.ts +43 -0
  52. package/dist/workspace-path.entity.functions.d.ts +90 -0
  53. package/dist/workspace-path.entity.functions.js +88 -0
  54. package/dist/workspace-path.entity.js +9 -0
  55. package/dist/workspace-tools.adapter.d.ts +66 -0
  56. package/dist/workspace-tools.adapter.js +60 -0
  57. package/dist/workspace-tools.entity.d.ts +552 -0
  58. package/dist/workspace-tools.entity.functions.d.ts +126 -0
  59. package/dist/workspace-tools.entity.functions.js +180 -0
  60. package/dist/workspace-tools.entity.js +154 -0
  61. package/dist/write-tools.adapter.d.ts +12 -0
  62. package/dist/write-tools.adapter.js +79 -0
  63. package/package.json +30 -4
@@ -0,0 +1,56 @@
1
+ import { Effect, Option, Stream } from "effect";
2
+ import { Bash, RunContext } from "@xandreed/core";
3
+ import { rgReasonOf } from "./local-system.entity.functions.js";
4
+ import { decodeRgMessage, emptyRgSearch, foldRgMessage, rgLines, rgRecords, ripgrepCommand } from "./ripgrep.entity.functions.js";
5
+ import { clipHead } from "./text-window.entity.functions.js";
6
+ import { displayOf, excludesBelow, isWithin } from "./workspace-path.entity.functions.js";
7
+ import { bashEnvironment } from "./workspace-tools.entity.functions.js";
8
+ const empty = { search: emptyRgSearch, buffer: "", stderr: "", exit: Option.none(), invalid: !1 }, failure = (error, message) => ({ error, message });
9
+ export const searchRipgrep = Effect.fn("workspace.grep")(function* (access, target, directory, params, config) {
10
+ const bash = yield* Bash, run = yield* Effect.serviceOption(RunContext), cwd = directory ? target.path : access.path.dirname(target.path), input = directory ? "." : access.path.basename(target.path), exclude = excludesBelow(config.exclude, isWithin(access.root, cwd) ? Option.some(access.path.relative(access.root, cwd)) : Option.none()), limit = params.limit ?? config.grepMatches, perFile = directory ? Math.max(1, Math.ceil(limit / 4)) : limit, options = { limit, perFile, directory, lineChars: config.grepLineChars, display: (path) => displayOf(access, access.path.resolve(cwd, path)) }, fold = (state, event) => {
11
+ if (event._tag === "BashExit")
12
+ return { ...state, exit: Option.some(event) };
13
+ if (event.stream === "stderr")
14
+ return { ...state, stderr: clipHead(state.stderr + event.text, 16384) };
15
+ const complete = state.buffer + event.text;
16
+ if (complete.split(`
17
+ `).some((line) => line.length > config.maxFileBytes * 6 + 65536))
18
+ return { ...state, invalid: !0 };
19
+ const parsed = rgRecords("", complete);
20
+ return { ...state, buffer: parsed.buffer, search: parsed.records.reduce((search, message) => search.stopped ? search : foldRgMessage(search, message, options), state.search) };
21
+ }, output = yield* bash.stream(ripgrepCommand(config, params, input, perFile, exclude), {
22
+ cwd,
23
+ stdin: params.pattern.length === 0 ? `
24
+ ` : params.pattern,
25
+ timeoutMs: config.grepTimeoutMs,
26
+ ...bashEnvironment(config, Option.map(run, (current) => ({ sessionId: String(current.session.id), runId: current.runId, workspace: access.root })))
27
+ }).pipe(Stream.mapAccum(() => empty, (state, event) => {
28
+ const next = fold(state, event);
29
+ return [next, [next]];
30
+ }), Stream.takeUntil((state) => state.search.stopped || state.invalid), Stream.runFold(() => empty, (_previous, state) => state), Effect.mapError((error) => failure(error.reason, error.message))), search = Option.match(decodeRgMessage(output.buffer), {
31
+ onNone: () => output.search,
32
+ onSome: (message) => foldRgMessage(output.search, message, options)
33
+ });
34
+ if (output.invalid)
35
+ return yield* Effect.fail(failure("SearchFailed", "The search output was too large to read; narrow the path or the pattern and search again"));
36
+ const timedOut = Option.exists(output.exit, (exit) => exit.termination === "timeout"), unreadable = output.stderr.split(`
37
+ `).flatMap((line) => {
38
+ const found = /^rg: (.*): .*\(os error (\d+)\)$/.exec(line);
39
+ return found === null ? [] : [{ path: options.display(found[1] ?? ""), reason: rgReasonOf(Number(found[2])) }];
40
+ });
41
+ if (Option.exists(output.exit, (exit) => Option.contains(exit.exitCode, 2)) && search.messages === 0 && unreadable.length === 0)
42
+ return yield* Effect.fail(failure("InvalidPattern", `The search pattern or glob could not be parsed: ${output.stderr.trim()}. Pass literal: true to search for the pattern as text`));
43
+ if (Option.exists(output.exit, (exit) => exit.termination === "signal" || exit.termination === "exit" && !Option.exists(exit.exitCode, (code) => code === 0 || code === 1 || code === 2)))
44
+ return yield* Effect.fail(failure("SearchFailed", "The file search could not finish; try a narrower path or tell the user the search is unavailable"));
45
+ return {
46
+ pattern: params.pattern,
47
+ path: target.display,
48
+ lines: rgLines(search, params.context ?? 0),
49
+ matches: [...search.counts.values()].reduce((sum, count) => sum + count, 0),
50
+ files: search.counts.size,
51
+ skipped: search.skipped,
52
+ truncated: search.truncated,
53
+ unreadable,
54
+ timedOut
55
+ };
56
+ });
@@ -0,0 +1,63 @@
1
+ import { Schema } from "effect";
2
+ /** ripgrep represents non-UTF-8 paths and lines as base64 bytes. */
3
+ export declare const RgText: Schema.Union<readonly [Schema.Struct<{
4
+ readonly text: Schema.String;
5
+ }>, Schema.Struct<{
6
+ readonly bytes: Schema.StringFromBase64;
7
+ }>]>;
8
+ export type RgText = typeof RgText.Type;
9
+ /** Only the fields the workspace search consumes from ripgrep's JSON stream. */
10
+ export declare const RgMessage: Schema.toTaggedUnion<"type", readonly [Schema.Struct<{
11
+ readonly type: Schema.Literal<"begin">;
12
+ readonly data: Schema.Struct<{
13
+ readonly path: Schema.Union<readonly [Schema.Struct<{
14
+ readonly text: Schema.String;
15
+ }>, Schema.Struct<{
16
+ readonly bytes: Schema.StringFromBase64;
17
+ }>]>;
18
+ }>;
19
+ }>, Schema.Struct<{
20
+ readonly type: Schema.Literal<"match">;
21
+ readonly data: Schema.Struct<{
22
+ readonly path: Schema.Union<readonly [Schema.Struct<{
23
+ readonly text: Schema.String;
24
+ }>, Schema.Struct<{
25
+ readonly bytes: Schema.StringFromBase64;
26
+ }>]>;
27
+ readonly lines: Schema.Union<readonly [Schema.Struct<{
28
+ readonly text: Schema.String;
29
+ }>, Schema.Struct<{
30
+ readonly bytes: Schema.StringFromBase64;
31
+ }>]>;
32
+ readonly line_number: Schema.Int;
33
+ }>;
34
+ }>, Schema.Struct<{
35
+ readonly type: Schema.Literal<"context">;
36
+ readonly data: Schema.Struct<{
37
+ readonly path: Schema.Union<readonly [Schema.Struct<{
38
+ readonly text: Schema.String;
39
+ }>, Schema.Struct<{
40
+ readonly bytes: Schema.StringFromBase64;
41
+ }>]>;
42
+ readonly lines: Schema.Union<readonly [Schema.Struct<{
43
+ readonly text: Schema.String;
44
+ }>, Schema.Struct<{
45
+ readonly bytes: Schema.StringFromBase64;
46
+ }>]>;
47
+ readonly line_number: Schema.Int;
48
+ }>;
49
+ }>, Schema.Struct<{
50
+ readonly type: Schema.Literal<"end">;
51
+ readonly data: Schema.Struct<{
52
+ readonly path: Schema.Union<readonly [Schema.Struct<{
53
+ readonly text: Schema.String;
54
+ }>, Schema.Struct<{
55
+ readonly bytes: Schema.StringFromBase64;
56
+ }>]>;
57
+ readonly binary_offset: Schema.NullOr<Schema.Int>;
58
+ }>;
59
+ }>, Schema.Struct<{
60
+ readonly type: Schema.Literal<"summary">;
61
+ readonly data: Schema.Struct<{}>;
62
+ }>]>;
63
+ export type RgMessage = typeof RgMessage.Type;
@@ -0,0 +1,87 @@
1
+ import { Option } from "effect";
2
+ import { RgMessage } from "./ripgrep.entity.js";
3
+ import type { RgText } from "./ripgrep.entity.js";
4
+ import type { GrepLine, GrepParams, WorkspaceToolsConfig } from "./workspace-tools.entity.js";
5
+ export { shellQuote } from "./shell.entity.functions.js";
6
+ /** The pattern goes only to stdin, never into the shell command. */
7
+ export declare const ripgrepCommand: (config: WorkspaceToolsConfig, params: GrepParams, target: string, perFile: number, exclude: ReadonlyArray<string>) => string;
8
+ export declare const rgText: (value: RgText) => string;
9
+ export declare const decodeRgMessage: (input: unknown, options?: import("effect/SchemaAST").ParseOptions) => Option.Option<{
10
+ readonly type: "begin";
11
+ readonly data: {
12
+ readonly path: {
13
+ readonly text: string;
14
+ } | {
15
+ readonly bytes: string;
16
+ };
17
+ };
18
+ } | {
19
+ readonly type: "match";
20
+ readonly data: {
21
+ readonly path: {
22
+ readonly text: string;
23
+ } | {
24
+ readonly bytes: string;
25
+ };
26
+ readonly lines: {
27
+ readonly text: string;
28
+ } | {
29
+ readonly bytes: string;
30
+ };
31
+ readonly line_number: number;
32
+ };
33
+ } | {
34
+ readonly type: "context";
35
+ readonly data: {
36
+ readonly path: {
37
+ readonly text: string;
38
+ } | {
39
+ readonly bytes: string;
40
+ };
41
+ readonly lines: {
42
+ readonly text: string;
43
+ } | {
44
+ readonly bytes: string;
45
+ };
46
+ readonly line_number: number;
47
+ };
48
+ } | {
49
+ readonly type: "end";
50
+ readonly data: {
51
+ readonly path: {
52
+ readonly text: string;
53
+ } | {
54
+ readonly bytes: string;
55
+ };
56
+ readonly binary_offset: number | null;
57
+ };
58
+ } | {
59
+ readonly type: "summary";
60
+ readonly data: {};
61
+ }>;
62
+ export interface RgSearch {
63
+ readonly lines: ReadonlyArray<GrepLine>;
64
+ readonly counts: ReadonlyMap<string, number>;
65
+ readonly skipped: number;
66
+ readonly truncated: boolean;
67
+ readonly stopAtEnd: boolean;
68
+ readonly capped: ReadonlySet<string>;
69
+ readonly stopped: boolean;
70
+ readonly messages: number;
71
+ }
72
+ export declare const emptyRgSearch: RgSearch;
73
+ /** Bound kept matches independently of rg's per-file cap (context can exceed it). */
74
+ export declare const foldRgMessage: (state: RgSearch, message: RgMessage, options: {
75
+ readonly limit: number;
76
+ readonly perFile: number;
77
+ readonly lineChars: number;
78
+ readonly directory?: boolean;
79
+ readonly display: (path: string) => string;
80
+ }) => RgSearch;
81
+ /** Keep only context surrounding selected matches; rg may send context for capped hits. */
82
+ export declare const rgLines: (state: RgSearch, context: number) => ReadonlyArray<GrepLine>;
83
+ /** Carry a partial JSON line between Bash chunks; decode only complete records. */
84
+ export declare const rgRecords: (buffer: string, chunk: string) => {
85
+ readonly buffer: string;
86
+ readonly records: ReadonlyArray<RgMessage>;
87
+ };
@@ -0,0 +1,62 @@
1
+ import { Option, Schema } from "effect";
2
+ import { RgMessage } from "./ripgrep.entity.js";
3
+ import { clipChars } from "./text-window.entity.functions.js";
4
+ import { shellQuote } from "./shell.entity.functions.js";
5
+ export { shellQuote } from "./shell.entity.functions.js";
6
+ export const ripgrepCommand = (config, params, target, perFile, exclude) => [
7
+ "rg",
8
+ "--json",
9
+ "--no-config",
10
+ "--sort",
11
+ "path",
12
+ "--hidden",
13
+ "--no-follow",
14
+ ...config.grepIgnoreFiles ? [] : ["--no-ignore"],
15
+ "--max-filesize",
16
+ String(config.maxFileBytes),
17
+ "--max-count",
18
+ String(perFile + 1),
19
+ ...params.context === void 0 ? [] : ["-C", String(params.context)],
20
+ ...params.ignoreCase ? ["-i"] : [],
21
+ ...params.literal ? ["-F"] : [],
22
+ "-f",
23
+ "-",
24
+ ...params.glob === void 0 ? [] : [`--glob=${params.glob}`],
25
+ ...exclude.map((glob) => `--glob=!${glob.replace(/^\.\//, "/")}`),
26
+ "--",
27
+ target
28
+ ].map(shellQuote).join(" "), rgText = (value) => ("text" in value) ? value.text : value.bytes, decodeRgMessage = Schema.decodeUnknownOption(Schema.fromJsonString(RgMessage)), emptyRgSearch = { lines: [], counts: new Map, skipped: 0, truncated: !1, stopped: !1, stopAtEnd: !1, capped: new Set, messages: 0 }, foldRgMessage = (state, message, options) => {
29
+ const next = { ...state, messages: state.messages + 1 }, add = (data, match) => {
30
+ const path = options.display(rgText(data.path)), count = state.counts.get(path) ?? 0, total = [...state.counts.values()].reduce((sum, found) => sum + found, 0), text = rgText(data.lines).replace(/\r?\n$/, ""), row = { path, line: data.line_number, text: clipChars(text, options.lineChars).text, match };
31
+ if (match && total >= options.limit)
32
+ return { ...next, lines: [...state.lines, { ...row, match: !1 }], truncated: !0, stopAtEnd: !0, capped: new Set([...state.capped, path]) };
33
+ if (match && count >= options.perFile)
34
+ return { ...next, lines: [...state.lines, { ...row, match: !1 }], truncated: !0, capped: new Set([...state.capped, path]) };
35
+ return {
36
+ ...next,
37
+ lines: [...state.lines, row],
38
+ counts: match ? new Map([...state.counts, [path, count + 1]]) : state.counts
39
+ };
40
+ };
41
+ return RgMessage.match(message, {
42
+ begin: () => next,
43
+ match: ({ data }) => add(data, !0),
44
+ context: ({ data }) => add(data, !1),
45
+ end: ({ data }) => {
46
+ if (data.binary_offset === null) {
47
+ const atLimit = [...state.counts.values()].reduce((sum, count) => sum + count, 0) >= options.limit;
48
+ return { ...next, stopped: state.stopAtEnd || atLimit, truncated: state.truncated || options.directory === !0 && atLimit };
49
+ }
50
+ const path = options.display(rgText(data.path));
51
+ return { ...next, lines: state.lines.filter((line) => line.path !== path), counts: new Map([...state.counts].filter(([name]) => name !== path)), skipped: state.skipped + 1, stopped: !1, stopAtEnd: !1, capped: new Set([...state.capped].filter((name) => name !== path)), truncated: [...state.capped].some((name) => name !== path) };
52
+ },
53
+ summary: () => next
54
+ });
55
+ }, rgLines = (state, context) => {
56
+ const shown = new Set(state.lines.filter((line) => line.match).flatMap((hit) => Array.from({ length: context * 2 + 1 }, (_, offset) => `${hit.path}\x00${hit.line - context + offset}`)));
57
+ return state.lines.filter((line) => shown.has(`${line.path}\x00${line.line}`));
58
+ }, rgRecords = (buffer, chunk) => {
59
+ const lines = (buffer + chunk).split(`
60
+ `);
61
+ return { buffer: lines.at(-1) ?? "", records: lines.slice(0, -1).flatMap((line) => Option.toArray(decodeRgMessage(line))) };
62
+ };
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+ export const RgText = Schema.Union([Schema.Struct({ text: Schema.String }), Schema.Struct({ bytes: Schema.StringFromBase64 })]);
3
+ const line = Schema.Struct({ path: RgText, lines: RgText, line_number: Schema.Int.check(Schema.isGreaterThan(0)) });
4
+ export const RgMessage = Schema.Union([
5
+ Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Struct({ path: RgText }) }),
6
+ Schema.Struct({ type: Schema.Literal("match"), data: line }),
7
+ Schema.Struct({ type: Schema.Literal("context"), data: line }),
8
+ Schema.Struct({ type: Schema.Literal("end"), data: Schema.Struct({ path: RgText, binary_offset: Schema.NullOr(Schema.Int) }) }),
9
+ Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Struct({}) })
10
+ ]).pipe(Schema.toTaggedUnion("type"));
@@ -0,0 +1,2 @@
1
+ /** One literal POSIX shell argument, including quotes, newlines and metacharacters. */
2
+ export declare const shellQuote: (value: string) => string;
@@ -0,0 +1 @@
1
+ export const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
@@ -0,0 +1,68 @@
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
+ * @public
13
+ */
14
+ export declare const loadSkillsDirectory: (config: SkillsDirectoryConfig, workspace: string) => Effect.Effect<ReadonlyArray<SkillDefinition>, HarnessError, FileSystem>;
15
+ /**
16
+ * Skills from directories of `SKILL.md` files (the Agent Skills layout,
17
+ * `.agents/skills/<name>/SKILL.md` by default), read once per agent through
18
+ * the `FileSystem` port and contributed as one capability. Tool discovery
19
+ * lists them in its catalogue, `load_skill` returns a skill's instructions
20
+ * and `read_skill_reference` the other files of its directory. A skill id
21
+ * the host or another plugin also defines fails `Agent.define` with
22
+ * `skills.duplicate`: set `idPrefix`. Relative roots resolve against the
23
+ * agent's workspace (`SessionEnvironment`), else the process's working
24
+ * directory.
25
+ */
26
+ export declare const skillsDirectoryPlugin: import("@xandreed/core").TypedPlugin<{
27
+ readonly roots: readonly string[];
28
+ readonly required: boolean;
29
+ readonly strict: boolean;
30
+ readonly tools: "ignore" | "declared";
31
+ readonly idPrefix: string;
32
+ readonly maxReferenceBytes: number;
33
+ readonly maxSkills: number;
34
+ readonly maxFiles: number;
35
+ readonly maxBytes: number;
36
+ readonly alwaysSection: boolean;
37
+ readonly sectionOrder: number;
38
+ }, {
39
+ readonly roots: readonly string[];
40
+ readonly required: boolean;
41
+ readonly strict: boolean;
42
+ readonly tools: "ignore" | "declared";
43
+ readonly idPrefix: string;
44
+ readonly maxReferenceBytes: number;
45
+ readonly maxSkills: number;
46
+ readonly maxFiles: number;
47
+ readonly maxBytes: number;
48
+ readonly alwaysSection: boolean;
49
+ readonly sectionOrder: number;
50
+ }, Capabilities, HarnessError, FileSystem>;
51
+ /**
52
+ * The skills directory as a typed layer: contributes `Capabilities`; requires `FileSystem` (and reads `SessionEnvironment` when present).
53
+ *
54
+ * @public
55
+ */
56
+ export declare const SkillsDirectoryPluginLive: (options?: {
57
+ readonly roots?: readonly string[];
58
+ readonly required?: boolean;
59
+ readonly strict?: boolean;
60
+ readonly tools?: "ignore" | "declared";
61
+ readonly idPrefix?: string;
62
+ readonly maxReferenceBytes?: number;
63
+ readonly maxSkills?: number;
64
+ readonly maxFiles?: number;
65
+ readonly maxBytes?: number;
66
+ readonly alwaysSection?: boolean;
67
+ readonly sectionOrder?: number;
68
+ } | 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), { concurrency: 1 }).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.15.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,99 @@
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.brand<Schema.Trimmed, "SkillId">;
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.brand<Schema.Trimmed, "SkillReferenceId">;
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.brand<Schema.Trimmed, "SkillId">;
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.brand<Schema.Trimmed, "SkillReferenceId">;
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.
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
+ * @public
83
+ */
84
+ export declare const SkillsDirectoryConfig: Schema.Struct<{
85
+ readonly roots: Schema.$Array<Schema.NonEmptyString>;
86
+ readonly required: Schema.Boolean;
87
+ readonly strict: Schema.Boolean;
88
+ readonly tools: Schema.Literals<readonly ["declared", "ignore"]>;
89
+ readonly idPrefix: Schema.String;
90
+ readonly maxReferenceBytes: Schema.Int;
91
+ readonly maxSkills: Schema.Int;
92
+ readonly maxFiles: Schema.Int;
93
+ readonly maxBytes: Schema.Int;
94
+ readonly alwaysSection: Schema.Boolean;
95
+ readonly sectionOrder: Schema.Int;
96
+ }>;
97
+ export type SkillsDirectoryConfig = typeof SkillsDirectoryConfig.Type;
98
+ /** @public */
99
+ export declare const skillsDirectoryDefaults: SkillsDirectoryConfig;
@@ -0,0 +1,88 @@
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
+ * @public
12
+ */
13
+ export declare const SKILLS_CAPABILITY_ID = "@xandreed/plugin-workspace/skills";
14
+ /**
15
+ * The static section with the always-on skills' instructions.
16
+ *
17
+ * @public
18
+ */
19
+ export declare const ALWAYS_SECTION_ID = "workspace.skills.always";
20
+ /**
21
+ * Every root's skills together, in root order: each id with `idPrefix`
22
+ * before it, a skill whose id an earlier root (or an earlier skill) already
23
+ * loaded left out with an error, and every issue's path made absolute.
24
+ */
25
+ export declare const mergeSkillRoots: (roots: ReadonlyArray<SkillRoot>, idPrefix: string) => SkillsFound;
26
+ /**
27
+ * The skills to contribute: with `strict`, any issue fails with
28
+ * `skills.files` naming each one; otherwise each is logged as a warning.
29
+ * More than `maxSkills` fails with `skills.limit`.
30
+ */
31
+ export declare const settleSkills: (found: {
32
+ readonly skills: readonly {
33
+ readonly id: string & import("effect/Brand").Brand<"SkillId">;
34
+ readonly version: string;
35
+ readonly summary: string;
36
+ readonly instructions: string;
37
+ readonly tools: readonly string[];
38
+ readonly always: boolean;
39
+ readonly permissions: readonly string[];
40
+ readonly references: readonly {
41
+ readonly id: string & import("effect/Brand").Brand<"SkillReferenceId">;
42
+ readonly title: string;
43
+ readonly text: string;
44
+ }[];
45
+ }[];
46
+ readonly issues: readonly {
47
+ readonly path: string;
48
+ readonly severity: "error" | "warning";
49
+ readonly message: string;
50
+ }[];
51
+ }, config: Pick<{
52
+ readonly roots: readonly string[];
53
+ readonly required: boolean;
54
+ readonly strict: boolean;
55
+ readonly tools: "ignore" | "declared";
56
+ readonly idPrefix: string;
57
+ readonly maxReferenceBytes: number;
58
+ readonly maxSkills: number;
59
+ readonly maxFiles: number;
60
+ readonly maxBytes: number;
61
+ readonly alwaysSection: boolean;
62
+ readonly sectionOrder: number;
63
+ }, "strict" | "maxSkills">) => Effect.Effect<readonly {
64
+ readonly id: string & import("effect/Brand").Brand<"SkillId">;
65
+ readonly version: string;
66
+ readonly summary: string;
67
+ readonly instructions: string;
68
+ readonly tools: readonly string[];
69
+ readonly always: boolean;
70
+ readonly permissions: readonly string[];
71
+ readonly references: readonly {
72
+ readonly id: string & import("effect/Brand").Brand<"SkillReferenceId">;
73
+ readonly title: string;
74
+ readonly text: string;
75
+ }[];
76
+ }[], HarnessError, never>;
77
+ /**
78
+ * The always-on skills' instructions as one system-prompt section: tool
79
+ * discovery activates them at every turn's start but lists only the skills
80
+ * that are loaded on demand.
81
+ */
82
+ export declare const alwaysSkillsText: (skills: ReadonlyArray<SkillDefinition>) => Option.Option<string>;
83
+ /**
84
+ * The skills as one capability, with the always-on section when `alwaysSection` is set and some skill is always on.
85
+ *
86
+ * @public
87
+ */
88
+ 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, definePromptSection, fingerprintOf, HarnessError, SkillId, 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: SkillId.make(`${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 = Effect.fn("skills.settle")(function* (found, config) {
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"})`), { concurrency: 1, 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) => definePromptSection({
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
+ };