botholomew 0.8.10 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/chat/agent.ts +7 -3
- package/src/commands/context.ts +223 -373
- package/src/commands/tools.ts +100 -11
- package/src/context/describer.ts +3 -118
- package/src/context/drives.ts +110 -0
- package/src/context/fetcher.ts +11 -1
- package/src/context/ingest.ts +13 -10
- package/src/context/refresh.ts +39 -24
- package/src/context/url-utils.ts +0 -23
- package/src/db/context.ts +195 -119
- package/src/db/embeddings.ts +35 -16
- package/src/db/sql/13-drive-paths.sql +49 -0
- package/src/tools/context/list-drives.ts +36 -0
- package/src/tools/context/refresh.ts +41 -23
- package/src/tools/context/search.ts +8 -3
- package/src/tools/dir/create.ts +14 -11
- package/src/tools/dir/size.ts +3 -2
- package/src/tools/dir/tree.ts +57 -17
- package/src/tools/file/copy.ts +14 -8
- package/src/tools/file/count-lines.ts +6 -3
- package/src/tools/file/delete.ts +12 -5
- package/src/tools/file/edit.ts +5 -3
- package/src/tools/file/exists.ts +25 -3
- package/src/tools/file/info.ts +90 -18
- package/src/tools/file/move.ts +15 -16
- package/src/tools/file/read.ts +79 -5
- package/src/tools/file/write.ts +29 -12
- package/src/tools/registry.ts +2 -2
- package/src/tools/search/grep.ts +44 -11
- package/src/tools/search/semantic.ts +7 -3
- package/src/tui/components/ContextPanel.tsx +73 -35
- package/src/tui/markdown.ts +2 -3
- package/src/worker/prompt.ts +5 -2
- package/src/tools/dir/list.ts +0 -89
package/src/db/embeddings.ts
CHANGED
|
@@ -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,
|
|
66
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7
|
|
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<
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
14
|
+
ref: z
|
|
14
15
|
.string()
|
|
15
16
|
.optional()
|
|
16
17
|
.describe(
|
|
17
|
-
"
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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-
|
|
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.
|
|
76
|
+
if (!input.ref && !input.all) {
|
|
76
77
|
return {
|
|
77
78
|
...empty,
|
|
78
|
-
message: "Provide a `
|
|
79
|
+
message: "Provide a `ref` or set `all: true`.",
|
|
79
80
|
is_error: true,
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
|
-
if (input.
|
|
83
|
+
if (input.ref && input.all) {
|
|
83
84
|
return {
|
|
84
85
|
...empty,
|
|
85
|
-
message: "`
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
117
|
+
message: `No context items match \`${input.ref ?? "all"}\`.`,
|
|
106
118
|
is_error: true,
|
|
107
119
|
};
|
|
108
120
|
}
|
|
109
121
|
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/tools/dir/create.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
.
|
|
9
|
-
.
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
42
|
+
return { created: true, ref: formatDriveRef(target), is_error: false };
|
|
40
43
|
},
|
|
41
44
|
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/dir/size.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/tools/dir/tree.ts
CHANGED
|
@@ -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 =
|
|
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: `${
|
|
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:
|
|
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.
|
|
80
|
-
if (relative.length === 0) continue;
|
|
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[] = [
|
|
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
|
|
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
|
}
|