botholomew 0.12.5 → 0.13.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 (103) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -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 +630 -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 +279 -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 +73 -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 +44 -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 +25 -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 +3 -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/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/utils/title.ts +5 -7
  80. package/src/utils/v7-date.ts +47 -0
  81. package/src/worker/heartbeat.ts +46 -24
  82. package/src/worker/index.ts +13 -15
  83. package/src/worker/llm.ts +30 -37
  84. package/src/worker/prompt.ts +19 -41
  85. package/src/worker/schedules.ts +48 -69
  86. package/src/worker/spawn.ts +11 -11
  87. package/src/worker/tick.ts +39 -43
  88. package/src/workers/store.ts +247 -0
  89. package/src/commands/tools.ts +0 -367
  90. package/src/context/describer.ts +0 -140
  91. package/src/context/drives.ts +0 -110
  92. package/src/context/ingest.ts +0 -162
  93. package/src/context/refresh.ts +0 -183
  94. package/src/db/context.ts +0 -637
  95. package/src/db/daemon-state.ts +0 -6
  96. package/src/db/reembed.ts +0 -113
  97. package/src/db/schedules.ts +0 -213
  98. package/src/db/tasks.ts +0 -347
  99. package/src/db/threads.ts +0 -276
  100. package/src/db/workers.ts +0 -212
  101. package/src/tools/context/list-drives.ts +0 -36
  102. package/src/tools/context/refresh.ts +0 -165
  103. package/src/tools/context/search.ts +0 -54
@@ -1,237 +1,55 @@
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 label = node.is_directory ? `${node.name}/` : node.name;
25
+ lines.push(`${prefix}${connector}${label}`);
26
+ if (node.is_directory && node.children) {
27
+ const childPrefix =
28
+ prefix + (prefix === "" ? "" : isLast ? " " : "│ ");
29
+ const children = node.children;
30
+ children.forEach((c, i) => {
31
+ const last = i === children.length - 1;
32
+ lines.push(...renderTree(c, childPrefix, last));
156
33
  });
157
34
  }
35
+ return lines;
36
+ }
158
37
 
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
- };
38
+ function countItems(node: TreeNode): number {
39
+ if (!node.is_directory) return 1;
40
+ let total = 0;
41
+ for (const c of node.children ?? []) total += countItems(c);
42
+ return total;
222
43
  }
223
44
 
224
45
  const inputSchema = z.object({
225
- drive: z
46
+ path: z
226
47
  .string()
227
48
  .optional()
49
+ .default("")
228
50
  .describe(
229
- "Drive to explore (e.g. 'disk', 'agent'). Omit to list every drive with its item count — useful as a first call.",
51
+ "Directory path under context/ to render (defaults to the context root).",
230
52
  ),
231
- path: z
232
- .string()
233
- .optional()
234
- .describe("Root path for the tree within the drive (defaults to /)"),
235
53
  max_depth: z
236
54
  .number()
237
55
  .int()
@@ -239,91 +57,67 @@ const inputSchema = z.object({
239
57
  .optional()
240
58
  .default(DEFAULT_MAX_DEPTH)
241
59
  .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)".`,
60
+ `Maximum depth of directories to render (defaults to ${DEFAULT_MAX_DEPTH}).`,
252
61
  ),
253
62
  });
254
63
 
255
- const TruncatedDirSchema = z.object({
256
- path: z.string(),
257
- shown: z.number(),
258
- total: z.number(),
259
- });
260
-
261
64
  const outputSchema = z.object({
262
65
  tree: z.string(),
263
- is_error: z.boolean(),
264
66
  total_items: z.number(),
265
- truncated_dirs: z.array(TruncatedDirSchema),
266
- hint: z.string(),
67
+ is_error: z.boolean(),
68
+ error_type: z.string().optional(),
69
+ message: z.string().optional(),
267
70
  });
268
71
 
269
72
  export const contextTreeTool = {
270
73
  name: "context_tree",
271
74
  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.",
75
+ "[[ bash equivalent command: tree ]] Render the file tree under context/ (or a sub-directory).",
273
76
  group: "context",
274
77
  inputSchema,
275
78
  outputSchema,
276
79
  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}".`,
80
+ try {
81
+ const node = await buildTree(
82
+ ctx.projectDir,
83
+ input.path ?? "",
84
+ input.max_depth,
323
85
  );
86
+ return {
87
+ tree: renderTree(node).join("\n"),
88
+ total_items: countItems(node),
89
+ is_error: false,
90
+ };
91
+ } catch (err) {
92
+ if (err instanceof NotFoundError) {
93
+ return {
94
+ tree: "",
95
+ total_items: 0,
96
+ is_error: true,
97
+ error_type: "not_found",
98
+ message: `No path at context/${err.path}`,
99
+ };
100
+ }
101
+ throw err;
324
102
  }
325
- }
103
+ },
104
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
326
105
 
327
- if (parts.length === 0) return "Tree is complete.";
328
- return parts.join(" ");
106
+ /**
107
+ * Convenience for callers that want a string tree from outside the tool layer.
108
+ */
109
+ export async function buildContextTree(
110
+ projectDir: string,
111
+ opts: { path?: string; maxDepth?: number } = {},
112
+ ): Promise<BuildContextTreeResult> {
113
+ const node = await buildTree(
114
+ projectDir,
115
+ opts.path ?? "",
116
+ opts.maxDepth ?? DEFAULT_MAX_DEPTH,
117
+ );
118
+ return {
119
+ tree: renderTree(node).join("\n"),
120
+ total_items: countItems(node),
121
+ hint: "",
122
+ };
329
123
  }
@@ -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.",
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,54 @@ const inputSchema = z.object({
21
20
 
22
21
  const outputSchema = z.object({
23
22
  deleted: z.number(),
23
+ was_directory: z.boolean(),
24
24
  is_error: z.boolean(),
25
+ error_type: z.string().optional(),
26
+ message: z.string().optional(),
27
+ next_action_hint: z.string().optional(),
25
28
  });
26
29
 
27
30
  export const contextDeleteTool = {
28
31
  name: "context_delete",
29
32
  description:
30
- "[[ bash equivalent command: rm -r ]] Delete a context item or directory.",
33
+ "[[ bash equivalent command: rm -r ]] Delete a file or (with recursive=true) a directory under context/.",
31
34
  group: "context",
32
35
  inputSchema,
33
36
  outputSchema,
34
37
  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 };
38
+ try {
39
+ const result = await deleteContextPath(ctx.projectDir, input.path, {
40
+ recursive: input.recursive,
41
+ });
42
+ return {
43
+ deleted: result.removed,
44
+ was_directory: result.was_directory,
45
+ is_error: false,
46
+ };
47
+ } catch (err) {
48
+ if (err instanceof NotFoundError) {
49
+ if (input.force) {
50
+ return { deleted: 0, was_directory: false, is_error: false };
51
+ }
52
+ return {
53
+ deleted: 0,
54
+ was_directory: false,
55
+ is_error: true,
56
+ error_type: "not_found",
57
+ message: `No file at context/${err.path}`,
58
+ };
59
+ }
60
+ if (err instanceof IsDirectoryError) {
61
+ return {
62
+ deleted: 0,
63
+ was_directory: true,
64
+ is_error: true,
65
+ error_type: "is_directory",
66
+ message: `context/${err.path} is a directory`,
67
+ next_action_hint: "Pass recursive=true to delete a directory tree.",
68
+ };
69
+ }
70
+ throw err;
44
71
  }
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
72
  },
52
73
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;