@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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/bash-tool.adapter.d.ts +15 -0
- package/dist/bash-tool.adapter.js +72 -0
- package/dist/bash.adapter.d.ts +29 -0
- package/dist/bash.adapter.js +69 -0
- package/dist/bash.adapter.test.d.ts +1 -0
- package/dist/file-system.adapter.d.ts +20 -0
- package/dist/file-system.adapter.js +52 -0
- package/dist/file-system.adapter.test.d.ts +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +35 -0
- package/dist/local-system.entity.d.ts +40 -0
- package/dist/local-system.entity.functions.d.ts +52 -0
- package/dist/local-system.entity.functions.js +32 -0
- package/dist/local-system.entity.functions.test.d.ts +1 -0
- package/dist/local-system.entity.js +11 -0
- package/dist/plugin.adapter.d.ts +120 -0
- package/dist/plugin.adapter.js +27 -0
- package/dist/plugin.adapter.test.d.ts +1 -0
- package/dist/read-tools.adapter.d.ts +33 -0
- package/dist/read-tools.adapter.js +100 -0
- package/dist/skills/skills-directory.adapter.d.ts +62 -0
- package/dist/skills/skills-directory.adapter.js +30 -0
- package/dist/skills/skills-directory.adapter.test.d.ts +1 -0
- package/dist/skills/skills-directory.entity.d.ts +96 -0
- package/dist/skills/skills-directory.entity.functions.d.ts +33 -0
- package/dist/skills/skills-directory.entity.functions.js +45 -0
- package/dist/skills/skills-directory.entity.functions.test.d.ts +1 -0
- package/dist/skills/skills-directory.entity.js +35 -0
- package/dist/text-edit.entity.d.ts +32 -0
- package/dist/text-edit.entity.functions.d.ts +32 -0
- package/dist/text-edit.entity.functions.js +68 -0
- package/dist/text-edit.entity.functions.test.d.ts +1 -0
- package/dist/text-edit.entity.js +15 -0
- package/dist/text-search.entity.d.ts +23 -0
- package/dist/text-search.entity.functions.d.ts +51 -0
- package/dist/text-search.entity.functions.js +30 -0
- package/dist/text-search.entity.functions.test.d.ts +1 -0
- package/dist/text-search.entity.js +10 -0
- package/dist/text-window.entity.d.ts +46 -0
- package/dist/text-window.entity.functions.d.ts +54 -0
- package/dist/text-window.entity.functions.js +88 -0
- package/dist/text-window.entity.functions.test.d.ts +1 -0
- package/dist/text-window.entity.js +21 -0
- package/dist/workspace-path.entity.d.ts +22 -0
- package/dist/workspace-path.entity.functions.d.ts +60 -0
- package/dist/workspace-path.entity.functions.js +63 -0
- package/dist/workspace-path.entity.functions.test.d.ts +1 -0
- package/dist/workspace-path.entity.js +8 -0
- package/dist/workspace-tools.adapter.d.ts +21 -0
- package/dist/workspace-tools.adapter.js +46 -0
- package/dist/workspace-tools.adapter.test.d.ts +1 -0
- package/dist/workspace-tools.entity.d.ts +433 -0
- package/dist/workspace-tools.entity.functions.d.ts +51 -0
- package/dist/workspace-tools.entity.functions.js +124 -0
- package/dist/workspace-tools.entity.functions.test.d.ts +1 -0
- package/dist/workspace-tools.entity.js +133 -0
- package/dist/workspace-tools.journey.test.d.ts +1 -0
- package/dist/write-tools.adapter.d.ts +12 -0
- package/dist/write-tools.adapter.js +77 -0
- 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,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 {};
|