botholomew 0.12.5 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +59 -86
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +178 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +803 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +293 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +74 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +53 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +27 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +8 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/tool.ts +5 -0
  71. package/src/tools/util/sleep.ts +77 -0
  72. package/src/tools/worker/spawn.ts +28 -14
  73. package/src/tui/App.tsx +12 -19
  74. package/src/tui/components/ContextPanel.tsx +83 -316
  75. package/src/tui/components/SchedulePanel.tsx +34 -48
  76. package/src/tui/components/SleepProgress.tsx +70 -0
  77. package/src/tui/components/StatusBar.tsx +15 -15
  78. package/src/tui/components/TaskPanel.tsx +34 -38
  79. package/src/tui/components/ThreadPanel.tsx +29 -38
  80. package/src/tui/components/ToolCall.tsx +10 -0
  81. package/src/tui/components/WorkerPanel.tsx +21 -19
  82. package/src/tui/markdown.ts +2 -8
  83. package/src/utils/title.ts +5 -7
  84. package/src/utils/v7-date.ts +47 -0
  85. package/src/worker/heartbeat.ts +46 -24
  86. package/src/worker/index.ts +13 -15
  87. package/src/worker/llm.ts +30 -37
  88. package/src/worker/prompt.ts +19 -41
  89. package/src/worker/schedules.ts +48 -69
  90. package/src/worker/spawn.ts +11 -11
  91. package/src/worker/tick.ts +39 -43
  92. package/src/workers/store.ts +247 -0
  93. package/src/commands/tools.ts +0 -367
  94. package/src/context/describer.ts +0 -140
  95. package/src/context/drives.ts +0 -110
  96. package/src/context/ingest.ts +0 -162
  97. package/src/context/refresh.ts +0 -183
  98. package/src/db/context.ts +0 -637
  99. package/src/db/daemon-state.ts +0 -6
  100. package/src/db/reembed.ts +0 -113
  101. package/src/db/schedules.ts +0 -213
  102. package/src/db/tasks.ts +0 -347
  103. package/src/db/threads.ts +0 -276
  104. package/src/db/workers.ts +0 -212
  105. package/src/tools/context/list-drives.ts +0 -36
  106. package/src/tools/context/refresh.ts +0 -165
  107. package/src/tools/context/search.ts +0 -54
@@ -1,237 +1,56 @@
1
1
  import { z } from "zod";
2
- import { formatDriveRef } from "../../context/drives.ts";
3
- import type { DbConnection } from "../../db/connection.ts";
4
2
  import {
5
- countContextItemsByPrefix,
6
- listContextItemsByPrefix,
7
- listDriveSummaries,
8
- } from "../../db/context.ts";
3
+ buildTree,
4
+ NotFoundError,
5
+ type TreeNode,
6
+ } from "../../context/store.ts";
9
7
  import type { ToolDefinition } from "../tool.ts";
10
8
 
11
9
  const DEFAULT_MAX_DEPTH = 10;
12
- const DEFAULT_ITEMS_PER_DIR = 15;
13
- const HARD_FETCH_CAP = 1000;
14
-
15
- export interface BuildContextTreeOptions {
16
- drive?: string;
17
- path?: string;
18
- maxDepth?: number;
19
- itemsPerDir?: number;
20
- }
21
10
 
22
11
  export interface BuildContextTreeResult {
23
12
  tree: string;
24
13
  total_items: number;
25
- truncated_dirs: Array<{ path: string; shown: number; total: number }>;
26
14
  hint: string;
27
15
  }
28
16
 
29
- interface DirNode {
30
- name: string;
31
- fullPath: string;
32
- isDir: true;
33
- children: TreeEntry[];
34
- }
35
-
36
- interface FileNode {
37
- name: string;
38
- fullPath: string;
39
- isDir: false;
40
- }
41
-
42
- type TreeEntry = DirNode | FileNode;
43
-
44
17
  /**
45
- * Build a markdown tree for a single drive, or when no drive is given — a
46
- * top-level summary listing every drive with its item count.
18
+ * Render a TreeNode as an indented string. Files are listed; directories show
19
+ * children. Depth is enforced inside `buildTree`.
47
20
  */
48
- export async function buildContextTree(
49
- conn: DbConnection,
50
- options: BuildContextTreeOptions = {},
51
- ): Promise<BuildContextTreeResult> {
52
- if (!options.drive) {
53
- const summaries = await listDriveSummaries(conn);
54
- if (summaries.length === 0) {
55
- return {
56
- tree: "(no drives context is empty)",
57
- total_items: 0,
58
- truncated_dirs: [],
59
- hint: "No context has been ingested yet. Use `context add` from the CLI to ingest files or URLs.",
60
- };
61
- }
62
- const lines = [
63
- "Drives:",
64
- ...summaries.map(
65
- (s) =>
66
- ` ${s.drive}:/ (${s.count} ${s.count === 1 ? "item" : "items"})`,
67
- ),
68
- ];
69
- const total = summaries.reduce((sum, s) => sum + s.count, 0);
70
- return {
71
- tree: lines.join("\n"),
72
- total_items: total,
73
- truncated_dirs: [],
74
- hint: `Call context_tree with drive="<name>" to drill into a drive.`,
75
- };
76
- }
77
-
78
- const drive = options.drive;
79
- const path = options.path ?? "/";
80
- const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
81
- const itemsPerDir = options.itemsPerDir ?? DEFAULT_ITEMS_PER_DIR;
82
- const normalizedPath = path.endsWith("/") ? path : `${path}/`;
83
- const rootLabel = formatDriveRef({ drive, path });
84
-
85
- const totalItems = await countContextItemsByPrefix(conn, drive, path, {
86
- recursive: true,
87
- });
88
-
89
- if (totalItems === 0) {
90
- return {
91
- tree: `${rootLabel}\n (empty)`,
92
- total_items: 0,
93
- truncated_dirs: [],
94
- hint: "Directory is empty.",
95
- };
96
- }
97
-
98
- const items = await listContextItemsByPrefix(conn, drive, path, {
99
- recursive: true,
100
- limit: HARD_FETCH_CAP,
101
- });
102
-
103
- const root: DirNode = {
104
- name: rootLabel,
105
- fullPath: path,
106
- isDir: true,
107
- children: [],
108
- };
109
- const dirIndex = new Map<string, DirNode>();
110
- dirIndex.set(stripTrailingSlash(path), root);
111
-
112
- for (const item of items) {
113
- const relative = item.path.slice(normalizedPath.length);
114
- if (relative.length === 0) continue;
115
- const parts = relative.split("/").filter((p) => p.length > 0);
116
- const isExplicitDir = item.mime_type === "inode/directory";
117
-
118
- let parentDir = root;
119
- let currentRel = "";
120
- for (let i = 0; i < parts.length; i++) {
121
- const segment = parts[i];
122
- if (!segment) continue;
123
- currentRel = currentRel ? `${currentRel}/${segment}` : segment;
124
- const fullPath = `${normalizedPath}${currentRel}`;
125
- const isLeaf = i === parts.length - 1;
126
- const isDirHere = !isLeaf || isExplicitDir;
127
-
128
- if (isDirHere) {
129
- const key = stripTrailingSlash(fullPath);
130
- let dir = dirIndex.get(key);
131
- if (!dir) {
132
- dir = {
133
- name: segment,
134
- fullPath,
135
- isDir: true,
136
- children: [],
137
- };
138
- dirIndex.set(key, dir);
139
- parentDir.children.push(dir);
140
- }
141
- parentDir = dir;
142
- } else {
143
- parentDir.children.push({
144
- name: segment,
145
- fullPath,
146
- isDir: false,
147
- });
148
- }
149
- }
150
- }
151
-
152
- for (const dir of dirIndex.values()) {
153
- dir.children.sort((a, b) => {
154
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
155
- return a.name.localeCompare(b.name);
21
+ function renderTree(node: TreeNode, prefix = "", isLast = true): string[] {
22
+ const lines: string[] = [];
23
+ const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
24
+ const base = node.is_directory ? `${node.name}/` : node.name;
25
+ const label = node.is_symlink ? `${base} -> (symlink)` : base;
26
+ lines.push(`${prefix}${connector}${label}`);
27
+ if (node.is_directory && node.children) {
28
+ const childPrefix =
29
+ prefix + (prefix === "" ? "" : isLast ? " " : "│ ");
30
+ const children = node.children;
31
+ children.forEach((c, i) => {
32
+ const last = i === children.length - 1;
33
+ lines.push(...renderTree(c, childPrefix, last));
156
34
  });
157
35
  }
36
+ return lines;
37
+ }
158
38
 
159
- const truncatedDirs: Array<{
160
- path: string;
161
- shown: number;
162
- total: number;
163
- }> = [];
164
- const depthLimitedDirs: string[] = [];
165
-
166
- const lines: string[] = [rootLabel];
167
-
168
- const render = (dir: DirNode, indent: string, currentDepth: number): void => {
169
- const children = dir.children;
170
- const total = children.length;
171
- const shown = Math.min(total, itemsPerDir);
172
- const visible = children.slice(0, shown);
173
- const overflow = total - shown;
174
-
175
- if (overflow > 0) {
176
- truncatedDirs.push({
177
- path: stripTrailingSlash(dir.fullPath),
178
- shown,
179
- total,
180
- });
181
- }
182
-
183
- for (let i = 0; i < visible.length; i++) {
184
- const child = visible[i];
185
- if (!child) continue;
186
- const isLastVisible = i === visible.length - 1 && overflow === 0;
187
- const connector = isLastVisible ? "└── " : "├── ";
188
- const childIndent = isLastVisible ? " " : "│ ";
189
-
190
- if (child.isDir) {
191
- const atDepthLimit = currentDepth + 1 >= maxDepth;
192
- if (atDepthLimit && child.children.length > 0) {
193
- depthLimitedDirs.push(stripTrailingSlash(child.fullPath));
194
- const subCount = countDescendants(child);
195
- lines.push(
196
- `${indent}${connector}${child.name}/ (${subCount} ${
197
- subCount === 1 ? "item" : "items"
198
- }, drill in)`,
199
- );
200
- } else {
201
- lines.push(`${indent}${connector}${child.name}/`);
202
- render(child, indent + childIndent, currentDepth + 1);
203
- }
204
- } else {
205
- lines.push(`${indent}${connector}${child.name}`);
206
- }
207
- }
208
-
209
- if (overflow > 0) {
210
- lines.push(`${indent}└── ... (+${overflow} more)`);
211
- }
212
- };
213
-
214
- render(root, "", 0);
215
-
216
- return {
217
- tree: lines.join("\n"),
218
- total_items: totalItems,
219
- truncated_dirs: truncatedDirs,
220
- hint: buildHint({ truncatedDirs, depthLimitedDirs, totalItems, drive }),
221
- };
39
+ function countItems(node: TreeNode): number {
40
+ if (!node.is_directory) return 1;
41
+ let total = 0;
42
+ for (const c of node.children ?? []) total += countItems(c);
43
+ return total;
222
44
  }
223
45
 
224
46
  const inputSchema = z.object({
225
- drive: z
47
+ path: z
226
48
  .string()
227
49
  .optional()
50
+ .default("")
228
51
  .describe(
229
- "Drive to explore (e.g. 'disk', 'agent'). Omit to list every drive with its item count — useful as a first call.",
52
+ "Directory path under context/ to render (defaults to the context root).",
230
53
  ),
231
- path: z
232
- .string()
233
- .optional()
234
- .describe("Root path for the tree within the drive (defaults to /)"),
235
54
  max_depth: z
236
55
  .number()
237
56
  .int()
@@ -239,91 +58,67 @@ const inputSchema = z.object({
239
58
  .optional()
240
59
  .default(DEFAULT_MAX_DEPTH)
241
60
  .describe(
242
- `Maximum depth of directories to render (defaults to ${DEFAULT_MAX_DEPTH}). Use a deeper path to drill in.`,
243
- ),
244
- items_per_dir: z
245
- .number()
246
- .int()
247
- .positive()
248
- .optional()
249
- .default(DEFAULT_ITEMS_PER_DIR)
250
- .describe(
251
- `Maximum entries shown per directory (defaults to ${DEFAULT_ITEMS_PER_DIR}). Overflow shown as "(+N more)".`,
61
+ `Maximum depth of directories to render (defaults to ${DEFAULT_MAX_DEPTH}).`,
252
62
  ),
253
63
  });
254
64
 
255
- const TruncatedDirSchema = z.object({
256
- path: z.string(),
257
- shown: z.number(),
258
- total: z.number(),
259
- });
260
-
261
65
  const outputSchema = z.object({
262
66
  tree: z.string(),
263
- is_error: z.boolean(),
264
67
  total_items: z.number(),
265
- truncated_dirs: z.array(TruncatedDirSchema),
266
- hint: z.string(),
68
+ is_error: z.boolean(),
69
+ error_type: z.string().optional(),
70
+ message: z.string().optional(),
267
71
  });
268
72
 
269
73
  export const contextTreeTool = {
270
74
  name: "context_tree",
271
75
  description:
272
- "[[ bash equivalent command: tree ]] Explore your context with a bird's-eye view. Call with no `drive` to list every drive; call with a drive (and optional path) to render a tree of that drive. Returns a markdown-style tree; tune max_depth and items_per_dir to bound output.",
76
+ "[[ bash equivalent command: tree ]] Render the file tree under context/ (or a sub-directory). Symlinks are followed for listing and indexing; entries that are symlinks are tagged ' -> (symlink)' in the output.",
273
77
  group: "context",
274
78
  inputSchema,
275
79
  outputSchema,
276
80
  execute: async (input, ctx) => {
277
- const result = await buildContextTree(ctx.conn, {
278
- drive: input.drive,
279
- path: input.path,
280
- maxDepth: input.max_depth,
281
- itemsPerDir: input.items_per_dir,
282
- });
283
- return { ...result, is_error: false };
284
- },
285
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
286
-
287
- function stripTrailingSlash(p: string): string {
288
- return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
289
- }
290
-
291
- function countDescendants(dir: DirNode): number {
292
- let count = 0;
293
- for (const child of dir.children) {
294
- count += 1;
295
- if (child.isDir) count += countDescendants(child);
296
- }
297
- return count;
298
- }
299
-
300
- function buildHint(args: {
301
- truncatedDirs: Array<{ path: string; shown: number; total: number }>;
302
- depthLimitedDirs: string[];
303
- totalItems: number;
304
- drive: string;
305
- }): string {
306
- const { truncatedDirs, depthLimitedDirs, drive } = args;
307
- const parts: string[] = [];
308
-
309
- if (truncatedDirs.length > 0) {
310
- const first = truncatedDirs[0];
311
- if (first) {
312
- parts.push(
313
- `${truncatedDirs.length} ${truncatedDirs.length === 1 ? "directory was" : "directories were"} capped by items_per_dir; raise items_per_dir or call context_tree with drive="${drive}", path="${first.path}".`,
314
- );
315
- }
316
- }
317
-
318
- if (depthLimitedDirs.length > 0) {
319
- const first = depthLimitedDirs[0];
320
- if (first) {
321
- parts.push(
322
- `${depthLimitedDirs.length} ${depthLimitedDirs.length === 1 ? "directory was" : "directories were"} not expanded due to max_depth; raise max_depth or call context_tree with drive="${drive}", path="${first}".`,
81
+ try {
82
+ const node = await buildTree(
83
+ ctx.projectDir,
84
+ input.path ?? "",
85
+ input.max_depth,
323
86
  );
87
+ return {
88
+ tree: renderTree(node).join("\n"),
89
+ total_items: countItems(node),
90
+ is_error: false,
91
+ };
92
+ } catch (err) {
93
+ if (err instanceof NotFoundError) {
94
+ return {
95
+ tree: "",
96
+ total_items: 0,
97
+ is_error: true,
98
+ error_type: "not_found",
99
+ message: `No path at context/${err.path}`,
100
+ };
101
+ }
102
+ throw err;
324
103
  }
325
- }
104
+ },
105
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
326
106
 
327
- if (parts.length === 0) return "Tree is complete.";
328
- return parts.join(" ");
107
+ /**
108
+ * Convenience for callers that want a string tree from outside the tool layer.
109
+ */
110
+ export async function buildContextTree(
111
+ projectDir: string,
112
+ opts: { path?: string; maxDepth?: number } = {},
113
+ ): Promise<BuildContextTreeResult> {
114
+ const node = await buildTree(
115
+ projectDir,
116
+ opts.path ?? "",
117
+ opts.maxDepth ?? DEFAULT_MAX_DEPTH,
118
+ );
119
+ return {
120
+ tree: renderTree(node).join("\n"),
121
+ total_items: countItems(node),
122
+ hint: "",
123
+ };
329
124
  }
@@ -1,45 +1,71 @@
1
1
  import { z } from "zod";
2
- import { formatDriveRef } from "../../context/drives.ts";
3
2
  import {
4
- contextPathExists,
5
- copyContextItem,
6
- deleteContextItemByPath,
7
- } from "../../db/context.ts";
3
+ copyContextPath,
4
+ deleteContextPath,
5
+ fileExists,
6
+ IsDirectoryError,
7
+ NotFoundError,
8
+ PathConflictError,
9
+ } from "../../context/store.ts";
8
10
  import type { ToolDefinition } from "../tool.ts";
9
11
 
10
12
  const inputSchema = z.object({
11
- src_drive: z.string().describe("Source drive"),
12
- src_path: z.string().describe("Source path within the drive"),
13
- dst_drive: z.string().describe("Destination drive"),
14
- dst_path: z.string().describe("Destination path within the drive"),
13
+ src: z.string().describe("Source path under context/"),
14
+ dst: z.string().describe("Destination path under context/"),
15
15
  overwrite: z.boolean().optional().describe("Overwrite if destination exists"),
16
16
  });
17
17
 
18
18
  const outputSchema = z.object({
19
- id: z.string(),
20
- ref: z.string(),
19
+ src: z.string(),
20
+ dst: z.string(),
21
21
  is_error: z.boolean(),
22
+ error_type: z.string().optional(),
23
+ message: z.string().optional(),
22
24
  });
23
25
 
24
26
  export const contextCopyTool = {
25
27
  name: "context_copy",
26
- description: "[[ bash equivalent command: cp ]] Copy a context item.",
28
+ description:
29
+ "[[ bash equivalent command: cp ]] Copy a file under context/ to a new path. Source/destination paths that traverse a user symlink fail with PathEscapeError.",
27
30
  group: "context",
28
31
  inputSchema,
29
32
  outputSchema,
30
33
  execute: async (input, ctx) => {
31
- const src = { drive: input.src_drive, path: input.src_path };
32
- const dst = { drive: input.dst_drive, path: input.dst_path };
33
-
34
- const dstExists = await contextPathExists(ctx.conn, dst);
35
- if (dstExists && !input.overwrite) {
36
- throw new Error(`Destination already exists: ${formatDriveRef(dst)}`);
37
- }
38
- if (dstExists) {
39
- await deleteContextItemByPath(ctx.conn, dst);
34
+ try {
35
+ if (input.overwrite && (await fileExists(ctx.projectDir, input.dst))) {
36
+ await deleteContextPath(ctx.projectDir, input.dst);
37
+ }
38
+ await copyContextPath(ctx.projectDir, input.src, input.dst);
39
+ return { src: input.src, dst: input.dst, is_error: false };
40
+ } catch (err) {
41
+ if (err instanceof NotFoundError) {
42
+ return {
43
+ src: input.src,
44
+ dst: input.dst,
45
+ is_error: true,
46
+ error_type: "not_found",
47
+ message: `No file at context/${err.path}`,
48
+ };
49
+ }
50
+ if (err instanceof PathConflictError) {
51
+ return {
52
+ src: input.src,
53
+ dst: input.dst,
54
+ is_error: true,
55
+ error_type: "path_conflict",
56
+ message: `Destination already exists at context/${err.path}; pass overwrite=true to replace.`,
57
+ };
58
+ }
59
+ if (err instanceof IsDirectoryError) {
60
+ return {
61
+ src: input.src,
62
+ dst: input.dst,
63
+ is_error: true,
64
+ error_type: "is_directory",
65
+ message: `Source is a directory: context/${err.path}. Copy is file-only.`,
66
+ };
67
+ }
68
+ throw err;
40
69
  }
41
-
42
- const item = await copyContextItem(ctx.conn, src, dst);
43
- return { id: item.id, ref: formatDriveRef(item), is_error: false };
44
70
  },
45
71
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,30 +1,54 @@
1
1
  import { z } from "zod";
2
- import { formatDriveRef } from "../../context/drives.ts";
3
- import { resolveContextItemOrThrow } from "../../db/context.ts";
2
+ import {
3
+ IsDirectoryError,
4
+ NotFoundError,
5
+ readContextFile,
6
+ } from "../../context/store.ts";
4
7
  import type { ToolDefinition } from "../tool.ts";
5
8
 
6
9
  const inputSchema = z.object({
7
- drive: z.string().describe("Drive name (e.g. 'disk', 'agent')"),
8
- path: z.string().describe("Path within the drive"),
10
+ path: z.string().describe("Path under context/"),
9
11
  });
10
12
 
11
13
  const outputSchema = z.object({
12
14
  lines: z.number(),
13
15
  is_error: z.boolean(),
16
+ error_type: z.string().optional(),
17
+ message: z.string().optional(),
14
18
  });
15
19
 
16
20
  export const contextCountLinesTool = {
17
21
  name: "context_count_lines",
18
22
  description:
19
- "[[ bash equivalent command: wc -l ]] Count the number of lines in a text context item.",
23
+ "[[ bash equivalent command: wc -l ]] Count the number of lines in a text file under context/.",
20
24
  group: "context",
21
25
  inputSchema,
22
26
  outputSchema,
23
27
  execute: async (input, ctx) => {
24
- const ref = formatDriveRef({ drive: input.drive, path: input.path });
25
- const item = await resolveContextItemOrThrow(ctx.conn, ref);
26
- if (item.content == null) throw new Error(`No text content: ${ref}`);
27
-
28
- return { lines: item.content.split("\n").length, is_error: false };
28
+ try {
29
+ const content = await readContextFile(ctx.projectDir, input.path);
30
+ return {
31
+ lines: content === "" ? 0 : content.split("\n").length,
32
+ is_error: false,
33
+ };
34
+ } catch (err) {
35
+ if (err instanceof NotFoundError) {
36
+ return {
37
+ lines: 0,
38
+ is_error: true,
39
+ error_type: "not_found",
40
+ message: `No file at context/${err.path}`,
41
+ };
42
+ }
43
+ if (err instanceof IsDirectoryError) {
44
+ return {
45
+ lines: 0,
46
+ is_error: true,
47
+ error_type: "is_directory",
48
+ message: `context/${err.path} is a directory`,
49
+ };
50
+ }
51
+ throw err;
52
+ }
29
53
  },
30
54
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,18 +1,17 @@
1
1
  import { z } from "zod";
2
- import { formatDriveRef } from "../../context/drives.ts";
3
2
  import {
4
- deleteContextItemByPath,
5
- deleteContextItemsByPrefix,
6
- } from "../../db/context.ts";
3
+ deleteContextPath,
4
+ IsDirectoryError,
5
+ NotFoundError,
6
+ } from "../../context/store.ts";
7
7
  import type { ToolDefinition } from "../tool.ts";
8
8
 
9
9
  const inputSchema = z.object({
10
- drive: z.string().describe("Drive name"),
11
- path: z.string().describe("Path to delete within the drive"),
10
+ path: z.string().describe("Path under context/ to delete"),
12
11
  recursive: z
13
12
  .boolean()
14
13
  .optional()
15
- .describe("Delete all items under this path prefix"),
14
+ .describe("Delete a directory and its contents recursively"),
16
15
  force: z
17
16
  .boolean()
18
17
  .optional()
@@ -21,32 +20,63 @@ const inputSchema = z.object({
21
20
 
22
21
  const outputSchema = z.object({
23
22
  deleted: z.number(),
23
+ was_directory: z.boolean(),
24
+ was_symlink: z.boolean(),
24
25
  is_error: z.boolean(),
26
+ error_type: z.string().optional(),
27
+ message: z.string().optional(),
28
+ next_action_hint: z.string().optional(),
25
29
  });
26
30
 
27
31
  export const contextDeleteTool = {
28
32
  name: "context_delete",
29
33
  description:
30
- "[[ bash equivalent command: rm -r ]] Delete a context item or directory.",
34
+ "[[ bash equivalent command: rm -r ]] Delete a file or (with recursive=true) a directory under context/. Symlinks are unlinked without touching their target — `recursive` is not required for a symlinked directory.",
31
35
  group: "context",
32
36
  inputSchema,
33
37
  outputSchema,
34
38
  execute: async (input, ctx) => {
35
- const target = { drive: input.drive, path: input.path };
36
- if (input.recursive) {
37
- const count = await deleteContextItemsByPrefix(
38
- ctx.conn,
39
- target.drive,
40
- target.path,
41
- );
42
- const exact = await deleteContextItemByPath(ctx.conn, target);
43
- return { deleted: count + (exact ? 1 : 0), is_error: false };
39
+ try {
40
+ const result = await deleteContextPath(ctx.projectDir, input.path, {
41
+ recursive: input.recursive,
42
+ });
43
+ return {
44
+ deleted: result.removed,
45
+ was_directory: result.was_directory,
46
+ was_symlink: result.was_symlink,
47
+ is_error: false,
48
+ };
49
+ } catch (err) {
50
+ if (err instanceof NotFoundError) {
51
+ if (input.force) {
52
+ return {
53
+ deleted: 0,
54
+ was_directory: false,
55
+ was_symlink: false,
56
+ is_error: false,
57
+ };
58
+ }
59
+ return {
60
+ deleted: 0,
61
+ was_directory: false,
62
+ was_symlink: false,
63
+ is_error: true,
64
+ error_type: "not_found",
65
+ message: `No file at context/${err.path}`,
66
+ };
67
+ }
68
+ if (err instanceof IsDirectoryError) {
69
+ return {
70
+ deleted: 0,
71
+ was_directory: true,
72
+ was_symlink: false,
73
+ is_error: true,
74
+ error_type: "is_directory",
75
+ message: `context/${err.path} is a directory`,
76
+ next_action_hint: "Pass recursive=true to delete a directory tree.",
77
+ };
78
+ }
79
+ throw err;
44
80
  }
45
-
46
- const deleted = await deleteContextItemByPath(ctx.conn, target);
47
- if (!deleted && !input.force) {
48
- throw new Error(`Not found: ${formatDriveRef(target)}`);
49
- }
50
- return { deleted: deleted ? 1 : 0, is_error: false };
51
81
  },
52
82
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;