@kaleidorg/mind 0.1.0 → 0.3.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.
- package/dist/capabilities.d.ts +38 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +41 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/context/budget.d.ts +29 -0
- package/dist/context/budget.d.ts.map +1 -0
- package/dist/context/budget.js +36 -0
- package/dist/context/budget.js.map +1 -0
- package/dist/context/builder.d.ts +39 -0
- package/dist/context/builder.d.ts.map +1 -0
- package/dist/context/builder.js +77 -0
- package/dist/context/builder.js.map +1 -0
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/fastpath/fastpath.d.ts +38 -0
- package/dist/fastpath/fastpath.d.ts.map +1 -0
- package/dist/fastpath/fastpath.js +52 -0
- package/dist/fastpath/fastpath.js.map +1 -0
- package/dist/funnel.d.ts +117 -0
- package/dist/funnel.d.ts.map +1 -0
- package/dist/funnel.js +195 -0
- package/dist/funnel.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +72 -0
- package/dist/kaleidoswap/contract.d.ts.map +1 -0
- package/dist/kaleidoswap/contract.js +125 -0
- package/dist/kaleidoswap/contract.js.map +1 -0
- package/dist/knowledge/bitcoin-copilot.d.ts +11 -0
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -0
- package/dist/knowledge/bitcoin-copilot.js +155 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -0
- package/dist/knowledge/btc-map.d.ts +87 -0
- package/dist/knowledge/btc-map.d.ts.map +1 -0
- package/dist/knowledge/btc-map.js +365 -0
- package/dist/knowledge/btc-map.js.map +1 -0
- package/dist/knowledge/merchants.d.ts +24 -0
- package/dist/knowledge/merchants.d.ts.map +1 -0
- package/dist/knowledge/merchants.js +34 -0
- package/dist/knowledge/merchants.js.map +1 -0
- package/dist/knowledge/wallet.d.ts +34 -0
- package/dist/knowledge/wallet.d.ts.map +1 -0
- package/dist/knowledge/wallet.js +63 -0
- package/dist/knowledge/wallet.js.map +1 -0
- package/dist/lsps1/contract.d.ts +55 -0
- package/dist/lsps1/contract.d.ts.map +1 -0
- package/dist/lsps1/contract.js +91 -0
- package/dist/lsps1/contract.js.map +1 -0
- package/dist/memory/store.d.ts +40 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +143 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/memory/tool.d.ts +9 -0
- package/dist/memory/tool.d.ts.map +1 -0
- package/dist/memory/tool.js +70 -0
- package/dist/memory/tool.js.map +1 -0
- package/dist/memory/types.d.ts +68 -0
- package/dist/memory/types.d.ts.map +1 -0
- package/dist/memory/types.js +14 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/rag/retriever.d.ts +30 -0
- package/dist/rag/retriever.d.ts.map +1 -0
- package/dist/rag/retriever.js +72 -0
- package/dist/rag/retriever.js.map +1 -0
- package/dist/rag/tool.d.ts +15 -0
- package/dist/rag/tool.d.ts.map +1 -0
- package/dist/rag/tool.js +42 -0
- package/dist/rag/tool.js.map +1 -0
- package/dist/rag/types.d.ts +44 -0
- package/dist/rag/types.d.ts.map +1 -0
- package/dist/rag/types.js +11 -0
- package/dist/rag/types.js.map +1 -0
- package/dist/rag/vector-store.d.ts +23 -0
- package/dist/rag/vector-store.d.ts.map +1 -0
- package/dist/rag/vector-store.js +72 -0
- package/dist/rag/vector-store.js.map +1 -0
- package/dist/recipe/asset-send.d.ts +15 -0
- package/dist/recipe/asset-send.d.ts.map +1 -0
- package/dist/recipe/asset-send.js +83 -0
- package/dist/recipe/asset-send.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +27 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.js +111 -0
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -0
- package/dist/recipe/payments.d.ts +15 -0
- package/dist/recipe/payments.d.ts.map +1 -0
- package/dist/recipe/payments.js +119 -0
- package/dist/recipe/payments.js.map +1 -0
- package/dist/recipe/receive.d.ts +14 -0
- package/dist/recipe/receive.d.ts.map +1 -0
- package/dist/recipe/receive.js +109 -0
- package/dist/recipe/receive.js.map +1 -0
- package/dist/recipe/runner.d.ts +42 -0
- package/dist/recipe/runner.d.ts.map +1 -0
- package/dist/recipe/runner.js +106 -0
- package/dist/recipe/runner.js.map +1 -0
- package/dist/recipe/swap.d.ts +16 -0
- package/dist/recipe/swap.d.ts.map +1 -0
- package/dist/recipe/swap.js +73 -0
- package/dist/recipe/swap.js.map +1 -0
- package/dist/recipe/types.d.ts +71 -0
- package/dist/recipe/types.d.ts.map +1 -0
- package/dist/recipe/types.js +13 -0
- package/dist/recipe/types.js.map +1 -0
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +20 -2
- package/dist/skills/registry.js.map +1 -1
- package/dist/tools/cli.d.ts +43 -0
- package/dist/tools/cli.d.ts.map +1 -0
- package/dist/tools/cli.js +61 -0
- package/dist/tools/cli.js.map +1 -0
- package/dist/tools/mcp.d.ts +3 -2
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mcp.js +3 -2
- package/dist/tools/mcp.js.map +1 -1
- package/dist/wallet/confirm.d.ts +12 -0
- package/dist/wallet/confirm.d.ts.map +1 -0
- package/dist/wallet/confirm.js +67 -0
- package/dist/wallet/confirm.js.map +1 -0
- package/dist/wallet/contract.d.ts +57 -0
- package/dist/wallet/contract.d.ts.map +1 -0
- package/dist/wallet/contract.js +113 -0
- package/dist/wallet/contract.js.map +1 -0
- package/package.json +10 -5
- package/skills/README.md +6 -1
- package/skills/kaleido-lsps/SKILL.md +56 -0
- package/skills/kaleido-trading/SKILL.md +85 -18
- package/skills/merchant-finder/SKILL.md +87 -0
- package/skills/paid-data/SKILL.md +12 -0
- package/skills/wallet-assistant/SKILL.md +38 -0
- package/src/capabilities.ts +79 -0
- package/src/context/budget.ts +46 -0
- package/src/context/builder.ts +100 -0
- package/src/context/context.test.ts +87 -0
- package/src/engine.ts +6 -0
- package/src/fastpath/fastpath.test.ts +34 -0
- package/src/fastpath/fastpath.ts +70 -0
- package/src/funnel.test.ts +207 -0
- package/src/funnel.ts +285 -0
- package/src/index.ts +128 -0
- package/src/kaleidoswap/contract.test.ts +147 -0
- package/src/kaleidoswap/contract.ts +212 -0
- package/src/knowledge/bitcoin-copilot.ts +177 -0
- package/src/knowledge/btc-map.test.ts +188 -0
- package/src/knowledge/btc-map.ts +446 -0
- package/src/knowledge/knowledge.test.ts +63 -0
- package/src/knowledge/merchants.ts +49 -0
- package/src/knowledge/wallet.ts +84 -0
- package/src/lsps1/contract.test.ts +81 -0
- package/src/lsps1/contract.ts +132 -0
- package/src/memory/memory.test.ts +140 -0
- package/src/memory/store.ts +174 -0
- package/src/memory/tool.ts +76 -0
- package/src/memory/types.ts +76 -0
- package/src/rag/rag.test.ts +85 -0
- package/src/rag/retriever.ts +94 -0
- package/src/rag/tool.ts +55 -0
- package/src/rag/types.ts +49 -0
- package/src/rag/vector-store.ts +78 -0
- package/src/recipe/asset-send.ts +79 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +138 -0
- package/src/recipe/kaleidoswap-atomic.ts +117 -0
- package/src/recipe/payments.ts +116 -0
- package/src/recipe/receive.ts +98 -0
- package/src/recipe/recipe.test.ts +193 -0
- package/src/recipe/runner.ts +134 -0
- package/src/recipe/swap.ts +74 -0
- package/src/recipe/types.ts +76 -0
- package/src/skills/registry.ts +21 -2
- package/src/skills/skills.test.ts +42 -0
- package/src/tools/cli.test.ts +53 -0
- package/src/tools/cli.ts +98 -0
- package/src/tools/mcp.ts +3 -2
- package/src/wallet/confirm.test.ts +57 -0
- package/src/wallet/confirm.ts +74 -0
- package/src/wallet/contract.test.ts +89 -0
- package/src/wallet/contract.ts +157 -0
- package/skills/kaleido-wallet/SKILL.md +0 -28
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in "swap" recipe — quote → (confirm) → execute, over the contract tools.
|
|
3
|
+
*
|
|
4
|
+
* "buy 0.001 btc with usdt" → from USDT, to BTC
|
|
5
|
+
* "swap 10 usdt for btc" → from USDT, to BTC
|
|
6
|
+
* "sell 100 usdt for sats" → from USDT, to BTC
|
|
7
|
+
*
|
|
8
|
+
* The deterministic extractor handles the common phrasings; the runner falls
|
|
9
|
+
* back to one LLM extraction otherwise. execute_swap is a spend → the engine's
|
|
10
|
+
* confirmation gate fires before it runs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Recipe } from './types.js';
|
|
14
|
+
|
|
15
|
+
const ASSET = /\b(btc|bitcoin|sats?|usdt|tether|xaut|gold)\b/i;
|
|
16
|
+
|
|
17
|
+
function normAsset(a?: string): string | undefined {
|
|
18
|
+
if (!a) return undefined;
|
|
19
|
+
const x = a.toLowerCase();
|
|
20
|
+
if (/btc|bitcoin|sat/.test(x)) return 'BTC';
|
|
21
|
+
if (/usdt|tether/.test(x)) return 'USDT';
|
|
22
|
+
if (/xaut|gold/.test(x)) return 'XAUT';
|
|
23
|
+
return a.toUpperCase();
|
|
24
|
+
}
|
|
25
|
+
const num = (s?: string) => (s ? Number(s.replace(/,/g, '')) : undefined);
|
|
26
|
+
|
|
27
|
+
/** "buy 0.001 btc with usdt" / "swap 10 usdt for btc" / "sell 100 usdt for sats". */
|
|
28
|
+
export function extractSwap(text: string): Record<string, unknown> | null {
|
|
29
|
+
const t = text.trim();
|
|
30
|
+
let m: RegExpMatchArray | null;
|
|
31
|
+
// buy <amt> <to> with/using <from> (amount is of the asset being bought)
|
|
32
|
+
if ((m = t.match(/buy\s+([\d.,]+)\s*([a-z]+)\s+(?:with|using|for)\s+([a-z]+)/i))) {
|
|
33
|
+
return { amount: num(m[1]), to_asset: normAsset(m[2]), from_asset: normAsset(m[3]) };
|
|
34
|
+
}
|
|
35
|
+
// swap/sell/convert/exchange/trade <amt> <from> for/to/into <to>
|
|
36
|
+
if ((m = t.match(/(?:swap|sell|convert|exchange|trade)\s+([\d.,]+)\s*([a-z]+)\s+(?:for|to|into)\s+([a-z]+)/i))) {
|
|
37
|
+
return { amount: num(m[1]), from_asset: normAsset(m[2]), to_asset: normAsset(m[3]) };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const swapRecipe: Recipe = {
|
|
43
|
+
name: 'swap',
|
|
44
|
+
description: 'Swap between BTC and an RGB asset — quote, then execute (with confirmation).',
|
|
45
|
+
// A crypto swap intent — but NOT buying a gift card (that's commerce) or an invoice.
|
|
46
|
+
match: (t) => /\b(swap|exchange|convert|trade)\b/i.test(t) || (/\b(buy|sell)\b/i.test(t) && ASSET.test(t) && !/\b(gift\s?card|top-?up|esim|voucher|invoice|address)\b/i.test(t)),
|
|
47
|
+
triggers: ['swap', 'exchange', 'convert', 'trade'],
|
|
48
|
+
slots: [
|
|
49
|
+
{ name: 'from_asset', type: 'string', description: 'Asset to spend (e.g. USDT, BTC)', required: true },
|
|
50
|
+
{ name: 'to_asset', type: 'string', description: 'Asset to receive (e.g. BTC, USDT)', required: true },
|
|
51
|
+
{ name: 'amount', type: 'number', description: 'Amount to swap' },
|
|
52
|
+
],
|
|
53
|
+
extract: extractSwap,
|
|
54
|
+
confident: (s) => !!s.from_asset && !!s.to_asset,
|
|
55
|
+
steps: [
|
|
56
|
+
{
|
|
57
|
+
tool: 'get_swap_quote',
|
|
58
|
+
as: 'quote',
|
|
59
|
+
args: (ctx) => ({ from_asset: ctx.slots.from_asset, to_asset: ctx.slots.to_asset, amount: ctx.slots.amount }),
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
final: {
|
|
63
|
+
tool: 'execute_swap',
|
|
64
|
+
args: (ctx) => {
|
|
65
|
+
const q = ctx.results.quote as { quote_id?: string } | undefined;
|
|
66
|
+
return { quote_id: q?.quote_id, from_asset: ctx.slots.from_asset, to_asset: ctx.slots.to_asset, amount: ctx.slots.amount };
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
summary: (ctx) => {
|
|
70
|
+
const q = ctx.results.quote as { receive_amount?: number } | undefined;
|
|
71
|
+
const tail = q?.receive_amount ? ` (~${q.receive_amount} ${ctx.slots.to_asset})` : '';
|
|
72
|
+
return `Swapped ${ctx.slots.amount} ${ctx.slots.from_asset} → ${ctx.slots.to_asset}${tail}.`;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipes — multi-step that works on a tiny model. "Recipes, not planning."
|
|
3
|
+
*
|
|
4
|
+
* A small model can't reliably PLAN a chain ("pay bob 3 EUR" = resolve → price
|
|
5
|
+
* → convert → send) from scratch. So a Recipe carries the plan; the model is
|
|
6
|
+
* used for ONE thing — extracting the request's slots (recipient, amount, …).
|
|
7
|
+
* The engine then runs the deterministic steps and the (confirmation-gated)
|
|
8
|
+
* final action. ~1 inference instead of 5; reliable on 0.6–4B.
|
|
9
|
+
*
|
|
10
|
+
* Pure data + interfaces — no deps. The provider + tools are injected.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface RecipeSlot {
|
|
14
|
+
name: string;
|
|
15
|
+
type?: 'string' | 'number' | 'boolean';
|
|
16
|
+
description: string;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RecipeContext {
|
|
21
|
+
/** The original user text. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** Extracted slots (deterministic regex, else one LLM call). */
|
|
24
|
+
slots: Record<string, unknown>;
|
|
25
|
+
/** Results of completed steps, keyed by `as` (or tool name). */
|
|
26
|
+
results: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RecipeStep {
|
|
30
|
+
/** Tool to call. */
|
|
31
|
+
tool: string;
|
|
32
|
+
/** Build the tool args from the accumulated context. */
|
|
33
|
+
args: (ctx: RecipeContext) => Record<string, unknown>;
|
|
34
|
+
/** Store the result under this key (default: the tool name). */
|
|
35
|
+
as?: string;
|
|
36
|
+
/** Skip this step when true (e.g. recipient is already an address). */
|
|
37
|
+
skipIf?: (ctx: RecipeContext) => boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Recipe {
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
/** Selection: a predicate or trigger phrases. */
|
|
44
|
+
match?: (text: string) => boolean;
|
|
45
|
+
triggers?: string[];
|
|
46
|
+
/** Fields the model (or regex) extracts from the request. */
|
|
47
|
+
slots: RecipeSlot[];
|
|
48
|
+
/** Optional deterministic extractor tried BEFORE the LLM (Tier-0 fast-path). */
|
|
49
|
+
extract?: (text: string) => Record<string, unknown> | null;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the recipe is confident enough to RUN deterministically given the
|
|
52
|
+
* extracted slots (vs falling back to the agentic loop). e.g. payments needs a
|
|
53
|
+
* recipient; receive needs an amount or asset. Default: any slot extracted.
|
|
54
|
+
*/
|
|
55
|
+
confident?: (slots: Record<string, unknown>) => boolean;
|
|
56
|
+
/** Deterministic steps, run in order, results threaded into `ctx`. */
|
|
57
|
+
steps: RecipeStep[];
|
|
58
|
+
/** The terminal action (usually a spend → confirmation-gated by its tool). */
|
|
59
|
+
final: RecipeStep;
|
|
60
|
+
/** Render the outcome for the user. */
|
|
61
|
+
summary?: (ctx: RecipeContext, finalResult: unknown) => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type RecipeStatus = 'done' | 'cancelled' | 'error';
|
|
65
|
+
|
|
66
|
+
export interface RecipeResult {
|
|
67
|
+
recipe: string;
|
|
68
|
+
slots: Record<string, unknown>;
|
|
69
|
+
results: Record<string, unknown>;
|
|
70
|
+
final?: unknown;
|
|
71
|
+
text: string;
|
|
72
|
+
status: RecipeStatus;
|
|
73
|
+
error?: string;
|
|
74
|
+
/** Number of LLM inferences used (0 if extraction was deterministic). */
|
|
75
|
+
inferences: number;
|
|
76
|
+
}
|
package/src/skills/registry.ts
CHANGED
|
@@ -75,6 +75,23 @@ const STOPWORDS = new Set([
|
|
|
75
75
|
'show', 'tell', 'how', 'much', 'many', 'about', 'into', 'over',
|
|
76
76
|
]);
|
|
77
77
|
|
|
78
|
+
/** Escape a string for safe inclusion in a regex. */
|
|
79
|
+
function reEscape(s: string): string {
|
|
80
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Match a trigger phrase in the query with WORD BOUNDARIES — so the
|
|
85
|
+
* `usd` trigger on a wallet skill doesn't fire on `usdt`/`usdc`, and
|
|
86
|
+
* `cafe` doesn't fire on `cafeteria`. Multi-word triggers ("near me")
|
|
87
|
+
* still work because spaces are already word boundaries.
|
|
88
|
+
*/
|
|
89
|
+
function triggerMatches(query: string, trigger: string): boolean {
|
|
90
|
+
const t = trigger.toLowerCase().trim();
|
|
91
|
+
if (!t) return false;
|
|
92
|
+
return new RegExp(`\\b${reEscape(t)}\\b`).test(query);
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
/** Default selector: score by meaningful keyword overlap; triggers weigh most. */
|
|
79
96
|
export const keywordSelector: SkillSelector = {
|
|
80
97
|
select(query, skills) {
|
|
@@ -89,8 +106,10 @@ export const keywordSelector: SkillSelector = {
|
|
|
89
106
|
const hayWords = haystack.split(/\W+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
|
|
90
107
|
let score = 0;
|
|
91
108
|
for (const w of hayWords) if (words.has(w)) score += 1;
|
|
92
|
-
// Strong boost for an explicit trigger appearing in the query
|
|
93
|
-
|
|
109
|
+
// Strong boost for an explicit trigger appearing in the query — at a
|
|
110
|
+
// word boundary, so short triggers (`usd`, `eur`, `cafe`) don't leak
|
|
111
|
+
// into longer words (`usdt`, `europe`, `cafeteria`).
|
|
112
|
+
for (const t of skill.triggers ?? []) if (triggerMatches(q, t)) score += 3;
|
|
94
113
|
if (score > bestScore) {
|
|
95
114
|
bestScore = score;
|
|
96
115
|
best = skill;
|
|
@@ -75,6 +75,48 @@ Manage Lightning channels via LSPS1.`);
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
describe('SkillRegistry selection — trigger word boundaries', () => {
|
|
79
|
+
// Regression: short triggers must NOT match inside longer words.
|
|
80
|
+
// Bug observed in the CLI: a wallet skill with `usd` as a trigger was
|
|
81
|
+
// picked for "what is the quote of usdt to btc" because the old
|
|
82
|
+
// q.includes("usd") was true for "usdt", outranking the trading skill.
|
|
83
|
+
const reg = new SkillRegistry();
|
|
84
|
+
reg.addMarkdown(`---
|
|
85
|
+
name: wallet-fiat
|
|
86
|
+
description: Check the BTC price and convert fiat to sats. Fiat support: usd, eur, gbp.
|
|
87
|
+
triggers: price, eur, usd, gbp
|
|
88
|
+
---
|
|
89
|
+
Wallet — fiat conversion.`);
|
|
90
|
+
reg.addMarkdown(`---
|
|
91
|
+
name: trading
|
|
92
|
+
description: Quote and execute swaps between BTC, USDT and XAUT on KaleidoSwap.
|
|
93
|
+
triggers: quote, swap, trade, usdt, xaut
|
|
94
|
+
---
|
|
95
|
+
Trading on the maker.`);
|
|
96
|
+
|
|
97
|
+
it("doesn't fire the `usd` trigger inside `usdt`", () => {
|
|
98
|
+
const sel = reg.select('what is the quote of usdt to btc')?.name;
|
|
99
|
+
expect(sel).toBe('trading'); // not wallet-fiat
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('still fires the `usd` trigger when the user actually said `usd`', () => {
|
|
103
|
+
const sel = reg.select('how many sats is 30 usd')?.name;
|
|
104
|
+
expect(sel).toBe('wallet-fiat');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("doesn't fire a short trigger inside a longer word — `cafe` not in `cafeteria`", () => {
|
|
108
|
+
const r2 = new SkillRegistry();
|
|
109
|
+
r2.addMarkdown(`---
|
|
110
|
+
name: merchants
|
|
111
|
+
description: Find merchants that accept Bitcoin.
|
|
112
|
+
triggers: cafe, restaurant, bar
|
|
113
|
+
---
|
|
114
|
+
Merchant finder.`);
|
|
115
|
+
expect(r2.select('the cafeteria menu')).toBeNull(); // no match
|
|
116
|
+
expect(r2.select('any bitcoin cafe nearby')?.name).toBe('merchants');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
78
120
|
describe('parseSkill — real Agent-Skills spec', () => {
|
|
79
121
|
it('unquotes the description, captures metadata, tolerates no tools', () => {
|
|
80
122
|
const s = parseSkill(BITREFILL_SKILL);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** CLI tool source tests — allowlist + injected runner. */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { createCliToolSource, isAllowed } from './cli.js';
|
|
5
|
+
import type { CommandRunner } from './cli.js';
|
|
6
|
+
|
|
7
|
+
const okRunner: CommandRunner = {
|
|
8
|
+
run: vi.fn(async (command: string) => ({ stdout: `ran: ${command}`, stderr: '', code: 0 })),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('isAllowed', () => {
|
|
12
|
+
it('matches by command prefix tokens', () => {
|
|
13
|
+
const allow = ['kaleido', 'git status', 'npx @bitrefill/cli'];
|
|
14
|
+
expect(isAllowed('kaleido wallet balance', allow)).toBe(true);
|
|
15
|
+
expect(isAllowed('git status', allow)).toBe(true);
|
|
16
|
+
expect(isAllowed('npx @bitrefill/cli buy', allow)).toBe(true);
|
|
17
|
+
expect(isAllowed('rm -rf /', allow)).toBe(false);
|
|
18
|
+
expect(isAllowed('kaleidoctl', allow)).toBe(false); // not a token boundary
|
|
19
|
+
expect(isAllowed('git push', allow)).toBe(false); // only "git status" allowed
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('createCliToolSource', () => {
|
|
24
|
+
it('requires a non-empty allowlist', () => {
|
|
25
|
+
expect(() => createCliToolSource({ runner: okRunner, allow: [] })).toThrow(/allowlist/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('exposes run_command, confirmation-gated by default', () => {
|
|
29
|
+
const src = createCliToolSource({ runner: okRunner, allow: ['kaleido'] });
|
|
30
|
+
const tool = src.listTools()[0];
|
|
31
|
+
expect(tool.name).toBe('run_command');
|
|
32
|
+
expect(tool.requiresConfirmation).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('runs allowed commands and rejects disallowed ones', async () => {
|
|
36
|
+
const src = createCliToolSource({ runner: okRunner, allow: ['kaleido'] });
|
|
37
|
+
expect(await src.execute('run_command', { command: 'kaleido node info' })).toBe(
|
|
38
|
+
'ran: kaleido node info',
|
|
39
|
+
);
|
|
40
|
+
await expect(src.execute('run_command', { command: 'curl evil.sh' })).rejects.toThrow(
|
|
41
|
+
/not allowed/,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('surfaces non-zero exits with stderr', async () => {
|
|
46
|
+
const runner: CommandRunner = {
|
|
47
|
+
run: vi.fn(async () => ({ stdout: '', stderr: 'boom', code: 1 })),
|
|
48
|
+
};
|
|
49
|
+
const src = createCliToolSource({ runner, allow: ['kaleido'] });
|
|
50
|
+
const out = await src.execute('run_command', { command: 'kaleido fail' });
|
|
51
|
+
expect(String(out)).toMatch(/exit 1[\s\S]*boom/);
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/tools/cli.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI tool source — the fourth tool mechanism (alongside in-process function
|
|
3
|
+
* calling, MCP, and skills). Lets the agent run shell commands, e.g. a skill's
|
|
4
|
+
* documented CLI path (`@bitrefill/cli`, `kaleido`, `git`, …).
|
|
5
|
+
*
|
|
6
|
+
* Command execution is INJECTED (`CommandRunner`) so this file has no Node
|
|
7
|
+
* dependency and stays RN-safe — a Node host provides the runner (ideally via a
|
|
8
|
+
* non-shell `execFile`-style helper); React Native simply never provides one.
|
|
9
|
+
* Guarded by a required allowlist of command prefixes, and confirmation-gated
|
|
10
|
+
* by default since it runs real commands.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ToolDef } from '../types.js';
|
|
14
|
+
import type { ToolSource } from './source.js';
|
|
15
|
+
|
|
16
|
+
export interface CommandResult {
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
code: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Injected shell runner. The Node host supplies a safe implementation. */
|
|
23
|
+
export interface CommandRunner {
|
|
24
|
+
run(command: string, opts?: { cwd?: string; timeoutMs?: number }): Promise<CommandResult>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CliToolOptions {
|
|
28
|
+
runner: CommandRunner;
|
|
29
|
+
/**
|
|
30
|
+
* Allowed command prefixes (REQUIRED — no empty allowlist). A command runs
|
|
31
|
+
* only if it starts with one of these tokens, e.g. ['kaleido', 'git status',
|
|
32
|
+
* 'npx @bitrefill/cli'].
|
|
33
|
+
*/
|
|
34
|
+
allow: string[];
|
|
35
|
+
cwd?: string;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** Confirmation gate (default true — it executes real commands). */
|
|
38
|
+
requiresConfirmation?: boolean;
|
|
39
|
+
/** Tool description override (e.g. name the specific CLI). */
|
|
40
|
+
description?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const RUN = 'run_command';
|
|
44
|
+
|
|
45
|
+
/** True if `command` is permitted by the allowlist (prefix match on tokens). */
|
|
46
|
+
export function isAllowed(command: string, allow: string[]): boolean {
|
|
47
|
+
const cmd = command.trim();
|
|
48
|
+
return allow.some((prefix) => {
|
|
49
|
+
const p = prefix.trim();
|
|
50
|
+
return p.length > 0 && (cmd === p || cmd.startsWith(p + ' '));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createCliToolSource(opts: CliToolOptions): ToolSource {
|
|
55
|
+
if (!opts.allow || opts.allow.length === 0) {
|
|
56
|
+
throw new Error('createCliToolSource: a non-empty `allow` allowlist is required');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tool: ToolDef = {
|
|
60
|
+
name: RUN,
|
|
61
|
+
description:
|
|
62
|
+
opts.description ??
|
|
63
|
+
`Run an allowed shell command and return its output. Allowed commands start ` +
|
|
64
|
+
`with: ${opts.allow.join(', ')}. Use for documented CLI tools.`,
|
|
65
|
+
parameters: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
command: { type: 'string', description: 'The full command line to run' },
|
|
69
|
+
},
|
|
70
|
+
required: ['command'],
|
|
71
|
+
},
|
|
72
|
+
requiresConfirmation: opts.requiresConfirmation ?? true,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
async function execute(_name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
76
|
+
const command = String(args.command ?? '').trim();
|
|
77
|
+
if (!command) throw new Error('run_command: command is required');
|
|
78
|
+
if (!isAllowed(command, opts.allow)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`run_command: "${command.split(' ')[0]}" is not allowed. Allowed: ${opts.allow.join(', ')}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const res = await opts.runner.run(command, { cwd: opts.cwd, timeoutMs: opts.timeoutMs });
|
|
84
|
+
const out = (res.stdout || '').trim();
|
|
85
|
+
const err = (res.stderr || '').trim();
|
|
86
|
+
if (res.code !== 0) {
|
|
87
|
+
return `exit ${res.code}${err ? `\n${err}` : ''}${out ? `\n${out}` : ''}`.trim();
|
|
88
|
+
}
|
|
89
|
+
return out || '(no output)';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
id: 'cli',
|
|
94
|
+
listTools: () => [tool],
|
|
95
|
+
has: (name) => name === RUN,
|
|
96
|
+
execute,
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/tools/mcp.ts
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* file type-checks and ships even where the SDK isn't installed; constructing
|
|
14
14
|
* an McpToolSource without it throws a clear error.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Wired end-to-end: connect() (stdio + HTTP transports), listTools() and
|
|
17
|
+
* execute() are implemented. Used by the desktop sidecar (kaleido-mcp +
|
|
18
|
+
* Bitrefill MCP) and verified against the remote Bitrefill MCP.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import type { ToolDef } from '../types.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Confirm-sheet readback — deterministic, voice-first spend summaries. */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { confirmReadback } from './confirm.js';
|
|
5
|
+
|
|
6
|
+
describe('confirmReadback', () => {
|
|
7
|
+
it('send_payment: sats + recipient, grouped thousands', () => {
|
|
8
|
+
expect(confirmReadback({ name: 'send_payment', arguments: { to: 'bob', amount_sats: 4800 } }))
|
|
9
|
+
.toBe('Send 4,800 sats to bob. Confirm?');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('send_payment: explicit layer is read back', () => {
|
|
13
|
+
expect(confirmReadback({ name: 'send_payment', arguments: { to: 'bob', amount_sats: 1000000, layer: 'spark' } }))
|
|
14
|
+
.toBe('Send 1,000,000 sats to bob over Spark. Confirm?');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('send_payment: asset amount when no sats (core router, no layer suffix)', () => {
|
|
18
|
+
expect(confirmReadback({ name: 'send_payment', arguments: { to: 'alice', asset: 'USDT', amount: 10 } }))
|
|
19
|
+
.toBe('Send 10 USDT to alice. Confirm?');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('spark_send: layer comes from the tool, address is shortened', () => {
|
|
23
|
+
const line = confirmReadback({
|
|
24
|
+
name: 'spark_send',
|
|
25
|
+
arguments: { amount_sats: 5000, to: 'bc1qabcdef0123456789xyzlongaddress' },
|
|
26
|
+
});
|
|
27
|
+
expect(line).toBe('Send 5,000 sats to bc1qab…ress over Spark. Confirm?');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rln_send_asset: asset + ticker + recipient over RLN', () => {
|
|
31
|
+
expect(confirmReadback({ name: 'rln_send_asset', arguments: { asset: 'USDT', amount: 10, to: 'bob' } }))
|
|
32
|
+
.toBe('Send 10 USDT to bob over RLN. Confirm?');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rln_pay_invoice: invoice shortened, over RLN', () => {
|
|
36
|
+
const line = confirmReadback({
|
|
37
|
+
name: 'rln_pay_invoice',
|
|
38
|
+
arguments: { invoice: 'lnbc1ptestinvoice0123456789abcd' },
|
|
39
|
+
});
|
|
40
|
+
expect(line).toBe('Pay Lightning invoice lnbc1p…abcd over RLN. Confirm?');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('execute_swap: from → to with amount', () => {
|
|
44
|
+
expect(confirmReadback({ name: 'execute_swap', arguments: { from_asset: 'BTC', to_asset: 'USDT', amount: 0.01 } }))
|
|
45
|
+
.toBe('Swap 0.01 BTC for USDT. Confirm?');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null for non-spend tools', () => {
|
|
49
|
+
expect(confirmReadback({ name: 'get_balances', arguments: {} })).toBeNull();
|
|
50
|
+
expect(confirmReadback({ name: 'resolve_contact', arguments: { name: 'bob' } })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('short contact names are not truncated; long refs are', () => {
|
|
54
|
+
expect(confirmReadback({ name: 'arkade_send', arguments: { amount_sats: 100, to: 'mum' } }))
|
|
55
|
+
.toBe('Send 100 sats to mum over Arkade. Confirm?');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirm-sheet readback — a deterministic, voice-first summary of a spend that
|
|
3
|
+
* the host speaks before executing it ("Send 4,800 sats to bob over Spark.
|
|
4
|
+
* Confirm?"). Built from the resolved tool call, not the model: zero inference,
|
|
5
|
+
* identical on every surface, and impossible for the model to phrase around.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getWalletTool } from './contract.js';
|
|
9
|
+
|
|
10
|
+
const LAYER_LABEL: Record<string, string> = {
|
|
11
|
+
spark: 'Spark',
|
|
12
|
+
rln: 'RLN',
|
|
13
|
+
arkade: 'Arkade',
|
|
14
|
+
liquid: 'Liquid',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Group an integer with thousands separators, locale-independently (test-stable). */
|
|
18
|
+
function fmtNum(n: number): string {
|
|
19
|
+
if (!Number.isFinite(n)) return String(n);
|
|
20
|
+
const neg = n < 0;
|
|
21
|
+
const [int, frac] = Math.abs(n).toString().split('.');
|
|
22
|
+
const grouped = int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
23
|
+
return (neg ? '-' : '') + (frac ? `${grouped}.${frac}` : grouped);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Looks like an address/invoice/lnurl (vs a human contact name). */
|
|
27
|
+
function isRef(s: string): boolean {
|
|
28
|
+
return /^(ln(bc|tb|bcrt)|bc1|tb1|lq1|lnurl)/i.test(s) || (s.length > 20 && !/\s/.test(s));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Shorten an address/invoice for speech; leave contact names intact. */
|
|
32
|
+
function shortRef(s: string): string {
|
|
33
|
+
const v = s.trim();
|
|
34
|
+
return isRef(v) ? `${v.slice(0, 6)}…${v.slice(-4)}` : v;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** " over Spark" suffix for the call's layer (explicit arg wins, else the tool's). */
|
|
38
|
+
function over(name: string, args: Record<string, unknown>): string {
|
|
39
|
+
const layer = typeof args.layer === 'string' ? args.layer : getWalletTool(name)?.layer;
|
|
40
|
+
const label = layer ? LAYER_LABEL[layer] : undefined;
|
|
41
|
+
return label ? ` over ${label}` : '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sats = (v: unknown) => `${fmtNum(Number(v))} sats`;
|
|
45
|
+
const asset = (amount: unknown, ticker: unknown) => `${fmtNum(Number(amount))} ${String(ticker)}`;
|
|
46
|
+
|
|
47
|
+
/** A spoken confirmation ending in "Confirm?", or null for non-spend tools. */
|
|
48
|
+
export function confirmReadback(call: { name: string; arguments: Record<string, unknown> }): string | null {
|
|
49
|
+
const { name, arguments: a } = call;
|
|
50
|
+
const to = (k = 'to') => shortRef(String(a[k] ?? ''));
|
|
51
|
+
const ask = (s: string) => `${s}. Confirm?`;
|
|
52
|
+
|
|
53
|
+
switch (name) {
|
|
54
|
+
case 'send_payment': {
|
|
55
|
+
const amt = a.amount_sats != null ? sats(a.amount_sats)
|
|
56
|
+
: a.asset != null && a.amount != null ? asset(a.amount, a.asset)
|
|
57
|
+
: undefined;
|
|
58
|
+
return ask(amt ? `Send ${amt} to ${to()}${over(name, a)}` : `Send a payment to ${to()}${over(name, a)}`);
|
|
59
|
+
}
|
|
60
|
+
case 'spark_send':
|
|
61
|
+
case 'arkade_send':
|
|
62
|
+
return ask(`Send ${sats(a.amount_sats)} to ${to()}${over(name, a)}`);
|
|
63
|
+
case 'rln_send_asset':
|
|
64
|
+
case 'liquid_send':
|
|
65
|
+
return ask(`Send ${asset(a.amount, a.asset)} to ${to()}${over(name, a)}`);
|
|
66
|
+
case 'rln_pay_invoice':
|
|
67
|
+
return ask(`Pay Lightning invoice ${shortRef(String(a.invoice ?? ''))}${over(name, a)}`);
|
|
68
|
+
case 'execute_swap':
|
|
69
|
+
return ask(`Swap ${fmtNum(Number(a.amount))} ${String(a.from_asset)} for ${String(a.to_asset)}`);
|
|
70
|
+
default:
|
|
71
|
+
// Unknown but spend-flagged tool → a generic, still-honest readback.
|
|
72
|
+
return getWalletTool(name)?.spend ? ask(`Confirm ${name.replace(/_/g, ' ')}`) : null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Wallet contract tests — integrity of the single source of truth. */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
WALLET_TOOLS,
|
|
6
|
+
SPEND_TOOLS,
|
|
7
|
+
isSpendTool,
|
|
8
|
+
walletTools,
|
|
9
|
+
toToolDefs,
|
|
10
|
+
bindWalletTools,
|
|
11
|
+
getWalletTool,
|
|
12
|
+
} from './contract.js';
|
|
13
|
+
|
|
14
|
+
describe('WALLET_TOOLS contract', () => {
|
|
15
|
+
it('has unique names and object schemas', () => {
|
|
16
|
+
const names = WALLET_TOOLS.map((t) => t.name);
|
|
17
|
+
expect(new Set(names).size).toBe(names.length);
|
|
18
|
+
for (const t of WALLET_TOOLS) {
|
|
19
|
+
expect(t.name).toMatch(/^[a-z][a-z0-9_]+$/);
|
|
20
|
+
expect((t.parameters as any).type).toBe('object');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('namespaces per-layer tools and keeps core helpers unprefixed', () => {
|
|
25
|
+
for (const t of WALLET_TOOLS) {
|
|
26
|
+
if (t.layer === 'core') continue;
|
|
27
|
+
expect(t.name.startsWith(`${t.layer}_`)).toBe(true);
|
|
28
|
+
}
|
|
29
|
+
expect(getWalletTool('send_payment')!.layer).toBe('core');
|
|
30
|
+
expect(getWalletTool('resolve_contact')!.layer).toBe('core');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('spend tools are confirmation-gated; reads do not move funds', () => {
|
|
34
|
+
for (const t of WALLET_TOOLS) {
|
|
35
|
+
expect(!!t.requiresConfirmation).toBe(!!t.spend);
|
|
36
|
+
}
|
|
37
|
+
// every fund-moving tool is flagged
|
|
38
|
+
expect(isSpendTool('send_payment')).toBe(true);
|
|
39
|
+
expect(isSpendTool('rln_send_asset')).toBe(true);
|
|
40
|
+
expect(isSpendTool('execute_swap')).toBe(true);
|
|
41
|
+
expect(isSpendTool('spark_send')).toBe(true);
|
|
42
|
+
// reads are not
|
|
43
|
+
expect(isSpendTool('get_balances')).toBe(false);
|
|
44
|
+
expect(isSpendTool('get_price')).toBe(false);
|
|
45
|
+
expect([...SPEND_TOOLS].length).toBeGreaterThanOrEqual(5);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('required args declared on the actionable tools', () => {
|
|
49
|
+
expect((getWalletTool('send_payment')!.parameters as any).required).toContain('to');
|
|
50
|
+
expect((getWalletTool('fiat_to_sats')!.parameters as any).required).toEqual(['amount', 'currency']);
|
|
51
|
+
expect((getWalletTool('rln_create_rgb_invoice')!.parameters as any).required).toEqual(['asset', 'amount']);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('selectors', () => {
|
|
56
|
+
it('walletTools filters by layer + always includes core unless disabled', () => {
|
|
57
|
+
const spark = walletTools({ layers: ['spark'] });
|
|
58
|
+
expect(spark.some((t) => t.name === 'spark_send')).toBe(true);
|
|
59
|
+
expect(spark.some((t) => t.layer === 'core')).toBe(true); // core included by default
|
|
60
|
+
expect(spark.some((t) => t.layer === 'rln')).toBe(false);
|
|
61
|
+
|
|
62
|
+
const noCore = walletTools({ layers: ['spark'], includeCore: false });
|
|
63
|
+
expect(noCore.every((t) => t.layer === 'spark')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('toToolDefs strips metadata but keeps requiresConfirmation', () => {
|
|
67
|
+
const defs = toToolDefs(walletTools({ layers: ['spark'] }));
|
|
68
|
+
const send = defs.find((d) => d.name === 'spark_send')!;
|
|
69
|
+
expect(send.requiresConfirmation).toBe(true);
|
|
70
|
+
expect('layer' in (send as any)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('bindWalletTools', () => {
|
|
75
|
+
it('binds handlers → an InProcessToolSource with spend flags preserved', async () => {
|
|
76
|
+
const handler = vi.fn(async () => ({ ok: true }));
|
|
77
|
+
const src = bindWalletTools({ spark_get_balance: handler, spark_send: handler }, { layers: ['spark'], includeCore: false, allowMissing: true });
|
|
78
|
+
const tools = src.listTools();
|
|
79
|
+
expect(tools.map((t) => t.name).sort()).toEqual(['spark_get_balance', 'spark_send']);
|
|
80
|
+
expect(tools.find((t) => t.name === 'spark_send')!.requiresConfirmation).toBe(true);
|
|
81
|
+
expect(await src.execute('spark_get_balance', {})).toEqual({ ok: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('throws on a missing handler unless allowMissing', () => {
|
|
85
|
+
expect(() => bindWalletTools({}, { layers: ['spark'], includeCore: false })).toThrow(/no handler/);
|
|
86
|
+
const src = bindWalletTools({ spark_get_balance: async () => 1 }, { layers: ['spark'], includeCore: false, allowMissing: true });
|
|
87
|
+
expect(src.listTools().map((t) => t.name)).toEqual(['spark_get_balance']);
|
|
88
|
+
});
|
|
89
|
+
});
|