botholomew 0.12.3 → 0.13.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.
Files changed (104) hide show
  1. package/README.md +91 -68
  2. package/package.json +3 -3
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +630 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +279 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +73 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +44 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +25 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +3 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/types/file-imports.d.ts +9 -0
  80. package/src/utils/title.ts +5 -7
  81. package/src/utils/v7-date.ts +47 -0
  82. package/src/worker/heartbeat.ts +46 -24
  83. package/src/worker/index.ts +13 -15
  84. package/src/worker/llm.ts +30 -37
  85. package/src/worker/prompt.ts +19 -41
  86. package/src/worker/schedules.ts +48 -69
  87. package/src/worker/spawn.ts +11 -11
  88. package/src/worker/tick.ts +39 -43
  89. package/src/workers/store.ts +247 -0
  90. package/src/commands/tools.ts +0 -367
  91. package/src/context/describer.ts +0 -140
  92. package/src/context/drives.ts +0 -110
  93. package/src/context/ingest.ts +0 -162
  94. package/src/context/refresh.ts +0 -183
  95. package/src/db/context.ts +0 -637
  96. package/src/db/daemon-state.ts +0 -6
  97. package/src/db/reembed.ts +0 -113
  98. package/src/db/schedules.ts +0 -213
  99. package/src/db/tasks.ts +0 -347
  100. package/src/db/threads.ts +0 -276
  101. package/src/db/workers.ts +0 -212
  102. package/src/tools/context/list-drives.ts +0 -36
  103. package/src/tools/context/refresh.ts +0 -165
  104. package/src/tools/context/search.ts +0 -54
@@ -1,367 +0,0 @@
1
- import ansis from "ansis";
2
- import type { Command } from "commander";
3
- import { z } from "zod";
4
- import { loadConfig } from "../config/loader.ts";
5
- import { getDbPath } from "../constants.ts";
6
- import { parseDriveRef } from "../context/drives.ts";
7
- import type { DbConnection } from "../db/connection.ts";
8
- import { getContextItemById } from "../db/context.ts";
9
- import { isUuid } from "../db/uuid.ts";
10
- import { registerAllTools } from "../tools/registry.ts";
11
- import {
12
- type AnyToolDefinition,
13
- getToolsByGroup,
14
- type ToolContext,
15
- } from "../tools/tool.ts";
16
- import { logger } from "../utils/logger.ts";
17
- import { withDb } from "./with-db.ts";
18
-
19
- registerAllTools();
20
-
21
- /**
22
- * Register context tool subcommands (read, write, edit, etc.) onto an
23
- * existing Commander command. Skips tools whose derived subcommand name
24
- * collides with an already-registered subcommand on the parent.
25
- */
26
- /** Context tools that are agent-only (not exposed as CLI subcommands) */
27
- const AGENT_ONLY_TOOLS = new Set(["update_beliefs", "update_goals"]);
28
-
29
- export function registerContextToolSubcommands(parent: Command) {
30
- const existing = new Set(parent.commands.map((c: Command) => c.name()));
31
-
32
- for (const tool of getToolsByGroup("context")) {
33
- if (AGENT_ONLY_TOOLS.has(tool.name)) continue;
34
- const subName = deriveSubName(tool.name);
35
- if (existing.has(subName)) continue; // skip conflicts with management subcommands
36
- registerToolAsCLI(parent, tool);
37
- }
38
- }
39
-
40
- /** Derive CLI subcommand name from tool name: "context_read" → "read", "context_create_dir" → "create-dir" */
41
- function deriveSubName(toolName: string): string {
42
- return toolName.replace(/^[^_]+_/, "").replace(/_/g, "-");
43
- }
44
-
45
- function registerToolAsCLI(parent: Command, tool: AnyToolDefinition) {
46
- const subName = deriveSubName(tool.name);
47
-
48
- // Inspect zod schema to determine positional args and options
49
- const shape = tool.inputSchema.shape as Record<string, z.ZodType>;
50
- const positionals: string[] = [];
51
- const options: {
52
- key: string;
53
- flag: string;
54
- description: string;
55
- isArray: boolean;
56
- }[] = [];
57
-
58
- for (const [key, schema] of Object.entries(shape)) {
59
- const desc = schema.description ?? key;
60
- const isOptional = schema.isOptional();
61
- const unwrapped = unwrapSchema(schema);
62
-
63
- if (isPositionalArg(key, tool.name)) {
64
- positionals.push(isOptional ? `[${key}]` : `<${key}>`);
65
- } else if (unwrapped instanceof z.ZodBoolean) {
66
- options.push({
67
- key,
68
- flag: `--${key.replace(/_/g, "-")}`,
69
- description: desc,
70
- isArray: false,
71
- });
72
- } else if (unwrapped instanceof z.ZodArray) {
73
- options.push({
74
- key,
75
- flag: `--${key.replace(/_/g, "-")} <json>`,
76
- description: desc,
77
- isArray: true,
78
- });
79
- } else {
80
- options.push({
81
- key,
82
- flag: `--${key.replace(/_/g, "-")} <value>`,
83
- description: desc,
84
- isArray: false,
85
- });
86
- }
87
- }
88
-
89
- const cmd = parent
90
- .command(`${subName} ${positionals.join(" ")}`.trim())
91
- .description(tool.description);
92
-
93
- for (const opt of options) {
94
- if (opt.isArray) {
95
- cmd.option(opt.flag, opt.description);
96
- } else {
97
- cmd.option(opt.flag, opt.description);
98
- }
99
- }
100
-
101
- cmd.action((...args: unknown[]) => {
102
- let root: Command = parent;
103
- while (root.parent) root = root.parent;
104
- return withDb(root, async (conn, dir) => {
105
- try {
106
- const input = await buildInput(
107
- tool,
108
- positionals,
109
- options,
110
- shape,
111
- args,
112
- conn,
113
- );
114
-
115
- const ctx: ToolContext = {
116
- conn,
117
- dbPath: getDbPath(dir),
118
- projectDir: dir,
119
- config: await loadConfig(dir),
120
- mcpxClient: null,
121
- };
122
-
123
- const result = await tool.execute(input, ctx);
124
- formatOutput(result, tool.name);
125
- } catch (err) {
126
- logger.error(String(err));
127
- process.exit(1);
128
- }
129
- });
130
- });
131
- }
132
-
133
- async function buildInput(
134
- tool: AnyToolDefinition,
135
- positionals: string[],
136
- options: {
137
- key: string;
138
- flag: string;
139
- description: string;
140
- isArray: boolean;
141
- }[],
142
- shape: Record<string, z.ZodType>,
143
- args: unknown[],
144
- conn: DbConnection,
145
- ): Promise<Record<string, unknown>> {
146
- const input: Record<string, unknown> = {};
147
-
148
- // Positional args come first in Commander's action callback. Context tools
149
- // carry `(drive, path)` or `(src_drive, src_path, …)` in their schema but
150
- // accept a friendlier `drive:/path` or bare-UUID form as a single positional
151
- // on the CLI.
152
- for (let i = 0; i < positionals.length; i++) {
153
- const key = positionals[i]?.replace(/[<>[\]]/g, "");
154
- const value = args[i];
155
- if (key === undefined || value === undefined) continue;
156
- const splitTargets = driveRefSplitTargets(key, shape);
157
- if (splitTargets && typeof value === "string") {
158
- const parsed = parseDriveRef(value);
159
- if (parsed) {
160
- input[splitTargets.drive] = parsed.drive;
161
- input[splitTargets.path] = parsed.path;
162
- continue;
163
- }
164
- if (isUuid(value)) {
165
- const item = await getContextItemById(conn, value);
166
- if (item) {
167
- input[splitTargets.drive] = item.drive;
168
- input[splitTargets.path] = item.path;
169
- continue;
170
- }
171
- }
172
- }
173
- input[key] = value;
174
- }
175
-
176
- // Options object is the last argument before the Command object
177
- const optsObj = (args[positionals.length] ?? {}) as Record<string, unknown>;
178
-
179
- for (const opt of options) {
180
- const cliKey = opt.key.replace(/_/g, "-");
181
- // Commander converts --foo-bar to fooBar
182
- const camelKey = cliKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
183
- let value = optsObj[camelKey] ?? optsObj[opt.key];
184
-
185
- if (value === undefined) continue;
186
-
187
- const schemaForKey = shape[opt.key];
188
- if (!schemaForKey) continue;
189
- const unwrapped = unwrapSchema(schemaForKey);
190
-
191
- // Parse JSON for array types
192
- if (opt.isArray && typeof value === "string") {
193
- value = JSON.parse(value);
194
- }
195
- // Parse numbers
196
- else if (unwrapped instanceof z.ZodNumber && typeof value === "string") {
197
- value = Number(value);
198
- }
199
-
200
- input[opt.key] = value;
201
- }
202
-
203
- // Validate with zod
204
- const parsed = tool.inputSchema.safeParse(input);
205
- if (!parsed.success) {
206
- throw new Error(`Invalid arguments: ${JSON.stringify(parsed.error)}`);
207
- }
208
-
209
- return parsed.data;
210
- }
211
-
212
- function formatOutput(result: unknown, _toolName: string) {
213
- if (result == null) return;
214
-
215
- if (typeof result === "object") {
216
- const obj = result as Record<string, unknown>;
217
-
218
- // Structured error shape: { is_error: true, message, next_action_hint? }
219
- if (obj.is_error === true) {
220
- const msg = typeof obj.message === "string" ? obj.message : "Error";
221
- logger.error(msg);
222
- if (
223
- typeof obj.next_action_hint === "string" &&
224
- obj.next_action_hint.length > 0
225
- ) {
226
- console.log(ansis.dim(obj.next_action_hint));
227
- }
228
- process.exit(1);
229
- }
230
-
231
- // Special formatting for known output shapes
232
- if ("tree" in obj && typeof obj.tree === "string") {
233
- console.log(obj.tree);
234
- return;
235
- }
236
-
237
- if ("content" in obj && typeof obj.content === "string") {
238
- console.log(obj.content);
239
- return;
240
- }
241
-
242
- if ("exists" in obj && typeof obj.exists === "boolean") {
243
- if (!obj.exists) process.exit(1);
244
- return;
245
- }
246
-
247
- if ("entries" in obj && Array.isArray(obj.entries)) {
248
- for (const entry of obj.entries) {
249
- const e = entry as { name: string; type: string; size: number };
250
- const suffix = e.type === "directory" ? "/" : "";
251
- console.log(` ${e.name}${suffix}`);
252
- }
253
- return;
254
- }
255
-
256
- if ("drives" in obj && Array.isArray(obj.drives)) {
257
- const drives = obj.drives as { drive: string; count: number }[];
258
- if (drives.length === 0) {
259
- if (typeof obj.hint === "string") console.log(ansis.dim(obj.hint));
260
- return;
261
- }
262
- const widest = Math.max(...drives.map((d) => d.drive.length));
263
- for (const d of drives) {
264
- const label = `${d.drive}:/`.padEnd(widest + 2);
265
- const plural = d.count === 1 ? "item" : "items";
266
- console.log(
267
- ` ${ansis.cyan(label)} ${ansis.dim(`(${d.count} ${plural})`)}`,
268
- );
269
- }
270
- if (typeof obj.hint === "string") {
271
- console.log(`\n${ansis.dim(obj.hint)}`);
272
- }
273
- return;
274
- }
275
-
276
- if ("matches" in obj && Array.isArray(obj.matches)) {
277
- for (const match of obj.matches) {
278
- if (typeof match === "string") {
279
- console.log(match);
280
- } else {
281
- const m = match as { path: string; line: number; content: string };
282
- console.log(`${m.path}:${m.line}: ${m.content}`);
283
- }
284
- }
285
- return;
286
- }
287
-
288
- if ("results" in obj && Array.isArray(obj.results)) {
289
- for (const [i, r] of (
290
- obj.results as {
291
- path: string;
292
- title: string;
293
- score: number;
294
- snippet: string;
295
- }[]
296
- ).entries()) {
297
- const score = (r.score * 100).toFixed(1);
298
- console.log(
299
- `${ansis.bold(`${i + 1}.`)} ${ansis.cyan(r.title)} ${ansis.dim(`(${score}%)`)}`,
300
- );
301
- console.log(` ${ansis.dim(r.path)}`);
302
- if (r.snippet) {
303
- const snippet = r.snippet.slice(0, 120).replace(/\n/g, " ");
304
- console.log(` ${snippet}...`);
305
- }
306
- console.log("");
307
- }
308
- return;
309
- }
310
-
311
- // Default: print as JSON
312
- console.log(JSON.stringify(obj, null, 2));
313
- } else {
314
- console.log(result);
315
- }
316
- }
317
-
318
- function isPositionalArg(key: string, toolName: string): boolean {
319
- // These keys are treated as positional arguments
320
- const positionalKeys: Record<string, string[]> = {
321
- context_create_dir: ["path"],
322
- context_tree: ["path"],
323
- context_dir_size: ["path"],
324
- context_read: ["path"],
325
- context_write: ["path"],
326
- context_edit: ["path"],
327
- context_delete: ["path"],
328
- context_copy: ["src", "dst"],
329
- context_move: ["src", "dst"],
330
- context_info: ["path"],
331
- context_exists: ["path"],
332
- context_count_lines: ["path"],
333
- context_search: ["query"],
334
- };
335
- return positionalKeys[toolName]?.includes(key) ?? false;
336
- }
337
-
338
- function unwrapSchema(schema: z.ZodType): z.ZodType {
339
- if (schema instanceof z.ZodOptional) {
340
- return unwrapSchema(schema.unwrap() as z.ZodType);
341
- }
342
- if (schema instanceof z.ZodDefault) {
343
- return unwrapSchema(schema.unwrap() as z.ZodType);
344
- }
345
- return schema;
346
- }
347
-
348
- /**
349
- * Decide how to expand a positional `path`/`src`/`dst` value into the tool's
350
- * schema when it carries a `drive:/path` prefix. Returns the drive+path field
351
- * names in the schema, or null if the schema has no matching drive field.
352
- */
353
- function driveRefSplitTargets(
354
- positionalKey: string,
355
- shape: Record<string, z.ZodType>,
356
- ): { drive: string; path: string } | null {
357
- if (positionalKey === "path" && "drive" in shape && "path" in shape) {
358
- return { drive: "drive", path: "path" };
359
- }
360
- if (positionalKey === "src" && "src_drive" in shape && "src_path" in shape) {
361
- return { drive: "src_drive", path: "src_path" };
362
- }
363
- if (positionalKey === "dst" && "dst_drive" in shape && "dst_path" in shape) {
364
- return { drive: "dst_drive", path: "dst_path" };
365
- }
366
- return null;
367
- }
@@ -1,140 +0,0 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
- import type { BotholomewConfig } from "../config/schemas.ts";
3
- import { logger } from "../utils/logger.ts";
4
-
5
- const DESCRIBE_TOOL_NAME = "return_description";
6
-
7
- const DESCRIBE_TOOL = {
8
- name: DESCRIBE_TOOL_NAME,
9
- description: "Return a one-sentence description of this content.",
10
- input_schema: {
11
- type: "object" as const,
12
- properties: {
13
- description: {
14
- type: "string",
15
- description:
16
- "A concise one-sentence summary of what this content is about.",
17
- },
18
- },
19
- required: ["description"],
20
- },
21
- };
22
-
23
- const TIMEOUT_MS = 10_000;
24
- const MAX_CONTENT_CHARS = 8000;
25
- const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
26
-
27
- const IMAGE_TYPES = new Set([
28
- "image/jpeg",
29
- "image/png",
30
- "image/gif",
31
- "image/webp",
32
- ]);
33
-
34
- type ImageMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
35
-
36
- async function buildMessageContent(
37
- opts: DescriberOpts,
38
- ): Promise<Anthropic.Messages.ContentBlockParam[]> {
39
- const textPrompt = `Describe this file in one sentence. Be specific about what it contains, not generic.\n\nFilename: ${opts.filename}\nMIME type: ${opts.mimeType}`;
40
-
41
- if (opts.content) {
42
- const truncated =
43
- opts.content.length > MAX_CONTENT_CHARS
44
- ? `${opts.content.slice(0, MAX_CONTENT_CHARS)}\n... (truncated)`
45
- : opts.content;
46
- return [{ type: "text", text: `${textPrompt}\n\nContent:\n${truncated}` }];
47
- }
48
-
49
- if (opts.filePath) {
50
- const file = Bun.file(opts.filePath);
51
- const size = file.size;
52
-
53
- if (size > 0 && size <= MAX_FILE_BYTES) {
54
- const data = Buffer.from(await file.arrayBuffer()).toString("base64");
55
-
56
- if (IMAGE_TYPES.has(opts.mimeType)) {
57
- return [
58
- {
59
- type: "image",
60
- source: {
61
- type: "base64",
62
- media_type: opts.mimeType as ImageMediaType,
63
- data,
64
- },
65
- },
66
- { type: "text", text: textPrompt },
67
- ];
68
- }
69
-
70
- if (opts.mimeType === "application/pdf") {
71
- return [
72
- {
73
- type: "document",
74
- source: { type: "base64", media_type: "application/pdf", data },
75
- },
76
- { type: "text", text: textPrompt },
77
- ];
78
- }
79
- }
80
- }
81
-
82
- return [
83
- {
84
- type: "text",
85
- text: `${textPrompt}\n\n(Binary file — no content preview available)`,
86
- },
87
- ];
88
- }
89
-
90
- interface DescriberOpts {
91
- filename: string;
92
- mimeType: string;
93
- content: string | null;
94
- filePath?: string;
95
- }
96
-
97
- /**
98
- * Generate a short description of a file using the LLM.
99
- * For textual files, summarises the content.
100
- * For binary files, attaches images/PDFs directly or describes from metadata.
101
- */
102
- export async function generateDescription(
103
- config: Required<BotholomewConfig>,
104
- opts: DescriberOpts,
105
- ): Promise<string> {
106
- if (!config.anthropic_api_key) {
107
- return "";
108
- }
109
-
110
- const client = new Anthropic({ apiKey: config.anthropic_api_key });
111
-
112
- try {
113
- const content = await buildMessageContent(opts);
114
-
115
- const response = await Promise.race([
116
- client.messages.create({
117
- model: config.chunker_model,
118
- max_tokens: 256,
119
- tools: [DESCRIBE_TOOL],
120
- tool_choice: { type: "tool", name: DESCRIBE_TOOL_NAME },
121
- messages: [{ role: "user", content }],
122
- }),
123
- new Promise<never>((_, reject) =>
124
- setTimeout(
125
- () => reject(new Error("Description generation timeout")),
126
- TIMEOUT_MS,
127
- ),
128
- ),
129
- ]);
130
-
131
- const toolBlock = response.content.find((b) => b.type === "tool_use");
132
- if (!toolBlock || toolBlock.type !== "tool_use") return "";
133
-
134
- const input = toolBlock.input as { description: string };
135
- return input.description || "";
136
- } catch (err) {
137
- logger.debug(`Description generation failed: ${err}`);
138
- return "";
139
- }
140
- }
@@ -1,110 +0,0 @@
1
- /**
2
- * Drives name the origin of a context item. Every item lives at a
3
- * `(drive, path)` pair; the `drive:/path` string form is a display and CLI
4
- * convention (single column queries use the two columns directly).
5
- *
6
- * Built-in drives:
7
- * disk — local filesystem; path is the absolute filesystem path
8
- * url — generic HTTP(S) URL; path is the full URL
9
- * agent — agent-authored scratch; path is whatever the agent chose
10
- * google-docs — Google Docs; path is `/<docId>`
11
- * github — GitHub content; path is `/<owner>/<repo>/<rest>`
12
- */
13
-
14
- export const BUILT_IN_DRIVES = [
15
- "disk",
16
- "url",
17
- "agent",
18
- "google-docs",
19
- "github",
20
- ] as const;
21
-
22
- export interface DriveTarget {
23
- drive: string;
24
- path: string;
25
- }
26
-
27
- /** Parse `drive:/path` → `{ drive, path }`. Returns null if not in drive form. */
28
- export function parseDriveRef(ref: string): DriveTarget | null {
29
- const i = ref.indexOf(":");
30
- if (i <= 0) return null;
31
- const drive = ref.slice(0, i);
32
- const path = ref.slice(i + 1);
33
- if (!path.startsWith("/")) return null;
34
- if (!/^[a-z][a-z0-9_-]*$/.test(drive)) return null;
35
- return { drive, path };
36
- }
37
-
38
- /** Format a `(drive, path)` pair for display / CLI. */
39
- export function formatDriveRef(target: DriveTarget): string {
40
- return `${target.drive}:${target.path}`;
41
- }
42
-
43
- /**
44
- * Detect the right drive for a URL. If `mcpxServerName` is provided, prefer it
45
- * as a hint (some MCP servers are named after the service they back).
46
- */
47
- export function detectDriveFromUrl(
48
- url: string,
49
- mcpxServerName?: string | null,
50
- ): DriveTarget {
51
- const hint = mcpxServerName?.toLowerCase() ?? "";
52
- let parsed: URL | null = null;
53
- try {
54
- parsed = new URL(url);
55
- } catch {
56
- return { drive: "url", path: `/${url}` };
57
- }
58
-
59
- const host = parsed.hostname.toLowerCase();
60
-
61
- if (
62
- host === "docs.google.com" ||
63
- (hint.includes("google") && hint.includes("doc"))
64
- ) {
65
- const docId = extractGoogleDocId(parsed);
66
- if (docId) return { drive: "google-docs", path: `/${docId}` };
67
- }
68
-
69
- if (
70
- host === "github.com" ||
71
- host === "raw.githubusercontent.com" ||
72
- hint.includes("github")
73
- ) {
74
- const ghPath = extractGithubPath(parsed);
75
- if (ghPath) return { drive: "github", path: ghPath };
76
- }
77
-
78
- return { drive: "url", path: `/${url}` };
79
- }
80
-
81
- function extractGoogleDocId(u: URL): string | null {
82
- // https://docs.google.com/document/d/<docId>/edit
83
- // https://docs.google.com/spreadsheets/d/<docId>/edit
84
- const m = u.pathname.match(/\/d\/([^/]+)/);
85
- return m?.[1] ?? null;
86
- }
87
-
88
- function extractGithubPath(u: URL): string | null {
89
- // https://github.com/<owner>/<repo>/blob/<ref>/<path...>
90
- // https://github.com/<owner>/<repo>/tree/<ref>/<path...>
91
- // https://github.com/<owner>/<repo>
92
- // https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path...>
93
- const segs = u.pathname.split("/").filter(Boolean);
94
- if (segs.length < 2) return null;
95
- const [owner, repo, kind, _ref, ...rest] = segs;
96
- if (!owner || !repo) return null;
97
- if (u.hostname === "raw.githubusercontent.com") {
98
- // segs: owner, repo, ref, ...rest
99
- const [_o, _r, _f, ...raw] = segs;
100
- return raw.length > 0
101
- ? `/${owner}/${repo}/${raw.join("/")}`
102
- : `/${owner}/${repo}`;
103
- }
104
- if (kind === "blob" || kind === "tree") {
105
- return rest.length > 0
106
- ? `/${owner}/${repo}/${rest.join("/")}`
107
- : `/${owner}/${repo}`;
108
- }
109
- return `/${owner}/${repo}`;
110
- }