botholomew 0.16.4 → 0.18.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/README.md +46 -41
- package/package.json +4 -9
- package/src/chat/agent.ts +37 -40
- package/src/chat/session.ts +10 -10
- package/src/cli.ts +0 -2
- package/src/commands/capabilities.ts +35 -33
- package/src/commands/context.ts +133 -221
- package/src/commands/init.ts +22 -1
- package/src/commands/mcpx.ts +21 -8
- package/src/commands/nuke.ts +52 -15
- package/src/commands/prepare.ts +16 -13
- package/src/config/loader.ts +1 -8
- package/src/config/schemas.ts +6 -0
- package/src/constants.ts +16 -32
- package/src/init/index.ts +52 -27
- package/src/mcpx/client.ts +21 -5
- package/src/mem/client.ts +33 -0
- package/src/{context → prompts}/capabilities.ts +11 -7
- package/src/schedules/store.ts +1 -1
- package/src/tasks/store.ts +1 -1
- package/src/threads/store.ts +1 -1
- package/src/tools/capabilities/refresh.ts +1 -1
- package/src/tools/membot/adapter.ts +111 -0
- package/src/tools/membot/copy.ts +59 -0
- package/src/tools/membot/count_lines.ts +53 -0
- package/src/tools/membot/edit.ts +72 -0
- package/src/tools/membot/exists.ts +54 -0
- package/src/tools/membot/index.ts +26 -0
- package/src/tools/{context → membot}/pipe.ts +34 -32
- package/src/tools/registry.ts +6 -37
- package/src/tools/tool.ts +6 -8
- package/src/tui/App.tsx +3 -4
- package/src/tui/components/ContextPanel.tsx +109 -226
- package/src/tui/components/HelpPanel.tsx +2 -2
- package/src/tui/components/StatusBar.tsx +0 -6
- package/src/tui/components/ThreadPanel.tsx +8 -7
- package/src/tui/wrapDetail.ts +11 -0
- package/src/worker/heartbeat.ts +0 -20
- package/src/worker/index.ts +13 -13
- package/src/worker/llm.ts +7 -9
- package/src/worker/prompt.ts +25 -13
- package/src/worker/spawn.ts +1 -1
- package/src/worker/tick.ts +10 -9
- package/src/commands/db.ts +0 -119
- package/src/commands/with-db.ts +0 -22
- package/src/context/chunker.ts +0 -275
- package/src/context/embedder-impl.ts +0 -100
- package/src/context/embedder.ts +0 -9
- package/src/context/fetcher-errors.ts +0 -8
- package/src/context/fetcher.ts +0 -515
- package/src/context/locks.ts +0 -146
- package/src/context/markdown-converter.ts +0 -186
- package/src/context/reindex.ts +0 -198
- package/src/context/store.ts +0 -841
- package/src/context/url-utils.ts +0 -25
- package/src/db/connection.ts +0 -255
- package/src/db/doctor.ts +0 -235
- package/src/db/embeddings.ts +0 -317
- package/src/db/query.ts +0 -56
- package/src/db/schema.ts +0 -93
- package/src/db/sql/1-core_tables.sql +0 -53
- package/src/db/sql/10-dedupe_context_items.sql +0 -26
- package/src/db/sql/11-rebuild_hnsw.sql +0 -8
- package/src/db/sql/12-workers.sql +0 -66
- package/src/db/sql/13-drive-paths.sql +0 -47
- package/src/db/sql/14-drop_hnsw_index.sql +0 -8
- package/src/db/sql/15-fts_index.sql +0 -8
- package/src/db/sql/16-source_url.sql +0 -7
- package/src/db/sql/17-worker_log_path.sql +0 -3
- package/src/db/sql/18-reset_embeddings_for_local.sql +0 -39
- package/src/db/sql/19-disk_backed_index.sql +0 -36
- package/src/db/sql/2-logging_tables.sql +0 -24
- package/src/db/sql/20-drop_db_tables_for_files.sql +0 -19
- package/src/db/sql/3-daemon_state.sql +0 -5
- package/src/db/sql/4-unique_context_path.sql +0 -1
- package/src/db/sql/5-reset_embeddings_for_openai.sql +0 -1
- package/src/db/sql/6-vss_index.sql +0 -7
- package/src/db/sql/7-drop_embeddings_fk.sql +0 -23
- package/src/db/sql/8-task_output.sql +0 -1
- package/src/db/sql/9-source-type.sql +0 -1
- package/src/tools/context/read-large-result.ts +0 -33
- package/src/tools/dir/create.ts +0 -47
- package/src/tools/dir/size.ts +0 -77
- package/src/tools/dir/tree.ts +0 -124
- package/src/tools/file/copy.ts +0 -73
- package/src/tools/file/count-lines.ts +0 -54
- package/src/tools/file/delete.ts +0 -83
- package/src/tools/file/edit.ts +0 -76
- package/src/tools/file/exists.ts +0 -33
- package/src/tools/file/info.ts +0 -66
- package/src/tools/file/move.ts +0 -66
- package/src/tools/file/read.ts +0 -67
- package/src/tools/file/write.ts +0 -58
- package/src/tools/search/fuse.ts +0 -96
- package/src/tools/search/index.ts +0 -127
- package/src/tools/search/regexp.ts +0 -82
- package/src/tools/search/semantic.ts +0 -167
- /package/src/{db → utils}/uuid.ts +0 -0
package/src/context/store.ts
DELETED
|
@@ -1,841 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
copyFile as fsCopyFile,
|
|
4
|
-
readFile as fsReadFile,
|
|
5
|
-
rename as fsRename,
|
|
6
|
-
lstat,
|
|
7
|
-
mkdir,
|
|
8
|
-
readdir,
|
|
9
|
-
rm,
|
|
10
|
-
stat,
|
|
11
|
-
unlink,
|
|
12
|
-
} from "node:fs/promises";
|
|
13
|
-
import { dirname, join, posix, relative, sep } from "node:path";
|
|
14
|
-
import { CONTEXT_DIR, PROTECTED_AREAS } from "../constants.ts";
|
|
15
|
-
import {
|
|
16
|
-
atomicWrite,
|
|
17
|
-
atomicWriteIfUnchanged,
|
|
18
|
-
MtimeConflictError,
|
|
19
|
-
readWithMtime,
|
|
20
|
-
} from "../fs/atomic.ts";
|
|
21
|
-
import { applyLinePatches, type LinePatch } from "../fs/patches.ts";
|
|
22
|
-
import {
|
|
23
|
-
getCanonicalRoot,
|
|
24
|
-
PathEscapeError,
|
|
25
|
-
resolveInRoot,
|
|
26
|
-
toRelativePath,
|
|
27
|
-
} from "../fs/sandbox.ts";
|
|
28
|
-
import { withContextLock } from "./locks.ts";
|
|
29
|
-
|
|
30
|
-
function defaultHolderId(): string {
|
|
31
|
-
return `pid:${process.pid}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Disk-backed replacement for the old DuckDB context_items CRUD layer. All
|
|
36
|
-
* agent-writable content lives under `<projectDir>/context/`. Tools take a
|
|
37
|
-
* project-relative path (e.g. `notes/foo.md`) that gets sandboxed against the
|
|
38
|
-
* context root via `resolveInRoot`.
|
|
39
|
-
*
|
|
40
|
-
* The path argument convention everywhere in this file is forward-slash
|
|
41
|
-
* relative-to-context (NOT absolute, NOT relative-to-project). Convert at
|
|
42
|
-
* the boundary with `relativeFromContext`.
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
export class NotFoundError extends Error {
|
|
46
|
-
constructor(readonly path: string) {
|
|
47
|
-
super(`Not found: ${path}`);
|
|
48
|
-
this.name = "NotFoundError";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export class IsDirectoryError extends Error {
|
|
53
|
-
constructor(readonly path: string) {
|
|
54
|
-
super(`Path is a directory: ${path}`);
|
|
55
|
-
this.name = "IsDirectoryError";
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export class NotDirectoryError extends Error {
|
|
60
|
-
constructor(readonly path: string) {
|
|
61
|
-
super(`Path is not a directory: ${path}`);
|
|
62
|
-
this.name = "NotDirectoryError";
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export class PathConflictError extends Error {
|
|
67
|
-
constructor(readonly path: string) {
|
|
68
|
-
super(`Path already exists: ${path}`);
|
|
69
|
-
this.name = "PathConflictError";
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export type Patch = LinePatch;
|
|
74
|
-
|
|
75
|
-
export interface ContextEntry {
|
|
76
|
-
/** Project-relative path under context/, e.g. "notes/foo.md". Forward-slashes. */
|
|
77
|
-
path: string;
|
|
78
|
-
is_directory: boolean;
|
|
79
|
-
is_textual: boolean;
|
|
80
|
-
/**
|
|
81
|
-
* True when the entry's path under `context/` is a symlink (set from
|
|
82
|
-
* `lstat`). The agent can read and delete the link, but writes that
|
|
83
|
-
* traverse a symlink fail with PathEscapeError so external content is
|
|
84
|
-
* never modified.
|
|
85
|
-
*/
|
|
86
|
-
is_symlink: boolean;
|
|
87
|
-
size: number;
|
|
88
|
-
mime_type: string;
|
|
89
|
-
mtime: Date;
|
|
90
|
-
content_hash: string | null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Hard cap on directory recursion across walks; defends against pathological symlink graphs. */
|
|
94
|
-
const MAX_WALK_DEPTH = 32;
|
|
95
|
-
|
|
96
|
-
const TEXTUAL_EXTENSIONS = new Set([
|
|
97
|
-
"md",
|
|
98
|
-
"markdown",
|
|
99
|
-
"txt",
|
|
100
|
-
"text",
|
|
101
|
-
"json",
|
|
102
|
-
"yaml",
|
|
103
|
-
"yml",
|
|
104
|
-
"toml",
|
|
105
|
-
"ini",
|
|
106
|
-
"cfg",
|
|
107
|
-
"conf",
|
|
108
|
-
"html",
|
|
109
|
-
"htm",
|
|
110
|
-
"xml",
|
|
111
|
-
"csv",
|
|
112
|
-
"tsv",
|
|
113
|
-
"log",
|
|
114
|
-
"rst",
|
|
115
|
-
"org",
|
|
116
|
-
"tex",
|
|
117
|
-
"ts",
|
|
118
|
-
"tsx",
|
|
119
|
-
"js",
|
|
120
|
-
"jsx",
|
|
121
|
-
"mjs",
|
|
122
|
-
"cjs",
|
|
123
|
-
"py",
|
|
124
|
-
"rb",
|
|
125
|
-
"go",
|
|
126
|
-
"rs",
|
|
127
|
-
"java",
|
|
128
|
-
"kt",
|
|
129
|
-
"swift",
|
|
130
|
-
"c",
|
|
131
|
-
"cc",
|
|
132
|
-
"cpp",
|
|
133
|
-
"h",
|
|
134
|
-
"hpp",
|
|
135
|
-
"cs",
|
|
136
|
-
"sh",
|
|
137
|
-
"bash",
|
|
138
|
-
"zsh",
|
|
139
|
-
"fish",
|
|
140
|
-
"sql",
|
|
141
|
-
"graphql",
|
|
142
|
-
"proto",
|
|
143
|
-
]);
|
|
144
|
-
|
|
145
|
-
function inferMimeType(path: string): { mime: string; textual: boolean } {
|
|
146
|
-
const ext = path.toLowerCase().split(".").pop() ?? "";
|
|
147
|
-
if (ext === "md" || ext === "markdown") {
|
|
148
|
-
return { mime: "text/markdown", textual: true };
|
|
149
|
-
}
|
|
150
|
-
if (ext === "json") return { mime: "application/json", textual: true };
|
|
151
|
-
if (ext === "html" || ext === "htm")
|
|
152
|
-
return { mime: "text/html", textual: true };
|
|
153
|
-
if (ext === "csv") return { mime: "text/csv", textual: true };
|
|
154
|
-
if (TEXTUAL_EXTENSIONS.has(ext))
|
|
155
|
-
return { mime: `text/${ext}`, textual: true };
|
|
156
|
-
return { mime: "application/octet-stream", textual: false };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function toPosix(p: string): string {
|
|
160
|
-
return p.split(sep).join("/");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function fromPosix(p: string): string {
|
|
164
|
-
return p.split("/").join(sep);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Normalize a user-supplied path: trim leading slashes, collapse to forward slashes. */
|
|
168
|
-
export function normalizeContextPath(path: string): string {
|
|
169
|
-
let p = (path ?? "").trim();
|
|
170
|
-
// Strip a leading `/` so the path is unambiguously relative-to-context.
|
|
171
|
-
while (p.startsWith("/")) p = p.slice(1);
|
|
172
|
-
return toPosix(p);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Resolve a context-relative path to an absolute filesystem path under
|
|
177
|
-
* `<projectDir>/context/`. Throws PathEscapeError on traversal, NUL bytes,
|
|
178
|
-
* or attempts to resolve into a protected area.
|
|
179
|
-
*
|
|
180
|
-
* `allowSymlinks` is the opt-in for read-side callers (read, list, tree,
|
|
181
|
-
* info, reindex). Mutating callers (write, edit, mv, cp, mkdir) leave it
|
|
182
|
-
* `false` so user-placed symlinks under `context/` cannot be traversed to
|
|
183
|
-
* modify external content. `allowSymlinkLeaf` is the narrower opt-in for
|
|
184
|
-
* `delete`: the leaf may be a symlink (so the agent can unlink it) but
|
|
185
|
-
* parent components may not, so a delete cannot reach external content
|
|
186
|
-
* through a symlinked parent directory.
|
|
187
|
-
*/
|
|
188
|
-
async function resolveContext(
|
|
189
|
-
projectDir: string,
|
|
190
|
-
path: string,
|
|
191
|
-
opts: { allowSymlinks?: boolean; allowSymlinkLeaf?: boolean } = {},
|
|
192
|
-
): Promise<string> {
|
|
193
|
-
const normalized = normalizeContextPath(path);
|
|
194
|
-
if (PROTECTED_AREAS.has(normalized)) {
|
|
195
|
-
throw new PathEscapeError(
|
|
196
|
-
`path is in a protected area: ${normalized}`,
|
|
197
|
-
normalized,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
return resolveInRoot(projectDir, fromPosix(normalized), {
|
|
201
|
-
area: CONTEXT_DIR,
|
|
202
|
-
allowSymlinks: opts.allowSymlinks,
|
|
203
|
-
allowSymlinkLeaf: opts.allowSymlinkLeaf,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function hashFile(absolutePath: string): Promise<string> {
|
|
208
|
-
const buf = await fsReadFile(absolutePath);
|
|
209
|
-
return createHash("sha256").update(buf).digest("hex");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* The canonical (symlink-resolved) absolute path of `<projectDir>/context/`.
|
|
214
|
-
* Always use this — not `getContextDir(projectDir)` — when computing relative
|
|
215
|
-
* paths from absolute fs results, because macOS tmp dirs symlink
|
|
216
|
-
* /var/folders → /private/var/folders and `resolveInRoot` returns the
|
|
217
|
-
* canonical form.
|
|
218
|
-
*/
|
|
219
|
-
function canonicalContextRoot(projectDir: string): string {
|
|
220
|
-
return join(getCanonicalRoot(projectDir), CONTEXT_DIR);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export async function fileExists(
|
|
224
|
-
projectDir: string,
|
|
225
|
-
path: string,
|
|
226
|
-
): Promise<boolean> {
|
|
227
|
-
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
228
|
-
try {
|
|
229
|
-
await stat(abs);
|
|
230
|
-
return true;
|
|
231
|
-
} catch (err) {
|
|
232
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
233
|
-
throw err;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export async function getInfo(
|
|
238
|
-
projectDir: string,
|
|
239
|
-
path: string,
|
|
240
|
-
): Promise<ContextEntry | null> {
|
|
241
|
-
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
242
|
-
let lst: Awaited<ReturnType<typeof lstat>>;
|
|
243
|
-
try {
|
|
244
|
-
lst = await lstat(abs);
|
|
245
|
-
} catch (err) {
|
|
246
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
247
|
-
throw err;
|
|
248
|
-
}
|
|
249
|
-
const isSymlink = lst.isSymbolicLink();
|
|
250
|
-
let st: Awaited<ReturnType<typeof stat>>;
|
|
251
|
-
if (isSymlink) {
|
|
252
|
-
try {
|
|
253
|
-
st = await stat(abs);
|
|
254
|
-
} catch (err) {
|
|
255
|
-
// Broken symlink — surface as a zero-byte symlink entry.
|
|
256
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
257
|
-
return {
|
|
258
|
-
path: normalizeContextPath(path),
|
|
259
|
-
is_directory: false,
|
|
260
|
-
is_textual: false,
|
|
261
|
-
is_symlink: true,
|
|
262
|
-
size: 0,
|
|
263
|
-
mime_type: "application/octet-stream",
|
|
264
|
-
mtime: lst.mtime,
|
|
265
|
-
content_hash: null,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
throw err;
|
|
269
|
-
}
|
|
270
|
-
} else {
|
|
271
|
-
st = lst;
|
|
272
|
-
}
|
|
273
|
-
const normalized = normalizeContextPath(path);
|
|
274
|
-
if (st.isDirectory()) {
|
|
275
|
-
return {
|
|
276
|
-
path: normalized,
|
|
277
|
-
is_directory: true,
|
|
278
|
-
is_textual: false,
|
|
279
|
-
is_symlink: isSymlink,
|
|
280
|
-
size: 0,
|
|
281
|
-
mime_type: "inode/directory",
|
|
282
|
-
mtime: st.mtime,
|
|
283
|
-
content_hash: null,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
const { mime, textual } = inferMimeType(normalized);
|
|
287
|
-
return {
|
|
288
|
-
path: normalized,
|
|
289
|
-
is_directory: false,
|
|
290
|
-
is_textual: textual,
|
|
291
|
-
is_symlink: isSymlink,
|
|
292
|
-
size: st.size,
|
|
293
|
-
mime_type: mime,
|
|
294
|
-
mtime: st.mtime,
|
|
295
|
-
content_hash: textual ? await hashFile(abs) : null,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export async function readContextFile(
|
|
300
|
-
projectDir: string,
|
|
301
|
-
path: string,
|
|
302
|
-
): Promise<string> {
|
|
303
|
-
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
304
|
-
let st: Awaited<ReturnType<typeof stat>>;
|
|
305
|
-
try {
|
|
306
|
-
st = await stat(abs);
|
|
307
|
-
} catch (err) {
|
|
308
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
309
|
-
throw new NotFoundError(normalizeContextPath(path));
|
|
310
|
-
}
|
|
311
|
-
throw err;
|
|
312
|
-
}
|
|
313
|
-
if (st.isDirectory()) {
|
|
314
|
-
throw new IsDirectoryError(normalizeContextPath(path));
|
|
315
|
-
}
|
|
316
|
-
return fsReadFile(abs, "utf-8");
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export async function writeContextFile(
|
|
320
|
-
projectDir: string,
|
|
321
|
-
path: string,
|
|
322
|
-
content: string,
|
|
323
|
-
opts: {
|
|
324
|
-
onConflict?: "error" | "overwrite";
|
|
325
|
-
holderId?: string;
|
|
326
|
-
} = {},
|
|
327
|
-
): Promise<ContextEntry> {
|
|
328
|
-
const abs = await resolveContext(projectDir, path);
|
|
329
|
-
const normalized = normalizeContextPath(path);
|
|
330
|
-
if (normalized === "" || normalized.endsWith("/")) {
|
|
331
|
-
throw new PathEscapeError(
|
|
332
|
-
`target must be a file path, not a directory: ${path}`,
|
|
333
|
-
path,
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
const conflict = opts.onConflict ?? "overwrite";
|
|
337
|
-
return withContextLock(
|
|
338
|
-
projectDir,
|
|
339
|
-
normalized,
|
|
340
|
-
opts.holderId ?? defaultHolderId(),
|
|
341
|
-
async () => {
|
|
342
|
-
let exists = false;
|
|
343
|
-
try {
|
|
344
|
-
const st = await stat(abs);
|
|
345
|
-
if (st.isDirectory()) throw new IsDirectoryError(normalized);
|
|
346
|
-
exists = true;
|
|
347
|
-
} catch (err) {
|
|
348
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
349
|
-
}
|
|
350
|
-
if (exists && conflict === "error") {
|
|
351
|
-
throw new PathConflictError(normalized);
|
|
352
|
-
}
|
|
353
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
354
|
-
await atomicWrite(abs, content);
|
|
355
|
-
const entry = await getInfo(projectDir, normalized);
|
|
356
|
-
if (!entry) throw new Error(`Wrote ${normalized} but could not stat`);
|
|
357
|
-
return entry;
|
|
358
|
-
},
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
export async function deleteContextPath(
|
|
363
|
-
projectDir: string,
|
|
364
|
-
path: string,
|
|
365
|
-
opts: { recursive?: boolean; holderId?: string } = {},
|
|
366
|
-
): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
|
|
367
|
-
const abs = await resolveContext(projectDir, path, {
|
|
368
|
-
allowSymlinkLeaf: true,
|
|
369
|
-
});
|
|
370
|
-
const normalized = normalizeContextPath(path);
|
|
371
|
-
if (normalized === "") {
|
|
372
|
-
throw new PathEscapeError("refusing to delete the context root", path);
|
|
373
|
-
}
|
|
374
|
-
return withContextLock(
|
|
375
|
-
projectDir,
|
|
376
|
-
normalized,
|
|
377
|
-
opts.holderId ?? defaultHolderId(),
|
|
378
|
-
async () => {
|
|
379
|
-
let lst: Awaited<ReturnType<typeof lstat>>;
|
|
380
|
-
try {
|
|
381
|
-
lst = await lstat(abs);
|
|
382
|
-
} catch (err) {
|
|
383
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
384
|
-
throw new NotFoundError(normalized);
|
|
385
|
-
}
|
|
386
|
-
throw err;
|
|
387
|
-
}
|
|
388
|
-
// A symlink (to a file or a directory, broken or not) is removed with
|
|
389
|
-
// a plain unlink — never follow into the target. This is what enforces
|
|
390
|
-
// "the symlink can be deleted, but not the original content".
|
|
391
|
-
if (lst.isSymbolicLink()) {
|
|
392
|
-
await unlink(abs);
|
|
393
|
-
return { removed: 1, was_directory: false, was_symlink: true };
|
|
394
|
-
}
|
|
395
|
-
if (lst.isDirectory()) {
|
|
396
|
-
if (!opts.recursive) {
|
|
397
|
-
throw new IsDirectoryError(normalized);
|
|
398
|
-
}
|
|
399
|
-
const removedPaths = await collectFiles(abs);
|
|
400
|
-
await rm(abs, { recursive: true, force: false });
|
|
401
|
-
return {
|
|
402
|
-
removed: removedPaths.length,
|
|
403
|
-
was_directory: true,
|
|
404
|
-
was_symlink: false,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
await unlink(abs);
|
|
408
|
-
return { removed: 1, was_directory: false, was_symlink: false };
|
|
409
|
-
},
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export async function moveContextPath(
|
|
414
|
-
projectDir: string,
|
|
415
|
-
src: string,
|
|
416
|
-
dst: string,
|
|
417
|
-
opts: { holderId?: string } = {},
|
|
418
|
-
): Promise<void> {
|
|
419
|
-
const srcAbs = await resolveContext(projectDir, src);
|
|
420
|
-
const dstAbs = await resolveContext(projectDir, dst);
|
|
421
|
-
const srcNorm = normalizeContextPath(src);
|
|
422
|
-
const dstNorm = normalizeContextPath(dst);
|
|
423
|
-
// Acquire both locks in a stable order to avoid AB/BA deadlocks between
|
|
424
|
-
// concurrent moves that swap two paths. Sorted lexicographically.
|
|
425
|
-
const [firstNorm, secondNorm] =
|
|
426
|
-
srcNorm < dstNorm ? [srcNorm, dstNorm] : [dstNorm, srcNorm];
|
|
427
|
-
const holder = opts.holderId ?? defaultHolderId();
|
|
428
|
-
return withContextLock(projectDir, firstNorm, holder, () =>
|
|
429
|
-
withContextLock(projectDir, secondNorm, holder, async () => {
|
|
430
|
-
try {
|
|
431
|
-
await stat(srcAbs);
|
|
432
|
-
} catch (err) {
|
|
433
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
434
|
-
throw new NotFoundError(srcNorm);
|
|
435
|
-
}
|
|
436
|
-
throw err;
|
|
437
|
-
}
|
|
438
|
-
try {
|
|
439
|
-
await stat(dstAbs);
|
|
440
|
-
throw new PathConflictError(dstNorm);
|
|
441
|
-
} catch (err) {
|
|
442
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
443
|
-
}
|
|
444
|
-
await mkdir(dirname(dstAbs), { recursive: true });
|
|
445
|
-
await fsRename(srcAbs, dstAbs);
|
|
446
|
-
}),
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
export async function copyContextPath(
|
|
451
|
-
projectDir: string,
|
|
452
|
-
src: string,
|
|
453
|
-
dst: string,
|
|
454
|
-
): Promise<void> {
|
|
455
|
-
const srcAbs = await resolveContext(projectDir, src);
|
|
456
|
-
const dstAbs = await resolveContext(projectDir, dst);
|
|
457
|
-
let srcSt: Awaited<ReturnType<typeof stat>>;
|
|
458
|
-
try {
|
|
459
|
-
srcSt = await stat(srcAbs);
|
|
460
|
-
} catch (err) {
|
|
461
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
462
|
-
throw new NotFoundError(normalizeContextPath(src));
|
|
463
|
-
}
|
|
464
|
-
throw err;
|
|
465
|
-
}
|
|
466
|
-
if (srcSt.isDirectory()) {
|
|
467
|
-
throw new IsDirectoryError(normalizeContextPath(src));
|
|
468
|
-
}
|
|
469
|
-
try {
|
|
470
|
-
await stat(dstAbs);
|
|
471
|
-
throw new PathConflictError(normalizeContextPath(dst));
|
|
472
|
-
} catch (err) {
|
|
473
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
474
|
-
}
|
|
475
|
-
await mkdir(dirname(dstAbs), { recursive: true });
|
|
476
|
-
await fsCopyFile(srcAbs, dstAbs);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
export async function createContextDir(
|
|
480
|
-
projectDir: string,
|
|
481
|
-
path: string,
|
|
482
|
-
): Promise<void> {
|
|
483
|
-
const abs = await resolveContext(projectDir, path);
|
|
484
|
-
await mkdir(abs, { recursive: true });
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
export async function listContextDir(
|
|
488
|
-
projectDir: string,
|
|
489
|
-
path: string,
|
|
490
|
-
opts: { recursive?: boolean } = {},
|
|
491
|
-
): Promise<ContextEntry[]> {
|
|
492
|
-
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
493
|
-
let st: Awaited<ReturnType<typeof stat>>;
|
|
494
|
-
try {
|
|
495
|
-
st = await stat(abs);
|
|
496
|
-
} catch (err) {
|
|
497
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
498
|
-
throw new NotFoundError(normalizeContextPath(path));
|
|
499
|
-
}
|
|
500
|
-
throw err;
|
|
501
|
-
}
|
|
502
|
-
if (!st.isDirectory()) {
|
|
503
|
-
throw new NotDirectoryError(normalizeContextPath(path));
|
|
504
|
-
}
|
|
505
|
-
const out: ContextEntry[] = [];
|
|
506
|
-
const visited = new Set<string>();
|
|
507
|
-
visited.add(`${st.dev}:${st.ino}`);
|
|
508
|
-
await walk(
|
|
509
|
-
abs,
|
|
510
|
-
canonicalContextRoot(projectDir),
|
|
511
|
-
opts.recursive ?? false,
|
|
512
|
-
out,
|
|
513
|
-
visited,
|
|
514
|
-
0,
|
|
515
|
-
);
|
|
516
|
-
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
517
|
-
return out;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async function walk(
|
|
521
|
-
dir: string,
|
|
522
|
-
contextRoot: string,
|
|
523
|
-
recursive: boolean,
|
|
524
|
-
acc: ContextEntry[],
|
|
525
|
-
visited: Set<string>,
|
|
526
|
-
depth: number,
|
|
527
|
-
): Promise<void> {
|
|
528
|
-
if (depth >= MAX_WALK_DEPTH) return;
|
|
529
|
-
const names = await readdir(dir);
|
|
530
|
-
for (const name of names) {
|
|
531
|
-
if (name.startsWith(".")) continue;
|
|
532
|
-
const abs = join(dir, name);
|
|
533
|
-
const rel = toPosix(relative(contextRoot, abs));
|
|
534
|
-
const lst = await lstat(abs);
|
|
535
|
-
const isSymlink = lst.isSymbolicLink();
|
|
536
|
-
let st: Awaited<ReturnType<typeof stat>>;
|
|
537
|
-
if (isSymlink) {
|
|
538
|
-
try {
|
|
539
|
-
st = await stat(abs);
|
|
540
|
-
} catch (err) {
|
|
541
|
-
// Broken symlink — surface as a zero-byte symlink leaf so the agent
|
|
542
|
-
// can see and remove it, but don't try to recurse into it.
|
|
543
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
544
|
-
acc.push({
|
|
545
|
-
path: rel,
|
|
546
|
-
is_directory: false,
|
|
547
|
-
is_textual: false,
|
|
548
|
-
is_symlink: true,
|
|
549
|
-
size: 0,
|
|
550
|
-
mime_type: "application/octet-stream",
|
|
551
|
-
mtime: lst.mtime,
|
|
552
|
-
content_hash: null,
|
|
553
|
-
});
|
|
554
|
-
continue;
|
|
555
|
-
}
|
|
556
|
-
throw err;
|
|
557
|
-
}
|
|
558
|
-
} else {
|
|
559
|
-
st = lst;
|
|
560
|
-
}
|
|
561
|
-
if (st.isDirectory()) {
|
|
562
|
-
acc.push({
|
|
563
|
-
path: rel,
|
|
564
|
-
is_directory: true,
|
|
565
|
-
is_textual: false,
|
|
566
|
-
is_symlink: isSymlink,
|
|
567
|
-
size: 0,
|
|
568
|
-
mime_type: "inode/directory",
|
|
569
|
-
mtime: st.mtime,
|
|
570
|
-
content_hash: null,
|
|
571
|
-
});
|
|
572
|
-
if (recursive) {
|
|
573
|
-
const key = `${st.dev}:${st.ino}`;
|
|
574
|
-
if (visited.has(key)) continue;
|
|
575
|
-
visited.add(key);
|
|
576
|
-
await walk(abs, contextRoot, recursive, acc, visited, depth + 1);
|
|
577
|
-
}
|
|
578
|
-
} else if (st.isFile()) {
|
|
579
|
-
const { mime, textual } = inferMimeType(rel);
|
|
580
|
-
acc.push({
|
|
581
|
-
path: rel,
|
|
582
|
-
is_directory: false,
|
|
583
|
-
is_textual: textual,
|
|
584
|
-
is_symlink: isSymlink,
|
|
585
|
-
size: st.size,
|
|
586
|
-
mime_type: mime,
|
|
587
|
-
mtime: st.mtime,
|
|
588
|
-
content_hash: textual ? await hashFile(abs) : null,
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
* Collect all real file paths under `absDir`, following symlinks (including
|
|
596
|
-
* symlinked directories) once each. Used for delete-count reporting and
|
|
597
|
-
* `dirSizeBytes`. Symlinked entries are returned as the *symlink path*
|
|
598
|
-
* relative to the walk root, not the resolved target — callers like the
|
|
599
|
-
* delete reporter want the agent-visible path. Cycles are prevented via a
|
|
600
|
-
* `dev:ino` visited set seeded with `absDir` itself.
|
|
601
|
-
*/
|
|
602
|
-
async function collectFiles(absDir: string): Promise<string[]> {
|
|
603
|
-
const out: string[] = [];
|
|
604
|
-
const visited = new Set<string>();
|
|
605
|
-
try {
|
|
606
|
-
const rootSt = await stat(absDir);
|
|
607
|
-
visited.add(`${rootSt.dev}:${rootSt.ino}`);
|
|
608
|
-
} catch {
|
|
609
|
-
return out;
|
|
610
|
-
}
|
|
611
|
-
async function recurse(d: string, depth: number): Promise<void> {
|
|
612
|
-
if (depth >= MAX_WALK_DEPTH) return;
|
|
613
|
-
let names: string[];
|
|
614
|
-
try {
|
|
615
|
-
names = await readdir(d);
|
|
616
|
-
} catch {
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
for (const name of names) {
|
|
620
|
-
const abs = join(d, name);
|
|
621
|
-
let st: Awaited<ReturnType<typeof stat>>;
|
|
622
|
-
try {
|
|
623
|
-
st = await stat(abs);
|
|
624
|
-
} catch {
|
|
625
|
-
// Broken symlink or permission issue — skip silently.
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
if (st.isDirectory()) {
|
|
629
|
-
const key = `${st.dev}:${st.ino}`;
|
|
630
|
-
if (visited.has(key)) continue;
|
|
631
|
-
visited.add(key);
|
|
632
|
-
await recurse(abs, depth + 1);
|
|
633
|
-
} else if (st.isFile()) {
|
|
634
|
-
out.push(abs);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
await recurse(absDir, 0);
|
|
639
|
-
return out;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
export interface TreeNode {
|
|
643
|
-
name: string;
|
|
644
|
-
path: string;
|
|
645
|
-
is_directory: boolean;
|
|
646
|
-
is_symlink?: boolean;
|
|
647
|
-
size?: number;
|
|
648
|
-
children?: TreeNode[];
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
export async function buildTree(
|
|
652
|
-
projectDir: string,
|
|
653
|
-
path: string,
|
|
654
|
-
maxDepth = 16,
|
|
655
|
-
): Promise<TreeNode> {
|
|
656
|
-
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
657
|
-
const lst = await lstat(abs).catch((err) => {
|
|
658
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
659
|
-
throw new NotFoundError(normalizeContextPath(path));
|
|
660
|
-
}
|
|
661
|
-
throw err;
|
|
662
|
-
});
|
|
663
|
-
const isSymlink = lst.isSymbolicLink();
|
|
664
|
-
let st: Awaited<ReturnType<typeof stat>>;
|
|
665
|
-
try {
|
|
666
|
-
st = await stat(abs);
|
|
667
|
-
} catch (err) {
|
|
668
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
669
|
-
throw new NotFoundError(normalizeContextPath(path));
|
|
670
|
-
}
|
|
671
|
-
throw err;
|
|
672
|
-
}
|
|
673
|
-
const root = canonicalContextRoot(projectDir);
|
|
674
|
-
const rel = abs === root ? "" : toPosix(relative(root, abs));
|
|
675
|
-
const name = rel === "" ? "." : posix.basename(rel);
|
|
676
|
-
if (!st.isDirectory()) {
|
|
677
|
-
return {
|
|
678
|
-
name,
|
|
679
|
-
path: rel,
|
|
680
|
-
is_directory: false,
|
|
681
|
-
...(isSymlink ? { is_symlink: true } : {}),
|
|
682
|
-
size: st.size,
|
|
683
|
-
};
|
|
684
|
-
}
|
|
685
|
-
const visited = new Set<string>();
|
|
686
|
-
visited.add(`${st.dev}:${st.ino}`);
|
|
687
|
-
return treeRecurse(abs, rel, name, root, maxDepth, visited, isSymlink);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
async function treeRecurse(
|
|
691
|
-
abs: string,
|
|
692
|
-
rel: string,
|
|
693
|
-
name: string,
|
|
694
|
-
contextRoot: string,
|
|
695
|
-
depthLeft: number,
|
|
696
|
-
visited: Set<string>,
|
|
697
|
-
isSymlink: boolean,
|
|
698
|
-
): Promise<TreeNode> {
|
|
699
|
-
const node: TreeNode = {
|
|
700
|
-
name,
|
|
701
|
-
path: rel,
|
|
702
|
-
is_directory: true,
|
|
703
|
-
...(isSymlink ? { is_symlink: true } : {}),
|
|
704
|
-
children: [],
|
|
705
|
-
};
|
|
706
|
-
if (depthLeft <= 0) return node;
|
|
707
|
-
let names: string[];
|
|
708
|
-
try {
|
|
709
|
-
names = await readdir(abs);
|
|
710
|
-
} catch {
|
|
711
|
-
return node;
|
|
712
|
-
}
|
|
713
|
-
names.sort((a, b) => a.localeCompare(b));
|
|
714
|
-
const children = node.children ?? [];
|
|
715
|
-
for (const name of names) {
|
|
716
|
-
if (name.startsWith(".")) continue;
|
|
717
|
-
const childAbs = join(abs, name);
|
|
718
|
-
const lst = await lstat(childAbs);
|
|
719
|
-
const childIsSymlink = lst.isSymbolicLink();
|
|
720
|
-
let childSt: Awaited<ReturnType<typeof stat>>;
|
|
721
|
-
if (childIsSymlink) {
|
|
722
|
-
try {
|
|
723
|
-
childSt = await stat(childAbs);
|
|
724
|
-
} catch (err) {
|
|
725
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
726
|
-
// Broken symlink — render as zero-byte leaf so it shows in the tree.
|
|
727
|
-
children.push({
|
|
728
|
-
name,
|
|
729
|
-
path: toPosix(relative(contextRoot, childAbs)),
|
|
730
|
-
is_directory: false,
|
|
731
|
-
is_symlink: true,
|
|
732
|
-
size: 0,
|
|
733
|
-
});
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
throw err;
|
|
737
|
-
}
|
|
738
|
-
} else {
|
|
739
|
-
childSt = lst;
|
|
740
|
-
}
|
|
741
|
-
const childRel = toPosix(relative(contextRoot, childAbs));
|
|
742
|
-
if (childSt.isDirectory()) {
|
|
743
|
-
const key = `${childSt.dev}:${childSt.ino}`;
|
|
744
|
-
if (visited.has(key)) {
|
|
745
|
-
// Cycle — render as a stub directory with no children.
|
|
746
|
-
children.push({
|
|
747
|
-
name,
|
|
748
|
-
path: childRel,
|
|
749
|
-
is_directory: true,
|
|
750
|
-
...(childIsSymlink ? { is_symlink: true } : {}),
|
|
751
|
-
children: [],
|
|
752
|
-
});
|
|
753
|
-
continue;
|
|
754
|
-
}
|
|
755
|
-
visited.add(key);
|
|
756
|
-
children.push(
|
|
757
|
-
await treeRecurse(
|
|
758
|
-
childAbs,
|
|
759
|
-
childRel,
|
|
760
|
-
name,
|
|
761
|
-
contextRoot,
|
|
762
|
-
depthLeft - 1,
|
|
763
|
-
visited,
|
|
764
|
-
childIsSymlink,
|
|
765
|
-
),
|
|
766
|
-
);
|
|
767
|
-
} else if (childSt.isFile()) {
|
|
768
|
-
children.push({
|
|
769
|
-
name,
|
|
770
|
-
path: childRel,
|
|
771
|
-
is_directory: false,
|
|
772
|
-
...(childIsSymlink ? { is_symlink: true } : {}),
|
|
773
|
-
size: childSt.size,
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
node.children = children;
|
|
778
|
-
return node;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
export async function dirSizeBytes(
|
|
782
|
-
projectDir: string,
|
|
783
|
-
path: string,
|
|
784
|
-
): Promise<{ files: number; bytes: number }> {
|
|
785
|
-
const abs = await resolveContext(projectDir, path, { allowSymlinks: true });
|
|
786
|
-
let st: Awaited<ReturnType<typeof stat>>;
|
|
787
|
-
try {
|
|
788
|
-
st = await stat(abs);
|
|
789
|
-
} catch (err) {
|
|
790
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
791
|
-
throw new NotFoundError(normalizeContextPath(path));
|
|
792
|
-
}
|
|
793
|
-
throw err;
|
|
794
|
-
}
|
|
795
|
-
if (!st.isDirectory()) {
|
|
796
|
-
throw new NotDirectoryError(normalizeContextPath(path));
|
|
797
|
-
}
|
|
798
|
-
let bytes = 0;
|
|
799
|
-
let files = 0;
|
|
800
|
-
for (const f of await collectFiles(abs)) {
|
|
801
|
-
const fst = await stat(f);
|
|
802
|
-
bytes += fst.size;
|
|
803
|
-
files++;
|
|
804
|
-
}
|
|
805
|
-
return { files, bytes };
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
export async function applyPatches(
|
|
809
|
-
projectDir: string,
|
|
810
|
-
path: string,
|
|
811
|
-
patches: Patch[],
|
|
812
|
-
opts: { holderId?: string } = {},
|
|
813
|
-
): Promise<{ applied: number; lines: number }> {
|
|
814
|
-
const abs = await resolveContext(projectDir, path);
|
|
815
|
-
const normalized = normalizeContextPath(path);
|
|
816
|
-
const holder = opts.holderId ?? defaultHolderId();
|
|
817
|
-
return withContextLock(projectDir, normalized, holder, async () => {
|
|
818
|
-
const read = await readWithMtime(abs);
|
|
819
|
-
if (!read) throw new NotFoundError(normalized);
|
|
820
|
-
const newContent = applyLinePatches(read.content, patches);
|
|
821
|
-
// The lock keeps other context tools out of this critical section, but
|
|
822
|
-
// an external editor (vim, IDE) can still mutate the file in parallel.
|
|
823
|
-
// The mtime guard catches that — agents and humans don't silently lose
|
|
824
|
-
// edits to each other.
|
|
825
|
-
await atomicWriteIfUnchanged(abs, newContent, read.mtimeMs);
|
|
826
|
-
return { applied: patches.length, lines: newContent.split("\n").length };
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
export { MtimeConflictError };
|
|
831
|
-
|
|
832
|
-
/**
|
|
833
|
-
* Convert an absolute filesystem path back to a context-relative path. Used
|
|
834
|
-
* when rendering search hits or worker output that originated in store.ts.
|
|
835
|
-
*/
|
|
836
|
-
export function relativeFromContext(
|
|
837
|
-
projectDir: string,
|
|
838
|
-
absolute: string,
|
|
839
|
-
): string {
|
|
840
|
-
return toPosix(toRelativePath(projectDir, absolute, CONTEXT_DIR));
|
|
841
|
-
}
|