@xandreed/plugin-workspace 0.14.0 → 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/README.md +1 -1
  2. package/dist/bash-tool.adapter.js +10 -9
  3. package/dist/bash.adapter.d.ts +60 -8
  4. package/dist/bash.adapter.js +56 -13
  5. package/dist/file-system.adapter.d.ts +5 -1
  6. package/dist/file-system.adapter.js +1 -1
  7. package/dist/image-resizer.adapter.d.ts +5 -0
  8. package/dist/image-resizer.adapter.js +68 -0
  9. package/dist/image-resizer.entity.d.ts +23 -0
  10. package/dist/image-resizer.entity.functions.d.ts +15 -0
  11. package/dist/image-resizer.entity.functions.js +4 -0
  12. package/dist/image-resizer.entity.js +7 -0
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +4 -1
  15. package/dist/local-system.entity.d.ts +10 -3
  16. package/dist/local-system.entity.functions.d.ts +3 -2
  17. package/dist/local-system.entity.functions.js +16 -2
  18. package/dist/local-system.entity.js +3 -2
  19. package/dist/plugin.adapter.d.ts +103 -40
  20. package/dist/plugin.adapter.js +16 -5
  21. package/dist/read-tools.adapter.d.ts +10 -4
  22. package/dist/read-tools.adapter.js +40 -31
  23. package/dist/ripgrep.adapter.d.ts +74 -0
  24. package/dist/ripgrep.adapter.js +56 -0
  25. package/dist/ripgrep.entity.d.ts +63 -0
  26. package/dist/ripgrep.entity.functions.d.ts +87 -0
  27. package/dist/ripgrep.entity.functions.js +62 -0
  28. package/dist/ripgrep.entity.js +10 -0
  29. package/dist/shell.entity.functions.d.ts +2 -0
  30. package/dist/shell.entity.functions.js +1 -0
  31. package/dist/skills/skills-directory.adapter.d.ts +20 -14
  32. package/dist/skills/skills-directory.adapter.js +2 -2
  33. package/dist/skills/skills-directory.entity.d.ts +8 -5
  34. package/dist/skills/skills-directory.entity.functions.d.ts +58 -3
  35. package/dist/skills/skills-directory.entity.functions.js +5 -5
  36. package/dist/text-edit.entity.functions.js +3 -3
  37. package/dist/text-search.entity.functions.d.ts +7 -0
  38. package/dist/text-search.entity.functions.js +2 -2
  39. package/dist/workspace-path.entity.d.ts +21 -0
  40. package/dist/workspace-path.entity.functions.d.ts +39 -9
  41. package/dist/workspace-path.entity.functions.js +43 -18
  42. package/dist/workspace-path.entity.js +3 -2
  43. package/dist/workspace-tools.adapter.d.ts +48 -3
  44. package/dist/workspace-tools.adapter.js +23 -9
  45. package/dist/workspace-tools.entity.d.ts +136 -17
  46. package/dist/workspace-tools.entity.functions.d.ts +80 -5
  47. package/dist/workspace-tools.entity.functions.js +87 -31
  48. package/dist/workspace-tools.entity.js +26 -5
  49. package/dist/write-tools.adapter.js +10 -8
  50. package/package.json +6 -5
  51. package/dist/bash.adapter.test.d.ts +0 -1
  52. package/dist/file-system.adapter.test.d.ts +0 -1
  53. package/dist/local-system.entity.functions.test.d.ts +0 -1
  54. package/dist/plugin.adapter.test.d.ts +0 -1
  55. package/dist/skills/skills-directory.adapter.test.d.ts +0 -1
  56. package/dist/skills/skills-directory.entity.functions.test.d.ts +0 -1
  57. package/dist/text-edit.entity.functions.test.d.ts +0 -1
  58. package/dist/text-search.entity.functions.test.d.ts +0 -1
  59. package/dist/text-window.entity.functions.test.d.ts +0 -1
  60. package/dist/workspace-path.entity.functions.test.d.ts +0 -1
  61. package/dist/workspace-tools.adapter.test.d.ts +0 -1
  62. package/dist/workspace-tools.entity.functions.test.d.ts +0 -1
  63. package/dist/workspace-tools.journey.test.d.ts +0 -1
@@ -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("'", "'\\''")}'`;
@@ -8,6 +8,8 @@ import { SkillsDirectoryConfig } from "./skills-directory.entity.js";
8
8
  * the ids prefixed, the first root winning a clash, issues failing
9
9
  * (`strict`) or logged, and the limits checked. What `skillsDirectoryPlugin`
10
10
  * contributes; a host that composes capabilities itself can call it.
11
+ *
12
+ * @public
11
13
  */
12
14
  export declare const loadSkillsDirectory: (config: SkillsDirectoryConfig, workspace: string) => Effect.Effect<ReadonlyArray<SkillDefinition>, HarnessError, FileSystem>;
13
15
  /**
@@ -46,17 +48,21 @@ export declare const skillsDirectoryPlugin: import("@xandreed/core").TypedPlugin
46
48
  readonly alwaysSection: boolean;
47
49
  readonly sectionOrder: number;
48
50
  }, 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>;
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>;
@@ -13,9 +13,9 @@ const rootFailure = (message) => new HarnessError({ code: "skills.root", message
13
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
14
  return Option.some({ root, tree });
15
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({
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
17
  id: "@xandreed/plugin-workspace/skills",
18
- version: "0.14.0",
18
+ version: "0.15.0",
19
19
  scope: "runtime",
20
20
  config: SkillsDirectoryConfig,
21
21
  defaults: skillsDirectoryDefaults,
@@ -6,7 +6,7 @@ export declare const SkillRoot: Schema.Struct<{
6
6
  readonly skills: Schema.$Array<Schema.Struct<{
7
7
  readonly directory: Schema.String;
8
8
  readonly skill: Schema.Struct<{
9
- readonly id: Schema.Trimmed;
9
+ readonly id: Schema.brand<Schema.Trimmed, "SkillId">;
10
10
  readonly version: Schema.Trimmed;
11
11
  readonly summary: Schema.Trimmed;
12
12
  readonly instructions: Schema.String;
@@ -14,7 +14,7 @@ export declare const SkillRoot: Schema.Struct<{
14
14
  readonly always: Schema.Boolean;
15
15
  readonly permissions: Schema.$Array<Schema.String>;
16
16
  readonly references: Schema.$Array<Schema.Struct<{
17
- readonly id: Schema.Trimmed;
17
+ readonly id: Schema.brand<Schema.Trimmed, "SkillReferenceId">;
18
18
  readonly title: Schema.Trimmed;
19
19
  readonly text: Schema.String;
20
20
  }>>;
@@ -31,7 +31,7 @@ export type SkillRoot = typeof SkillRoot.Type;
31
31
  /** The skills of every root together, and every issue, its path absolute. */
32
32
  export declare const SkillsFound: Schema.Struct<{
33
33
  readonly skills: Schema.$Array<Schema.Struct<{
34
- readonly id: Schema.Trimmed;
34
+ readonly id: Schema.brand<Schema.Trimmed, "SkillId">;
35
35
  readonly version: Schema.Trimmed;
36
36
  readonly summary: Schema.Trimmed;
37
37
  readonly instructions: Schema.String;
@@ -39,7 +39,7 @@ export declare const SkillsFound: Schema.Struct<{
39
39
  readonly always: Schema.Boolean;
40
40
  readonly permissions: Schema.$Array<Schema.String>;
41
41
  readonly references: Schema.$Array<Schema.Struct<{
42
- readonly id: Schema.Trimmed;
42
+ readonly id: Schema.brand<Schema.Trimmed, "SkillReferenceId">;
43
43
  readonly title: Schema.Trimmed;
44
44
  readonly text: Schema.String;
45
45
  }>>;
@@ -53,7 +53,7 @@ export declare const SkillsFound: Schema.Struct<{
53
53
  export type SkillsFound = typeof SkillsFound.Type;
54
54
  /**
55
55
  * How `skillsDirectoryPlugin` loads skills from directories of `SKILL.md`
56
- * files. Flat: plugin defaults merge one level deep.
56
+ * files. Flat.
57
57
  *
58
58
  * - `roots`: the directories, absolute or relative to the agent's workspace
59
59
  * (`SessionEnvironment`, else the process's working directory). A skill
@@ -78,6 +78,8 @@ export type SkillsFound = typeof SkillsFound.Type;
78
78
  * - `alwaysSection`: render the instructions of the skills marked `always`
79
79
  * in a static system-prompt section (tool discovery activates always-on
80
80
  * skills but never shows their instructions), at `sectionOrder`.
81
+ *
82
+ * @public
81
83
  */
82
84
  export declare const SkillsDirectoryConfig: Schema.Struct<{
83
85
  readonly roots: Schema.$Array<Schema.NonEmptyString>;
@@ -93,4 +95,5 @@ export declare const SkillsDirectoryConfig: Schema.Struct<{
93
95
  readonly sectionOrder: Schema.Int;
94
96
  }>;
95
97
  export type SkillsDirectoryConfig = typeof SkillsDirectoryConfig.Type;
98
+ /** @public */
96
99
  export declare const skillsDirectoryDefaults: SkillsDirectoryConfig;
@@ -7,9 +7,15 @@ import type { SkillRoot, SkillsDirectoryConfig, SkillsFound } from "./skills-dir
7
7
  * is `ALWAYS_SECTION_ID`: read several directories with one plugin instance
8
8
  * and several `roots`. Two instances would contribute two capabilities, and
9
9
  * two sections, with the same id in `context.built` and turn records.
10
+ *
11
+ * @public
10
12
  */
11
13
  export declare const SKILLS_CAPABILITY_ID = "@xandreed/plugin-workspace/skills";
12
- /** The static section with the always-on skills' instructions. */
14
+ /**
15
+ * The static section with the always-on skills' instructions.
16
+ *
17
+ * @public
18
+ */
13
19
  export declare const ALWAYS_SECTION_ID = "workspace.skills.always";
14
20
  /**
15
21
  * Every root's skills together, in root order: each id with `idPrefix`
@@ -22,12 +28,61 @@ export declare const mergeSkillRoots: (roots: ReadonlyArray<SkillRoot>, idPrefix
22
28
  * `skills.files` naming each one; otherwise each is logged as a warning.
23
29
  * More than `maxSkills` fails with `skills.limit`.
24
30
  */
25
- export declare const settleSkills: (found: SkillsFound, config: Pick<SkillsDirectoryConfig, "strict" | "maxSkills">) => Effect.Effect<ReadonlyArray<SkillDefinition>, HarnessError>;
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>;
26
77
  /**
27
78
  * The always-on skills' instructions as one system-prompt section: tool
28
79
  * discovery activates them at every turn's start but lists only the skills
29
80
  * that are loaded on demand.
30
81
  */
31
82
  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. */
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
+ */
33
88
  export declare const skillsCapability: (skills: ReadonlyArray<SkillDefinition>, config: Pick<SkillsDirectoryConfig, "alwaysSection" | "sectionOrder">) => Capability;
@@ -1,17 +1,17 @@
1
1
  import { Effect, Option } from "effect";
2
- import { canonicalJson, defineCapability, fingerprintOf, HarnessError, skillIssuesText } from "@xandreed/core";
2
+ import { canonicalJson, defineCapability, definePromptSection, fingerprintOf, HarnessError, SkillId, skillIssuesText } from "@xandreed/core";
3
3
  export const SKILLS_CAPABILITY_ID = "@xandreed/plugin-workspace/skills", ALWAYS_SECTION_ID = "workspace.skills.always";
4
4
  const inRoot = (root, path) => root.endsWith("/") ? `${root}${path}` : `${root}/${path}`, skillFileOf = (root, directory) => inRoot(root, directory === "" ? "SKILL.md" : `${directory}/SKILL.md`);
5
5
  export const mergeSkillRoots = (roots, idPrefix) => {
6
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);
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
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
9
  }, { ...sofar, issues: [...sofar.issues, ...tree.issues.map((found) => ({ ...found, path: inRoot(root, found.path) }))] }), { skills: [], issues: [] });
10
10
  return { skills: merged.skills.map((item) => item.skill), issues: merged.issues };
11
- }, settleSkills = (found, config) => Effect.gen(function* () {
11
+ }, settleSkills = Effect.fn("skills.settle")(function* (found, config) {
12
12
  if (config.strict && found.issues.length > 0)
13
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 });
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
15
  if (found.skills.length > config.maxSkills)
16
16
  return yield* Effect.fail(new HarnessError({ code: "skills.limit", message: `found ${found.skills.length} skills, more than ${config.maxSkills} (maxSkills)` }));
17
17
  return found.skills;
@@ -29,7 +29,7 @@ export const mergeSkillRoots = (roots, idPrefix) => {
29
29
 
30
30
  `));
31
31
  }, skillsCapability = (skills, config) => {
32
- const section = Option.filter(alwaysSkillsText(skills), () => config.alwaysSection).pipe(Option.map((text) => ({
32
+ const section = Option.filter(alwaysSkillsText(skills), () => config.alwaysSection).pipe(Option.map((text) => definePromptSection({
33
33
  id: ALWAYS_SECTION_ID,
34
34
  version: fingerprintOf(text),
35
35
  tier: "static",
@@ -7,7 +7,7 @@ const label = (index, count) => count === 1 ? "oldText" : `edit ${index + 1}'s o
7
7
  if (first < 0)
8
8
  return Result.succeed(Option.none());
9
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`));
10
+ return Result.fail(failure("AmbiguousMatch", `${label(index, count)} appears ${Math.max(2, base.split(edit.oldText).length - 1)} times; add surrounding lines so it appears exactly once`));
11
11
  return Result.succeed(Option.some({ index, start: first, end: first + edit.oldText.length, newText: edit.newText, normalized: !1 }));
12
12
  }, normalizedSpan = (base, edit, index, count) => {
13
13
  const withBreak = edit.oldText.endsWith(`
@@ -20,7 +20,7 @@ const label = (index, count) => count === 1 ? "oldText" : `edit ${index + 1}'s o
20
20
  if (start === void 0)
21
21
  return Result.succeed(Option.none());
22
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`));
23
+ return Result.fail(failure("AmbiguousMatch", `${label(index, count)} matches ${starts.length} places once whitespace and quotes are normalised; add surrounding lines so it matches exactly once`));
24
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
25
  return Result.succeed(Option.some({ index, start: lineStart(start), end, newText: edit.newText, normalized: !0 }));
26
26
  }, spanOf = (base, edit, index, count, fuzzy) => {
@@ -42,7 +42,7 @@ export const applyTextEdits = (original, edits, options) => {
42
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
43
  if (clash > 0) {
44
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`));
45
+ return Result.fail(failure("OverlappingEdits", `edits ${(first ?? 0) + 1} and ${(second ?? 0) + 1} change overlapping text; merge them into one edit`));
46
46
  }
47
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
48
  if (after === base)
@@ -9,6 +9,13 @@ import type { FileSearch } from "./text-search.entity.js";
9
9
  * number of times (`(\d+\.){3}`) is not counted.
10
10
  */
11
11
  export declare const repeatsRepetition: (source: string) => boolean;
12
+ /**
13
+ * The pattern, refused (`InvalidPattern`) when it holds a line break: `grep`
14
+ * matches one line at a time, so the builtin engine would find nothing and
15
+ * ripgrep, which reads its patterns one per line from stdin, would search
16
+ * for each line of it on its own. Both engines refuse it alike.
17
+ */
18
+ export declare const singleLinePattern: (pattern: string) => Result.Result<string, Failure>;
12
19
  /**
13
20
  * The expression `grep` matches with: the pattern as a JavaScript regular
14
21
  * expression, or, with `literal`, as plain text; `ignoreCase` ignores
@@ -11,10 +11,10 @@ export const repeatsRepetition = (source) => (source.match(tokenPattern) ?? []).
11
11
  if (isQuantifier(token) && unbounded(token))
12
12
  return { groups: [...scan.groups.slice(0, -1), !0], closedRepeating: !1, nested: scan.nested || scan.closedRepeating };
13
13
  return { ...scan, closedRepeating: !1 };
14
- }, { groups: [!1], closedRepeating: !1, nested: !1 }).nested, compilePattern = (pattern, options) => {
14
+ }, { groups: [!1], closedRepeating: !1, nested: !1 }).nested, singleLinePattern = (pattern) => /[\r\n]/.test(pattern) ? Result.fail(failure("InvalidPattern", "the pattern contains a line break, but grep matches one line at a time; search for a single line of the text instead")) : Result.succeed(pattern), compilePattern = (pattern, options) => {
15
15
  const source = options.literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern, compiled = Result.try({
16
16
  try: () => new RegExp(source, options.ignoreCase ? "i" : ""),
17
- catch: (error) => failure("InvalidPattern", `${pattern} is not a valid regular expression (${error instanceof Error ? error.message : String(error)}); pass literal: true to search for it as text`)
17
+ catch: (error) => failure("InvalidPattern", `${pattern} is not a valid regular expression; the engine says: ${error instanceof Error ? error.message : String(error)}. Pass literal: true to search for it as text`)
18
18
  });
19
19
  return Result.flatMap(compiled, (regex) => !options.literal && repeatsRepetition(source) ? Result.fail(failure("InvalidPattern", `${pattern} repeats a group that itself repeats (as in (a+)+), which can take exponential time on some lines; rewrite it without the nested repetition, or search with bash (grep -E, rg)`)) : Result.succeed(regex));
20
20
  }, searchText = (text, pattern, options) => {
@@ -1,4 +1,20 @@
1
1
  import { Schema } from "effect";
2
+ /** Whether the file tools may only read a root, or may also change it. */
3
+ export declare const WorkspaceAccessMode: Schema.Literals<readonly ["read", "write"]>;
4
+ export type WorkspaceAccessMode = typeof WorkspaceAccessMode.Type;
5
+ /** An extra file-tool root, absolute or relative to the session's workspace. */
6
+ export declare const WorkspaceAllowedRoot: Schema.Struct<{
7
+ readonly path: Schema.NonEmptyString;
8
+ readonly access: Schema.Literals<readonly ["read", "write"]>;
9
+ }>;
10
+ export type WorkspaceAllowedRoot = typeof WorkspaceAllowedRoot.Type;
11
+ /** An allowed root, resolved on the same file system as the workspace. */
12
+ export declare const AllowedRoot: Schema.Struct<{
13
+ readonly root: Schema.String;
14
+ readonly lexicalRoot: Schema.String;
15
+ readonly access: Schema.Literals<readonly ["read", "write"]>;
16
+ }>;
17
+ export type AllowedRoot = typeof AllowedRoot.Type;
2
18
  /**
3
19
  * A path a tool was given, resolved in the workspace: `path` is where the
4
20
  * tool acts (absolute, every link on the way resolved, through the nearest
@@ -18,5 +34,10 @@ export type WorkspaceTarget = typeof WorkspaceTarget.Type;
18
34
  export declare const WorkspaceRoot: Schema.Struct<{
19
35
  readonly root: Schema.String;
20
36
  readonly lexicalRoot: Schema.String;
37
+ readonly roots: Schema.$Array<Schema.Struct<{
38
+ readonly root: Schema.String;
39
+ readonly lexicalRoot: Schema.String;
40
+ readonly access: Schema.Literals<readonly ["read", "write"]>;
41
+ }>>;
21
42
  }>;
22
43
  export type WorkspaceRoot = typeof WorkspaceRoot.Type;
@@ -1,43 +1,73 @@
1
1
  import { Effect, Option, Result } from "effect";
2
2
  import type { Context, Path } from "effect";
3
3
  import { FileSystem, FsError, SessionEnvironment } from "@xandreed/core";
4
- import type { Failure } from "@xandreed/core";
5
- import type { WorkspaceRoot, WorkspaceTarget } from "./workspace-path.entity.js";
4
+ import type { Failure, FsReason } from "@xandreed/core";
5
+ import type { WorkspaceAccessMode, WorkspaceAllowedRoot, WorkspaceRoot, WorkspaceTarget } from "./workspace-path.entity.js";
6
6
  type Fs = Context.Service.Shape<typeof FileSystem>;
7
7
  /** The workspace a call works in (`WorkspaceRoot`), with the file system and path functions it uses. */
8
8
  export type WorkspaceAccess = WorkspaceRoot & {
9
9
  readonly fs: Fs;
10
10
  readonly path: Path.Path;
11
11
  };
12
- /** A file-system failure as the model reads it: what went wrong with the path it gave. */
12
+ /**
13
+ * A file-system failure as the model reads it: what went wrong with the path
14
+ * it gave, in words, by the failure's reason. Never the backend's message,
15
+ * which names host paths the model never gave.
16
+ */
13
17
  export declare const fsFailure: (display: string) => (error: FsError) => Failure;
18
+ /** The workspace directory cannot be opened: why, in words, never the reason's name or the backend's message (it names the host's path). */
19
+ export declare const workspaceMissing: (reason: FsReason) => Failure;
14
20
  /** Whether `path` is `root` or below it. */
15
21
  export declare const isWithin: (root: string, path: string) => boolean;
16
22
  /**
17
23
  * Open the workspace of the call: `SessionEnvironment.workspace` made
18
24
  * absolute (against the process's directory) and resolved by the file
19
25
  * system; one that cannot be resolved fails with `WorkspaceMissing`.
26
+ * Extra roots are resolved against it for this call. An extra root that
27
+ * cannot be opened as a directory is logged and left out.
20
28
  */
21
- export declare const openWorkspace: (path: Path.Path) => Effect.Effect<WorkspaceAccess, Failure, FileSystem | SessionEnvironment>;
29
+ export declare const openWorkspace: (path: Path.Path, options?: {
30
+ readonly roots: ReadonlyArray<WorkspaceAllowedRoot>;
31
+ readonly workspaceAccess: WorkspaceAccessMode;
32
+ } | undefined) => Effect.Effect<WorkspaceAccess, {
33
+ readonly error: string;
34
+ readonly message: string;
35
+ }, FileSystem | SessionEnvironment>;
36
+ /** Whether a real path is inside any root the file tools may read. */
37
+ export declare const withinAnyRoot: (access: WorkspaceAccess, path: string) => boolean;
22
38
  /** How a path appears to the model: relative to the root (`.` for the root itself), or absolute outside it. */
23
39
  export declare const displayOf: (access: WorkspaceAccess, absolute: string) => string;
24
40
  /**
25
41
  * Resolve a path a tool was given: relative to the workspace root, or
26
- * absolute. With `confine`, a path written outside the root is refused
42
+ * absolute. With `confine`, a path written outside every allowed root is refused
27
43
  * before anything is read, and so is one whose real location (links
28
- * resolved, through the nearest existing ancestor) is outside it
29
- * (`OutsideWorkspace`). This is a guardrail against mistakes, not a
44
+ * resolved, through the nearest existing ancestor) is outside all roots
45
+ * (`OutsideWorkspace`). A write into a read root fails with `ReadOnly`;
46
+ * the most specific real root decides access. With confinement off these
47
+ * restrictions are disabled. This is a guardrail against mistakes, not a
30
48
  * sandbox: what a path names can change between this check and its use.
31
49
  */
32
50
  export declare const resolveTarget: (access: WorkspaceAccess, input: string, options: {
33
51
  readonly confine: boolean;
34
- }) => Effect.Effect<WorkspaceTarget, Failure>;
52
+ readonly intent?: WorkspaceAccessMode;
53
+ }) => Effect.Effect<{
54
+ readonly path: string;
55
+ readonly display: string;
56
+ }, {
57
+ readonly error: string;
58
+ readonly message: string;
59
+ }, never>;
35
60
  /** A compiled `protect` glob: the pattern, and whether a path relative to the root matches it. */
36
61
  export interface ProtectRule {
37
62
  readonly glob: string;
38
63
  readonly matches: (relative: string) => boolean;
39
64
  }
40
- /** Compile `protect` globs (dot entries included); fails with the first pattern that does not compile. */
65
+ /**
66
+ * Compile `protect` globs (dot entries included); fails with the first
67
+ * pattern that does not compile. A rule also matches a path that differs
68
+ * from it only in letter case, so a case-insensitive file system cannot
69
+ * reach a protected file (`.GIT/config`) through another spelling.
70
+ */
41
71
  export declare const compileProtect: (globs: ReadonlyArray<string>) => Result.Result<ReadonlyArray<ProtectRule>, string>;
42
72
  /**
43
73
  * The `exclude` globs as a walk from a directory must be given them, the