@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
@@ -1,10 +1,42 @@
1
- import { Effect, Option } from "effect";
2
- import type { BashOptions, Media } from "@xandreed/core";
1
+ import { Effect, Option, Schema } from "effect";
2
+ import { ImageResizeError } from "@xandreed/core";
3
+ import type { BashOptions, ImageFitTarget, ImageInput, Media } from "@xandreed/core";
3
4
  import type { BashToolResult, EditResult, FindResult, GrepResult, LsResult, ReadResult, WorkspaceSkillName, WorkspaceToolName, WorkspaceToolsConfig, WriteResult } from "./workspace-tools.entity.js";
4
5
  /** The skill each tool belongs to, by its short name. */
5
6
  export declare const skillOfTool: Readonly<Record<WorkspaceToolName, WorkspaceSkillName>>;
7
+ /**
8
+ * The parameters `read`, `ls`, `find` and `grep` take under `config`: their
9
+ * `limit` goes up to the configured cap (`maxLines`, `lsEntries`,
10
+ * `findResults`, `grepMatches`), so the model is shown the real maximum and
11
+ * a larger one is refused rather than quietly lowered.
12
+ */
13
+ export declare const toolParameters: (config: WorkspaceToolsConfig) => {
14
+ read: Schema.Struct<{
15
+ readonly limit: Schema.optionalKey<Schema.Int>;
16
+ readonly path: Schema.String;
17
+ readonly offset: Schema.optionalKey<Schema.Int>;
18
+ }>;
19
+ ls: Schema.Struct<{
20
+ readonly limit: Schema.optionalKey<Schema.Int>;
21
+ readonly path: Schema.optionalKey<Schema.String>;
22
+ }>;
23
+ find: Schema.Struct<{
24
+ readonly limit: Schema.optionalKey<Schema.Int>;
25
+ readonly pattern: Schema.String;
26
+ readonly path: Schema.optionalKey<Schema.String>;
27
+ }>;
28
+ grep: Schema.Struct<{
29
+ readonly limit: Schema.optionalKey<Schema.Int>;
30
+ readonly pattern: Schema.String;
31
+ readonly path: Schema.optionalKey<Schema.String>;
32
+ readonly glob: Schema.optionalKey<Schema.String>;
33
+ readonly ignoreCase: Schema.optionalKey<Schema.Boolean>;
34
+ readonly literal: Schema.optionalKey<Schema.Boolean>;
35
+ readonly context: Schema.optionalKey<Schema.Int>;
36
+ }>;
37
+ };
6
38
  /** What the model is told each tool does, with the configured limits in it. */
7
- export declare const toolDescriptions: (config: WorkspaceToolsConfig) => Readonly<Record<WorkspaceToolName, string>>;
39
+ export declare const toolDescriptions: (config: WorkspaceToolsConfig, resizesImages?: boolean) => Readonly<Record<WorkspaceToolName, string>>;
8
40
  /** The text of the `workspace.environment` section: where the workspace is and how the enabled tools behave there. */
9
41
  export declare const environmentText: (root: string, config: WorkspaceToolsConfig, tools: ReadonlyArray<WorkspaceToolName>) => string;
10
42
  /**
@@ -24,9 +56,51 @@ export declare const bashEnvironment: (config: WorkspaceToolsConfig, session: Op
24
56
  * by the path; an image it refuses (over `MEDIA_LIMITS.imageBytes`, over
25
57
  * `MEDIA_LIMITS.maxPixels` a side, or a header without a size) is an
26
58
  * `ImageRejected` note, without the bytes. Anything else is `Binary`, with
27
- * the media type the name suggests.
59
+ * `nameType`: the media type the name suggests, as the result carries it
60
+ * (`null` for none; the adapter reads it with `mediaTypeOf`).
28
61
  */
29
- export declare const binaryRead: (path: string, bytes: Uint8Array, readImages: boolean) => Effect.Effect<ReadResult>;
62
+ type FitImage = (image: ImageInput, target: ImageFitTarget) => Effect.Effect<Uint8Array, ImageResizeError>;
63
+ export declare const binaryRead: (path: string, bytes: Uint8Array<ArrayBufferLike>, readImages: boolean, nameType: string | null, fit?: Option.Option<FitImage> | undefined, maxSide?: number | undefined) => Effect.Effect<{
64
+ readonly _tag: "Text";
65
+ readonly path: string;
66
+ readonly text: string;
67
+ readonly firstLine: number;
68
+ readonly lastLine: number;
69
+ readonly totalLines: number;
70
+ readonly next: number | null;
71
+ readonly clipped: boolean;
72
+ readonly lossy: boolean;
73
+ } | {
74
+ readonly _tag: "Binary";
75
+ readonly path: string;
76
+ readonly bytes: number;
77
+ readonly mediaType: string | null;
78
+ } | {
79
+ readonly _tag: "Image";
80
+ readonly path: string;
81
+ readonly image: {
82
+ readonly type: "media";
83
+ readonly mediaType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
84
+ readonly data: string;
85
+ readonly fingerprint: string & import("effect/Brand").Brand<"MediaFingerprint">;
86
+ readonly bytes: number;
87
+ readonly name?: string;
88
+ readonly alt?: string;
89
+ };
90
+ readonly original?: {
91
+ readonly mediaType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
92
+ readonly bytes: number;
93
+ readonly width: number;
94
+ readonly height: number;
95
+ };
96
+ } | {
97
+ readonly _tag: "ImageRejected";
98
+ readonly path: string;
99
+ readonly mediaType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
100
+ readonly bytes: number;
101
+ readonly reason: "Empty" | "InvalidBase64" | "UnsupportedType" | "TooLarge" | "TooManyPixels" | "Unreadable" | "TooMany" | "TooLargeTogether";
102
+ readonly message: string;
103
+ }, never, never>;
30
104
  /**
31
105
  * What the model can do about a file over `maxFileBytes`, which `read`
32
106
  * refuses unread: with `readImages`, a file named as a PNG, JPEG, GIF or
@@ -49,3 +123,4 @@ export declare const renderGrep: (result: GrepResult, withContext: boolean, maxL
49
123
  export declare const compactGrep: (result: GrepResult) => string;
50
124
  export declare const renderBash: (result: BashToolResult) => string;
51
125
  export declare const compactBash: (result: BashToolResult) => string;
126
+ export {};
@@ -1,7 +1,8 @@
1
- import { Effect, Match, Option, Schema } from "effect";
2
- import { ImageMediaType, MEDIA_LIMITS, mediaFromBytes, sniffImageType } from "@xandreed/core";
1
+ import { Effect, Match, Option, Result, Schema } from "effect";
2
+ import { fitTarget, imageSizeOf, ImageMediaType, ImageResizeError, MEDIA_LIMITS, mediaFromBytes, sniffImageType } from "@xandreed/core";
3
3
  import { renderSearchLines } from "./text-search.entity.functions.js";
4
4
  import { formatBytes, headLines, mediaTypeOf } from "./text-window.entity.functions.js";
5
+ import { FindParams, GrepParams, LsParams, ReadParams } from "./workspace-tools.entity.js";
5
6
  export const skillOfTool = {
6
7
  read: "read",
7
8
  ls: "read",
@@ -14,25 +15,36 @@ export const skillOfTool = {
14
15
  const seconds = (ms) => `${Math.round(ms / 1000)} s`, imageLimit = (config) => {
15
16
  const bytes = Math.min(MEDIA_LIMITS.imageBytes, config.maxFileBytes);
16
17
  return bytes < 1048576 ? `${Math.floor(bytes * 100 / 1024) / 100} KB` : `${Math.floor(bytes * 100 / 1048576) / 100} MB`;
17
- }, listed = (names) => names.length === 0 ? "nothing" : names.join(", ");
18
- export const toolDescriptions = (config) => ({
19
- read: `Read a text file of the workspace${config.readImages ? ", or look at an image" : ""}. Shows at most ${config.maxLines} lines or ${formatBytes(config.maxBytes)} at a time; when more is left, the result says which offset to continue from. offset is the first line to show (1-based), limit how many lines. ${config.readImages ? `A PNG, JPEG, GIF or WebP image (up to ${imageLimit(config)} and ${MEDIA_LIMITS.maxPixels} pixels a side) is returned as an image you can see; another binary file is reported, not shown.` : "A binary file is reported, not shown."} To see a directory, use ls.`,
18
+ }, inWords = (names, joiner) => names.length <= 1 ? names.join("") : `${names.slice(0, -1).join(", ")} ${joiner} ${names.at(-1)}`, counted = (n, one, many) => `${n} ${n === 1 ? one : many}`, place = (path) => path === "." ? "the workspace root" : path, limitUpTo = (maximum) => Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum })));
19
+ export const toolParameters = (config) => ({
20
+ read: Schema.Struct({ ...ReadParams.fields, limit: limitUpTo(config.maxLines) }),
21
+ ls: Schema.Struct({ ...LsParams.fields, limit: limitUpTo(config.lsEntries) }),
22
+ find: Schema.Struct({ ...FindParams.fields, limit: limitUpTo(config.findResults) }),
23
+ grep: Schema.Struct({ ...GrepParams.fields, limit: limitUpTo(config.grepMatches) })
24
+ }), toolDescriptions = (config, resizesImages = !1) => ({
25
+ read: `Read a text file of the workspace${config.readImages ? ", or look at an image" : ""}. Shows at most ${config.maxLines} lines or ${formatBytes(config.maxBytes)} at a time; when more is left, the result says which offset to continue from. offset is the first line to show (1-based), limit how many lines (at most ${config.maxLines}). ${config.readImages ? resizesImages ? `A PNG, JPEG, GIF or WebP image is returned as an image you can see; larger images are scaled down to fit ${config.imageMaxSide} pixels a side and ${MEDIA_LIMITS.imageBytes / 1048576} MB. Files over ${formatBytes(config.maxFileBytes)} are refused before resizing. Another binary file is reported, not shown.` : `A PNG, JPEG, GIF or WebP image (up to ${imageLimit(config)} and ${MEDIA_LIMITS.maxPixels} pixels a side) is returned as an image you can see; another binary file is reported, not shown.` : "A binary file is reported, not shown."} To see a directory, use ls.`,
20
26
  write: "Create a file, or replace all of an existing one, with content; missing directories are created. To change part of a file, use edit. Emptying a non-empty file is refused.",
21
27
  edit: "Change part of a file by replacing exact text. Each edit's oldText must appear exactly once in the file: copy it from read (without the notice lines), with enough surrounding lines to be unique. All edits are matched against the file as it was before this call and must not overlap; newText replaces oldText (empty to delete it). Line endings and a byte-order mark are kept.",
22
- ls: `List a directory (by default the workspace root): one entry per line, directories ending in /, symbolic links in @. At most ${config.lsEntries} entries.`,
23
- find: `Find files whose path matches a glob pattern, relative to path (by default the workspace root): * and ? match within a name, ** any number of directories, {a,b} either; e.g. **/*.ts or src/**/test_*.py. Never looks inside ${listed(config.exclude)} or symbolic links. At most ${config.findResults} paths.`,
24
- grep: `Search file contents for a regular expression (JavaScript syntax), or for plain text with literal: true. path is a file or a directory (by default the workspace root); glob limits the files searched (*.ts searches every directory). Shows path:line:text for each matching line, and context lines around each match when asked. Lines are cut to ${config.grepLineChars} characters before matching. At most ${config.grepMatches} matching lines; binary files, ${listed(config.exclude)} and symbolic links are skipped.`,
28
+ ls: `List a directory (by default the workspace root): one entry per line, directories ending in /, symbolic links in @. At most ${config.lsEntries} entries; limit (1 to ${config.lsEntries}) asks for fewer.`,
29
+ find: `Find files whose path matches a glob pattern, relative to path (by default the workspace root): * and ? match within a name, ** any number of directories, {a,b} either; e.g. **/*.ts or src/**/test_*.py. Never looks inside ${inWords([...config.exclude, "symbolic links"], "or")}. At most ${config.findResults} paths; limit (1 to ${config.findResults}) asks for fewer.`,
30
+ grep: `Search file contents for a regular expression (${config.grepEngine === "ripgrep" ? "ripgrep syntax: no lookaround or backreferences" : "JavaScript syntax"}), or for plain text with literal: true. path is a file or a directory (by default the workspace root); glob limits the files searched (*.ts searches every directory). Shows path:line:text for each matching line, and context lines around each match when asked. ${config.grepEngine === "ripgrep" ? `Lines longer than ${config.grepLineChars} characters are shown cut. The search stops after ${seconds(config.grepTimeoutMs)}.` : `Lines are cut to ${config.grepLineChars} characters before matching.`} At most ${config.grepMatches} matching lines; limit (1 to ${config.grepMatches}) asks for fewer. ${inWords(["Binary files", ...config.exclude, "symbolic links"], "and")} are skipped, and files that cannot be read are named.`,
25
31
  bash: `Run a shell command with bash -c in the workspace root. timeout is in seconds (default ${seconds(config.bashTimeoutMs)}, at most ${seconds(config.bashMaxTimeoutMs)}); stdin is written to the command's standard input. Returns the exit code and the output, stdout and stderr in the order they came. Output longer than ${config.maxLines} lines or ${formatBytes(config.maxBytes)} is cut to its end${config.spillDir === null ? "" : `, and the whole of it (up to ${formatBytes(config.maxFileBytes)}) is saved to a file you can read`}. A non-zero exit code is a result to read, not a failure. Anything the command leaves running may be stopped when it returns.`
26
32
  }), environmentText = (root, config, tools) => {
27
- const has = (tool) => tools.includes(tool);
33
+ const has = (tool) => tools.includes(tool), fileTools = tools.some((tool) => tool !== "bash"), extra = (access) => config.roots.filter((root) => root.access === access).map((root) => root.path), readable = extra("read"), writable = extra("write"), rootAccess = [
34
+ ...readable.length > 0 ? [`read ${inWords(readable, "and")}`] : [],
35
+ ...writable.length > 0 ? [`read and change ${inWords(writable, "and")}`] : []
36
+ ];
28
37
  return [
29
38
  "## Workspace",
30
- `The workspace is ${root}. Give paths relative to it${config.confine ? "; the tools refuse paths that lead outside it." : "."}`,
39
+ `The workspace is ${root}. Give paths relative to it${config.confine && fileTools ? config.roots.length === 0 ? "; the file tools refuse paths that lead outside it." : "; the file tools refuse paths that lead outside it and the allowed roots." : "."}`,
40
+ ...config.confine && fileTools && rootAccess.length > 0 ? [`${rootAccess.map((access) => `The file tools may also ${access}.`).join(" ")} Extra roots are relative to the workspace unless absolute.`] : [],
41
+ ...config.confine && config.workspaceAccess === "read" && (has("write") || has("edit")) ? ["write and edit change nothing in the workspace unless a writable extra root permits it."] : [],
31
42
  ...has("read") ? [`read shows a file ${config.maxLines} lines or ${formatBytes(config.maxBytes)} at a time; continue with the offset it gives.`] : [],
32
- ...has("find") || has("grep") ? [`find and grep never look inside ${listed(config.exclude)}.`] : [],
43
+ ...(has("find") || has("grep")) && config.exclude.length > 0 ? [`find and grep never look inside ${config.exclude.join(", ")}.`] : [],
44
+ ...has("grep") && config.grepEngine === "ripgrep" && config.grepIgnoreFiles ? ["grep honours .gitignore, .ignore and other ripgrep ignore files."] : [],
33
45
  ...has("edit") ? ["edit replaces exact text: read the file first and copy the text to change from it."] : [],
34
- ...has("write") || has("edit") ? [config.protect.length === 0 ? "" : `write and edit leave ${listed(config.protect)} alone.`] : [],
35
- ...has("bash") ? [`bash runs commands in the workspace root with a ${seconds(config.bashTimeoutMs)} timeout.${config.spillDir === null ? "" : " When their output is cut, the whole of it is saved to a file under " + config.spillDir + " that read can open."}`] : []
46
+ ...has("write") || has("edit") ? [config.protect.length === 0 ? "" : `write and edit leave ${config.protect.join(", ")} alone in the workspace.`] : [],
47
+ ...has("bash") ? [`bash runs commands in the workspace root with a ${seconds(config.bashTimeoutMs)} timeout.${config.spillDir === null ? "" : " When their output is cut, the whole of it is saved to a file under " + config.spillDir + " that read can open."} Shell commands are not confined by the file tools' roots or read-only access.`] : []
36
48
  ].filter((line) => line.length > 0).join(`
37
49
  `);
38
50
  }, bashEnvironment = (config, session) => ({
@@ -44,12 +56,25 @@ export const toolDescriptions = (config) => ({
44
56
  ...config.bashEnv
45
57
  },
46
58
  inheritEnv: config.bashInheritEnv ? !0 : config.bashEnvAllow
47
- }), binaryRead = (path, bytes, readImages) => Option.match(readImages ? sniffImageType(bytes) : Option.none(), {
48
- onNone: () => Effect.succeed({ _tag: "Binary", path, bytes: bytes.length, mediaType: Option.getOrNull(mediaTypeOf(path)) }),
49
- onSome: (mediaType) => mediaFromBytes(bytes, { name: path }).pipe(Effect.match({
59
+ }), binaryRead = Effect.fnUntraced(function* (path, bytes, readImages, nameType, fit = Option.none(), maxSide = MEDIA_LIMITS.maxPixels) {
60
+ const type = readImages ? sniffImageType(bytes) : Option.none();
61
+ if (Option.isNone(type))
62
+ return { _tag: "Binary", path, bytes: bytes.length, mediaType: nameType };
63
+ const mediaType = type.value, initial = yield* Effect.result(mediaFromBytes(bytes, { name: path })), size = imageSizeOf(bytes, mediaType), ordinary = Result.match(initial, {
50
64
  onSuccess: (image) => ({ _tag: "Image", path, image }),
51
65
  onFailure: (rejected) => ({ _tag: "ImageRejected", path, mediaType, bytes: bytes.length, reason: rejected.reason, message: rejected.message })
52
- }))
66
+ });
67
+ if (Option.isNone(fit) || Option.isNone(size) || Result.isSuccess(initial) && Math.max(size.value.width, size.value.height) <= maxSide)
68
+ return ordinary;
69
+ const source = size.value, target = fitTarget(maxSide), fitted = yield* Effect.result(fit.value({ bytes, mediaType }, target).pipe(Effect.flatMap((output) => mediaFromBytes(output, { name: path }).pipe(Effect.flatMap((image) => {
70
+ const actual = imageSizeOf(output, image.mediaType);
71
+ return Option.isSome(actual) && Math.max(actual.value.width, actual.value.height) <= target.maxSide ? Effect.succeed(image) : Effect.fail(new ImageResizeError({ reason: "Failed", message: "the fitted image exceeds the requested pixel limit" }));
72
+ })))));
73
+ if (Result.isFailure(fitted)) {
74
+ yield* Effect.logWarning("read: the image could not be fitted", fitted.failure);
75
+ return Result.isFailure(initial) ? ordinary : { _tag: "ImageRejected", path, mediaType, bytes: bytes.length, reason: "TooManyPixels", message: `the image is ${source.width}x${source.height} pixels, more than ${maxSide} a side` };
76
+ }
77
+ return { _tag: "Image", path, image: fitted.success, original: { mediaType, bytes: bytes.length, width: source.width, height: source.height } };
53
78
  }), fileTooLargeHint = (path, readImages) => readImages && Option.exists(mediaTypeOf(path), Schema.is(ImageMediaType)) ? "to see it, make a smaller copy with bash (scaled down or recompressed) and read that" : "use grep to find lines in it, or bash (head, tail, sed -n) for parts of it", readMedia = (result) => Match.valueTags(result, {
54
79
  Text: () => [],
55
80
  Binary: () => [],
@@ -68,7 +93,7 @@ const rejectedHint = {
68
93
  };
69
94
  export const renderRead = (result) => Match.valueTags(result, {
70
95
  Binary: (binary) => `[${binary.path} is a binary file (${formatBytes(binary.bytes)}${binary.mediaType === null ? "" : `, ${binary.mediaType}`}); it cannot be shown as text.]`,
71
- Image: (read) => `[Image ${read.path} (${read.image.mediaType}, ${formatBytes(read.image.bytes)}).]`,
96
+ Image: (read) => `[Image ${read.path} (${read.image.mediaType}, ${formatBytes(read.image.bytes)})${read.original === void 0 ? "" : `, scaled down from ${read.original.width}x${read.original.height} (${read.original.mediaType}, ${formatBytes(read.original.bytes)})`}.]`,
72
97
  ImageRejected: (read) => `[${read.path} is an image (${read.mediaType}, ${formatBytes(read.bytes)}) that read cannot show: ${read.message}.${rejectedHint[read.reason]}]`,
73
98
  Text: (text) => text.totalLines === 0 ? `[${text.path} is empty.]` : [
74
99
  text.text,
@@ -77,10 +102,21 @@ export const renderRead = (result) => Match.valueTags(result, {
77
102
  ...text.next === null ? [] : [`[Showing lines ${text.firstLine}-${text.lastLine} of ${text.totalLines}. Use offset=${text.next} to continue.]`]
78
103
  ].join(`
79
104
  `)
80
- }), compactRead = (result) => Match.valueTags(result, {
105
+ });
106
+ const rejectedWhy = {
107
+ TooLarge: "too large to send",
108
+ TooManyPixels: "more pixels a side than the limit",
109
+ Unreadable: "its header gives no size",
110
+ Empty: "it has no bytes",
111
+ InvalidBase64: "its data is not base64",
112
+ UnsupportedType: "not a PNG, JPEG, GIF or WebP image",
113
+ TooMany: "too many images at once",
114
+ TooLargeTogether: "too large together with the other images"
115
+ };
116
+ export const compactRead = (result) => Match.valueTags(result, {
81
117
  Binary: (binary) => `(read ${binary.path}: binary, ${formatBytes(binary.bytes)})`,
82
- Image: (read) => `(read ${read.path}: ${read.image.mediaType} image, ${formatBytes(read.image.bytes)})`,
83
- ImageRejected: (read) => `(read ${read.path}: ${read.mediaType} image not shown, ${read.reason})`,
118
+ Image: (read) => `(read ${read.path}: ${read.image.mediaType} image, ${formatBytes(read.image.bytes)}${read.original === void 0 ? "" : `, scaled down from ${read.original.width}x${read.original.height} (${read.original.mediaType}, ${formatBytes(read.original.bytes)})`})`,
119
+ ImageRejected: (read) => `(read ${read.path}: ${read.mediaType} image not shown, ${rejectedWhy[read.reason]})`,
84
120
  Text: (text) => `(read ${text.path}: lines ${text.firstLine}-${text.lastLine} of ${text.totalLines})`
85
121
  }), renderWrite = (result) => `${result.created ? "Created" : "Replaced"} ${result.path} (${formatBytes(result.bytes)}).`, compactWrite = (result) => `(${result.created ? "created" : "replaced"} ${result.path}: ${formatBytes(result.bytes)})`, renderEdit = (result) => `Replaced ${result.replaced} block${result.replaced === 1 ? "" : "s"} in ${result.path}; the first change is at line ${result.firstChangedLine}.${result.normalized ? " Some text matched only once whitespace and quotes were normalised: read the changed lines to check them." : ""}`, compactEdit = (result) => `(edited ${result.path}: ${result.replaced} block${result.replaced === 1 ? "" : "s"} from line ${result.firstChangedLine})`;
86
122
  const lsSuffix = { file: "", directory: "/", symlink: "@", other: "" };
@@ -88,29 +124,47 @@ export const renderLs = (result) => result.total === 0 ? `[${result.path} is emp
88
124
  ...result.entries.map((entry) => `${entry.name}${lsSuffix[entry.kind]}`),
89
125
  ...result.entries.length < result.total ? [`[Showing ${result.entries.length} of ${result.total} entries. Use find with a pattern to narrow them down.]`] : []
90
126
  ].join(`
91
- `), compactLs = (result) => `(listed ${result.path}: ${result.total} entries)`, renderFind = (result, maxLines, maxBytes) => {
127
+ `), compactLs = (result) => `(listed ${result.path}: ${counted(result.total, "entry", "entries")})`, renderFind = (result, maxLines, maxBytes) => {
92
128
  if (result.paths.length === 0)
93
- return `No files under ${result.path} match ${result.pattern}.`;
129
+ return `No files under ${place(result.path)} match ${result.pattern}.`;
94
130
  const head = headLines(result.paths, maxLines, maxBytes);
95
131
  return [
96
132
  ...head.lines,
97
- ...head.omitted > 0 ? [`[${head.omitted} more paths not shown. Narrow the pattern or the path.]`] : result.truncated ? [`[Stopped at ${result.paths.length} paths; there are more. Narrow the pattern or the path.]`] : []
133
+ ...head.omitted > 0 ? [`[${counted(head.omitted, "more path", "more paths")} not shown. Narrow the pattern or the path.]`] : result.truncated ? [`[Stopped at ${counted(result.paths.length, "path", "paths")}; there are more. Narrow the pattern or the path.]`] : []
98
134
  ].join(`
99
135
  `);
100
- }, compactFind = (result) => `(find ${result.pattern} in ${result.path}: ${result.paths.length}${result.truncated ? "+" : ""} paths)`, renderGrep = (result, withContext, maxLines, maxBytes) => {
101
- const skipped = result.skipped === 0 ? [] : [`[${result.skipped} file${result.skipped === 1 ? "" : "s"} skipped: binary, larger than the read limit, or leading outside the workspace.]`];
136
+ }, compactFind = (result) => `(find ${result.pattern} in ${result.path}: ${result.truncated ? `${result.paths.length}+ paths` : counted(result.paths.length, "path", "paths")})`;
137
+ const unreadableWhy = {
138
+ NotFound: "no longer there",
139
+ NotDirectory: "no longer there",
140
+ Permission: "permission denied",
141
+ Invalid: "not a path the file system accepts",
142
+ Exists: "a file-system error",
143
+ IsDirectory: "a file-system error",
144
+ NotEmpty: "a file-system error",
145
+ Io: "a file-system error"
146
+ }, unreadableLines = (result) => Array.from(new Set(result.unreadable.map((file) => unreadableWhy[file.reason])), (why) => {
147
+ const paths = result.unreadable.filter((file) => unreadableWhy[file.reason] === why).map((file) => file.path);
148
+ return `[Could not read ${paths.length} file${paths.length === 1 ? "" : "s"}, ${why}: ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? `, and ${paths.length - 5} more` : ""}.]`;
149
+ });
150
+ export const renderGrep = (result, withContext, maxLines, maxBytes) => {
151
+ const skipped = [
152
+ ...result.timedOut ? ["[Search timed out; these are partial results. Narrow the pattern or the path and search again.]"] : [],
153
+ ...result.skipped === 0 ? [] : [`[${result.skipped} file${result.skipped === 1 ? "" : "s"} skipped: binary, larger than the read limit, or leading outside the workspace.]`],
154
+ ...unreadableLines(result)
155
+ ];
102
156
  if (result.matches === 0)
103
- return [`No matches for ${result.pattern} in ${result.path}.`, ...skipped].join(`
157
+ return [`No matches for ${result.pattern} in ${place(result.path)}.`, ...skipped].join(`
104
158
  `);
105
159
  const head = headLines(renderSearchLines(result.lines, withContext), maxLines, maxBytes);
106
160
  return [
107
161
  ...head.lines,
108
- ...head.omitted > 0 ? [`[${head.omitted} more lines not shown. Narrow the pattern or the path.]`] : [],
109
- ...result.truncated ? [`[Stopped at ${result.matches} matching lines; there are more. Narrow the pattern or the path, or raise limit.]`] : [],
162
+ ...head.omitted > 0 ? [`[${counted(head.omitted, "more line", "more lines")} not shown. Narrow the pattern or the path.]`] : [],
163
+ ...result.truncated ? [`[Stopped at ${counted(result.matches, "matching line", "matching lines")}; there are more. Narrow the pattern or the path, or raise limit.]`] : [],
110
164
  ...skipped
111
165
  ].join(`
112
166
  `);
113
- }, compactGrep = (result) => `(grep ${result.pattern} in ${result.path}: ${result.matches}${result.truncated ? "+" : ""} matching lines in ${result.files} files)`;
167
+ }, compactGrep = (result) => `(grep ${result.pattern} in ${result.path}: ${result.truncated ? `${result.matches}+ matching lines` : counted(result.matches, "matching line", "matching lines")} in ${counted(result.files, "file", "files")}${result.timedOut ? "; timed out, partial results" : ""})`;
114
168
  const endOf = (result) => result.termination === "timeout" ? `[The command was killed after ${result.durationMs < 1e4 ? `${(result.durationMs / 1000).toFixed(1)} s` : seconds(result.durationMs)}, at its timeout.]` : result.termination === "interrupted" ? "[The command was stopped before it finished.]" : result.termination === "signal" ? `[The command was killed by ${result.signal ?? "a signal"}.]` : `[Exit code ${result.exitCode ?? "unknown"}.]`, cutOf = (result) => {
115
169
  const shownLines = result.output.length === 0 ? 0 : result.output.split(`
116
170
  `).length, what = shownLines < result.totalLines ? `Output cut to its last ${shownLines} of ${result.totalLines} lines.` : "Output cut: only the end of its last line is shown.", saved = result.fullOutput === null ? "" : result.fullOutputCut ? ` Only its start was saved, in ${result.fullOutput}: the output grew past the size read opens, and the rest was not kept. Read the file with offset to see that start; to see the rest, run the command again with its output filtered (grep, tail, sed -n).` : ` The whole output is in ${result.fullOutput}; read it with offset to see the rest.`;
@@ -121,4 +175,6 @@ export const renderBash = (result) => [
121
175
  ...result.truncated ? [cutOf(result)] : [],
122
176
  endOf(result)
123
177
  ].join(`
124
- `), compactBash = (result) => `(ran ${result.command.length > 80 ? `${result.command.slice(0, 80)}\u2026` : result.command}: ${result.termination === "exit" ? `exit ${result.exitCode ?? "?"}` : result.termination}${result.fullOutput === null ? "" : `; output in ${result.fullOutput}`})`;
178
+ `);
179
+ const endedIn = (result) => Match.value(result.termination).pipe(Match.when("exit", () => `exit ${result.exitCode ?? "?"}`), Match.when("timeout", () => "killed at its timeout"), Match.when("signal", () => `killed by ${result.signal ?? "a signal"}`), Match.when("interrupted", () => "stopped before it finished"), Match.exhaustive);
180
+ export const compactBash = (result) => `(ran ${result.command.length > 80 ? `${result.command.slice(0, 80)}\u2026` : result.command}: ${endedIn(result)}${result.fullOutput === null ? "" : `; output in ${result.fullOutput}`})`;
@@ -1,11 +1,14 @@
1
- import { Result, Schema } from "effect";
2
- import { AttachmentRejectedReason, BASH_ENV_ALLOWLIST, BashTermination, compileGlob, ImageMediaType, Media } from "@xandreed/core";
1
+ import { Effect, Result, Schema } from "effect";
2
+ import { AttachmentRejectedReason, BASH_ENV_ALLOWLIST, MEDIA_LIMITS, BashTermination, compileGlob, FsReason, ImageMediaType, Media } from "@xandreed/core";
3
+ import { WorkspaceAccessMode, WorkspaceAllowedRoot } from "./workspace-path.entity.js";
3
4
  export const WorkspaceToolName = Schema.Literals(["read", "write", "edit", "ls", "find", "grep", "bash"]), WorkspaceSkillName = Schema.Literals(["read", "edit", "shell"]), WORKSPACE_PERMISSIONS = { write: "fs.write", exec: "shell.exec" };
4
5
  const atLeast = (minimum) => Schema.Int.check(Schema.isGreaterThanOrEqualTo(minimum)), between = (minimum, maximum) => Schema.Int.check(Schema.isBetween({ minimum, maximum })), InsideWorkspace = Schema.String.check(Schema.makeFilter((path) => path.length > 0 && !path.startsWith("/") && !path.split("/").includes("..") ? !0 : "a path relative to the workspace root, without a .. segment")), Glob = Schema.String.check(Schema.makeFilter((glob) => Result.match(compileGlob(glob, { dot: !0 }), { onSuccess: () => !0, onFailure: (reason) => `not a usable glob: ${reason}` })));
5
6
  export const WorkspaceToolsConfig = Schema.Struct({
6
7
  tools: Schema.Array(WorkspaceToolName),
7
8
  always: Schema.Array(WorkspaceSkillName),
8
9
  confine: Schema.Boolean,
10
+ workspaceAccess: WorkspaceAccessMode,
11
+ roots: Schema.Array(WorkspaceAllowedRoot),
9
12
  protect: Schema.Array(Glob),
10
13
  exclude: Schema.Array(Glob),
11
14
  maxLines: between(1, 1e5),
@@ -14,8 +17,12 @@ export const WorkspaceToolsConfig = Schema.Struct({
14
17
  findResults: between(1, 1e5),
15
18
  grepMatches: between(1, 1e4),
16
19
  grepLineChars: between(16, 1e5),
20
+ grepEngine: Schema.Literals(["auto", "ripgrep", "builtin"]),
21
+ grepTimeoutMs: atLeast(1),
22
+ grepIgnoreFiles: Schema.Boolean,
17
23
  maxFileBytes: between(1024, 1073741824),
18
24
  readImages: Schema.Boolean,
25
+ imageMaxSide: between(64, MEDIA_LIMITS.maxPixels),
19
26
  fuzzyEdit: Schema.Boolean,
20
27
  bashTimeoutMs: atLeast(1),
21
28
  bashMaxTimeoutMs: atLeast(1),
@@ -32,6 +39,8 @@ export const WorkspaceToolsConfig = Schema.Struct({
32
39
  tools: ["read", "write", "edit", "ls", "find", "grep", "bash"],
33
40
  always: ["read"],
34
41
  confine: !0,
42
+ workspaceAccess: "write",
43
+ roots: [],
35
44
  protect: [".git/**", ".efferent/**"],
36
45
  exclude: ["node_modules", ".git", ".efferent"],
37
46
  maxLines: 2000,
@@ -40,8 +49,12 @@ export const WorkspaceToolsConfig = Schema.Struct({
40
49
  findResults: 1000,
41
50
  grepMatches: 100,
42
51
  grepLineChars: 500,
52
+ grepEngine: "auto",
53
+ grepTimeoutMs: 30000,
54
+ grepIgnoreFiles: !1,
43
55
  maxFileBytes: 33554432,
44
56
  readImages: !0,
57
+ imageMaxSide: MEDIA_LIMITS.maxPixels,
45
58
  fuzzyEdit: !0,
46
59
  bashTimeoutMs: 120000,
47
60
  bashMaxTimeoutMs: 600000,
@@ -76,7 +89,13 @@ export const WorkspaceToolsConfig = Schema.Struct({
76
89
  }),
77
90
  Schema.TaggedStruct("Image", {
78
91
  path: Schema.String,
79
- image: Media
92
+ image: Media,
93
+ original: Schema.optionalKey(Schema.Struct({
94
+ mediaType: ImageMediaType,
95
+ bytes: atLeast(1),
96
+ width: atLeast(1),
97
+ height: atLeast(1)
98
+ }))
80
99
  }),
81
100
  Schema.TaggedStruct("ImageRejected", {
82
101
  path: Schema.String,
@@ -99,14 +118,16 @@ export const WorkspaceToolsConfig = Schema.Struct({
99
118
  literal: Schema.optionalKey(Schema.Boolean),
100
119
  context: Schema.optionalKey(between(0, 10)),
101
120
  limit: Schema.optionalKey(atLeast(1))
102
- }), GrepLine = Schema.Struct({ path: Schema.String, line: Schema.Int, text: Schema.String, match: Schema.Boolean }), GrepResult = Schema.Struct({
121
+ }), GrepLine = Schema.Struct({ path: Schema.String, line: Schema.Int, text: Schema.String, match: Schema.Boolean }), GrepUnreadable = Schema.Struct({ path: Schema.String, reason: FsReason }), GrepResult = Schema.Struct({
103
122
  pattern: Schema.String,
104
123
  path: Schema.String,
105
124
  lines: Schema.Array(GrepLine),
106
125
  matches: Schema.Int,
107
126
  files: Schema.Int,
108
127
  truncated: Schema.Boolean,
109
- skipped: Schema.Int
128
+ skipped: Schema.Int,
129
+ unreadable: Schema.Array(GrepUnreadable).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
130
+ timedOut: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(!1)))
110
131
  }), BashParams = Schema.Struct({
111
132
  command: Schema.String,
112
133
  timeout: Schema.optionalKey(atLeast(1)),
@@ -6,10 +6,10 @@ import { decodeText, encodeText, formatBytes, looksBinary, utf8Length } from "./
6
6
  import { checkWritable, fsFailure, openWorkspace, resolveTarget } from "./workspace-path.entity.functions.js";
7
7
  import { EditParams, EditResult, WORKSPACE_PERMISSIONS, WriteParams, WriteResult } from "./workspace-tools.entity.js";
8
8
  import { compactEdit, compactWrite, renderEdit, renderWrite, toolDescriptions } from "./workspace-tools.entity.functions.js";
9
- import { failure, fileSubjects, notAFile } from "./read-tools.adapter.js";
9
+ import { failure, fileSubjects, notAFile, WORKSPACE_SERVICES } from "./read-tools.adapter.js";
10
10
  const WriteTool = (description) => Tool.make("write", { description, parameters: WriteParams, success: WriteResult, failure: Failure, failureMode: "return" }), EditTool = (description) => Tool.make("edit", { description, parameters: EditParams, success: EditResult, failure: Failure, failureMode: "return" }), holding = (context, path) => (change) => Effect.scoped(RcMap.get(context.locks, path).pipe(Effect.flatMap((lock) => lock.withPermits(1)(change)))), existing = (access, target) => access.fs.stat(target.path).pipe(Effect.map(Option.some), Effect.catchIf((error) => error.reason === "NotFound", () => Effect.succeed(Option.none())), Effect.mapError(fsFailure(target.display))), notForNewFiles = 3657, save = (access, target, content, before) => {
11
11
  const mode = Option.flatMap(before, (info) => info.mode), inPlace = access.fs.write(target.path, content), replaced = Effect.gen(function* () {
12
- const suffix = (yield* Random.nextIntBetween(0, 2147483647)).toString(36), temporary = access.path.join(access.path.dirname(target.path), `.${access.path.basename(target.path)}.${suffix}.efferent-tmp`), discard = access.fs.remove(temporary).pipe(Effect.ignore);
12
+ const suffix = (yield* Random.nextIntBetween(0, 2147483647)).toString(36), temporary = access.path.join(access.path.dirname(target.path), `.${access.path.basename(target.path)}.${suffix}.efferent-tmp`), discard = access.fs.remove(temporary).pipe(Effect.ignore({ log: "Warn", message: `the temporary file ${temporary} was not removed` }));
13
13
  yield* access.fs.write(temporary, content, { createParents: !0 });
14
14
  if (!(yield* Option.match(mode, {
15
15
  onNone: () => Effect.succeed(!0),
@@ -22,13 +22,14 @@ const WriteTool = (description) => Tool.make("write", { description, parameters:
22
22
  };
23
23
  export const writeTool = (context) => defineTool({
24
24
  tool: WriteTool(toolDescriptions(context.config).write),
25
+ requires: WORKSPACE_SERVICES,
25
26
  handler: (params) => Effect.gen(function* () {
26
- const access = yield* openWorkspace(context.path), target = yield* resolveTarget(access, params.path, { confine: context.config.confine });
27
+ const access = yield* openWorkspace(context.path, context.config), target = yield* resolveTarget(access, params.path, { confine: context.config.confine, intent: "write" });
27
28
  yield* checkWritable(access, target, context.protect);
28
29
  return yield* holding(context, target.path)(Effect.gen(function* () {
29
30
  const before = yield* existing(access, target);
30
31
  if (Option.exists(before, (info) => info.kind === "directory"))
31
- return yield* Effect.fail(failure("IsDirectory", `${target.display} is a directory`));
32
+ return yield* Effect.fail(failure("IsDirectory", `${target.display} is a directory; write takes the path of a file`));
32
33
  if (Option.exists(before, (info) => info.kind !== "file"))
33
34
  return yield* Effect.fail(notAFile(target.display, "write"));
34
35
  if (params.content.length === 0 && Option.exists(before, (info) => info.size > 0))
@@ -41,8 +42,9 @@ export const writeTool = (context) => defineTool({
41
42
  annotations: { permissions: [WORKSPACE_PERMISSIONS.write] }
42
43
  }), editTool = (context) => defineTool({
43
44
  tool: EditTool(toolDescriptions(context.config).edit),
45
+ requires: WORKSPACE_SERVICES,
44
46
  handler: (params) => Effect.gen(function* () {
45
- const { config } = context, access = yield* openWorkspace(context.path), target = yield* resolveTarget(access, params.path, { confine: config.confine });
47
+ const { config } = context, access = yield* openWorkspace(context.path, context.config), target = yield* resolveTarget(access, params.path, { confine: config.confine, intent: "write" });
46
48
  yield* checkWritable(access, target, context.protect);
47
49
  return yield* holding(context, target.path)(Effect.gen(function* () {
48
50
  const before = yield* existing(access, target), info = yield* Option.match(before, {
@@ -50,14 +52,14 @@ export const writeTool = (context) => defineTool({
50
52
  onSome: Effect.succeed
51
53
  });
52
54
  if (info.kind === "directory")
53
- return yield* Effect.fail(failure("IsDirectory", `${target.display} is a directory`));
55
+ return yield* Effect.fail(failure("IsDirectory", `${target.display} is a directory; edit takes the path of a file`));
54
56
  if (info.kind !== "file")
55
57
  return yield* Effect.fail(notAFile(target.display, "edit"));
56
58
  if (info.size > config.maxFileBytes)
57
- return yield* Effect.fail(failure("FileTooLarge", `${target.display} is ${formatBytes(info.size)}, more than the ${formatBytes(config.maxFileBytes)} edit loads`));
59
+ return yield* Effect.fail(failure("FileTooLarge", `${target.display} is ${formatBytes(info.size)}, more than the ${formatBytes(config.maxFileBytes)} edit loads; change it with bash (sed -i, or a script)`));
58
60
  const bytes = yield* access.fs.readBytes(target.path).pipe(Effect.mapError(fsFailure(target.display)));
59
61
  if (looksBinary(bytes))
60
- return yield* Effect.fail(failure("Binary", `${target.display} is a binary file; edit changes text files only`));
62
+ return yield* Effect.fail(failure("BinaryFile", `${target.display} is a binary file; edit changes text files only`));
61
63
  const decoded = decodeText(bytes);
62
64
  if (decoded.lossy)
63
65
  return yield* Effect.fail(failure("NotUtf8", `${target.display} is not valid UTF-8 (Latin-1 or another encoding, say); edit changes UTF-8 text files only, since it would replace the bytes it cannot decode. Use bash (sed, iconv) to change it`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xandreed/plugin-workspace",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "engines": {
5
5
  "node": ">=24",
6
6
  "bun": ">=1.3.0"
@@ -14,12 +14,13 @@
14
14
  "default": "./dist/index.js"
15
15
  }
16
16
  },
17
- "dependencies": {
18
- "@xandreed/core": "0.14.0",
19
- "effect": "4.0.7"
20
- },
17
+ "dependencies": {},
21
18
  "private": false,
22
19
  "types": "./dist/index.d.ts",
20
+ "peerDependencies": {
21
+ "@xandreed/core": "^0.15.0",
22
+ "effect": "^4.0.7"
23
+ },
23
24
  "files": [
24
25
  "dist",
25
26
  "skills",
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};