@zocomputer/agent-sdk 0.5.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
@@ -0,0 +1,179 @@
1
+ import Defuddle from "defuddle";
2
+ import { parseHTML } from "linkedom";
3
+
4
+ // Main-content extraction and honest-failure signals for fetched web pages.
5
+ // Defuddle (Obsidian Web Clipper's reader-mode engine) prunes nav/footer/
6
+ // cookie-banner boilerplate and reads title/author/date metadata in the same
7
+ // pass; linkedom supplies the server-side DOM it runs on. Pure and
8
+ // framework-free: `renderWebText` (../web-fetch.ts) orchestrates, this module
9
+ // owns the extraction, the metadata header, and the collapse/domain notes.
10
+
11
+ export interface ExtractedWebPage {
12
+ /** Cleaned main-content HTML, ready for markdown/text conversion. */
13
+ readonly contentHtml: string;
14
+ readonly title: string | null;
15
+ readonly author: string | null;
16
+ readonly published: string | null;
17
+ readonly site: string | null;
18
+ }
19
+
20
+ const asField = (value: unknown): string | null => {
21
+ if (typeof value !== "string") return null;
22
+ const trimmed = value.trim();
23
+ return trimmed === "" ? null : trimmed;
24
+ };
25
+
26
+ /** Visible-ish text length: script/style/noscript bodies and tags stripped. */
27
+ export function visibleTextLength(html: string): number {
28
+ return html
29
+ .replace(/<(script|style|noscript)\b[\s\S]*?<\/\1\s*>/gi, " ")
30
+ .replace(/<[^>]+>/g, " ")
31
+ .replace(/\s+/g, " ")
32
+ .trim().length;
33
+ }
34
+
35
+ /**
36
+ * Extract the page's main content and metadata. Returns null — meaning
37
+ * "convert the whole document instead" — when extraction throws, comes back
38
+ * empty, or prunes so aggressively it lost most of a modest page (better to
39
+ * show boilerplate than to silently drop real content).
40
+ */
41
+ export function extractMainContent(html: string, url: string): ExtractedWebPage | null {
42
+ let parsed: ReturnType<Defuddle["parse"]>;
43
+ try {
44
+ const { document } = parseHTML(html);
45
+ // linkedom's document isn't lib.dom's Document (we compile without the DOM
46
+ // lib); defuddle only needs the query/traversal surface linkedom provides,
47
+ // so this is the one sanctioned cast at the seam.
48
+ parsed = new Defuddle(document as unknown as ConstructorParameters<typeof Defuddle>[0], {
49
+ url,
50
+ }).parse();
51
+ } catch {
52
+ return null;
53
+ }
54
+ const contentHtml = typeof parsed.content === "string" ? parsed.content : "";
55
+ const extractedLength = visibleTextLength(contentHtml);
56
+ if (extractedLength === 0) return null;
57
+ // Over-pruning guard: a small extraction from a page with several times more
58
+ // visible text means the cleaner guessed wrong about what's boilerplate.
59
+ if (extractedLength < 500 && extractedLength * 4 < visibleTextLength(html)) return null;
60
+ return {
61
+ contentHtml,
62
+ title: asField(parsed.title),
63
+ author: asField(parsed.author),
64
+ published: asField(parsed.published),
65
+ site: asField(parsed.site),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * A markdown header for the extracted page: the title as `#`, then one
71
+ * blockquote line with whichever of author/site/published exist. Null when
72
+ * there's nothing to say.
73
+ */
74
+ export function buildMetadataHeader(
75
+ page: Pick<ExtractedWebPage, "title" | "author" | "published" | "site">,
76
+ ): string | null {
77
+ const byline = [
78
+ page.author === null ? null : `By ${page.author}`,
79
+ page.site,
80
+ page.published === null ? null : `Published ${page.published}`,
81
+ ]
82
+ .filter((part): part is string => part !== null)
83
+ .join(" · ");
84
+ const lines: string[] = [];
85
+ if (page.title !== null) lines.push(`# ${page.title}`);
86
+ if (byline !== "") lines.push(`> ${byline}`);
87
+ return lines.length === 0 ? null : lines.join("\n\n");
88
+ }
89
+
90
+ // Domains whose pages are client-rendered or login-walled: a fetch sees the
91
+ // empty shell, so the collapse note names the reason and a way forward.
92
+ const JS_RENDERED_DOMAIN_HINTS: readonly {
93
+ readonly suffixes: readonly string[];
94
+ readonly hint: string;
95
+ }[] = [
96
+ {
97
+ suffixes: ["x.com", "twitter.com"],
98
+ hint: "X (Twitter) renders posts with client-side JavaScript and blocks anonymous scraping, so a plain fetch can't see the post.",
99
+ },
100
+ {
101
+ suffixes: ["reddit.com"],
102
+ hint: "Reddit renders client-side — fetch the JSON view (append .json to the post URL) or the same path on old.reddit.com instead.",
103
+ },
104
+ {
105
+ suffixes: ["linkedin.com"],
106
+ hint: "LinkedIn requires login; anonymous fetches get a wall instead of the content.",
107
+ },
108
+ {
109
+ suffixes: ["instagram.com", "threads.net", "tiktok.com", "facebook.com"],
110
+ hint: "This site renders client-side and gates content behind login, so a plain fetch can't see it.",
111
+ },
112
+ {
113
+ suffixes: ["youtube.com", "youtu.be"],
114
+ hint: "YouTube pages are a client-rendered player; the video itself isn't fetchable as text.",
115
+ },
116
+ ];
117
+
118
+ /** The domain-specific reason a fetch of this URL comes back empty, if known. */
119
+ export function jsRenderedDomainHint(url: string): string | null {
120
+ let hostname: string;
121
+ try {
122
+ hostname = new URL(url).hostname.toLowerCase();
123
+ } catch {
124
+ return null;
125
+ }
126
+ for (const { suffixes, hint } of JS_RENDERED_DOMAIN_HINTS) {
127
+ for (const suffix of suffixes) {
128
+ if (hostname === suffix || hostname.endsWith(`.${suffix}`)) return hint;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /** Rendered text this small from HTML this big means the content never made it. */
135
+ export const COLLAPSE_RENDERED_MAX_CHARS = 200;
136
+ export const COLLAPSE_HTML_MIN_CHARS = 2_000;
137
+
138
+ /**
139
+ * When a substantial HTML page renders to almost nothing, say so — the model
140
+ * would otherwise read the near-empty result as "the page has no content"
141
+ * instead of "the fetch couldn't see the content".
142
+ *
143
+ * `documentTextChars` — the whole document's visible text, before
144
+ * main-content extraction — is what separates the two ways a render comes
145
+ * back short: a JS shell or login wall has almost no visible text anywhere
146
+ * in the document, while a legitimately short article sits inside a template
147
+ * whose nav/footer boilerplate still carries plenty. Only the former earns
148
+ * the note.
149
+ */
150
+ export function buildContentCollapseNote(opts: {
151
+ url: string;
152
+ renderedChars: number;
153
+ htmlChars: number;
154
+ documentTextChars: number;
155
+ }): string | null {
156
+ if (opts.renderedChars >= COLLAPSE_RENDERED_MAX_CHARS) return null;
157
+ if (opts.documentTextChars >= COLLAPSE_RENDERED_MAX_CHARS) return null;
158
+ if (opts.htmlChars < COLLAPSE_HTML_MIN_CHARS) return null;
159
+ const base = `The page produced almost no readable text (${opts.renderedChars} chars from ${opts.htmlChars} chars of HTML) — its content likely renders with client-side JavaScript or sits behind a login or bot wall.`;
160
+ const hint = jsRenderedDomainHint(opts.url);
161
+ return hint === null ? base : `${base} ${hint}`;
162
+ }
163
+
164
+ /**
165
+ * Detect a markdown conversion that left the output substantially raw HTML
166
+ * (e.g. markup turndown couldn't traverse), so the result can say so instead
167
+ * of passing tag soup off as a clean read.
168
+ */
169
+ export function looksLikeRawHtmlOutput(rendered: string): boolean {
170
+ if (rendered.length === 0) return false;
171
+ // `[^<>]` (not `[^>]`) bounds the attribute scan to a single tag: an
172
+ // unterminated `<tag …` fails at the next `<` instead of scanning to the
173
+ // end of the string, which kept this linear on hostile input (a fetched
174
+ // page of `"<a ".repeat(n)` was polynomial — CodeQL js/polynomial-redos).
175
+ const tags = rendered.match(/<\/?[a-z][a-z0-9-]*(?:\s[^<>]*)?>/gi);
176
+ if (tags === null || tags.length < 10) return false;
177
+ const tagChars = tags.reduce((sum, tag) => sum + tag.length, 0);
178
+ return tagChars / rendered.length > 0.1;
179
+ }
@@ -0,0 +1,225 @@
1
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { globToRegExp } from "./glob-match";
4
+ import { listGitFiles } from "./list-files";
5
+ import { readTextForSearch } from "./read-text";
6
+ import { walkFiles } from "./walk";
7
+ import { relativizeWithin } from "./workspace";
8
+
9
+ // The I/O seam under every stdlib file tool. Tools do path math through
10
+ // `Workspace` (pure) and effects through a `WorkspaceIO` — so the same
11
+ // read/edit/write/glob/grep factories run against the harness process's own
12
+ // disk (rib, the coder example) or a remote eve sandbox (hosted agents, where
13
+ // the eve process and the workspace are different machines — see
14
+ // ./sandbox-io.ts). The local backend lives here; it is exactly the node:fs +
15
+ // `git ls-files` + in-process-scan behavior the tools shipped with.
16
+ //
17
+ // A sandbox arrives per tool call (eve's `ctx.getSandbox()`), not per factory
18
+ // build, so tools hold a `WorkspaceIoProvider` — `(ctx) => WorkspaceIO` — and
19
+ // resolve it at the top of each execute. The local provider ignores `ctx`
20
+ // entirely; the tools' no-session usability (direct factory calls in tests)
21
+ // is preserved.
22
+
23
+ /** Stat result for one path. */
24
+ export interface IoStat {
25
+ readonly isFile: boolean;
26
+ readonly size: number;
27
+ readonly mtimeMs: number;
28
+ }
29
+
30
+ /** One matching line from a content search. `file` is workspace-root-relative. */
31
+ export interface IoSearchMatch {
32
+ readonly file: string;
33
+ readonly line: number;
34
+ readonly text: string;
35
+ }
36
+
37
+ export interface IoSearchOptions {
38
+ /** JavaScript regex source. The tool validates it before calling. */
39
+ readonly pattern: string;
40
+ readonly ignoreCase: boolean;
41
+ /**
42
+ * Absolute file or directory to search. The whole workspace when omitted.
43
+ * Callers stat the scope first, so it exists.
44
+ */
45
+ readonly scope?: string | undefined;
46
+ /** Filename glob filter over root-relative paths (e.g. `**​/*.ts`). */
47
+ readonly glob?: string | undefined;
48
+ /** Stop scanning once this many matching lines have been collected. */
49
+ readonly maxMatches: number;
50
+ }
51
+
52
+ export interface IoSearchResult {
53
+ readonly matches: readonly IoSearchMatch[];
54
+ /**
55
+ * Why the scan ended early, or `false` when it covered everything:
56
+ * `"max-matches"` = the `maxMatches` bound; `"output-cap"` = a remote
57
+ * backend's byte cap cut the stream mid-scan (fewer than `maxMatches`
58
+ * lines were parsed, and more may exist).
59
+ */
60
+ readonly stopped: false | "max-matches" | "output-cap";
61
+ /**
62
+ * Files skipped for being over the search size cap, or `null` when the
63
+ * backend can't know (a remote searcher enforces the cap but doesn't
64
+ * report a count). Consumers must omit the figure rather than show 0.
65
+ */
66
+ readonly skippedLargeFiles: number | null;
67
+ }
68
+
69
+ /**
70
+ * Byte-oriented workspace effects, async so a remote backend fits. All paths
71
+ * are absolute (already resolved through `Workspace`); returned file lists
72
+ * and match paths are workspace-root-relative with forward slashes.
73
+ */
74
+ export interface WorkspaceIO {
75
+ /** Stat one path; `null` when it doesn't exist. */
76
+ stat(abs: string): Promise<IoStat | null>;
77
+ /** Read one file's bytes; `null` when it doesn't exist. */
78
+ readFile(abs: string): Promise<Buffer | null>;
79
+ /** Write one file, creating parent directories and overwriting. */
80
+ writeFile(abs: string, content: string | Uint8Array): Promise<void>;
81
+ /**
82
+ * Candidate file paths for glob/grep — root-relative, gitignore-aware.
83
+ * `scope` (absolute directory) narrows the listing.
84
+ */
85
+ listFiles(scope?: string): Promise<Iterable<string>>;
86
+ /** Regex content search, backend-native (in-process locally, rg/grep remotely). */
87
+ search(options: IoSearchOptions): Promise<IoSearchResult>;
88
+ }
89
+
90
+ /**
91
+ * The slice of eve's `SandboxSession` the sandbox backend needs, declared
92
+ * structurally so lib modules stay framework-free (eve's type satisfies it).
93
+ * Shapes mirror the AI SDK sandbox surface: reads resolve `null` for a
94
+ * missing file, writes create parent directories.
95
+ */
96
+ export interface SandboxSessionLike {
97
+ readonly readBinaryFile: (options: {
98
+ path: string;
99
+ }) => PromiseLike<Uint8Array | null>;
100
+ readonly writeBinaryFile: (options: {
101
+ path: string;
102
+ content: Uint8Array;
103
+ }) => PromiseLike<void>;
104
+ readonly run: (options: {
105
+ command: string;
106
+ workingDirectory?: string;
107
+ }) => PromiseLike<{ exitCode: number; stdout: string; stderr: string }>;
108
+ }
109
+
110
+ /**
111
+ * The slice of eve's `ToolContext` an IO provider may use. Structural, so
112
+ * tools can hand their eve context straight through without the lib importing
113
+ * `eve/*`. The session id is exposed for `resolveSession` hooks that run
114
+ * per-session setup (the Builder's workspace bootstrap keys on it).
115
+ */
116
+ export interface IoToolContext {
117
+ readonly session?: { readonly id: string };
118
+ getSandbox(): PromiseLike<SandboxSessionLike>;
119
+ }
120
+
121
+ /**
122
+ * Resolves the IO for one tool call. The local provider ignores `ctx`; the
123
+ * sandbox provider (see ./sandbox-io.ts) resolves `ctx.getSandbox()` lazily,
124
+ * so constructing the IO never touches the session.
125
+ */
126
+ export type WorkspaceIoProvider = (ctx: IoToolContext | undefined) => WorkspaceIO;
127
+
128
+ /** The local backend: node:fs against the harness process's own disk. */
129
+ export function createLocalIo(root: string): WorkspaceIO {
130
+ return {
131
+ async stat(abs) {
132
+ try {
133
+ const st = statSync(abs);
134
+ return { isFile: st.isFile(), size: st.size, mtimeMs: st.mtimeMs };
135
+ } catch {
136
+ return null;
137
+ }
138
+ },
139
+ async readFile(abs) {
140
+ try {
141
+ return readFileSync(abs);
142
+ } catch (err) {
143
+ if (isMissingFileError(err)) return null;
144
+ throw err;
145
+ }
146
+ },
147
+ async writeFile(abs, content) {
148
+ mkdirSync(dirname(abs), { recursive: true });
149
+ writeFileSync(abs, content);
150
+ },
151
+ async listFiles(scope) {
152
+ if (scope === undefined) {
153
+ return listGitFiles(root) ?? walkFiles(root);
154
+ }
155
+ const rel = relativizeWithin(root, scope);
156
+ return listGitFiles(root, rel) ?? walkFiles(scope, root);
157
+ },
158
+ async search(options) {
159
+ return searchLocal(root, options);
160
+ },
161
+ };
162
+ }
163
+
164
+ /** A provider over one shared local IO — every call gets the same instance. */
165
+ export function localIoProvider(root: string): WorkspaceIoProvider {
166
+ const io = createLocalIo(root);
167
+ return () => io;
168
+ }
169
+
170
+ function isMissingFileError(err: unknown): boolean {
171
+ return (
172
+ typeof err === "object" &&
173
+ err !== null &&
174
+ "code" in err &&
175
+ (err as { code?: unknown }).code === "ENOENT"
176
+ );
177
+ }
178
+
179
+ /** The in-process scan the local grep has always run; exported for tests. */
180
+ export async function searchLocal(
181
+ root: string,
182
+ options: IoSearchOptions,
183
+ ): Promise<IoSearchResult> {
184
+ const re = new RegExp(options.pattern, options.ignoreCase ? "i" : "");
185
+ const globRe = options.glob ? globToRegExp(options.glob) : null;
186
+
187
+ let candidates: Iterable<string>;
188
+ if (options.scope !== undefined) {
189
+ const rel = relativizeWithin(root, options.scope);
190
+ let isFile = false;
191
+ try {
192
+ isFile = statSync(options.scope).isFile();
193
+ } catch {
194
+ isFile = false;
195
+ }
196
+ candidates = isFile
197
+ ? [rel]
198
+ : (listGitFiles(root, rel) ?? walkFiles(options.scope, root));
199
+ } else {
200
+ candidates = listGitFiles(root) ?? walkFiles(root);
201
+ }
202
+
203
+ const matches: IoSearchMatch[] = [];
204
+ let stopped: false | "max-matches" = false;
205
+ let skippedLargeFiles = 0;
206
+ scan: for (const file of candidates) {
207
+ if (globRe && !globRe.test(file)) continue;
208
+ const read = readTextForSearch(join(root, file));
209
+ if (read.kind === "too-large") {
210
+ skippedLargeFiles += 1;
211
+ continue;
212
+ }
213
+ if (read.kind !== "text") continue;
214
+ const lines = read.content.split("\n");
215
+ for (const [index, line] of lines.entries()) {
216
+ if (!re.test(line)) continue;
217
+ matches.push({ file, line: index + 1, text: line });
218
+ if (matches.length >= options.maxMatches) {
219
+ stopped = "max-matches";
220
+ break scan;
221
+ }
222
+ }
223
+ }
224
+ return { matches, stopped, skippedLargeFiles };
225
+ }
@@ -0,0 +1,41 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+
3
+ // Every file tool resolves paths against one root and refuses anything that
4
+ // escapes it — an agent is scoped to the workspace it was launched in. `bash`
5
+ // is deliberately not confined this way (it's a real shell); the file tools are.
6
+
7
+ /**
8
+ * Resolve `path` against `root` and refuse anything that escapes it. Relative
9
+ * paths resolve from `root`; an absolute path must already sit inside it.
10
+ */
11
+ export function resolveWithin(root: string, path: string): string {
12
+ const abs = isAbsolute(path) ? resolve(path) : resolve(root, path);
13
+ // `root + sep` (not bare `root`) so a sibling like `/repo-evil` isn't read as
14
+ // inside `/repo`.
15
+ if (abs !== root && !abs.startsWith(root + sep)) {
16
+ throw new Error(`Path escapes the workspace root (${root}): ${path}`);
17
+ }
18
+ return abs;
19
+ }
20
+
21
+ /** A root-relative, forward-slash path for display. Pure counterpart to resolveWithin. */
22
+ export function relativizeWithin(root: string, abs: string): string {
23
+ const rel = relative(root, abs);
24
+ return rel === "" ? "." : rel.split(sep).join("/");
25
+ }
26
+
27
+ /** One workspace root plus the two path operations every file tool needs. */
28
+ export interface Workspace {
29
+ readonly root: string;
30
+ resolve(path: string): string;
31
+ relativize(abs: string): string;
32
+ }
33
+
34
+ export function createWorkspace(root: string): Workspace {
35
+ const abs = resolve(root);
36
+ return {
37
+ root: abs,
38
+ resolve: (path) => resolveWithin(abs, path),
39
+ relativize: (path) => relativizeWithin(abs, path),
40
+ };
41
+ }