@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.
- package/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/attachments.js +52 -0
- package/dist/gateway-fetch.js +67 -0
- package/dist/index.js +4169 -0
- package/dist/initiator-auth.js +49 -0
- package/dist/platform/agent-sandbox/index.js +691 -0
- package/dist/platform/cloud-tools/image.js +498 -0
- package/dist/platform/cloud-tools/index.js +507 -0
- package/dist/platform/cloud-tools/web-search.js +87 -0
- package/dist/platform/runtime-ai/gateway.js +87 -0
- package/dist/platform/runtime-ai/index.js +91 -0
- package/dist/platform/runtime-ai/register.js +88 -0
- package/dist/platform/runtime-ai/session-fetch.js +54 -0
- package/dist/platform/runtime-auth/index.js +141 -0
- package/dist/state-files.js +287 -0
- package/dist/state-sandbox.js +383 -0
- package/dist/state.js +29 -0
- package/dist/steer-inbox.js +84 -0
- package/dist/steer.js +83 -0
- package/package.json +143 -0
- package/platform/agent-sandbox/api-client.ts +196 -0
- package/platform/agent-sandbox/index.ts +27 -0
- package/platform/agent-sandbox/pure.ts +83 -0
- package/platform/agent-sandbox/sftp.ts +141 -0
- package/platform/agent-sandbox/ssh-connection.ts +165 -0
- package/platform/agent-sandbox/ssh-exec.ts +98 -0
- package/platform/agent-sandbox/ssh-session.ts +487 -0
- package/platform/agent-sandbox/zo-backend.ts +88 -0
- package/platform/agent-sandbox/zo-sandbox.ts +39 -0
- package/platform/cloud-tools/image-path.ts +44 -0
- package/platform/cloud-tools/image.ts +225 -0
- package/platform/cloud-tools/index.ts +22 -0
- package/platform/cloud-tools/state-files.ts +368 -0
- package/platform/cloud-tools/tool-meta.ts +32 -0
- package/platform/cloud-tools/web-search.ts +15 -0
- package/platform/runtime-ai/gateway.ts +76 -0
- package/platform/runtime-ai/index.ts +20 -0
- package/platform/runtime-ai/register.ts +26 -0
- package/platform/runtime-ai/session-fetch.ts +124 -0
- package/platform/runtime-auth/index.ts +331 -0
- package/src/async-tasks.ts +273 -0
- package/src/attachments.ts +109 -0
- package/src/backgroundable.ts +88 -0
- package/src/bounded-output.ts +159 -0
- package/src/dir-conventions.ts +238 -0
- package/src/extract/cache.ts +40 -0
- package/src/extract/docx.ts +18 -0
- package/src/extract/pdf.ts +54 -0
- package/src/extract/sheet.ts +56 -0
- package/src/file-kind.ts +258 -0
- package/src/file-view.ts +80 -0
- package/src/gateway-fetch.ts +115 -0
- package/src/glob-match.ts +13 -0
- package/src/hooks.ts +213 -0
- package/src/index.ts +419 -0
- package/src/initiator-auth.ts +81 -0
- package/src/instructions.ts +224 -0
- package/src/list-files.ts +41 -0
- package/src/mock-model.ts +572 -0
- package/src/park-delivery.ts +247 -0
- package/src/path-locks.ts +52 -0
- package/src/read-file-content.ts +142 -0
- package/src/read-text.ts +40 -0
- package/src/redeliver.ts +155 -0
- package/src/run.ts +159 -0
- package/src/sandbox-io.ts +414 -0
- package/src/state-files.ts +460 -0
- package/src/state-sandbox.ts +710 -0
- package/src/state.ts +96 -0
- package/src/steer-inbox.ts +105 -0
- package/src/steer-tool.ts +83 -0
- package/src/steer.ts +146 -0
- package/src/task.ts +320 -0
- package/src/tools/bash.ts +143 -0
- package/src/tools/edit.ts +58 -0
- package/src/tools/glob.ts +56 -0
- package/src/tools/grep.ts +188 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/tasks.ts +241 -0
- package/src/tools/webfetch.ts +368 -0
- package/src/tools/write.ts +42 -0
- package/src/walk.ts +112 -0
- package/src/watch-output.ts +104 -0
- package/src/web-fetch.ts +324 -0
- package/src/web-page.ts +179 -0
- package/src/workspace-io.ts +225 -0
- package/src/workspace.ts +41 -0
package/src/web-page.ts
ADDED
|
@@ -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
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -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
|
+
}
|