botholomew 0.8.10 → 0.9.4

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.
@@ -13,7 +13,6 @@ export interface Embedding {
13
13
  chunk_content: string | null;
14
14
  title: string;
15
15
  description: string;
16
- source_path: string | null;
17
16
  embedding: number[];
18
17
  created_at: Date;
19
18
  }
@@ -29,7 +28,6 @@ interface EmbeddingRow {
29
28
  chunk_content: string | null;
30
29
  title: string;
31
30
  description: string;
32
- source_path: string | null;
33
31
  embedding: number[] | null;
34
32
  created_at: string;
35
33
  }
@@ -42,7 +40,6 @@ function rowToEmbedding(row: EmbeddingRow): Embedding {
42
40
  chunk_content: row.chunk_content,
43
41
  title: row.title,
44
42
  description: row.description,
45
- source_path: row.source_path,
46
43
  embedding: row.embedding ?? [],
47
44
  created_at: new Date(row.created_at),
48
45
  };
@@ -56,21 +53,19 @@ export async function createEmbedding(
56
53
  chunkContent: string | null;
57
54
  title: string;
58
55
  description?: string;
59
- sourcePath?: string | null;
60
56
  embedding: number[];
61
57
  },
62
58
  ): Promise<Embedding> {
63
59
  const id = uuidv7();
64
60
  await conn.queryRun(
65
- `INSERT INTO embeddings (id, context_item_id, chunk_index, chunk_content, title, description, source_path, embedding)
66
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8::FLOAT[${EMBEDDING_DIMENSION}])`,
61
+ `INSERT INTO embeddings (id, context_item_id, chunk_index, chunk_content, title, description, embedding)
62
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7::FLOAT[${EMBEDDING_DIMENSION}])`,
67
63
  id,
68
64
  params.contextItemId,
69
65
  params.chunkIndex,
70
66
  params.chunkContent,
71
67
  params.title,
72
68
  params.description ?? "",
73
- params.sourcePath ?? null,
74
69
  params.embedding,
75
70
  );
76
71
 
@@ -81,7 +76,6 @@ export async function createEmbedding(
81
76
  chunk_content: params.chunkContent,
82
77
  title: params.title,
83
78
  description: params.description ?? "",
84
- source_path: params.sourcePath ?? null,
85
79
  embedding: params.embedding,
86
80
  created_at: new Date(),
87
81
  };
@@ -139,15 +133,19 @@ export async function searchEmbeddings(
139
133
  }));
140
134
  }
141
135
 
136
+ export interface HybridSearchResult extends EmbeddingSearchResult {
137
+ drive: string | null;
138
+ path: string | null;
139
+ }
140
+
142
141
  export async function hybridSearch(
143
142
  conn: DbConnection,
144
143
  query: string,
145
144
  queryEmbedding: number[],
146
145
  limit = 10,
147
- ): Promise<EmbeddingSearchResult[]> {
146
+ ): Promise<HybridSearchResult[]> {
148
147
  const k = 60; // RRF constant
149
148
 
150
- // Keyword search: match on chunk_content and title
151
149
  const keywordRows = await conn.queryAll<EmbeddingRow>(
152
150
  `SELECT * FROM embeddings
153
151
  WHERE chunk_content ILIKE '%' || ?1 || '%'
@@ -158,10 +156,8 @@ export async function hybridSearch(
158
156
 
159
157
  const keywordRanked = keywordRows.map(rowToEmbedding);
160
158
 
161
- // Vector search via DuckDB VSS
162
159
  const vectorResults = await searchEmbeddings(conn, queryEmbedding, 100);
163
160
 
164
- // Reciprocal rank fusion
165
161
  const scores = new Map<string, { embedding: Embedding; score: number }>();
166
162
 
167
163
  for (const [i, emb] of keywordRanked.entries()) {
@@ -187,8 +183,31 @@ export async function hybridSearch(
187
183
  const merged = Array.from(scores.values());
188
184
  merged.sort((a, b) => b.score - a.score);
189
185
 
190
- return merged.slice(0, limit).map((entry) => ({
191
- ...entry.embedding,
192
- score: entry.score,
193
- }));
186
+ const top = merged.slice(0, limit);
187
+ if (top.length === 0) return [];
188
+
189
+ // Look up drive + path from context_items for each surviving embedding
190
+ const itemIds = Array.from(
191
+ new Set(top.map((t) => t.embedding.context_item_id)),
192
+ );
193
+ const placeholders = itemIds.map((_, i) => `?${i + 1}`).join(", ");
194
+ const itemRows = await conn.queryAll<{
195
+ id: string;
196
+ drive: string;
197
+ path: string;
198
+ }>(
199
+ `SELECT id, drive, path FROM context_items WHERE id IN (${placeholders})`,
200
+ ...itemIds,
201
+ );
202
+ const itemIndex = new Map(itemRows.map((r) => [r.id, r]));
203
+
204
+ return top.map((entry) => {
205
+ const item = itemIndex.get(entry.embedding.context_item_id);
206
+ return {
207
+ ...entry.embedding,
208
+ score: entry.score,
209
+ drive: item?.drive ?? null,
210
+ path: item?.path ?? null,
211
+ };
212
+ });
194
213
  }
@@ -0,0 +1,49 @@
1
+ -- Milestone 10: collapse `source_path` + `context_path` + `source_type` into a
2
+ -- single `(drive, path)` identity pair. Pre-1.0, no backwards-compat promise —
3
+ -- we wipe context_items + embeddings and have the user re-add their content.
4
+ --
5
+ -- DuckDB's ALTER TABLE support is thin (no SET NOT NULL, flaky DROP COLUMN with
6
+ -- existing indexes), so this is a table rebuild. Order matters: drop indexes
7
+ -- first, then the old tables, then recreate with the new shape.
8
+
9
+ DELETE FROM embeddings;
10
+ DELETE FROM context_items;
11
+
12
+ DROP INDEX IF EXISTS idx_embeddings_cosine;
13
+ DROP INDEX IF EXISTS idx_context_items_context_path;
14
+
15
+ DROP TABLE embeddings;
16
+ DROP TABLE context_items;
17
+
18
+ CREATE TABLE context_items (
19
+ id TEXT PRIMARY KEY,
20
+ title TEXT NOT NULL,
21
+ description TEXT NOT NULL DEFAULT '',
22
+ content TEXT,
23
+ content_blob BLOB,
24
+ mime_type TEXT NOT NULL DEFAULT 'text/plain',
25
+ is_textual BOOLEAN NOT NULL DEFAULT true,
26
+ drive TEXT NOT NULL,
27
+ path TEXT NOT NULL,
28
+ indexed_at TEXT,
29
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
30
+ updated_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR)
31
+ );
32
+
33
+ CREATE UNIQUE INDEX idx_context_items_drive_path ON context_items(drive, path);
34
+
35
+ CREATE TABLE embeddings (
36
+ id TEXT PRIMARY KEY,
37
+ context_item_id TEXT NOT NULL,
38
+ chunk_index INTEGER NOT NULL,
39
+ chunk_content TEXT,
40
+ title TEXT NOT NULL,
41
+ description TEXT NOT NULL DEFAULT '',
42
+ embedding FLOAT[1536],
43
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
44
+ UNIQUE(context_item_id, chunk_index)
45
+ );
46
+
47
+ CREATE INDEX idx_embeddings_cosine ON embeddings USING HNSW (embedding) WITH (metric = 'cosine');
48
+
49
+ CHECKPOINT;
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import { listDriveSummaries } from "../../db/context.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({});
6
+
7
+ const outputSchema = z.object({
8
+ drives: z.array(
9
+ z.object({
10
+ drive: z.string(),
11
+ count: z.number(),
12
+ }),
13
+ ),
14
+ is_error: z.boolean(),
15
+ hint: z.string().optional(),
16
+ });
17
+
18
+ export const contextListDrivesTool = {
19
+ name: "context_list_drives",
20
+ description:
21
+ "List every drive that currently has content, with its item count. Use this to discover which values to pass as `drive` on other context tools (disk / url / agent / google-docs / github / …).",
22
+ group: "context",
23
+ inputSchema,
24
+ outputSchema,
25
+ execute: async (_input, ctx) => {
26
+ const drives = await listDriveSummaries(ctx.conn);
27
+ if (drives.length === 0) {
28
+ return {
29
+ drives: [],
30
+ is_error: false,
31
+ hint: "No context has been ingested yet. The user can run `botholomew context add <path-or-url>` to add content.",
32
+ };
33
+ }
34
+ return { drives, is_error: false };
35
+ },
36
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { parseDriveRef } from "../../context/drives.ts";
2
3
  import { refreshContextItems } from "../../context/refresh.ts";
3
4
  import {
4
5
  type ContextItem,
@@ -10,17 +11,17 @@ import { buildContextTree } from "../dir/tree.ts";
10
11
  import type { ToolDefinition } from "../tool.ts";
11
12
 
12
13
  const inputSchema = z.object({
13
- path: z
14
+ ref: z
14
15
  .string()
15
16
  .optional()
16
17
  .describe(
17
- "Context path or ID of a single item, or a path prefix to refresh a subtree. Mutually exclusive with `all`.",
18
+ "UUID or 'drive:/path' of a single item, or 'drive:/prefix' to refresh a subtree. Mutually exclusive with `all`.",
18
19
  ),
19
20
  all: z
20
21
  .boolean()
21
22
  .optional()
22
23
  .describe(
23
- "Refresh every item that has a source_path (file or URL). Mutually exclusive with `path`.",
24
+ "Refresh every item that has an external origin (drive != 'agent'). Mutually exclusive with `ref`.",
24
25
  ),
25
26
  });
26
27
 
@@ -35,9 +36,9 @@ const outputSchema = z.object({
35
36
  items: z.array(
36
37
  z.object({
37
38
  id: z.string(),
38
- context_path: z.string(),
39
- source_path: z.string(),
40
- source_type: z.enum(["file", "url"]),
39
+ drive: z.string(),
40
+ path: z.string(),
41
+ ref: z.string(),
41
42
  status: z.enum(["updated", "unchanged", "missing", "error"]),
42
43
  error: z.string().optional(),
43
44
  }),
@@ -67,22 +68,22 @@ const empty = {
67
68
  export const contextRefreshTool = {
68
69
  name: "context_refresh",
69
70
  description:
70
- "[[ bash equivalent command: curl ]] Re-read source files from disk / re-fetch source URLs, update stored content if it changed, and re-embed only changed items. Use `path` for a single item or subtree, or `all: true` for every sourced item. Items without a source_path are skipped. URL fetches use the project's MCPX client when available and fall back to plain HTTP.",
71
+ "[[ bash equivalent command: curl ]] Re-import items from their origin (disk / URL / MCP) and re-embed changed items. Use `ref` for a single item or subtree, or `all: true` for every non-agent item. URL fetches use the project's MCPX client when available and fall back to plain HTTP.",
71
72
  group: "context",
72
73
  inputSchema,
73
74
  outputSchema,
74
75
  execute: async (input, ctx) => {
75
- if (!input.path && !input.all) {
76
+ if (!input.ref && !input.all) {
76
77
  return {
77
78
  ...empty,
78
- message: "Provide a `path` or set `all: true`.",
79
+ message: "Provide a `ref` or set `all: true`.",
79
80
  is_error: true,
80
81
  };
81
82
  }
82
- if (input.path && input.all) {
83
+ if (input.ref && input.all) {
83
84
  return {
84
85
  ...empty,
85
- message: "`path` and `all` are mutually exclusive.",
86
+ message: "`ref` and `all` are mutually exclusive.",
86
87
  is_error: true,
87
88
  };
88
89
  }
@@ -91,35 +92,46 @@ export const contextRefreshTool = {
91
92
  if (input.all) {
92
93
  items = await listContextItems(ctx.conn);
93
94
  } else {
94
- const exact = await resolveContextItem(ctx.conn, input.path as string);
95
- items = exact
96
- ? [exact]
97
- : await listContextItemsByPrefix(ctx.conn, input.path as string, {
98
- recursive: true,
99
- });
95
+ const ref = input.ref as string;
96
+ const exact = await resolveContextItem(ctx.conn, ref);
97
+ if (exact) {
98
+ items = [exact];
99
+ } else {
100
+ const parsed = parseDriveRef(ref);
101
+ items = parsed
102
+ ? await listContextItemsByPrefix(
103
+ ctx.conn,
104
+ parsed.drive,
105
+ parsed.path,
106
+ {
107
+ recursive: true,
108
+ },
109
+ )
110
+ : [];
111
+ }
100
112
  }
101
113
 
102
114
  if (items.length === 0) {
103
115
  return {
104
116
  ...empty,
105
- message: `No context items match \`${input.path ?? "all"}\`.`,
117
+ message: `No context items match \`${input.ref ?? "all"}\`.`,
106
118
  is_error: true,
107
119
  };
108
120
  }
109
121
 
110
- const sourced = items.filter((i) => i.source_path);
111
- if (sourced.length === 0) {
122
+ const refreshable = items.filter((i) => i.drive !== "agent");
123
+ if (refreshable.length === 0) {
112
124
  return {
113
125
  ...empty,
114
126
  message:
115
- "No matching items have a source_path nothing to refresh. (Items created via `context write` are not sourced.)",
127
+ "No refreshable items everything matched lives on drive=agent (agent-authored content has no external origin).",
116
128
  is_error: false,
117
129
  };
118
130
  }
119
131
 
120
132
  const result = await refreshContextItems(
121
133
  ctx.conn,
122
- sourced,
134
+ refreshable,
123
135
  ctx.config,
124
136
  ctx.mcpxClient,
125
137
  );
@@ -135,7 +147,13 @@ export const contextRefreshTool = {
135
147
  parts.push("embeddings skipped (no OpenAI API key configured)");
136
148
  }
137
149
 
138
- const { tree } = await buildContextTree(ctx.conn);
150
+ // For a single-ref refresh, render that ref's drive; for `all: true`,
151
+ // render the top-level drive summary so the scope of the tree matches
152
+ // the scope of the operation.
153
+ const treeDrive = input.all
154
+ ? undefined
155
+ : (result.items[0]?.drive ?? undefined);
156
+ const { tree } = await buildContextTree(ctx.conn, { drive: treeDrive });
139
157
 
140
158
  return {
141
159
  ...result,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { formatDriveRef } from "../../context/drives.ts";
2
3
  import { searchContextByKeyword } from "../../db/context.ts";
3
4
  import type { ToolDefinition } from "../tool.ts";
4
5
 
@@ -14,7 +15,9 @@ const outputSchema = z.object({
14
15
  z.object({
15
16
  id: z.string(),
16
17
  title: z.string(),
17
- context_path: z.string(),
18
+ drive: z.string(),
19
+ path: z.string(),
20
+ ref: z.string(),
18
21
  content_preview: z.string(),
19
22
  }),
20
23
  ),
@@ -25,7 +28,7 @@ const outputSchema = z.object({
25
28
  export const contextSearchTool = {
26
29
  name: "context_search",
27
30
  description:
28
- "[[ bash equivalent command: grep -r ]] Search context by keyword.",
31
+ "[[ bash equivalent command: grep -r ]] Search context by keyword across all drives.",
29
32
  group: "context",
30
33
  inputSchema,
31
34
  outputSchema,
@@ -39,7 +42,9 @@ export const contextSearchTool = {
39
42
  results: items.map((item) => ({
40
43
  id: item.id,
41
44
  title: item.title,
42
- context_path: item.context_path,
45
+ drive: item.drive,
46
+ path: item.path,
47
+ ref: formatDriveRef(item),
43
48
  content_preview: (item.content ?? "").slice(0, 500),
44
49
  })),
45
50
  count: items.length,
@@ -1,41 +1,44 @@
1
1
  import { z } from "zod";
2
+ import { formatDriveRef } from "../../context/drives.ts";
2
3
  import { contextPathExists, createContextItem } from "../../db/context.ts";
3
4
  import type { ToolDefinition } from "../tool.ts";
4
5
 
5
6
  const inputSchema = z.object({
6
- path: z.string().describe("Directory path to create"),
7
- parents: z
8
- .boolean()
9
- .optional()
10
- .describe("Create parent directories as needed"),
7
+ drive: z
8
+ .string()
9
+ .default("agent")
10
+ .describe("Drive to create the directory in (defaults to 'agent')"),
11
+ path: z.string().describe("Directory path to create (starts with /)"),
11
12
  });
12
13
 
13
14
  const outputSchema = z.object({
14
15
  created: z.boolean(),
15
- path: z.string(),
16
+ ref: z.string(),
16
17
  is_error: z.boolean(),
17
18
  });
18
19
 
19
20
  export const contextCreateDirTool = {
20
21
  name: "context_create_dir",
21
22
  description:
22
- "[[ bash equivalent command: mkdir -p ]] Create a directory in context.",
23
+ "[[ bash equivalent command: mkdir -p ]] Create a directory placeholder in context.",
23
24
  group: "context",
24
25
  inputSchema,
25
26
  outputSchema,
26
27
  execute: async (input, ctx) => {
27
- const exists = await contextPathExists(ctx.conn, input.path);
28
+ const target = { drive: input.drive, path: input.path };
29
+ const exists = await contextPathExists(ctx.conn, target);
28
30
  if (exists) {
29
- return { created: false, path: input.path, is_error: false };
31
+ return { created: false, ref: formatDriveRef(target), is_error: false };
30
32
  }
31
33
 
32
34
  await createContextItem(ctx.conn, {
33
35
  title: input.path.split("/").filter(Boolean).pop() ?? input.path,
34
- contextPath: input.path,
36
+ drive: target.drive,
37
+ path: target.path,
35
38
  mimeType: "inode/directory",
36
39
  isTextual: false,
37
40
  });
38
41
 
39
- return { created: true, path: input.path, is_error: false };
42
+ return { created: true, ref: formatDriveRef(target), is_error: false };
40
43
  },
41
44
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -11,6 +11,7 @@ function formatBytes(bytes: number): string {
11
11
  }
12
12
 
13
13
  const inputSchema = z.object({
14
+ drive: z.string().describe("Drive name"),
14
15
  path: z.string().optional().describe("Directory path (defaults to /)"),
15
16
  recursive: z
16
17
  .boolean()
@@ -27,13 +28,13 @@ const outputSchema = z.object({
27
28
  export const contextDirSizeTool = {
28
29
  name: "context_dir_size",
29
30
  description:
30
- "[[ bash equivalent command: du -s ]] Get the total size of context items in a directory.",
31
+ "[[ bash equivalent command: du -s ]] Get the total size of context items under a drive/directory.",
31
32
  group: "context",
32
33
  inputSchema,
33
34
  outputSchema,
34
35
  execute: async (input, ctx) => {
35
36
  const path = input.path ?? "/";
36
- const items = await listContextItemsByPrefix(ctx.conn, path, {
37
+ const items = await listContextItemsByPrefix(ctx.conn, input.drive, path, {
37
38
  recursive: input.recursive !== false,
38
39
  });
39
40
 
@@ -1,16 +1,19 @@
1
1
  import { z } from "zod";
2
+ import { formatDriveRef } from "../../context/drives.ts";
2
3
  import type { DbConnection } from "../../db/connection.ts";
3
4
  import {
4
5
  countContextItemsByPrefix,
5
6
  listContextItemsByPrefix,
7
+ listDriveSummaries,
6
8
  } from "../../db/context.ts";
7
9
  import type { ToolDefinition } from "../tool.ts";
8
10
 
9
- const DEFAULT_MAX_DEPTH = 3;
11
+ const DEFAULT_MAX_DEPTH = 10;
10
12
  const DEFAULT_ITEMS_PER_DIR = 15;
11
13
  const HARD_FETCH_CAP = 1000;
12
14
 
13
15
  export interface BuildContextTreeOptions {
16
+ drive?: string;
14
17
  path?: string;
15
18
  maxDepth?: number;
16
19
  itemsPerDir?: number;
@@ -38,36 +41,67 @@ interface FileNode {
38
41
 
39
42
  type TreeEntry = DirNode | FileNode;
40
43
 
44
+ /**
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.
47
+ */
41
48
  export async function buildContextTree(
42
49
  conn: DbConnection,
43
50
  options: BuildContextTreeOptions = {},
44
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;
45
79
  const path = options.path ?? "/";
46
80
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
47
81
  const itemsPerDir = options.itemsPerDir ?? DEFAULT_ITEMS_PER_DIR;
48
82
  const normalizedPath = path.endsWith("/") ? path : `${path}/`;
83
+ const rootLabel = formatDriveRef({ drive, path });
49
84
 
50
- const totalItems = await countContextItemsByPrefix(conn, path, {
85
+ const totalItems = await countContextItemsByPrefix(conn, drive, path, {
51
86
  recursive: true,
52
87
  });
53
88
 
54
89
  if (totalItems === 0) {
55
90
  return {
56
- tree: `${path}\n (empty)`,
91
+ tree: `${rootLabel}\n (empty)`,
57
92
  total_items: 0,
58
93
  truncated_dirs: [],
59
94
  hint: "Directory is empty.",
60
95
  };
61
96
  }
62
97
 
63
- const items = await listContextItemsByPrefix(conn, path, {
98
+ const items = await listContextItemsByPrefix(conn, drive, path, {
64
99
  recursive: true,
65
100
  limit: HARD_FETCH_CAP,
66
101
  });
67
102
 
68
- // Build tree structure: dirs map child name -> child node
69
103
  const root: DirNode = {
70
- name: path,
104
+ name: rootLabel,
71
105
  fullPath: path,
72
106
  isDir: true,
73
107
  children: [],
@@ -76,12 +110,11 @@ export async function buildContextTree(
76
110
  dirIndex.set(stripTrailingSlash(path), root);
77
111
 
78
112
  for (const item of items) {
79
- const relative = item.context_path.slice(normalizedPath.length);
80
- if (relative.length === 0) continue; // root itself, skip
113
+ const relative = item.path.slice(normalizedPath.length);
114
+ if (relative.length === 0) continue;
81
115
  const parts = relative.split("/").filter((p) => p.length > 0);
82
116
  const isExplicitDir = item.mime_type === "inode/directory";
83
117
 
84
- // Walk segments, creating intermediate directories as needed
85
118
  let parentDir = root;
86
119
  let currentRel = "";
87
120
  for (let i = 0; i < parts.length; i++) {
@@ -116,7 +149,6 @@ export async function buildContextTree(
116
149
  }
117
150
  }
118
151
 
119
- // Sort each directory's children: dirs first, then alphabetical
120
152
  for (const dir of dirIndex.values()) {
121
153
  dir.children.sort((a, b) => {
122
154
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
@@ -131,7 +163,7 @@ export async function buildContextTree(
131
163
  }> = [];
132
164
  const depthLimitedDirs: string[] = [];
133
165
 
134
- const lines: string[] = [path];
166
+ const lines: string[] = [rootLabel];
135
167
 
136
168
  const render = (dir: DirNode, indent: string, currentDepth: number): void => {
137
169
  const children = dir.children;
@@ -185,15 +217,21 @@ export async function buildContextTree(
185
217
  tree: lines.join("\n"),
186
218
  total_items: totalItems,
187
219
  truncated_dirs: truncatedDirs,
188
- hint: buildHint({ truncatedDirs, depthLimitedDirs, totalItems }),
220
+ hint: buildHint({ truncatedDirs, depthLimitedDirs, totalItems, drive }),
189
221
  };
190
222
  }
191
223
 
192
224
  const inputSchema = z.object({
225
+ drive: z
226
+ .string()
227
+ .optional()
228
+ .describe(
229
+ "Drive to explore (e.g. 'disk', 'agent'). Omit to list every drive with its item count — useful as a first call.",
230
+ ),
193
231
  path: z
194
232
  .string()
195
233
  .optional()
196
- .describe("Root path for the tree (defaults to /)"),
234
+ .describe("Root path for the tree within the drive (defaults to /)"),
197
235
  max_depth: z
198
236
  .number()
199
237
  .int()
@@ -231,12 +269,13 @@ const outputSchema = z.object({
231
269
  export const contextTreeTool = {
232
270
  name: "context_tree",
233
271
  description:
234
- "[[ bash equivalent command: tree ]] Explore your context filesystem with a bird's-eye view shows many paths across nested directories in one call. Reach for this first when you need to discover what content exists before reading a specific file (context_read) or running a keyword search (context_search). Returns a markdown-style tree; tune max_depth and items_per_dir to bound output, or pass a deeper path to drill into a subtree.",
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.",
235
273
  group: "context",
236
274
  inputSchema,
237
275
  outputSchema,
238
276
  execute: async (input, ctx) => {
239
277
  const result = await buildContextTree(ctx.conn, {
278
+ drive: input.drive,
240
279
  path: input.path,
241
280
  maxDepth: input.max_depth,
242
281
  itemsPerDir: input.items_per_dir,
@@ -262,15 +301,16 @@ function buildHint(args: {
262
301
  truncatedDirs: Array<{ path: string; shown: number; total: number }>;
263
302
  depthLimitedDirs: string[];
264
303
  totalItems: number;
304
+ drive: string;
265
305
  }): string {
266
- const { truncatedDirs, depthLimitedDirs } = args;
306
+ const { truncatedDirs, depthLimitedDirs, drive } = args;
267
307
  const parts: string[] = [];
268
308
 
269
309
  if (truncatedDirs.length > 0) {
270
310
  const first = truncatedDirs[0];
271
311
  if (first) {
272
312
  parts.push(
273
- `${truncatedDirs.length} ${truncatedDirs.length === 1 ? "directory was" : "directories were"} capped by items_per_dir; raise items_per_dir or call context_tree with path="${first.path}".`,
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}".`,
274
314
  );
275
315
  }
276
316
  }
@@ -279,7 +319,7 @@ function buildHint(args: {
279
319
  const first = depthLimitedDirs[0];
280
320
  if (first) {
281
321
  parts.push(
282
- `${depthLimitedDirs.length} ${depthLimitedDirs.length === 1 ? "directory was" : "directories were"} not expanded due to max_depth; raise max_depth or call context_tree with path="${first}".`,
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}".`,
283
323
  );
284
324
  }
285
325
  }