botholomew 0.8.3 → 0.8.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/README.md +23 -12
- package/package.json +2 -2
- package/src/config/loader.ts +3 -0
- package/src/config/schemas.ts +2 -0
- 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/src/utils/logger.ts +47 -3
package/README.md
CHANGED
|
@@ -8,15 +8,17 @@
|
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
**
|
|
11
|
+
**An AI agent for knowledge work.** Botholomew is an autonomous agent
|
|
12
12
|
that works its way through a task queue — reading email, summarizing
|
|
13
13
|
documents, researching topics, organizing notes, and maintaining context
|
|
14
14
|
over time — while you sleep, work, or chat with it.
|
|
15
15
|
|
|
16
|
-
Unlike coding agents, Botholomew has **no shell
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
Unlike coding agents, Botholomew has **no shell and no direct access to
|
|
17
|
+
your filesystem**. It can't edit files on disk — instead, it ingests local
|
|
18
|
+
files, folders, and URLs into a DuckDB-backed context store that it can
|
|
19
|
+
read, search, and summarize. External capabilities (email, Slack, the web,
|
|
20
|
+
and hundreds of other services) are granted deliberately, per project,
|
|
21
|
+
through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
|
@@ -27,19 +29,19 @@ is granted deliberately, per project, through MCP servers.
|
|
|
27
29
|
long-running `--persist` worker, or point cron at `botholomew worker run`.
|
|
28
30
|
- **Portable.** Each project is a `.botholomew/` directory — markdown +
|
|
29
31
|
DuckDB. Copy it, share it, check it in (or `.gitignore` it).
|
|
30
|
-
- **
|
|
31
|
-
|
|
32
|
-
and OpenAI
|
|
32
|
+
- **Your data, your disk.** Project state — tasks, threads, ingested
|
|
33
|
+
context, embeddings — lives in `.botholomew/`, indexed in DuckDB with
|
|
34
|
+
HNSW for vector search. Model calls go direct to Anthropic and OpenAI;
|
|
35
|
+
any further reach is scoped to the MCP servers you add.
|
|
33
36
|
- **Extensible.** External tools come from MCP servers via
|
|
34
37
|
[MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
|
|
35
38
|
Slack, GitHub) or connect through an MCP gateway like
|
|
36
39
|
[Arcade.dev](https://www.arcade.dev/) to reach hundreds of
|
|
37
40
|
authenticated services without managing each server yourself.
|
|
38
41
|
Reusable workflows are defined as markdown "skills" (slash commands).
|
|
39
|
-
- **Safe by default.** The agent has no shell
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
explicitly add.
|
|
42
|
+
- **Safe by default.** The agent has no shell and no direct filesystem
|
|
43
|
+
access. Out of the box, everything it can touch lives in `.botholomew/`;
|
|
44
|
+
every external capability is a MCP server you explicitly add.
|
|
43
45
|
- **Concurrent.** Many workers can run at once. Each registers itself in
|
|
44
46
|
the DB and heartbeats; crashed workers get reaped and their tasks go
|
|
45
47
|
back into the queue automatically.
|
|
@@ -49,6 +51,15 @@ is granted deliberately, per project, through MCP servers.
|
|
|
49
51
|
|
|
50
52
|
---
|
|
51
53
|
|
|
54
|
+
## Demo
|
|
55
|
+
|
|
56
|
+
A full tour of the chat TUI — every tab, slash-command autocomplete,
|
|
57
|
+
the message queue, tool-call visualization, and the live workers panel:
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
52
63
|
## Install
|
|
53
64
|
|
|
54
65
|
Requires [Bun](https://bun.sh) 1.1+.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.5",
|
|
4
|
+
"description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"botholomew": "./src/cli.ts"
|
package/src/config/loader.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getConfigPath } from "../constants.ts";
|
|
2
|
+
import { setLogLevel } from "../utils/logger.ts";
|
|
2
3
|
import { type BotholomewConfig, DEFAULT_CONFIG } from "./schemas.ts";
|
|
3
4
|
|
|
4
5
|
export async function loadConfig(
|
|
@@ -22,6 +23,8 @@ export async function loadConfig(
|
|
|
22
23
|
config.openai_api_key = process.env.OPENAI_API_KEY;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
setLogLevel(config.log_level);
|
|
27
|
+
|
|
25
28
|
return config;
|
|
26
29
|
}
|
|
27
30
|
|
package/src/config/schemas.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface BotholomewConfig {
|
|
|
15
15
|
worker_stopped_retention_seconds?: number;
|
|
16
16
|
schedule_min_interval_seconds?: number;
|
|
17
17
|
schedule_claim_stale_seconds?: number;
|
|
18
|
+
log_level?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
|
|
@@ -34,4 +35,5 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
|
|
|
34
35
|
worker_stopped_retention_seconds: 3600,
|
|
35
36
|
schedule_min_interval_seconds: 60,
|
|
36
37
|
schedule_claim_stale_seconds: 300,
|
|
38
|
+
log_level: "",
|
|
37
39
|
};
|
|
@@ -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 {
|
package/src/utils/logger.ts
CHANGED
|
@@ -4,34 +4,78 @@ function ts(): string {
|
|
|
4
4
|
return ansis.gray(new Date().toTimeString().slice(0, 8));
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
const LEVELS = {
|
|
8
|
+
silent: 0,
|
|
9
|
+
error: 1,
|
|
10
|
+
warn: 2,
|
|
11
|
+
info: 3,
|
|
12
|
+
debug: 4,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
type LogLevel = keyof typeof LEVELS;
|
|
16
|
+
|
|
17
|
+
function parseLevel(raw: string | undefined): number | undefined {
|
|
18
|
+
const key = raw?.toLowerCase();
|
|
19
|
+
if (key && key in LEVELS) return LEVELS[key as LogLevel];
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const envPinned = parseLevel(process.env.BOTHOLOMEW_LOG_LEVEL) !== undefined;
|
|
24
|
+
|
|
25
|
+
function defaultLevel(): number {
|
|
26
|
+
const explicit = parseLevel(process.env.BOTHOLOMEW_LOG_LEVEL);
|
|
27
|
+
if (explicit !== undefined) return explicit;
|
|
28
|
+
if (process.env.NODE_ENV === "test") return LEVELS.error;
|
|
29
|
+
return LEVELS.info;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let currentLevel = defaultLevel();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Apply a log level from config. `BOTHOLOMEW_LOG_LEVEL` always wins, so
|
|
36
|
+
* this is a no-op when that env var is set. Empty/invalid values are
|
|
37
|
+
* ignored — callers can pass `config.log_level` directly without checking.
|
|
38
|
+
*/
|
|
39
|
+
export function setLogLevel(level: string | undefined): void {
|
|
40
|
+
if (envPinned) return;
|
|
41
|
+
const parsed = parseLevel(level);
|
|
42
|
+
if (parsed === undefined) return;
|
|
43
|
+
currentLevel = parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
7
46
|
export const logger = {
|
|
8
47
|
info(msg: string) {
|
|
48
|
+
if (currentLevel < LEVELS.info) return;
|
|
9
49
|
console.log(ts(), ansis.blue("ℹ"), msg);
|
|
10
50
|
},
|
|
11
51
|
|
|
12
52
|
success(msg: string) {
|
|
53
|
+
if (currentLevel < LEVELS.info) return;
|
|
13
54
|
console.log(ts(), ansis.green("✓"), msg);
|
|
14
55
|
},
|
|
15
56
|
|
|
16
57
|
warn(msg: string) {
|
|
58
|
+
if (currentLevel < LEVELS.warn) return;
|
|
17
59
|
console.log(ts(), ansis.yellow("⚠"), msg);
|
|
18
60
|
},
|
|
19
61
|
|
|
20
62
|
error(msg: string) {
|
|
63
|
+
if (currentLevel < LEVELS.error) return;
|
|
21
64
|
console.error(ts(), ansis.red("✗"), msg);
|
|
22
65
|
},
|
|
23
66
|
|
|
24
67
|
debug(msg: string) {
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
}
|
|
68
|
+
if (currentLevel < LEVELS.debug) return;
|
|
69
|
+
console.log(ts(), ansis.gray("·"), ansis.gray(msg));
|
|
28
70
|
},
|
|
29
71
|
|
|
30
72
|
dim(msg: string) {
|
|
73
|
+
if (currentLevel < LEVELS.info) return;
|
|
31
74
|
console.log(ts(), ansis.dim(msg));
|
|
32
75
|
},
|
|
33
76
|
|
|
34
77
|
phase(name: string, detail?: string) {
|
|
78
|
+
if (currentLevel < LEVELS.info) return;
|
|
35
79
|
const tag = ansis.magenta.bold(`[[${name}]]`);
|
|
36
80
|
if (detail) {
|
|
37
81
|
console.log(ts(), tag, ansis.dim(detail));
|