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.
Files changed (98) hide show
  1. package/README.md +46 -41
  2. package/package.json +4 -9
  3. package/src/chat/agent.ts +37 -40
  4. package/src/chat/session.ts +10 -10
  5. package/src/cli.ts +0 -2
  6. package/src/commands/capabilities.ts +35 -33
  7. package/src/commands/context.ts +133 -221
  8. package/src/commands/init.ts +22 -1
  9. package/src/commands/mcpx.ts +21 -8
  10. package/src/commands/nuke.ts +52 -15
  11. package/src/commands/prepare.ts +16 -13
  12. package/src/config/loader.ts +1 -8
  13. package/src/config/schemas.ts +6 -0
  14. package/src/constants.ts +16 -32
  15. package/src/init/index.ts +52 -27
  16. package/src/mcpx/client.ts +21 -5
  17. package/src/mem/client.ts +33 -0
  18. package/src/{context → prompts}/capabilities.ts +11 -7
  19. package/src/schedules/store.ts +1 -1
  20. package/src/tasks/store.ts +1 -1
  21. package/src/threads/store.ts +1 -1
  22. package/src/tools/capabilities/refresh.ts +1 -1
  23. package/src/tools/membot/adapter.ts +111 -0
  24. package/src/tools/membot/copy.ts +59 -0
  25. package/src/tools/membot/count_lines.ts +53 -0
  26. package/src/tools/membot/edit.ts +72 -0
  27. package/src/tools/membot/exists.ts +54 -0
  28. package/src/tools/membot/index.ts +26 -0
  29. package/src/tools/{context → membot}/pipe.ts +34 -32
  30. package/src/tools/registry.ts +6 -37
  31. package/src/tools/tool.ts +6 -8
  32. package/src/tui/App.tsx +3 -4
  33. package/src/tui/components/ContextPanel.tsx +109 -226
  34. package/src/tui/components/HelpPanel.tsx +2 -2
  35. package/src/tui/components/StatusBar.tsx +0 -6
  36. package/src/tui/components/ThreadPanel.tsx +8 -7
  37. package/src/tui/wrapDetail.ts +11 -0
  38. package/src/worker/heartbeat.ts +0 -20
  39. package/src/worker/index.ts +13 -13
  40. package/src/worker/llm.ts +7 -9
  41. package/src/worker/prompt.ts +25 -13
  42. package/src/worker/spawn.ts +1 -1
  43. package/src/worker/tick.ts +10 -9
  44. package/src/commands/db.ts +0 -119
  45. package/src/commands/with-db.ts +0 -22
  46. package/src/context/chunker.ts +0 -275
  47. package/src/context/embedder-impl.ts +0 -100
  48. package/src/context/embedder.ts +0 -9
  49. package/src/context/fetcher-errors.ts +0 -8
  50. package/src/context/fetcher.ts +0 -515
  51. package/src/context/locks.ts +0 -146
  52. package/src/context/markdown-converter.ts +0 -186
  53. package/src/context/reindex.ts +0 -198
  54. package/src/context/store.ts +0 -841
  55. package/src/context/url-utils.ts +0 -25
  56. package/src/db/connection.ts +0 -255
  57. package/src/db/doctor.ts +0 -235
  58. package/src/db/embeddings.ts +0 -317
  59. package/src/db/query.ts +0 -56
  60. package/src/db/schema.ts +0 -93
  61. package/src/db/sql/1-core_tables.sql +0 -53
  62. package/src/db/sql/10-dedupe_context_items.sql +0 -26
  63. package/src/db/sql/11-rebuild_hnsw.sql +0 -8
  64. package/src/db/sql/12-workers.sql +0 -66
  65. package/src/db/sql/13-drive-paths.sql +0 -47
  66. package/src/db/sql/14-drop_hnsw_index.sql +0 -8
  67. package/src/db/sql/15-fts_index.sql +0 -8
  68. package/src/db/sql/16-source_url.sql +0 -7
  69. package/src/db/sql/17-worker_log_path.sql +0 -3
  70. package/src/db/sql/18-reset_embeddings_for_local.sql +0 -39
  71. package/src/db/sql/19-disk_backed_index.sql +0 -36
  72. package/src/db/sql/2-logging_tables.sql +0 -24
  73. package/src/db/sql/20-drop_db_tables_for_files.sql +0 -19
  74. package/src/db/sql/3-daemon_state.sql +0 -5
  75. package/src/db/sql/4-unique_context_path.sql +0 -1
  76. package/src/db/sql/5-reset_embeddings_for_openai.sql +0 -1
  77. package/src/db/sql/6-vss_index.sql +0 -7
  78. package/src/db/sql/7-drop_embeddings_fk.sql +0 -23
  79. package/src/db/sql/8-task_output.sql +0 -1
  80. package/src/db/sql/9-source-type.sql +0 -1
  81. package/src/tools/context/read-large-result.ts +0 -33
  82. package/src/tools/dir/create.ts +0 -47
  83. package/src/tools/dir/size.ts +0 -77
  84. package/src/tools/dir/tree.ts +0 -124
  85. package/src/tools/file/copy.ts +0 -73
  86. package/src/tools/file/count-lines.ts +0 -54
  87. package/src/tools/file/delete.ts +0 -83
  88. package/src/tools/file/edit.ts +0 -76
  89. package/src/tools/file/exists.ts +0 -33
  90. package/src/tools/file/info.ts +0 -66
  91. package/src/tools/file/move.ts +0 -66
  92. package/src/tools/file/read.ts +0 -67
  93. package/src/tools/file/write.ts +0 -58
  94. package/src/tools/search/fuse.ts +0 -96
  95. package/src/tools/search/index.ts +0 -127
  96. package/src/tools/search/regexp.ts +0 -82
  97. package/src/tools/search/semantic.ts +0 -167
  98. /package/src/{db → utils}/uuid.ts +0 -0
@@ -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
- }