botholomew 0.6.1 → 0.6.2
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 +1 -1
- package/src/chat/session.ts +5 -0
- package/src/cli.ts +2 -2
- package/src/commands/context.ts +31 -47
- package/src/commands/skill.ts +100 -0
- package/src/commands/tools.ts +78 -42
- package/src/constants.ts +5 -0
- package/src/db/context.ts +49 -2
- package/src/db/uuid.ts +7 -0
- package/src/init/index.ts +14 -1
- package/src/init/templates.ts +23 -0
- package/src/skills/commands.ts +61 -0
- package/src/skills/loader.ts +36 -0
- package/src/skills/parser.ts +95 -0
- package/src/tools/context/search.ts +3 -3
- package/src/tools/dir/create.ts +4 -4
- package/src/tools/dir/list.ts +4 -4
- package/src/tools/dir/size.ts +4 -4
- package/src/tools/dir/tree.ts +234 -51
- package/src/tools/file/copy.ts +4 -4
- package/src/tools/file/count-lines.ts +7 -8
- package/src/tools/file/delete.ts +4 -4
- package/src/tools/file/edit.ts +4 -4
- package/src/tools/file/exists.ts +8 -8
- package/src/tools/file/info.ts +8 -8
- package/src/tools/file/move.ts +4 -4
- package/src/tools/file/read.ts +7 -8
- package/src/tools/file/write.ts +4 -4
- package/src/tools/registry.ts +35 -38
- package/src/tui/App.tsx +63 -4
- package/src/tui/components/InputBar.tsx +39 -1
package/src/init/templates.ts
CHANGED
|
@@ -37,6 +37,29 @@ agent-modification: true
|
|
|
37
37
|
- Get set up and ready to help.
|
|
38
38
|
`;
|
|
39
39
|
|
|
40
|
+
export const SUMMARIZE_SKILL = `---
|
|
41
|
+
name: summarize
|
|
42
|
+
description: "Summarize the current conversation"
|
|
43
|
+
arguments: []
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
Summarize this conversation so far. Provide a concise bullet-point summary
|
|
47
|
+
of what we discussed, any decisions made, and any open action items.
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
export const STANDUP_SKILL = `---
|
|
51
|
+
name: standup
|
|
52
|
+
description: "Generate a standup update from recent tasks"
|
|
53
|
+
arguments: []
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
Generate a standup update. Look at recent tasks (completed in the last 24 hours
|
|
57
|
+
and currently in progress) and format a brief standup-style update with:
|
|
58
|
+
- What was done (completed tasks)
|
|
59
|
+
- What's in progress
|
|
60
|
+
- Any blockers or waiting items
|
|
61
|
+
`;
|
|
62
|
+
|
|
40
63
|
export const DEFAULT_CONFIG = {
|
|
41
64
|
anthropic_api_key: "your-api-key-here",
|
|
42
65
|
model: "claude-opus-4-20250514",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { SkillDefinition } from "./parser.ts";
|
|
2
|
+
import { renderSkill } from "./parser.ts";
|
|
3
|
+
|
|
4
|
+
export interface SlashCommandContext {
|
|
5
|
+
skills: Map<string, SkillDefinition>;
|
|
6
|
+
addSystemMessage: (content: string) => void;
|
|
7
|
+
queueUserMessage: (content: string) => void;
|
|
8
|
+
exit: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handle a slash-command input. Returns true if the command was consumed
|
|
13
|
+
* (recognized or errored), false if it should fall through.
|
|
14
|
+
*/
|
|
15
|
+
export function handleSlashCommand(
|
|
16
|
+
input: string,
|
|
17
|
+
ctx: SlashCommandContext,
|
|
18
|
+
): boolean {
|
|
19
|
+
const spaceIdx = input.indexOf(" ");
|
|
20
|
+
const commandPart = spaceIdx === -1 ? input : input.slice(0, spaceIdx);
|
|
21
|
+
const rawArgs = spaceIdx === -1 ? "" : input.slice(spaceIdx + 1).trim();
|
|
22
|
+
const name = commandPart.slice(1).toLowerCase(); // remove leading /
|
|
23
|
+
|
|
24
|
+
// Built-in commands
|
|
25
|
+
if (name === "quit" || name === "exit") {
|
|
26
|
+
ctx.exit();
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (name === "skills") {
|
|
31
|
+
if (ctx.skills.size === 0) {
|
|
32
|
+
ctx.addSystemMessage(
|
|
33
|
+
"No skills loaded. Add .md files to .botholomew/skills/",
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
const lines = ["Available skills:"];
|
|
37
|
+
for (const [skillName, skill] of ctx.skills) {
|
|
38
|
+
lines.push(
|
|
39
|
+
` /${skillName.padEnd(16)} ${skill.description || "(no description)"}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
ctx.addSystemMessage(lines.join("\n"));
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Skill dispatch
|
|
48
|
+
const skill = ctx.skills.get(name);
|
|
49
|
+
if (skill) {
|
|
50
|
+
const rendered = renderSkill(skill, rawArgs);
|
|
51
|
+
ctx.addSystemMessage(`Running skill: ${skill.name}`);
|
|
52
|
+
ctx.queueUserMessage(rendered);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Unknown command
|
|
57
|
+
ctx.addSystemMessage(
|
|
58
|
+
`Unknown command: /${name}. Type /skills to see available commands.`,
|
|
59
|
+
);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getSkillsDir } from "../constants.ts";
|
|
4
|
+
import { parseSkillFile, type SkillDefinition } from "./parser.ts";
|
|
5
|
+
|
|
6
|
+
export async function loadSkills(
|
|
7
|
+
projectDir: string,
|
|
8
|
+
): Promise<Map<string, SkillDefinition>> {
|
|
9
|
+
const skills = new Map<string, SkillDefinition>();
|
|
10
|
+
const dir = getSkillsDir(projectDir);
|
|
11
|
+
|
|
12
|
+
let entries: string[];
|
|
13
|
+
try {
|
|
14
|
+
entries = await readdir(dir);
|
|
15
|
+
} catch {
|
|
16
|
+
return skills; // directory doesn't exist — graceful for pre-M7 projects
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (!entry.endsWith(".md")) continue;
|
|
21
|
+
const filePath = join(dir, entry);
|
|
22
|
+
const raw = await Bun.file(filePath).text();
|
|
23
|
+
const skill = parseSkillFile(raw, filePath);
|
|
24
|
+
skills.set(skill.name, skill);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return skills;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getSkill(
|
|
31
|
+
projectDir: string,
|
|
32
|
+
name: string,
|
|
33
|
+
): Promise<SkillDefinition | null> {
|
|
34
|
+
const skills = await loadSkills(projectDir);
|
|
35
|
+
return skills.get(name.toLowerCase()) ?? null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import matter from "gray-matter";
|
|
3
|
+
|
|
4
|
+
export interface SkillArgDef {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
required: boolean;
|
|
8
|
+
default?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SkillDefinition {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
arguments: SkillArgDef[];
|
|
15
|
+
body: string;
|
|
16
|
+
filePath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseSkillFile(raw: string, filePath: string): SkillDefinition {
|
|
20
|
+
const { data, content } = matter(raw);
|
|
21
|
+
|
|
22
|
+
const name: string =
|
|
23
|
+
typeof data.name === "string" && data.name
|
|
24
|
+
? data.name.toLowerCase()
|
|
25
|
+
: basename(filePath, ".md").toLowerCase();
|
|
26
|
+
|
|
27
|
+
const description: string =
|
|
28
|
+
typeof data.description === "string" ? data.description : "";
|
|
29
|
+
|
|
30
|
+
const args: SkillArgDef[] = [];
|
|
31
|
+
if (Array.isArray(data.arguments)) {
|
|
32
|
+
for (const arg of data.arguments) {
|
|
33
|
+
if (arg && typeof arg === "object" && typeof arg.name === "string") {
|
|
34
|
+
args.push({
|
|
35
|
+
name: arg.name,
|
|
36
|
+
description:
|
|
37
|
+
typeof arg.description === "string" ? arg.description : "",
|
|
38
|
+
required: arg.required === true,
|
|
39
|
+
default: typeof arg.default === "string" ? arg.default : undefined,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
description,
|
|
48
|
+
arguments: args,
|
|
49
|
+
body: content.trim(),
|
|
50
|
+
filePath,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Split a raw argument string into positional tokens,
|
|
56
|
+
* respecting double-quoted strings.
|
|
57
|
+
*/
|
|
58
|
+
function tokenize(raw: string): string[] {
|
|
59
|
+
const tokens: string[] = [];
|
|
60
|
+
let current = "";
|
|
61
|
+
let inQuote = false;
|
|
62
|
+
|
|
63
|
+
for (const ch of raw) {
|
|
64
|
+
if (ch === '"') {
|
|
65
|
+
inQuote = !inQuote;
|
|
66
|
+
} else if (!inQuote && /\s/.test(ch)) {
|
|
67
|
+
if (current) {
|
|
68
|
+
tokens.push(current);
|
|
69
|
+
current = "";
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
current += ch;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (current) tokens.push(current);
|
|
77
|
+
return tokens;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function renderSkill(skill: SkillDefinition, rawArgs: string): string {
|
|
81
|
+
const tokens = tokenize(rawArgs);
|
|
82
|
+
let result = skill.body;
|
|
83
|
+
|
|
84
|
+
result = result.replaceAll("$ARGUMENTS", rawArgs);
|
|
85
|
+
|
|
86
|
+
// Replace $1-$9 with positional args or defaults
|
|
87
|
+
for (let i = 1; i <= 9; i++) {
|
|
88
|
+
const token = tokens[i - 1];
|
|
89
|
+
const argDef = skill.arguments[i - 1];
|
|
90
|
+
const value = token ?? argDef?.default ?? "";
|
|
91
|
+
result = result.replaceAll(`$${i}`, value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
@@ -22,9 +22,9 @@ const outputSchema = z.object({
|
|
|
22
22
|
is_error: z.boolean(),
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
export const
|
|
26
|
-
name: "
|
|
27
|
-
description: "Search
|
|
25
|
+
export const contextSearchTool = {
|
|
26
|
+
name: "context_search",
|
|
27
|
+
description: "Search context by keyword.",
|
|
28
28
|
group: "context",
|
|
29
29
|
inputSchema,
|
|
30
30
|
outputSchema,
|
package/src/tools/dir/create.ts
CHANGED
|
@@ -16,10 +16,10 @@ const outputSchema = z.object({
|
|
|
16
16
|
is_error: z.boolean(),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
export const
|
|
20
|
-
name: "
|
|
21
|
-
description: "Create a directory in
|
|
22
|
-
group: "
|
|
19
|
+
export const contextCreateDirTool = {
|
|
20
|
+
name: "context_create_dir",
|
|
21
|
+
description: "Create a directory in context.",
|
|
22
|
+
group: "context",
|
|
23
23
|
inputSchema,
|
|
24
24
|
outputSchema,
|
|
25
25
|
execute: async (input, ctx) => {
|
package/src/tools/dir/list.ts
CHANGED
|
@@ -36,10 +36,10 @@ const outputSchema = z.object({
|
|
|
36
36
|
is_error: z.boolean(),
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
export const
|
|
40
|
-
name: "
|
|
41
|
-
description: "List directory contents in
|
|
42
|
-
group: "
|
|
39
|
+
export const contextListDirTool = {
|
|
40
|
+
name: "context_list_dir",
|
|
41
|
+
description: "List directory contents in context.",
|
|
42
|
+
group: "context",
|
|
43
43
|
inputSchema,
|
|
44
44
|
outputSchema,
|
|
45
45
|
execute: async (input, ctx) => {
|
package/src/tools/dir/size.ts
CHANGED
|
@@ -24,10 +24,10 @@ const outputSchema = z.object({
|
|
|
24
24
|
is_error: z.boolean(),
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
export const
|
|
28
|
-
name: "
|
|
29
|
-
description: "Get the total size of
|
|
30
|
-
group: "
|
|
27
|
+
export const contextDirSizeTool = {
|
|
28
|
+
name: "context_dir_size",
|
|
29
|
+
description: "Get the total size of context items in a directory.",
|
|
30
|
+
group: "context",
|
|
31
31
|
inputSchema,
|
|
32
32
|
outputSchema,
|
|
33
33
|
execute: async (input, ctx) => {
|
package/src/tools/dir/tree.ts
CHANGED
|
@@ -1,92 +1,275 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
countContextItemsByPrefix,
|
|
4
|
+
listContextItemsByPrefix,
|
|
5
|
+
} from "../../db/context.ts";
|
|
3
6
|
import type { ToolDefinition } from "../tool.ts";
|
|
4
7
|
|
|
5
|
-
const
|
|
8
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
9
|
+
const DEFAULT_ITEMS_PER_DIR = 15;
|
|
10
|
+
const HARD_FETCH_CAP = 1000;
|
|
6
11
|
|
|
7
12
|
const inputSchema = z.object({
|
|
8
13
|
path: z
|
|
9
14
|
.string()
|
|
10
15
|
.optional()
|
|
11
16
|
.describe("Root path for the tree (defaults to /)"),
|
|
12
|
-
|
|
17
|
+
max_depth: z
|
|
13
18
|
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.positive()
|
|
14
21
|
.optional()
|
|
15
|
-
.default(
|
|
22
|
+
.default(DEFAULT_MAX_DEPTH)
|
|
16
23
|
.describe(
|
|
17
|
-
`Maximum
|
|
24
|
+
`Maximum depth of directories to render (defaults to ${DEFAULT_MAX_DEPTH}). Use a deeper path to drill in.`,
|
|
18
25
|
),
|
|
26
|
+
items_per_dir: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.positive()
|
|
30
|
+
.optional()
|
|
31
|
+
.default(DEFAULT_ITEMS_PER_DIR)
|
|
32
|
+
.describe(
|
|
33
|
+
`Maximum entries shown per directory (defaults to ${DEFAULT_ITEMS_PER_DIR}). Overflow shown as "(+N more)".`,
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const TruncatedDirSchema = z.object({
|
|
38
|
+
path: z.string(),
|
|
39
|
+
shown: z.number(),
|
|
40
|
+
total: z.number(),
|
|
19
41
|
});
|
|
20
42
|
|
|
21
43
|
const outputSchema = z.object({
|
|
22
44
|
tree: z.string(),
|
|
23
45
|
is_error: z.boolean(),
|
|
46
|
+
total_items: z.number(),
|
|
47
|
+
truncated_dirs: z.array(TruncatedDirSchema),
|
|
48
|
+
hint: z.string(),
|
|
24
49
|
});
|
|
25
50
|
|
|
26
|
-
|
|
27
|
-
name:
|
|
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
|
+
export const contextTreeTool = {
|
|
67
|
+
name: "context_tree",
|
|
28
68
|
description:
|
|
29
|
-
"Render a directory as a markdown-style tree in
|
|
30
|
-
group: "
|
|
69
|
+
"Render a directory as a markdown-style tree. Use max_depth and items_per_dir to bound output; drill in by passing a deeper path.",
|
|
70
|
+
group: "context",
|
|
31
71
|
inputSchema,
|
|
32
72
|
outputSchema,
|
|
33
73
|
execute: async (input, ctx) => {
|
|
34
74
|
const path = input.path ?? "/";
|
|
35
|
-
const
|
|
36
|
-
const
|
|
75
|
+
const maxDepth = input.max_depth ?? DEFAULT_MAX_DEPTH;
|
|
76
|
+
const itemsPerDir = input.items_per_dir ?? DEFAULT_ITEMS_PER_DIR;
|
|
77
|
+
const normalizedPath = path.endsWith("/") ? path : `${path}/`;
|
|
78
|
+
|
|
79
|
+
const totalItems = await countContextItemsByPrefix(ctx.conn, path, {
|
|
37
80
|
recursive: true,
|
|
38
|
-
limit: maxItems,
|
|
39
81
|
});
|
|
40
82
|
|
|
41
|
-
if (
|
|
42
|
-
return {
|
|
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
|
+
};
|
|
43
91
|
}
|
|
44
92
|
|
|
45
|
-
const
|
|
93
|
+
const items = await listContextItemsByPrefix(ctx.conn, path, {
|
|
94
|
+
recursive: true,
|
|
95
|
+
limit: HARD_FETCH_CAP,
|
|
96
|
+
});
|
|
46
97
|
|
|
47
|
-
// Build tree structure
|
|
48
|
-
const
|
|
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";
|
|
49
113
|
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
52
124
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
|
60
146
|
}
|
|
61
147
|
}
|
|
62
148
|
|
|
63
|
-
//
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
})),
|
|
70
|
-
].sort((a, b) => a.path.localeCompare(b.path));
|
|
71
|
-
|
|
72
|
-
for (let i = 0; i < allEntries.length; i++) {
|
|
73
|
-
const entry = allEntries[i];
|
|
74
|
-
if (!entry) continue;
|
|
75
|
-
const depth = entry.path.split("/").length - 1;
|
|
76
|
-
const isLast =
|
|
77
|
-
i === allEntries.length - 1 ||
|
|
78
|
-
(allEntries[i + 1]?.path.split("/").length ?? 0) - 1 <= depth;
|
|
79
|
-
const prefix = isLast ? "└── " : "├── ";
|
|
80
|
-
const indent = "│ ".repeat(depth);
|
|
81
|
-
const name = entry.path.split("/").pop() ?? "";
|
|
82
|
-
const suffix = entry.isDir ? "/" : "";
|
|
83
|
-
lines.push(`${indent}${prefix}${name}${suffix}`);
|
|
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
|
+
});
|
|
84
155
|
}
|
|
85
156
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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);
|
|
89
217
|
|
|
90
|
-
|
|
218
|
+
const hint = buildHint({
|
|
219
|
+
truncatedDirs,
|
|
220
|
+
depthLimitedDirs,
|
|
221
|
+
totalItems,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
tree: lines.join("\n"),
|
|
226
|
+
is_error: false,
|
|
227
|
+
total_items: totalItems,
|
|
228
|
+
truncated_dirs: truncatedDirs,
|
|
229
|
+
hint,
|
|
230
|
+
};
|
|
91
231
|
},
|
|
92
232
|
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
233
|
+
|
|
234
|
+
function stripTrailingSlash(p: string): string {
|
|
235
|
+
return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function countDescendants(dir: DirNode): number {
|
|
239
|
+
let count = 0;
|
|
240
|
+
for (const child of dir.children) {
|
|
241
|
+
count += 1;
|
|
242
|
+
if (child.isDir) count += countDescendants(child);
|
|
243
|
+
}
|
|
244
|
+
return count;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildHint(args: {
|
|
248
|
+
truncatedDirs: Array<{ path: string; shown: number; total: number }>;
|
|
249
|
+
depthLimitedDirs: string[];
|
|
250
|
+
totalItems: number;
|
|
251
|
+
}): string {
|
|
252
|
+
const { truncatedDirs, depthLimitedDirs } = args;
|
|
253
|
+
const parts: string[] = [];
|
|
254
|
+
|
|
255
|
+
if (truncatedDirs.length > 0) {
|
|
256
|
+
const first = truncatedDirs[0];
|
|
257
|
+
if (first) {
|
|
258
|
+
parts.push(
|
|
259
|
+
`${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}".`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (depthLimitedDirs.length > 0) {
|
|
265
|
+
const first = depthLimitedDirs[0];
|
|
266
|
+
if (first) {
|
|
267
|
+
parts.push(
|
|
268
|
+
`${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}".`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (parts.length === 0) return "Tree is complete.";
|
|
274
|
+
return parts.join(" ");
|
|
275
|
+
}
|
package/src/tools/file/copy.ts
CHANGED
|
@@ -18,10 +18,10 @@ const outputSchema = z.object({
|
|
|
18
18
|
is_error: z.boolean(),
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
export const
|
|
22
|
-
name: "
|
|
23
|
-
description: "Copy a
|
|
24
|
-
group: "
|
|
21
|
+
export const contextCopyTool = {
|
|
22
|
+
name: "context_copy",
|
|
23
|
+
description: "Copy a context item.",
|
|
24
|
+
group: "context",
|
|
25
25
|
inputSchema,
|
|
26
26
|
outputSchema,
|
|
27
27
|
execute: async (input, ctx) => {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveContextItemOrThrow } from "../../db/context.ts";
|
|
3
3
|
import type { ToolDefinition } from "../tool.ts";
|
|
4
4
|
|
|
5
5
|
const inputSchema = z.object({
|
|
6
|
-
path: z.string().describe("File path"),
|
|
6
|
+
path: z.string().describe("File path or context item ID"),
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
const outputSchema = z.object({
|
|
@@ -11,15 +11,14 @@ const outputSchema = z.object({
|
|
|
11
11
|
is_error: z.boolean(),
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
export const
|
|
15
|
-
name: "
|
|
16
|
-
description: "Count the number of lines in a text
|
|
17
|
-
group: "
|
|
14
|
+
export const contextCountLinesTool = {
|
|
15
|
+
name: "context_count_lines",
|
|
16
|
+
description: "Count the number of lines in a text context item.",
|
|
17
|
+
group: "context",
|
|
18
18
|
inputSchema,
|
|
19
19
|
outputSchema,
|
|
20
20
|
execute: async (input, ctx) => {
|
|
21
|
-
const item = await
|
|
22
|
-
if (!item) throw new Error(`Not found: ${input.path}`);
|
|
21
|
+
const item = await resolveContextItemOrThrow(ctx.conn, input.path);
|
|
23
22
|
if (item.content == null) throw new Error(`No text content: ${input.path}`);
|
|
24
23
|
|
|
25
24
|
return { lines: item.content.split("\n").length, is_error: false };
|
package/src/tools/file/delete.ts
CHANGED
|
@@ -22,10 +22,10 @@ const outputSchema = z.object({
|
|
|
22
22
|
is_error: z.boolean(),
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
export const
|
|
26
|
-
name: "
|
|
27
|
-
description: "Delete a
|
|
28
|
-
group: "
|
|
25
|
+
export const contextDeleteTool = {
|
|
26
|
+
name: "context_delete",
|
|
27
|
+
description: "Delete a context item or directory.",
|
|
28
|
+
group: "context",
|
|
29
29
|
inputSchema,
|
|
30
30
|
outputSchema,
|
|
31
31
|
execute: async (input, ctx) => {
|