@xandreed/plugin-workspace 0.0.0-stage → 0.14.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 (62) 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 +72 -0
  5. package/dist/bash.adapter.d.ts +29 -0
  6. package/dist/bash.adapter.js +69 -0
  7. package/dist/bash.adapter.test.d.ts +1 -0
  8. package/dist/file-system.adapter.d.ts +20 -0
  9. package/dist/file-system.adapter.js +52 -0
  10. package/dist/file-system.adapter.test.d.ts +1 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.js +35 -0
  13. package/dist/local-system.entity.d.ts +40 -0
  14. package/dist/local-system.entity.functions.d.ts +52 -0
  15. package/dist/local-system.entity.functions.js +32 -0
  16. package/dist/local-system.entity.functions.test.d.ts +1 -0
  17. package/dist/local-system.entity.js +11 -0
  18. package/dist/plugin.adapter.d.ts +120 -0
  19. package/dist/plugin.adapter.js +27 -0
  20. package/dist/plugin.adapter.test.d.ts +1 -0
  21. package/dist/read-tools.adapter.d.ts +33 -0
  22. package/dist/read-tools.adapter.js +100 -0
  23. package/dist/skills/skills-directory.adapter.d.ts +62 -0
  24. package/dist/skills/skills-directory.adapter.js +30 -0
  25. package/dist/skills/skills-directory.adapter.test.d.ts +1 -0
  26. package/dist/skills/skills-directory.entity.d.ts +96 -0
  27. package/dist/skills/skills-directory.entity.functions.d.ts +33 -0
  28. package/dist/skills/skills-directory.entity.functions.js +45 -0
  29. package/dist/skills/skills-directory.entity.functions.test.d.ts +1 -0
  30. package/dist/skills/skills-directory.entity.js +35 -0
  31. package/dist/text-edit.entity.d.ts +32 -0
  32. package/dist/text-edit.entity.functions.d.ts +32 -0
  33. package/dist/text-edit.entity.functions.js +68 -0
  34. package/dist/text-edit.entity.functions.test.d.ts +1 -0
  35. package/dist/text-edit.entity.js +15 -0
  36. package/dist/text-search.entity.d.ts +23 -0
  37. package/dist/text-search.entity.functions.d.ts +51 -0
  38. package/dist/text-search.entity.functions.js +30 -0
  39. package/dist/text-search.entity.functions.test.d.ts +1 -0
  40. package/dist/text-search.entity.js +10 -0
  41. package/dist/text-window.entity.d.ts +46 -0
  42. package/dist/text-window.entity.functions.d.ts +54 -0
  43. package/dist/text-window.entity.functions.js +88 -0
  44. package/dist/text-window.entity.functions.test.d.ts +1 -0
  45. package/dist/text-window.entity.js +21 -0
  46. package/dist/workspace-path.entity.d.ts +22 -0
  47. package/dist/workspace-path.entity.functions.d.ts +60 -0
  48. package/dist/workspace-path.entity.functions.js +63 -0
  49. package/dist/workspace-path.entity.functions.test.d.ts +1 -0
  50. package/dist/workspace-path.entity.js +8 -0
  51. package/dist/workspace-tools.adapter.d.ts +21 -0
  52. package/dist/workspace-tools.adapter.js +46 -0
  53. package/dist/workspace-tools.adapter.test.d.ts +1 -0
  54. package/dist/workspace-tools.entity.d.ts +433 -0
  55. package/dist/workspace-tools.entity.functions.d.ts +51 -0
  56. package/dist/workspace-tools.entity.functions.js +124 -0
  57. package/dist/workspace-tools.entity.functions.test.d.ts +1 -0
  58. package/dist/workspace-tools.entity.js +133 -0
  59. package/dist/workspace-tools.journey.test.d.ts +1 -0
  60. package/dist/write-tools.adapter.d.ts +12 -0
  61. package/dist/write-tools.adapter.js +77 -0
  62. package/package.json +29 -4
@@ -0,0 +1,23 @@
1
+ import { Schema } from "effect";
2
+ /** One line of a file's search result: its 1-based number, its text (cut to the line width), and whether it matched or is context. */
3
+ export declare const SearchLine: Schema.Struct<{
4
+ readonly line: Schema.Int;
5
+ readonly text: Schema.String;
6
+ readonly match: Schema.Boolean;
7
+ }>;
8
+ export type SearchLine = typeof SearchLine.Type;
9
+ /**
10
+ * One file searched: the lines to show (matches and their context, in
11
+ * order), how many lines matched in what was searched, and whether the
12
+ * search stopped at its cap with more of the file left.
13
+ */
14
+ export declare const FileSearch: Schema.Struct<{
15
+ readonly lines: Schema.$Array<Schema.Struct<{
16
+ readonly line: Schema.Int;
17
+ readonly text: Schema.String;
18
+ readonly match: Schema.Boolean;
19
+ }>>;
20
+ readonly matches: Schema.Int;
21
+ readonly capped: Schema.Boolean;
22
+ }>;
23
+ export type FileSearch = typeof FileSearch.Type;
@@ -0,0 +1,51 @@
1
+ import { Result } from "effect";
2
+ import type { Failure } from "@xandreed/core";
3
+ import type { FileSearch } from "./text-search.entity.js";
4
+ /**
5
+ * Whether a regular expression (one that compiles, without the `u` or `v`
6
+ * flag) repeats without bound a group that itself holds an unbounded
7
+ * repetition: `(a+)+`, `(x*)*`, `(?:\w+\s?)*`, `((ab)+)+`. Such patterns
8
+ * backtrack exponentially on some lines. Repeating a group a bounded
9
+ * number of times (`(\d+\.){3}`) is not counted.
10
+ */
11
+ export declare const repeatsRepetition: (source: string) => boolean;
12
+ /**
13
+ * The expression `grep` matches with: the pattern as a JavaScript regular
14
+ * expression, or, with `literal`, as plain text; `ignoreCase` ignores
15
+ * case. Fails with `InvalidPattern` when the pattern does not compile, or
16
+ * when it repeats a repeating group (`repeatsRepetition`): matching runs
17
+ * in the host's process and cannot be interrupted, and such a pattern can
18
+ * take exponential time on one line.
19
+ */
20
+ export declare const compilePattern: (pattern: string, options: {
21
+ readonly literal: boolean;
22
+ readonly ignoreCase: boolean;
23
+ }) => Result.Result<RegExp, Failure>;
24
+ /**
25
+ * Search a file's text line by line. Each line is cut to `lineChars`
26
+ * characters first and only the cut line is matched (a match past the cut
27
+ * is not found), so how long a line may take is bounded by the cut, not by
28
+ * the line. That bound holds for patterns that match in linear time: the
29
+ * expression runs synchronously in the host's process, and a backtracking
30
+ * one can still take long on a cut line. `compilePattern` refuses nested
31
+ * repetition, the usual exponential form, but not every slow pattern
32
+ * (overlapping alternatives such as `(a|a)*`). At most `cap` matching lines
33
+ * are taken, each with up to `context` lines before and after it;
34
+ * overlapping context is shown once, in line order.
35
+ */
36
+ export declare const searchText: (text: string, pattern: RegExp, options: {
37
+ readonly lineChars: number;
38
+ readonly context: number;
39
+ readonly cap: number;
40
+ }) => FileSearch;
41
+ /**
42
+ * The model's view of matches: `path:line:text` for a matching line,
43
+ * `path-line-text` for context, and, when there is context, `--` between
44
+ * runs of lines that are not adjacent (or not in one file).
45
+ */
46
+ export declare const renderSearchLines: (lines: ReadonlyArray<{
47
+ readonly path: string;
48
+ readonly line: number;
49
+ readonly text: string;
50
+ readonly match: boolean;
51
+ }>, withContext: boolean) => ReadonlyArray<string>;
@@ -0,0 +1,30 @@
1
+ import { Result } from "effect";
2
+ import { clipChars, splitLines } from "./text-window.entity.functions.js";
3
+ const failure = (error, message) => ({ error, message }), tokenPattern = /\\[\s\S]|\[(?:\\[\s\S]|[^\]\\])*\]|\((?:\?(?::|=|!|<=|<!|<[A-Za-z_$][\w$]*>))?|\)|\{\d+(?:,\d*)?\}\??|[*+?]\??|[\s\S]/g, unbounded = (token) => token.startsWith("*") || token.startsWith("+") || /^\{\d+,\}/.test(token), isQuantifier = (token) => /^(?:[*+?]|\{\d+(?:,\d*)?\})\??$/.test(token);
4
+ export const repeatsRepetition = (source) => (source.match(tokenPattern) ?? []).reduce((scan, token) => {
5
+ if (token.startsWith("("))
6
+ return { groups: [...scan.groups, !1], closedRepeating: !1, nested: scan.nested };
7
+ if (token === ")") {
8
+ const inner = scan.groups.at(-1) ?? !1, outer = scan.groups.slice(0, -1);
9
+ return { groups: outer.length === 0 ? [inner] : [...outer.slice(0, -1), (outer.at(-1) ?? !1) || inner], closedRepeating: inner, nested: scan.nested };
10
+ }
11
+ if (isQuantifier(token) && unbounded(token))
12
+ return { groups: [...scan.groups.slice(0, -1), !0], closedRepeating: !1, nested: scan.nested || scan.closedRepeating };
13
+ return { ...scan, closedRepeating: !1 };
14
+ }, { groups: [!1], closedRepeating: !1, nested: !1 }).nested, compilePattern = (pattern, options) => {
15
+ const source = options.literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern, compiled = Result.try({
16
+ try: () => new RegExp(source, options.ignoreCase ? "i" : ""),
17
+ catch: (error) => failure("InvalidPattern", `${pattern} is not a valid regular expression (${error instanceof Error ? error.message : String(error)}); pass literal: true to search for it as text`)
18
+ });
19
+ return Result.flatMap(compiled, (regex) => !options.literal && repeatsRepetition(source) ? Result.fail(failure("InvalidPattern", `${pattern} repeats a group that itself repeats (as in (a+)+), which can take exponential time on some lines; rewrite it without the nested repetition, or search with bash (grep -E, rg)`)) : Result.succeed(regex));
20
+ }, searchText = (text, pattern, options) => {
21
+ const lines = splitLines(text).map((line) => clipChars(line, options.lineChars).text), hits = lines.reduce((state, line, at) => state.capped ? state : !pattern.test(line) ? state : state.found.length >= options.cap ? { found: state.found, capped: !0 } : { found: [...state.found, at], capped: !1 }, { found: [], capped: !1 }), shown = new Set(hits.found.flatMap((at) => Array.from({ length: options.context * 2 + 1 }, (_, offset) => at - options.context + offset).filter((line) => line >= 0 && line < lines.length))), matched = new Set(hits.found);
22
+ return {
23
+ lines: [...shown].sort((left, right) => left - right).map((at) => ({ line: at + 1, text: lines[at] ?? "", match: matched.has(at) })),
24
+ matches: hits.found.length,
25
+ capped: hits.capped
26
+ };
27
+ }, renderSearchLines = (lines, withContext) => lines.flatMap((entry, at) => {
28
+ const previous = lines[at - 1];
29
+ return [...withContext && previous !== void 0 && (previous.path !== entry.path || previous.line + 1 !== entry.line) ? ["--"] : [], `${entry.path}${entry.match ? ":" : "-"}${entry.line}${entry.match ? ":" : "-"}${entry.text}`];
30
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+ export const SearchLine = Schema.Struct({
3
+ line: Schema.Int,
4
+ text: Schema.String,
5
+ match: Schema.Boolean
6
+ }), FileSearch = Schema.Struct({
7
+ lines: Schema.Array(SearchLine),
8
+ matches: Schema.Int,
9
+ capped: Schema.Boolean
10
+ });
@@ -0,0 +1,46 @@
1
+ import { Schema } from "effect";
2
+ /**
3
+ * Lines `lineWindow` picked from a text: `lines` are lines `firstLine` to
4
+ * `lastLine` (1-based) of `totalLines`; `next` is the line to continue from
5
+ * when some are left; `clipped` when the first line alone was longer than
6
+ * the window's bytes and only its start is in `lines`.
7
+ */
8
+ export declare const LineWindow: Schema.Struct<{
9
+ readonly lines: Schema.$Array<Schema.String>;
10
+ readonly firstLine: Schema.Int;
11
+ readonly lastLine: Schema.Int;
12
+ readonly totalLines: Schema.Int;
13
+ readonly next: Schema.Option<Schema.Int>;
14
+ readonly clipped: Schema.Boolean;
15
+ }>;
16
+ export type LineWindow = typeof LineWindow.Type;
17
+ /** The lines `headLines` keeps from the start of a list, and how many it left out. */
18
+ export declare const HeadLines: Schema.Struct<{
19
+ readonly lines: Schema.$Array<Schema.String>;
20
+ readonly omitted: Schema.Int;
21
+ }>;
22
+ export type HeadLines = typeof HeadLines.Type;
23
+ /**
24
+ * The end of a command's output `tailText` keeps: `text` is its last whole
25
+ * lines within the limits (or, when the last line alone is longer than the
26
+ * bytes allowed, the end of that line); `totalLines` counts the lines of the
27
+ * whole output; `truncated` when anything was left out.
28
+ */
29
+ export declare const OutputTail: Schema.Struct<{
30
+ readonly text: Schema.String;
31
+ readonly shownLines: Schema.Int;
32
+ readonly totalLines: Schema.Int;
33
+ readonly truncated: Schema.Boolean;
34
+ }>;
35
+ export type OutputTail = typeof OutputTail.Type;
36
+ /**
37
+ * A file's bytes as text: the BOM, when there was one, removed and
38
+ * remembered; `lossy` when the bytes were not valid UTF-8, so the sequences
39
+ * that were not became U+FFFD and writing the text back would change them.
40
+ */
41
+ export declare const DecodedText: Schema.Struct<{
42
+ readonly text: Schema.String;
43
+ readonly bom: Schema.Boolean;
44
+ readonly lossy: Schema.Boolean;
45
+ }>;
46
+ export type DecodedText = typeof DecodedText.Type;
@@ -0,0 +1,54 @@
1
+ import { Option, Result } from "effect";
2
+ import type { DecodedText, HeadLines, LineWindow, OutputTail } from "./text-window.entity.js";
3
+ /** How many bytes `text` takes as UTF-8. */
4
+ export declare const utf8Length: (text: string) => number;
5
+ /** The start of `text`, at most `maxBytes` of UTF-8, never cutting a character in two. */
6
+ export declare const clipHead: (text: string, maxBytes: number) => string;
7
+ /** The end of `text`, at most `maxBytes` of UTF-8, never cutting a character in two. */
8
+ export declare const clipTail: (text: string, maxBytes: number) => string;
9
+ /** The start of a line, at most `maxChars` UTF-16 code units, never splitting a surrogate pair; and whether anything was cut. */
10
+ export declare const clipChars: (line: string, maxChars: number) => {
11
+ readonly text: string;
12
+ readonly clipped: boolean;
13
+ };
14
+ /**
15
+ * A text's lines: split at `\n`, with a `\r` before it dropped. A final
16
+ * newline ends the last line rather than starting an empty one, so `""` has
17
+ * no lines and `"a\n"` has one.
18
+ */
19
+ export declare const splitLines: (text: string) => ReadonlyArray<string>;
20
+ /**
21
+ * The lines `read` shows: from line `offset` (1-based), at most `limit` of
22
+ * them and at most `maxBytes` of UTF-8, whole lines only, except that a
23
+ * first line longer than `maxBytes` is shown cut (`clipped`) rather than
24
+ * not at all. An empty text has an empty window at offset 1; an offset past
25
+ * the last line fails with the number of lines there are.
26
+ */
27
+ export declare const lineWindow: (text: string, options: {
28
+ readonly offset: number;
29
+ readonly limit: number;
30
+ readonly maxBytes: number;
31
+ }) => Result.Result<LineWindow, number>;
32
+ /** The first lines of a list that fit `maxLines` and `maxBytes` (a first line longer than `maxBytes` is cut), and how many were left out. */
33
+ export declare const headLines: (lines: ReadonlyArray<string>, maxLines: number, maxBytes: number) => HeadLines;
34
+ /**
35
+ * The end of a command's output: its last whole lines within `maxLines` and
36
+ * `maxBytes`, or, when the last line alone is longer than `maxBytes`, that
37
+ * line's end.
38
+ */
39
+ export declare const tailText: (text: string, maxLines: number, maxBytes: number) => OutputTail;
40
+ /** Whether bytes look binary: a NUL byte in the first 8 KB. */
41
+ export declare const looksBinary: (bytes: Uint8Array) => boolean;
42
+ /**
43
+ * A file's bytes as UTF-8 text, its BOM removed and remembered. Bytes that
44
+ * are not valid UTF-8 (Latin-1, Windows-1252, Shift-JIS) are decoded all
45
+ * the same, their invalid sequences as U+FFFD, and the result says `lossy`:
46
+ * fit to show or search, not to write back.
47
+ */
48
+ export declare const decodeText: (bytes: Uint8Array) => DecodedText;
49
+ /** Text to write back: with its BOM again when it had one. */
50
+ export declare const encodeText: (decoded: Pick<DecodedText, "text" | "bom">) => string;
51
+ /** The media type a file name's extension suggests, for binary files the tools do not open. */
52
+ export declare const mediaTypeOf: (name: string) => Option.Option<string>;
53
+ /** A byte count for people: `812 B`, `50 KB`, `1.5 MB`. */
54
+ export declare const formatBytes: (bytes: number) => string;
@@ -0,0 +1,88 @@
1
+ import { Option, Result } from "effect";
2
+ const encoder = new TextEncoder, decoder = new TextDecoder("utf-8", { ignoreBOM: !0 }), strictDecoder = new TextDecoder("utf-8", { ignoreBOM: !0, fatal: !0 });
3
+ export const utf8Length = (text) => encoder.encode(text).length;
4
+ const isContinuation = (bytes, at) => ((bytes[at] ?? 0) & 192) === 128, startAtOrBefore = (bytes, at) => at <= 0 ? 0 : at >= bytes.length ? bytes.length : isContinuation(bytes, at) ? startAtOrBefore(bytes, at - 1) : at, startAtOrAfter = (bytes, at) => at >= bytes.length ? bytes.length : at > 0 && isContinuation(bytes, at) ? startAtOrAfter(bytes, at + 1) : Math.max(0, at);
5
+ export const clipHead = (text, maxBytes) => {
6
+ const bytes = encoder.encode(text);
7
+ return bytes.length <= maxBytes ? text : decoder.decode(bytes.subarray(0, startAtOrBefore(bytes, maxBytes)));
8
+ }, clipTail = (text, maxBytes) => {
9
+ const bytes = encoder.encode(text);
10
+ return bytes.length <= maxBytes ? text : decoder.decode(bytes.subarray(startAtOrAfter(bytes, bytes.length - maxBytes)));
11
+ }, clipChars = (line, maxChars) => {
12
+ if (line.length <= maxChars)
13
+ return { text: line, clipped: !1 };
14
+ const last = line.charCodeAt(maxChars - 1);
15
+ return { text: line.slice(0, last >= 55296 && last <= 56319 ? maxChars - 1 : maxChars), clipped: !0 };
16
+ }, splitLines = (text) => {
17
+ if (text.length === 0)
18
+ return [];
19
+ const parts = text.split(`
20
+ `);
21
+ return (parts.at(-1) === "" ? parts.slice(0, -1) : parts).map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
22
+ };
23
+ const fitting = (lines, maxBytes) => {
24
+ const over = lines.reduce((sums, line) => {
25
+ sums.push(sums.length === 0 ? utf8Length(line) : (sums.at(-1) ?? 0) + 1 + utf8Length(line));
26
+ return sums;
27
+ }, []).findIndex((total) => total > maxBytes);
28
+ return over < 0 ? lines.length : over;
29
+ };
30
+ export const lineWindow = (text, options) => {
31
+ const all = splitLines(text), start = options.offset - 1;
32
+ if (all.length === 0 && start === 0)
33
+ return Result.succeed({ lines: [], firstLine: 0, lastLine: 0, totalLines: 0, next: Option.none(), clipped: !1 });
34
+ if (start >= all.length)
35
+ return Result.fail(all.length);
36
+ const candidate = all.slice(start, start + options.limit), count = fitting(candidate, options.maxBytes), clipped = count === 0, lines = clipped ? [clipHead(candidate[0] ?? "", options.maxBytes)] : candidate.slice(0, count), lastLine = start + lines.length;
37
+ return Result.succeed({
38
+ lines,
39
+ firstLine: options.offset,
40
+ lastLine,
41
+ totalLines: all.length,
42
+ next: lastLine < all.length ? Option.some(lastLine + 1) : Option.none(),
43
+ clipped
44
+ });
45
+ }, headLines = (lines, maxLines, maxBytes) => {
46
+ const capped = lines.slice(0, maxLines), count = fitting(capped, maxBytes), kept = count === 0 && capped.length > 0 ? [clipHead(capped[0] ?? "", maxBytes)] : capped.slice(0, count);
47
+ return { lines: kept, omitted: lines.length - kept.length };
48
+ }, tailText = (text, maxLines, maxBytes) => {
49
+ const all = splitLines(text), capped = all.slice(Math.max(0, all.length - maxLines)), count = fitting([...capped].reverse(), maxBytes), kept = count === 0 && capped.length > 0 ? [clipTail(capped.at(-1) ?? "", maxBytes)] : capped.slice(capped.length - count);
50
+ return { text: kept.join(`
51
+ `), shownLines: kept.length, totalLines: all.length, truncated: kept.length < all.length || count === 0 && capped.length > 0 };
52
+ }, looksBinary = (bytes) => bytes.subarray(0, 8192).includes(0), decodeText = (bytes) => {
53
+ const bom = bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191, body = bom ? bytes.subarray(3) : bytes;
54
+ return Result.match(Result.try(() => strictDecoder.decode(body)), {
55
+ onSuccess: (text) => ({ text, bom, lossy: !1 }),
56
+ onFailure: () => ({ text: decoder.decode(body), bom, lossy: !0 })
57
+ });
58
+ }, encodeText = (decoded) => decoded.bom ? `\uFEFF${decoded.text}` : decoded.text;
59
+ const mediaTypes = new Map([
60
+ ["png", "image/png"],
61
+ ["jpg", "image/jpeg"],
62
+ ["jpeg", "image/jpeg"],
63
+ ["gif", "image/gif"],
64
+ ["webp", "image/webp"],
65
+ ["bmp", "image/bmp"],
66
+ ["ico", "image/x-icon"],
67
+ ["tif", "image/tiff"],
68
+ ["tiff", "image/tiff"],
69
+ ["avif", "image/avif"],
70
+ ["heic", "image/heic"],
71
+ ["pdf", "application/pdf"],
72
+ ["zip", "application/zip"],
73
+ ["gz", "application/gzip"],
74
+ ["tar", "application/x-tar"],
75
+ ["wasm", "application/wasm"],
76
+ ["mp3", "audio/mpeg"],
77
+ ["wav", "audio/wav"],
78
+ ["mp4", "video/mp4"],
79
+ ["webm", "video/webm"],
80
+ ["woff", "font/woff"],
81
+ ["woff2", "font/woff2"],
82
+ ["ttf", "font/ttf"],
83
+ ["otf", "font/otf"]
84
+ ]);
85
+ export const mediaTypeOf = (name) => {
86
+ const base = name.slice(name.lastIndexOf("/") + 1), dot = base.lastIndexOf(".");
87
+ return dot <= 0 ? Option.none() : Option.fromNullishOr(mediaTypes.get(base.slice(dot + 1).toLowerCase()));
88
+ }, formatBytes = (bytes) => bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${Number.isInteger(bytes / 1024) ? bytes / 1024 : (bytes / 1024).toFixed(1)} KB` : `${Number.isInteger(bytes / 1048576) ? bytes / 1048576 : (bytes / 1048576).toFixed(1)} MB`;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { Schema } from "effect";
2
+ export const LineWindow = Schema.Struct({
3
+ lines: Schema.Array(Schema.String),
4
+ firstLine: Schema.Int,
5
+ lastLine: Schema.Int,
6
+ totalLines: Schema.Int,
7
+ next: Schema.Option(Schema.Int),
8
+ clipped: Schema.Boolean
9
+ }), HeadLines = Schema.Struct({
10
+ lines: Schema.Array(Schema.String),
11
+ omitted: Schema.Int
12
+ }), OutputTail = Schema.Struct({
13
+ text: Schema.String,
14
+ shownLines: Schema.Int,
15
+ totalLines: Schema.Int,
16
+ truncated: Schema.Boolean
17
+ }), DecodedText = Schema.Struct({
18
+ text: Schema.String,
19
+ bom: Schema.Boolean,
20
+ lossy: Schema.Boolean
21
+ });
@@ -0,0 +1,22 @@
1
+ import { Schema } from "effect";
2
+ /**
3
+ * A path a tool was given, resolved in the workspace: `path` is where the
4
+ * tool acts (absolute, every link on the way resolved, through the nearest
5
+ * ancestor that exists), `display` is how the model wrote it, relative to
6
+ * the workspace root (`.` for the root; absolute when outside it).
7
+ */
8
+ export declare const WorkspaceTarget: Schema.Struct<{
9
+ readonly path: Schema.String;
10
+ readonly display: Schema.String;
11
+ }>;
12
+ export type WorkspaceTarget = typeof WorkspaceTarget.Type;
13
+ /**
14
+ * The workspace a call works in: its root as configured, made absolute
15
+ * (`lexicalRoot`), and as the file system resolves it (`root`, links
16
+ * resolved). Paths are resolved against `root`.
17
+ */
18
+ export declare const WorkspaceRoot: Schema.Struct<{
19
+ readonly root: Schema.String;
20
+ readonly lexicalRoot: Schema.String;
21
+ }>;
22
+ export type WorkspaceRoot = typeof WorkspaceRoot.Type;
@@ -0,0 +1,60 @@
1
+ import { Effect, Option, Result } from "effect";
2
+ import type { Context, Path } from "effect";
3
+ import { FileSystem, FsError, SessionEnvironment } from "@xandreed/core";
4
+ import type { Failure } from "@xandreed/core";
5
+ import type { WorkspaceRoot, WorkspaceTarget } from "./workspace-path.entity.js";
6
+ type Fs = Context.Service.Shape<typeof FileSystem>;
7
+ /** The workspace a call works in (`WorkspaceRoot`), with the file system and path functions it uses. */
8
+ export type WorkspaceAccess = WorkspaceRoot & {
9
+ readonly fs: Fs;
10
+ readonly path: Path.Path;
11
+ };
12
+ /** A file-system failure as the model reads it: what went wrong with the path it gave. */
13
+ export declare const fsFailure: (display: string) => (error: FsError) => Failure;
14
+ /** Whether `path` is `root` or below it. */
15
+ export declare const isWithin: (root: string, path: string) => boolean;
16
+ /**
17
+ * Open the workspace of the call: `SessionEnvironment.workspace` made
18
+ * absolute (against the process's directory) and resolved by the file
19
+ * system; one that cannot be resolved fails with `WorkspaceMissing`.
20
+ */
21
+ export declare const openWorkspace: (path: Path.Path) => Effect.Effect<WorkspaceAccess, Failure, FileSystem | SessionEnvironment>;
22
+ /** How a path appears to the model: relative to the root (`.` for the root itself), or absolute outside it. */
23
+ export declare const displayOf: (access: WorkspaceAccess, absolute: string) => string;
24
+ /**
25
+ * Resolve a path a tool was given: relative to the workspace root, or
26
+ * absolute. With `confine`, a path written outside the root is refused
27
+ * before anything is read, and so is one whose real location (links
28
+ * resolved, through the nearest existing ancestor) is outside it
29
+ * (`OutsideWorkspace`). This is a guardrail against mistakes, not a
30
+ * sandbox: what a path names can change between this check and its use.
31
+ */
32
+ export declare const resolveTarget: (access: WorkspaceAccess, input: string, options: {
33
+ readonly confine: boolean;
34
+ }) => Effect.Effect<WorkspaceTarget, Failure>;
35
+ /** A compiled `protect` glob: the pattern, and whether a path relative to the root matches it. */
36
+ export interface ProtectRule {
37
+ readonly glob: string;
38
+ readonly matches: (relative: string) => boolean;
39
+ }
40
+ /** Compile `protect` globs (dot entries included); fails with the first pattern that does not compile. */
41
+ export declare const compileProtect: (globs: ReadonlyArray<string>) => Result.Result<ReadonlyArray<ProtectRule>, string>;
42
+ /**
43
+ * The `exclude` globs as a walk from a directory must be given them, the
44
+ * directory written relative to the root (`""` for the root; none when it
45
+ * is outside the workspace). A name (no `/`) applies at any depth, so it is
46
+ * kept. A path is relative to the root, and the walk matches it against
47
+ * paths relative to where it starts: its leading segments are matched
48
+ * against the directory's names and what is left is kept, anchored there
49
+ * with `./`. A path that cannot reach below the directory, or that names
50
+ * the directory or one above it (the walk was asked to go there), is
51
+ * dropped, and so is every path when the directory is outside the
52
+ * workspace. A path whose braces span segments is kept as it is.
53
+ */
54
+ export declare const excludesBelow: (excludes: ReadonlyArray<string>, directory: Option.Option<string>) => ReadonlyArray<string>;
55
+ /**
56
+ * Refuse a write to a protected path: the path as written and where it
57
+ * really is are both checked, relative to the root, against every rule.
58
+ */
59
+ export declare const checkWritable: (access: WorkspaceAccess, target: WorkspaceTarget, rules: ReadonlyArray<ProtectRule>) => Effect.Effect<void, Failure>;
60
+ export {};
@@ -0,0 +1,63 @@
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
+ IsDirectory: (display) => failure("IsDirectory", `${display} is a directory`),
6
+ NotDirectory: (display) => failure("NotDirectory", `${display} is not a directory, or a part of the path before it is a file`),
7
+ Permission: (display) => failure("Permission", `permission denied: ${display}`)
8
+ };
9
+ export const fsFailure = (display) => (error) => reasons[error.reason]?.(display) ?? failure(error.reason, `${display}: ${error.message}`), isWithin = (root, path) => path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`), openWorkspace = (path) => Effect.gen(function* () {
10
+ const fs = yield* FileSystem, { workspace } = yield* SessionEnvironment, lexicalRoot = path.resolve(workspace), root = yield* fs.realPath(lexicalRoot).pipe(Effect.mapError((error) => failure("WorkspaceMissing", `the workspace directory cannot be opened (${error.reason}): ${error.message}`)));
11
+ return { fs, path, root, lexicalRoot };
12
+ }), displayOf = (access, absolute) => {
13
+ const base = isWithin(access.root, absolute) ? access.root : isWithin(access.lexicalRoot, absolute) ? access.lexicalRoot : void 0;
14
+ if (base === void 0)
15
+ return absolute;
16
+ const relative = access.path.relative(base, absolute);
17
+ return relative === "" ? "." : relative;
18
+ };
19
+ const canonicalOf = (access, target) => access.fs.realPath(target).pipe(Effect.catchIf((error) => error.reason === "NotFound", (missing) => {
20
+ const parent = access.path.dirname(target);
21
+ if (parent === target)
22
+ return Effect.fail(missing);
23
+ return access.fs.stat(target, { followSymlinks: !1 }).pipe(Effect.matchEffect({
24
+ onSuccess: () => Effect.fail(new FsError({ path: target, reason: "Invalid", message: "it is a symbolic link to something that does not exist" })),
25
+ onFailure: (error) => error.reason === "NotFound" ? canonicalOf(access, parent).pipe(Effect.map((dir) => access.path.join(dir, access.path.basename(target)))) : Effect.fail(error)
26
+ }));
27
+ }));
28
+ export const resolveTarget = (access, input, options) => Effect.gen(function* () {
29
+ const lexical = access.path.resolve(access.root, input.length === 0 ? "." : input), display = displayOf(access, lexical), inside = (path) => isWithin(access.root, path) || isWithin(access.lexicalRoot, path);
30
+ if (options.confine && !inside(lexical))
31
+ return yield* Effect.fail(failure("OutsideWorkspace", `${input} is outside the workspace; use a path under its root`));
32
+ const path = yield* canonicalOf(access, lexical).pipe(Effect.mapError(fsFailure(display)));
33
+ if (options.confine && !isWithin(access.root, path))
34
+ return yield* Effect.fail(failure("OutsideWorkspace", `${display} leads outside the workspace through a symbolic link`));
35
+ return { path, display };
36
+ }), compileProtect = (globs) => Result.all(globs.map((glob) => Result.mapError(Result.map(compileGlob(glob, { dot: !0 }), (matcher) => ({ glob, matches: matcher.matches })), (reason) => `protect glob ${glob} is not usable: ${reason}`)));
37
+ const segmentsOf = (glob) => glob.split("/").filter((segment) => segment.length > 0 && segment !== "."), ownBraces = (segment) => (segment.match(/\{/g) ?? []).length === (segment.match(/\}/g) ?? []).length, remainderBelow = (pattern, names) => {
38
+ const [head, ...rest] = pattern, [name, ...below] = names;
39
+ if (name === void 0)
40
+ return pattern.length === 0 ? [] : [`./${pattern.join("/")}`];
41
+ if (head === void 0)
42
+ return [];
43
+ if (head === "**")
44
+ return [`./${pattern.join("/")}`, ...names.flatMap((_, at) => remainderBelow(rest, names.slice(at)))];
45
+ return Result.match(compileGlob(head, { dot: !0 }), {
46
+ onFailure: () => [],
47
+ onSuccess: (segment) => segment.matches(name) ? remainderBelow(rest, below) : []
48
+ });
49
+ };
50
+ export const excludesBelow = (excludes, directory) => [...new Set(excludes.flatMap((glob) => {
51
+ if (!glob.includes("/"))
52
+ return [glob];
53
+ return Option.match(directory, {
54
+ onNone: () => [],
55
+ onSome: (relative) => {
56
+ const names = segmentsOf(relative), pattern = segmentsOf(glob);
57
+ return names.length === 0 || !pattern.every(ownBraces) ? [glob] : remainderBelow(pattern, names);
58
+ }
59
+ });
60
+ }))], checkWritable = (access, target, rules) => {
61
+ 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)));
62
+ return rule === void 0 ? Effect.void : Effect.fail(failure("Protected", `${target.display} is protected (${rule.glob}); the workspace tools do not change it`));
63
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { Schema } from "effect";
2
+ export const WorkspaceTarget = Schema.Struct({
3
+ path: Schema.String,
4
+ display: Schema.String
5
+ }), WorkspaceRoot = Schema.Struct({
6
+ root: Schema.String,
7
+ lexicalRoot: Schema.String
8
+ });
@@ -0,0 +1,21 @@
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
+ import type { WorkspaceToolsConfig } from "./workspace-tools.entity.js";
6
+ /**
7
+ * The workspace tools as one capability, for the workspace in each call's
8
+ * `SessionEnvironment`, over each call's `FileSystem` and `Bash`: `read`,
9
+ * `ls`, `find` and `grep` (skill `workspace.read`, read-only), `write` and
10
+ * `edit` (`workspace.edit`, needing `fs.write`) and `bash`
11
+ * (`workspace.shell`, needing `shell.exec`), plus the
12
+ * `workspace.environment` prompt section. 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
+ */
21
+ export declare const workspaceTools: (config: WorkspaceToolsConfig) => Effect.Effect<Capability, HarnessError, Scope.Scope>;
@@ -0,0 +1,46 @@
1
+ import { Effect, Option, Path, RcMap, Result, Semaphore } from "effect";
2
+ import { Bash, compileGlob, defineCapability, defineSkill, HarnessError, SessionEnvironment } from "@xandreed/core";
3
+ import { bashTool } from "./bash-tool.adapter.js";
4
+ import { findTool, grepTool, lsTool, readTool } from "./read-tools.adapter.js";
5
+ import { compileProtect, openWorkspace } from "./workspace-path.entity.functions.js";
6
+ import { WORKSPACE_PERMISSIONS } from "./workspace-tools.entity.js";
7
+ import { environmentText, skillOfTool } from "./workspace-tools.entity.functions.js";
8
+ import { editTool, writeTool } from "./write-tools.adapter.js";
9
+ const ORDER = ["read", "ls", "find", "grep", "write", "edit", "bash"], builders = {
10
+ read: readTool,
11
+ ls: lsTool,
12
+ find: findTool,
13
+ grep: grepTool,
14
+ write: writeTool,
15
+ edit: editTool,
16
+ bash: bashTool
17
+ }, skills = {
18
+ read: { id: "workspace.read", summary: "Read, list and search the workspace's files (read, ls, find, grep).", permissions: [] },
19
+ edit: { id: "workspace.edit", summary: "Create and change files in the workspace (write, edit).", permissions: [WORKSPACE_PERMISSIONS.write] },
20
+ shell: { id: "workspace.shell", summary: "Run shell commands in the workspace (bash).", permissions: [WORKSPACE_PERMISSIONS.exec] }
21
+ }, invalid = (message) => new HarnessError({ code: "config.options", plugin: "@xandreed/plugin-workspace/tools", message });
22
+ export const workspaceTools = (config) => Effect.gen(function* () {
23
+ 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 })));
24
+ if (badExclude !== void 0)
25
+ return yield* Effect.fail(invalid(`exclude glob ${badExclude} is not usable`));
26
+ const locks = yield* RcMap.make({ lookup: (_path) => Semaphore.make(1), idleTimeToLive: "1 minute" }), hasBash = Option.isSome(yield* Effect.serviceOption(Bash)), wanted = ORDER.filter((tool) => config.tools.includes(tool));
27
+ if (wanted.includes("bash") && !hasBash)
28
+ 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)");
29
+ const enabled = wanted.filter((tool) => tool !== "bash" || hasBash), context = { config, path, protect, locks }, section = {
30
+ id: "workspace.environment",
31
+ version: "1",
32
+ tier: "session",
33
+ order: config.sectionOrder,
34
+ 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, config, enabled))))
35
+ };
36
+ return defineCapability({
37
+ id: "@xandreed/plugin-workspace/tools",
38
+ version: "1",
39
+ tools: enabled.map((tool) => builders[tool](context)),
40
+ skills: ["read", "edit", "shell"].flatMap((name) => {
41
+ const tools = enabled.filter((tool) => skillOfTool[tool] === name);
42
+ return tools.length === 0 ? [] : [defineSkill({ ...skills[name], tools, always: config.always.includes(name) })];
43
+ }),
44
+ promptSections: config.environmentSection && enabled.length > 0 ? [section] : []
45
+ });
46
+ });
@@ -0,0 +1 @@
1
+ export {};