membot 0.5.1 → 0.6.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 (40) hide show
  1. package/.claude/skills/membot.md +25 -10
  2. package/.cursor/rules/membot.mdc +25 -10
  3. package/README.md +35 -4
  4. package/package.json +8 -5
  5. package/scripts/apply-patches.sh +0 -11
  6. package/src/cli.ts +2 -2
  7. package/src/commands/login-page.mustache +50 -0
  8. package/src/commands/login.ts +83 -0
  9. package/src/config/schemas.ts +17 -5
  10. package/src/constants.ts +13 -1
  11. package/src/context.ts +1 -24
  12. package/src/db/files.ts +21 -25
  13. package/src/db/migrations/003-downloader-columns.ts +58 -0
  14. package/src/db/migrations.ts +2 -1
  15. package/src/ingest/converter/index.ts +9 -0
  16. package/src/ingest/converter/xlsx.ts +111 -0
  17. package/src/ingest/downloaders/browser.ts +180 -0
  18. package/src/ingest/downloaders/generic-web.ts +81 -0
  19. package/src/ingest/downloaders/github.ts +178 -0
  20. package/src/ingest/downloaders/google-docs.ts +56 -0
  21. package/src/ingest/downloaders/google-shared.ts +86 -0
  22. package/src/ingest/downloaders/google-sheets.ts +58 -0
  23. package/src/ingest/downloaders/google-slides.ts +53 -0
  24. package/src/ingest/downloaders/index.ts +182 -0
  25. package/src/ingest/downloaders/linear.ts +291 -0
  26. package/src/ingest/fetcher.ts +107 -127
  27. package/src/ingest/ingest.ts +43 -69
  28. package/src/mcp/instructions.ts +4 -2
  29. package/src/operations/add.ts +6 -4
  30. package/src/operations/info.ts +4 -6
  31. package/src/operations/move.ts +2 -3
  32. package/src/operations/refresh.ts +2 -4
  33. package/src/operations/remove.ts +23 -2
  34. package/src/operations/tree.ts +1 -1
  35. package/src/operations/types.ts +1 -1
  36. package/src/refresh/runner.ts +59 -114
  37. package/src/types/text-modules.d.ts +5 -0
  38. package/patches/@evantahler%2Fmcpx@0.21.4.patch +0 -51
  39. package/src/commands/mcpx.ts +0 -112
  40. package/src/ingest/agent-fetcher.ts +0 -564
@@ -0,0 +1,291 @@
1
+ import { HelpfulError } from "../../errors.ts";
2
+ import { sha256Hex } from "../local-reader.ts";
3
+ import type { DownloadedRemote, Downloader } from "./index.ts";
4
+
5
+ const ISSUE_PATH = /^\/([^/]+)\/issue\/([A-Z]+-\d+)(?:$|\/|#|\?)/;
6
+ const PROJECT_PATH = /^\/([^/]+)\/project\/([^/?#]+)/;
7
+
8
+ const GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
9
+
10
+ /**
11
+ * Linear's web app uses a sophisticated cookie + signed-request scheme
12
+ * (`client-api.linear.app/graphql` with `useraccount`/`linear-client-id`
13
+ * headers) that's not realistically replayable from outside a real
14
+ * Linear browser session. Instead we use Linear's official API at
15
+ * `api.linear.app/graphql` with a personal API key — set up once via
16
+ * `membot config set downloaders.linear.api_key <KEY>` after creating
17
+ * the key at https://linear.app/settings/api.
18
+ *
19
+ * The API gives us the structured issue/project payload (title, body,
20
+ * comments, status, …) directly; we render it to markdown
21
+ * deterministically rather than scraping the rendered DOM.
22
+ */
23
+ export const linearDownloader: Downloader = {
24
+ name: "linear",
25
+ description: "Linear (linear.app/<workspace>/issue/<KEY> and /project/<slug>) — uses the Linear API.",
26
+ logins: [
27
+ {
28
+ kind: "api_key",
29
+ name: "Linear",
30
+ url: "https://linear.app/settings/api",
31
+ setupCommand: "membot config set downloaders.linear.api_key <KEY>",
32
+ description: "create a personal API key, then run the command on the right",
33
+ },
34
+ ],
35
+ requiresApiKey: true,
36
+ matches(url) {
37
+ return url.hostname === "linear.app" && (ISSUE_PATH.test(url.pathname) || PROJECT_PATH.test(url.pathname));
38
+ },
39
+ async download(url, ctx): Promise<DownloadedRemote> {
40
+ const apiKey = ctx.config.downloaders.linear.api_key.trim();
41
+ if (apiKey === "") {
42
+ throw new HelpfulError({
43
+ kind: "auth_error",
44
+ message: `Linear API key not configured.`,
45
+ hint: "Create a personal API key at https://linear.app/settings/api, then run `membot config set downloaders.linear.api_key <KEY>`.",
46
+ });
47
+ }
48
+
49
+ const issueMatch = url.pathname.match(ISSUE_PATH);
50
+ const projectMatch = url.pathname.match(PROJECT_PATH);
51
+ let markdown: string;
52
+ let downloaderArgs: Record<string, unknown>;
53
+
54
+ if (issueMatch) {
55
+ const identifier = issueMatch[2] as string;
56
+ ctx.onProgress?.(`querying issue ${identifier}`);
57
+ const issue = await fetchIssue(identifier, apiKey, url);
58
+ markdown = renderIssue(issue);
59
+ downloaderArgs = { kind: "issue", workspace: issueMatch[1], identifier };
60
+ } else if (projectMatch) {
61
+ const slug = projectMatch[2] as string;
62
+ const slugId = extractProjectSlugId(slug);
63
+ ctx.onProgress?.(`querying project ${slugId}`);
64
+ const project = await fetchProject(slugId, apiKey, url);
65
+ markdown = renderProject(project);
66
+ downloaderArgs = { kind: "project", workspace: projectMatch[1], slug, slug_id: slugId };
67
+ } else {
68
+ throw new HelpfulError({
69
+ kind: "input_error",
70
+ message: `not a Linear issue/project URL: ${url.toString()}`,
71
+ hint: "Pass a URL like https://linear.app/<workspace>/issue/<KEY> or .../project/<slug>.",
72
+ });
73
+ }
74
+
75
+ const bytes = new TextEncoder().encode(markdown);
76
+ return {
77
+ bytes,
78
+ sha256: sha256Hex(bytes),
79
+ mimeType: "text/markdown",
80
+ downloader: "linear",
81
+ downloaderArgs,
82
+ sourceUrl: url.toString(),
83
+ };
84
+ },
85
+ };
86
+
87
+ interface LinearUser {
88
+ name?: string | null;
89
+ displayName?: string | null;
90
+ email?: string | null;
91
+ }
92
+
93
+ interface LinearComment {
94
+ body: string | null;
95
+ createdAt: string | null;
96
+ user: LinearUser | null;
97
+ }
98
+
99
+ interface LinearIssue {
100
+ identifier: string;
101
+ url: string;
102
+ title: string;
103
+ description: string | null;
104
+ priorityLabel: string | null;
105
+ state: { name: string } | null;
106
+ assignee: LinearUser | null;
107
+ creator: LinearUser | null;
108
+ createdAt: string;
109
+ updatedAt: string;
110
+ comments: { nodes: LinearComment[] };
111
+ }
112
+
113
+ interface LinearProject {
114
+ id: string;
115
+ url: string;
116
+ name: string;
117
+ slugId: string;
118
+ description: string | null;
119
+ content: string | null;
120
+ state: string | null;
121
+ startDate: string | null;
122
+ targetDate: string | null;
123
+ createdAt: string;
124
+ updatedAt: string;
125
+ lead: LinearUser | null;
126
+ members: { nodes: LinearUser[] };
127
+ }
128
+
129
+ const ISSUE_QUERY = `query Issue($id: String!) {
130
+ issue(id: $id) {
131
+ identifier url title description priorityLabel
132
+ state { name }
133
+ assignee { name displayName email }
134
+ creator { name displayName email }
135
+ createdAt updatedAt
136
+ comments(first: 100) {
137
+ nodes { body createdAt user { name displayName email } }
138
+ }
139
+ }
140
+ }`;
141
+
142
+ const PROJECT_QUERY = `query ProjectBySlug($slugId: String!) {
143
+ projects(filter: { slugId: { eq: $slugId } }, first: 1) {
144
+ nodes {
145
+ id url name slugId description content state startDate targetDate createdAt updatedAt
146
+ lead { name displayName email }
147
+ members(first: 50) { nodes { name displayName email } }
148
+ }
149
+ }
150
+ }`;
151
+
152
+ async function fetchIssue(identifier: string, apiKey: string, url: URL): Promise<LinearIssue> {
153
+ const result = await graphql<{ issue: LinearIssue | null }>(apiKey, ISSUE_QUERY, { id: identifier }, url);
154
+ if (!result.issue) {
155
+ throw new HelpfulError({
156
+ kind: "not_found",
157
+ message: `Linear has no issue ${identifier} visible to this API key.`,
158
+ hint: "Verify the URL exists and that the API key belongs to a member of the issue's workspace.",
159
+ });
160
+ }
161
+ return result.issue;
162
+ }
163
+
164
+ async function fetchProject(slugId: string, apiKey: string, url: URL): Promise<LinearProject> {
165
+ const result = await graphql<{ projects: { nodes: LinearProject[] } }>(apiKey, PROJECT_QUERY, { slugId }, url);
166
+ const project = result.projects.nodes[0];
167
+ if (!project) {
168
+ throw new HelpfulError({
169
+ kind: "not_found",
170
+ message: `Linear has no project with slug ${slugId} visible to this API key.`,
171
+ hint: "Verify the URL exists and that the API key belongs to a member of the project's workspace.",
172
+ });
173
+ }
174
+ return project;
175
+ }
176
+
177
+ /**
178
+ * The trailing token on a Linear project URL is `<name>-<slugId>`,
179
+ * where `slugId` is a 12-char hex suffix. Linear's API matches by
180
+ * `slugId` exactly, so we slice the suffix off here.
181
+ */
182
+ function extractProjectSlugId(slug: string): string {
183
+ const match = slug.match(/-([0-9a-f]{8,})$/i);
184
+ return match ? (match[1] as string) : slug;
185
+ }
186
+
187
+ async function graphql<T>(apiKey: string, query: string, variables: Record<string, unknown>, url: URL): Promise<T> {
188
+ const response = await fetch(GRAPHQL_ENDPOINT, {
189
+ method: "POST",
190
+ headers: {
191
+ "Content-Type": "application/json",
192
+ Authorization: apiKey,
193
+ },
194
+ body: JSON.stringify({ query, variables }),
195
+ });
196
+ if (!response.ok) {
197
+ throw new HelpfulError({
198
+ kind: response.status === 401 || response.status === 403 ? "auth_error" : "network_error",
199
+ message: `Linear GraphQL returned ${response.status} ${response.statusText} for ${url.toString()}.`,
200
+ hint:
201
+ response.status === 401 || response.status === 403
202
+ ? "Re-create the API key at https://linear.app/settings/api and run `membot config set downloaders.linear.api_key <KEY>`."
203
+ : "Check that the URL is reachable and that the API key has access to the issue/project.",
204
+ });
205
+ }
206
+ const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> };
207
+ if (json.errors && json.errors.length > 0) {
208
+ const detail = json.errors.map((e) => e.message).join("; ");
209
+ throw new HelpfulError({
210
+ kind: "input_error",
211
+ message: `Linear GraphQL errors for ${url.toString()}: ${detail}`,
212
+ hint: "Verify the URL is correct and the API key has visibility into the workspace.",
213
+ });
214
+ }
215
+ if (!json.data) {
216
+ throw new HelpfulError({
217
+ kind: "internal_error",
218
+ message: `Linear GraphQL returned no data for ${url.toString()}.`,
219
+ hint: "Re-run with `--verbose` and report the response shape.",
220
+ });
221
+ }
222
+ return json.data;
223
+ }
224
+
225
+ function renderIssue(issue: LinearIssue): string {
226
+ const lines: string[] = [];
227
+ lines.push(`# ${issue.identifier}: ${issue.title}`);
228
+ lines.push("");
229
+ lines.push(`- URL: ${issue.url}`);
230
+ if (issue.state) lines.push(`- Status: ${issue.state.name}`);
231
+ if (issue.priorityLabel) lines.push(`- Priority: ${issue.priorityLabel}`);
232
+ if (issue.assignee) lines.push(`- Assignee: ${userLabel(issue.assignee)}`);
233
+ if (issue.creator) lines.push(`- Author: ${userLabel(issue.creator)}`);
234
+ lines.push(`- Created: ${issue.createdAt}`);
235
+ lines.push(`- Updated: ${issue.updatedAt}`);
236
+ lines.push("");
237
+ if (issue.description) {
238
+ lines.push("## Description");
239
+ lines.push("");
240
+ lines.push(issue.description.trim());
241
+ lines.push("");
242
+ }
243
+ const comments = issue.comments.nodes;
244
+ if (comments.length > 0) {
245
+ lines.push(`## Comments (${comments.length})`);
246
+ lines.push("");
247
+ for (const c of comments) {
248
+ const who = c.user ? userLabel(c.user) : "(unknown)";
249
+ lines.push(`### ${who} — ${c.createdAt ?? ""}`);
250
+ lines.push("");
251
+ lines.push((c.body ?? "").trim());
252
+ lines.push("");
253
+ }
254
+ }
255
+ return lines.join("\n").trim();
256
+ }
257
+
258
+ function renderProject(project: LinearProject): string {
259
+ const lines: string[] = [];
260
+ lines.push(`# ${project.name}`);
261
+ lines.push("");
262
+ lines.push(`- URL: ${project.url}`);
263
+ if (project.state) lines.push(`- State: ${project.state}`);
264
+ if (project.startDate) lines.push(`- Start: ${project.startDate}`);
265
+ if (project.targetDate) lines.push(`- Target: ${project.targetDate}`);
266
+ if (project.lead) lines.push(`- Lead: ${userLabel(project.lead)}`);
267
+ const members = project.members.nodes;
268
+ if (members.length > 0) lines.push(`- Members: ${members.map(userLabel).join(", ")}`);
269
+ lines.push(`- Created: ${project.createdAt}`);
270
+ lines.push(`- Updated: ${project.updatedAt}`);
271
+ lines.push("");
272
+ if (project.description) {
273
+ lines.push("## Summary");
274
+ lines.push("");
275
+ lines.push(project.description.trim());
276
+ lines.push("");
277
+ }
278
+ if (project.content) {
279
+ lines.push("## Overview");
280
+ lines.push("");
281
+ lines.push(project.content.trim());
282
+ lines.push("");
283
+ }
284
+ return lines.join("\n").trim();
285
+ }
286
+
287
+ function userLabel(user: LinearUser): string {
288
+ const name = user.displayName ?? user.name ?? "(unknown)";
289
+ if (user.email) return `${name} <${user.email}>`;
290
+ return name;
291
+ }
@@ -1,153 +1,133 @@
1
- import type { LlmConfig } from "../config/schemas.ts";
2
- import { DEFAULTS } from "../constants.ts";
3
- import { asHelpful, HelpfulError } from "../errors.ts";
1
+ import { join } from "node:path";
2
+ import type { MembotConfig } from "../config/schemas.ts";
3
+ import { FILES } from "../constants.ts";
4
+ import { HelpfulError } from "../errors.ts";
4
5
  import { logger } from "../output/logger.ts";
5
- import type { AgentMcpxAdapter } from "./agent-fetcher.ts";
6
- import { agentFetch } from "./agent-fetcher.ts";
7
- import { sha256Hex } from "./local-reader.ts";
6
+ import { BrowserPool } from "./downloaders/browser.ts";
7
+ import {
8
+ type DownloadedRemote,
9
+ type Downloader,
10
+ type DownloaderCtx,
11
+ findDownloader,
12
+ findDownloaderByName,
13
+ listDownloaders,
14
+ } from "./downloaders/index.ts";
8
15
 
9
- export interface FetchedRemote {
10
- bytes: Uint8Array;
11
- sha256: string;
12
- mimeType: string;
13
- fetcher: "http" | "mcpx";
14
- fetcherServer: string | null;
15
- fetcherTool: string | null;
16
- fetcherArgs: Record<string, unknown> | null;
17
- sourceUrl: string;
18
- }
16
+ export type FetchedRemote = DownloadedRemote;
19
17
 
20
18
  export interface FetchOptions {
21
19
  /**
22
- * User-provided hint. Free-form keyword (e.g. "firecrawl", "github",
23
- * "google-docs", "http"). Special-cased: "http" forces plain fetch.
24
- * Otherwise the hint is passed verbatim to the agent loop as extra
25
- * guidance about which provider to prefer.
20
+ * Optional explicit downloader override. Free-form; matched
21
+ * case-insensitively against `Downloader.name`. When given, skips the
22
+ * URL-based matching and forces that downloader (useful for the
23
+ * "use the generic-web fallback even though google-docs claimed
24
+ * this URL" escape hatch).
25
+ */
26
+ downloaderName?: string;
27
+ /**
28
+ * Override the on-disk path of the persistent chromium profile.
29
+ * Defaults to `<ctx.dataDir>/auth/browser-profile`.
26
30
  */
27
- hint?: string;
28
- /** Live mcpx adapter the agent loop drives via search/list/info/exec. */
29
- mcpx?: AgentMcpxAdapter | null;
31
+ userDataDir?: string;
32
+ /** Pre-built BrowserPool to share across many fetches (set by ingest's outer loop). */
33
+ pool?: BrowserPool;
30
34
  /**
31
- * LLM config. The agent loop needs an Anthropic key; without one the
32
- * mcpx path is skipped and we fall back to plain HTTP.
35
+ * Sublabel hook forwarded to the downloader's `DownloaderCtx`.
36
+ * Drives the per-entry spinner text during multi-step fetches.
33
37
  */
34
- llm?: LlmConfig;
38
+ onProgress?: (sublabel: string) => void;
35
39
  }
36
40
 
37
41
  /**
38
- * Fetch a remote URL.
39
- *
40
- * - `--fetcher http` (or no mcpx, or no LLM key) → plain HTTP.
41
- * - Otherwise multi-turn agent loop: Claude is given mcpx tools
42
- * (search/list/info/exec) and decides how to retrieve the URL,
43
- * including multi-step flows (start a job poll → download).
44
- * The agent's selected mcp_exec invocation is recorded on the
45
- * returned row so refresh can replay it deterministically without
46
- * another agent round-trip.
47
- *
48
- * If the agent decides plain HTTP is the right call (`request_http_fallback`,
49
- * no tool calls, max turns) we transparently fall through to `httpFetch`.
50
- * If the agent reports an actionable failure, we surface that as a
51
- * `HelpfulError`. If mcpx is configured but the LLM key is missing AND
52
- * the HTTP fallback also fails, we surface an `auth_error` naming the env
53
- * var so users see the real cause instead of a misleading 401.
42
+ * Fetch a remote URL via the per-service downloader registry. Specific
43
+ * downloaders (Google, GitHub, Linear) match first; the generic-web
44
+ * downloader is the always-matching catch-all. Every fetch authenticates
45
+ * via the cookies the user persisted with `membot login`. The returned
46
+ * shape includes the chosen downloader name and its args so refresh can
47
+ * replay it deterministically without involving the LLM.
54
48
  */
55
- export async function fetchRemote(url: string, options: FetchOptions = {}): Promise<FetchedRemote> {
56
- const mcpx = options.mcpx;
57
- const hint = options.hint?.trim();
49
+ export async function fetchRemote(
50
+ url: string,
51
+ config: MembotConfig,
52
+ options: FetchOptions = {},
53
+ dataDir?: string,
54
+ ): Promise<FetchedRemote> {
55
+ const downloader = pickDownloader(url, options.downloaderName);
56
+ const userDataDir = options.userDataDir ?? defaultProfileDir(dataDir);
57
+ const ownsPool = !options.pool;
58
+ const headless = !downloader.requireHeaded;
59
+ const pool = options.pool ?? new BrowserPool({ userDataDir, headless });
60
+ const dctx: DownloaderCtx = { pool, logger, config, onProgress: options.onProgress };
58
61
 
59
- if (hint === "http") return httpFetch(url);
60
- if (!mcpx) return httpFetch(url);
61
-
62
- const apiKey = options.llm?.anthropic_api_key?.trim();
63
- if (!apiKey) {
64
- // No way to drive the agent. Try HTTP; if that fails, the user
65
- // almost certainly wanted mcpx — surface a clear key-missing error.
66
- try {
67
- return await httpFetch(url);
68
- } catch (err) {
69
- if (err instanceof HelpfulError && err.kind === "network_error") {
70
- throw new HelpfulError({
71
- kind: "auth_error",
72
- message: `${url} couldn't be fetched directly (${err.message}). Membot has mcpx configured, but routing through it requires Claude to translate the URL into the right tool arguments — and ANTHROPIC_API_KEY isn't set.`,
73
- hint: `Set ANTHROPIC_API_KEY in your environment (or under llm.anthropic_api_key in ~/.membot/config.json), then retry. To force the HTTP path explicitly, run \`membot add ${url} --fetcher http\`.`,
74
- });
75
- }
76
- throw err;
77
- }
78
- }
79
-
80
- let outcome: Awaited<ReturnType<typeof agentFetch>>;
81
62
  try {
82
- outcome = await agentFetch({ url, mcpx, llm: options.llm!, hint });
83
- } catch (err) {
84
- if (err instanceof HelpfulError) throw err;
85
- logger.warn(`agent-fetch failed (${err instanceof Error ? err.message : String(err)}) — falling back to HTTP`);
86
- return httpFetch(url);
63
+ // Fetches are strictly non-interactive: there's no auto-launch
64
+ // of a browser when auth fails. Batch ingest (`membot add` of
65
+ // many URLs) and the refresh daemon both run without a human
66
+ // available to drive a window, so any auth_error must
67
+ // propagate as-is. The HelpfulError's hint tells the user to
68
+ // `membot login` (cookie-based services) or `membot config set
69
+ // downloaders.<svc>.api_key` (API-key services); they fix it
70
+ // once and re-run.
71
+ return await downloader.download(new URL(url), dctx);
72
+ } finally {
73
+ if (ownsPool) await pool.dispose();
87
74
  }
75
+ }
88
76
 
89
- if (outcome.kind === "accepted") {
90
- return {
91
- bytes: outcome.result.bytes,
92
- sha256: outcome.result.sha256,
93
- mimeType: outcome.result.mimeType,
94
- fetcher: "mcpx",
95
- fetcherServer: outcome.result.fetcherServer,
96
- fetcherTool: outcome.result.fetcherTool,
97
- fetcherArgs: outcome.result.fetcherArgs,
98
- sourceUrl: url,
99
- };
77
+ /**
78
+ * Replay a fetch by downloader name (used by refresh). Looks up the
79
+ * persisted downloader by name and calls it against the original URL —
80
+ * deterministic, no agent loop. When the persisted downloader is no
81
+ * longer registered (e.g. from a prior membot version), falls back to
82
+ * URL-based dispatch so refresh degrades gracefully instead of erroring.
83
+ */
84
+ export async function fetchRemoteByDownloader(
85
+ downloaderName: string | null,
86
+ url: string,
87
+ pool: BrowserPool,
88
+ config: MembotConfig,
89
+ ): Promise<FetchedRemote> {
90
+ const named = downloaderName ? findDownloaderByName(downloaderName) : null;
91
+ const downloader = named ?? findDownloader(url);
92
+ if (!downloader) {
93
+ throw new HelpfulError({
94
+ kind: "input_error",
95
+ message: `no downloader matches ${url}`,
96
+ hint: "Re-add the URL with `membot add <url>` to pick a fresh downloader.",
97
+ });
100
98
  }
101
- logger.debug(`agent-fetch fell back to HTTP: ${outcome.reason}`);
102
- return httpFetch(url);
99
+ const dctx: DownloaderCtx = { pool, logger, config };
100
+ return downloader.download(new URL(url), dctx);
103
101
  }
104
102
 
105
- /** Plain `fetch` fallback. Used when mcpx isn't configured or the hint says so. */
106
- async function httpFetch(url: string): Promise<FetchedRemote> {
107
- let resp: Response;
108
- try {
109
- resp = await fetch(url, {
110
- headers: { "User-Agent": "membot/0.1" },
111
- signal: AbortSignal.timeout(DEFAULTS.HTTP_TIMEOUT_MS),
112
- });
113
- } catch (err) {
114
- throw asHelpful(
115
- err,
116
- `while fetching ${url}`,
117
- `Check your network and that ${url} is reachable. For mcpx-managed sources (gdocs/github/firecrawl), set ANTHROPIC_API_KEY so membot can drive an mcpx tool.`,
118
- "network_error",
119
- );
103
+ function pickDownloader(url: string, override?: string): Downloader {
104
+ if (override) {
105
+ const named = findDownloaderByName(override.toLowerCase());
106
+ if (!named) {
107
+ const available = listDownloaders()
108
+ .map((d) => d.name)
109
+ .join(", ");
110
+ throw new HelpfulError({
111
+ kind: "input_error",
112
+ message: `unknown downloader '${override}'`,
113
+ hint: `Pick one of: ${available}.`,
114
+ });
115
+ }
116
+ return named;
120
117
  }
121
- if (!resp.ok) {
118
+ const matched = findDownloader(url);
119
+ if (!matched) {
122
120
  throw new HelpfulError({
123
- kind: "network_error",
124
- message: `HTTP ${resp.status} ${resp.statusText}: ${url}`,
125
- hint: "Verify the URL is reachable and not gated behind auth. For private docs use mcpx (set ANTHROPIC_API_KEY).",
121
+ kind: "input_error",
122
+ message: `not a fetchable URL: ${url}`,
123
+ hint: "Pass an http(s):// URL.",
126
124
  });
127
125
  }
128
- const bytes = new Uint8Array(await resp.arrayBuffer());
129
- const ct = resp.headers.get("content-type") ?? "";
130
- const mime = ct.split(";")[0]?.trim() || "application/octet-stream";
131
- return {
132
- bytes,
133
- sha256: sha256Hex(bytes),
134
- mimeType: mime,
135
- fetcher: "http",
136
- fetcherServer: null,
137
- fetcherTool: null,
138
- fetcherArgs: null,
139
- sourceUrl: url,
140
- };
126
+ return matched;
141
127
  }
142
128
 
143
- /**
144
- * Detect MCP `CallToolResult` envelopes that signal tool failure. MCP
145
- * tool errors don't throw — they return `{ isError: true, content: [...] }`
146
- * — so callers must check this explicitly before treating the content
147
- * as a successful payload. Used by the refresh runner; the agent loop
148
- * has its own preview-aware check.
149
- */
150
- export function isMcpToolError(result: unknown): boolean {
151
- if (!result || typeof result !== "object") return false;
152
- return (result as { isError?: unknown }).isError === true;
129
+ function defaultProfileDir(dataDir?: string): string {
130
+ if (dataDir) return join(dataDir, FILES.BROWSER_PROFILE);
131
+ const home = process.env.MEMBOT_HOME ?? `${process.env.HOME ?? "."}/.membot`;
132
+ return join(home, FILES.BROWSER_PROFILE);
153
133
  }