@kaleidorg/mind 0.0.1 → 0.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.
- package/dist/capabilities.d.ts +34 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +34 -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 +18 -2
- 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 +111 -0
- package/dist/funnel.d.ts.map +1 -0
- package/dist/funnel.js +175 -0
- package/dist/funnel.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -1
- 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/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/memory/store.d.ts +34 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +103 -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 +56 -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/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 +94 -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/bundle.d.ts +30 -0
- package/dist/skills/bundle.d.ts.map +1 -0
- package/dist/skills/bundle.js +24 -0
- package/dist/skills/bundle.js.map +1 -0
- package/dist/skills/loader.d.ts +33 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +59 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/reference-source.d.ts +18 -0
- package/dist/skills/reference-source.d.ts.map +1 -0
- package/dist/skills/reference-source.js +53 -0
- package/dist/skills/reference-source.js.map +1 -0
- package/dist/skills/registry.d.ts +41 -0
- package/dist/skills/registry.d.ts.map +1 -0
- package/dist/skills/registry.js +167 -0
- package/dist/skills/registry.js.map +1 -0
- package/dist/skills/types.d.ts +53 -0
- package/dist/skills/types.d.ts.map +1 -0
- package/dist/skills/types.js +18 -0
- package/dist/skills/types.js.map +1 -0
- 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/l402.d.ts +47 -0
- package/dist/tools/l402.d.ts.map +1 -0
- package/dist/tools/l402.js +84 -0
- package/dist/tools/l402.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/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 +16 -5
- package/scripts/bundle-skills.mjs +84 -0
- package/skills/README.md +74 -0
- package/skills/bitrefill/SKILL.md +66 -0
- package/skills/bitrefill/references/api.md +99 -0
- package/skills/bitrefill/references/browse.md +71 -0
- package/skills/bitrefill/references/capability-matrix.md +115 -0
- package/skills/bitrefill/references/cli-headless-auth.md +133 -0
- package/skills/bitrefill/references/cli.md +237 -0
- package/skills/bitrefill/references/host-openclaw.md +167 -0
- package/skills/bitrefill/references/mcp.md +150 -0
- package/skills/bitrefill/references/safeguards.md +138 -0
- package/skills/bitrefill/references/troubleshooting.md +182 -0
- package/skills/kaleido-trading/SKILL.md +31 -0
- package/skills/kaleido-wallet/SKILL.md +28 -0
- package/src/capabilities.ts +67 -0
- package/src/context/budget.ts +46 -0
- package/src/context/builder.ts +100 -0
- package/src/context/context.test.ts +83 -0
- package/src/engine.test.ts +204 -0
- package/src/engine.ts +27 -2
- 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 +260 -0
- package/src/index.ts +102 -0
- package/src/knowledge/bitcoin-copilot.ts +177 -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/memory/memory.test.ts +85 -0
- package/src/memory/store.ts +129 -0
- package/src/memory/tool.ts +76 -0
- package/src/memory/types.ts +63 -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/payments.ts +116 -0
- package/src/recipe/receive.ts +98 -0
- package/src/recipe/recipe.test.ts +193 -0
- package/src/recipe/runner.ts +122 -0
- package/src/recipe/swap.ts +74 -0
- package/src/recipe/types.ts +76 -0
- package/src/skills/bundle.ts +42 -0
- package/src/skills/loader.ts +63 -0
- package/src/skills/reference-source.ts +60 -0
- package/src/skills/registry.ts +183 -0
- package/src/skills/skills.test.ts +191 -0
- package/src/skills/types.ts +55 -0
- package/src/tools/cli.test.ts +53 -0
- package/src/tools/cli.ts +98 -0
- package/src/tools/l402.test.ts +113 -0
- package/src/tools/l402.ts +122 -0
- package/src/tools/mcp.ts +3 -2
- package/src/wallet/contract.test.ts +89 -0
- package/src/wallet/contract.ts +157 -0
- package/dist/providers/qvac.d.ts +0 -89
- package/dist/providers/qvac.d.ts.map +0 -1
- package/dist/providers/qvac.js +0 -150
- package/dist/providers/qvac.js.map +0 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in "receive" recipe — create an invoice/address to get paid, in sats,
|
|
3
|
+
* BTC, an RGB asset, OR a fiat amount (converted to sats deterministically so a
|
|
4
|
+
* small model never mis-parses "$2.00").
|
|
5
|
+
*
|
|
6
|
+
* "invoice for 5000 sats" → BTC invoice, 5000 sats
|
|
7
|
+
* "create a payment request of $2" → fiat_to_sats(2, USD) → BTC invoice
|
|
8
|
+
* "invoice for 25 USDT on Liquid" → RGB asset invoice
|
|
9
|
+
* "an invoice" → BTC invoice, any amount
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Recipe } from './types.js';
|
|
13
|
+
|
|
14
|
+
const LAYER = /\b(spark|arkade|liquid|rln|lightning|rgb)\b/i;
|
|
15
|
+
const RECEIVE_INTENT = /\b(invoice|receive|deposit|get paid|pay me|payment request|request (a )?payment)\b/i;
|
|
16
|
+
|
|
17
|
+
type Kind = 'sats' | 'btc' | 'fiat' | 'asset';
|
|
18
|
+
|
|
19
|
+
function classify(text: string): { kind: Kind; code: string } {
|
|
20
|
+
if (/\$|\busd\b|dollar/i.test(text)) return { kind: 'fiat', code: 'USD' };
|
|
21
|
+
if (/€|\beur\b|euro/i.test(text)) return { kind: 'fiat', code: 'EUR' };
|
|
22
|
+
if (/£|\bgbp\b|pound/i.test(text)) return { kind: 'fiat', code: 'GBP' };
|
|
23
|
+
if (/usdt|tether/i.test(text)) return { kind: 'asset', code: 'USDT' };
|
|
24
|
+
if (/xaut|gold/i.test(text)) return { kind: 'asset', code: 'XAUT' };
|
|
25
|
+
if (/\bbtc\b|bitcoin/i.test(text)) return { kind: 'btc', code: 'BTC' };
|
|
26
|
+
return { kind: 'sats', code: 'BTC' }; // default: sats
|
|
27
|
+
}
|
|
28
|
+
function normLayer(l?: string): string | undefined {
|
|
29
|
+
if (!l) return undefined;
|
|
30
|
+
const x = l.toLowerCase();
|
|
31
|
+
if (x === 'lightning' || x === 'rgb') return 'rln';
|
|
32
|
+
if (['spark', 'arkade', 'liquid', 'rln'].includes(x)) return x;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
function parseAmount(t: string): number | undefined {
|
|
36
|
+
const m = t.match(/(\d[\d.,]*)\s*([km])?\b/i);
|
|
37
|
+
if (!m) return undefined;
|
|
38
|
+
let n = Number(m[1]!.replace(/,/g, ''));
|
|
39
|
+
if (m[2]) n *= m[2].toLowerCase() === 'k' ? 1_000 : 1_000_000;
|
|
40
|
+
return Number.isNaN(n) ? undefined : n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function extractReceive(text: string): Record<string, unknown> | null {
|
|
44
|
+
const t = text.trim();
|
|
45
|
+
if (!RECEIVE_INTENT.test(t)) return null;
|
|
46
|
+
const amount = parseAmount(t);
|
|
47
|
+
const { kind, code } = classify(t);
|
|
48
|
+
const layer = normLayer(t.match(LAYER)?.[1]);
|
|
49
|
+
return { ...(amount != null ? { amount } : {}), kind, currency: code, ...(layer ? { layer } : {}) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const receiveRecipe: Recipe = {
|
|
53
|
+
name: 'receive',
|
|
54
|
+
description: 'Create an invoice/address to receive funds — sats, BTC, a fiat amount (converted), or an RGB asset.',
|
|
55
|
+
match: (t) => RECEIVE_INTENT.test(t) && !/\b(send|transfer|swap|buy|sell)\b/i.test(t),
|
|
56
|
+
triggers: ['invoice', 'receive', 'deposit', 'payment request'],
|
|
57
|
+
slots: [
|
|
58
|
+
{ name: 'amount', type: 'number', description: 'Amount to receive (optional)' },
|
|
59
|
+
{ name: 'currency', type: 'string', description: 'sats | BTC | USD | EUR | USDT | XAUT' },
|
|
60
|
+
{ name: 'layer', type: 'string', description: 'Rail: spark, rln, liquid (optional)' },
|
|
61
|
+
],
|
|
62
|
+
extract: extractReceive,
|
|
63
|
+
confident: () => true,
|
|
64
|
+
steps: [
|
|
65
|
+
{
|
|
66
|
+
// Fiat → sats, only when the amount is fiat-denominated.
|
|
67
|
+
tool: 'fiat_to_sats',
|
|
68
|
+
as: 'conv',
|
|
69
|
+
args: (ctx) => ({ amount: ctx.slots.amount, currency: ctx.slots.currency }),
|
|
70
|
+
skipIf: (ctx) => ctx.slots.kind !== 'fiat' || ctx.slots.amount == null,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
final: {
|
|
74
|
+
tool: 'create_invoice',
|
|
75
|
+
args: (ctx) => {
|
|
76
|
+
const kind = ctx.slots.kind as Kind;
|
|
77
|
+
const conv = ctx.results.conv as { sats?: number } | undefined;
|
|
78
|
+
let asset = 'BTC';
|
|
79
|
+
let amount = ctx.slots.amount as number | undefined;
|
|
80
|
+
if (kind === 'asset') asset = String(ctx.slots.currency);
|
|
81
|
+
else if (kind === 'fiat') amount = conv?.sats;
|
|
82
|
+
else if (kind === 'btc' && amount != null) amount = Math.round(amount * 1e8); // sats
|
|
83
|
+
return { asset, amount, layer: ctx.slots.layer };
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
summary: (ctx, result) => {
|
|
87
|
+
const r = result as { invoice?: string; address?: string } | undefined;
|
|
88
|
+
const dest = r?.invoice ?? r?.address ?? '';
|
|
89
|
+
const kind = ctx.slots.kind as Kind;
|
|
90
|
+
const conv = ctx.results.conv as { sats?: number } | undefined;
|
|
91
|
+
let amt = 'any amount';
|
|
92
|
+
if (kind === 'fiat' && ctx.slots.amount != null) amt = `${ctx.slots.currency} ${ctx.slots.amount} (${(conv?.sats ?? 0).toLocaleString()} sats)`;
|
|
93
|
+
else if (kind === 'asset' && ctx.slots.amount != null) amt = `${Number(ctx.slots.amount).toLocaleString()} ${ctx.slots.currency}`;
|
|
94
|
+
else if (ctx.slots.amount != null) amt = `${Number(ctx.slots.amount).toLocaleString()} ${kind === 'btc' ? 'BTC' : 'sats'}`;
|
|
95
|
+
const label = kind === 'asset' ? String(ctx.slots.currency) : 'BTC';
|
|
96
|
+
return dest ? `Here's your ${label} invoice for ${amt}:\n\n${dest}` : `Created an invoice for ${amt}.`;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
3
|
+
import { InProcessToolSource } from '../tools/in-process.js';
|
|
4
|
+
import type { LLMProvider } from '../providers/types.js';
|
|
5
|
+
import { runRecipe, RecipeRegistry } from './runner.js';
|
|
6
|
+
import { paymentsRecipe, extractPayment } from './payments.js';
|
|
7
|
+
import { swapRecipe, extractSwap } from './swap.js';
|
|
8
|
+
import { receiveRecipe, extractReceive } from './receive.js';
|
|
9
|
+
import { assetSendRecipe, extractAssetSend } from './asset-send.js';
|
|
10
|
+
import { paymentsRecipe as _pay } from './payments.js';
|
|
11
|
+
|
|
12
|
+
// Stub contract tools: resolve_contact, fiat_to_sats, send_payment (spend).
|
|
13
|
+
function stubTools(spy?: { send?: (a: any) => void }) {
|
|
14
|
+
const src = new InProcessToolSource('wallet', [
|
|
15
|
+
{ name: 'resolve_contact', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ name }) => ({ name, ln_address: `${name}@kaleidoswap.com` }) },
|
|
16
|
+
{ name: 'fiat_to_sats', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ amount }) => ({ sats: Math.round(Number(amount) * 1000) }) },
|
|
17
|
+
{ name: 'send_payment', description: '', parameters: { type: 'object', properties: {} }, requiresConfirmation: true, handler: async (a) => { spy?.send?.(a); return { status: 'SUCCESS', payment_hash: 'h' }; } },
|
|
18
|
+
]);
|
|
19
|
+
return new ToolRegistry([src]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const approve: LLMProvider = { name: 'x', runTurn: async () => ({ text: '', rawContent: '', toolCalls: [] }) };
|
|
23
|
+
|
|
24
|
+
describe('extractPayment (deterministic Tier-0)', () => {
|
|
25
|
+
it('parses contact + fiat', () => {
|
|
26
|
+
expect(extractPayment('pay bob 3 eur')).toEqual({ recipient: 'bob', amount: 3, currency: 'eur' });
|
|
27
|
+
});
|
|
28
|
+
it('parses "send N sats to X"', () => {
|
|
29
|
+
expect(extractPayment('send 5,000 sats to alice')).toEqual({ recipient: 'alice', amount: 5000, currency: 'sats' });
|
|
30
|
+
});
|
|
31
|
+
it('parses btc + onchain-ish recipient', () => {
|
|
32
|
+
expect(extractPayment('send 0.001 btc to bob')).toEqual({ recipient: 'bob', amount: 0.001, currency: 'btc' });
|
|
33
|
+
});
|
|
34
|
+
it('expands k/m shorthand (no 1000x under-send)', () => {
|
|
35
|
+
expect(extractPayment('send 5k sats to bob')).toEqual({ recipient: 'bob', amount: 5000, currency: 'sats' });
|
|
36
|
+
expect(extractPayment('send 2m sats to alice')).toEqual({ recipient: 'alice', amount: 2_000_000, currency: 'sats' });
|
|
37
|
+
});
|
|
38
|
+
it('returns null for non-payment text', () => {
|
|
39
|
+
expect(extractPayment('what is my balance')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('runRecipe — pay a contact', () => {
|
|
44
|
+
it('fiat path: resolve → fiat_to_sats → confirm → send', async () => {
|
|
45
|
+
const sent: any[] = [];
|
|
46
|
+
const tools = stubTools({ send: (a) => sent.push(a) });
|
|
47
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
48
|
+
const res = await runRecipe(paymentsRecipe, 'pay bob 3 eur', { provider: approve, tools, onConfirm });
|
|
49
|
+
|
|
50
|
+
expect(res.status).toBe('done');
|
|
51
|
+
expect(res.inferences).toBe(0); // deterministic extraction, no LLM
|
|
52
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
53
|
+
expect(res.results.contact).toMatchObject({ ln_address: 'bob@kaleidoswap.com' });
|
|
54
|
+
expect(sent[0]).toEqual({ to: 'bob@kaleidoswap.com', amount_sats: 3000 }); // 3 * 1000
|
|
55
|
+
expect(res.text).toContain('3,000 sats');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('sats path skips fiat_to_sats', async () => {
|
|
59
|
+
const sent: any[] = [];
|
|
60
|
+
const tools = stubTools({ send: (a) => sent.push(a) });
|
|
61
|
+
const res = await runRecipe(paymentsRecipe, 'send 5000 sats to alice', { provider: approve, tools, onConfirm: async () => ({ approved: true }) });
|
|
62
|
+
expect(res.status).toBe('done');
|
|
63
|
+
expect(res.results.conv).toBeUndefined(); // fiat step skipped
|
|
64
|
+
expect(sent[0]).toEqual({ to: 'alice@kaleidoswap.com', amount_sats: 5000 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('denied confirmation → cancelled, nothing sent', async () => {
|
|
68
|
+
const sent: any[] = [];
|
|
69
|
+
const tools = stubTools({ send: (a) => sent.push(a) });
|
|
70
|
+
const res = await runRecipe(paymentsRecipe, 'pay bob 3 eur', { provider: approve, tools, onConfirm: async () => ({ approved: false }) });
|
|
71
|
+
expect(res.status).toBe('cancelled');
|
|
72
|
+
expect(sent).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('falls back to ONE LLM extraction when regex misses', async () => {
|
|
76
|
+
const sent: any[] = [];
|
|
77
|
+
const tools = stubTools({ send: (a) => sent.push(a) });
|
|
78
|
+
// A recipe with no deterministic extractor → must use the provider.
|
|
79
|
+
const llmOnly = { ...paymentsRecipe, extract: undefined };
|
|
80
|
+
const provider: LLMProvider = {
|
|
81
|
+
name: 'mock',
|
|
82
|
+
runTurn: vi.fn(async () => ({ text: '', rawContent: '', toolCalls: [{ id: '1', name: 'extract_request', arguments: { recipient: 'bob', amount: 2, currency: 'usd' } }] })),
|
|
83
|
+
};
|
|
84
|
+
const res = await runRecipe(llmOnly, 'could you move a couple bucks to bob', { provider, tools, onConfirm: async () => ({ approved: true }) });
|
|
85
|
+
expect(res.inferences).toBe(1);
|
|
86
|
+
expect(provider.runTurn).toHaveBeenCalledOnce();
|
|
87
|
+
expect(sent[0]).toEqual({ to: 'bob@kaleidoswap.com', amount_sats: 2000 });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('extractSwap', () => {
|
|
92
|
+
it('parses "buy X <to> with <from>"', () => {
|
|
93
|
+
expect(extractSwap('buy 0.001 btc with usdt')).toEqual({ amount: 0.001, to_asset: 'BTC', from_asset: 'USDT' });
|
|
94
|
+
});
|
|
95
|
+
it('parses "swap X <from> for <to>"', () => {
|
|
96
|
+
expect(extractSwap('swap 10 usdt for btc')).toEqual({ amount: 10, from_asset: 'USDT', to_asset: 'BTC' });
|
|
97
|
+
});
|
|
98
|
+
it('returns null for non-swap text', () => {
|
|
99
|
+
expect(extractSwap('what is my balance')).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('runRecipe — swap', () => {
|
|
104
|
+
it('quote → confirm → execute', async () => {
|
|
105
|
+
const exec: any[] = [];
|
|
106
|
+
const tools = new ToolRegistry([new InProcessToolSource('w', [
|
|
107
|
+
{ name: 'get_swap_quote', description: '', parameters: { type: 'object', properties: {} }, handler: async (a) => ({ quote_id: 'q1', receive_amount: 1500, ...a }) },
|
|
108
|
+
{ name: 'execute_swap', description: '', parameters: { type: 'object', properties: {} }, requiresConfirmation: true, handler: async (a) => { exec.push(a); return { status: 'SUCCESS' }; } },
|
|
109
|
+
])]);
|
|
110
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
111
|
+
const res = await runRecipe(swapRecipe, 'buy 0.001 btc with usdt', { provider: approve, tools, onConfirm });
|
|
112
|
+
expect(res.status).toBe('done');
|
|
113
|
+
expect(res.inferences).toBe(0);
|
|
114
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
115
|
+
expect(res.results.quote).toMatchObject({ quote_id: 'q1' });
|
|
116
|
+
expect(exec[0]).toMatchObject({ quote_id: 'q1', from_asset: 'USDT', to_asset: 'BTC', amount: 0.001 });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('extractReceive', () => {
|
|
121
|
+
it('parses asset + layer', () => {
|
|
122
|
+
expect(extractReceive('create an invoice for 25 usdt on liquid')).toEqual({ amount: 25, kind: 'asset', currency: 'USDT', layer: 'liquid' });
|
|
123
|
+
});
|
|
124
|
+
it('amountless BTC invoice', () => {
|
|
125
|
+
expect(extractReceive('give me an invoice')).toEqual({ kind: 'sats', currency: 'BTC' });
|
|
126
|
+
});
|
|
127
|
+
it('sats with k shorthand', () => {
|
|
128
|
+
expect(extractReceive('invoice for 5k sats')).toEqual({ amount: 5000, kind: 'sats', currency: 'BTC' });
|
|
129
|
+
});
|
|
130
|
+
it('fiat: "$2.00" → 2 USD (not 2000)', () => {
|
|
131
|
+
expect(extractReceive('create a payment request of $2.00')).toEqual({ amount: 2, kind: 'fiat', currency: 'USD' });
|
|
132
|
+
});
|
|
133
|
+
it('null for non-receive text', () => {
|
|
134
|
+
expect(extractReceive('pay bob 3 eur')).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('runRecipe — receive', () => {
|
|
139
|
+
const tools = () => new ToolRegistry([new InProcessToolSource('w', [
|
|
140
|
+
{ name: 'fiat_to_sats', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ amount }) => ({ sats: Math.round(Number(amount) * 1500) }) },
|
|
141
|
+
{ name: 'create_invoice', description: '', parameters: { type: 'object', properties: {} }, handler: async (a) => ({ invoice: `lnbc-${a.asset}-${a.amount ?? 'any'}` }) },
|
|
142
|
+
])]);
|
|
143
|
+
it('sats → create_invoice (no confirmation)', async () => {
|
|
144
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
145
|
+
const res = await runRecipe(receiveRecipe, 'invoice for 5000 sats', { provider: approve, tools: tools(), onConfirm });
|
|
146
|
+
expect(res.status).toBe('done');
|
|
147
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
148
|
+
expect(res.text).toContain('lnbc-BTC-5000');
|
|
149
|
+
});
|
|
150
|
+
it('fiat "$2" → fiat_to_sats → invoice (3000 sats), never 2000', async () => {
|
|
151
|
+
const res = await runRecipe(receiveRecipe, 'create a payment request of $2', { provider: approve, tools: tools() });
|
|
152
|
+
expect(res.status).toBe('done');
|
|
153
|
+
expect(res.text).toContain('lnbc-BTC-3000'); // 2 * 1500
|
|
154
|
+
expect(res.text).toContain('USD 2');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('extractAssetSend + USDT bug fix', () => {
|
|
159
|
+
it('parses "send N USDT to contact"', () => {
|
|
160
|
+
expect(extractAssetSend('send 10 usdt to bob')).toEqual({ recipient: 'bob', asset: 'USDT', amount: 10 });
|
|
161
|
+
});
|
|
162
|
+
it('null for BTC/sats sends (payments owns those)', () => {
|
|
163
|
+
expect(extractAssetSend('send 5000 sats to bob')).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
it('payments recipe NO LONGER matches an asset send (was the bug)', () => {
|
|
166
|
+
expect(_pay.match!('send 10 usdt to bob')).toBe(false);
|
|
167
|
+
expect(_pay.match!('send 5000 sats to bob')).toBe(true);
|
|
168
|
+
expect(assetSendRecipe.match!('send 10 usdt to bob')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('runRecipe — asset send', () => {
|
|
173
|
+
it('resolve → rln_send_asset (confirmation-gated), no fiat conversion', async () => {
|
|
174
|
+
const sent: any[] = [];
|
|
175
|
+
const tools = new ToolRegistry([new InProcessToolSource('w', [
|
|
176
|
+
{ name: 'resolve_contact', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ name }) => ({ name, ln_address: `${name}@x.com` }) },
|
|
177
|
+
{ name: 'rln_send_asset', description: '', parameters: { type: 'object', properties: {} }, requiresConfirmation: true, handler: async (a) => { sent.push(a); return { status: 'SUCCESS' }; } },
|
|
178
|
+
])]);
|
|
179
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
180
|
+
const res = await runRecipe(assetSendRecipe, 'send 10 usdt to bob', { provider: approve, tools, onConfirm });
|
|
181
|
+
expect(res.status).toBe('done');
|
|
182
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
183
|
+
expect(sent[0]).toEqual({ asset: 'USDT', amount: 10, to: 'bob@x.com' });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('RecipeRegistry', () => {
|
|
188
|
+
it('selects by trigger', () => {
|
|
189
|
+
const reg = new RecipeRegistry([paymentsRecipe]);
|
|
190
|
+
expect(reg.select('pay bob 3 eur')?.name).toBe('pay-contact');
|
|
191
|
+
expect(reg.select('what is my balance')).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe runner — executes a Recipe with ONE structured extraction (or a
|
|
3
|
+
* deterministic regex), then runs the deterministic step chain and the
|
|
4
|
+
* confirmation-gated final action. This is mobile multi-step: a tiny model
|
|
5
|
+
* fills slots; the engine does the planning.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LLMProvider } from '../providers/types.js';
|
|
9
|
+
import type { ToolRegistry } from '../tools/registry.js';
|
|
10
|
+
import type { ConfirmDecision } from '../types.js';
|
|
11
|
+
import type { Recipe, RecipeContext, RecipeResult } from './types.js';
|
|
12
|
+
|
|
13
|
+
const EXTRACT_TOOL = 'extract_request';
|
|
14
|
+
|
|
15
|
+
export interface RunRecipeOptions {
|
|
16
|
+
provider: LLMProvider;
|
|
17
|
+
tools: ToolRegistry;
|
|
18
|
+
/** Called before the (spend) final action when its tool is confirmation-gated. */
|
|
19
|
+
onConfirm?: (call: { name: string; arguments: Record<string, unknown> }) => Promise<ConfirmDecision>;
|
|
20
|
+
/** Progress hook per completed step. */
|
|
21
|
+
onStep?: (name: string, args: Record<string, unknown>, result: unknown) => void;
|
|
22
|
+
/** Skip extraction and use these slots (deterministic Tier-0 / tests). */
|
|
23
|
+
slots?: Record<string, unknown>;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Extract the recipe's slots — deterministic regex first, else ONE LLM call. */
|
|
28
|
+
export async function extractSlots(
|
|
29
|
+
provider: LLMProvider,
|
|
30
|
+
recipe: Recipe,
|
|
31
|
+
text: string,
|
|
32
|
+
): Promise<{ slots: Record<string, unknown>; inferences: number }> {
|
|
33
|
+
const det = recipe.extract?.(text);
|
|
34
|
+
if (det && Object.values(det).some((v) => v !== undefined && v !== null && v !== '')) {
|
|
35
|
+
return { slots: det, inferences: 0 };
|
|
36
|
+
}
|
|
37
|
+
const properties: Record<string, { type: string; description: string }> = {};
|
|
38
|
+
for (const s of recipe.slots) properties[s.name] = { type: s.type ?? 'string', description: s.description };
|
|
39
|
+
const extractTool = {
|
|
40
|
+
name: EXTRACT_TOOL,
|
|
41
|
+
description: `Extract the fields from the user's request.`,
|
|
42
|
+
parameters: { type: 'object', properties, required: recipe.slots.filter((s) => s.required).map((s) => s.name) },
|
|
43
|
+
};
|
|
44
|
+
const out = await provider.runTurn({
|
|
45
|
+
system: `Call ${EXTRACT_TOOL} with the fields from the user's message. Do not call any other tool and do not add commentary.`,
|
|
46
|
+
messages: [{ role: 'user', content: text }],
|
|
47
|
+
tools: [extractTool],
|
|
48
|
+
});
|
|
49
|
+
const call = out.toolCalls?.find((c) => c.name === EXTRACT_TOOL) ?? out.toolCalls?.[0];
|
|
50
|
+
return { slots: (call?.arguments as Record<string, unknown>) ?? {}, inferences: 1 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Run a recipe end to end. Never throws — failures come back as status:'error'. */
|
|
54
|
+
export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOptions): Promise<RecipeResult> {
|
|
55
|
+
const ctx: RecipeContext = { text, slots: opts.slots ?? {}, results: {} };
|
|
56
|
+
let inferences = 0;
|
|
57
|
+
try {
|
|
58
|
+
if (!opts.slots) {
|
|
59
|
+
const ex = await extractSlots(opts.provider, recipe, text);
|
|
60
|
+
ctx.slots = ex.slots;
|
|
61
|
+
inferences = ex.inferences;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Deterministic steps.
|
|
65
|
+
for (const step of recipe.steps) {
|
|
66
|
+
if (step.skipIf?.(ctx)) continue;
|
|
67
|
+
const args = step.args(ctx);
|
|
68
|
+
const result = await opts.tools.execute(step.tool, args);
|
|
69
|
+
ctx.results[step.as ?? step.tool] = result;
|
|
70
|
+
opts.onStep?.(step.tool, args, result);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Final action — confirmation-gated if the tool requires it. Like the
|
|
74
|
+
// Engine, a missing onConfirm FAILS CLOSED: the spend is declined, never
|
|
75
|
+
// silently executed.
|
|
76
|
+
const finalArgs = recipe.final.args(ctx);
|
|
77
|
+
const def = await opts.tools.getDef(recipe.final.tool);
|
|
78
|
+
if (def?.requiresConfirmation) {
|
|
79
|
+
const decision = opts.onConfirm
|
|
80
|
+
? await opts.onConfirm({ name: recipe.final.tool, arguments: finalArgs })
|
|
81
|
+
: { approved: false, reason: 'no confirmation handler available' };
|
|
82
|
+
if (!decision.approved) {
|
|
83
|
+
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: 'Cancelled — nothing was sent.', status: 'cancelled', inferences };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const finalResult = await opts.tools.execute(recipe.final.tool, finalArgs);
|
|
87
|
+
ctx.results[recipe.final.as ?? recipe.final.tool] = finalResult;
|
|
88
|
+
opts.onStep?.(recipe.final.tool, finalArgs, finalResult);
|
|
89
|
+
|
|
90
|
+
const out = recipe.summary?.(ctx, finalResult) ?? 'Done.';
|
|
91
|
+
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, final: finalResult, text: out, status: 'done', inferences };
|
|
92
|
+
} catch (e) {
|
|
93
|
+
const msg = (e as Error)?.message ?? String(e);
|
|
94
|
+
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: `Couldn't complete that: ${msg}`, status: 'error', error: msg, inferences };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Selects a recipe for a request. Use before falling back to the free agentic loop. */
|
|
99
|
+
export class RecipeRegistry {
|
|
100
|
+
private recipes: Recipe[];
|
|
101
|
+
constructor(recipes: Recipe[] = []) {
|
|
102
|
+
this.recipes = [...recipes];
|
|
103
|
+
}
|
|
104
|
+
add(recipe: Recipe): void {
|
|
105
|
+
this.recipes.push(recipe);
|
|
106
|
+
}
|
|
107
|
+
list(): Recipe[] {
|
|
108
|
+
return [...this.recipes];
|
|
109
|
+
}
|
|
110
|
+
get(name: string): Recipe | undefined {
|
|
111
|
+
return this.recipes.find((r) => r.name === name);
|
|
112
|
+
}
|
|
113
|
+
/** First recipe whose match()/triggers fit the text, else null. */
|
|
114
|
+
select(text: string): Recipe | null {
|
|
115
|
+
const lc = text.toLowerCase();
|
|
116
|
+
return (
|
|
117
|
+
this.recipes.find((r) =>
|
|
118
|
+
r.match ? r.match(text) : (r.triggers ?? []).some((t) => lc.includes(t.toLowerCase())),
|
|
119
|
+
) ?? null
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill bundle — the RN-safe counterpart to the Node fs loader.
|
|
3
|
+
*
|
|
4
|
+
* React Native has no filesystem, so a skill folder can't be read at runtime.
|
|
5
|
+
* Instead a build step serialises the skills into a `SkillBundle` (plain JSON:
|
|
6
|
+
* each skill's raw SKILL.md text + its reference files), and the app rehydrates
|
|
7
|
+
* them here with `skillsFromBundle()`. Same skills, same SKILL.md authoring —
|
|
8
|
+
* just delivered as data instead of files.
|
|
9
|
+
*
|
|
10
|
+
* Pure, dependency-free, no fs/url imports — safe to import from the package's
|
|
11
|
+
* main entry on any host.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Skill, SkillReference } from './types.js';
|
|
15
|
+
import { parseSkill } from './registry.js';
|
|
16
|
+
|
|
17
|
+
/** One serialised skill: the SKILL.md text + its reference files. */
|
|
18
|
+
export interface BundledSkill {
|
|
19
|
+
/** Folder name (informational; the real name comes from the frontmatter). */
|
|
20
|
+
dir?: string;
|
|
21
|
+
/** Raw SKILL.md contents. */
|
|
22
|
+
markdown: string;
|
|
23
|
+
/** references/*.md files. */
|
|
24
|
+
references?: SkillReference[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A bundle of skills produced by the bundler script. */
|
|
28
|
+
export interface SkillBundle {
|
|
29
|
+
version: 1;
|
|
30
|
+
skills: BundledSkill[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Rehydrate Skills from a bundle (RN-safe — no filesystem). */
|
|
34
|
+
export function skillsFromBundle(bundle: SkillBundle): Skill[] {
|
|
35
|
+
if (!bundle || bundle.version !== 1 || !Array.isArray(bundle.skills)) {
|
|
36
|
+
throw new Error('skillsFromBundle: not a valid v1 SkillBundle');
|
|
37
|
+
}
|
|
38
|
+
return bundle.skills.map((b) => ({
|
|
39
|
+
...parseSkill(b.markdown, b.references),
|
|
40
|
+
dir: b.dir,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill loader — reads Claude-style Agent Skill folders from disk. NODE ONLY.
|
|
3
|
+
*
|
|
4
|
+
* Import from `@kaleidorg/mind/skills` on Node hosts (desktop sidecar,
|
|
5
|
+
* kaleidoagent). React Native has no filesystem — there, build skills with
|
|
6
|
+
* `SkillRegistry.addMarkdown(text, references)` from bundled strings instead.
|
|
7
|
+
*
|
|
8
|
+
* Layout (Anthropic Agent Skills spec, e.g. bitrefill/agents):
|
|
9
|
+
*
|
|
10
|
+
* skills/
|
|
11
|
+
* bitrefill/
|
|
12
|
+
* SKILL.md
|
|
13
|
+
* references/
|
|
14
|
+
* mcp.md
|
|
15
|
+
* cli.md
|
|
16
|
+
* …
|
|
17
|
+
*
|
|
18
|
+
* `loadSkillsDir(root)` returns one Skill per SKILL.md found, with every
|
|
19
|
+
* reference markdown read into `skill.references` for progressive disclosure.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import type { Skill, SkillReference } from './types.js';
|
|
26
|
+
import { parseSkill } from './registry.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Absolute path to the skills shipped inside this package
|
|
30
|
+
* (`@kaleidorg/mind/skills`). Resolves relative to the compiled loader, so it
|
|
31
|
+
* works from any host that installs the package. Override with an explicit dir
|
|
32
|
+
* when you keep skills elsewhere.
|
|
33
|
+
*/
|
|
34
|
+
export function packagedSkillsDir(): string {
|
|
35
|
+
// dist/skills/loader.js → ../../skills == <package root>/skills
|
|
36
|
+
return fileURLToPath(new URL('../../skills/', import.meta.url));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Load one skill folder containing a SKILL.md (+ optional references/). */
|
|
40
|
+
export function loadSkillFromDir(dir: string): Skill {
|
|
41
|
+
const skillFile = join(dir, 'SKILL.md');
|
|
42
|
+
if (!existsSync(skillFile)) throw new Error(`No SKILL.md in ${dir}`);
|
|
43
|
+
const markdown = readFileSync(skillFile, 'utf8');
|
|
44
|
+
|
|
45
|
+
const refDir = join(dir, 'references');
|
|
46
|
+
const references: SkillReference[] = existsSync(refDir)
|
|
47
|
+
? readdirSync(refDir)
|
|
48
|
+
.filter((f) => f.endsWith('.md'))
|
|
49
|
+
.sort()
|
|
50
|
+
.map((name) => ({ name, content: readFileSync(join(refDir, name), 'utf8') }))
|
|
51
|
+
: [];
|
|
52
|
+
|
|
53
|
+
return { ...parseSkill(markdown, references), dir };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Load every skill folder under `root` (each a dir with a SKILL.md). */
|
|
57
|
+
export function loadSkillsDir(root: string): Skill[] {
|
|
58
|
+
if (!existsSync(root)) return [];
|
|
59
|
+
return readdirSync(root, { withFileTypes: true })
|
|
60
|
+
.filter((e) => e.isDirectory() && existsSync(join(root, e.name, 'SKILL.md')))
|
|
61
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
62
|
+
.map((e) => loadSkillFromDir(join(root, e.name)));
|
|
63
|
+
}
|