botholomew 0.19.3 → 0.20.0
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 +2 -1
- package/src/config/loader.ts +29 -0
- package/src/tools/membot/index.ts +4 -2
- package/src/tools/membot/query.ts +298 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"ink-spinner": "^5.0.0",
|
|
39
39
|
"ink-text-input": "^6.0.0",
|
|
40
40
|
"istextorbinary": "^9.5.0",
|
|
41
|
+
"jsonata": "^2.0.6",
|
|
41
42
|
"membot": "^0.17.0",
|
|
42
43
|
"nanospinner": "^1.2.2",
|
|
43
44
|
"ollama-ai-provider-v2": "^3.5.1",
|
package/src/config/loader.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lstat, readlink, stat } from "node:fs/promises";
|
|
1
2
|
import { getConfigPath } from "../constants.ts";
|
|
2
3
|
import { setLogLevel } from "../utils/logger.ts";
|
|
3
4
|
import {
|
|
@@ -44,6 +45,9 @@ export async function loadConfig(
|
|
|
44
45
|
projectDir: string,
|
|
45
46
|
): Promise<BotholomewConfig> {
|
|
46
47
|
const configPath = getConfigPath(projectDir);
|
|
48
|
+
|
|
49
|
+
await assertNotDanglingSymlink(configPath);
|
|
50
|
+
|
|
47
51
|
const file = Bun.file(configPath);
|
|
48
52
|
|
|
49
53
|
let userConfig: DeepPartial<BotholomewConfig> = {};
|
|
@@ -65,6 +69,31 @@ export async function loadConfig(
|
|
|
65
69
|
return config;
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
async function assertNotDanglingSymlink(configPath: string): Promise<void> {
|
|
73
|
+
let lst: Awaited<ReturnType<typeof lstat>>;
|
|
74
|
+
try {
|
|
75
|
+
lst = await lstat(configPath);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
if (!lst.isSymbolicLink()) return;
|
|
81
|
+
try {
|
|
82
|
+
await stat(configPath);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
85
|
+
const target = await readlink(configPath).catch(() => "<unreadable>");
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Config file is a symlink to a missing target: ${configPath} -> ${target}. ` +
|
|
88
|
+
`Symlink targets are resolved relative to the symlink's own directory, ` +
|
|
89
|
+
`not the current working directory — use an absolute path or a target ` +
|
|
90
|
+
`relative to ${configPath.replace(/\/[^/]+$/, "")}.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
export async function saveConfig(
|
|
69
98
|
projectDir: string,
|
|
70
99
|
config: DeepPartial<BotholomewConfig>,
|
|
@@ -6,13 +6,14 @@ import { membotCountLinesTool } from "./count_lines.ts";
|
|
|
6
6
|
import { membotEditTool } from "./edit.ts";
|
|
7
7
|
import { membotExistsTool } from "./exists.ts";
|
|
8
8
|
import { membotPipeTool } from "./pipe.ts";
|
|
9
|
+
import { membotQueryTool } from "./query.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Register every membot operation as a Botholomew tool. The 14 verbs that
|
|
12
13
|
* have a direct membot Operation (add, list, tree, read, search, info,
|
|
13
14
|
* stats, versions, diff, write, move, delete, refresh, prune) are wired via
|
|
14
|
-
* `adaptOperation`; the
|
|
15
|
-
* count_lines, pipe) bolt on the file-shaped UX our agents already know.
|
|
15
|
+
* `adaptOperation`; the six Botholomew-side wrappers (edit, copy, exists,
|
|
16
|
+
* count_lines, pipe, query) bolt on the file-shaped UX our agents already know.
|
|
16
17
|
*/
|
|
17
18
|
export function registerMembotTools(): void {
|
|
18
19
|
for (const op of OPERATIONS) {
|
|
@@ -23,4 +24,5 @@ export function registerMembotTools(): void {
|
|
|
23
24
|
registerTool(membotExistsTool);
|
|
24
25
|
registerTool(membotCountLinesTool);
|
|
25
26
|
registerTool(membotPipeTool);
|
|
27
|
+
registerTool(membotQueryTool);
|
|
26
28
|
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import jsonata from "jsonata";
|
|
2
|
+
import { isHelpfulError } from "membot";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
5
|
+
|
|
6
|
+
const PREVIEW_CHARS = 200;
|
|
7
|
+
/** Default ceiling on the source document size, in characters. */
|
|
8
|
+
const DEFAULT_MAX_INPUT_BYTES = 20_000_000;
|
|
9
|
+
/** Best-effort wall-clock budget for a single evaluation. */
|
|
10
|
+
const EVAL_TIMEOUT_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Full JSONata syntax reference. Deliberately kept OUT of the tool description
|
|
14
|
+
* (which is loaded every turn) — it is returned only on error or when the agent
|
|
15
|
+
* asks for it via `expression: "?"`, so the standing token cost stays tiny.
|
|
16
|
+
*/
|
|
17
|
+
const JSONATA_PRIMER = [
|
|
18
|
+
"JSONata syntax reference. Expressions run against the parsed JSON root (`$` = root).",
|
|
19
|
+
"If the file is an array of records, field names map over it; if it's an object, start with a top-level field name.",
|
|
20
|
+
"",
|
|
21
|
+
"Operators:",
|
|
22
|
+
" field / a.b.c path navigation (maps over arrays automatically)",
|
|
23
|
+
" $ the document root",
|
|
24
|
+
" [ predicate ] filter, e.g. $[amount > 100] or $[status = 'open']",
|
|
25
|
+
" .{ k: v } map: build an object per item (projection)",
|
|
26
|
+
" { k: agg } group: bucket the sequence by key k, aggregate each bucket",
|
|
27
|
+
" ^( >a, <b ) sort: > descending, < ascending; multiple keys allowed",
|
|
28
|
+
" [[ m..n ]] slice/index range (0-based, inclusive)",
|
|
29
|
+
" & ~> | and/or string concat, function-chain, boolean",
|
|
30
|
+
"",
|
|
31
|
+
"Common functions: $count() $sum() $average() $max() $min() $distinct()",
|
|
32
|
+
" $substring(str,start,len) $split() $join() $keys() $sort() $reverse()",
|
|
33
|
+
" $number() $string() $uppercase() $lowercase() $contains() $match()",
|
|
34
|
+
"",
|
|
35
|
+
"Examples (root is an array of records):",
|
|
36
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal JSONata group syntax, not a JS template
|
|
37
|
+
" count by day: ${ $substring(ts,0,10): $count($) }",
|
|
38
|
+
" filter: $[amount > 100]",
|
|
39
|
+
" pluck fields: $.{ 'id': id, 'subject': subject }",
|
|
40
|
+
" dedup a field: $distinct(email)",
|
|
41
|
+
" top-10 newest: $^(>created)[[0..9]]",
|
|
42
|
+
" sum a field: $sum(amount)",
|
|
43
|
+
" count total: $count($)",
|
|
44
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal JSONata group syntax, not a JS template
|
|
45
|
+
" group + sum: ${ category: $sum(amount) }",
|
|
46
|
+
"",
|
|
47
|
+
'If the file is an object like { "items": [...] }, prefix with the field: items{ ... }, $sum(items.amount).',
|
|
48
|
+
"",
|
|
49
|
+
"Full language docs (fetch if you have a web tool): https://docs.jsonata.org",
|
|
50
|
+
].join("\n");
|
|
51
|
+
|
|
52
|
+
const inputSchema = z.object({
|
|
53
|
+
logical_path: z
|
|
54
|
+
.string()
|
|
55
|
+
.describe(
|
|
56
|
+
"Logical path of the JSON file to transform (e.g. 'mcp/inbox.json'). Land big tool output here first with membot_pipe, then query it.",
|
|
57
|
+
),
|
|
58
|
+
expression: z.string().describe(
|
|
59
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal JSONata example, not a JS template
|
|
60
|
+
"JSONata expression evaluated against the parsed JSON root (`$` = root). Examples: count by day `${ $substring(ts,0,10): $count($) }`; filter `$[amount > 100]`; pluck `$.{ 'id': id, 'subject': subject }`; dedup `$distinct(email)`; top-10 newest `$^(>created)[[0..9]]`; sum `$sum(amount)`. Pass \"?\" to get the full syntax reference.",
|
|
61
|
+
),
|
|
62
|
+
output_logical_path: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
"If set, write the transform result here as a new membot version and return only a storage ack (use for large or chainable output). If omitted, the result is returned inline.",
|
|
67
|
+
),
|
|
68
|
+
change_note: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
"Free-text note attached to the new version when output_logical_path is set.",
|
|
73
|
+
),
|
|
74
|
+
max_input_bytes: z
|
|
75
|
+
.number()
|
|
76
|
+
.int()
|
|
77
|
+
.positive()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe(
|
|
80
|
+
`Reject the source if its markdown surrogate exceeds this many characters (default ${DEFAULT_MAX_INPUT_BYTES}).`,
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const outputSchema = z.object({
|
|
85
|
+
is_error: z.boolean(),
|
|
86
|
+
// inline-result branch
|
|
87
|
+
result: z
|
|
88
|
+
.unknown()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe(
|
|
91
|
+
"The transform result, returned inline when output_logical_path is omitted. Auto-parked by the large-results mechanism if it is still large.",
|
|
92
|
+
),
|
|
93
|
+
result_type: z
|
|
94
|
+
.enum(["array", "object", "string", "number", "boolean", "null"])
|
|
95
|
+
.optional(),
|
|
96
|
+
result_count: z
|
|
97
|
+
.number()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe("Element count for an array result, or key count for an object."),
|
|
100
|
+
// write branch (parallels membot_pipe)
|
|
101
|
+
logical_path: z.string().optional(),
|
|
102
|
+
version_id: z.string().optional(),
|
|
103
|
+
bytes_written: z.number().optional(),
|
|
104
|
+
preview: z
|
|
105
|
+
.string()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe(`First ${PREVIEW_CHARS} characters of the stored output.`),
|
|
108
|
+
// PAT error envelope
|
|
109
|
+
error_type: z
|
|
110
|
+
.enum([
|
|
111
|
+
"source_not_found",
|
|
112
|
+
"source_too_large",
|
|
113
|
+
"invalid_json",
|
|
114
|
+
"invalid_expression",
|
|
115
|
+
"evaluation_error",
|
|
116
|
+
"write_failed",
|
|
117
|
+
"internal_error",
|
|
118
|
+
])
|
|
119
|
+
.optional(),
|
|
120
|
+
message: z.string().optional(),
|
|
121
|
+
next_action_hint: z.string().optional(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Classify a JSONata result for the token-light output envelope. */
|
|
125
|
+
function describeResult(value: unknown): {
|
|
126
|
+
result_type: z.infer<typeof outputSchema>["result_type"];
|
|
127
|
+
result_count?: number;
|
|
128
|
+
} {
|
|
129
|
+
if (value === null || value === undefined) return { result_type: "null" };
|
|
130
|
+
if (Array.isArray(value))
|
|
131
|
+
return { result_type: "array", result_count: value.length };
|
|
132
|
+
const t = typeof value;
|
|
133
|
+
if (t === "object")
|
|
134
|
+
return {
|
|
135
|
+
result_type: "object",
|
|
136
|
+
result_count: Object.keys(value as object).length,
|
|
137
|
+
};
|
|
138
|
+
if (t === "string" || t === "number" || t === "boolean")
|
|
139
|
+
return { result_type: t };
|
|
140
|
+
return { result_type: "null" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Run an evaluation under a soft wall-clock budget. Best-effort: JSONata
|
|
144
|
+
* yields on async function calls, so a tight synchronous loop may overrun. */
|
|
145
|
+
async function evaluateWithTimeout(
|
|
146
|
+
expr: ReturnType<typeof jsonata>,
|
|
147
|
+
data: unknown,
|
|
148
|
+
): Promise<unknown> {
|
|
149
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
150
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
151
|
+
timer = setTimeout(
|
|
152
|
+
() => reject(new Error(`evaluation exceeded ${EVAL_TIMEOUT_MS}ms`)),
|
|
153
|
+
EVAL_TIMEOUT_MS,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
// Swallow a late rejection from a still-running evaluate after a timeout win,
|
|
157
|
+
// so it never surfaces as an unhandled rejection in the worker/chat process.
|
|
158
|
+
const evaluation = expr.evaluate(data);
|
|
159
|
+
evaluation.catch(() => {});
|
|
160
|
+
try {
|
|
161
|
+
return await Promise.race([evaluation, timeout]);
|
|
162
|
+
} finally {
|
|
163
|
+
if (timer) clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const membotQueryTool = {
|
|
168
|
+
name: "membot_query",
|
|
169
|
+
description:
|
|
170
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal JSONata example, not a JS template
|
|
171
|
+
"[[ bash equivalent command: jq '<expr>' file ]] Run a JSONata transform over JSON stored at a membot logical_path — reduce/reshape a large blob (group, filter, pluck, dedup, sort, aggregate) WITHOUT loading it into context. Returns the (usually small) result inline, or writes it to output_logical_path for chaining. Pair with membot_pipe: pipe a big MCP result to a logical_path, then query it here. Expressions run against the JSON root (`$`). Examples: count by day `${ $substring(ts,0,10): $count($) }`; filter `$[amount > 100]`; pluck `$.{ 'id': id, 'subject': subject }`; dedup `$distinct(email)`; top-10 newest `$^(>created)[[0..9]]`; sum `$sum(amount)`. Pass expression=\"?\" for the full syntax reference.",
|
|
172
|
+
group: "membot",
|
|
173
|
+
inputSchema,
|
|
174
|
+
outputSchema,
|
|
175
|
+
execute: async (input, ctx): Promise<z.infer<typeof outputSchema>> => {
|
|
176
|
+
// Tier 3: on-demand help — return the primer without touching the source.
|
|
177
|
+
const expression = input.expression.trim();
|
|
178
|
+
if (expression === "" || expression === "?") {
|
|
179
|
+
return { is_error: false, message: JSONATA_PRIMER };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 1. Read the source document.
|
|
183
|
+
let content: string;
|
|
184
|
+
try {
|
|
185
|
+
const read = await ctx.withMem((mem) =>
|
|
186
|
+
mem.read({ logical_path: input.logical_path }),
|
|
187
|
+
);
|
|
188
|
+
content = read.content ?? "";
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (isHelpfulError(err)) {
|
|
191
|
+
return {
|
|
192
|
+
is_error: true,
|
|
193
|
+
error_type: "source_not_found",
|
|
194
|
+
message: err.message,
|
|
195
|
+
next_action_hint: err.hint,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
is_error: true,
|
|
200
|
+
error_type: "internal_error",
|
|
201
|
+
message: err instanceof Error ? err.message : String(err),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Size guard.
|
|
206
|
+
const maxBytes = input.max_input_bytes ?? DEFAULT_MAX_INPUT_BYTES;
|
|
207
|
+
if (content.length > maxBytes) {
|
|
208
|
+
return {
|
|
209
|
+
is_error: true,
|
|
210
|
+
error_type: "source_too_large",
|
|
211
|
+
message: `Source is ${content.length} chars, exceeding max_input_bytes (${maxBytes}).`,
|
|
212
|
+
next_action_hint:
|
|
213
|
+
"Narrow the data at the source (more selective MCP args), raise max_input_bytes, or split the document.",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 3. Parse JSON.
|
|
218
|
+
let parsed: unknown;
|
|
219
|
+
try {
|
|
220
|
+
parsed = JSON.parse(content);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
is_error: true,
|
|
224
|
+
error_type: "invalid_json",
|
|
225
|
+
message: `Source at ${input.logical_path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
226
|
+
next_action_hint:
|
|
227
|
+
"membot_query only runs on JSON. Use membot_read for plain text, and confirm the right logical_path.",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 4. Compile the expression.
|
|
232
|
+
let expr: ReturnType<typeof jsonata>;
|
|
233
|
+
try {
|
|
234
|
+
expr = jsonata(expression);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
const je = err as { message?: string; position?: number };
|
|
237
|
+
const where =
|
|
238
|
+
typeof je.position === "number" ? ` (at position ${je.position})` : "";
|
|
239
|
+
return {
|
|
240
|
+
is_error: true,
|
|
241
|
+
error_type: "invalid_expression",
|
|
242
|
+
message: `Could not compile JSONata expression${where}: ${je.message ?? String(err)}`,
|
|
243
|
+
next_action_hint: JSONATA_PRIMER,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 5. Evaluate (best-effort timeout).
|
|
248
|
+
let out: unknown;
|
|
249
|
+
try {
|
|
250
|
+
out = await evaluateWithTimeout(expr, parsed);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return {
|
|
253
|
+
is_error: true,
|
|
254
|
+
error_type: "evaluation_error",
|
|
255
|
+
message: `JSONata evaluation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
256
|
+
next_action_hint: `Simplify or pre-filter the expression and retry.\n\n${JSONATA_PRIMER}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 6a. Write branch.
|
|
261
|
+
if (input.output_logical_path) {
|
|
262
|
+
const body = JSON.stringify(out ?? null, null, 2);
|
|
263
|
+
try {
|
|
264
|
+
const written = await ctx.withMem((mem) =>
|
|
265
|
+
mem.write({
|
|
266
|
+
logical_path: input.output_logical_path as string,
|
|
267
|
+
content: body,
|
|
268
|
+
change_note: input.change_note,
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
return {
|
|
272
|
+
is_error: false,
|
|
273
|
+
logical_path: written.logical_path,
|
|
274
|
+
version_id: written.version_id,
|
|
275
|
+
bytes_written: written.size_bytes,
|
|
276
|
+
preview: body.slice(0, PREVIEW_CHARS),
|
|
277
|
+
};
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (isHelpfulError(err)) {
|
|
280
|
+
return {
|
|
281
|
+
is_error: true,
|
|
282
|
+
error_type: "write_failed",
|
|
283
|
+
message: `Transform ran, but write to ${input.output_logical_path} failed: ${err.message}`,
|
|
284
|
+
next_action_hint: err.hint,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
is_error: true,
|
|
289
|
+
error_type: "internal_error",
|
|
290
|
+
message: err instanceof Error ? err.message : String(err),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 6b. Inline branch — let the agent loop auto-park if still large.
|
|
296
|
+
return { is_error: false, result: out, ...describeResult(out) };
|
|
297
|
+
},
|
|
298
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|