@kaleidorg/mind 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/funnel.d.ts +19 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +48 -10
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -3
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +16 -4
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +102 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/knowledge/btc-map.d.ts +14 -17
- package/dist/knowledge/btc-map.d.ts.map +1 -1
- package/dist/knowledge/btc-map.js +66 -266
- package/dist/knowledge/btc-map.js.map +1 -1
- package/dist/lsps1/contract.d.ts.map +1 -1
- package/dist/lsps1/contract.js +28 -10
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/qvac/parse.d.ts +15 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +68 -5
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/text.d.ts.map +1 -1
- package/dist/qvac/text.js +4 -0
- package/dist/qvac/text.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +26 -0
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
- package/dist/recipe/buy-asset-channel.js +112 -0
- package/dist/recipe/buy-asset-channel.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +101 -63
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-channel-order.js +493 -0
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
- package/dist/recipe/kaleidoswap-price.d.ts +21 -0
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-price.js +57 -0
- package/dist/recipe/kaleidoswap-price.js.map +1 -0
- package/dist/recipe/runner.d.ts +7 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +115 -29
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts +26 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +108 -13
- package/dist/recipe/swap.js.map +1 -1
- package/dist/recipe/types.d.ts +25 -1
- package/dist/recipe/types.d.ts.map +1 -1
- package/dist/skills/registry.d.ts +33 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +45 -1
- package/dist/skills/registry.js.map +1 -1
- package/package.json +1 -1
- package/skills/README.md +3 -0
- package/skills/kaleido-lsps/SKILL.md +101 -43
- package/skills/kaleido-trading/SKILL.md +81 -31
- package/skills/merchant-finder/SKILL.md +96 -66
- package/skills/rgb-lightning-node/SKILL.md +108 -0
- package/skills/wallet-assistant/SKILL.md +32 -21
- package/src/funnel.ts +66 -11
- package/src/index.ts +14 -2
- package/src/kaleidoswap/contract.test.ts +7 -2
- package/src/kaleidoswap/contract.ts +27 -5
- package/src/knowledge/bitcoin-copilot.ts +111 -0
- package/src/knowledge/btc-map.test.ts +53 -96
- package/src/knowledge/btc-map.ts +72 -287
- package/src/lsps1/contract.ts +32 -14
- package/src/qvac/parse.test.ts +70 -1
- package/src/qvac/parse.ts +71 -5
- package/src/qvac/text.ts +4 -0
- package/src/recipe/buy-asset-channel.test.ts +148 -0
- package/src/recipe/buy-asset-channel.ts +118 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
- package/src/recipe/kaleidoswap-atomic.ts +112 -66
- package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
- package/src/recipe/kaleidoswap-channel-order.ts +548 -0
- package/src/recipe/kaleidoswap-price.ts +68 -0
- package/src/recipe/recipe.test.ts +61 -5
- package/src/recipe/runner.ts +128 -31
- package/src/recipe/swap.ts +109 -13
- package/src/recipe/types.ts +25 -1
- package/src/skills/registry.ts +52 -1
|
@@ -4,7 +4,7 @@ import { InProcessToolSource } from '../tools/in-process.js';
|
|
|
4
4
|
import type { LLMProvider } from '../providers/types.js';
|
|
5
5
|
import { runRecipe, RecipeRegistry } from './runner.js';
|
|
6
6
|
import { paymentsRecipe, extractPayment } from './payments.js';
|
|
7
|
-
import { swapRecipe, extractSwap } from './swap.js';
|
|
7
|
+
import { swapRecipe, extractSwap, extractPriceQuery } from './swap.js';
|
|
8
8
|
import { receiveRecipe, extractReceive } from './receive.js';
|
|
9
9
|
import { assetSendRecipe, extractAssetSend } from './asset-send.js';
|
|
10
10
|
import { paymentsRecipe as _pay } from './payments.js';
|
|
@@ -89,15 +89,71 @@ describe('runRecipe — pay a contact', () => {
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
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' });
|
|
92
|
+
it('parses "buy X <to> with <from>" — amount on the TO leg', () => {
|
|
93
|
+
expect(extractSwap('buy 0.001 btc with usdt')).toEqual({ amount: 0.001, to_asset: 'BTC', from_asset: 'USDT', amount_side: 'to' });
|
|
94
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' });
|
|
95
|
+
it('parses "swap X <from> for <to>" — amount on the FROM leg', () => {
|
|
96
|
+
expect(extractSwap('swap 10 usdt for btc')).toEqual({ amount: 10, from_asset: 'USDT', to_asset: 'BTC', amount_side: 'from' });
|
|
97
|
+
});
|
|
98
|
+
it('parses "buy one usdt" — word-number, default funding asset, TO leg (the reported bug)', () => {
|
|
99
|
+
expect(extractSwap('buy one usdt from kaleido')).toEqual({ amount: 1, from_asset: 'BTC', to_asset: 'USDT', amount_side: 'to' });
|
|
100
|
+
});
|
|
101
|
+
it('parses "sell 100 usdt" — default target BTC, FROM leg', () => {
|
|
102
|
+
expect(extractSwap('sell 100 usdt')).toEqual({ amount: 100, from_asset: 'USDT', to_asset: 'BTC', amount_side: 'from' });
|
|
103
|
+
});
|
|
104
|
+
it('ignores a non-asset word as the funding asset ("from kaleido" → defaults BTC)', () => {
|
|
105
|
+
const r = extractSwap('buy 5 xaut from kaleido') as any;
|
|
106
|
+
expect(r.to_asset).toBe('XAUT');
|
|
107
|
+
expect(r.from_asset).toBe('BTC');
|
|
97
108
|
});
|
|
98
109
|
it('returns null for non-swap text', () => {
|
|
99
110
|
expect(extractSwap('what is my balance')).toBeNull();
|
|
100
111
|
});
|
|
112
|
+
|
|
113
|
+
// Price-flavoured phrasings belong to extractPriceQuery (separate recipe) —
|
|
114
|
+
// extractSwap returns null for them so the atomic recipe doesn't move funds
|
|
115
|
+
// on a question the user only meant as a rate lookup.
|
|
116
|
+
it('does NOT parse price/rate phrasings (those go to kaleidoswapPriceRecipe)', () => {
|
|
117
|
+
expect(extractSwap('what is the price of usdt in sats')).toBeNull();
|
|
118
|
+
expect(extractSwap('btc price')).toBeNull();
|
|
119
|
+
expect(extractSwap('how much sats for 1 usdt')).toBeNull();
|
|
120
|
+
expect(extractSwap('cost of xaut')).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('extractPriceQuery', () => {
|
|
125
|
+
it('parses the reported transcript case', () => {
|
|
126
|
+
expect(extractPriceQuery('what is the price of usdt in sats')).toEqual({
|
|
127
|
+
amount: 1, from_asset: 'BTC', to_asset: 'USDT', amount_side: 'to',
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
it('tolerates a "the" article', () => {
|
|
131
|
+
expect(extractPriceQuery('what is the price of the usdt in sats?')).toEqual({
|
|
132
|
+
amount: 1, from_asset: 'BTC', to_asset: 'USDT', amount_side: 'to',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
it('"btc price" — funding defaults to USDT when pricing BTC', () => {
|
|
136
|
+
expect(extractPriceQuery('btc price')).toEqual({
|
|
137
|
+
amount: 1, from_asset: 'USDT', to_asset: 'BTC', amount_side: 'to',
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
it('"how much sats for 1 usdt" — denom inferred from the unit, not order', () => {
|
|
141
|
+
expect(extractPriceQuery('how much sats for 1 usdt')).toEqual({
|
|
142
|
+
amount: 1, from_asset: 'BTC', to_asset: 'USDT', amount_side: 'to',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
it('handles "cost of xaut" and "how much does 1 btc cost"', () => {
|
|
146
|
+
expect((extractPriceQuery('cost of xaut') as any)?.to_asset).toBe('XAUT');
|
|
147
|
+
expect((extractPriceQuery('how much does 1 btc cost') as any)?.to_asset).toBe('BTC');
|
|
148
|
+
});
|
|
149
|
+
it('does NOT fire on a non-asset price question', () => {
|
|
150
|
+
expect(extractPriceQuery('what is the price of gas')).toBeNull();
|
|
151
|
+
expect(extractPriceQuery('how much does it cost')).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
it('does NOT fire on a swap intent (those go to the atomic recipe)', () => {
|
|
154
|
+
expect(extractPriceQuery('swap 10 usdt to btc')).toBeNull();
|
|
155
|
+
expect(extractPriceQuery('buy one usdt')).toBeNull();
|
|
156
|
+
});
|
|
101
157
|
});
|
|
102
158
|
|
|
103
159
|
describe('runRecipe — swap', () => {
|
package/src/recipe/runner.ts
CHANGED
|
@@ -15,8 +15,13 @@ const EXTRACT_TOOL = 'extract_request';
|
|
|
15
15
|
export interface RunRecipeOptions {
|
|
16
16
|
provider: LLMProvider;
|
|
17
17
|
tools: ToolRegistry;
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Called before a confirmation-gated spend. `summary` is set when the recipe
|
|
20
|
+
* supplies a `confirm(ctx)` — a human-readable description of the whole
|
|
21
|
+
* approved action (e.g. "swap 10 USDT → 15,250 sats, fee 154 sats"). Hosts
|
|
22
|
+
* should prefer `summary` over the raw tool name/args when showing a sheet.
|
|
23
|
+
*/
|
|
24
|
+
onConfirm?: (call: { name: string; arguments: Record<string, unknown>; summary?: string }) => Promise<ConfirmDecision>;
|
|
20
25
|
/** Progress hook per completed step. */
|
|
21
26
|
onStep?: (name: string, args: Record<string, unknown>, result: unknown) => void;
|
|
22
27
|
/** Skip extraction and use these slots (deterministic Tier-0 / tests). */
|
|
@@ -31,23 +36,87 @@ export async function extractSlots(
|
|
|
31
36
|
text: string,
|
|
32
37
|
): Promise<{ slots: Record<string, unknown>; inferences: number }> {
|
|
33
38
|
const det = recipe.extract?.(text);
|
|
34
|
-
|
|
39
|
+
const detValid = det && Object.values(det).some((v) => v !== undefined && v !== null && v !== '');
|
|
40
|
+
|
|
41
|
+
if (detValid && !recipe.forceModelExtract) {
|
|
35
42
|
return { slots: det, inferences: 0 };
|
|
36
43
|
}
|
|
44
|
+
|
|
45
|
+
// Build a richer extraction prompt + tool schema so small models have a
|
|
46
|
+
// better chance of producing correct structured output for recipes (especially
|
|
47
|
+
// when forceModelExtract is on for natural language intents like "buy 1 usdt").
|
|
37
48
|
const properties: Record<string, { type: string; description: string }> = {};
|
|
38
49
|
for (const s of recipe.slots) properties[s.name] = { type: s.type ?? 'string', description: s.description };
|
|
50
|
+
|
|
51
|
+
const recipeHint = recipe.description ? ` for the "${recipe.name}" recipe (${recipe.description})` : '';
|
|
39
52
|
const extractTool = {
|
|
40
53
|
name: EXTRACT_TOOL,
|
|
41
|
-
description: `Extract the fields from the user's request.`,
|
|
54
|
+
description: `Extract the fields from the user's request${recipeHint}.`,
|
|
42
55
|
parameters: { type: 'object', properties, required: recipe.slots.filter((s) => s.required).map((s) => s.name) },
|
|
43
56
|
};
|
|
57
|
+
|
|
58
|
+
const system = [
|
|
59
|
+
`Call ${EXTRACT_TOOL} with the fields from the user's message.`,
|
|
60
|
+
recipe.description ? `This extraction is for: ${recipe.description}.` : '',
|
|
61
|
+
'Only emit values that match the field descriptions.',
|
|
62
|
+
'Canonical assets: BTC, USDT, XAUT (pass as strings like "BTC" or "USDT").',
|
|
63
|
+
'amount_side: "to" when the named amount is what you receive/buy (e.g. "buy 1 USDT" → to_asset=USDT, amount=1, from_asset=BTC); "from" for sell/swap (amount on from_asset).',
|
|
64
|
+
'The host binding handles per-asset precision scaling (BTC in sats → maker units; USDT/XAUT whole units). Pass the user\'s number as-is for the correct side.',
|
|
65
|
+
'Do not call any other tool and do not add commentary.',
|
|
66
|
+
].filter(Boolean).join(' ');
|
|
67
|
+
|
|
44
68
|
const out = await provider.runTurn({
|
|
45
|
-
system
|
|
69
|
+
system,
|
|
46
70
|
messages: [{ role: 'user', content: text }],
|
|
47
71
|
tools: [extractTool],
|
|
48
72
|
});
|
|
73
|
+
|
|
49
74
|
const call = out.toolCalls?.find((c) => c.name === EXTRACT_TOOL) ?? out.toolCalls?.[0];
|
|
50
|
-
|
|
75
|
+
let llmSlots: Record<string, unknown> = (call?.arguments as Record<string, unknown>) ?? {};
|
|
76
|
+
|
|
77
|
+
// Safety net when forceModelExtract is active.
|
|
78
|
+
// - The LLM is authoritative for the slots it filled — its output wins.
|
|
79
|
+
// - Det is used only to backfill required fields the LLM left empty.
|
|
80
|
+
// - The amount_side-specific check below applies ONLY to recipes that
|
|
81
|
+
// actually declare an `amount_side` slot (swap-shaped recipes) — for
|
|
82
|
+
// others (channel-order, etc.) it would clobber correct LLM extraction
|
|
83
|
+
// because amount_side is always undefined.
|
|
84
|
+
if (recipe.forceModelExtract && detValid) {
|
|
85
|
+
const required = recipe.slots.filter((s) => s.required);
|
|
86
|
+
const llmHasAllRequired = required.every((s) => {
|
|
87
|
+
const v = llmSlots[s.name];
|
|
88
|
+
return v != null && v !== '';
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const recipeHasAmountSide = recipe.slots.some((s) => s.name === 'amount_side');
|
|
92
|
+
if (recipeHasAmountSide) {
|
|
93
|
+
const llmSide = String(llmSlots.amount_side || '').toLowerCase();
|
|
94
|
+
const validSide = llmSide === 'from' || llmSide === 'to';
|
|
95
|
+
if (!llmHasAllRequired || !validSide) {
|
|
96
|
+
llmSlots = { ...det, ...llmSlots };
|
|
97
|
+
} else {
|
|
98
|
+
llmSlots.amount_side = llmSide;
|
|
99
|
+
}
|
|
100
|
+
if (!validSide && det.amount_side) {
|
|
101
|
+
llmSlots.amount_side = det.amount_side;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// Generic path: backfill ANY slot the LLM didn't populate from det's
|
|
105
|
+
// value, when det has one. LLM wins on every field it actually filled,
|
|
106
|
+
// but det shouldn't be silently erased — small models often omit
|
|
107
|
+
// non-required slots (e.g. asset_ticker on a USDT channel) that the
|
|
108
|
+
// deterministic regex caught reliably.
|
|
109
|
+
for (const s of recipe.slots) {
|
|
110
|
+
const llmVal = llmSlots[s.name];
|
|
111
|
+
const detVal = det[s.name];
|
|
112
|
+
if ((llmVal == null || llmVal === '') && detVal != null && detVal !== '') {
|
|
113
|
+
llmSlots[s.name] = detVal;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { slots: llmSlots, inferences: 1 };
|
|
51
120
|
}
|
|
52
121
|
|
|
53
122
|
/** Run a recipe end to end. Never throws — failures come back as status:'error'. */
|
|
@@ -61,40 +130,68 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
|
|
|
61
130
|
inferences = ex.inferences;
|
|
62
131
|
}
|
|
63
132
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
133
|
+
// Confidence re-check AFTER extraction (whether deterministic, LLM, or
|
|
134
|
+
// pre-supplied). When the recipe defines `confident()` and the extracted
|
|
135
|
+
// slots fail it, refuse to run the steps with bad data — surface a
|
|
136
|
+
// friendly "please specify <missing required slots>" message so the user
|
|
137
|
+
// can re-ask with the info instead of getting a maker 4xx mid-chain.
|
|
138
|
+
if (recipe.confident && !recipe.confident(ctx.slots)) {
|
|
139
|
+
const missing = recipe.slots
|
|
140
|
+
.filter((s) => s.required && (ctx.slots[s.name] == null || ctx.slots[s.name] === ''))
|
|
141
|
+
.map((s) => `${s.name} (${s.description})`);
|
|
142
|
+
const ask =
|
|
143
|
+
missing.length > 0
|
|
144
|
+
? `I need a bit more info — please specify: ${missing.join('; ')}.`
|
|
145
|
+
: "I don't have enough info to do that — could you rephrase with the specifics?";
|
|
146
|
+
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: ask, status: 'needs-info', inferences };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Confirmation model:
|
|
150
|
+
// - Recipe with `confirm(ctx)`: fire ONE gate before the first spend step,
|
|
151
|
+
// showing the recipe-level summary; once approved, later spend steps run
|
|
152
|
+
// ungated (the whole chain is one approved decision).
|
|
153
|
+
// - Recipe without `confirm`: gate EACH spend tool individually (default;
|
|
154
|
+
// payments/receive/asset-send rely on this).
|
|
155
|
+
// Missing onConfirm FAILS CLOSED in both cases, matching the Engine.
|
|
156
|
+
const cancelled = (): RecipeResult => ({
|
|
157
|
+
recipe: recipe.name, slots: ctx.slots, results: ctx.results,
|
|
158
|
+
text: 'Cancelled — nothing was sent.', status: 'cancelled', inferences,
|
|
159
|
+
});
|
|
160
|
+
let recipeApproved = false;
|
|
161
|
+
|
|
162
|
+
/** Gate a single (spend) step. Returns false if the user declined. */
|
|
163
|
+
const passesGate = async (toolName: string, args: Record<string, unknown>): Promise<boolean> => {
|
|
164
|
+
const def = await opts.tools.getDef(toolName);
|
|
165
|
+
if (!def?.requiresConfirmation) return true;
|
|
166
|
+
// Recipe-level single confirm: ask once, then remember the approval.
|
|
167
|
+
if (recipe.confirm) {
|
|
168
|
+
if (recipeApproved) return true;
|
|
169
|
+
const summary = recipe.confirm(ctx) ?? undefined;
|
|
73
170
|
const decision = opts.onConfirm
|
|
74
|
-
? await opts.onConfirm({ name:
|
|
171
|
+
? await opts.onConfirm({ name: toolName, arguments: args, summary })
|
|
75
172
|
: { approved: false, reason: 'no confirmation handler available' };
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
}
|
|
173
|
+
if (decision.approved) recipeApproved = true;
|
|
174
|
+
return decision.approved;
|
|
79
175
|
}
|
|
176
|
+
// Per-tool confirm (legacy default).
|
|
177
|
+
const decision = opts.onConfirm
|
|
178
|
+
? await opts.onConfirm({ name: toolName, arguments: args })
|
|
179
|
+
: { approved: false, reason: 'no confirmation handler available' };
|
|
180
|
+
return decision.approved;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
for (const step of recipe.steps) {
|
|
184
|
+
if (step.skipIf?.(ctx)) continue;
|
|
185
|
+
const args = step.args(ctx);
|
|
186
|
+
if (!(await passesGate(step.tool, args))) return cancelled();
|
|
80
187
|
const result = await opts.tools.execute(step.tool, args);
|
|
81
188
|
ctx.results[step.as ?? step.tool] = result;
|
|
82
189
|
opts.onStep?.(step.tool, args, result);
|
|
83
190
|
}
|
|
84
191
|
|
|
85
|
-
// Final action
|
|
86
|
-
// Engine, a missing onConfirm FAILS CLOSED: the spend is declined, never
|
|
87
|
-
// silently executed.
|
|
192
|
+
// Final action.
|
|
88
193
|
const finalArgs = recipe.final.args(ctx);
|
|
89
|
-
|
|
90
|
-
if (def?.requiresConfirmation) {
|
|
91
|
-
const decision = opts.onConfirm
|
|
92
|
-
? await opts.onConfirm({ name: recipe.final.tool, arguments: finalArgs })
|
|
93
|
-
: { approved: false, reason: 'no confirmation handler available' };
|
|
94
|
-
if (!decision.approved) {
|
|
95
|
-
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: 'Cancelled — nothing was sent.', status: 'cancelled', inferences };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
194
|
+
if (!(await passesGate(recipe.final.tool, finalArgs))) return cancelled();
|
|
98
195
|
const finalResult = await opts.tools.execute(recipe.final.tool, finalArgs);
|
|
99
196
|
ctx.results[recipe.final.as ?? recipe.final.tool] = finalResult;
|
|
100
197
|
opts.onStep?.(recipe.final.tool, finalArgs, finalResult);
|
package/src/recipe/swap.ts
CHANGED
|
@@ -14,31 +14,127 @@ import type { Recipe } from './types.js';
|
|
|
14
14
|
|
|
15
15
|
const ASSET = /\b(btc|bitcoin|sats?|usdt|tether|xaut|gold)\b/i;
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** Strict: returns a canonical code only for a KNOWN crypto asset, else undefined
|
|
18
|
+
* (so "kaleido", "the", etc. are not mistaken for an asset). */
|
|
19
|
+
function knownAsset(a?: string): string | undefined {
|
|
18
20
|
if (!a) return undefined;
|
|
19
21
|
const x = a.toLowerCase();
|
|
20
|
-
if (
|
|
21
|
-
if (
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
22
|
+
if (/^(btc|bitcoin|sat|sats|satoshi|satoshis)$/.test(x)) return 'BTC';
|
|
23
|
+
if (/^(usdt|tether)$/.test(x)) return 'USDT';
|
|
24
|
+
if (/^(xaut|gold)$/.test(x)) return 'XAUT';
|
|
25
|
+
return undefined;
|
|
24
26
|
}
|
|
25
|
-
const num = (s?: string) => (s ? Number(s.replace(/,/g, '')) : undefined);
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
// Small word-numbers cover the common spoken/typed cases ("buy one usdt").
|
|
29
|
+
const WORD_NUM: Record<string, number> = {
|
|
30
|
+
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5,
|
|
31
|
+
six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
|
32
|
+
};
|
|
33
|
+
const AMT = '([\\d.,]+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)';
|
|
34
|
+
|
|
35
|
+
function parseAmount(s?: string): number | undefined {
|
|
36
|
+
if (!s) return undefined;
|
|
37
|
+
const t = s.trim().toLowerCase();
|
|
38
|
+
if (t in WORD_NUM) return WORD_NUM[t];
|
|
39
|
+
const n = Number(t.replace(/,/g, ''));
|
|
40
|
+
return Number.isFinite(n) ? n : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a swap/buy/sell request into { from_asset, to_asset, amount, amount_side }.
|
|
45
|
+
*
|
|
46
|
+
* `amount_side` says which leg the amount belongs to (the maker takes the amount
|
|
47
|
+
* on exactly one leg):
|
|
48
|
+
* - "buy N X" → receive N of X → amount on the TO leg, from defaults to BTC
|
|
49
|
+
* - "sell N X" → spend N of X → amount on the FROM leg, to defaults to BTC
|
|
50
|
+
* - "swap N X to Y" → spend N of X → amount on the FROM leg
|
|
51
|
+
*
|
|
52
|
+
* "buy one usdt" → from BTC, to USDT, amount 1 on `to`
|
|
53
|
+
* "buy 0.001 btc with usdt" → from USDT, to BTC, amount 0.001 on `to`
|
|
54
|
+
* "sell 100 usdt" → from USDT, to BTC, amount 100 on `from`
|
|
55
|
+
* "swap 10 usdt for btc" → from USDT, to BTC, amount 10 on `from`
|
|
56
|
+
*/
|
|
28
57
|
export function extractSwap(text: string): Record<string, unknown> | null {
|
|
29
58
|
const t = text.trim();
|
|
30
59
|
let m: RegExpMatchArray | null;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
|
|
61
|
+
// buy/get/purchase <amt> <asset> [with/using/from <funding-asset>]
|
|
62
|
+
// amount is of the asset being BOUGHT → it sits on the TO leg.
|
|
63
|
+
if ((m = t.match(new RegExp(`\\b(?:buy|get|purchase|acquire)\\s+${AMT}\\s*([a-z]+)(?:\\s+(?:with|using|from|for)\\s+([a-z]+))?`, 'i')))) {
|
|
64
|
+
const to = knownAsset(m[2]);
|
|
65
|
+
if (to) {
|
|
66
|
+
const from = knownAsset(m[3]) ?? (to === 'BTC' ? 'USDT' : 'BTC');
|
|
67
|
+
return { amount: parseAmount(m[1]), from_asset: from, to_asset: to, amount_side: 'to' };
|
|
68
|
+
}
|
|
34
69
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
70
|
+
|
|
71
|
+
// sell <amt> <asset> [for/to/into <target>]
|
|
72
|
+
// amount is of the asset being SOLD → it sits on the FROM leg.
|
|
73
|
+
if ((m = t.match(new RegExp(`\\bsell\\s+${AMT}\\s*([a-z]+)(?:\\s+(?:for|to|into)\\s+([a-z]+))?`, 'i')))) {
|
|
74
|
+
const from = knownAsset(m[2]);
|
|
75
|
+
if (from) {
|
|
76
|
+
const to = knownAsset(m[3]) ?? (from === 'BTC' ? 'USDT' : 'BTC');
|
|
77
|
+
return { amount: parseAmount(m[1]), from_asset: from, to_asset: to, amount_side: 'from' };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// swap/convert/exchange/trade <amt> <from> for/to/into <to>
|
|
82
|
+
if ((m = t.match(new RegExp(`\\b(?:swap|convert|exchange|trade)\\s+${AMT}\\s*([a-z]+)\\s+(?:for|to|into)\\s+([a-z]+)`, 'i')))) {
|
|
83
|
+
const from = knownAsset(m[2]);
|
|
84
|
+
const to = knownAsset(m[3]);
|
|
85
|
+
if (from && to) return { amount: parseAmount(m[1]), from_asset: from, to_asset: to, amount_side: 'from' };
|
|
38
86
|
}
|
|
87
|
+
|
|
88
|
+
// Price/rate questions are NOT swaps — they belong to extractPriceQuery +
|
|
89
|
+
// kaleidoswapPriceRecipe (read-only). Don't gobble them here.
|
|
39
90
|
return null;
|
|
40
91
|
}
|
|
41
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Parse a PRICE / rate / "how much" question — read-only intent.
|
|
95
|
+
*
|
|
96
|
+
* Distinct from extractSwap: never returns slots for swap/buy/sell phrasings.
|
|
97
|
+
* Always `amount: 1` on the asked-about asset (TO leg). Used by
|
|
98
|
+
* `kaleidoswapPriceRecipe` to fire a quote without moving funds.
|
|
99
|
+
*
|
|
100
|
+
* "what is the price of usdt in sats" → {from: BTC, to: USDT, amount: 1, side: 'to'}
|
|
101
|
+
* "btc price" → {from: USDT, to: BTC, amount: 1, side: 'to'}
|
|
102
|
+
* "how much sats for 1 usdt" → {from: BTC, to: USDT, amount: 1, side: 'to'}
|
|
103
|
+
*/
|
|
104
|
+
export function extractPriceQuery(text: string): Record<string, unknown> | null {
|
|
105
|
+
const t = text.trim();
|
|
106
|
+
// Reject swap intent — those go to the atomic recipe, not the price recipe.
|
|
107
|
+
if (/\b(swap|exchange|convert|trade|buy|sell|get|purchase|acquire)\b/i.test(t)) return null;
|
|
108
|
+
|
|
109
|
+
// ORDER MATTERS: "how much B for A" (first) must be checked BEFORE
|
|
110
|
+
// "how much X (in Y)?" — otherwise the latter would gobble the first asset
|
|
111
|
+
// and miss the "for/per" tail. Optional "the" article is tolerated
|
|
112
|
+
// ("price of THE usdt") — natural English the maker doesn't care about.
|
|
113
|
+
const priceLike =
|
|
114
|
+
t.match(/\bhow\s+(?:many|much)\s+(?:the\s+)?([a-z]+)\s+(?:for|per|in)\s+(?:1\s+|one\s+|the\s+)?([a-z]+)\b/i) ||
|
|
115
|
+
t.match(/\b(?:price|cost|worth)\s+of\s+(?:the\s+)?([a-z]+)(?:\s+in\s+(?:the\s+)?([a-z]+))?/i) ||
|
|
116
|
+
t.match(/\b(?:the\s+)?([a-z]+)\s+(?:price|cost)\b/i) ||
|
|
117
|
+
t.match(/\brate\s+of\s+(?:the\s+)?([a-z]+)(?:\s+(?:in|to|vs)\s+(?:the\s+)?([a-z]+))?/i) ||
|
|
118
|
+
t.match(/\b(?:the\s+)?([a-z]+)\s+(?:to|vs|in|\/)\s+([a-z]+)\s+rate\b/i) ||
|
|
119
|
+
t.match(/\bhow\s+much\s+(?:does\s+)?(?:1\s+|one\s+|the\s+)?([a-z]+)\s+cost\b/i) ||
|
|
120
|
+
t.match(/\bhow\s+much\s+(?:is\s+)?(?:1\s+|one\s+|the\s+)?([a-z]+)(?:\s+in\s+(?:the\s+)?([a-z]+))?\b/i);
|
|
121
|
+
if (!priceLike) return null;
|
|
122
|
+
|
|
123
|
+
const a = knownAsset(priceLike[1]);
|
|
124
|
+
const b = knownAsset(priceLike[2]);
|
|
125
|
+
let asset: string | undefined;
|
|
126
|
+
let denom: string | undefined;
|
|
127
|
+
if (/how\s+(?:many|much)\s+\w+\s+(?:for|per|in)/i.test(t) && b) {
|
|
128
|
+
// "how much B for A" — asset is A (the named priced one), denom is B (unit).
|
|
129
|
+
asset = b; denom = a;
|
|
130
|
+
} else {
|
|
131
|
+
asset = a; denom = b;
|
|
132
|
+
}
|
|
133
|
+
if (!asset) return null;
|
|
134
|
+
const from = denom ?? (asset === 'BTC' ? 'USDT' : 'BTC');
|
|
135
|
+
return { amount: 1, from_asset: from, to_asset: asset, amount_side: 'to' };
|
|
136
|
+
}
|
|
137
|
+
|
|
42
138
|
export const swapRecipe: Recipe = {
|
|
43
139
|
name: 'swap',
|
|
44
140
|
description: 'Swap between BTC and an RGB asset — quote, then execute (with confirmation).',
|
package/src/recipe/types.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface Recipe {
|
|
|
47
47
|
slots: RecipeSlot[];
|
|
48
48
|
/** Optional deterministic extractor tried BEFORE the LLM (Tier-0 fast-path). */
|
|
49
49
|
extract?: (text: string) => Record<string, unknown> | null;
|
|
50
|
+
/**
|
|
51
|
+
* When true (and `extract` is provided), the runner will *ignore* a successful
|
|
52
|
+
* deterministic extraction and always perform the 1-inference LLM slot
|
|
53
|
+
* extraction. This lets the model do the natural-language understanding of
|
|
54
|
+
* the user's request (e.g. "buy 1 usdt") while the Recipe still owns the
|
|
55
|
+
* reliable multi-step execution plan and single-confirmation safety.
|
|
56
|
+
*/
|
|
57
|
+
forceModelExtract?: boolean;
|
|
50
58
|
/**
|
|
51
59
|
* Whether the recipe is confident enough to RUN deterministically given the
|
|
52
60
|
* extracted slots (vs falling back to the agentic loop). e.g. payments needs a
|
|
@@ -59,9 +67,25 @@ export interface Recipe {
|
|
|
59
67
|
final: RecipeStep;
|
|
60
68
|
/** Render the outcome for the user. */
|
|
61
69
|
summary?: (ctx: RecipeContext, finalResult: unknown) => string;
|
|
70
|
+
/**
|
|
71
|
+
* Single recipe-level confirmation. When set, the runner fires exactly ONE
|
|
72
|
+
* confirmation gate immediately before the first spend step, passing the
|
|
73
|
+
* returned string as the confirm summary; once approved, the remaining spend
|
|
74
|
+
* steps run WITHOUT re-prompting (the whole chain is one approved decision).
|
|
75
|
+
*
|
|
76
|
+
* Use for multi-spend chains where the user makes a single choice up front
|
|
77
|
+
* from data gathered by earlier (read-only) steps — e.g. an atomic swap:
|
|
78
|
+
* quote first, then confirm "swap X → Y, fee Z" once, then init/whitelist/
|
|
79
|
+
* execute run as a unit.
|
|
80
|
+
*
|
|
81
|
+
* Return `null` to skip confirmation entirely (rare). When `confirm` is
|
|
82
|
+
* absent, the runner falls back to gating EACH spend tool individually
|
|
83
|
+
* (the default — used by payments/receive/asset-send).
|
|
84
|
+
*/
|
|
85
|
+
confirm?: (ctx: RecipeContext) => string | null;
|
|
62
86
|
}
|
|
63
87
|
|
|
64
|
-
export type RecipeStatus = 'done' | 'cancelled' | 'error';
|
|
88
|
+
export type RecipeStatus = 'done' | 'cancelled' | 'error' | 'needs-info';
|
|
65
89
|
|
|
66
90
|
export interface RecipeResult {
|
|
67
91
|
recipe: string;
|
package/src/skills/registry.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Skill, SkillReference, SkillSelector } from './types.js';
|
|
10
|
+
import type { EmbeddingProvider } from '../rag/types.js';
|
|
11
|
+
import { cosineSimilarity } from '../rag/vector-store.js';
|
|
10
12
|
|
|
11
13
|
/** Tool name the reference source exposes for progressive disclosure. */
|
|
12
14
|
export const READ_REFERENCE_TOOL = 'read_skill_reference';
|
|
@@ -92,7 +94,10 @@ function triggerMatches(query: string, trigger: string): boolean {
|
|
|
92
94
|
return new RegExp(`\\b${reEscape(t)}\\b`).test(query);
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
/** Default selector: score by meaningful keyword overlap; triggers weigh most.
|
|
97
|
+
/** Default selector: score by meaningful keyword overlap; triggers weigh most.
|
|
98
|
+
* Light extra sensitivity for common location / discovery phrasing so merchant-finder
|
|
99
|
+
* and similar skills surface on natural queries even without exact trigger words.
|
|
100
|
+
*/
|
|
96
101
|
export const keywordSelector: SkillSelector = {
|
|
97
102
|
select(query, skills) {
|
|
98
103
|
const q = query.toLowerCase();
|
|
@@ -110,6 +115,15 @@ export const keywordSelector: SkillSelector = {
|
|
|
110
115
|
// word boundary, so short triggers (`usd`, `eur`, `cafe`) don't leak
|
|
111
116
|
// into longer words (`usdt`, `europe`, `cafeteria`).
|
|
112
117
|
for (const t of skill.triggers ?? []) if (triggerMatches(q, t)) score += 3;
|
|
118
|
+
|
|
119
|
+
// Light discovery / location phrase boost (helps merchant-finder and
|
|
120
|
+
// similar skills on natural language like "coffee near the station" or
|
|
121
|
+
// "buy pizza with sats in turin").
|
|
122
|
+
if (/\b(near|nearby|around|close|spend|find|shop|cafe|coffee|food|eat|lunch|dinner|atm|buy|pizz|restaurant)\b/i.test(q)) {
|
|
123
|
+
const skillText = (skill.description + ' ' + (skill.triggers || []).join(' ')).toLowerCase();
|
|
124
|
+
if (/(merchant|btcmap|map|location|nearby|spend.*bitcoin|find.*place|food|restaurant|cafe|eat|pizza)/.test(skillText)) score += 1.5;
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
if (score > bestScore) {
|
|
114
128
|
bestScore = score;
|
|
115
129
|
best = skill;
|
|
@@ -120,6 +134,43 @@ export const keywordSelector: SkillSelector = {
|
|
|
120
134
|
},
|
|
121
135
|
};
|
|
122
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Optional embedding-powered selector factory.
|
|
139
|
+
*
|
|
140
|
+
* When an EmbeddingProvider (the same shape used by Retriever/RAG) is supplied,
|
|
141
|
+
* hosts can use the returned selector for more semantic skill routing. This
|
|
142
|
+
* helps vague/natural location and discovery queries ("coffee near the station",
|
|
143
|
+
* "somewhere to grab a bite that takes lightning") reach merchant-finder or
|
|
144
|
+
* similar skills even without exact keyword overlap.
|
|
145
|
+
*
|
|
146
|
+
* The current implementation keeps a synchronous SkillSelector contract (to match
|
|
147
|
+
* the existing interface used by Funnel and SkillRegistry). It therefore:
|
|
148
|
+
* - Uses an enhanced keywordSelector as the fast path (see above).
|
|
149
|
+
* - If embeddings are provided it is ready for hosts to wrap or evolve into a
|
|
150
|
+
* fully semantic version (prototype embeddings of skill descriptions +
|
|
151
|
+
* cosine vs. query, mixed with keyword score).
|
|
152
|
+
*
|
|
153
|
+
* Example host usage (CLI / provider with embeddings already loaded):
|
|
154
|
+
* import { createEmbeddingSkillSelector, SkillRegistry } from '@kaleidorg/mind';
|
|
155
|
+
* const selector = createEmbeddingSkillSelector(embeddingsProvider);
|
|
156
|
+
* const reg = new SkillRegistry(loadedSkills, selector);
|
|
157
|
+
*
|
|
158
|
+
* For a production semantic version a host can implement an async select
|
|
159
|
+
* wrapper around its own Funnel turn or pre-compute skill prototypes.
|
|
160
|
+
*/
|
|
161
|
+
export function createEmbeddingSkillSelector(
|
|
162
|
+
embeddings?: EmbeddingProvider,
|
|
163
|
+
_opts: { minCosine?: number; keywordFallback?: boolean } = {},
|
|
164
|
+
): SkillSelector {
|
|
165
|
+
// Today we return the (already lightly enhanced) keyword selector.
|
|
166
|
+
// The embeddings parameter and factory exist so hosts have a single
|
|
167
|
+
// obvious extension point and the public API signals the intent.
|
|
168
|
+
// A future revision can make SkillSelector support async or add a
|
|
169
|
+
// separate async entry point if the Funnel routing is made async.
|
|
170
|
+
void embeddings; // intentionally unused in the current sync impl
|
|
171
|
+
return keywordSelector;
|
|
172
|
+
}
|
|
173
|
+
|
|
123
174
|
export class SkillRegistry {
|
|
124
175
|
private readonly skills: Skill[] = [];
|
|
125
176
|
private readonly selector: SkillSelector;
|