@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.
Files changed (89) hide show
  1. package/dist/funnel.d.ts +19 -0
  2. package/dist/funnel.d.ts.map +1 -1
  3. package/dist/funnel.js +48 -10
  4. package/dist/funnel.js.map +1 -1
  5. package/dist/index.d.ts +5 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/kaleidoswap/contract.d.ts +3 -3
  10. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  11. package/dist/kaleidoswap/contract.js +16 -4
  12. package/dist/kaleidoswap/contract.js.map +1 -1
  13. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
  14. package/dist/knowledge/bitcoin-copilot.js +102 -0
  15. package/dist/knowledge/bitcoin-copilot.js.map +1 -1
  16. package/dist/knowledge/btc-map.d.ts +14 -17
  17. package/dist/knowledge/btc-map.d.ts.map +1 -1
  18. package/dist/knowledge/btc-map.js +66 -266
  19. package/dist/knowledge/btc-map.js.map +1 -1
  20. package/dist/lsps1/contract.d.ts.map +1 -1
  21. package/dist/lsps1/contract.js +28 -10
  22. package/dist/lsps1/contract.js.map +1 -1
  23. package/dist/qvac/parse.d.ts +15 -0
  24. package/dist/qvac/parse.d.ts.map +1 -1
  25. package/dist/qvac/parse.js +68 -5
  26. package/dist/qvac/parse.js.map +1 -1
  27. package/dist/qvac/text.d.ts.map +1 -1
  28. package/dist/qvac/text.js +4 -0
  29. package/dist/qvac/text.js.map +1 -1
  30. package/dist/recipe/buy-asset-channel.d.ts +26 -0
  31. package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
  32. package/dist/recipe/buy-asset-channel.js +112 -0
  33. package/dist/recipe/buy-asset-channel.js.map +1 -0
  34. package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
  35. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
  36. package/dist/recipe/kaleidoswap-atomic.js +101 -63
  37. package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
  38. package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
  39. package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
  40. package/dist/recipe/kaleidoswap-channel-order.js +493 -0
  41. package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
  42. package/dist/recipe/kaleidoswap-price.d.ts +21 -0
  43. package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
  44. package/dist/recipe/kaleidoswap-price.js +57 -0
  45. package/dist/recipe/kaleidoswap-price.js.map +1 -0
  46. package/dist/recipe/runner.d.ts +7 -1
  47. package/dist/recipe/runner.d.ts.map +1 -1
  48. package/dist/recipe/runner.js +115 -29
  49. package/dist/recipe/runner.js.map +1 -1
  50. package/dist/recipe/swap.d.ts +26 -1
  51. package/dist/recipe/swap.d.ts.map +1 -1
  52. package/dist/recipe/swap.js +108 -13
  53. package/dist/recipe/swap.js.map +1 -1
  54. package/dist/recipe/types.d.ts +25 -1
  55. package/dist/recipe/types.d.ts.map +1 -1
  56. package/dist/skills/registry.d.ts +33 -1
  57. package/dist/skills/registry.d.ts.map +1 -1
  58. package/dist/skills/registry.js +45 -1
  59. package/dist/skills/registry.js.map +1 -1
  60. package/package.json +1 -1
  61. package/skills/README.md +3 -0
  62. package/skills/kaleido-lsps/SKILL.md +101 -43
  63. package/skills/kaleido-trading/SKILL.md +81 -31
  64. package/skills/merchant-finder/SKILL.md +96 -66
  65. package/skills/rgb-lightning-node/SKILL.md +108 -0
  66. package/skills/wallet-assistant/SKILL.md +32 -21
  67. package/src/funnel.ts +66 -11
  68. package/src/index.ts +14 -2
  69. package/src/kaleidoswap/contract.test.ts +7 -2
  70. package/src/kaleidoswap/contract.ts +27 -5
  71. package/src/knowledge/bitcoin-copilot.ts +111 -0
  72. package/src/knowledge/btc-map.test.ts +53 -96
  73. package/src/knowledge/btc-map.ts +72 -287
  74. package/src/lsps1/contract.ts +32 -14
  75. package/src/qvac/parse.test.ts +70 -1
  76. package/src/qvac/parse.ts +71 -5
  77. package/src/qvac/text.ts +4 -0
  78. package/src/recipe/buy-asset-channel.test.ts +148 -0
  79. package/src/recipe/buy-asset-channel.ts +118 -0
  80. package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
  81. package/src/recipe/kaleidoswap-atomic.ts +112 -66
  82. package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
  83. package/src/recipe/kaleidoswap-channel-order.ts +548 -0
  84. package/src/recipe/kaleidoswap-price.ts +68 -0
  85. package/src/recipe/recipe.test.ts +61 -5
  86. package/src/recipe/runner.ts +128 -31
  87. package/src/recipe/swap.ts +109 -13
  88. package/src/recipe/types.ts +25 -1
  89. 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', () => {
@@ -15,8 +15,13 @@ const EXTRACT_TOOL = 'extract_request';
15
15
  export interface RunRecipeOptions {
16
16
  provider: LLMProvider;
17
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>;
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
- if (det && Object.values(det).some((v) => v !== undefined && v !== null && v !== '')) {
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: `Call ${EXTRACT_TOOL} with the fields from the user's message. Do not call any other tool and do not add commentary.`,
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
- return { slots: (call?.arguments as Record<string, unknown>) ?? {}, inferences: 1 };
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
- // Deterministic steps. Intermediate spend tools fire the same confirmation
65
- // gate as the final step recipes with multi-spend chains (e.g. atomic
66
- // swaps) MUST have every money-moving call gated, never just the last one.
67
- // Missing onConfirm fails closed, matching the Engine.
68
- for (const step of recipe.steps) {
69
- if (step.skipIf?.(ctx)) continue;
70
- const args = step.args(ctx);
71
- const def = await opts.tools.getDef(step.tool);
72
- if (def?.requiresConfirmation) {
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: step.tool, arguments: args })
171
+ ? await opts.onConfirm({ name: toolName, arguments: args, summary })
75
172
  : { approved: false, reason: 'no confirmation handler available' };
76
- if (!decision.approved) {
77
- return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: 'Cancelled — nothing was sent.', status: 'cancelled', inferences };
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 — confirmation-gated if the tool requires it. Like the
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
- const def = await opts.tools.getDef(recipe.final.tool);
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);
@@ -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
- function normAsset(a?: string): string | undefined {
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 (/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();
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
- /** "buy 0.001 btc with usdt" / "swap 10 usdt for btc" / "sell 100 usdt for sats". */
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
- // 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]) };
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
- // 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]) };
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).',
@@ -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;
@@ -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;