membot 0.4.2 → 0.5.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.
@@ -1,6 +1,9 @@
1
+ import type { LlmConfig } from "../config/schemas.ts";
1
2
  import { DEFAULTS } from "../constants.ts";
2
3
  import { asHelpful, HelpfulError } from "../errors.ts";
3
4
  import { logger } from "../output/logger.ts";
5
+ import type { AgentMcpxAdapter } from "./agent-fetcher.ts";
6
+ import { agentFetch } from "./agent-fetcher.ts";
4
7
  import { sha256Hex } from "./local-reader.ts";
5
8
 
6
9
  export interface FetchedRemote {
@@ -14,38 +17,40 @@ export interface FetchedRemote {
14
17
  sourceUrl: string;
15
18
  }
16
19
 
17
- export interface McpxToolDescriptor {
18
- server: string;
19
- tool: { name: string; description?: string };
20
- }
21
-
22
- export interface McpxSearchHit {
23
- server: string;
24
- tool: { name: string; description?: string };
25
- score?: number;
26
- }
27
-
28
20
  export interface FetchOptions {
29
21
  /**
30
22
  * User-provided hint. Free-form keyword (e.g. "firecrawl", "github",
31
23
  * "google-docs", "http"). Special-cased: "http" forces plain fetch.
32
- * Otherwise the hint is used as a search query against the live
33
- * mcpx tool catalog we never hardcode server names.
24
+ * Otherwise the hint is passed verbatim to the agent loop as extra
25
+ * guidance about which provider to prefer.
34
26
  */
35
27
  hint?: string;
36
- /** Live mcpx adapter. Use listTools/search/exec to find a fetcher on the fly. */
37
- mcpx?: {
38
- exec(server: string, tool: string, args: Record<string, unknown>): Promise<unknown>;
39
- listTools(): Promise<McpxToolDescriptor[]>;
40
- search?(query: string): Promise<McpxSearchHit[]>;
41
- } | null;
28
+ /** Live mcpx adapter the agent loop drives via search/list/info/exec. */
29
+ mcpx?: AgentMcpxAdapter | null;
30
+ /**
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.
33
+ */
34
+ llm?: LlmConfig;
42
35
  }
43
36
 
44
37
  /**
45
- * Fetch a remote URL, preferring an mcpx-managed server (Firecrawl, Google
46
- * Docs, GitHub, …) for known providers and falling back to a plain `fetch`
47
- * otherwise. The chosen invocation (server/tool/args) is returned alongside
48
- * the bytes so the caller can persist it on the row for replay-on-refresh.
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.
49
54
  */
50
55
  export async function fetchRemote(url: string, options: FetchOptions = {}): Promise<FetchedRemote> {
51
56
  const mcpx = options.mcpx;
@@ -54,8 +59,46 @@ export async function fetchRemote(url: string, options: FetchOptions = {}): Prom
54
59
  if (hint === "http") return httpFetch(url);
55
60
  if (!mcpx) return httpFetch(url);
56
61
 
57
- const tried = await tryMcpx(url, mcpx, hint);
58
- if (tried) return tried;
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
+ 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);
87
+ }
88
+
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
+ };
100
+ }
101
+ logger.debug(`agent-fetch fell back to HTTP: ${outcome.reason}`);
59
102
  return httpFetch(url);
60
103
  }
61
104
 
@@ -71,7 +114,7 @@ async function httpFetch(url: string): Promise<FetchedRemote> {
71
114
  throw asHelpful(
72
115
  err,
73
116
  `while fetching ${url}`,
74
- `Check your network and that ${url} is reachable. For mcpx-managed sources (gdocs/github/firecrawl), set --fetcher firecrawl etc.`,
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.`,
75
118
  "network_error",
76
119
  );
77
120
  }
@@ -79,7 +122,7 @@ async function httpFetch(url: string): Promise<FetchedRemote> {
79
122
  throw new HelpfulError({
80
123
  kind: "network_error",
81
124
  message: `HTTP ${resp.status} ${resp.statusText}: ${url}`,
82
- hint: "Verify the URL is reachable and not gated behind auth. For private docs use mcpx via --fetcher.",
125
+ hint: "Verify the URL is reachable and not gated behind auth. For private docs use mcpx (set ANTHROPIC_API_KEY).",
83
126
  });
84
127
  }
85
128
  const bytes = new Uint8Array(await resp.arrayBuffer());
@@ -98,183 +141,13 @@ async function httpFetch(url: string): Promise<FetchedRemote> {
98
141
  }
99
142
 
100
143
  /**
101
- * Attempt to fetch via mcpx by discovering a suitable tool at runtime.
102
- *
103
- * Strategy:
104
- * 1. If the user passed a hint, search for it via mcpx.search() (semantic
105
- * tool search over the live catalog). The hint is the user's free-text
106
- * label for which provider they want — we never assume server names.
107
- * 2. Otherwise, fall back to a host-based search query (e.g. URL host
108
- * "github.com" → search for "github fetch markdown").
109
- * 3. From the returned candidates, prefer tools whose name or description
110
- * signals markdown output. Failing that, the first tool that takes a
111
- * URL-shaped argument.
112
- * 4. Execute the tool with `{ url, format: "markdown" }`-shaped args.
113
- * If exec fails, return null so the caller falls back to plain HTTP.
114
- */
115
- async function tryMcpx(
116
- url: string,
117
- mcpx: NonNullable<FetchOptions["mcpx"]>,
118
- hint: string | undefined,
119
- ): Promise<FetchedRemote | null> {
120
- const candidates = await discoverCandidates(url, mcpx, hint);
121
- if (candidates.length === 0) return null;
122
-
123
- const chosen = pickTool(candidates);
124
- if (!chosen) return null;
125
-
126
- const args = buildArgs(chosen.tool.name, url);
127
- let result: unknown;
128
- try {
129
- result = await mcpx.exec(chosen.server, chosen.tool.name, args);
130
- } catch (err) {
131
- logger.warn(
132
- `mcpx: ${chosen.server}/${chosen.tool.name} failed (${err instanceof Error ? err.message : String(err)})`,
133
- );
134
- return null;
135
- }
136
-
137
- const text = extractText(result);
138
- if (!text || text.trim().length === 0) return null;
139
- const bytes = new TextEncoder().encode(text);
140
- return {
141
- bytes,
142
- sha256: sha256Hex(bytes),
143
- mimeType: "text/markdown",
144
- fetcher: "mcpx",
145
- fetcherServer: chosen.server,
146
- fetcherTool: chosen.tool.name,
147
- fetcherArgs: args,
148
- sourceUrl: url,
149
- };
150
- }
151
-
152
- /**
153
- * Build a list of candidate fetcher tools by querying mcpx's live catalog.
154
- * Tries semantic search first (using the hint or the URL's host as the
155
- * query) then falls back to listing all tools and filtering by name. Never
156
- * hardcodes a server name — the catalog is the source of truth.
157
- */
158
- async function discoverCandidates(
159
- url: string,
160
- mcpx: NonNullable<FetchOptions["mcpx"]>,
161
- hint: string | undefined,
162
- ): Promise<McpxToolDescriptor[]> {
163
- const host = safeHost(url);
164
- const queries = buildQueries(hint, host);
165
-
166
- if (mcpx.search) {
167
- for (const q of queries) {
168
- try {
169
- const hits = await mcpx.search(q);
170
- if (hits.length > 0) {
171
- return hits.slice(0, 5).map((h) => ({ server: h.server, tool: h.tool }));
172
- }
173
- } catch (err) {
174
- logger.debug(`mcpx: search(${q}) failed (${err instanceof Error ? err.message : String(err)})`);
175
- }
176
- }
177
- }
178
-
179
- let tools: McpxToolDescriptor[];
180
- try {
181
- tools = await mcpx.listTools();
182
- } catch (err) {
183
- logger.debug(`mcpx: listTools failed (${err instanceof Error ? err.message : String(err)})`);
184
- return [];
185
- }
186
-
187
- const lowercaseHaystack = (t: McpxToolDescriptor) =>
188
- `${t.server} ${t.tool.name} ${t.tool.description ?? ""}`.toLowerCase();
189
-
190
- if (hint) {
191
- const needle = hint.toLowerCase();
192
- const matched = tools.filter((t) => lowercaseHaystack(t).includes(needle));
193
- if (matched.length > 0) return matched;
194
- }
195
-
196
- if (host) {
197
- const tokens = host.split(".");
198
- const matched = tools.filter((t) => tokens.some((tok) => tok.length > 2 && lowercaseHaystack(t).includes(tok)));
199
- if (matched.length > 0) return matched;
200
- }
201
-
202
- // Fall back to any tool that looks like a URL fetcher.
203
- return tools.filter((t) => /fetch|scrape|http|url/i.test(`${t.tool.name} ${t.tool.description ?? ""}`));
204
- }
205
-
206
- /** Compose semantic-search queries to feed mcpx.search. */
207
- function buildQueries(hint: string | undefined, host: string | null): string[] {
208
- const out: string[] = [];
209
- if (hint) out.push(`${hint} fetch markdown`);
210
- if (host) out.push(`fetch ${host} as markdown`, `scrape ${host}`);
211
- out.push("fetch URL as markdown", "scrape webpage to markdown");
212
- return out;
213
- }
214
-
215
- /** URL → hostname or null. */
216
- function safeHost(url: string): string | null {
217
- try {
218
- return new URL(url).hostname.toLowerCase();
219
- } catch {
220
- return null;
221
- }
222
- }
223
-
224
- /**
225
- * Among the candidate tools, prefer one whose name or description signals
226
- * markdown output (contains "markdown", "md", "Docmd", etc.). Falls back
227
- * to anything that looks like a generic fetch/scrape verb, and finally
228
- * to the first candidate so we always try something.
229
- */
230
- function pickTool(tools: McpxToolDescriptor[]): McpxToolDescriptor | null {
231
- const score = (t: McpxToolDescriptor) => {
232
- const hay = `${t.tool.name} ${t.tool.description ?? ""}`.toLowerCase();
233
- let s = 0;
234
- if (/markdown|docmd|asmd|\bmd\b/.test(hay)) s += 5;
235
- if (/scrape|extract|fetch|get|read/.test(hay)) s += 2;
236
- if (/url|web|html|page/.test(hay)) s += 1;
237
- return s;
238
- };
239
- const sorted = [...tools].sort((a, b) => score(b) - score(a));
240
- return sorted[0] ?? null;
241
- }
242
-
243
- /**
244
- * Build the argument object the mcpx fetcher tool likely accepts. We can't
245
- * know the schema without calling info(), so we build a permissive bag with
246
- * the common shapes (`{url, format: "markdown", formats: ["markdown"]}`)
247
- * and trust the underlying tool to ignore unknown fields.
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.
248
149
  */
249
- function buildArgs(toolName: string, url: string): Record<string, unknown> {
250
- const args: Record<string, unknown> = { url };
251
- if (/markdown|md/i.test(toolName)) args.format = "markdown";
252
- args.formats = ["markdown"];
253
- return args;
254
- }
255
-
256
- /** Pull a string out of the heterogeneous shapes mcpx tools return. */
257
- function extractText(result: unknown): string {
258
- if (typeof result === "string") return result;
259
- if (result && typeof result === "object") {
260
- const maybe = result as Record<string, unknown>;
261
- if (typeof maybe.text === "string") return maybe.text;
262
- if (typeof maybe.content === "string") return maybe.content;
263
- if (typeof maybe.markdown === "string") return maybe.markdown;
264
- if (Array.isArray(maybe.content)) {
265
- const out: string[] = [];
266
- for (const c of maybe.content) {
267
- if (c && typeof c === "object") {
268
- const inner = c as Record<string, unknown>;
269
- if (typeof inner.text === "string") out.push(inner.text);
270
- }
271
- }
272
- if (out.length > 0) return out.join("\n\n");
273
- }
274
- }
275
- try {
276
- return JSON.stringify(result);
277
- } catch {
278
- return "";
279
- }
150
+ export function isMcpToolError(result: unknown): boolean {
151
+ if (!result || typeof result !== "object") return false;
152
+ return (result as { isError?: unknown }).isError === true;
280
153
  }
@@ -3,6 +3,7 @@ import { upsertBlob } from "../db/blobs.ts";
3
3
  import { insertChunksForVersion, rebuildFts } from "../db/chunks.ts";
4
4
  import { type FetcherKind, getCurrent, insertVersion, millisIso, type SourceType } from "../db/files.ts";
5
5
  import { asHelpful, HelpfulError } from "../errors.ts";
6
+ import { logger } from "../output/logger.ts";
6
7
  import { chunkDeterministic } from "./chunker.ts";
7
8
  import { convert } from "./converter/index.ts";
8
9
  import { describe } from "./describer.ts";
@@ -188,12 +189,32 @@ async function ingestUrl(
188
189
  ): Promise<IngestResult> {
189
190
  const mcpxAdapter = ctx.mcpx
190
191
  ? {
191
- async listTools() {
192
- const tools = await ctx.mcpx!.listTools();
193
- return tools;
192
+ async search(query: string, options?: { keywordOnly?: boolean; semanticOnly?: boolean }) {
193
+ try {
194
+ const results = await ctx.mcpx!.search(query, options);
195
+ return results.map((r) => ({
196
+ server: r.server,
197
+ tool: r.tool,
198
+ description: r.description ?? undefined,
199
+ score: r.score,
200
+ matchType: r.matchType ?? undefined,
201
+ }));
202
+ } catch (err) {
203
+ logger.debug(`mcpx.search(${query}) failed: ${err instanceof Error ? err.message : String(err)}`);
204
+ return [];
205
+ }
194
206
  },
195
- async exec(server: string, tool: string, args: Record<string, unknown>) {
196
- return ctx.mcpx!.exec(server, tool, args);
207
+ async listTools(server?: string) {
208
+ const tools = await ctx.mcpx!.listTools(server);
209
+ return tools.map((t) => ({ server: t.server, tool: { name: t.tool.name, description: t.tool.description } }));
210
+ },
211
+ async info(server: string, tool: string) {
212
+ const t = await ctx.mcpx!.info(server, tool);
213
+ if (!t) return undefined;
214
+ return { name: t.name, description: t.description, inputSchema: t.inputSchema };
215
+ },
216
+ async exec(server: string, tool: string, args?: Record<string, unknown>) {
217
+ return ctx.mcpx!.exec(server, tool, args ?? {});
197
218
  },
198
219
  }
199
220
  : null;
@@ -212,7 +233,11 @@ async function ingestUrl(
212
233
  };
213
234
 
214
235
  try {
215
- const fetched = await fetchRemote(url, { hint: input.fetcher_hint, mcpx: mcpxAdapter });
236
+ const fetched = await fetchRemote(url, {
237
+ hint: input.fetcher_hint,
238
+ mcpx: mcpxAdapter,
239
+ llm: ctx.config.llm,
240
+ });
216
241
  result.mime_type = fetched.mimeType;
217
242
  result.size_bytes = fetched.bytes.byteLength;
218
243
  result.fetcher = fetched.fetcher;
@@ -583,6 +608,7 @@ function summarize(entries: IngestEntryResult[]): IngestResult {
583
608
  }
584
609
 
585
610
  function errorMessage(err: unknown): string {
611
+ if (err instanceof HelpfulError) return `${err.message} — ${err.hint}`;
586
612
  if (err instanceof Error) return err.message;
587
613
  return String(err);
588
614
  }
@@ -8,7 +8,7 @@ import { chunkDeterministic } from "../ingest/chunker.ts";
8
8
  import { convert } from "../ingest/converter/index.ts";
9
9
  import { describe } from "../ingest/describer.ts";
10
10
  import { embed } from "../ingest/embedder.ts";
11
- import { fetchRemote } from "../ingest/fetcher.ts";
11
+ import { fetchRemote, isMcpToolError } from "../ingest/fetcher.ts";
12
12
  import { mimeFromPath, readLocalFile, sha256Hex } from "../ingest/local-reader.ts";
13
13
  import { buildSearchText } from "../ingest/search-text.ts";
14
14
 
@@ -192,6 +192,14 @@ async function replayFetch(
192
192
  if (cur.fetcher === "mcpx" && cur.fetcher_server && cur.fetcher_tool && mcpx) {
193
193
  const args = cur.fetcher_args ?? {};
194
194
  const result = await mcpx.exec(cur.fetcher_server, cur.fetcher_tool, args);
195
+ if (isMcpToolError(result)) {
196
+ const detail = extractText(result).trim();
197
+ throw new HelpfulError({
198
+ kind: "network_error",
199
+ message: `mcpx tool ${cur.fetcher_server}/${cur.fetcher_tool} returned isError=true${detail ? `: ${detail}` : ""}`,
200
+ hint: `Re-add with a working fetcher: \`membot remove ${cur.logical_path}\` then \`membot add ${cur.source_path} --fetcher http\` (or another --fetcher hint).`,
201
+ });
202
+ }
195
203
  const text = extractText(result);
196
204
  const bytes = new TextEncoder().encode(text);
197
205
  return {