@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,88 @@
1
+ import { Effect, Option, Result } from "effect";
2
+ import { compileGlob, FileSystem, FsError, SessionEnvironment } from "@xandreed/core";
3
+ const failure = (error, message) => ({ error, message }), reasons = {
4
+ NotFound: (display) => failure("NotFound", `${display} does not exist`),
5
+ Exists: (display) => failure("AlreadyExists", `${display} already exists, and the file system would not replace it; give another path`),
6
+ IsDirectory: (display) => failure("IsDirectory", `${display} is a directory; give the path of a file`),
7
+ NotDirectory: (display) => failure("NotDirectory", `${display} is not a directory, or a part of the path before it is a file`),
8
+ NotEmpty: (display) => failure("DirectoryNotEmpty", `${display} is a directory with entries in it; give another path`),
9
+ Permission: (display) => failure("AccessDenied", `permission denied: ${display}`),
10
+ Invalid: (display) => failure("InvalidPath", `${display} is not a path the file system accepts; check it and try again`),
11
+ Io: (display) => failure("FileSystemError", `${display} could not be read or written (a file-system error); try again, or give another path`)
12
+ };
13
+ export const fsFailure = (display) => (error) => reasons[error.reason](display);
14
+ const unopenable = {
15
+ NotFound: "does not exist",
16
+ NotDirectory: "is not a directory",
17
+ Permission: "cannot be opened for lack of permission",
18
+ Invalid: "is not a path the file system accepts",
19
+ Exists: "cannot be opened because of a file-system error",
20
+ IsDirectory: "cannot be opened because of a file-system error",
21
+ NotEmpty: "cannot be opened because of a file-system error",
22
+ Io: "cannot be opened because of a file-system error"
23
+ };
24
+ export const workspaceMissing = (reason) => failure("WorkspaceMissing", `the workspace directory ${unopenable[reason]}, so no workspace tool can run in this session; tell the user`), isWithin = (root, path) => path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`), openWorkspace = Effect.fnUntraced(function* (path, options = { roots: [], workspaceAccess: "write" }) {
25
+ const fs = yield* FileSystem, { workspace } = yield* SessionEnvironment, lexicalRoot = path.resolve(workspace), root = yield* fs.realPath(lexicalRoot).pipe(Effect.mapError((error) => workspaceMissing(error.reason))), extra = yield* Effect.forEach(options.roots, (allowed) => {
26
+ const lexicalRoot = path.resolve(root, allowed.path);
27
+ return fs.realPath(lexicalRoot).pipe(Effect.flatMap((root) => fs.stat(root).pipe(Effect.flatMap((info) => info.kind === "directory" ? Effect.succeed(Option.some({ root, lexicalRoot, access: allowed.access })) : Effect.logWarning("an extra workspace root is not a directory", { path: allowed.path }).pipe(Effect.as(Option.none()))))), Effect.catch((error) => Effect.logWarning("an extra workspace root could not be opened", { path: allowed.path, reason: error.reason }).pipe(Effect.as(Option.none()))));
28
+ }, { concurrency: 1 });
29
+ return { fs, path, root, lexicalRoot, roots: [{ root, lexicalRoot, access: options.workspaceAccess }, ...extra.flatMap(Option.toArray)] };
30
+ }), withinAnyRoot = (access, path) => access.roots.some((root) => isWithin(root.root, path));
31
+ const folded = (path) => path.normalize("NFC").toLowerCase(), rootOf = (access, path, fold = (same) => same) => access.roots.reduce((selected, candidate) => isWithin(fold(candidate.root), fold(path)) && Option.match(selected, { onNone: () => !0, onSome: (current) => candidate.root.length >= current.root.length }) ? Option.some(candidate) : selected, Option.none()), readOnlyRootOf = (access, path) => Option.orElse(Option.filter(rootOf(access, path), (root) => root.access === "read"), () => Option.filter(rootOf(access, path, folded), (root) => root.access === "read"));
32
+ export const displayOf = (access, absolute) => {
33
+ const base = isWithin(access.root, absolute) ? access.root : isWithin(access.lexicalRoot, absolute) ? access.lexicalRoot : void 0;
34
+ if (base === void 0)
35
+ return absolute;
36
+ const relative = access.path.relative(base, absolute);
37
+ return relative === "" ? "." : relative;
38
+ };
39
+ const canonicalOf = (access, target, display) => access.fs.realPath(target).pipe(Effect.catch((error) => {
40
+ const parent = access.path.dirname(target);
41
+ if (error.reason !== "NotFound" || parent === target)
42
+ return Effect.fail(fsFailure(display)(error));
43
+ return access.fs.stat(target, { followSymlinks: !1 }).pipe(Effect.matchEffect({
44
+ onSuccess: () => Effect.fail(failure("BrokenSymlink", `${display}: it is a symbolic link to something that does not exist`)),
45
+ onFailure: (missing) => missing.reason === "NotFound" ? canonicalOf(access, parent, display).pipe(Effect.map((dir) => access.path.join(dir, access.path.basename(target)))) : Effect.fail(fsFailure(display)(missing))
46
+ }));
47
+ }));
48
+ export const resolveTarget = Effect.fnUntraced(function* (access, input, options) {
49
+ const lexical = access.path.resolve(access.root, input.length === 0 ? "." : input), display = displayOf(access, lexical), inside = (path) => access.roots.some((root) => isWithin(root.root, path) || isWithin(root.lexicalRoot, path)), extras = access.roots.slice(1).map((root) => displayOf(access, root.root));
50
+ if (options.confine && !inside(lexical))
51
+ return yield* Effect.fail(failure("OutsideWorkspace", extras.length === 0 ? `${input} is outside the workspace; use a path under its root` : `${input} is outside the workspace and its allowed roots (${extras.join(", ")}); use a path under one of them`));
52
+ const path = yield* canonicalOf(access, lexical, display);
53
+ if (options.confine && !withinAnyRoot(access, path))
54
+ return yield* Effect.fail(failure("OutsideWorkspace", extras.length === 0 ? `${display} leads outside the workspace through a symbolic link` : `${display} leads outside the workspace and its allowed roots (${extras.join(", ")}) through a symbolic link`));
55
+ const readOnly = options.confine && options.intent === "write" ? readOnlyRootOf(access, path) : Option.none();
56
+ if (Option.isSome(readOnly)) {
57
+ const place = readOnly.value.root === access.root ? "the workspace root" : displayOf(access, readOnly.value.root);
58
+ return yield* Effect.fail(failure("ReadOnly", `${display} is under ${place}, which the workspace tools may read but not change`));
59
+ }
60
+ return { path, display };
61
+ }), compileProtect = (globs) => Result.all(globs.map((glob) => Result.mapError(Result.flatMap(compileGlob(glob, { dot: !0 }), (exact) => Result.map(compileGlob(folded(glob), { dot: !0 }), (caseless) => ({ glob, matches: (relative) => exact.matches(relative) || caseless.matches(folded(relative)) }))), (reason) => `protect glob ${glob} is not usable: ${reason}`)));
62
+ const segmentsOf = (glob) => glob.split("/").filter((segment) => segment.length > 0 && segment !== "."), ownBraces = (segment) => (segment.match(/\{/g) ?? []).length === (segment.match(/\}/g) ?? []).length, remainderBelow = (pattern, names) => {
63
+ const [head, ...rest] = pattern, [name, ...below] = names;
64
+ if (name === void 0)
65
+ return pattern.length === 0 ? [] : [`./${pattern.join("/")}`];
66
+ if (head === void 0)
67
+ return [];
68
+ if (head === "**")
69
+ return [`./${pattern.join("/")}`, ...names.flatMap((_, at) => remainderBelow(rest, names.slice(at)))];
70
+ return Result.match(compileGlob(head, { dot: !0 }), {
71
+ onFailure: () => [],
72
+ onSuccess: (segment) => segment.matches(name) ? remainderBelow(rest, below) : []
73
+ });
74
+ };
75
+ export const excludesBelow = (excludes, directory) => [...new Set(excludes.flatMap((glob) => {
76
+ if (!glob.includes("/"))
77
+ return [glob];
78
+ return Option.match(directory, {
79
+ onNone: () => [],
80
+ onSome: (relative) => {
81
+ const names = segmentsOf(relative), pattern = segmentsOf(glob);
82
+ return names.length === 0 || !pattern.every(ownBraces) ? [glob] : remainderBelow(pattern, names);
83
+ }
84
+ });
85
+ }))], checkWritable = (access, target, rules) => {
86
+ const relatives = [target.display, isWithin(access.root, target.path) ? access.path.relative(access.root, target.path) : ""].filter((relative) => relative.length > 0 && relative !== "." && !relative.startsWith("/") && !relative.startsWith("..")), rule = rules.find((candidate) => relatives.some((relative) => candidate.matches(relative)));
87
+ return rule === void 0 ? Effect.void : Effect.fail(failure("Protected", `${target.display} is protected (${rule.glob}); the workspace tools do not change it`));
88
+ };
@@ -0,0 +1,9 @@
1
+ import { Schema } from "effect";
2
+ export const WorkspaceAccessMode = Schema.Literals(["read", "write"]), WorkspaceAllowedRoot = Schema.Struct({ path: Schema.NonEmptyString, access: WorkspaceAccessMode }), AllowedRoot = Schema.Struct({ root: Schema.String, lexicalRoot: Schema.String, access: WorkspaceAccessMode }), WorkspaceTarget = Schema.Struct({
3
+ path: Schema.String,
4
+ display: Schema.String
5
+ }), WorkspaceRoot = Schema.Struct({
6
+ root: Schema.String,
7
+ lexicalRoot: Schema.String,
8
+ roots: Schema.Array(AllowedRoot)
9
+ });
@@ -0,0 +1,66 @@
1
+ import { Effect } from "effect";
2
+ import type { Scope } from "effect";
3
+ import { HarnessError } from "@xandreed/core";
4
+ import type { Capability } from "@xandreed/core";
5
+ /**
6
+ * The workspace tools as one capability, for the workspace in each call's
7
+ * `SessionEnvironment`, over each call's `FileSystem` and `Bash`: `read`,
8
+ * `ls`, `find` and `grep` (skill `workspace.read`, read-only), `write` and
9
+ * `edit` (`workspace.edit`, needing `fs.write`) and `bash`
10
+ * (`workspace.shell`, needing `shell.exec`), plus the
11
+ * `workspace.environment` prompt section, which reads the same services as
12
+ * the tools, where the turn renders it. Built once (in a scope, which
13
+ * holds the per-file write locks): `bash` and its skill are left out, with
14
+ * a warning, when there is no `Bash` service here; `protect` and `exclude`
15
+ * globs that do not compile fail with `config.options` (through the plugin,
16
+ * `WorkspaceToolsConfig` refuses them already when its options are
17
+ * decoded, so a graph refuses them before activating anything). Tools are never
18
+ * given guidance as skill instructions: it is in their descriptions and the
19
+ * section, since an always-on skill's instructions never reach the model.
20
+ * Ripgrep requires FileSystem and Bash to see the same file tree, so
21
+ * `grepEngine: "auto"` uses it only when both, here, are this machine's
22
+ * (`localFileSystem` and a `makeBash` shell, as `localSystemPlugin`
23
+ * provides); with any other pair (an in-memory file system, a container's
24
+ * shell, a wrapper) it selects builtin without probing. A host whose pair
25
+ * shares a tree selects `ripgrep` itself.
26
+ *
27
+ * @public
28
+ */
29
+ export declare const workspaceTools: (config: {
30
+ readonly tools: readonly ("find" | "write" | "read" | "grep" | "bash" | "ls" | "edit")[];
31
+ readonly always: readonly ("read" | "edit" | "shell")[];
32
+ readonly confine: boolean;
33
+ readonly workspaceAccess: "write" | "read";
34
+ readonly roots: readonly {
35
+ readonly path: string;
36
+ readonly access: "write" | "read";
37
+ }[];
38
+ readonly protect: readonly string[];
39
+ readonly exclude: readonly string[];
40
+ readonly maxLines: number;
41
+ readonly maxBytes: number;
42
+ readonly lsEntries: number;
43
+ readonly findResults: number;
44
+ readonly grepMatches: number;
45
+ readonly grepLineChars: number;
46
+ readonly grepEngine: "auto" | "ripgrep" | "builtin";
47
+ readonly grepTimeoutMs: number;
48
+ readonly grepIgnoreFiles: boolean;
49
+ readonly maxFileBytes: number;
50
+ readonly readImages: boolean;
51
+ readonly imageMaxSide: number;
52
+ readonly fuzzyEdit: boolean;
53
+ readonly bashTimeoutMs: number;
54
+ readonly bashMaxTimeoutMs: number;
55
+ readonly bashEnv: {
56
+ readonly [x: string]: string;
57
+ };
58
+ readonly bashInheritEnv: boolean;
59
+ readonly bashEnvAllow: readonly string[];
60
+ readonly bashSessionEnv: boolean;
61
+ readonly spillDir: string | null;
62
+ readonly spillKeep: number;
63
+ readonly progressMs: number;
64
+ readonly environmentSection: boolean;
65
+ readonly sectionOrder: number;
66
+ }) => Effect.Effect<Capability, HarnessError, Scope.Scope>;
@@ -0,0 +1,60 @@
1
+ import { Effect, Option, Path, RcMap, Result, Semaphore } from "effect";
2
+ import { Bash, compileGlob, defineCapability, definePromptSection, defineSkill, FileSystem, HarnessError, ImageResizer, SessionEnvironment } from "@xandreed/core";
3
+ import { runsOnThisMachine } from "./bash.adapter.js";
4
+ import { bashTool } from "./bash-tool.adapter.js";
5
+ import { localFileSystem } from "./file-system.adapter.js";
6
+ import { findTool, grepTool, lsTool, readTool, WORKSPACE_SERVICES } from "./read-tools.adapter.js";
7
+ import { compileProtect, openWorkspace } from "./workspace-path.entity.functions.js";
8
+ import { WORKSPACE_PERMISSIONS } from "./workspace-tools.entity.js";
9
+ import { bashEnvironment, environmentText, skillOfTool } from "./workspace-tools.entity.functions.js";
10
+ import { editTool, writeTool } from "./write-tools.adapter.js";
11
+ const ORDER = ["read", "ls", "find", "grep", "write", "edit", "bash"], builders = {
12
+ read: readTool,
13
+ ls: lsTool,
14
+ find: findTool,
15
+ grep: grepTool,
16
+ write: writeTool,
17
+ edit: editTool,
18
+ bash: bashTool
19
+ }, skills = {
20
+ read: { id: "workspace.read", summary: "Read, list and search the workspace's files (read, ls, find, grep).", permissions: [] },
21
+ edit: { id: "workspace.edit", summary: "Create and change files in the workspace (write, edit).", permissions: [WORKSPACE_PERMISSIONS.write] },
22
+ shell: { id: "workspace.shell", summary: "Run shell commands in the workspace (bash).", permissions: [WORKSPACE_PERMISSIONS.exec] }
23
+ }, invalid = (message) => new HarnessError({ code: "config.options", plugin: "@xandreed/plugin-workspace/tools", message });
24
+ export const workspaceTools = Effect.fnUntraced(function* (config) {
25
+ const path = yield* Effect.service(Path.Path).pipe(Effect.provide(Path.layer)), protect = yield* Effect.fromResult(compileProtect(config.protect)).pipe(Effect.mapError(invalid)), badExclude = config.exclude.find((glob) => Result.isFailure(compileGlob(glob, { dot: !0 })));
26
+ if (badExclude !== void 0)
27
+ return yield* Effect.fail(invalid(`exclude glob ${badExclude} is not usable`));
28
+ const locks = yield* RcMap.make({ lookup: (_path) => Semaphore.make(1), idleTimeToLive: "1 minute" }), bash = yield* Effect.serviceOption(Bash), hasBash = Option.isSome(bash), sameTree = Option.exists(yield* Effect.serviceOption(FileSystem), (service) => service === localFileSystem) && Option.exists(bash, runsOnThisMachine);
29
+ if (config.tools.includes("grep") && config.grepEngine === "auto" && !sameTree)
30
+ yield* Effect.logDebug("grep: the FileSystem and Bash are not this machine's local pair; using the builtin engine");
31
+ const probe = config.tools.includes("grep") && (config.grepEngine === "ripgrep" || config.grepEngine === "auto" && sameTree) ? yield* Option.match(bash, {
32
+ onNone: () => Effect.succeed(!1),
33
+ onSome: (service) => service.exec("rg --version", { cwd: "/", timeoutMs: 5000, ...bashEnvironment(config, Option.none()) }).pipe(Effect.map((result) => result.termination === "exit" && Option.contains(result.exitCode, 0)), Effect.catch((error) => Effect.logDebug("grep: ripgrep probe failed", error).pipe(Effect.as(!1))))
34
+ }) : !1;
35
+ if (config.tools.includes("grep") && config.grepEngine === "ripgrep" && !probe)
36
+ return yield* Effect.fail(invalid("grepEngine ripgrep requires rg on the Bash service PATH"));
37
+ if (config.tools.includes("grep") && config.grepEngine === "auto" && sameTree && !probe)
38
+ yield* Effect.logDebug("grep: ripgrep is unavailable; using the builtin engine");
39
+ const resolved = { ...config, grepEngine: probe ? "ripgrep" : "builtin" }, wanted = ORDER.filter((tool) => config.tools.includes(tool));
40
+ if (wanted.includes("bash") && !hasBash)
41
+ yield* Effect.logWarning("@xandreed/plugin-workspace/tools: there is no Bash service, so the bash tool and the workspace.shell skill are left out (add localSystemPlugin, or a Bash, to have them)");
42
+ const enabled = wanted.filter((tool) => tool !== "bash" || hasBash), context = { config: resolved, path, protect, locks, resizesImages: config.readImages && Option.isSome(yield* Effect.serviceOption(ImageResizer)) }, section = definePromptSection({
43
+ id: "workspace.environment",
44
+ version: "3",
45
+ tier: "session",
46
+ order: config.sectionOrder,
47
+ requires: WORKSPACE_SERVICES,
48
+ render: () => openWorkspace(path).pipe(Effect.map((access) => access.root), Effect.catch(() => SessionEnvironment.pipe(Effect.map((environment) => path.resolve(environment.workspace)))), Effect.map((root) => Option.some(environmentText(root, resolved, enabled))))
49
+ });
50
+ return defineCapability({
51
+ id: "@xandreed/plugin-workspace/tools",
52
+ version: "1",
53
+ tools: enabled.map((tool) => builders[tool](context)),
54
+ skills: ["read", "edit", "shell"].flatMap((name) => {
55
+ const tools = enabled.filter((tool) => skillOfTool[tool] === name);
56
+ return tools.length === 0 ? [] : [defineSkill({ ...skills[name], tools, always: config.always.includes(name) })];
57
+ }),
58
+ promptSections: config.environmentSection && enabled.length > 0 ? [section] : []
59
+ });
60
+ });