@finchagentic/mcp 4.0.0 → 4.2.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.
@@ -15,21 +15,48 @@ const SYSTEM_PROMPT = [
15
15
  "- For ALL Base chain operations, you MUST use the base_mcp_* family: base_mcp_swap (NOT swap_tokens), base_mcp_send (NOT send_token), base_mcp_balance (NOT get_portfolio), base_mcp_estimate, base_mcp_resolve, base_mcp_lend, base_mcp_status. This is non-negotiable - Base operations go through the Base MCP skill, period.",
16
16
  "- For Robinhood Chain tokenized stocks (chainId 4663, NVDA/AAPL/etc on Uniswap V4), you MUST use rh_mcp_*: rh_mcp_status, rh_mcp_list_stocks, rh_mcp_balance, rh_mcp_estimate, rh_mcp_swap. Never use base_mcp_* or 0x for RH stocks. rh_mcp_swap requires confirm:true. Explorer = robinhoodchain.blockscout.com. This is NOT Robinhood Agentic brokerage (agent.robinhood.com).",
17
17
  "- Do NOT claim an address belongs to the user (e.g. 'your own address') unless you have verified ownership. Resolving a basename returns whoever owns that name - usually NOT the caller.",
18
+ "- The Finch wallet is the SAME address on both Base and Robinhood Chain, but they are separate ledgers - a balance on one tells you nothing about the other. If the user asks a chain-unspecified question ('what's my balance', 'do I have any funds') call BOTH base_mcp_balance AND rh_mcp_balance before answering. Never report only one chain's result as 'your balance' or 'your wallet is empty' - say which chain(s) you checked, and give both figures.",
19
+ "- Swap/send requests are only chain-unambiguous when the token itself pins the chain (a RH catalog stock like NVDA/AAPL, or a token you already know only exists on one side). A bare request like 'swap 0.001 ETH to a stablecoin' with no chain named is NOT unambiguous just because you picked one - ETH and 'stablecoin' both exist on Base (USDC/USDT/DAI) and Robinhood Chain (USDG). Before estimating or executing an ambiguous swap, call both base_mcp_balance and rh_mcp_balance first: if only one chain actually holds enough of the source token, use that chain and say which one and why; if neither holds enough, say so plainly instead of quoting a swap the wallet can't cover; if both hold enough, ask the user which chain before proceeding.",
20
+ "- There is NO send/transfer tool for Robinhood Chain - base_mcp_send only moves assets on Base mainnet. If the user asks to send/transfer USDG or any RH catalog stock (NVDA, AAPL, etc.), do NOT call base_mcp_send with that token name hoping it resolves - it will either error or, worse, silently match an unrelated Base token with the same symbol. Tell the user directly that on-chain sends are not yet supported on Robinhood Chain.",
21
+ "- Never state a balance, price, quote, token address, or transaction result from memory or inference - every number in your answer must trace to a tool call you made THIS turn. If you are not sure which chain, token, or address a term refers to, ask or resolve it (rh_token_resolve / base_mcp_resolve) before answering - do not guess and present the guess as fact.",
18
22
  "",
19
23
  "For deep research: prefer deep_research (multi-stage, saves to vault). Use continueFrom when extending prior reports.",
20
24
  "For live web info: use web_search. For market questions: use get_market_data or market_thesis.",
21
25
  "Save substantive findings to vault; do not save thin or empty outputs.",
26
+ "",
27
+ "NEVER call ask_finch from this shell. It exists for MCP clients that have no reasoning model of their own - you already are one. Calling it mid-task means asking a second model to think for you, which produces nothing you couldn't write yourself from the data you already have, and repeating it when unsatisfied just burns turns. If a tool's result already answers the question, write the answer yourself.",
28
+ "Do not call the same tool with the same or near-identical arguments more than once in a single answer. If deep_research, a search, or an analysis tool already returned data, synthesize from that - do not re-run it hoping for a different result, and do not chain more tool calls than the question actually needs. Stop calling tools and answer as soon as you have enough to answer well.",
22
29
  ].join("\n");
23
30
  async function runAgent(userMessage, history, onToolCall) {
31
+ const provider = process.env.FINCH_PROVIDER?.toLowerCase().trim();
24
32
  const bankrKey = process.env.BANKR_API_KEY;
25
33
  const anthropicKey = process.env.ANTHROPIC_API_KEY;
26
34
  const openaiKey = process.env.OPENAI_API_KEY;
35
+ const grokKey = process.env.GROK_API_KEY;
36
+ // Explicit override - lets FINCH_PROVIDER=openai win even when BANKR_API_KEY
37
+ // is also set (e.g. as a persistent shell env var), same as llm.ts's callLLM.
38
+ if (provider === "bankr" && bankrKey)
39
+ return runBankrLoop(bankrKey, userMessage, history, onToolCall);
40
+ if (provider === "anthropic" && anthropicKey)
41
+ return runAnthropicLoop(anthropicKey, userMessage, history, onToolCall);
42
+ if (provider === "openai" && openaiKey)
43
+ return runOpenAILoop(openaiKey, userMessage, history, onToolCall);
44
+ if (provider === "grok" && grokKey)
45
+ return runGrokLoop(grokKey, userMessage, history, onToolCall);
46
+ // Auto-priority - matches llm.ts's callLLM() order exactly (bankr →
47
+ // anthropic → openai → grok → Convex proxy) so a one-shot LLM tool call
48
+ // (ask_finch, memory extraction, etc.) and the interactive agent loop
49
+ // never silently pick different providers for the same configured keys.
50
+ // A GROK_API_KEY-only user used to fall all the way through to the
51
+ // Convex-proxied Anthropic loop here despite callLLM() using Grok.
27
52
  if (bankrKey)
28
53
  return runBankrLoop(bankrKey, userMessage, history, onToolCall);
29
54
  if (anthropicKey)
30
55
  return runAnthropicLoop(anthropicKey, userMessage, history, onToolCall);
31
56
  if (openaiKey)
32
57
  return runOpenAILoop(openaiKey, userMessage, history, onToolCall);
58
+ if (grokKey)
59
+ return runGrokLoop(grokKey, userMessage, history, onToolCall);
33
60
  // No direct key - proxy through Finch backend. Wallet auto-creates at ~/.finch/wallet.json
34
61
  // on first use and signs requests transparently. No account or config needed.
35
62
  try {
@@ -49,8 +76,14 @@ function toAnthropicTool(tool) {
49
76
  input_schema: tool.inputSchema ?? { type: "object", properties: {} },
50
77
  };
51
78
  }
52
- async function runAnthropicLoop(apiKey, userMessage, history, onToolCall) {
53
- const model = process.env.FINCH_MODEL ?? process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5-20251001";
79
+ // Shared loop body for any provider that speaks Anthropic's native
80
+ // content-block format (tool_use/tool_result) - runAnthropicLoop (direct
81
+ // api.anthropic.com) and runConvexProxiedLoop (same wire format via Finch's
82
+ // /llm/complete proxy) previously duplicated this ~70-line body verbatim,
83
+ // with only the transport (sendTurn/sendFinal) differing. Keeping one copy
84
+ // means a future fix (retry-on-5xx, tool-result truncation, etc.) can't be
85
+ // applied to one provider and silently miss the other.
86
+ async function runAnthropicStyleLoop(sendTurn, sendFinal, userMessage, history, onToolCall) {
54
87
  const tools = server_js_1.ALL_TOOLS.map(toAnthropicTool);
55
88
  const toolCalls = [];
56
89
  const messages = [
@@ -58,21 +91,7 @@ async function runAnthropicLoop(apiKey, userMessage, history, onToolCall) {
58
91
  { role: "user", content: userMessage },
59
92
  ];
60
93
  for (let turn = 0; turn < 10; turn++) {
61
- const res = await fetch("https://api.anthropic.com/v1/messages", {
62
- method: "POST",
63
- headers: {
64
- "Content-Type": "application/json",
65
- "x-api-key": apiKey,
66
- "anthropic-version": "2023-06-01",
67
- },
68
- body: JSON.stringify({ model, max_tokens: 2048, system: SYSTEM_PROMPT, tools, messages }),
69
- signal: AbortSignal.timeout(90000),
70
- });
71
- if (!res.ok) {
72
- const body = await res.text().catch(() => "");
73
- throw new Error(`Anthropic ${res.status}: ${body.slice(0, 300)}`);
74
- }
75
- const data = await res.json();
94
+ const data = await sendTurn(messages, tools);
76
95
  messages.push({ role: "assistant", content: data.content });
77
96
  if (data.stop_reason !== "tool_use") {
78
97
  const text = data.content
@@ -86,15 +105,27 @@ async function runAnthropicLoop(apiKey, userMessage, history, onToolCall) {
86
105
  for (const block of data.content) {
87
106
  if (block.type !== "tool_use")
88
107
  continue;
89
- onToolCall(block.name);
90
- toolCalls.push({ name: block.name });
91
108
  let resultText;
92
109
  try {
93
110
  const handler = server_js_1.HANDLER_MAP.get(block.name);
94
111
  if (!handler)
95
112
  throw new Error(`Unknown tool: ${block.name}`);
113
+ // Recorded only once the handler is confirmed to exist - an "Unknown
114
+ // tool" miss is a model error, not a real tool execution, and callers
115
+ // reading AgentResult.toolCalls should be able to trust every entry
116
+ // actually ran.
117
+ onToolCall(block.name);
118
+ toolCalls.push({ name: block.name });
96
119
  const result = await handler(block.name, block.input ?? {});
97
- resultText = result?.content?.[0]?.text ?? "Done.";
120
+ // A handler returning null (a name declared in its *_TOOLS array with
121
+ // no matching branch - a latent bug ruled out today but not
122
+ // structurally prevented) must not fall through to "Done." - that
123
+ // would report success to the model for a call that never actually
124
+ // ran, directly contradicting this loop's own system-prompt rule
125
+ // against claiming an unverified result.
126
+ if (result === null)
127
+ throw new Error(`Tool ${block.name} returned no result (handler bug - not executed)`);
128
+ resultText = result.content?.[0]?.text ?? "Done.";
98
129
  }
99
130
  catch (err) {
100
131
  resultText = `Error: ${err.message}`;
@@ -103,56 +134,76 @@ async function runAnthropicLoop(apiKey, userMessage, history, onToolCall) {
103
134
  }
104
135
  messages.push({ role: "user", content: toolResults });
105
136
  }
106
- return { text: "Reached max tool iterations.", toolCalls };
137
+ // Hit the turn cap without a final answer - rather than hand back nothing,
138
+ // force one more call with no tools available so the model has to
139
+ // synthesize a real answer from whatever it already gathered.
140
+ return finishWithoutTools(sendFinal, messages, toolCalls);
141
+ }
142
+ function runAnthropicLoop(apiKey, userMessage, history, onToolCall) {
143
+ const model = process.env.FINCH_MODEL ?? process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5-20251001";
144
+ const sendTurn = async (messages, tools) => {
145
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
146
+ method: "POST",
147
+ headers: {
148
+ "Content-Type": "application/json",
149
+ "x-api-key": apiKey,
150
+ "anthropic-version": "2023-06-01",
151
+ },
152
+ body: JSON.stringify({ model, max_tokens: 4096, system: SYSTEM_PROMPT, tools, messages }),
153
+ signal: AbortSignal.timeout(90000),
154
+ });
155
+ if (!res.ok) {
156
+ const body = await res.text().catch(() => "");
157
+ throw new Error(`Anthropic ${res.status}: ${body.slice(0, 300)}`);
158
+ }
159
+ return await res.json();
160
+ };
161
+ const sendFinal = async (msgs) => {
162
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
165
+ body: JSON.stringify({ model, max_tokens: 4096, system: SYSTEM_PROMPT, messages: msgs }),
166
+ signal: AbortSignal.timeout(90000),
167
+ });
168
+ if (!res.ok)
169
+ return "";
170
+ const data = await res.json();
171
+ return (data.content ?? []).filter(b => b.type === "text").map(b => b.text).join("");
172
+ };
173
+ return runAnthropicStyleLoop(sendTurn, sendFinal, userMessage, history, onToolCall);
174
+ }
175
+ // Shared turn-cap fallback: rather than "Reached max tool iterations." with
176
+ // nothing useful, ask the model to synthesize a real answer from whatever
177
+ // conversation history (including every tool result so far) it already has,
178
+ // with no tools offered so it can't keep deferring.
179
+ async function finishWithoutTools(call, messages, toolCalls) {
180
+ try {
181
+ const closingMessages = [
182
+ ...messages,
183
+ { role: "user", content: "Stop calling tools. Summarize what you found above into a direct answer for the user right now." },
184
+ ];
185
+ const text = await call(closingMessages);
186
+ if (text.trim())
187
+ return { text, toolCalls };
188
+ }
189
+ catch { /* fall through to the honest failure message below */ }
190
+ return {
191
+ text: "I gathered some information but ran out of turns before finishing the analysis. Try a narrower question, or ask me to continue from what I found.",
192
+ toolCalls,
193
+ };
107
194
  }
108
195
  // ── Convex-proxied Anthropic loop (session token only - platform covers LLM) ──
109
- async function runConvexProxiedLoop(userMessage, history, onToolCall) {
196
+ function runConvexProxiedLoop(userMessage, history, onToolCall) {
110
197
  const model = process.env.FINCH_MODEL ?? process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5-20251001";
111
- const tools = server_js_1.ALL_TOOLS.map(toAnthropicTool);
112
- const toolCalls = [];
113
- const messages = [
114
- ...history.map(h => ({ role: h.role, content: h.content })),
115
- { role: "user", content: userMessage },
116
- ];
117
- for (let turn = 0; turn < 10; turn++) {
118
- // callConvex handles wallet/session auth automatically; 90s timeout matches the proxy endpoint
198
+ // callConvex handles wallet/session auth automatically; 90s timeout matches the proxy endpoint
199
+ const sendTurn = (messages, tools) => (0, convex_js_1.callConvex)("/llm/complete", "POST", { model, max_tokens: 4096, system: SYSTEM_PROMPT, tools, messages }, "llm_complete", 90000);
200
+ const sendFinal = async (msgs) => {
119
201
  const data = await (0, convex_js_1.callConvex)("/llm/complete", "POST", {
120
- model,
121
- max_tokens: 2048,
122
- system: SYSTEM_PROMPT,
123
- tools,
124
- messages,
202
+ model, max_tokens: 4096, system: SYSTEM_PROMPT, messages: msgs,
125
203
  }, "llm_complete", 90000);
126
- messages.push({ role: "assistant", content: data.content });
127
- if (data.stop_reason !== "tool_use") {
128
- const text = data.content
129
- .filter(b => b.type === "text")
130
- .map(b => b.text)
131
- .join("");
132
- return { text, toolCalls };
133
- }
134
- const toolResults = [];
135
- for (const block of data.content) {
136
- if (block.type !== "tool_use")
137
- continue;
138
- onToolCall(block.name);
139
- toolCalls.push({ name: block.name });
140
- let resultText;
141
- try {
142
- const handler = server_js_1.HANDLER_MAP.get(block.name);
143
- if (!handler)
144
- throw new Error(`Unknown tool: ${block.name}`);
145
- const result = await handler(block.name, block.input ?? {});
146
- resultText = result?.content?.[0]?.text ?? "Done.";
147
- }
148
- catch (err) {
149
- resultText = `Error: ${err.message}`;
150
- }
151
- toolResults.push({ type: "tool_result", tool_use_id: block.id, content: resultText });
152
- }
153
- messages.push({ role: "user", content: toolResults });
154
- }
155
- return { text: "Reached max tool iterations.", toolCalls };
204
+ return (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
205
+ };
206
+ return runAnthropicStyleLoop(sendTurn, sendFinal, userMessage, history, onToolCall);
156
207
  }
157
208
  // ── Bankr (OpenAI-compatible) agent loop ─────────────────────────────────────
158
209
  function toBankrTool(tool) {
@@ -180,7 +231,7 @@ async function runOpenAICompatibleLoop(url, authHeaders, model, providerLabel, u
180
231
  const res = await fetch(url, {
181
232
  method: "POST",
182
233
  headers: { "Content-Type": "application/json", ...authHeaders },
183
- body: JSON.stringify({ model, messages, tools, max_tokens: 2048 }),
234
+ body: JSON.stringify({ model, messages, tools, max_tokens: 4096 }),
184
235
  signal: AbortSignal.timeout(90000),
185
236
  });
186
237
  if (!res.ok) {
@@ -196,14 +247,16 @@ async function runOpenAICompatibleLoop(url, authHeaders, model, providerLabel, u
196
247
  return { text: choice.content ?? "", toolCalls };
197
248
  }
198
249
  for (const call of choice.tool_calls) {
199
- onToolCall(call.function.name);
200
- toolCalls.push({ name: call.function.name });
201
250
  let resultText;
202
251
  try {
203
252
  const args = JSON.parse(call.function.arguments ?? "{}");
204
253
  const handler = server_js_1.HANDLER_MAP.get(call.function.name);
205
254
  if (!handler)
206
255
  throw new Error(`Unknown tool: ${call.function.name}`);
256
+ // Recorded only once the handler is confirmed to exist - see the
257
+ // matching comment in runAnthropicLoop/runConvexProxiedLoop.
258
+ onToolCall(call.function.name);
259
+ toolCalls.push({ name: call.function.name });
207
260
  const result = await handler(call.function.name, args);
208
261
  resultText = result?.content?.[0]?.text ?? "Done.";
209
262
  }
@@ -213,7 +266,18 @@ async function runOpenAICompatibleLoop(url, authHeaders, model, providerLabel, u
213
266
  messages.push({ role: "tool", tool_call_id: call.id, content: resultText });
214
267
  }
215
268
  }
216
- return { text: "Reached max tool iterations.", toolCalls };
269
+ return finishWithoutTools(async (msgs) => {
270
+ const res = await fetch(url, {
271
+ method: "POST",
272
+ headers: { "Content-Type": "application/json", ...authHeaders },
273
+ body: JSON.stringify({ model, messages: msgs, max_tokens: 4096 }),
274
+ signal: AbortSignal.timeout(90000),
275
+ });
276
+ if (!res.ok)
277
+ return "";
278
+ const data = await res.json();
279
+ return data.choices?.[0]?.message?.content ?? "";
280
+ }, messages, toolCalls);
217
281
  }
218
282
  async function runBankrLoop(apiKey, userMessage, history, onToolCall) {
219
283
  const model = process.env.FINCH_MODEL ?? process.env.BANKR_MODEL ?? "claude-haiku-4-5-20251001";
@@ -229,3 +293,9 @@ async function runOpenAILoop(apiKey, userMessage, history, onToolCall) {
229
293
  const model = process.env.FINCH_MODEL ?? process.env.OPENAI_MODEL ?? "gpt-4o-mini";
230
294
  return runOpenAICompatibleLoop(openAiChatUrl(), { Authorization: `Bearer ${apiKey}` }, model, "OpenAI", userMessage, history, onToolCall);
231
295
  }
296
+ // xAI's Chat Completions API is OpenAI-compatible (same as llm.ts's callGrok),
297
+ // so this reuses runOpenAICompatibleLoop rather than a bespoke loop.
298
+ async function runGrokLoop(apiKey, userMessage, history, onToolCall) {
299
+ const model = process.env.FINCH_MODEL ?? process.env.FINCH_GROK_MODEL ?? process.env.GROK_MODEL ?? "grok-4-fast-reasoning";
300
+ return runOpenAICompatibleLoop("https://api.x.ai/v1/chat/completions", { Authorization: `Bearer ${apiKey}` }, model, "Grok", userMessage, history, onToolCall);
301
+ }
@@ -14,32 +14,23 @@ const READ_ONLY_LOCAL = new Set([
14
14
  // Toggles and upserts: re-running with the same args lands in the same state.
15
15
  const WRITE_IDEMPOTENT = new Set([
16
16
  "agent_update",
17
- "agent_schedule",
18
- "agent_pause",
19
- "agent_resume",
20
- "agent_unschedule",
21
17
  "pause_automation",
18
+ "stake_auto_restake",
22
19
  "vault_link",
23
20
  "vault_pin",
24
21
  "vault_tag",
25
22
  "vault_unpublish",
26
- "wallet_sign_message", // signing is not itself a fund move; re-sign = same sig
27
23
  ]);
28
24
  // readOnly=false, destructive=false.
29
25
  // Additive writes / new resources: they create or append, they don't destroy.
30
- // (hire_agent is deliberately NOT here: it only returns a specialist persona
31
- // scoped to the caller's task - it reads, it does not write - so it stays in
32
- // the read-only default.)
33
26
  const WRITE = new Set([
34
27
  "agent_spawn",
35
28
  "chronicle_add",
36
29
  "create_automation",
37
- "create_monitor",
38
30
  "memory_add",
39
31
  "memory_extract",
40
32
  "memory_consolidate",
41
33
  "packet_create",
42
- "packet_share",
43
34
  "schedule_research",
44
35
  "vault_save",
45
36
  "vault_store_credential",
@@ -58,11 +49,18 @@ const DESTRUCTIVE = new Set([
58
49
  "rh_order_cancel",
59
50
  "vault_delete",
60
51
  // irreversible public exposure
61
- "memory_publish", // "IRREVERSIBLE, PUBLIC" per its own description
52
+ "packet_share", // "copies already taken remain" per its own description
62
53
  // money movement (Base)
54
+ // NOTE: base_mcp_lend deliberately excluded - per its own description it
55
+ // "Returns deposit INSTRUCTIONS only... Does NOT broadcast" - it's a read,
56
+ // not a fund move, so it falls through to the read-only default below.
63
57
  "base_mcp_send",
64
58
  "base_mcp_swap",
65
- "base_mcp_lend",
59
+ // off-chain signature that can itself authorise value movement (order,
60
+ // session login) without any on-chain tx - same risk class as a real
61
+ // transfer per its own tool description, so it belongs here rather than
62
+ // in WRITE_IDEMPOTENT ("re-sign = same sig" is true but undersells the risk)
63
+ "wallet_sign_message",
66
64
  // money movement (Robinhood Chain)
67
65
  "rh_mcp_swap",
68
66
  "rh_dca_create", // arms recurring real buys
@@ -70,8 +68,11 @@ const DESTRUCTIVE = new Set([
70
68
  "rh_orders_tick", // preview by default, but can execute:true and move funds
71
69
  // executors that run other (possibly fund-moving) tools
72
70
  "run_automation",
73
- "run_playbook",
74
71
  "packet_run",
72
+ // money movement (FINCH staking, custodial wallet) - stake locks real value
73
+ // for a fixed period; unstake moves it (plus rewards) back
74
+ "stake_finch",
75
+ "unstake_finch",
75
76
  ]);
76
77
  function annotationsFor(name) {
77
78
  if (DESTRUCTIVE.has(name)) {
package/dist/cli.js CHANGED
@@ -44,6 +44,7 @@ const tool_filter_js_1 = require("./tool-filter.js");
44
44
  const config_js_1 = require("./config.js");
45
45
  const local_memory_js_1 = require("./local-memory.js");
46
46
  const clink_input_js_1 = require("./clink-input.js");
47
+ const wallet_js_1 = require("./wallet.js");
47
48
  const child_process = __importStar(require("child_process"));
48
49
  (0, config_js_1.hydrateEnvFromConfig)();
49
50
  // Derived tool counts - single source of truth, kept in sync with the
@@ -117,8 +118,8 @@ async function loginWithApiKey(rl) {
117
118
  }
118
119
  const email = data.email ?? "api-key-user";
119
120
  const name = data.displayName ?? undefined;
120
- (0, config_js_1.writeConfig)({ sessionToken: data.token, email, name });
121
- printLoginSuccess({ email, name });
121
+ (0, config_js_1.writeConfig)({ sessionToken: data.token, email, name, walletAddress: data.walletAddress });
122
+ printLoginSuccess({ email, name, walletAddress: data.walletAddress });
122
123
  }
123
124
  async function loginFlow(loginRl) {
124
125
  const rl = loginRl ?? readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -136,8 +137,8 @@ async function loginFlow(loginRl) {
136
137
  if (res.ok && data.token) {
137
138
  const email = data.email ?? "api-key-user";
138
139
  const name = data.displayName ?? undefined;
139
- (0, config_js_1.writeConfig)({ sessionToken: data.token, email, name });
140
- printLoginSuccess({ email, name });
140
+ (0, config_js_1.writeConfig)({ sessionToken: data.token, email, name, walletAddress: data.walletAddress });
141
+ printLoginSuccess({ email, name, walletAddress: data.walletAddress });
141
142
  rl.close();
142
143
  return;
143
144
  }
@@ -210,10 +211,16 @@ async function setupFlow() {
210
211
  console.log(`\n ${C.dim}Skipped - using Finch proxy.${C.reset}\n`);
211
212
  }
212
213
  // ── Step 2: local memory ──────────────────────────────────────────────────
213
- console.log(` ${C.cyan}${C.bold}Local memory${C.reset} ${C.dim}(self-hosted supermemory - github.com/supermemoryai/supermemory)${C.reset}`);
214
- console.log(` ${C.dim}Runs on your machine, MIT licensed, free. Replaces the Finch-hosted memory proxy.${C.reset}\n`);
215
- const enableLocal = (0, clink_input_js_1.dedupClinkInput)((await ask(` Enable local memory? [y/N]: `)).trim()).toLowerCase();
216
- if (enableLocal === "y" || enableLocal === "yes") {
214
+ console.log(` ${C.cyan}${C.bold}Local memory${C.reset} ${C.dim}(self-hosted - your choice of backend)${C.reset}`);
215
+ console.log(` ${C.dim}[1] Simple file-based (recommended) - zero dependency, JSON on disk at ~/.finch/memory/, keyword search only.${C.reset}`);
216
+ console.log(` ${C.dim}[2] Supermemory server - real semantic/embedding search, but you run a separate process yourself.${C.reset}`);
217
+ console.log(` ${C.dim}[3] Skip - memory tools use the Finch-hosted proxy.${C.reset}\n`);
218
+ const memChoice = (0, clink_input_js_1.dedupClinkInput)((await ask(` Choose [1-3]: `)).trim());
219
+ if (memChoice === "1") {
220
+ (0, config_js_1.writeConfig)({ memoryBackend: "local-file" });
221
+ console.log(`\n ${C.green}✓${C.reset} Local file memory enabled at ~/.finch/memory/. No server, no key - memory tools run fully local.\n`);
222
+ }
223
+ else if (memChoice === "2") {
217
224
  const url = "http://localhost:6767";
218
225
  console.log(`\n ${C.dim}Checking ${url}...${C.reset}`);
219
226
  let reachable = await isLocalMemoryReachableRaw(url);
@@ -389,16 +396,21 @@ const C = {
389
396
  // ██║ ╚████║╚██████╔╝███████╗███████╗╚██████╗███████╗██║ ██║╚███╔███╔╝
390
397
  // ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝
391
398
  const LOGO_LINES = [
392
- ` ███╗ ██╗ ██████╗ ███████╗██╗ ██████╗██╗ █████╗ ██╗ ██╗`,
393
- ` ████╗ ██║██╔═══██╗██╔════╝██║ ██╔════╝██║ ██╔══██╗██║ ██║`,
394
- ` ██╔██╗ ██║██║ ██║█████╗ ██║ ██║ ██║ ███████║██║ █╗ ██║`,
395
- ` ██║╚██╗██║██║ ██║██╔══╝ ██║ ██║ ██║ ██╔══██║██║███╗██║`,
396
- ` ██║ ╚████║╚██████╔╝███████╗███████╗╚██████╗███████╗██║ ██║╚███╔███╔╝`,
397
- ` ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝`,
399
+ ` ███████╗ ██╗ ███╗ ██╗ ██████╗ ██╗ ██╗`,
400
+ ` ██╔════╝ ██║ ████╗ ██║ ██╔════╝ ██║ ██║`,
401
+ ` █████╗ ██║ ██╔██╗ ██║ ██║ ███████║`,
402
+ ` ██╔══╝ ██║ ██║╚██╗██║ ██║ ██╔══██║`,
403
+ ` ██║ ██║ ██║ ╚████║ ╚██████╗ ██║ ██║`,
404
+ ` ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝`,
398
405
  ];
399
406
  const SEP = ` ${"─".repeat(70)}`;
400
407
  function buildBanner() {
401
408
  const cfg = (0, config_js_1.readConfig)();
409
+ // Every base_mcp_*/rh_mcp_* tool signs locally via getOrCreateWallet()
410
+ // (~/.finch/wallet.json) on BOTH Base and Robinhood Chain (RH reuses the
411
+ // same local address) - this CLI never touches the account's custodial
412
+ // webapp wallet. Showing that other address here would mismatch what
413
+ // every tool call actually reports and operates on.
402
414
  const walletAddr = getLocalWalletAddress();
403
415
  const provider = process.env.BANKR_API_KEY ? "Bankr"
404
416
  : process.env.ANTHROPIC_API_KEY ? "Anthropic"
@@ -415,8 +427,9 @@ function buildBanner() {
415
427
  const nameLine = displayName
416
428
  ? ` ${C.green}●${C.reset} ${displayName} ${displayEmail}`
417
429
  : ` ${C.green}●${C.reset} ${displayEmail || `${C.green}Signed in${C.reset}`}`;
430
+ const walletNote = "Base + Robinhood Chain · local device wallet · keys never leave your machine";
418
431
  const walletLine = walletAddr
419
- ? ` ${C.dim}Wallet${C.reset} ${C.cyan}${walletAddr.slice(0, 6)}...${walletAddr.slice(-4)}${C.reset} ${C.dim}· Base mainnet · keys never leave your machine${C.reset}`
432
+ ? ` ${C.dim}Wallet${C.reset} ${C.cyan}${walletAddr.slice(0, 6)}...${walletAddr.slice(-4)}${C.reset} ${C.dim}· ${walletNote}${C.reset}`
420
433
  : ` ${C.dim}Wallet not created yet · auto-creates on first DeFi call${C.reset}`;
421
434
  const llmLine = ` ${C.dim}LLM ${C.reset}${C.green}${provider}${C.reset} ${C.dim}· ${TOTAL_TOOL_COUNT} tools active${C.reset}`;
422
435
  authBlock = `\n${nameLine}\n${walletLine}\n${llmLine}`;
@@ -434,6 +447,10 @@ function buildBanner() {
434
447
  }
435
448
  // ── Post-login success block ──────────────────────────────────────────────────
436
449
  function printLoginSuccess({ email, name }) {
450
+ // Always show the local per-device wallet: every base_mcp_*/rh_mcp_* tool
451
+ // signs with it on BOTH chains. The account's custodial webapp wallet is a
452
+ // separate address this CLI never touches - showing it here would mismatch
453
+ // what every subsequent tool call actually reports.
437
454
  const walletAddr = getLocalWalletAddress();
438
455
  const displayName = name ?? "";
439
456
  console.log(`\n${SEP}`);
@@ -444,7 +461,8 @@ function printLoginSuccess({ email, name }) {
444
461
  console.log(` ${C.green}✓${C.reset} ${C.green}${C.bold}Signed in${C.reset} ${C.dim}as ${email}${C.reset}`);
445
462
  }
446
463
  if (walletAddr) {
447
- console.log(` ${C.dim}Wallet${C.reset} ${C.cyan}${walletAddr.slice(0, 6)}...${walletAddr.slice(-4)}${C.reset} ${C.dim}· Base mainnet${C.reset}`);
464
+ const note = "Base + Robinhood Chain · local device wallet";
465
+ console.log(` ${C.dim}Wallet${C.reset} ${C.cyan}${walletAddr.slice(0, 6)}...${walletAddr.slice(-4)}${C.reset} ${C.dim}· ${note}${C.reset}`);
448
466
  }
449
467
  else {
450
468
  console.log(` ${C.dim}Wallet auto-creates on first DeFi tool call${C.reset}`);
@@ -527,7 +545,7 @@ async function main() {
527
545
  });
528
546
  const data = await res.json();
529
547
  if (res.ok && data.token) {
530
- (0, config_js_1.writeConfig)({ sessionToken: data.token, email: data.email ?? "api-key-user", name: data.displayName ?? undefined });
548
+ (0, config_js_1.writeConfig)({ sessionToken: data.token, email: data.email ?? "api-key-user", name: data.displayName ?? undefined, walletAddress: data.walletAddress });
531
549
  console.log(`${C.green}✓${C.reset} ${C.dim}Signed in as ${data.email}${C.reset}`);
532
550
  }
533
551
  else {
@@ -539,6 +557,8 @@ async function main() {
539
557
  }
540
558
  }
541
559
  process.stdout.write(buildBanner());
560
+ // Once, here, so it doesn't interrupt the first tool reply mid-conversation.
561
+ (0, wallet_js_1.warnIfNoPassphrase)();
542
562
  // Check for updates in background - shows after banner, doesn't block prompt
543
563
  checkForUpdate().catch(() => { });
544
564
  const history = [];
@@ -998,10 +1018,17 @@ async function doctorFlow() {
998
1018
  fix: `Optional. Create at https://github.com/settings/tokens (scopes: public_repo) and add to MCP env.`,
999
1019
  });
1000
1020
  }
1001
- // 9. Local memory (self-hosted supermemory) - optional, zero-cost alternative
1002
- // to the Convex-proxied cloud memory backend.
1021
+ // 9. Local memory (self-hosted) - optional, zero-cost alternative to the
1022
+ // Convex-proxied cloud memory backend. Two flavors: zero-dependency file
1023
+ // store, or a separately-run supermemory server.
1003
1024
  const localMemCfg = (0, local_memory_js_1.getLocalMemoryConfig)();
1004
- if (localMemCfg) {
1025
+ if (localMemCfg?.kind === "file") {
1026
+ checks.push({
1027
+ name: "Local memory", status: "✓",
1028
+ detail: `file-based at ~/.finch/memory - memory tools run fully local, zero cost, no server`,
1029
+ });
1030
+ }
1031
+ else if (localMemCfg?.kind === "supermemory") {
1005
1032
  const reachable = await (0, local_memory_js_1.isLocalMemoryReachable)(localMemCfg);
1006
1033
  checks.push(reachable ? {
1007
1034
  name: "Local memory", status: "✓",
@@ -1143,6 +1170,9 @@ else if (cmd === "logout") {
1143
1170
  }
1144
1171
  else if (cmd === "status") {
1145
1172
  const cfg = (0, config_js_1.readConfig)();
1173
+ // Every base_mcp_*/rh_mcp_* tool signs locally on BOTH chains - the
1174
+ // account's custodial webapp wallet is a separate address this CLI never
1175
+ // touches, so always show the local one to match what tools report.
1146
1176
  const walletAddr = getLocalWalletAddress();
1147
1177
  const provider = process.env.BANKR_API_KEY ? "Bankr"
1148
1178
  : process.env.ANTHROPIC_API_KEY ? "Anthropic"
@@ -1155,7 +1185,8 @@ else if (cmd === "status") {
1155
1185
  const displayEmail = cfg.email ? `${C.dim}${cfg.email}${C.reset}` : "";
1156
1186
  console.log(` ${C.green}●${C.reset} ${displayName}${displayEmail}`);
1157
1187
  if (walletAddr) {
1158
- console.log(` ${C.dim}Wallet ${C.reset}${C.cyan}${walletAddr.slice(0, 6)}...${walletAddr.slice(-4)}${C.reset} ${C.dim}· Base mainnet${C.reset}`);
1188
+ const note = "Base + Robinhood Chain · local device wallet";
1189
+ console.log(` ${C.dim}Wallet ${C.reset}${C.cyan}${walletAddr.slice(0, 6)}...${walletAddr.slice(-4)}${C.reset} ${C.dim}· ${note}${C.reset}`);
1159
1190
  }
1160
1191
  else {
1161
1192
  console.log(` ${C.dim}Wallet not created yet${C.reset}`);
@@ -1169,12 +1200,38 @@ else if (cmd === "status") {
1169
1200
  }
1170
1201
  console.log(`${SEP_S}\n`);
1171
1202
  }
1203
+ else if (cmd === "run") {
1204
+ const prompt = process.argv.slice(3).join(" ");
1205
+ if (!prompt) {
1206
+ console.error(` ${C.red}✗ usage: finch run "your prompt"${C.reset}\n`);
1207
+ process.exit(1);
1208
+ }
1209
+ (0, wallet_js_1.warnIfNoPassphrase)();
1210
+ (async () => {
1211
+ const history = [];
1212
+ const stop = spinner("thinking");
1213
+ try {
1214
+ const result = await (0, agent_loop_js_1.runAgent)(prompt, history, (toolName) => {
1215
+ stop();
1216
+ process.stdout.write(` ${C.dim}✦ ${toolName}${C.reset}\n`);
1217
+ });
1218
+ stop();
1219
+ console.log(`\n${result.text}\n`);
1220
+ }
1221
+ catch (err) {
1222
+ stop();
1223
+ console.error(` ${C.red}✗ run error: ${err.message || err}${C.reset}\n`);
1224
+ process.exit(1);
1225
+ }
1226
+ })();
1227
+ }
1172
1228
  else if (cmd === "help" || cmd === "--help" || cmd === "-h") {
1173
1229
  console.log(`
1174
1230
  ${C.cyan}${C.bold}finch${C.reset} ${C.dim}runtime layer for Agentic AI · terminal CLI${C.reset}
1175
1231
 
1176
1232
  ${C.cyan}Commands:${C.reset}
1177
1233
  finch Start interactive AI terminal
1234
+ finch run "..." Run a single prompt and exit (scripting/CI-friendly)
1178
1235
  finch install Auto-configure all detected MCP clients
1179
1236
  finch login Sign in to unlock all tools
1180
1237
  finch logout Sign out and clear saved token
package/dist/config.js CHANGED
@@ -43,10 +43,10 @@ const fs = __importStar(require("fs"));
43
43
  const os = __importStar(require("os"));
44
44
  const path = __importStar(require("path"));
45
45
  const CONFIG_DIR = path.join(os.homedir(), ".finch");
46
- const LEGACY_DIR = path.join(os.homedir(), ".noelclaw");
46
+ const LEGACY_DIR = path.join(os.homedir(), ".finch");
47
47
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
48
48
  const LEGACY_FILE = path.join(LEGACY_DIR, "config.json");
49
- /** Prefer FINCH_* env, fall back to legacy NOELCLAW_* during rebrand. */
49
+ /** Prefer FINCH_* env, fall back to legacy FINCH_* during rebrand. */
50
50
  function env(name, legacy) {
51
51
  const primary = process.env[name];
52
52
  if (primary !== undefined && primary !== "")
@@ -56,9 +56,9 @@ function env(name, legacy) {
56
56
  if (v !== undefined && v !== "")
57
57
  return v;
58
58
  }
59
- // Auto-map FINCH_X → NOELCLAW_X when legacy not passed
59
+ // Auto-map FINCH_X → FINCH_X when legacy not passed
60
60
  if (name.startsWith("FINCH_")) {
61
- const auto = "NOELCLAW_" + name.slice("FINCH_".length);
61
+ const auto = "FINCH_" + name.slice("FINCH_".length);
62
62
  const v = process.env[auto];
63
63
  if (v !== undefined && v !== "")
64
64
  return v;
@@ -70,7 +70,7 @@ function readConfig() {
70
70
  if (fs.existsSync(CONFIG_FILE)) {
71
71
  return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
72
72
  }
73
- // migrate-read: still pick up old ~/.noelclaw/config.json
73
+ // migrate-read: still pick up old ~/.finch/config.json
74
74
  if (fs.existsSync(LEGACY_FILE)) {
75
75
  return JSON.parse(fs.readFileSync(LEGACY_FILE, "utf8"));
76
76
  }
@@ -89,7 +89,7 @@ function writeConfig(patch) {
89
89
  }
90
90
  function getSavedToken() {
91
91
  // env var always wins over saved config
92
- return env("FINCH_SESSION_TOKEN", "NOELCLAW_SESSION_TOKEN") ?? readConfig().sessionToken;
92
+ return env("FINCH_SESSION_TOKEN", "FINCH_SESSION_TOKEN") ?? readConfig().sessionToken;
93
93
  }
94
94
  /** Accept finch_sk_* (new) and noel_sk_* (backend still issues these). */
95
95
  function isApiKey(value) {
@@ -114,16 +114,16 @@ function hydrateEnvFromConfig() {
114
114
  process.env.OPENAI_BASE_URL = cfg.openaiBaseUrl;
115
115
  // Bridge legacy env into FINCH_* so the rest of the codebase can read one name.
116
116
  const bridges = [
117
- ["FINCH_SESSION_TOKEN", "NOELCLAW_SESSION_TOKEN"],
118
- ["FINCH_API_KEY", "NOELCLAW_API_KEY"],
119
- ["FINCH_CONVEX_URL", "NOELCLAW_CONVEX_URL"],
120
- ["FINCH_TOOLS", "NOELCLAW_TOOLS"],
121
- ["FINCH_PROVIDER", "NOELCLAW_PROVIDER"],
122
- ["FINCH_MODEL", "NOELCLAW_MODEL"],
123
- ["FINCH_RPC_URL", "NOELCLAW_RPC_URL"],
124
- ["FINCH_BROADCAST_RPC", "NOELCLAW_BROADCAST_RPC"],
125
- ["FINCH_WALLET_PASSPHRASE", "NOELCLAW_WALLET_PASSPHRASE"],
126
- ["FINCH_PAYMENT_HEADER", "NOELCLAW_PAYMENT_HEADER"],
117
+ ["FINCH_SESSION_TOKEN", "FINCH_SESSION_TOKEN"],
118
+ ["FINCH_API_KEY", "FINCH_API_KEY"],
119
+ ["FINCH_CONVEX_URL", "FINCH_CONVEX_URL"],
120
+ ["FINCH_TOOLS", "FINCH_TOOLS"],
121
+ ["FINCH_PROVIDER", "FINCH_PROVIDER"],
122
+ ["FINCH_MODEL", "FINCH_MODEL"],
123
+ ["FINCH_RPC_URL", "FINCH_RPC_URL"],
124
+ ["FINCH_BROADCAST_RPC", "FINCH_BROADCAST_RPC"],
125
+ ["FINCH_WALLET_PASSPHRASE", "FINCH_WALLET_PASSPHRASE"],
126
+ ["FINCH_PAYMENT_HEADER", "FINCH_PAYMENT_HEADER"],
127
127
  ];
128
128
  for (const [fin, leg] of bridges) {
129
129
  if (!process.env[fin] && process.env[leg])