@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,47 @@
1
+ import { Schema } from "effect";
2
+ /**
3
+ * How `BashLive` (and `localSystemPlugin`) runs commands. `shell` is the
4
+ * program commands run with (`<shell> -c <command>`): a name looked up once,
5
+ * when the layer is built, on the parent environment's `PATH` (its absolute
6
+ * entries), or an absolute path; there is no fallback, so a host without
7
+ * bash sets `shell: "sh"` (or another path) itself. With `killBackground`, a
8
+ * command's whole process group is killed when the command returns, so
9
+ * nothing it started in the background outlives it (a timeout or an
10
+ * interrupted caller always kills the group). `exitGraceMs` is how long,
11
+ * after the command exits, its output may take to close (a background
12
+ * process that kept it open) before the stream ends without the rest. The
13
+ * output pipes close then, so a background process kept with
14
+ * `killBackground: false` must have its own output (`cmd > log 2>&1 &`):
15
+ * one still writing to the command's dies of `SIGPIPE` or gets `EPIPE`.
16
+ *
17
+ * @public
18
+ */
19
+ export declare const LocalSystemConfig: Schema.Struct<{
20
+ readonly shell: Schema.NonEmptyString;
21
+ readonly killBackground: Schema.Boolean;
22
+ readonly exitGraceMs: Schema.Int;
23
+ /** The host's base environment, or null to inherit from the agent process. Filtered per command by `resolveBashEnv`. */
24
+ readonly parentEnv: Schema.NullOr<Schema.$Record<Schema.String, Schema.String>>;
25
+ }>;
26
+ export type LocalSystemConfig = typeof LocalSystemConfig.Type;
27
+ /** @public */
28
+ export declare const localSystemDefaults: LocalSystemConfig;
29
+ /**
30
+ * `BashLive`'s options: the plugin's configuration, each part optional, and
31
+ * `parentEnv`, the environment commands inherit from (filtered by
32
+ * `resolveBashEnv`) and the shell is looked up on; `process.env` when not
33
+ * given or null.
34
+ *
35
+ * @public
36
+ */
37
+ export type BashLiveOptions = Partial<Omit<LocalSystemConfig, "parentEnv">> & {
38
+ readonly parentEnv?: Readonly<Record<string, string | undefined>> | null;
39
+ };
40
+ /** How a spawned process ended, as Node reports it, and whether the adapter killed it for running past its timeout. */
41
+ export declare const ProcessEnd: Schema.Struct<{
42
+ readonly code: Schema.NullOr<Schema.Int>;
43
+ readonly signal: Schema.NullOr<Schema.String>;
44
+ readonly timedOut: Schema.Boolean;
45
+ readonly durationMs: Schema.Number;
46
+ }>;
47
+ export type ProcessEnd = typeof ProcessEnd.Type;
@@ -0,0 +1,53 @@
1
+ import { Option, Result } from "effect";
2
+ import type { BashExit, FsReason } from "@xandreed/core";
3
+ import type { ProcessEnd } from "./local-system.entity.js";
4
+ /** A thrown value's message, for the `message` of the error it becomes. */
5
+ export declare const errorMessageOf: (error: unknown) => string;
6
+ /**
7
+ * The `FsReason` of a failed node:fs call, from its error code: `ENOENT`
8
+ * is `NotFound`, `EEXIST` `Exists`, `EISDIR` (and Node's `ERR_FS_EISDIR`)
9
+ * `IsDirectory`, `ENOTDIR` `NotDirectory`, `ENOTEMPTY` `NotEmpty`, `EACCES`
10
+ * and `EPERM` `Permission`; `EINVAL`, a symbolic link loop (`ELOOP`), a
11
+ * name too long and an unusable argument (a path with a NUL byte) are
12
+ * `Invalid`; anything else, or no code at all, is `Io`.
13
+ *
14
+ * @public
15
+ */
16
+ export declare const fsReasonOf: (error: unknown) => FsReason;
17
+ export declare const rgReasonOf: (code: number) => FsReason;
18
+ /**
19
+ * Whether a write to a command's standard input failed only because the
20
+ * command no longer reads it: it exited or closed its input first (`EPIPE`),
21
+ * or the stream was already torn down (`ERR_STREAM_DESTROYED`). The command
22
+ * is free to ignore its input, so these are not failures.
23
+ */
24
+ export declare const isClosedInput: (error: unknown) => boolean;
25
+ /**
26
+ * Where to look for an executable: an absolute path is itself; a bare name
27
+ * is tried in each absolute directory of `path` (a `PATH` value), in order;
28
+ * relative and empty entries, which would depend on the current directory,
29
+ * are skipped, as is a relative name with a slash.
30
+ */
31
+ export declare const executableCandidates: (name: string, path: Option.Option<string>) => ReadonlyArray<string>;
32
+ /**
33
+ * Where `remove(path)` acts, or why it never does. Trailing slashes are
34
+ * ignored, so a link named with one is still the link. The root (`/`, `//`)
35
+ * is refused, and so is a path whose last segment is `.` or `..`: such a
36
+ * path names a directory only by where it leads (`<link>/..` can be the
37
+ * root), so it is never removed. Otherwise the place is the parent as
38
+ * written (to be resolved, links and all) and the last name, never followed.
39
+ */
40
+ export declare const removalPlace: (path: string) => Result.Result<{
41
+ readonly parent: string;
42
+ readonly name: string;
43
+ }, string>;
44
+ /** `name` in the directory `dir` (an absolute, resolved path). */
45
+ export declare const pathIn: (dir: string, name: string) => string;
46
+ /**
47
+ * A process's end as the `BashExit` the stream closes with. A process the
48
+ * adapter killed for its timeout ends `timeout` (it has no exit code, and its
49
+ * signal is the one that killed it); one a signal killed ends `signal`;
50
+ * otherwise it exited, with its code. A process that exited on its own just
51
+ * as its timeout came keeps its exit code and ends `exit`.
52
+ */
53
+ export declare const bashExitOf: (end: ProcessEnd) => BashExit;
@@ -0,0 +1,46 @@
1
+ import { Option, Result } from "effect";
2
+ import { errorCodeOf } from "@xandreed/core";
3
+ export const errorMessageOf = (error) => error instanceof Error ? error.message : String(error);
4
+ const reasons = new Map([
5
+ ["ENOENT", "NotFound"],
6
+ ["EEXIST", "Exists"],
7
+ ["EISDIR", "IsDirectory"],
8
+ ["ERR_FS_EISDIR", "IsDirectory"],
9
+ ["ENOTDIR", "NotDirectory"],
10
+ ["ENOTEMPTY", "NotEmpty"],
11
+ ["EACCES", "Permission"],
12
+ ["EPERM", "Permission"],
13
+ ["EINVAL", "Invalid"],
14
+ ["ELOOP", "Invalid"],
15
+ ["ENAMETOOLONG", "Invalid"],
16
+ ["ERR_INVALID_ARG_VALUE", "Invalid"]
17
+ ]);
18
+ export const fsReasonOf = (error) => Option.match(errorCodeOf(error), {
19
+ onNone: () => "Io",
20
+ onSome: (code) => reasons.get(code) ?? "Io"
21
+ });
22
+ const rgReasons = new Map([
23
+ [1, "Permission"],
24
+ [13, "Permission"],
25
+ [2, "NotFound"],
26
+ [20, "NotDirectory"],
27
+ [21, "IsDirectory"],
28
+ [22, "Invalid"],
29
+ [36, "Invalid"],
30
+ [40, "Invalid"],
31
+ [62, "Invalid"],
32
+ [63, "Invalid"]
33
+ ]);
34
+ export const rgReasonOf = (code) => rgReasons.get(code) ?? "Io", isClosedInput = (error) => Option.match(errorCodeOf(error), { onNone: () => !1, onSome: (code) => code === "EPIPE" || code === "ERR_STREAM_DESTROYED" }), executableCandidates = (name, path) => name.startsWith("/") ? [name] : name.includes("/") || name.length === 0 ? [] : Option.getOrElse(path, () => "").split(":").filter((dir) => dir.startsWith("/")).map((dir) => `${dir.endsWith("/") ? dir.slice(0, -1) : dir}/${name}`), removalPlace = (path) => {
35
+ const segments = path.split("/").filter((segment) => segment.length > 0);
36
+ return !path.startsWith("/") ? Result.fail(`paths must be absolute: ${path}`) : Option.match(Option.fromNullishOr(segments.at(-1)), {
37
+ onNone: () => Result.fail("the root directory is never removed"),
38
+ onSome: (name) => name === "." || name === ".." ? Result.fail(`a path whose last segment is ${name} is never removed: ${path}`) : Result.succeed({ parent: `/${segments.slice(0, -1).join("/")}`, name })
39
+ });
40
+ }, pathIn = (dir, name) => dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`, bashExitOf = (end) => ({
41
+ _tag: "BashExit",
42
+ termination: end.code !== null ? "exit" : end.timedOut ? "timeout" : end.signal !== null ? "signal" : "exit",
43
+ exitCode: Option.fromNullishOr(end.code),
44
+ signal: Option.fromNullishOr(end.signal),
45
+ durationMs: end.durationMs
46
+ });
@@ -0,0 +1,12 @@
1
+ import { Schema } from "effect";
2
+ export const LocalSystemConfig = Schema.Struct({
3
+ shell: Schema.NonEmptyString,
4
+ killBackground: Schema.Boolean,
5
+ exitGraceMs: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 0, maximum: 60000 }))),
6
+ parentEnv: Schema.NullOr(Schema.Record(Schema.String, Schema.String))
7
+ }), localSystemDefaults = { shell: "bash", killBackground: !0, exitGraceMs: 500, parentEnv: null }, ProcessEnd = Schema.Struct({
8
+ code: Schema.NullOr(Schema.Int),
9
+ signal: Schema.NullOr(Schema.String),
10
+ timedOut: Schema.Boolean,
11
+ durationMs: Schema.Number
12
+ });
@@ -0,0 +1,183 @@
1
+ import { Layer } from "effect";
2
+ import { Bash, Capabilities, FileSystem, ImageResizer } from "@xandreed/core";
3
+ /**
4
+ * The machine the agent runs on as its `FileSystem` and `Bash`: the local
5
+ * disk (`FileSystemLive`) and commands run with the configured shell
6
+ * (`BashLive`), built once per agent. Activation fails when the shell is not
7
+ * installed. Commands inherit `parentEnv` (the host process's environment
8
+ * when null) through `resolveBashEnv`'s allowlist.
9
+ */
10
+ export declare const localSystemPlugin: import("@xandreed/core").TypedPlugin<{
11
+ readonly shell: string;
12
+ readonly killBackground: boolean;
13
+ readonly exitGraceMs: number;
14
+ readonly parentEnv: {
15
+ readonly [x: string]: string;
16
+ } | null;
17
+ }, {
18
+ readonly shell: string;
19
+ readonly killBackground: boolean;
20
+ readonly exitGraceMs: number;
21
+ readonly parentEnv: {
22
+ readonly [x: string]: string;
23
+ } | null;
24
+ }, FileSystem | Bash, import("@xandreed/core").BashError, never>;
25
+ /**
26
+ * The local system as a typed layer: provides FileSystem and Bash; requires nothing.
27
+ *
28
+ * @public
29
+ */
30
+ export declare const LocalSystemPluginLive: (options?: {
31
+ readonly shell?: string;
32
+ readonly killBackground?: boolean;
33
+ readonly exitGraceMs?: number;
34
+ readonly parentEnv?: {
35
+ readonly [x: string]: string | undefined;
36
+ } | null;
37
+ } | undefined) => Layer.Layer<FileSystem | Bash, import("@xandreed/core").HarnessError | import("@xandreed/core").BashError, never>;
38
+ /**
39
+ * The workspace tools (`workspaceTools`): read, ls, find and grep always on,
40
+ * write and edit behind `fs.write`, bash behind `shell.exec`, over the
41
+ * `FileSystem`, `Bash` and `SessionEnvironment` of each call. Built once
42
+ * per agent, so two sessions editing one file wait for each other. `Bash`
43
+ * is optional: without it there is no `bash` tool. Hosts grant the
44
+ * permissions through tool discovery's `grants` or a turn's
45
+ * `PermissionGrants`.
46
+ */
47
+ export declare const workspaceToolsPlugin: import("@xandreed/core").TypedPlugin<{
48
+ readonly tools: readonly ("find" | "write" | "read" | "grep" | "bash" | "ls" | "edit")[];
49
+ readonly always: readonly ("read" | "edit" | "shell")[];
50
+ readonly confine: boolean;
51
+ readonly workspaceAccess: "write" | "read";
52
+ readonly roots: readonly {
53
+ readonly path: string;
54
+ readonly access: "write" | "read";
55
+ }[];
56
+ readonly protect: readonly string[];
57
+ readonly exclude: readonly string[];
58
+ readonly maxLines: number;
59
+ readonly maxBytes: number;
60
+ readonly lsEntries: number;
61
+ readonly findResults: number;
62
+ readonly grepMatches: number;
63
+ readonly grepLineChars: number;
64
+ readonly grepEngine: "auto" | "ripgrep" | "builtin";
65
+ readonly grepTimeoutMs: number;
66
+ readonly grepIgnoreFiles: boolean;
67
+ readonly maxFileBytes: number;
68
+ readonly readImages: boolean;
69
+ readonly imageMaxSide: number;
70
+ readonly fuzzyEdit: boolean;
71
+ readonly bashTimeoutMs: number;
72
+ readonly bashMaxTimeoutMs: number;
73
+ readonly bashEnv: {
74
+ readonly [x: string]: string;
75
+ };
76
+ readonly bashInheritEnv: boolean;
77
+ readonly bashEnvAllow: readonly string[];
78
+ readonly bashSessionEnv: boolean;
79
+ readonly spillDir: string | null;
80
+ readonly spillKeep: number;
81
+ readonly progressMs: number;
82
+ readonly environmentSection: boolean;
83
+ readonly sectionOrder: number;
84
+ }, {
85
+ readonly tools: readonly ("find" | "write" | "read" | "grep" | "bash" | "ls" | "edit")[];
86
+ readonly always: readonly ("read" | "edit" | "shell")[];
87
+ readonly confine: boolean;
88
+ readonly workspaceAccess: "write" | "read";
89
+ readonly roots: readonly {
90
+ readonly path: string;
91
+ readonly access: "write" | "read";
92
+ }[];
93
+ readonly protect: readonly string[];
94
+ readonly exclude: readonly string[];
95
+ readonly maxLines: number;
96
+ readonly maxBytes: number;
97
+ readonly lsEntries: number;
98
+ readonly findResults: number;
99
+ readonly grepMatches: number;
100
+ readonly grepLineChars: number;
101
+ readonly grepEngine: "auto" | "ripgrep" | "builtin";
102
+ readonly grepTimeoutMs: number;
103
+ readonly grepIgnoreFiles: boolean;
104
+ readonly maxFileBytes: number;
105
+ readonly readImages: boolean;
106
+ readonly imageMaxSide: number;
107
+ readonly fuzzyEdit: boolean;
108
+ readonly bashTimeoutMs: number;
109
+ readonly bashMaxTimeoutMs: number;
110
+ readonly bashEnv: {
111
+ readonly [x: string]: string;
112
+ };
113
+ readonly bashInheritEnv: boolean;
114
+ readonly bashEnvAllow: readonly string[];
115
+ readonly bashSessionEnv: boolean;
116
+ readonly spillDir: string | null;
117
+ readonly spillKeep: number;
118
+ readonly progressMs: number;
119
+ readonly environmentSection: boolean;
120
+ readonly sectionOrder: number;
121
+ }, Capabilities, import("@xandreed/core").HarnessError, never>;
122
+ /**
123
+ * The workspace tools as a typed layer: contributes their capability; its handlers need FileSystem and SessionEnvironment (and Bash for bash) where the tools are opened.
124
+ *
125
+ * @public
126
+ */
127
+ export declare const WorkspaceToolsPluginLive: (options?: {
128
+ readonly tools?: readonly ("find" | "write" | "read" | "grep" | "bash" | "ls" | "edit")[];
129
+ readonly always?: readonly ("read" | "edit" | "shell")[];
130
+ readonly confine?: boolean;
131
+ readonly workspaceAccess?: "write" | "read";
132
+ readonly roots?: readonly {
133
+ readonly path: string;
134
+ readonly access: "write" | "read";
135
+ }[];
136
+ readonly protect?: readonly string[];
137
+ readonly exclude?: readonly string[];
138
+ readonly maxLines?: number;
139
+ readonly maxBytes?: number;
140
+ readonly lsEntries?: number;
141
+ readonly findResults?: number;
142
+ readonly grepMatches?: number;
143
+ readonly grepLineChars?: number;
144
+ readonly grepEngine?: "auto" | "ripgrep" | "builtin";
145
+ readonly grepTimeoutMs?: number;
146
+ readonly grepIgnoreFiles?: boolean;
147
+ readonly maxFileBytes?: number;
148
+ readonly readImages?: boolean;
149
+ readonly imageMaxSide?: number;
150
+ readonly fuzzyEdit?: boolean;
151
+ readonly bashTimeoutMs?: number;
152
+ readonly bashMaxTimeoutMs?: number;
153
+ readonly bashEnv?: {
154
+ readonly [x: string]: string | undefined;
155
+ };
156
+ readonly bashInheritEnv?: boolean;
157
+ readonly bashEnvAllow?: readonly string[];
158
+ readonly bashSessionEnv?: boolean;
159
+ readonly spillDir?: string | null;
160
+ readonly spillKeep?: number;
161
+ readonly progressMs?: number;
162
+ readonly environmentSection?: boolean;
163
+ readonly sectionOrder?: number;
164
+ } | undefined) => Layer.Layer<Capabilities, import("@xandreed/core").HarnessError, never>;
165
+ /** Optional CLI image fitting on the host's matching Bash and FileSystem. @public */
166
+ export declare const imageResizerPlugin: import("@xandreed/core").TypedPlugin<{
167
+ readonly tool: "auto" | "magick" | "convert" | "vips" | "sips";
168
+ readonly tempDir: string | null;
169
+ readonly timeoutMs: number;
170
+ readonly maxSourcePixels: number;
171
+ }, {
172
+ readonly tool: "auto" | "magick" | "convert" | "vips" | "sips";
173
+ readonly tempDir: string | null;
174
+ readonly timeoutMs: number;
175
+ readonly maxSourcePixels: number;
176
+ }, ImageResizer, import("@xandreed/core").HarnessError, FileSystem | Bash>;
177
+ /** @public */
178
+ export declare const ImageResizerPluginLive: (options?: {
179
+ readonly tool?: "auto" | "magick" | "convert" | "vips" | "sips";
180
+ readonly tempDir?: string | null;
181
+ readonly timeoutMs?: number;
182
+ readonly maxSourcePixels?: number;
183
+ } | undefined) => Layer.Layer<ImageResizer, import("@xandreed/core").HarnessError, FileSystem | Bash>;
@@ -0,0 +1,38 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { Bash, Capabilities, definePlugin, FileSystem, ImageResizer, SessionEnvironment } from "@xandreed/core";
3
+ import { BashLive } from "./bash.adapter.js";
4
+ import { FileSystemLive } from "./file-system.adapter.js";
5
+ import { LocalSystemConfig, localSystemDefaults } from "./local-system.entity.js";
6
+ import { ImageResizerCliLive } from "./image-resizer.adapter.js";
7
+ import { ImageResizerConfig, imageResizerDefaults } from "./image-resizer.entity.js";
8
+ import { workspaceTools } from "./workspace-tools.adapter.js";
9
+ import { WorkspaceToolsConfig, workspaceToolsDefaults } from "./workspace-tools.entity.js";
10
+ export const localSystemPlugin = definePlugin({
11
+ id: "@xandreed/plugin-workspace/local",
12
+ version: "0.15.0",
13
+ scope: "runtime",
14
+ config: LocalSystemConfig,
15
+ defaults: localSystemDefaults,
16
+ provides: [FileSystem, Bash],
17
+ layer: (config) => Layer.merge(FileSystemLive, BashLive(config))
18
+ }), LocalSystemPluginLive = localSystemPlugin.live, workspaceToolsPlugin = definePlugin({
19
+ id: "@xandreed/plugin-workspace/tools",
20
+ version: "0.15.0",
21
+ scope: "runtime",
22
+ config: WorkspaceToolsConfig,
23
+ defaults: workspaceToolsDefaults,
24
+ requires: [FileSystem, SessionEnvironment],
25
+ optional: [Bash, ImageResizer],
26
+ provides: [],
27
+ contributes: [Capabilities],
28
+ layer: (config) => Layer.effect(Capabilities, workspaceTools(config).pipe(Effect.map((capability) => [capability])))
29
+ }), WorkspaceToolsPluginLive = workspaceToolsPlugin.live, imageResizerPlugin = definePlugin({
30
+ id: "@xandreed/plugin-workspace/images",
31
+ version: "0.15.0",
32
+ scope: "runtime",
33
+ config: ImageResizerConfig,
34
+ defaults: imageResizerDefaults,
35
+ requires: [FileSystem, Bash],
36
+ provides: [ImageResizer],
37
+ layer: (config) => ImageResizerCliLive(config)
38
+ }), ImageResizerPluginLive = imageResizerPlugin.live;
@@ -0,0 +1,39 @@
1
+ import type { Path, RcMap, Semaphore } from "effect";
2
+ import { Failure, FileSystem, SessionEnvironment } from "@xandreed/core";
3
+ import type { RegisteredTool, Subject } from "@xandreed/core";
4
+ import type { ProtectRule } from "./workspace-path.entity.functions.js";
5
+ import type { WorkspaceToolsConfig } from "./workspace-tools.entity.js";
6
+ /** What every workspace tool is built with: the configuration, the path functions, the compiled `protect` globs and the per-file write locks. */
7
+ export interface WorkspaceToolContext {
8
+ readonly config: WorkspaceToolsConfig;
9
+ readonly path: Path.Path;
10
+ readonly protect: ReadonlyArray<ProtectRule>;
11
+ readonly locks: RcMap.RcMap<string, Semaphore.Semaphore>;
12
+ readonly resizesImages: boolean;
13
+ }
14
+ /** The services every workspace tool reads on each call, and the `workspace.environment` section on each render: the file system, and the session's workspace on it (`bash` reads `Bash` too). */
15
+ export declare const WORKSPACE_SERVICES: readonly [typeof FileSystem, typeof SessionEnvironment];
16
+ export declare const failure: (error: string, message: string) => Failure;
17
+ /** The refusal of a path that is neither a regular file nor a directory: reading a pipe, socket or device could wait forever. */
18
+ export declare const notAFile: (display: string, tool: string) => Failure;
19
+ /** A file the result names, for memory to keep track of; none for a name memory could not store as an id. */
20
+ export declare const fileSubjects: (path: string) => ReadonlyArray<Subject>;
21
+ /** `read`: a window of a text file's lines, an image (`readImages`), or what a binary file is. */
22
+ export declare const readTool: ({ config, path, resizesImages }: WorkspaceToolContext) => RegisteredTool;
23
+ /** `ls`: a directory's entries by name, at most `lsEntries` of them. */
24
+ export declare const lsTool: ({ config, path }: WorkspaceToolContext) => RegisteredTool;
25
+ /** `find`: the files under a directory whose path matches a glob, as paths relative to the workspace root. */
26
+ export declare const findTool: ({ config, path }: WorkspaceToolContext) => RegisteredTool;
27
+ /**
28
+ * `grep`: the lines of the files under a path (or of one file) that match.
29
+ * Files come from a walk that never enters links or excluded names; each is
30
+ * resolved again before it is read and skipped when it leads outside the
31
+ * workspace, is not a regular file, is larger than `maxFileBytes` or is
32
+ * binary; one the file system refuses (permission denied, say) is named in
33
+ * `unreadable` with the reason, not counted as skipped. Lines are cut to `grepLineChars` before matching. Searching a
34
+ * directory, one file gives at most a quarter of the limit, so one large
35
+ * file cannot fill the result, however many files the directory holds (as
36
+ * with ripgrep, which cannot know that ahead). A pattern with a line break
37
+ * is refused (`singleLinePattern`) whichever engine runs.
38
+ */
39
+ export declare const grepTool: ({ config, path }: WorkspaceToolContext) => RegisteredTool;
@@ -0,0 +1,109 @@
1
+ import { Tool } from "effect/ai";
2
+ import { Effect, Option, Result } from "effect";
3
+ import { Bash, defineTool, Failure, FileSystem, ImageResizer, SessionEnvironment, SubjectId } from "@xandreed/core";
4
+ import { searchRipgrep } from "./ripgrep.adapter.js";
5
+ import { compilePattern, searchText, singleLinePattern } from "./text-search.entity.functions.js";
6
+ import { decodeText, formatBytes, lineWindow, looksBinary, mediaTypeOf } from "./text-window.entity.functions.js";
7
+ import { displayOf, excludesBelow, fsFailure, isWithin, openWorkspace, resolveTarget, withinAnyRoot } from "./workspace-path.entity.functions.js";
8
+ import { FindResult, GrepResult, LsResult, ReadResult } from "./workspace-tools.entity.js";
9
+ import { binaryRead, compactFind, fileTooLargeHint, compactGrep, compactLs, compactRead, readMedia, renderFind, renderGrep, renderLs, renderRead, toolDescriptions, toolParameters } from "./workspace-tools.entity.functions.js";
10
+ export const WORKSPACE_SERVICES = [FileSystem, SessionEnvironment], failure = (error, message) => ({ error, message }), notAFile = (display, tool) => failure("NotAFile", `${display} is not a regular file (a pipe, socket or device); ${tool} works on regular files only, use bash for it`);
11
+ const walkExcludes = (access, target, excludes) => excludesBelow(excludes, isWithin(access.root, target.path) ? Option.some(access.path.relative(access.root, target.path)) : Option.none());
12
+ export const fileSubjects = (path) => path.length > 0 && path.trim() === path ? [{ kind: "file", id: SubjectId.make(path), label: Option.none(), data: Option.none() }] : [];
13
+ const ReadTool = (config, resizesImages) => Tool.make("read", { description: toolDescriptions(config, resizesImages).read, parameters: toolParameters(config).read, success: ReadResult, failure: Failure, failureMode: "return" }), LsTool = (config) => Tool.make("ls", { description: toolDescriptions(config).ls, parameters: toolParameters(config).ls, success: LsResult, failure: Failure, failureMode: "return" }), FindTool = (config) => Tool.make("find", { description: toolDescriptions(config).find, parameters: toolParameters(config).find, success: FindResult, failure: Failure, failureMode: "return" }), GrepTool = (config) => Tool.make("grep", { description: toolDescriptions(config).grep, parameters: toolParameters(config).grep, success: GrepResult, failure: Failure, failureMode: "return" });
14
+ export const readTool = ({ config, path, resizesImages }) => defineTool({
15
+ tool: ReadTool(config, resizesImages),
16
+ requires: resizesImages ? [...WORKSPACE_SERVICES, ImageResizer] : WORKSPACE_SERVICES,
17
+ handler: (params) => Effect.gen(function* () {
18
+ const access = yield* openWorkspace(path, config), target = yield* resolveTarget(access, params.path, { confine: config.confine }), info = yield* access.fs.stat(target.path).pipe(Effect.mapError(fsFailure(target.display)));
19
+ if (info.kind === "directory")
20
+ return yield* Effect.fail(failure("IsDirectory", `${target.display} is a directory; use ls to list it`));
21
+ if (info.kind !== "file")
22
+ return yield* Effect.fail(notAFile(target.display, "read"));
23
+ if (info.size > config.maxFileBytes)
24
+ return yield* Effect.fail(failure("FileTooLarge", `${target.display} is ${formatBytes(info.size)}, more than the ${formatBytes(config.maxFileBytes)} read loads; ${fileTooLargeHint(target.display, config.readImages)}`));
25
+ const bytes = yield* access.fs.readBytes(target.path).pipe(Effect.mapError(fsFailure(target.display)));
26
+ if (looksBinary(bytes))
27
+ return yield* binaryRead(target.display, bytes, config.readImages, Option.getOrNull(mediaTypeOf(target.display)), resizesImages ? Option.map(yield* Effect.serviceOption(ImageResizer), (resizer) => resizer.fit) : Option.none(), config.imageMaxSide);
28
+ const decoded = decodeText(bytes), window = lineWindow(decoded.text, { offset: params.offset ?? 1, limit: params.limit ?? config.maxLines, maxBytes: config.maxBytes });
29
+ return yield* Result.match(window, {
30
+ onFailure: (total) => Effect.fail(failure("OffsetPastEnd", `${target.display} has ${total} line${total === 1 ? "" : "s"}; offset must be at most ${Math.max(1, total)}`)),
31
+ onSuccess: (lines) => Effect.succeed({
32
+ _tag: "Text",
33
+ path: target.display,
34
+ text: lines.lines.join(`
35
+ `),
36
+ firstLine: lines.firstLine,
37
+ lastLine: lines.lastLine,
38
+ totalLines: lines.totalLines,
39
+ next: Option.getOrNull(lines.next),
40
+ clipped: lines.clipped,
41
+ lossy: decoded.lossy
42
+ })
43
+ });
44
+ }),
45
+ view: { version: "3", render: renderRead, compact: compactRead, subjects: (result) => fileSubjects(result.path), media: readMedia },
46
+ annotations: { readOnly: !0 }
47
+ }), lsTool = ({ config, path }) => defineTool({
48
+ tool: LsTool(config),
49
+ requires: WORKSPACE_SERVICES,
50
+ handler: (params) => Effect.gen(function* () {
51
+ const access = yield* openWorkspace(path, config), target = yield* resolveTarget(access, params.path ?? ".", { confine: config.confine }), entries = yield* access.fs.list(target.path).pipe(Effect.mapError((error) => error.reason === "NotDirectory" ? failure("NotDirectory", `${target.display} is not a directory; use read to see a file`) : fsFailure(target.display)(error))), limit = params.limit ?? config.lsEntries;
52
+ return { path: target.display, entries: entries.slice(0, limit).map((entry) => ({ name: entry.name, kind: entry.kind })), total: entries.length };
53
+ }),
54
+ view: { version: "2", render: renderLs, compact: compactLs },
55
+ annotations: { readOnly: !0 }
56
+ }), findTool = ({ config, path }) => defineTool({
57
+ tool: FindTool(config),
58
+ requires: WORKSPACE_SERVICES,
59
+ handler: (params) => Effect.gen(function* () {
60
+ const access = yield* openWorkspace(path, config), target = yield* resolveTarget(access, params.path ?? ".", { confine: config.confine }), limit = params.limit ?? config.findResults, found = yield* access.fs.glob(params.pattern, { cwd: target.path, exclude: walkExcludes(access, target, config.exclude), dot: !0, onlyFiles: !0, limit }).pipe(Effect.mapError((error) => error.reason === "Invalid" ? failure("InvalidPattern", `${params.pattern}: ${error.message}`) : fsFailure(target.display)(error)));
61
+ return { pattern: params.pattern, path: target.display, paths: found.paths.map((relative) => displayOf(access, access.path.join(target.path, relative))), truncated: found.truncated };
62
+ }),
63
+ view: { version: "2", render: (result) => renderFind(result, config.maxLines, config.maxBytes), compact: compactFind },
64
+ annotations: { readOnly: !0 }
65
+ }), grepTool = ({ config, path }) => defineTool({
66
+ tool: GrepTool(config),
67
+ requires: config.grepEngine === "ripgrep" ? [...WORKSPACE_SERVICES, Bash] : WORKSPACE_SERVICES,
68
+ handler: (params) => Effect.gen(function* () {
69
+ const access = yield* openWorkspace(path, config), target = yield* resolveTarget(access, params.path ?? ".", { confine: config.confine });
70
+ yield* Effect.fromResult(singleLinePattern(params.pattern));
71
+ const pattern = config.grepEngine === "ripgrep" ? Option.none() : Option.some(yield* Effect.fromResult(compilePattern(params.pattern, { literal: params.literal ?? !1, ignoreCase: params.ignoreCase ?? !1 }))), info = yield* access.fs.stat(target.path).pipe(Effect.mapError(fsFailure(target.display)));
72
+ if (info.kind !== "file" && info.kind !== "directory")
73
+ return yield* Effect.fail(notAFile(target.display, "grep"));
74
+ if (config.grepEngine === "ripgrep")
75
+ return yield* searchRipgrep(access, target, info.kind === "directory", params, config);
76
+ const glob = params.glob === void 0 ? "**/*" : params.glob.includes("/") ? params.glob : `**/${params.glob}`, files = info.kind === "file" ? [{ path: target.path, display: target.display }] : yield* access.fs.glob(glob, { cwd: target.path, exclude: walkExcludes(access, target, config.exclude), dot: !0, onlyFiles: !0 }).pipe(Effect.map((found) => found.paths.map((relative) => {
77
+ const absolute = access.path.join(target.path, relative);
78
+ return { path: absolute, display: displayOf(access, absolute) };
79
+ })), Effect.mapError((error) => error.reason === "Invalid" ? failure("InvalidPattern", `glob ${params.glob ?? glob}: ${error.message}`) : fsFailure(target.display)(error))), limit = params.limit ?? config.grepMatches, perFile = info.kind === "directory" ? Math.max(1, Math.ceil(limit / 4)) : limit, context = params.context ?? 0, skip = (state) => ({ ...state, skipped: state.skipped + 1 }), searched = yield* Effect.reduce(files, () => ({ lines: [], matches: 0, files: 0, skipped: 0, truncated: !1, unreadable: [] }), (state, file) => {
80
+ if (state.matches >= limit)
81
+ return Effect.succeed({ ...state, truncated: !0 });
82
+ return Effect.gen(function* () {
83
+ const real = yield* access.fs.realPath(file.path);
84
+ if (config.confine && !withinAnyRoot(access, real))
85
+ return Option.none();
86
+ const found = yield* access.fs.stat(real);
87
+ if (found.kind !== "file" || found.size > config.maxFileBytes)
88
+ return Option.none();
89
+ const bytes = yield* access.fs.readBytes(real);
90
+ return looksBinary(bytes) ? Option.none() : Option.some(bytes);
91
+ }).pipe(Effect.map(Option.match({
92
+ onNone: () => skip(state),
93
+ onSome: (content) => {
94
+ const found = searchText(decodeText(content).text, Option.getOrThrow(pattern), { lineChars: config.grepLineChars, context, cap: Math.min(perFile, limit - state.matches) });
95
+ return {
96
+ ...state,
97
+ lines: [...state.lines, ...found.lines.map((line) => ({ path: file.display, ...line }))],
98
+ matches: state.matches + found.matches,
99
+ files: state.files + (found.matches > 0 ? 1 : 0),
100
+ truncated: state.truncated || found.capped
101
+ };
102
+ }
103
+ })), Effect.catch((error) => Effect.succeed({ ...state, unreadable: [...state.unreadable, { path: file.display, reason: error.reason }] })));
104
+ });
105
+ return { pattern: params.pattern, path: target.display, ...searched, timedOut: !1 };
106
+ }),
107
+ view: { version: "4", render: (result, params) => renderGrep(result, (params.context ?? 0) > 0, config.maxLines, config.maxBytes), compact: compactGrep },
108
+ annotations: { readOnly: !0 }
109
+ });
@@ -0,0 +1,74 @@
1
+ import { Effect } from "effect";
2
+ import { Bash } from "@xandreed/core";
3
+ import type { WorkspaceAccess } from "./workspace-path.entity.functions.js";
4
+ /** A bounded, interruptible search on the same machine as the Bash/FileSystem pair. */
5
+ export declare const searchRipgrep: (access: WorkspaceAccess, target: {
6
+ readonly path: string;
7
+ readonly display: string;
8
+ }, directory: boolean, params: {
9
+ readonly pattern: string;
10
+ readonly path?: string;
11
+ readonly limit?: number;
12
+ readonly context?: number;
13
+ readonly literal?: boolean;
14
+ readonly glob?: string;
15
+ readonly ignoreCase?: boolean;
16
+ }, config: {
17
+ readonly tools: readonly ("find" | "write" | "read" | "grep" | "bash" | "ls" | "edit")[];
18
+ readonly always: readonly ("read" | "edit" | "shell")[];
19
+ readonly confine: boolean;
20
+ readonly workspaceAccess: "write" | "read";
21
+ readonly roots: readonly {
22
+ readonly path: string;
23
+ readonly access: "write" | "read";
24
+ }[];
25
+ readonly protect: readonly string[];
26
+ readonly exclude: readonly string[];
27
+ readonly maxLines: number;
28
+ readonly maxBytes: number;
29
+ readonly lsEntries: number;
30
+ readonly findResults: number;
31
+ readonly grepMatches: number;
32
+ readonly grepLineChars: number;
33
+ readonly grepEngine: "auto" | "ripgrep" | "builtin";
34
+ readonly grepTimeoutMs: number;
35
+ readonly grepIgnoreFiles: boolean;
36
+ readonly maxFileBytes: number;
37
+ readonly readImages: boolean;
38
+ readonly imageMaxSide: number;
39
+ readonly fuzzyEdit: boolean;
40
+ readonly bashTimeoutMs: number;
41
+ readonly bashMaxTimeoutMs: number;
42
+ readonly bashEnv: {
43
+ readonly [x: string]: string;
44
+ };
45
+ readonly bashInheritEnv: boolean;
46
+ readonly bashEnvAllow: readonly string[];
47
+ readonly bashSessionEnv: boolean;
48
+ readonly spillDir: string | null;
49
+ readonly spillKeep: number;
50
+ readonly progressMs: number;
51
+ readonly environmentSection: boolean;
52
+ readonly sectionOrder: number;
53
+ }) => Effect.Effect<{
54
+ pattern: string;
55
+ path: string;
56
+ lines: readonly {
57
+ readonly path: string;
58
+ readonly line: number;
59
+ readonly text: string;
60
+ readonly match: boolean;
61
+ }[];
62
+ matches: number;
63
+ files: number;
64
+ skipped: number;
65
+ truncated: boolean;
66
+ unreadable: {
67
+ path: string;
68
+ reason: "Invalid" | "NotFound" | "Exists" | "IsDirectory" | "NotDirectory" | "NotEmpty" | "Permission" | "Io";
69
+ }[];
70
+ timedOut: boolean;
71
+ }, {
72
+ readonly error: string;
73
+ readonly message: string;
74
+ }, Bash>;