botholomew 0.6.3 → 0.7.1
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 +218 -1
- package/package.json +1 -1
- package/src/commands/chat.ts +1 -1
- package/src/commands/context.ts +227 -66
- package/src/context/chunker.ts +98 -1
- package/src/context/fetcher.ts +436 -0
- package/src/context/url-utils.ts +48 -0
- package/src/db/context.ts +8 -2
- package/src/db/sql/9-source-type.sql +1 -0
- package/src/skills/commands.ts +12 -1
- package/src/tui/App.tsx +33 -18
- package/src/tui/components/HelpPanel.tsx +1 -1
- package/src/tui/components/InputBar.tsx +176 -99
- package/src/tui/components/SlashCommandPopup.tsx +50 -0
- package/src/tui/slashCompletion.ts +38 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type {
|
|
3
|
+
Tool as AnthropicTool,
|
|
4
|
+
MessageParam,
|
|
5
|
+
ToolResultBlockParam,
|
|
6
|
+
ToolUseBlock,
|
|
7
|
+
} from "@anthropic-ai/sdk/resources/messages";
|
|
8
|
+
import type { McpxClient } from "@evantahler/mcpx";
|
|
9
|
+
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
10
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
11
|
+
import { mcpExecTool } from "../tools/mcp/exec.ts";
|
|
12
|
+
import { mcpInfoTool } from "../tools/mcp/info.ts";
|
|
13
|
+
import { mcpListToolsTool } from "../tools/mcp/list-tools.ts";
|
|
14
|
+
import { mcpSearchTool } from "../tools/mcp/search.ts";
|
|
15
|
+
import type { ToolContext } from "../tools/tool.ts";
|
|
16
|
+
import { type AnyToolDefinition, toAnthropicTool } from "../tools/tool.ts";
|
|
17
|
+
import { logger } from "../utils/logger.ts";
|
|
18
|
+
import { stripHtmlTags } from "./url-utils.ts";
|
|
19
|
+
|
|
20
|
+
const MAX_CONTENT_BYTES = 500_000;
|
|
21
|
+
const MAX_TURNS = 10;
|
|
22
|
+
const MAX_RESPONSE_TOKENS = 4_096;
|
|
23
|
+
const PREVIEW_CHARS = 2_000;
|
|
24
|
+
const HTTP_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
export interface FetchedContent {
|
|
27
|
+
title: string;
|
|
28
|
+
content: string;
|
|
29
|
+
mimeType: string;
|
|
30
|
+
sourceUrl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FetchFailureError extends Error {
|
|
34
|
+
readonly userMessage: string;
|
|
35
|
+
constructor(message: string) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "FetchFailureError";
|
|
38
|
+
this.userMessage = message;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const FETCHER_SYSTEM_PROMPT = `You are a content fetcher. Your job is to find the right MCP tool to retrieve the content at the given URL, run it, and tell the harness which result to save.
|
|
43
|
+
|
|
44
|
+
**Important: the harness captures the full result of every mcp_exec call automatically.** You only see a short preview of each result so you can verify it looks reasonable. You do NOT need to read or copy the full content — you just identify which exec call to save.
|
|
45
|
+
|
|
46
|
+
Strongly prefer markdown output. Most MCP tools support a markdown/format parameter — use it when available.
|
|
47
|
+
|
|
48
|
+
Workflow:
|
|
49
|
+
1. Use mcp_search or mcp_list_tools to find the best tool for this URL (e.g., Google Docs tools for docs.google.com, Firecrawl for generic web pages, GitHub tools for github.com).
|
|
50
|
+
2. Use mcp_info to inspect the tool's input schema.
|
|
51
|
+
3. Call mcp_exec with the right arguments — request markdown format when supported.
|
|
52
|
+
4. Look at the preview returned by mcp_exec. If it looks like the right content, call accept_content with the exec_call_id (the tool_use_id of the mcp_exec call) and a sensible title.
|
|
53
|
+
|
|
54
|
+
Terminal tools:
|
|
55
|
+
- accept_content(exec_call_id, title, mime_type?) — save the full content captured from a previous mcp_exec call. The harness has the full content; you just supply the id, title, and optional mime_type (defaults to text/markdown).
|
|
56
|
+
- request_http_fallback() — fall back to a basic HTTP fetch. Use only when no MCP tool can handle the URL after a genuine attempt. Tools like Firecrawl can handle most URLs, so don't give up on the first try.
|
|
57
|
+
- report_failure(message) — surface an actionable message to the user (e.g., "this Google Doc is private — share it with your service account", "Firecrawl is not authenticated"). Use only when there is a specific next step the user must take.`;
|
|
58
|
+
|
|
59
|
+
const acceptContentTool: AnthropicTool = {
|
|
60
|
+
name: "accept_content",
|
|
61
|
+
description:
|
|
62
|
+
"Save the full content captured by the harness from a previous mcp_exec call. You only need to supply the exec_call_id (the tool_use_id of that mcp_exec call) and a title — the harness already has the full content. Do NOT paste content here.",
|
|
63
|
+
input_schema: {
|
|
64
|
+
type: "object" as const,
|
|
65
|
+
properties: {
|
|
66
|
+
exec_call_id: {
|
|
67
|
+
type: "string",
|
|
68
|
+
description:
|
|
69
|
+
"The tool_use_id of the mcp_exec call whose result should be saved (the harness lists captured ids in mcp_exec previews).",
|
|
70
|
+
},
|
|
71
|
+
title: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description:
|
|
74
|
+
"A human-readable title for the content (e.g., the document title, or derived from the URL).",
|
|
75
|
+
},
|
|
76
|
+
mime_type: {
|
|
77
|
+
type: "string",
|
|
78
|
+
description: "MIME type of the content (defaults to text/markdown).",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: ["exec_call_id", "title"],
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
interface AcceptContentInput {
|
|
86
|
+
exec_call_id: string;
|
|
87
|
+
title: string;
|
|
88
|
+
mime_type?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const requestHttpFallbackTool: AnthropicTool = {
|
|
92
|
+
name: "request_http_fallback",
|
|
93
|
+
description:
|
|
94
|
+
"Fall back to a basic HTTP fetch. Use only when no MCP tool can handle the URL after a genuine attempt.",
|
|
95
|
+
input_schema: {
|
|
96
|
+
type: "object" as const,
|
|
97
|
+
properties: {},
|
|
98
|
+
required: [],
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const reportFailureTool: AnthropicTool = {
|
|
103
|
+
name: "report_failure",
|
|
104
|
+
description:
|
|
105
|
+
"Report a fetch failure with an actionable message for the user (e.g., 'this Google Doc is private — share it with your service account'). Use only when there is a clear next step the user must take.",
|
|
106
|
+
input_schema: {
|
|
107
|
+
type: "object" as const,
|
|
108
|
+
properties: {
|
|
109
|
+
message: {
|
|
110
|
+
type: "string",
|
|
111
|
+
description:
|
|
112
|
+
"A clear, actionable, user-facing message explaining what the user needs to do to make this URL fetchable.",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
required: ["message"],
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
interface ReportFailureInput {
|
|
120
|
+
message: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const mcpTools: AnyToolDefinition[] = [
|
|
124
|
+
mcpListToolsTool as unknown as AnyToolDefinition,
|
|
125
|
+
mcpSearchTool as unknown as AnyToolDefinition,
|
|
126
|
+
mcpInfoTool as unknown as AnyToolDefinition,
|
|
127
|
+
mcpExecTool as unknown as AnyToolDefinition,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
export async function fetchUrl(
|
|
131
|
+
url: string,
|
|
132
|
+
config: Required<BotholomewConfig>,
|
|
133
|
+
mcpxClient: McpxClient | null,
|
|
134
|
+
promptAddition?: string,
|
|
135
|
+
): Promise<FetchedContent> {
|
|
136
|
+
if (!config.anthropic_api_key) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"Anthropic API key is required for URL fetching. Set ANTHROPIC_API_KEY or configure it in .botholomew/config.json",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!mcpxClient) {
|
|
143
|
+
logger.dim(" no MCPX client — using HTTP fallback");
|
|
144
|
+
return httpFallback(url);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const result = await runFetcherLoop(url, config, mcpxClient, promptAddition);
|
|
148
|
+
if (result) return result;
|
|
149
|
+
|
|
150
|
+
logger.dim(" agent signaled fallback — using HTTP");
|
|
151
|
+
return httpFallback(url);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function runFetcherLoop(
|
|
155
|
+
url: string,
|
|
156
|
+
config: Required<BotholomewConfig>,
|
|
157
|
+
mcpxClient: McpxClient,
|
|
158
|
+
promptAddition?: string,
|
|
159
|
+
): Promise<FetchedContent | null> {
|
|
160
|
+
const client = new Anthropic({ apiKey: config.anthropic_api_key });
|
|
161
|
+
|
|
162
|
+
const toolCtx: ToolContext = {
|
|
163
|
+
conn: null as unknown as DbConnection,
|
|
164
|
+
projectDir: "",
|
|
165
|
+
config,
|
|
166
|
+
mcpxClient,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const tools: AnthropicTool[] = [
|
|
170
|
+
...mcpTools.map(toAnthropicTool),
|
|
171
|
+
acceptContentTool,
|
|
172
|
+
requestHttpFallbackTool,
|
|
173
|
+
reportFailureTool,
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
// Cache of full mcp_exec results keyed by tool_use_id.
|
|
177
|
+
// The LLM only sees a truncated preview; on accept_content it references
|
|
178
|
+
// the id and the harness saves the captured content.
|
|
179
|
+
const execResults = new Map<
|
|
180
|
+
string,
|
|
181
|
+
{ server: string; tool: string; content: string; mimeType: string }
|
|
182
|
+
>();
|
|
183
|
+
|
|
184
|
+
const userPrompt = promptAddition
|
|
185
|
+
? `Fetch the content at: ${url}\n\nAdditional guidance:\n${promptAddition}`
|
|
186
|
+
: `Fetch the content at: ${url}`;
|
|
187
|
+
const messages: MessageParam[] = [{ role: "user", content: userPrompt }];
|
|
188
|
+
|
|
189
|
+
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
190
|
+
const response = await client.messages.create({
|
|
191
|
+
model: config.model,
|
|
192
|
+
max_tokens: MAX_RESPONSE_TOKENS,
|
|
193
|
+
system: FETCHER_SYSTEM_PROMPT,
|
|
194
|
+
messages,
|
|
195
|
+
tools,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Log assistant text reasoning
|
|
199
|
+
for (const block of response.content) {
|
|
200
|
+
if (block.type === "text" && block.text.trim()) {
|
|
201
|
+
logger.dim(` turn ${turn + 1}: ${block.text.trim()}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (response.stop_reason === "max_tokens") {
|
|
206
|
+
throw new FetchFailureError(
|
|
207
|
+
`The fetched document is too large to return in a single LLM response (hit max_tokens=${MAX_RESPONSE_TOKENS}). Try fetching a smaller section, a specific page, or a tool that supports pagination.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const toolUseBlocks = response.content.filter(
|
|
212
|
+
(block): block is ToolUseBlock => block.type === "tool_use",
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (toolUseBlocks.length === 0) {
|
|
216
|
+
logger.dim(` turn ${turn + 1}: no tool calls — signaling fallback`);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
messages.push({ role: "assistant", content: response.content });
|
|
221
|
+
|
|
222
|
+
// Check for report_failure first (terminal — surfaces actionable user message)
|
|
223
|
+
const failureCall = toolUseBlocks.find((b) => b.name === "report_failure");
|
|
224
|
+
if (failureCall) {
|
|
225
|
+
const input = failureCall.input as Partial<ReportFailureInput>;
|
|
226
|
+
const message =
|
|
227
|
+
typeof input.message === "string" && input.message.trim()
|
|
228
|
+
? input.message
|
|
229
|
+
: "Fetch failed but the agent did not provide a message.";
|
|
230
|
+
logger.dim(` turn ${turn + 1}: report_failure: ${message}`);
|
|
231
|
+
throw new FetchFailureError(message);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check for request_http_fallback (terminal)
|
|
235
|
+
const fallbackCall = toolUseBlocks.find(
|
|
236
|
+
(b) => b.name === "request_http_fallback",
|
|
237
|
+
);
|
|
238
|
+
if (fallbackCall) {
|
|
239
|
+
logger.dim(` turn ${turn + 1}: agent requested HTTP fallback`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check for accept_content (terminal — looks up captured exec result)
|
|
244
|
+
const acceptCall = toolUseBlocks.find((b) => b.name === "accept_content");
|
|
245
|
+
if (acceptCall) {
|
|
246
|
+
const input = acceptCall.input as Partial<AcceptContentInput>;
|
|
247
|
+
if (
|
|
248
|
+
typeof input.exec_call_id !== "string" ||
|
|
249
|
+
typeof input.title !== "string"
|
|
250
|
+
) {
|
|
251
|
+
logger.dim(
|
|
252
|
+
` turn ${turn + 1}: accept_content missing required fields — asking agent to retry`,
|
|
253
|
+
);
|
|
254
|
+
messages.push({
|
|
255
|
+
role: "user",
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
type: "tool_result" as const,
|
|
259
|
+
tool_use_id: acceptCall.id,
|
|
260
|
+
content:
|
|
261
|
+
"Invalid accept_content call: both 'exec_call_id' and 'title' are required strings.",
|
|
262
|
+
is_error: true,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const cached = execResults.get(input.exec_call_id);
|
|
269
|
+
if (!cached) {
|
|
270
|
+
const validIds = [...execResults.keys()];
|
|
271
|
+
logger.dim(
|
|
272
|
+
` turn ${turn + 1}: accept_content: unknown exec_call_id "${input.exec_call_id}"`,
|
|
273
|
+
);
|
|
274
|
+
messages.push({
|
|
275
|
+
role: "user",
|
|
276
|
+
content: [
|
|
277
|
+
{
|
|
278
|
+
type: "tool_result" as const,
|
|
279
|
+
tool_use_id: acceptCall.id,
|
|
280
|
+
content: `No mcp_exec call with id "${input.exec_call_id}" was captured. Captured ids: ${validIds.length ? validIds.join(", ") : "(none yet — run mcp_exec first)"}.`,
|
|
281
|
+
is_error: true,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const mimeType = input.mime_type || cached.mimeType;
|
|
288
|
+
logger.dim(
|
|
289
|
+
` turn ${turn + 1}: accept_content: "${input.title}" (${cached.content.length} chars, ${mimeType}, from ${cached.server}/${cached.tool})`,
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
title: input.title,
|
|
293
|
+
content: cached.content.slice(0, MAX_CONTENT_BYTES),
|
|
294
|
+
mimeType,
|
|
295
|
+
sourceUrl: url,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Execute non-terminal MCP tools in parallel
|
|
300
|
+
const toolResults: ToolResultBlockParam[] = await Promise.all(
|
|
301
|
+
toolUseBlocks.map(async (toolUse) => {
|
|
302
|
+
// Log which tool the agent selected (and the underlying MCP server/tool for mcp_exec)
|
|
303
|
+
const toolInput = toolUse.input as Record<string, unknown>;
|
|
304
|
+
if (toolUse.name === "mcp_exec") {
|
|
305
|
+
logger.dim(
|
|
306
|
+
` turn ${turn + 1}: mcp_exec → ${toolInput.server}/${toolInput.tool}`,
|
|
307
|
+
);
|
|
308
|
+
} else {
|
|
309
|
+
const args = JSON.stringify(toolInput).slice(0, 80);
|
|
310
|
+
logger.dim(` turn ${turn + 1}: ${toolUse.name}(${args})`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const toolDef = mcpTools.find((t) => t.name === toolUse.name);
|
|
314
|
+
if (!toolDef) {
|
|
315
|
+
return {
|
|
316
|
+
type: "tool_result" as const,
|
|
317
|
+
tool_use_id: toolUse.id,
|
|
318
|
+
content: `Unknown tool: ${toolUse.name}`,
|
|
319
|
+
is_error: true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const parsed = toolDef.inputSchema.safeParse(toolUse.input);
|
|
325
|
+
if (!parsed.success) {
|
|
326
|
+
return {
|
|
327
|
+
type: "tool_result" as const,
|
|
328
|
+
tool_use_id: toolUse.id,
|
|
329
|
+
content: `Invalid input: ${parsed.error.message}`,
|
|
330
|
+
is_error: true,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const result = await toolDef.execute(parsed.data, toolCtx);
|
|
334
|
+
if (result.is_error) {
|
|
335
|
+
logger.dim(
|
|
336
|
+
` → error: ${JSON.stringify(result).slice(0, 160)}`,
|
|
337
|
+
);
|
|
338
|
+
return {
|
|
339
|
+
type: "tool_result" as const,
|
|
340
|
+
tool_use_id: toolUse.id,
|
|
341
|
+
content: JSON.stringify(result),
|
|
342
|
+
is_error: true,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// For successful mcp_exec calls, capture the full content in the
|
|
347
|
+
// harness and send only a preview to the LLM. The LLM accepts the
|
|
348
|
+
// result by referring to its tool_use_id.
|
|
349
|
+
if (toolUse.name === "mcp_exec") {
|
|
350
|
+
const execResult = result as {
|
|
351
|
+
result: string;
|
|
352
|
+
is_error: boolean;
|
|
353
|
+
};
|
|
354
|
+
const content = execResult.result;
|
|
355
|
+
execResults.set(toolUse.id, {
|
|
356
|
+
server: String(toolInput.server),
|
|
357
|
+
tool: String(toolInput.tool),
|
|
358
|
+
content,
|
|
359
|
+
mimeType: "text/markdown",
|
|
360
|
+
});
|
|
361
|
+
const preview =
|
|
362
|
+
content.length > PREVIEW_CHARS
|
|
363
|
+
? `${content.slice(0, PREVIEW_CHARS)}\n\n[... ${content.length - PREVIEW_CHARS} more chars truncated. Full content (${content.length} chars total) is captured by the harness with exec_call_id="${toolUse.id}". Call accept_content with this id to save it.]`
|
|
364
|
+
: `${content}\n\n[Full content (${content.length} chars) captured by the harness with exec_call_id="${toolUse.id}". Call accept_content with this id to save it.]`;
|
|
365
|
+
logger.dim(
|
|
366
|
+
` → captured ${content.length} chars (id=${toolUse.id})`,
|
|
367
|
+
);
|
|
368
|
+
return {
|
|
369
|
+
type: "tool_result" as const,
|
|
370
|
+
tool_use_id: toolUse.id,
|
|
371
|
+
content: preview,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
type: "tool_result" as const,
|
|
377
|
+
tool_use_id: toolUse.id,
|
|
378
|
+
content: JSON.stringify(result),
|
|
379
|
+
};
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.dim(` → exception: ${err}`);
|
|
382
|
+
return {
|
|
383
|
+
type: "tool_result" as const,
|
|
384
|
+
tool_use_id: toolUse.id,
|
|
385
|
+
content: `Error: ${err}`,
|
|
386
|
+
is_error: true,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
messages.push({ role: "user", content: toolResults });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
logger.dim(` max turns (${MAX_TURNS}) exceeded — signaling fallback`);
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function httpFallback(url: string): Promise<FetchedContent> {
|
|
400
|
+
const response = await fetch(url, {
|
|
401
|
+
headers: { "User-Agent": "Botholomew/1.0" },
|
|
402
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}: ${url}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const contentType = response.headers.get("content-type") || "";
|
|
410
|
+
const isHtml = contentType.includes("text/html");
|
|
411
|
+
let text = await response.text();
|
|
412
|
+
|
|
413
|
+
let title = url;
|
|
414
|
+
if (isHtml) {
|
|
415
|
+
const titleMatch = text.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
416
|
+
if (titleMatch?.[1]) {
|
|
417
|
+
title = titleMatch[1].trim();
|
|
418
|
+
}
|
|
419
|
+
text = stripHtmlTags(text);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (text.length > MAX_CONTENT_BYTES) {
|
|
423
|
+
text = text.slice(0, MAX_CONTENT_BYTES);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const mimeType = isHtml
|
|
427
|
+
? "text/markdown"
|
|
428
|
+
: contentType.split(";")[0] || "text/plain";
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
title,
|
|
432
|
+
content: text,
|
|
433
|
+
mimeType,
|
|
434
|
+
sourceUrl: url,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempts to parse the input as a URL and returns true if the protocol is http or https.
|
|
3
|
+
*/
|
|
4
|
+
export function isUrl(input: string): boolean {
|
|
5
|
+
try {
|
|
6
|
+
const url = new URL(input);
|
|
7
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Derives a virtual context path from a URL.
|
|
15
|
+
* Example: `https://docs.google.com/document/d/abc123/edit` → `/{prefix}/docs.google.com/document-d-abc123.md`
|
|
16
|
+
*/
|
|
17
|
+
export function urlToContextPath(url: string, prefix: string): string {
|
|
18
|
+
const parsed = new URL(url);
|
|
19
|
+
const hostname = parsed.hostname;
|
|
20
|
+
const pathname = parsed.pathname
|
|
21
|
+
.replace(/\/+$/, "") // strip trailing slashes
|
|
22
|
+
.replace(/^\/+/, "") // strip leading slashes
|
|
23
|
+
.replace(/[^a-zA-Z0-9\-_.]/g, "-") // slugify
|
|
24
|
+
.replace(/-{2,}/g, "-"); // collapse repeated dashes
|
|
25
|
+
|
|
26
|
+
const slug = pathname ? `${hostname}/${pathname}` : hostname;
|
|
27
|
+
const full = `${prefix.replace(/\/+$/, "")}/${slug}.md`;
|
|
28
|
+
|
|
29
|
+
if (full.length > 120) {
|
|
30
|
+
return `${full.slice(0, 117 - 3)}.md`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return full;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strips HTML tags from a string, removing script/style blocks first,
|
|
38
|
+
* then all remaining tags, and collapsing whitespace.
|
|
39
|
+
*/
|
|
40
|
+
export function stripHtmlTags(html: string): string {
|
|
41
|
+
return html
|
|
42
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "") // remove script blocks
|
|
43
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "") // remove style blocks
|
|
44
|
+
.replace(/<[^>]*>/g, "") // remove all remaining tags
|
|
45
|
+
.replace(/[ \t]+/g, " ") // collapse horizontal whitespace
|
|
46
|
+
.replace(/\n{3,}/g, "\n\n") // collapse excessive newlines
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
package/src/db/context.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface ContextItem {
|
|
|
9
9
|
content: string | null;
|
|
10
10
|
mime_type: string;
|
|
11
11
|
is_textual: boolean;
|
|
12
|
+
source_type: "file" | "url";
|
|
12
13
|
source_path: string | null;
|
|
13
14
|
context_path: string;
|
|
14
15
|
indexed_at: Date | null;
|
|
@@ -30,6 +31,7 @@ interface ContextItemRow {
|
|
|
30
31
|
content_blob: unknown;
|
|
31
32
|
mime_type: string;
|
|
32
33
|
is_textual: boolean;
|
|
34
|
+
source_type: string;
|
|
33
35
|
source_path: string | null;
|
|
34
36
|
context_path: string;
|
|
35
37
|
indexed_at: string | null;
|
|
@@ -45,6 +47,7 @@ function rowToContextItem(row: ContextItemRow): ContextItem {
|
|
|
45
47
|
content: row.content,
|
|
46
48
|
mime_type: row.mime_type,
|
|
47
49
|
is_textual: !!row.is_textual,
|
|
50
|
+
source_type: row.source_type as "file" | "url",
|
|
48
51
|
source_path: row.source_path,
|
|
49
52
|
context_path: row.context_path,
|
|
50
53
|
indexed_at: row.indexed_at ? new Date(row.indexed_at) : null,
|
|
@@ -61,6 +64,7 @@ export async function createContextItem(
|
|
|
61
64
|
title: string;
|
|
62
65
|
content?: string;
|
|
63
66
|
mimeType?: string;
|
|
67
|
+
sourceType?: "file" | "url";
|
|
64
68
|
sourcePath?: string;
|
|
65
69
|
contextPath: string;
|
|
66
70
|
description?: string;
|
|
@@ -69,8 +73,8 @@ export async function createContextItem(
|
|
|
69
73
|
): Promise<ContextItem> {
|
|
70
74
|
const id = uuidv7();
|
|
71
75
|
const row = await db.queryGet<ContextItemRow>(
|
|
72
|
-
`INSERT INTO context_items (id, title, description, content, mime_type, is_textual, source_path, context_path)
|
|
73
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
76
|
+
`INSERT INTO context_items (id, title, description, content, mime_type, is_textual, source_type, source_path, context_path)
|
|
77
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
74
78
|
RETURNING *`,
|
|
75
79
|
id,
|
|
76
80
|
params.title,
|
|
@@ -78,6 +82,7 @@ export async function createContextItem(
|
|
|
78
82
|
params.content ?? null,
|
|
79
83
|
params.mimeType ?? "text/plain",
|
|
80
84
|
params.isTextual !== false,
|
|
85
|
+
params.sourceType ?? "file",
|
|
81
86
|
params.sourcePath ?? null,
|
|
82
87
|
params.contextPath,
|
|
83
88
|
);
|
|
@@ -99,6 +104,7 @@ export async function upsertContextItem(
|
|
|
99
104
|
title: string;
|
|
100
105
|
content?: string;
|
|
101
106
|
mimeType?: string;
|
|
107
|
+
sourceType?: "file" | "url";
|
|
102
108
|
sourcePath?: string;
|
|
103
109
|
contextPath: string;
|
|
104
110
|
description?: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE context_items ADD COLUMN source_type TEXT DEFAULT 'file';
|
package/src/skills/commands.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { SkillDefinition } from "./parser.ts";
|
|
2
2
|
import { renderSkill } from "./parser.ts";
|
|
3
3
|
|
|
4
|
+
export interface SlashCommand {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|
10
|
+
{ name: "help", description: "Show command reference and shortcuts" },
|
|
11
|
+
{ name: "skills", description: "List available skills" },
|
|
12
|
+
{ name: "exit", description: "End the chat session" },
|
|
13
|
+
];
|
|
14
|
+
|
|
4
15
|
export interface SlashCommandContext {
|
|
5
16
|
skills: Map<string, SkillDefinition>;
|
|
6
17
|
addSystemMessage: (content: string) => void;
|
|
@@ -22,7 +33,7 @@ export function handleSlashCommand(
|
|
|
22
33
|
const name = commandPart.slice(1).toLowerCase(); // remove leading /
|
|
23
34
|
|
|
24
35
|
// Built-in commands
|
|
25
|
-
if (name === "
|
|
36
|
+
if (name === "exit") {
|
|
26
37
|
ctx.exit();
|
|
27
38
|
return true;
|
|
28
39
|
}
|
package/src/tui/App.tsx
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
|
|
10
10
|
import type { Interaction } from "../db/threads.ts";
|
|
11
11
|
import { getThread } from "../db/threads.ts";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
BUILTIN_SLASH_COMMANDS,
|
|
14
|
+
handleSlashCommand,
|
|
15
|
+
type SlashCommand,
|
|
16
|
+
} from "../skills/commands.ts";
|
|
13
17
|
import { ContextPanel } from "./components/ContextPanel.tsx";
|
|
14
18
|
import { HelpPanel } from "./components/HelpPanel.tsx";
|
|
15
19
|
import { InputBar } from "./components/InputBar.tsx";
|
|
@@ -27,6 +31,7 @@ import { TaskPanel } from "./components/TaskPanel.tsx";
|
|
|
27
31
|
import { ThreadPanel } from "./components/ThreadPanel.tsx";
|
|
28
32
|
import type { ToolCallData } from "./components/ToolCall.tsx";
|
|
29
33
|
import { ToolPanel } from "./components/ToolPanel.tsx";
|
|
34
|
+
import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
|
|
30
35
|
import { ansi } from "./theme.ts";
|
|
31
36
|
|
|
32
37
|
interface AppProps {
|
|
@@ -210,10 +215,8 @@ export function App({
|
|
|
210
215
|
queuedMessagesRef.current = queuedMessages;
|
|
211
216
|
selectedQueueIndexRef.current = selectedQueueIndex;
|
|
212
217
|
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
tabConsumedRef.current = true;
|
|
216
|
-
}, []);
|
|
218
|
+
const slashCommandsRef = useRef<SlashCommand[]>([]);
|
|
219
|
+
const inputValueRef = useRef("");
|
|
217
220
|
|
|
218
221
|
const stableAppHandler = useCallback(
|
|
219
222
|
// biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
|
|
@@ -224,11 +227,15 @@ export function App({
|
|
|
224
227
|
return;
|
|
225
228
|
}
|
|
226
229
|
|
|
227
|
-
// Tab key cycles tabs —
|
|
230
|
+
// Tab key cycles tabs — but on the Chat tab, let InputBar consume it
|
|
231
|
+
// whenever the slash autocomplete popup would be open.
|
|
228
232
|
if (key.tab && !key.shift) {
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
233
|
+
if (activeTabRef.current === 1) {
|
|
234
|
+
const popupOpen = getSlashMatches(
|
|
235
|
+
inputValueRef.current,
|
|
236
|
+
slashCommandsRef.current,
|
|
237
|
+
);
|
|
238
|
+
if (popupOpen) return;
|
|
232
239
|
}
|
|
233
240
|
setActiveTab((t) => ((t % 7) + 1) as TabId);
|
|
234
241
|
return;
|
|
@@ -454,6 +461,10 @@ export function App({
|
|
|
454
461
|
" Enter Send message",
|
|
455
462
|
" ⌥+Enter Insert newline",
|
|
456
463
|
" ↑/↓ Browse input history",
|
|
464
|
+
" / Open slash-command autocomplete",
|
|
465
|
+
" Tab/Enter Accept highlighted command (popup open)",
|
|
466
|
+
" ↑/↓ Move highlight (popup open)",
|
|
467
|
+
" Esc Close popup",
|
|
457
468
|
"",
|
|
458
469
|
"Tools (Tab 2):",
|
|
459
470
|
" ↑/↓ Select tool call",
|
|
@@ -495,7 +506,7 @@ export function App({
|
|
|
495
506
|
"Commands:",
|
|
496
507
|
" /help Show this help",
|
|
497
508
|
" /skills List available skills",
|
|
498
|
-
" /
|
|
509
|
+
" /exit End the chat session",
|
|
499
510
|
...skillLines,
|
|
500
511
|
].join("\n"),
|
|
501
512
|
timestamp: new Date(),
|
|
@@ -551,14 +562,19 @@ export function App({
|
|
|
551
562
|
);
|
|
552
563
|
|
|
553
564
|
const sessionSkills = ready ? sessionRef.current?.skills : undefined;
|
|
554
|
-
const
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
565
|
+
const slashCommands = useMemo<SlashCommand[]>(() => {
|
|
566
|
+
const skillList = sessionSkills
|
|
567
|
+
? Array.from(sessionSkills.values()).map((s) => ({
|
|
568
|
+
name: s.name,
|
|
569
|
+
description: s.description,
|
|
570
|
+
}))
|
|
571
|
+
: [];
|
|
572
|
+
return buildSlashCommands(BUILTIN_SLASH_COMMANDS, skillList);
|
|
560
573
|
}, [sessionSkills]);
|
|
561
574
|
|
|
575
|
+
slashCommandsRef.current = slashCommands;
|
|
576
|
+
inputValueRef.current = inputValue;
|
|
577
|
+
|
|
562
578
|
const allToolCalls = useMemo(
|
|
563
579
|
() => messages.flatMap((m) => m.toolCalls ?? []),
|
|
564
580
|
[messages],
|
|
@@ -681,8 +697,7 @@ export function App({
|
|
|
681
697
|
disabled={activeTab !== 1}
|
|
682
698
|
history={inputHistory}
|
|
683
699
|
header={inputBarHeader}
|
|
684
|
-
|
|
685
|
-
onTabConsumed={handleTabConsumed}
|
|
700
|
+
slashCommands={slashCommands}
|
|
686
701
|
/>
|
|
687
702
|
<TabBar activeTab={activeTab} />
|
|
688
703
|
</Box>
|