membot 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "membot",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Versioned context store with hybrid search for AI agents. Stdio + HTTP MCP server and CLI.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,6 +58,13 @@ export interface AgentFetchOptions {
58
58
  mcpx: AgentMcpxAdapter;
59
59
  llm: LlmConfig;
60
60
  hint?: string;
61
+ /**
62
+ * Optional sublabel callback. Receives compact, human-readable strings
63
+ * describing what the agent is doing each turn (e.g. "mcp_exec
64
+ * linear/list_comments (turn 2)"). Wired to the spinner suffix in TTY
65
+ * mode so users see live progress without `--verbose`.
66
+ */
67
+ onProgress?: (sublabel: string) => void;
61
68
  /** Test seam: inject a pre-built Anthropic client. */
62
69
  _testClient?: Anthropic;
63
70
  }
@@ -238,7 +245,14 @@ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOut
238
245
 
239
246
  const captured = new Map<string, CapturedExec>();
240
247
 
248
+ opts.onProgress?.(`fetching via mcpx agent (turn 1)`);
249
+
241
250
  for (let turn = 0; turn < MAX_TURNS; turn++) {
251
+ if (turn > 0) {
252
+ logger.info(`[fetcher] turn ${turn + 1}/${MAX_TURNS}`);
253
+ opts.onProgress?.(`fetching via mcpx agent (turn ${turn + 1})`);
254
+ }
255
+
242
256
  const response = await client.messages.create({
243
257
  model: opts.llm.converter_model,
244
258
  max_tokens: MAX_RESPONSE_TOKENS,
@@ -249,7 +263,7 @@ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOut
249
263
 
250
264
  for (const block of response.content) {
251
265
  if (block.type === "text" && block.text.trim()) {
252
- logger.debug(`agent-fetch turn ${turn + 1}: ${block.text.trim()}`);
266
+ logger.debug(`[fetcher] turn ${turn + 1} reasoning: ${block.text.trim()}`);
253
267
  }
254
268
  }
255
269
 
@@ -263,12 +277,19 @@ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOut
263
277
 
264
278
  const toolUseBlocks = response.content.filter((b): b is ToolUseBlock => b.type === "tool_use");
265
279
  if (toolUseBlocks.length === 0) {
266
- logger.debug(`agent-fetch turn ${turn + 1}: no tool calls — falling back to HTTP`);
280
+ logger.info(`[fetcher] turn ${turn + 1}: no tool calls — falling back to HTTP`);
267
281
  return { kind: "fallback", reason: "agent stopped without selecting an outcome" };
268
282
  }
269
283
 
270
284
  messages.push({ role: "assistant", content: response.content });
271
285
 
286
+ // Log selected tools at info-level so users see what the agent is doing
287
+ // without enabling --verbose. Discovery (search/info/list) stays quiet
288
+ // at info; the actual mcp_exec calls are the high-signal events.
289
+ for (const tu of toolUseBlocks) {
290
+ logToolSelection(tu, turn + 1, opts.onProgress);
291
+ }
292
+
272
293
  // Terminal tools — checked in priority order.
273
294
  const failureCall = toolUseBlocks.find((b) => b.name === "report_failure");
274
295
  if (failureCall) {
@@ -277,6 +298,7 @@ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOut
277
298
  typeof input.message === "string" && input.message.trim()
278
299
  ? input.message.trim()
279
300
  : "Fetch failed but the agent did not provide a message.";
301
+ logger.info(`[fetcher] turn ${turn + 1}: report_failure: ${message}`);
280
302
  throw new HelpfulError({
281
303
  kind: "input_error",
282
304
  message: `Fetcher agent reported failure for ${opts.url}: ${message}`,
@@ -286,7 +308,7 @@ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOut
286
308
 
287
309
  const fallbackCall = toolUseBlocks.find((b) => b.name === "request_http_fallback");
288
310
  if (fallbackCall) {
289
- logger.debug(`agent-fetch turn ${turn + 1}: agent requested HTTP fallback`);
311
+ logger.info(`[fetcher] turn ${turn + 1}: agent requested HTTP fallback`);
290
312
  return { kind: "fallback", reason: "agent requested HTTP fallback" };
291
313
  }
292
314
 
@@ -325,6 +347,10 @@ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOut
325
347
  }
326
348
  const claimedMime = (input.mime_type ?? cached.mimeType ?? "text/markdown").trim() || "text/markdown";
327
349
  const bytes = new TextEncoder().encode(cached.content);
350
+ logger.info(`[fetcher] accepted: ${cached.server}/${cached.tool} (${bytes.byteLength} bytes, ${claimedMime})`);
351
+ logger.debug(`[fetcher] accepted args: ${truncateJson(cached.args, 500)}`);
352
+ logger.debug(`[fetcher] accepted preview: ${truncate(cached.content, 200)}`);
353
+ opts.onProgress?.(`accepted ${cached.server}/${cached.tool}`);
328
354
  return {
329
355
  kind: "accepted",
330
356
  result: {
@@ -347,10 +373,52 @@ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOut
347
373
  messages.push({ role: "user", content: toolResults });
348
374
  }
349
375
 
350
- logger.debug(`agent-fetch: max turns (${MAX_TURNS}) exceeded — falling back to HTTP`);
376
+ logger.info(`[fetcher] max turns (${MAX_TURNS}) exceeded — falling back to HTTP`);
351
377
  return { kind: "fallback", reason: `agent exceeded MAX_TURNS=${MAX_TURNS}` };
352
378
  }
353
379
 
380
+ /**
381
+ * Emit a per-turn line about which tool the agent is about to invoke. mcp_exec
382
+ * is the high-signal event that surfaces *which provider was chosen*, so it
383
+ * goes to info; discovery (search / list / info) stays at debug.
384
+ */
385
+ function logToolSelection(tu: ToolUseBlock, turn: number, onProgress?: (s: string) => void): void {
386
+ if (tu.name === "mcp_exec") {
387
+ const i = tu.input as Partial<{ server: string; tool: string; args: Record<string, unknown> }>;
388
+ const server = i.server ?? "?";
389
+ const tool = i.tool ?? "?";
390
+ logger.info(`[fetcher] turn ${turn}: mcp_exec ${server}/${tool}`);
391
+ logger.debug(`[fetcher] turn ${turn}: mcp_exec args: ${truncateJson(i.args ?? {}, 500)}`);
392
+ onProgress?.(`mcp_exec ${server}/${tool} (turn ${turn})`);
393
+ } else if (tu.name === "mcp_search") {
394
+ const i = tu.input as Partial<{ query: string }>;
395
+ logger.debug(`[fetcher] turn ${turn}: mcp_search "${i.query ?? ""}"`);
396
+ } else if (tu.name === "mcp_info") {
397
+ const i = tu.input as Partial<{ server: string; tool: string }>;
398
+ logger.debug(`[fetcher] turn ${turn}: mcp_info ${i.server ?? "?"}/${i.tool ?? "?"}`);
399
+ } else if (tu.name === "mcp_list_tools") {
400
+ const i = tu.input as Partial<{ server: string }>;
401
+ logger.debug(`[fetcher] turn ${turn}: mcp_list_tools${i.server ? ` ${i.server}` : ""}`);
402
+ }
403
+ }
404
+
405
+ /** JSON-stringify with a length cap so a giant args payload doesn't bloat logs. */
406
+ function truncateJson(value: unknown, max: number): string {
407
+ let s: string;
408
+ try {
409
+ s = JSON.stringify(value);
410
+ } catch {
411
+ s = String(value);
412
+ }
413
+ return s.length > max ? `${s.slice(0, max)}… (+${s.length - max} chars)` : s;
414
+ }
415
+
416
+ /** Single-line truncation for debug previews; collapses whitespace. */
417
+ function truncate(s: string, max: number): string {
418
+ const oneLine = s.replace(/\s+/g, " ").trim();
419
+ return oneLine.length > max ? `${oneLine.slice(0, max)}… (+${oneLine.length - max} chars)` : oneLine;
420
+ }
421
+
354
422
  /** Execute one agent tool call and produce the tool_result block fed back to Claude. */
355
423
  async function dispatchAgentTool(
356
424
  toolUse: ToolUseBlock,
@@ -392,6 +460,8 @@ async function runMcpSearch(toolUse: ToolUseBlock, mcpx: AgentMcpxAdapter): Prom
392
460
  }
393
461
  try {
394
462
  const results = await mcpx.search(input.query);
463
+ const top = results.slice(0, 3).map((r) => `${r.server}/${r.tool}${r.score ? ` (${r.score.toFixed(2)})` : ""}`);
464
+ logger.debug(`[fetcher] mcp_search "${input.query}" → ${top.length ? top.join(", ") : "(no hits)"}`);
395
465
  return {
396
466
  type: "tool_result",
397
467
  tool_use_id: toolUse.id,
@@ -498,10 +568,12 @@ async function runMcpExec(
498
568
  try {
499
569
  result = await mcpx.exec(input.server, input.tool, args);
500
570
  } catch (err) {
571
+ const msg = err instanceof Error ? err.message : String(err);
572
+ logger.info(`[fetcher] → ${input.server}/${input.tool} threw: ${truncate(msg, 200)}`);
501
573
  return {
502
574
  type: "tool_result",
503
575
  tool_use_id: toolUse.id,
504
- content: `mcp_exec ${input.server}/${input.tool} threw: ${err instanceof Error ? err.message : String(err)}. Use mcp_info to verify the schema, then retry — or pivot to a different tool.`,
576
+ content: `mcp_exec ${input.server}/${input.tool} threw: ${msg}. Use mcp_info to verify the schema, then retry — or pivot to a different tool.`,
505
577
  is_error: true,
506
578
  };
507
579
  }
@@ -509,6 +581,7 @@ async function runMcpExec(
509
581
  const text = extractText(result);
510
582
 
511
583
  if (result.isError === true) {
584
+ logger.info(`[fetcher] → ${input.server}/${input.tool} error: ${truncate(text, 200)}`);
512
585
  return {
513
586
  type: "tool_result",
514
587
  tool_use_id: toolUse.id,
@@ -518,6 +591,7 @@ async function runMcpExec(
518
591
  }
519
592
 
520
593
  if (!text?.trim()) {
594
+ logger.info(`[fetcher] → ${input.server}/${input.tool} empty result`);
521
595
  return {
522
596
  type: "tool_result",
523
597
  tool_use_id: toolUse.id,
@@ -526,6 +600,7 @@ async function runMcpExec(
526
600
  };
527
601
  }
528
602
 
603
+ logger.info(`[fetcher] → ${input.server}/${input.tool} ok (${text.length} chars)`);
529
604
  captured.set(toolUse.id, { server: input.server, tool: input.tool, args, content: text, mimeType: "text/markdown" });
530
605
  const preview =
531
606
  text.length > PREVIEW_CHARS
@@ -32,6 +32,11 @@ export interface FetchOptions {
32
32
  * mcpx path is skipped and we fall back to plain HTTP.
33
33
  */
34
34
  llm?: LlmConfig;
35
+ /**
36
+ * Forwarded to the agent loop so callers (e.g. the ingest progress
37
+ * reporter) can drive a spinner sublabel from per-turn agent activity.
38
+ */
39
+ onProgress?: (sublabel: string) => void;
35
40
  }
36
41
 
37
42
  /**
@@ -79,7 +84,7 @@ export async function fetchRemote(url: string, options: FetchOptions = {}): Prom
79
84
 
80
85
  let outcome: Awaited<ReturnType<typeof agentFetch>>;
81
86
  try {
82
- outcome = await agentFetch({ url, mcpx, llm: options.llm!, hint });
87
+ outcome = await agentFetch({ url, mcpx, llm: options.llm!, hint, onProgress: options.onProgress });
83
88
  } catch (err) {
84
89
  if (err instanceof HelpfulError) throw err;
85
90
  logger.warn(`agent-fetch failed (${err instanceof Error ? err.message : String(err)}) — falling back to HTTP`);
@@ -98,7 +103,7 @@ export async function fetchRemote(url: string, options: FetchOptions = {}): Prom
98
103
  sourceUrl: url,
99
104
  };
100
105
  }
101
- logger.debug(`agent-fetch fell back to HTTP: ${outcome.reason}`);
106
+ logger.info(`[fetcher] falling back to HTTP: ${outcome.reason}`);
102
107
  return httpFetch(url);
103
108
  }
104
109
 
@@ -237,6 +237,7 @@ async function ingestUrl(
237
237
  hint: input.fetcher_hint,
238
238
  mcpx: mcpxAdapter,
239
239
  llm: ctx.config.llm,
240
+ onProgress: (sublabel) => callbacks?.onEntryProgress?.(url, sublabel),
240
241
  });
241
242
  result.mime_type = fetched.mimeType;
242
243
  result.size_bytes = fetched.bytes.byteLength;