botholomew 0.8.4 → 0.8.6
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/commands/mcpx.ts +47 -146
- package/src/tools/context/refresh.ts +11 -0
- package/src/tools/dir/tree.ts +185 -171
- package/src/tools/file/write.ts +14 -1
package/package.json
CHANGED
package/src/commands/mcpx.ts
CHANGED
|
@@ -51,155 +51,64 @@ function getDir(program: Command): string {
|
|
|
51
51
|
return program.opts().dir;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Slice process.argv from the token after "mcpx" so flags (including --help)
|
|
55
|
+
// and positional args flow through to upstream mcpx verbatim.
|
|
56
|
+
function getRawMcpxArgs(): string[] {
|
|
57
|
+
const idx = process.argv.indexOf("mcpx");
|
|
58
|
+
return idx === -1 ? [] : process.argv.slice(idx + 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PASSTHROUGH_SUBCOMMANDS: ReadonlyArray<[name: string, desc: string]> = [
|
|
62
|
+
["servers", "List configured MCP server names"],
|
|
63
|
+
["info", "Show server overview or schema for a specific tool"],
|
|
64
|
+
["search", "Search tools by keyword and/or semantic similarity"],
|
|
65
|
+
["exec", "Execute a tool call"],
|
|
66
|
+
["add", "Add an MCP server"],
|
|
67
|
+
["remove", "Remove an MCP server"],
|
|
68
|
+
["ping", "Check connectivity to MCP servers"],
|
|
69
|
+
["auth", "Authenticate with an HTTP MCP server"],
|
|
70
|
+
["deauth", "Remove stored authentication for a server"],
|
|
71
|
+
["resource", "List resources for a server, or read a specific resource"],
|
|
72
|
+
["prompt", "List prompts for a server, or get a specific prompt"],
|
|
73
|
+
["task", "Manage async tool tasks (list, get, result, cancel)"],
|
|
74
|
+
["index", "Build the search index from all configured servers"],
|
|
75
|
+
];
|
|
76
|
+
|
|
54
77
|
export function registerMcpxCommand(program: Command) {
|
|
55
78
|
const mcpx = program
|
|
56
79
|
.command("mcpx")
|
|
57
80
|
.description("Manage MCP servers via MCPX");
|
|
58
81
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.command("info <first> [second]")
|
|
71
|
-
.description(
|
|
72
|
-
"Show server overview, or schema for a specific tool (server is optional if tool name is unambiguous)",
|
|
73
|
-
)
|
|
74
|
-
.action(async (first: string, second?: string) => {
|
|
75
|
-
const out = await runMcpx(getDir(program), ["info", first, second]);
|
|
76
|
-
process.stdout.write(out);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
// --- search ---
|
|
80
|
-
mcpx
|
|
81
|
-
.command("search <terms...>")
|
|
82
|
-
.description("Search tools by keyword and/or semantic similarity")
|
|
83
|
-
.action(async (terms: string[]) => {
|
|
84
|
-
const out = await runMcpx(getDir(program), ["search", ...terms]);
|
|
85
|
-
process.stdout.write(out);
|
|
86
|
-
});
|
|
82
|
+
for (const [name, description] of PASSTHROUGH_SUBCOMMANDS) {
|
|
83
|
+
mcpx
|
|
84
|
+
.command(name)
|
|
85
|
+
.description(description)
|
|
86
|
+
.allowUnknownOption(true)
|
|
87
|
+
.helpOption(false)
|
|
88
|
+
.argument("[args...]", "arguments forwarded to mcpx")
|
|
89
|
+
.action(async () => {
|
|
90
|
+
await runMcpx(getDir(program), getRawMcpxArgs(), { inherit: true });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
87
93
|
|
|
88
|
-
//
|
|
94
|
+
// Upstream mcpx's "list" is the default action when invoked with no
|
|
95
|
+
// subcommand — not a registered subcommand — so we strip the "list"
|
|
96
|
+
// token before forwarding.
|
|
89
97
|
mcpx
|
|
90
|
-
.command("
|
|
98
|
+
.command("list")
|
|
91
99
|
.description(
|
|
92
|
-
"
|
|
100
|
+
"List all tools, resources, and prompts across all configured servers",
|
|
93
101
|
)
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
process.stdout.write(out);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
// --- add ---
|
|
105
|
-
mcpx
|
|
106
|
-
.command("add <name>")
|
|
107
|
-
.description("Add an MCP server")
|
|
108
|
-
.option("--command <cmd>", "Stdio server command")
|
|
109
|
-
.option("--args <args...>", "Stdio server arguments")
|
|
110
|
-
.option("--url <url>", "HTTP server URL")
|
|
111
|
-
.option("--transport <type>", "HTTP transport: sse or streamable-http")
|
|
112
|
-
.option("--env <pairs...>", "Environment variables as KEY=VALUE pairs")
|
|
113
|
-
.action(
|
|
114
|
-
async (
|
|
115
|
-
name: string,
|
|
116
|
-
opts: {
|
|
117
|
-
command?: string;
|
|
118
|
-
args?: string[];
|
|
119
|
-
url?: string;
|
|
120
|
-
transport?: string;
|
|
121
|
-
env?: string[];
|
|
122
|
-
},
|
|
123
|
-
) => {
|
|
124
|
-
const cliArgs: string[] = ["add", name];
|
|
125
|
-
if (opts.command) cliArgs.push("--command", opts.command);
|
|
126
|
-
if (opts.args) {
|
|
127
|
-
for (const a of opts.args) cliArgs.push("--args", a);
|
|
128
|
-
}
|
|
129
|
-
if (opts.url) cliArgs.push("--url", opts.url);
|
|
130
|
-
if (opts.transport) cliArgs.push("--transport", opts.transport);
|
|
131
|
-
if (opts.env) {
|
|
132
|
-
for (const e of opts.env) cliArgs.push("--env", e);
|
|
133
|
-
}
|
|
134
|
-
const out = await runMcpx(getDir(program), cliArgs);
|
|
135
|
-
process.stdout.write(out);
|
|
136
|
-
},
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
// --- remove ---
|
|
140
|
-
mcpx
|
|
141
|
-
.command("remove <name>")
|
|
142
|
-
.description("Remove an MCP server")
|
|
143
|
-
.action(async (name: string) => {
|
|
144
|
-
const out = await runMcpx(getDir(program), ["remove", name]);
|
|
145
|
-
process.stdout.write(out);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// --- ping ---
|
|
149
|
-
mcpx
|
|
150
|
-
.command("ping [servers...]")
|
|
151
|
-
.description("Check connectivity to MCP servers")
|
|
152
|
-
.action(async (servers: string[]) => {
|
|
153
|
-
const out = await runMcpx(getDir(program), ["ping", ...servers]);
|
|
154
|
-
process.stdout.write(out);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// --- auth ---
|
|
158
|
-
mcpx
|
|
159
|
-
.command("auth <server>")
|
|
160
|
-
.description("Authenticate with an HTTP MCP server")
|
|
161
|
-
.action(async (server: string) => {
|
|
162
|
-
await runMcpx(getDir(program), ["auth", server], { inherit: true });
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// --- resource ---
|
|
166
|
-
mcpx
|
|
167
|
-
.command("resource [server] [uri]")
|
|
168
|
-
.description("List resources for a server, or read a specific resource")
|
|
169
|
-
.action(async (server?: string, uri?: string) => {
|
|
170
|
-
const out = await runMcpx(getDir(program), ["resource", server, uri]);
|
|
171
|
-
process.stdout.write(out);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// --- prompt ---
|
|
175
|
-
mcpx
|
|
176
|
-
.command("prompt [server] [name] [args]")
|
|
177
|
-
.description("List prompts for a server, or get a specific prompt")
|
|
178
|
-
.action(async (server?: string, name?: string, argsJson?: string) => {
|
|
179
|
-
const out = await runMcpx(getDir(program), [
|
|
180
|
-
"prompt",
|
|
181
|
-
server,
|
|
182
|
-
name,
|
|
183
|
-
argsJson,
|
|
184
|
-
]);
|
|
185
|
-
process.stdout.write(out);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// --- task ---
|
|
189
|
-
mcpx
|
|
190
|
-
.command("task <action> <server> [taskId]")
|
|
191
|
-
.description("Manage async tasks (actions: list, get, result, cancel)")
|
|
192
|
-
.action(async (action: string, server: string, taskId?: string) => {
|
|
193
|
-
const out = await runMcpx(getDir(program), [
|
|
194
|
-
"task",
|
|
195
|
-
action,
|
|
196
|
-
server,
|
|
197
|
-
taskId,
|
|
198
|
-
]);
|
|
199
|
-
process.stdout.write(out);
|
|
102
|
+
.allowUnknownOption(true)
|
|
103
|
+
.helpOption(false)
|
|
104
|
+
.argument("[args...]", "arguments forwarded to mcpx")
|
|
105
|
+
.action(async () => {
|
|
106
|
+
const raw = getRawMcpxArgs();
|
|
107
|
+
const args = raw[0] === "list" ? raw.slice(1) : raw;
|
|
108
|
+
await runMcpx(getDir(program), args, { inherit: true });
|
|
200
109
|
});
|
|
201
110
|
|
|
202
|
-
//
|
|
111
|
+
// Botholomew-specific: copy system-wide MCPX settings into this project.
|
|
203
112
|
mcpx
|
|
204
113
|
.command("import-global")
|
|
205
114
|
.description("Copy system-wide MCPX settings (~/.mcpx) into this project")
|
|
@@ -234,12 +143,4 @@ export function registerMcpxCommand(program: Command) {
|
|
|
234
143
|
);
|
|
235
144
|
}
|
|
236
145
|
});
|
|
237
|
-
|
|
238
|
-
// --- index ---
|
|
239
|
-
mcpx
|
|
240
|
-
.command("index")
|
|
241
|
-
.description("Build the search index from all configured servers")
|
|
242
|
-
.action(async () => {
|
|
243
|
-
await runMcpx(getDir(program), ["index"], { inherit: true });
|
|
244
|
-
});
|
|
245
146
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
listContextItemsByPrefix,
|
|
7
7
|
resolveContextItem,
|
|
8
8
|
} from "../../db/context.ts";
|
|
9
|
+
import { buildContextTree } from "../dir/tree.ts";
|
|
9
10
|
import type { ToolDefinition } from "../tool.ts";
|
|
10
11
|
|
|
11
12
|
const inputSchema = z.object({
|
|
@@ -43,6 +44,12 @@ const outputSchema = z.object({
|
|
|
43
44
|
),
|
|
44
45
|
message: z.string(),
|
|
45
46
|
is_error: z.boolean(),
|
|
47
|
+
tree: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
"Snapshot of the context filesystem after the refresh so you can see what's currently stored.",
|
|
52
|
+
),
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
const empty = {
|
|
@@ -54,6 +61,7 @@ const empty = {
|
|
|
54
61
|
chunks: 0,
|
|
55
62
|
embeddings_skipped: false,
|
|
56
63
|
items: [],
|
|
64
|
+
tree: undefined as string | undefined,
|
|
57
65
|
};
|
|
58
66
|
|
|
59
67
|
export const contextRefreshTool = {
|
|
@@ -127,10 +135,13 @@ export const contextRefreshTool = {
|
|
|
127
135
|
parts.push("embeddings skipped (no OpenAI API key configured)");
|
|
128
136
|
}
|
|
129
137
|
|
|
138
|
+
const { tree } = await buildContextTree(ctx.conn);
|
|
139
|
+
|
|
130
140
|
return {
|
|
131
141
|
...result,
|
|
132
142
|
message: parts.join(", "),
|
|
133
143
|
is_error: false,
|
|
144
|
+
tree,
|
|
134
145
|
};
|
|
135
146
|
},
|
|
136
147
|
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/dir/tree.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import type { DbConnection } from "../../db/connection.ts";
|
|
2
3
|
import {
|
|
3
4
|
countContextItemsByPrefix,
|
|
4
5
|
listContextItemsByPrefix,
|
|
@@ -9,6 +10,185 @@ const DEFAULT_MAX_DEPTH = 3;
|
|
|
9
10
|
const DEFAULT_ITEMS_PER_DIR = 15;
|
|
10
11
|
const HARD_FETCH_CAP = 1000;
|
|
11
12
|
|
|
13
|
+
export interface BuildContextTreeOptions {
|
|
14
|
+
path?: string;
|
|
15
|
+
maxDepth?: number;
|
|
16
|
+
itemsPerDir?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BuildContextTreeResult {
|
|
20
|
+
tree: string;
|
|
21
|
+
total_items: number;
|
|
22
|
+
truncated_dirs: Array<{ path: string; shown: number; total: number }>;
|
|
23
|
+
hint: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DirNode {
|
|
27
|
+
name: string;
|
|
28
|
+
fullPath: string;
|
|
29
|
+
isDir: true;
|
|
30
|
+
children: TreeEntry[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface FileNode {
|
|
34
|
+
name: string;
|
|
35
|
+
fullPath: string;
|
|
36
|
+
isDir: false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type TreeEntry = DirNode | FileNode;
|
|
40
|
+
|
|
41
|
+
export async function buildContextTree(
|
|
42
|
+
conn: DbConnection,
|
|
43
|
+
options: BuildContextTreeOptions = {},
|
|
44
|
+
): Promise<BuildContextTreeResult> {
|
|
45
|
+
const path = options.path ?? "/";
|
|
46
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
47
|
+
const itemsPerDir = options.itemsPerDir ?? DEFAULT_ITEMS_PER_DIR;
|
|
48
|
+
const normalizedPath = path.endsWith("/") ? path : `${path}/`;
|
|
49
|
+
|
|
50
|
+
const totalItems = await countContextItemsByPrefix(conn, path, {
|
|
51
|
+
recursive: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (totalItems === 0) {
|
|
55
|
+
return {
|
|
56
|
+
tree: `${path}\n (empty)`,
|
|
57
|
+
total_items: 0,
|
|
58
|
+
truncated_dirs: [],
|
|
59
|
+
hint: "Directory is empty.",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const items = await listContextItemsByPrefix(conn, path, {
|
|
64
|
+
recursive: true,
|
|
65
|
+
limit: HARD_FETCH_CAP,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Build tree structure: dirs map child name -> child node
|
|
69
|
+
const root: DirNode = {
|
|
70
|
+
name: path,
|
|
71
|
+
fullPath: path,
|
|
72
|
+
isDir: true,
|
|
73
|
+
children: [],
|
|
74
|
+
};
|
|
75
|
+
const dirIndex = new Map<string, DirNode>();
|
|
76
|
+
dirIndex.set(stripTrailingSlash(path), root);
|
|
77
|
+
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
const relative = item.context_path.slice(normalizedPath.length);
|
|
80
|
+
if (relative.length === 0) continue; // root itself, skip
|
|
81
|
+
const parts = relative.split("/").filter((p) => p.length > 0);
|
|
82
|
+
const isExplicitDir = item.mime_type === "inode/directory";
|
|
83
|
+
|
|
84
|
+
// Walk segments, creating intermediate directories as needed
|
|
85
|
+
let parentDir = root;
|
|
86
|
+
let currentRel = "";
|
|
87
|
+
for (let i = 0; i < parts.length; i++) {
|
|
88
|
+
const segment = parts[i];
|
|
89
|
+
if (!segment) continue;
|
|
90
|
+
currentRel = currentRel ? `${currentRel}/${segment}` : segment;
|
|
91
|
+
const fullPath = `${normalizedPath}${currentRel}`;
|
|
92
|
+
const isLeaf = i === parts.length - 1;
|
|
93
|
+
const isDirHere = !isLeaf || isExplicitDir;
|
|
94
|
+
|
|
95
|
+
if (isDirHere) {
|
|
96
|
+
const key = stripTrailingSlash(fullPath);
|
|
97
|
+
let dir = dirIndex.get(key);
|
|
98
|
+
if (!dir) {
|
|
99
|
+
dir = {
|
|
100
|
+
name: segment,
|
|
101
|
+
fullPath,
|
|
102
|
+
isDir: true,
|
|
103
|
+
children: [],
|
|
104
|
+
};
|
|
105
|
+
dirIndex.set(key, dir);
|
|
106
|
+
parentDir.children.push(dir);
|
|
107
|
+
}
|
|
108
|
+
parentDir = dir;
|
|
109
|
+
} else {
|
|
110
|
+
parentDir.children.push({
|
|
111
|
+
name: segment,
|
|
112
|
+
fullPath,
|
|
113
|
+
isDir: false,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Sort each directory's children: dirs first, then alphabetical
|
|
120
|
+
for (const dir of dirIndex.values()) {
|
|
121
|
+
dir.children.sort((a, b) => {
|
|
122
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
123
|
+
return a.name.localeCompare(b.name);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const truncatedDirs: Array<{
|
|
128
|
+
path: string;
|
|
129
|
+
shown: number;
|
|
130
|
+
total: number;
|
|
131
|
+
}> = [];
|
|
132
|
+
const depthLimitedDirs: string[] = [];
|
|
133
|
+
|
|
134
|
+
const lines: string[] = [path];
|
|
135
|
+
|
|
136
|
+
const render = (dir: DirNode, indent: string, currentDepth: number): void => {
|
|
137
|
+
const children = dir.children;
|
|
138
|
+
const total = children.length;
|
|
139
|
+
const shown = Math.min(total, itemsPerDir);
|
|
140
|
+
const visible = children.slice(0, shown);
|
|
141
|
+
const overflow = total - shown;
|
|
142
|
+
|
|
143
|
+
if (overflow > 0) {
|
|
144
|
+
truncatedDirs.push({
|
|
145
|
+
path: stripTrailingSlash(dir.fullPath),
|
|
146
|
+
shown,
|
|
147
|
+
total,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < visible.length; i++) {
|
|
152
|
+
const child = visible[i];
|
|
153
|
+
if (!child) continue;
|
|
154
|
+
const isLastVisible = i === visible.length - 1 && overflow === 0;
|
|
155
|
+
const connector = isLastVisible ? "└── " : "├── ";
|
|
156
|
+
const childIndent = isLastVisible ? " " : "│ ";
|
|
157
|
+
|
|
158
|
+
if (child.isDir) {
|
|
159
|
+
const atDepthLimit = currentDepth + 1 >= maxDepth;
|
|
160
|
+
if (atDepthLimit && child.children.length > 0) {
|
|
161
|
+
depthLimitedDirs.push(stripTrailingSlash(child.fullPath));
|
|
162
|
+
const subCount = countDescendants(child);
|
|
163
|
+
lines.push(
|
|
164
|
+
`${indent}${connector}${child.name}/ (${subCount} ${
|
|
165
|
+
subCount === 1 ? "item" : "items"
|
|
166
|
+
}, drill in)`,
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
lines.push(`${indent}${connector}${child.name}/`);
|
|
170
|
+
render(child, indent + childIndent, currentDepth + 1);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
lines.push(`${indent}${connector}${child.name}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (overflow > 0) {
|
|
178
|
+
lines.push(`${indent}└── ... (+${overflow} more)`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
render(root, "", 0);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
tree: lines.join("\n"),
|
|
186
|
+
total_items: totalItems,
|
|
187
|
+
truncated_dirs: truncatedDirs,
|
|
188
|
+
hint: buildHint({ truncatedDirs, depthLimitedDirs, totalItems }),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
12
192
|
const inputSchema = z.object({
|
|
13
193
|
path: z
|
|
14
194
|
.string()
|
|
@@ -48,21 +228,6 @@ const outputSchema = z.object({
|
|
|
48
228
|
hint: z.string(),
|
|
49
229
|
});
|
|
50
230
|
|
|
51
|
-
interface DirNode {
|
|
52
|
-
name: string;
|
|
53
|
-
fullPath: string;
|
|
54
|
-
isDir: true;
|
|
55
|
-
children: TreeEntry[];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
interface FileNode {
|
|
59
|
-
name: string;
|
|
60
|
-
fullPath: string;
|
|
61
|
-
isDir: false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
type TreeEntry = DirNode | FileNode;
|
|
65
|
-
|
|
66
231
|
export const contextTreeTool = {
|
|
67
232
|
name: "context_tree",
|
|
68
233
|
description:
|
|
@@ -71,163 +236,12 @@ export const contextTreeTool = {
|
|
|
71
236
|
inputSchema,
|
|
72
237
|
outputSchema,
|
|
73
238
|
execute: async (input, ctx) => {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const totalItems = await countContextItemsByPrefix(ctx.conn, path, {
|
|
80
|
-
recursive: true,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
if (totalItems === 0) {
|
|
84
|
-
return {
|
|
85
|
-
tree: `${path}\n (empty)`,
|
|
86
|
-
is_error: false,
|
|
87
|
-
total_items: 0,
|
|
88
|
-
truncated_dirs: [],
|
|
89
|
-
hint: "Directory is empty.",
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const items = await listContextItemsByPrefix(ctx.conn, path, {
|
|
94
|
-
recursive: true,
|
|
95
|
-
limit: HARD_FETCH_CAP,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// Build tree structure: dirs map child name -> child node
|
|
99
|
-
const root: DirNode = {
|
|
100
|
-
name: path,
|
|
101
|
-
fullPath: path,
|
|
102
|
-
isDir: true,
|
|
103
|
-
children: [],
|
|
104
|
-
};
|
|
105
|
-
const dirIndex = new Map<string, DirNode>();
|
|
106
|
-
dirIndex.set(stripTrailingSlash(path), root);
|
|
107
|
-
|
|
108
|
-
for (const item of items) {
|
|
109
|
-
const relative = item.context_path.slice(normalizedPath.length);
|
|
110
|
-
if (relative.length === 0) continue; // root itself, skip
|
|
111
|
-
const parts = relative.split("/").filter((p) => p.length > 0);
|
|
112
|
-
const isExplicitDir = item.mime_type === "inode/directory";
|
|
113
|
-
|
|
114
|
-
// Walk segments, creating intermediate directories as needed
|
|
115
|
-
let parentDir = root;
|
|
116
|
-
let currentRel = "";
|
|
117
|
-
for (let i = 0; i < parts.length; i++) {
|
|
118
|
-
const segment = parts[i];
|
|
119
|
-
if (!segment) continue;
|
|
120
|
-
currentRel = currentRel ? `${currentRel}/${segment}` : segment;
|
|
121
|
-
const fullPath = `${normalizedPath}${currentRel}`;
|
|
122
|
-
const isLeaf = i === parts.length - 1;
|
|
123
|
-
const isDirHere = !isLeaf || isExplicitDir;
|
|
124
|
-
|
|
125
|
-
if (isDirHere) {
|
|
126
|
-
const key = stripTrailingSlash(fullPath);
|
|
127
|
-
let dir = dirIndex.get(key);
|
|
128
|
-
if (!dir) {
|
|
129
|
-
dir = {
|
|
130
|
-
name: segment,
|
|
131
|
-
fullPath,
|
|
132
|
-
isDir: true,
|
|
133
|
-
children: [],
|
|
134
|
-
};
|
|
135
|
-
dirIndex.set(key, dir);
|
|
136
|
-
parentDir.children.push(dir);
|
|
137
|
-
}
|
|
138
|
-
parentDir = dir;
|
|
139
|
-
} else {
|
|
140
|
-
parentDir.children.push({
|
|
141
|
-
name: segment,
|
|
142
|
-
fullPath,
|
|
143
|
-
isDir: false,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Sort each directory's children: dirs first, then alphabetical
|
|
150
|
-
for (const dir of dirIndex.values()) {
|
|
151
|
-
dir.children.sort((a, b) => {
|
|
152
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
153
|
-
return a.name.localeCompare(b.name);
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const truncatedDirs: Array<{
|
|
158
|
-
path: string;
|
|
159
|
-
shown: number;
|
|
160
|
-
total: number;
|
|
161
|
-
}> = [];
|
|
162
|
-
const depthLimitedDirs: string[] = [];
|
|
163
|
-
|
|
164
|
-
const lines: string[] = [path];
|
|
165
|
-
|
|
166
|
-
const render = (
|
|
167
|
-
dir: DirNode,
|
|
168
|
-
indent: string,
|
|
169
|
-
currentDepth: number,
|
|
170
|
-
): void => {
|
|
171
|
-
const children = dir.children;
|
|
172
|
-
const total = children.length;
|
|
173
|
-
const shown = Math.min(total, itemsPerDir);
|
|
174
|
-
const visible = children.slice(0, shown);
|
|
175
|
-
const overflow = total - shown;
|
|
176
|
-
|
|
177
|
-
if (overflow > 0) {
|
|
178
|
-
truncatedDirs.push({
|
|
179
|
-
path: stripTrailingSlash(dir.fullPath),
|
|
180
|
-
shown,
|
|
181
|
-
total,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
for (let i = 0; i < visible.length; i++) {
|
|
186
|
-
const child = visible[i];
|
|
187
|
-
if (!child) continue;
|
|
188
|
-
const isLastVisible = i === visible.length - 1 && overflow === 0;
|
|
189
|
-
const connector = isLastVisible ? "└── " : "├── ";
|
|
190
|
-
const childIndent = isLastVisible ? " " : "│ ";
|
|
191
|
-
|
|
192
|
-
if (child.isDir) {
|
|
193
|
-
const atDepthLimit = currentDepth + 1 >= maxDepth;
|
|
194
|
-
if (atDepthLimit && child.children.length > 0) {
|
|
195
|
-
depthLimitedDirs.push(stripTrailingSlash(child.fullPath));
|
|
196
|
-
const subCount = countDescendants(child);
|
|
197
|
-
lines.push(
|
|
198
|
-
`${indent}${connector}${child.name}/ (${subCount} ${
|
|
199
|
-
subCount === 1 ? "item" : "items"
|
|
200
|
-
}, drill in)`,
|
|
201
|
-
);
|
|
202
|
-
} else {
|
|
203
|
-
lines.push(`${indent}${connector}${child.name}/`);
|
|
204
|
-
render(child, indent + childIndent, currentDepth + 1);
|
|
205
|
-
}
|
|
206
|
-
} else {
|
|
207
|
-
lines.push(`${indent}${connector}${child.name}`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (overflow > 0) {
|
|
212
|
-
lines.push(`${indent}└── ... (+${overflow} more)`);
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
render(root, "", 0);
|
|
217
|
-
|
|
218
|
-
const hint = buildHint({
|
|
219
|
-
truncatedDirs,
|
|
220
|
-
depthLimitedDirs,
|
|
221
|
-
totalItems,
|
|
239
|
+
const result = await buildContextTree(ctx.conn, {
|
|
240
|
+
path: input.path,
|
|
241
|
+
maxDepth: input.max_depth,
|
|
242
|
+
itemsPerDir: input.items_per_dir,
|
|
222
243
|
});
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
tree: lines.join("\n"),
|
|
226
|
-
is_error: false,
|
|
227
|
-
total_items: totalItems,
|
|
228
|
-
truncated_dirs: truncatedDirs,
|
|
229
|
-
hint,
|
|
230
|
-
};
|
|
244
|
+
return { ...result, is_error: false };
|
|
231
245
|
},
|
|
232
246
|
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
233
247
|
|
package/src/tools/file/write.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
PathConflictError,
|
|
7
7
|
upsertContextItem,
|
|
8
8
|
} from "../../db/context.ts";
|
|
9
|
+
import { buildContextTree } from "../dir/tree.ts";
|
|
9
10
|
import type { ToolDefinition } from "../tool.ts";
|
|
10
11
|
|
|
11
12
|
function mimeFromPath(path: string): string {
|
|
@@ -49,6 +50,12 @@ const outputSchema = z.object({
|
|
|
49
50
|
error_type: z.string().optional(),
|
|
50
51
|
message: z.string().optional(),
|
|
51
52
|
next_action_hint: z.string().optional(),
|
|
53
|
+
tree: z
|
|
54
|
+
.string()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe(
|
|
57
|
+
"Snapshot of the context filesystem after the write so you can see the surrounding files.",
|
|
58
|
+
),
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
export const contextWriteTool = {
|
|
@@ -86,7 +93,13 @@ export const contextWriteTool = {
|
|
|
86
93
|
});
|
|
87
94
|
|
|
88
95
|
await ingestByPath(ctx.conn, input.path, ctx.config);
|
|
89
|
-
|
|
96
|
+
const { tree } = await buildContextTree(ctx.conn);
|
|
97
|
+
return {
|
|
98
|
+
id: item.id,
|
|
99
|
+
path: item.context_path,
|
|
100
|
+
is_error: false,
|
|
101
|
+
tree,
|
|
102
|
+
};
|
|
90
103
|
} catch (err) {
|
|
91
104
|
if (err instanceof PathConflictError) {
|
|
92
105
|
return {
|