botholomew 0.11.5 → 0.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.11.5",
3
+ "version": "0.11.6",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -56,6 +56,7 @@ const CHAT_TOOL_NAMES = new Set([
56
56
  "mcp_info",
57
57
  "mcp_exec",
58
58
  "read_large_result",
59
+ "pipe_to_context",
59
60
  "spawn_worker",
60
61
  "skill_list",
61
62
  "skill_read",
@@ -47,11 +47,13 @@ function isModelCached(model: string): boolean {
47
47
  async function getPipeline(model: string): Promise<FeatureExtractionPipeline> {
48
48
  let p = pipelinePromises.get(model);
49
49
  if (!p) {
50
- logger.info(
51
- isModelCached(model)
52
- ? `Loading embedding model ${model}`
53
- : `Loading embedding model ${model} (first run, downloading weights)`,
54
- );
50
+ if (isModelCached(model)) {
51
+ logger.debug(`Loading embedding model ${model}`);
52
+ } else {
53
+ logger.info(
54
+ `Loading embedding model ${model} (first run, downloading weights)`,
55
+ );
56
+ }
55
57
  p = pipeline("feature-extraction", model);
56
58
  pipelinePromises.set(model, p);
57
59
  }
@@ -0,0 +1,228 @@
1
+ import { isText } from "istextorbinary";
2
+ import { z } from "zod";
3
+ import { formatDriveRef } from "../../context/drives.ts";
4
+ import { ingestByPath } from "../../context/ingest.ts";
5
+ import {
6
+ createContextItemStrict,
7
+ PathConflictError,
8
+ upsertContextItem,
9
+ } from "../../db/context.ts";
10
+ import { getTool, type ToolDefinition } from "../tool.ts";
11
+
12
+ const PREVIEW_CHARS = 200;
13
+ const ERROR_MESSAGE_CAP = 2000;
14
+ const TOOL_NAME = "pipe_to_context";
15
+
16
+ function mimeFromPath(path: string): string {
17
+ const type = Bun.file(path).type.split(";")[0];
18
+ return type ?? "application/octet-stream";
19
+ }
20
+
21
+ function isTextualPath(path: string): boolean {
22
+ const filename = path.split("/").pop() ?? path;
23
+ return isText(filename) !== false;
24
+ }
25
+
26
+ function truncate(s: string, cap: number): string {
27
+ if (s.length <= cap) return s;
28
+ return `${s.slice(0, cap)}…[truncated, ${s.length - cap} more chars]`;
29
+ }
30
+
31
+ const inputSchema = z.object({
32
+ tool_name: z
33
+ .string()
34
+ .describe(
35
+ "Name of the tool to dispatch. Its full output is piped into a context item; you (the LLM) will only see the storage acknowledgment, never the raw bytes.",
36
+ ),
37
+ tool_input: z
38
+ .record(z.string(), z.unknown())
39
+ .describe(
40
+ "Arguments to pass to the inner tool (same shape as a normal call).",
41
+ ),
42
+ drive: z
43
+ .string()
44
+ .default("agent")
45
+ .describe(
46
+ "Drive to write to (defaults to 'agent', the agent's scratch drive).",
47
+ ),
48
+ path: z.string().describe("Path within the drive (starts with /)"),
49
+ title: z
50
+ .string()
51
+ .optional()
52
+ .describe("Title for the file (defaults to filename)"),
53
+ description: z.string().optional().describe("Description of the file"),
54
+ on_conflict: z
55
+ .enum(["error", "overwrite"])
56
+ .optional()
57
+ .describe(
58
+ "What to do if a file already exists at this (drive, path). Defaults to 'error'. Pass 'overwrite' to replace.",
59
+ ),
60
+ });
61
+
62
+ const outputSchema = z.object({
63
+ is_error: z.boolean(),
64
+ id: z.string().optional(),
65
+ drive: z.string().optional(),
66
+ path: z.string().optional(),
67
+ ref: z.string().optional(),
68
+ bytes_written: z.number().optional(),
69
+ preview: z
70
+ .string()
71
+ .optional()
72
+ .describe(
73
+ `First ${PREVIEW_CHARS} characters of the stored content so you can sanity-check what was captured.`,
74
+ ),
75
+ inner_tool_is_error: z.boolean().optional(),
76
+ error_type: z
77
+ .enum([
78
+ "unknown_tool",
79
+ "forbidden_tool",
80
+ "invalid_input",
81
+ "inner_tool_error",
82
+ "path_conflict",
83
+ ])
84
+ .optional(),
85
+ message: z.string().optional(),
86
+ next_action_hint: z.string().optional(),
87
+ });
88
+
89
+ export const pipeToContextTool = {
90
+ name: TOOL_NAME,
91
+ description:
92
+ "[[ bash equivalent command: cmd > file ]] Run another tool and pipe its full output directly into a context item, without the result flowing through the conversation. Use this when you need a large tool output (web pages, search dumps, big mcp_exec results) to be searchable/embedded for later but you do NOT need to read the bytes yourself. You'll only see the storage ack (drive, path, id, size, short preview).",
93
+ group: "context",
94
+ inputSchema,
95
+ outputSchema,
96
+ execute: async (input, ctx) => {
97
+ const inner = getTool(input.tool_name);
98
+ if (!inner) {
99
+ return {
100
+ is_error: true,
101
+ error_type: "unknown_tool",
102
+ message: `No tool named "${input.tool_name}".`,
103
+ next_action_hint:
104
+ "Check the tool name spelling, or call the inner tool directly if you do need to see its output.",
105
+ };
106
+ }
107
+
108
+ if (inner.name === TOOL_NAME || inner.terminal) {
109
+ return {
110
+ is_error: true,
111
+ error_type: "forbidden_tool",
112
+ message: `Tool "${inner.name}" cannot be piped (terminal tools and pipe_to_context itself are not allowed).`,
113
+ next_action_hint:
114
+ "Pipe a non-terminal tool (search_grep, mcp_exec, context_refresh, etc.) instead.",
115
+ };
116
+ }
117
+
118
+ const parsedInner = inner.inputSchema.safeParse(input.tool_input);
119
+ if (!parsedInner.success) {
120
+ const issues = parsedInner.error.issues
121
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
122
+ .join("; ");
123
+ return {
124
+ is_error: true,
125
+ error_type: "invalid_input",
126
+ message: `Invalid input for ${inner.name}: ${issues}.`,
127
+ next_action_hint:
128
+ "Fix tool_input to match the inner tool's schema and retry.",
129
+ };
130
+ }
131
+
132
+ let innerResult: unknown;
133
+ try {
134
+ innerResult = await inner.execute(parsedInner.data, ctx);
135
+ } catch (err) {
136
+ return {
137
+ is_error: true,
138
+ error_type: "inner_tool_error",
139
+ inner_tool_is_error: true,
140
+ message: truncate(
141
+ `Tool ${inner.name} threw: ${err instanceof Error ? err.message : String(err)}`,
142
+ ERROR_MESSAGE_CAP,
143
+ ),
144
+ next_action_hint:
145
+ "Retry with different arguments, or call the tool directly to see the full error.",
146
+ };
147
+ }
148
+
149
+ const innerIsError =
150
+ typeof innerResult === "object" &&
151
+ innerResult !== null &&
152
+ "is_error" in innerResult
153
+ ? (innerResult as { is_error: boolean }).is_error
154
+ : false;
155
+
156
+ const innerOutput =
157
+ typeof innerResult === "string"
158
+ ? innerResult
159
+ : JSON.stringify(innerResult);
160
+
161
+ if (innerIsError) {
162
+ return {
163
+ is_error: true,
164
+ error_type: "inner_tool_error",
165
+ inner_tool_is_error: true,
166
+ message: truncate(innerOutput, ERROR_MESSAGE_CAP),
167
+ next_action_hint:
168
+ "The inner tool returned an error and nothing was written. Fix the inputs and retry, or pipe a different tool.",
169
+ };
170
+ }
171
+
172
+ const mimeType = mimeFromPath(input.path);
173
+ const isTextual = isTextualPath(input.path);
174
+ const title =
175
+ input.title ?? input.path.split("/").filter(Boolean).pop() ?? input.path;
176
+ const onConflict = input.on_conflict ?? "error";
177
+ const target = { drive: input.drive, path: input.path };
178
+
179
+ try {
180
+ const item =
181
+ onConflict === "overwrite"
182
+ ? await upsertContextItem(ctx.conn, {
183
+ title,
184
+ description: input.description,
185
+ content: innerOutput,
186
+ drive: target.drive,
187
+ path: target.path,
188
+ mimeType,
189
+ isTextual,
190
+ })
191
+ : await createContextItemStrict(ctx.conn, {
192
+ title,
193
+ description: input.description,
194
+ content: innerOutput,
195
+ drive: target.drive,
196
+ path: target.path,
197
+ mimeType,
198
+ isTextual,
199
+ });
200
+
201
+ await ingestByPath(ctx.conn, target, ctx.config);
202
+
203
+ return {
204
+ is_error: false,
205
+ id: item.id,
206
+ drive: item.drive,
207
+ path: item.path,
208
+ ref: formatDriveRef(item),
209
+ bytes_written: innerOutput.length,
210
+ preview: innerOutput.slice(0, PREVIEW_CHARS),
211
+ };
212
+ } catch (err) {
213
+ if (err instanceof PathConflictError) {
214
+ return {
215
+ is_error: true,
216
+ error_type: "path_conflict",
217
+ drive: err.drive,
218
+ path: err.path,
219
+ ref: formatDriveRef({ drive: err.drive, path: err.path }),
220
+ message: `A file already exists at ${formatDriveRef({ drive: err.drive, path: err.path })} (id: ${err.existingId}). The inner tool ran but its output was discarded.`,
221
+ next_action_hint:
222
+ "Retry with on_conflict='overwrite' to replace, or pick a different path.",
223
+ };
224
+ }
225
+ throw err;
226
+ }
227
+ },
228
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -2,6 +2,7 @@
2
2
  import { capabilitiesRefreshTool } from "./capabilities/refresh.ts";
3
3
  // Context tools
4
4
  import { contextListDrivesTool } from "./context/list-drives.ts";
5
+ import { pipeToContextTool } from "./context/pipe.ts";
5
6
  import { readLargeResultTool } from "./context/read-large-result.ts";
6
7
  import { contextRefreshTool } from "./context/refresh.ts";
7
8
  import { contextSearchTool } from "./context/search.ts";
@@ -85,6 +86,7 @@ export function registerAllTools(): void {
85
86
  registerTool(updateBeliefsTool);
86
87
  registerTool(updateGoalsTool);
87
88
  registerTool(readLargeResultTool);
89
+ registerTool(pipeToContextTool);
88
90
 
89
91
  // Capabilities
90
92
  registerTool(capabilitiesRefreshTool);