@kaleidorg/mind 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/capabilities.d.ts +4 -0
  2. package/dist/capabilities.d.ts.map +1 -1
  3. package/dist/capabilities.js +7 -0
  4. package/dist/capabilities.js.map +1 -1
  5. package/dist/engine.d.ts +9 -0
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +1 -0
  8. package/dist/engine.js.map +1 -1
  9. package/dist/funnel.d.ts +6 -0
  10. package/dist/funnel.d.ts.map +1 -1
  11. package/dist/funnel.js +26 -6
  12. package/dist/funnel.js.map +1 -1
  13. package/dist/index.d.ts +9 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +8 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/kaleidoswap/contract.d.ts +72 -0
  18. package/dist/kaleidoswap/contract.d.ts.map +1 -0
  19. package/dist/kaleidoswap/contract.js +125 -0
  20. package/dist/kaleidoswap/contract.js.map +1 -0
  21. package/dist/knowledge/btc-map.d.ts +87 -0
  22. package/dist/knowledge/btc-map.d.ts.map +1 -0
  23. package/dist/knowledge/btc-map.js +365 -0
  24. package/dist/knowledge/btc-map.js.map +1 -0
  25. package/dist/lsps1/contract.d.ts +55 -0
  26. package/dist/lsps1/contract.d.ts.map +1 -0
  27. package/dist/lsps1/contract.js +91 -0
  28. package/dist/lsps1/contract.js.map +1 -0
  29. package/dist/memory/store.d.ts +7 -1
  30. package/dist/memory/store.d.ts.map +1 -1
  31. package/dist/memory/store.js +43 -3
  32. package/dist/memory/store.js.map +1 -1
  33. package/dist/memory/types.d.ts +12 -0
  34. package/dist/memory/types.d.ts.map +1 -1
  35. package/dist/recipe/kaleidoswap-atomic.d.ts +27 -0
  36. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -0
  37. package/dist/recipe/kaleidoswap-atomic.js +111 -0
  38. package/dist/recipe/kaleidoswap-atomic.js.map +1 -0
  39. package/dist/recipe/runner.d.ts.map +1 -1
  40. package/dist/recipe/runner.js +13 -1
  41. package/dist/recipe/runner.js.map +1 -1
  42. package/dist/skills/registry.d.ts.map +1 -1
  43. package/dist/skills/registry.js +20 -2
  44. package/dist/skills/registry.js.map +1 -1
  45. package/dist/wallet/confirm.d.ts +12 -0
  46. package/dist/wallet/confirm.d.ts.map +1 -0
  47. package/dist/wallet/confirm.js +67 -0
  48. package/dist/wallet/confirm.js.map +1 -0
  49. package/package.json +2 -1
  50. package/skills/README.md +6 -1
  51. package/skills/kaleido-lsps/SKILL.md +56 -0
  52. package/skills/kaleido-trading/SKILL.md +85 -18
  53. package/skills/merchant-finder/SKILL.md +87 -0
  54. package/skills/paid-data/SKILL.md +12 -0
  55. package/skills/wallet-assistant/SKILL.md +38 -0
  56. package/src/capabilities.ts +12 -0
  57. package/src/context/context.test.ts +6 -2
  58. package/src/engine.ts +6 -0
  59. package/src/funnel.ts +32 -7
  60. package/src/index.ts +43 -0
  61. package/src/kaleidoswap/contract.test.ts +147 -0
  62. package/src/kaleidoswap/contract.ts +212 -0
  63. package/src/knowledge/btc-map.test.ts +188 -0
  64. package/src/knowledge/btc-map.ts +446 -0
  65. package/src/lsps1/contract.test.ts +81 -0
  66. package/src/lsps1/contract.ts +132 -0
  67. package/src/memory/memory.test.ts +55 -0
  68. package/src/memory/store.ts +49 -4
  69. package/src/memory/types.ts +13 -0
  70. package/src/recipe/kaleidoswap-atomic.test.ts +138 -0
  71. package/src/recipe/kaleidoswap-atomic.ts +117 -0
  72. package/src/recipe/runner.ts +13 -1
  73. package/src/skills/registry.ts +21 -2
  74. package/src/skills/skills.test.ts +42 -0
  75. package/src/wallet/confirm.test.ts +57 -0
  76. package/src/wallet/confirm.ts +74 -0
  77. package/skills/kaleido-wallet/SKILL.md +0 -28
@@ -61,6 +61,61 @@ describe('InMemoryMemoryStore', () => {
61
61
  const hits = await store.search({ text: 'how many sats do I have', limit: 1 });
62
62
  expect(hits[0].text).toMatch(/wallet balance/);
63
63
  });
64
+
65
+ // Embedding-only dedup: same vector → near-dup → newer supersedes older. No LLM.
66
+ it('consolidate (dedup): near-duplicates supersede instead of appending', async () => {
67
+ const embed = async (text: string): Promise<number[]> =>
68
+ /eur/i.test(text) ? [1, 0] : [0, 1];
69
+ let t = 0;
70
+ const store = new InMemoryMemoryStore({
71
+ embed,
72
+ consolidate: { threshold: 0.9 },
73
+ now: () => ++t,
74
+ });
75
+ await store.add({ text: 'user prefers EUR', kind: 'preference' });
76
+ await store.add({ text: 'user prefers EUR for fiat display', kind: 'preference' });
77
+
78
+ const all = await store.all();
79
+ expect(all).toHaveLength(1); // folded, not appended
80
+ expect(all[0].text).toBe('user prefers EUR for fiat display'); // newer wins
81
+ });
82
+
83
+ it('consolidate (dedup): distinct facts are kept separate', async () => {
84
+ const embed = async (text: string): Promise<number[]> =>
85
+ /eur/i.test(text) ? [1, 0] : [0, 1];
86
+ const store = new InMemoryMemoryStore({ embed, consolidate: { threshold: 0.9 }, now: () => 1 });
87
+ await store.add({ text: 'user prefers EUR', kind: 'preference' });
88
+ await store.add({ text: 'it is sunny today', kind: 'note' });
89
+ expect(await store.all()).toHaveLength(2);
90
+ });
91
+
92
+ it('consolidate (dedup): different kinds are never merged', async () => {
93
+ const embed = async (): Promise<number[]> => [1, 0]; // identical vectors
94
+ const store = new InMemoryMemoryStore({ embed, consolidate: { threshold: 0.9 }, now: () => 1 });
95
+ await store.add({ text: 'EUR', kind: 'preference' });
96
+ await store.add({ text: 'EUR', kind: 'fact' });
97
+ expect(await store.all()).toHaveLength(2);
98
+ });
99
+
100
+ // LLM merge: injected merger rewrites old + new into one consolidated item, with unioned tags.
101
+ it('consolidate (merge): injected merger folds near-dups into one item', async () => {
102
+ const embed = async (): Promise<number[]> => [1, 0];
103
+ const merge = vi.fn(async (existing: string, incoming: string) => `${existing}; ${incoming}`);
104
+ let t = 0;
105
+ const store = new InMemoryMemoryStore({
106
+ embed,
107
+ consolidate: { threshold: 0.9, merge },
108
+ now: () => ++t,
109
+ });
110
+ await store.add({ text: 'likes EUR', kind: 'preference', tags: ['fiat'] });
111
+ await store.add({ text: 'and CHF', kind: 'preference', tags: ['currency'] });
112
+
113
+ expect(merge).toHaveBeenCalledWith('likes EUR', 'and CHF');
114
+ const all = await store.all();
115
+ expect(all).toHaveLength(1);
116
+ expect(all[0].text).toBe('likes EUR; and CHF');
117
+ expect(all[0].tags).toEqual(['fiat', 'currency']); // unioned
118
+ });
64
119
  });
65
120
 
66
121
  describe('memory tool source', () => {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { cosineSimilarity } from '../rag/vector-store.js';
11
11
  import type {
12
+ MemoryConsolidation,
12
13
  MemoryIO,
13
14
  MemoryItem,
14
15
  MemoryQuery,
@@ -16,11 +17,18 @@ import type {
16
17
  NewMemory,
17
18
  } from './types.js';
18
19
 
20
+ const DEFAULT_DEDUP_THRESHOLD = 0.92;
21
+
19
22
  export interface MemoryStoreOptions {
20
23
  /** Persistence (load on first use, save on writes). Omit for ephemeral memory. */
21
24
  io?: MemoryIO;
22
25
  /** Embed text for semantic recall. Omit to fall back to substring matching. */
23
26
  embed?: (text: string) => Promise<number[]>;
27
+ /**
28
+ * Fold near-duplicate writes into one item instead of appending. Needs `embed`.
29
+ * Omit for append-only. See {@link MemoryConsolidation}.
30
+ */
31
+ consolidate?: MemoryConsolidation;
24
32
  /** Clock — injectable for deterministic tests. */
25
33
  now?: () => number;
26
34
  }
@@ -31,11 +39,13 @@ export class InMemoryMemoryStore implements MemoryStore {
31
39
  private counter = 0;
32
40
  private readonly io?: MemoryIO;
33
41
  private readonly embed?: (text: string) => Promise<number[]>;
42
+ private readonly consolidate?: MemoryConsolidation;
34
43
  private readonly now: () => number;
35
44
 
36
45
  constructor(opts: MemoryStoreOptions = {}) {
37
46
  this.io = opts.io;
38
47
  this.embed = opts.embed;
48
+ this.consolidate = opts.consolidate;
39
49
  this.now = opts.now ?? (() => Date.now());
40
50
  }
41
51
 
@@ -58,16 +68,45 @@ export class InMemoryMemoryStore implements MemoryStore {
58
68
 
59
69
  async add(item: NewMemory): Promise<MemoryItem> {
60
70
  await this.hydrate();
61
- const embedding =
62
- item.embedding ?? (this.embed ? await this.embed(item.text).catch(() => undefined) : undefined);
71
+ let text = item.text;
72
+ let embedding =
73
+ item.embedding ?? (this.embed ? await this.embed(text).catch(() => undefined) : undefined);
74
+ let tags = item.tags;
75
+ let supersedeId: string | undefined;
76
+
77
+ // Consolidation: fold a same-kind near-duplicate into this write instead of
78
+ // appending — embedding-only by default, LLM rewrite when `merge` is set.
79
+ if (this.consolidate && embedding) {
80
+ const threshold = this.consolidate.threshold ?? DEFAULT_DEDUP_THRESHOLD;
81
+ let best: { item: MemoryItem; score: number } | undefined;
82
+ for (const m of this.items) {
83
+ if (m.kind !== item.kind || !m.embedding) continue;
84
+ const score = cosineSimilarity(embedding, m.embedding);
85
+ if (!best || score > best.score) best = { item: m, score };
86
+ }
87
+ if (best && best.score >= threshold) {
88
+ supersedeId = best.item.id;
89
+ tags = unionTags(best.item.tags, item.tags);
90
+ if (this.consolidate.merge) {
91
+ const merged = await this.consolidate.merge(best.item.text, text).catch(() => null);
92
+ if (merged && merged.trim()) {
93
+ text = merged.trim();
94
+ if (this.embed) embedding = await this.embed(text).catch(() => embedding);
95
+ }
96
+ }
97
+ // No merger → the incoming (newer) text supersedes the older item as-is.
98
+ }
99
+ }
100
+
63
101
  const full: MemoryItem = {
64
102
  id: item.id ?? `mem_${this.now()}_${++this.counter}`,
65
- text: item.text,
103
+ text,
66
104
  kind: item.kind,
67
- tags: item.tags,
105
+ tags,
68
106
  createdAt: item.createdAt ?? this.now(),
69
107
  ...(embedding ? { embedding } : {}),
70
108
  };
109
+ if (supersedeId) this.items = this.items.filter((m) => m.id !== supersedeId);
71
110
  this.items.push(full);
72
111
  await this.persist();
73
112
  return full;
@@ -127,3 +166,9 @@ export class InMemoryMemoryStore implements MemoryStore {
127
166
  await this.persist();
128
167
  }
129
168
  }
169
+
170
+ /** Merge two optional tag lists, de-duplicated. Returns undefined when both empty. */
171
+ function unionTags(a?: string[], b?: string[]): string[] | undefined {
172
+ if (!a?.length && !b?.length) return undefined;
173
+ return [...new Set([...(a ?? []), ...(b ?? [])])];
174
+ }
@@ -61,3 +61,16 @@ export interface MemoryIO {
61
61
  load(): Promise<MemoryItem[]>;
62
62
  save(items: MemoryItem[]): Promise<void>;
63
63
  }
64
+
65
+ /**
66
+ * Consolidation — fold same-kind near-duplicate memories into one item instead
67
+ * of bloating with "user likes EUR" ×5. Needs embeddings (the dup check is
68
+ * cosine). Omit for append-only. Two tiers: embedding-only dedup (zero
69
+ * inference, mobile-safe) and, when `merge` is set, an LLM rewrite.
70
+ */
71
+ export interface MemoryConsolidation {
72
+ /** Cosine threshold above which two same-kind memories are "the same". Default 0.92. */
73
+ threshold?: number;
74
+ /** Optional LLM merger; without it the newer item simply supersedes the older. */
75
+ merge?: (existing: string, incoming: string) => Promise<string>;
76
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } 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 } from './runner.js';
6
+ import { kaleidoswapAtomicRecipe } from './kaleidoswap-atomic.js';
7
+
8
+ // LLM provider that should never be called when slots are extracted deterministically.
9
+ const refusingProvider: LLMProvider = {
10
+ name: 'refusing',
11
+ runTurn: async () => {
12
+ throw new Error('provider should NOT be called when extractSwap succeeds');
13
+ },
14
+ };
15
+
16
+ // Stub tools that record every call so we can assert the chain ran end-to-end.
17
+ function buildStubs(captured: { name: string; args: any }[]) {
18
+ const tool = (name: string, response: any, spend = false) => ({
19
+ name,
20
+ description: '',
21
+ parameters: { type: 'object', properties: {} },
22
+ requiresConfirmation: spend,
23
+ handler: async (a: any) => {
24
+ captured.push({ name, args: a });
25
+ return typeof response === 'function' ? response(a) : response;
26
+ },
27
+ });
28
+ return new ToolRegistry([
29
+ new InProcessToolSource('kaleidoswap', [
30
+ tool('kaleidoswap_get_quote', { quote_id: 'q-1', receive_amount: 100, fees: 250 }),
31
+ tool('kaleidoswap_atomic_init', { atomic_id: 'a-1', maker_invoice: 'lnbc1maker' }, /* spend */ true),
32
+ tool('kaleidoswap_atomic_execute', { status: 'completed' }, /* spend */ true),
33
+ ]),
34
+ new InProcessToolSource('rln', [
35
+ tool('rln_create_rgb_invoice', { invoice: 'rgb:invoice:USDT:100' }),
36
+ tool('rln_create_ln_invoice', { invoice: 'lnbc1user' }),
37
+ tool('rln_pay_invoice', { status: 'SUCCESS', payment_hash: 'h' }, /* spend */ true),
38
+ ]),
39
+ ]);
40
+ }
41
+
42
+ describe('kaleidoswapAtomicRecipe — selection (match + triggers)', () => {
43
+ it('triggers on explicit atomic-swap phrasings', () => {
44
+ expect(kaleidoswapAtomicRecipe.match!('atomic swap 100000 sats for usdt')).toBe(true);
45
+ expect(kaleidoswapAtomicRecipe.match!('trustless swap btc to usdt')).toBe(true);
46
+ expect(kaleidoswapAtomicRecipe.match!('htlc swap 1000 sats to USDT')).toBe(true);
47
+ });
48
+
49
+ it('does NOT fire on a plain swap (those go to swapRecipe)', () => {
50
+ expect(kaleidoswapAtomicRecipe.match!('swap 10 usdt for btc')).toBe(false);
51
+ expect(kaleidoswapAtomicRecipe.match!('exchange 1000 sats for usdt')).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('kaleidoswapAtomicRecipe — RGB receive leg', () => {
56
+ it('runs quote → rgb_invoice → atomic_init → pay → atomic_execute (one inference)', async () => {
57
+ const captured: { name: string; args: any }[] = [];
58
+ const tools = buildStubs(captured);
59
+
60
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100000 sats for usdt', {
61
+ provider: refusingProvider,
62
+ tools,
63
+ onConfirm: async () => ({ approved: true }),
64
+ });
65
+
66
+ expect(res.status).toBe('done');
67
+ expect(res.inferences).toBe(0); // extractSwap handled it deterministically
68
+
69
+ // The chain: quote → rgb_invoice → atomic_init → pay → atomic_execute (5 calls).
70
+ expect(captured.map((c) => c.name)).toEqual([
71
+ 'kaleidoswap_get_quote',
72
+ 'rln_create_rgb_invoice',
73
+ 'kaleidoswap_atomic_init',
74
+ 'rln_pay_invoice',
75
+ 'kaleidoswap_atomic_execute',
76
+ ]);
77
+
78
+ // RGB invoice fed into atomic_init.
79
+ const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
80
+ expect(init.args).toEqual({ quote_id: 'q-1', receive_invoice: 'rgb:invoice:USDT:100' });
81
+
82
+ // Maker invoice fed into pay step.
83
+ const pay = captured.find((c) => c.name === 'rln_pay_invoice')!;
84
+ expect(pay.args).toEqual({ invoice: 'lnbc1maker' });
85
+
86
+ // Final execute carried the atomic id.
87
+ const exe = captured.find((c) => c.name === 'kaleidoswap_atomic_execute')!;
88
+ expect(exe.args).toEqual({ atomic_id: 'a-1' });
89
+ });
90
+ });
91
+
92
+ describe('kaleidoswapAtomicRecipe — BTC receive leg', () => {
93
+ it('uses rln_create_ln_invoice (not rgb) when to_asset is BTC', async () => {
94
+ const captured: { name: string; args: any }[] = [];
95
+ const tools = buildStubs(captured);
96
+
97
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100 usdt for btc', {
98
+ provider: refusingProvider,
99
+ tools,
100
+ onConfirm: async () => ({ approved: true }),
101
+ });
102
+
103
+ expect(res.status).toBe('done');
104
+ expect(captured.map((c) => c.name)).toEqual([
105
+ 'kaleidoswap_get_quote',
106
+ 'rln_create_ln_invoice',
107
+ 'kaleidoswap_atomic_init',
108
+ 'rln_pay_invoice',
109
+ 'kaleidoswap_atomic_execute',
110
+ ]);
111
+ const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
112
+ expect(init.args.receive_invoice).toBe('lnbc1user');
113
+ });
114
+ });
115
+
116
+ describe('kaleidoswapAtomicRecipe — confirmation gate', () => {
117
+ it('cancels the chain when the user declines a spend gate', async () => {
118
+ const captured: { name: string; args: any }[] = [];
119
+ const tools = buildStubs(captured);
120
+ let firstSpendSeen = false;
121
+
122
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100000 sats for usdt', {
123
+ provider: refusingProvider,
124
+ tools,
125
+ onConfirm: async () => {
126
+ if (firstSpendSeen) return { approved: true };
127
+ firstSpendSeen = true;
128
+ return { approved: false, reason: 'user said no' };
129
+ },
130
+ });
131
+
132
+ expect(res.status).not.toBe('done');
133
+ // The first spend tool (atomic_init) should NOT have completed successfully —
134
+ // the chain stops before pay/execute.
135
+ expect(captured.some((c) => c.name === 'rln_pay_invoice')).toBe(false);
136
+ expect(captured.some((c) => c.name === 'kaleidoswap_atomic_execute')).toBe(false);
137
+ });
138
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Built-in "atomic swap on KaleidoSwap" recipe — trust-minimised chain.
3
+ *
4
+ * Most users want the simple market-order swap (`swapRecipe` over generic
5
+ * `get_swap_quote` / `execute_swap`). This recipe is the EXPLICIT atomic path:
6
+ * the user creates an RGB/LN receive invoice, the maker locks the swap, the
7
+ * user pays the maker's Lightning invoice, and the maker releases.
8
+ *
9
+ * Triggered only by explicit atomic-swap intent ("atomic swap", "trustless
10
+ * swap", "htlc swap") so it never preempts the simpler swap path for vague
11
+ * phrasings.
12
+ *
13
+ * "atomic swap 100000 sats for usdt"
14
+ * ↓ 1 model inference (slot extraction)
15
+ * kaleidoswap_get_quote ← maker prices the swap
16
+ * rln_create_rgb_invoice ← user's node prepares receive (if to_asset is RGB)
17
+ * rln_create_ln_invoice ← (alt) if to_asset is BTC
18
+ * kaleidoswap_atomic_init 🔒 ← maker locks the swap, returns its invoice
19
+ * rln_pay_invoice 🔒 ← user pays the maker
20
+ * kaleidoswap_atomic_execute 🔒 ← (final) maker releases the asset
21
+ *
22
+ * Two-or-three confirmation gates are intentional: each represents a distinct
23
+ * decision point. The host's confirm UI describes what's about to happen.
24
+ */
25
+
26
+ import type { Recipe } from './types.js';
27
+ import { extractSwap } from './swap.js';
28
+
29
+ const ATOMIC_INTENT =
30
+ /\b(atomic|trustless|htlc)\b.*\b(swap|exchange|convert|trade)\b|\b(swap|exchange|convert|trade)\b.*\b(atomic|trustless|htlc)\b/i;
31
+
32
+ function isBtc(asset: unknown): boolean {
33
+ return String(asset ?? '').toUpperCase() === 'BTC';
34
+ }
35
+
36
+ export const kaleidoswapAtomicRecipe: Recipe = {
37
+ name: 'kaleidoswap-atomic',
38
+ description:
39
+ 'Trust-minimised atomic swap on KaleidoSwap: quote, prepare a receive invoice on the user\'s node, lock the swap with the maker, pay, and execute.',
40
+ match: (t) => ATOMIC_INTENT.test(t),
41
+ triggers: ['atomic swap', 'trustless swap', 'htlc swap'],
42
+ slots: [
43
+ { name: 'from_asset', type: 'string', description: 'Asset to spend (BTC / USDT / XAUT)', required: true },
44
+ { name: 'to_asset', type: 'string', description: 'Asset to receive (BTC / USDT / XAUT)', required: true },
45
+ { name: 'amount', type: 'number', description: 'Amount of from_asset to swap' },
46
+ ],
47
+ extract: extractSwap,
48
+ confident: (s) => !!s.from_asset && !!s.to_asset && !!s.amount,
49
+ steps: [
50
+ // 1. Maker quotes the swap. Returns { quote_id, receive_amount, fees, ttl_ms, ... }.
51
+ {
52
+ tool: 'kaleidoswap_get_quote',
53
+ as: 'quote',
54
+ args: (ctx) => ({
55
+ from_asset: ctx.slots.from_asset,
56
+ to_asset: ctx.slots.to_asset,
57
+ amount: ctx.slots.amount,
58
+ }),
59
+ },
60
+ // 2a. User's node creates an RGB receive invoice (when to_asset is an RGB asset).
61
+ {
62
+ tool: 'rln_create_rgb_invoice',
63
+ as: 'receive_rgb',
64
+ args: (ctx) => {
65
+ const q = ctx.results.quote as { receive_amount?: number } | undefined;
66
+ return { asset: ctx.slots.to_asset, amount: q?.receive_amount };
67
+ },
68
+ skipIf: (ctx) => isBtc(ctx.slots.to_asset),
69
+ },
70
+ // 2b. User's node creates an LN receive invoice (when to_asset is BTC).
71
+ {
72
+ tool: 'rln_create_ln_invoice',
73
+ as: 'receive_ln',
74
+ args: (ctx) => {
75
+ const q = ctx.results.quote as { receive_amount?: number } | undefined;
76
+ return { amount_sats: q?.receive_amount };
77
+ },
78
+ skipIf: (ctx) => !isBtc(ctx.slots.to_asset),
79
+ },
80
+ // 3. Maker locks the swap. Returns { atomic_id, maker_invoice }. Spend-gated.
81
+ {
82
+ tool: 'kaleidoswap_atomic_init',
83
+ as: 'atomic',
84
+ args: (ctx) => {
85
+ const rgb = ctx.results.receive_rgb as { invoice?: string } | undefined;
86
+ const ln = ctx.results.receive_ln as { invoice?: string } | undefined;
87
+ const q = ctx.results.quote as { quote_id?: string } | undefined;
88
+ return {
89
+ quote_id: q?.quote_id,
90
+ receive_invoice: rgb?.invoice ?? ln?.invoice,
91
+ };
92
+ },
93
+ },
94
+ // 4. User pays the maker's Lightning invoice. Spend-gated by the wallet contract.
95
+ {
96
+ tool: 'rln_pay_invoice',
97
+ as: 'paid',
98
+ args: (ctx) => {
99
+ const a = ctx.results.atomic as { maker_invoice?: string } | undefined;
100
+ return { invoice: a?.maker_invoice };
101
+ },
102
+ },
103
+ ],
104
+ // 5. Maker releases the receive asset → swap completes. Spend-gated.
105
+ final: {
106
+ tool: 'kaleidoswap_atomic_execute',
107
+ args: (ctx) => {
108
+ const a = ctx.results.atomic as { atomic_id?: string } | undefined;
109
+ return { atomic_id: a?.atomic_id };
110
+ },
111
+ },
112
+ summary: (ctx) => {
113
+ const q = ctx.results.quote as { receive_amount?: number } | undefined;
114
+ const tail = q?.receive_amount ? ` ≈ ${q.receive_amount} ${ctx.slots.to_asset}` : '';
115
+ return `Atomic swap: ${ctx.slots.amount} ${ctx.slots.from_asset} → ${ctx.slots.to_asset}${tail}.`;
116
+ },
117
+ };
@@ -61,10 +61,22 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
61
61
  inferences = ex.inferences;
62
62
  }
63
63
 
64
- // Deterministic steps.
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.
65
68
  for (const step of recipe.steps) {
66
69
  if (step.skipIf?.(ctx)) continue;
67
70
  const args = step.args(ctx);
71
+ const def = await opts.tools.getDef(step.tool);
72
+ if (def?.requiresConfirmation) {
73
+ const decision = opts.onConfirm
74
+ ? await opts.onConfirm({ name: step.tool, arguments: args })
75
+ : { 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
+ }
79
+ }
68
80
  const result = await opts.tools.execute(step.tool, args);
69
81
  ctx.results[step.as ?? step.tool] = result;
70
82
  opts.onStep?.(step.tool, args, result);
@@ -75,6 +75,23 @@ const STOPWORDS = new Set([
75
75
  'show', 'tell', 'how', 'much', 'many', 'about', 'into', 'over',
76
76
  ]);
77
77
 
78
+ /** Escape a string for safe inclusion in a regex. */
79
+ function reEscape(s: string): string {
80
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
81
+ }
82
+
83
+ /**
84
+ * Match a trigger phrase in the query with WORD BOUNDARIES — so the
85
+ * `usd` trigger on a wallet skill doesn't fire on `usdt`/`usdc`, and
86
+ * `cafe` doesn't fire on `cafeteria`. Multi-word triggers ("near me")
87
+ * still work because spaces are already word boundaries.
88
+ */
89
+ function triggerMatches(query: string, trigger: string): boolean {
90
+ const t = trigger.toLowerCase().trim();
91
+ if (!t) return false;
92
+ return new RegExp(`\\b${reEscape(t)}\\b`).test(query);
93
+ }
94
+
78
95
  /** Default selector: score by meaningful keyword overlap; triggers weigh most. */
79
96
  export const keywordSelector: SkillSelector = {
80
97
  select(query, skills) {
@@ -89,8 +106,10 @@ export const keywordSelector: SkillSelector = {
89
106
  const hayWords = haystack.split(/\W+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
90
107
  let score = 0;
91
108
  for (const w of hayWords) if (words.has(w)) score += 1;
92
- // Strong boost for an explicit trigger appearing in the query.
93
- for (const t of skill.triggers ?? []) if (q.includes(t.toLowerCase())) score += 3;
109
+ // Strong boost for an explicit trigger appearing in the query — at a
110
+ // word boundary, so short triggers (`usd`, `eur`, `cafe`) don't leak
111
+ // into longer words (`usdt`, `europe`, `cafeteria`).
112
+ for (const t of skill.triggers ?? []) if (triggerMatches(q, t)) score += 3;
94
113
  if (score > bestScore) {
95
114
  bestScore = score;
96
115
  best = skill;
@@ -75,6 +75,48 @@ Manage Lightning channels via LSPS1.`);
75
75
  });
76
76
  });
77
77
 
78
+ describe('SkillRegistry selection — trigger word boundaries', () => {
79
+ // Regression: short triggers must NOT match inside longer words.
80
+ // Bug observed in the CLI: a wallet skill with `usd` as a trigger was
81
+ // picked for "what is the quote of usdt to btc" because the old
82
+ // q.includes("usd") was true for "usdt", outranking the trading skill.
83
+ const reg = new SkillRegistry();
84
+ reg.addMarkdown(`---
85
+ name: wallet-fiat
86
+ description: Check the BTC price and convert fiat to sats. Fiat support: usd, eur, gbp.
87
+ triggers: price, eur, usd, gbp
88
+ ---
89
+ Wallet — fiat conversion.`);
90
+ reg.addMarkdown(`---
91
+ name: trading
92
+ description: Quote and execute swaps between BTC, USDT and XAUT on KaleidoSwap.
93
+ triggers: quote, swap, trade, usdt, xaut
94
+ ---
95
+ Trading on the maker.`);
96
+
97
+ it("doesn't fire the `usd` trigger inside `usdt`", () => {
98
+ const sel = reg.select('what is the quote of usdt to btc')?.name;
99
+ expect(sel).toBe('trading'); // not wallet-fiat
100
+ });
101
+
102
+ it('still fires the `usd` trigger when the user actually said `usd`', () => {
103
+ const sel = reg.select('how many sats is 30 usd')?.name;
104
+ expect(sel).toBe('wallet-fiat');
105
+ });
106
+
107
+ it("doesn't fire a short trigger inside a longer word — `cafe` not in `cafeteria`", () => {
108
+ const r2 = new SkillRegistry();
109
+ r2.addMarkdown(`---
110
+ name: merchants
111
+ description: Find merchants that accept Bitcoin.
112
+ triggers: cafe, restaurant, bar
113
+ ---
114
+ Merchant finder.`);
115
+ expect(r2.select('the cafeteria menu')).toBeNull(); // no match
116
+ expect(r2.select('any bitcoin cafe nearby')?.name).toBe('merchants');
117
+ });
118
+ });
119
+
78
120
  describe('parseSkill — real Agent-Skills spec', () => {
79
121
  it('unquotes the description, captures metadata, tolerates no tools', () => {
80
122
  const s = parseSkill(BITREFILL_SKILL);
@@ -0,0 +1,57 @@
1
+ /** Confirm-sheet readback — deterministic, voice-first spend summaries. */
2
+
3
+ import { describe, it, expect } from 'vitest';
4
+ import { confirmReadback } from './confirm.js';
5
+
6
+ describe('confirmReadback', () => {
7
+ it('send_payment: sats + recipient, grouped thousands', () => {
8
+ expect(confirmReadback({ name: 'send_payment', arguments: { to: 'bob', amount_sats: 4800 } }))
9
+ .toBe('Send 4,800 sats to bob. Confirm?');
10
+ });
11
+
12
+ it('send_payment: explicit layer is read back', () => {
13
+ expect(confirmReadback({ name: 'send_payment', arguments: { to: 'bob', amount_sats: 1000000, layer: 'spark' } }))
14
+ .toBe('Send 1,000,000 sats to bob over Spark. Confirm?');
15
+ });
16
+
17
+ it('send_payment: asset amount when no sats (core router, no layer suffix)', () => {
18
+ expect(confirmReadback({ name: 'send_payment', arguments: { to: 'alice', asset: 'USDT', amount: 10 } }))
19
+ .toBe('Send 10 USDT to alice. Confirm?');
20
+ });
21
+
22
+ it('spark_send: layer comes from the tool, address is shortened', () => {
23
+ const line = confirmReadback({
24
+ name: 'spark_send',
25
+ arguments: { amount_sats: 5000, to: 'bc1qabcdef0123456789xyzlongaddress' },
26
+ });
27
+ expect(line).toBe('Send 5,000 sats to bc1qab…ress over Spark. Confirm?');
28
+ });
29
+
30
+ it('rln_send_asset: asset + ticker + recipient over RLN', () => {
31
+ expect(confirmReadback({ name: 'rln_send_asset', arguments: { asset: 'USDT', amount: 10, to: 'bob' } }))
32
+ .toBe('Send 10 USDT to bob over RLN. Confirm?');
33
+ });
34
+
35
+ it('rln_pay_invoice: invoice shortened, over RLN', () => {
36
+ const line = confirmReadback({
37
+ name: 'rln_pay_invoice',
38
+ arguments: { invoice: 'lnbc1ptestinvoice0123456789abcd' },
39
+ });
40
+ expect(line).toBe('Pay Lightning invoice lnbc1p…abcd over RLN. Confirm?');
41
+ });
42
+
43
+ it('execute_swap: from → to with amount', () => {
44
+ expect(confirmReadback({ name: 'execute_swap', arguments: { from_asset: 'BTC', to_asset: 'USDT', amount: 0.01 } }))
45
+ .toBe('Swap 0.01 BTC for USDT. Confirm?');
46
+ });
47
+
48
+ it('returns null for non-spend tools', () => {
49
+ expect(confirmReadback({ name: 'get_balances', arguments: {} })).toBeNull();
50
+ expect(confirmReadback({ name: 'resolve_contact', arguments: { name: 'bob' } })).toBeNull();
51
+ });
52
+
53
+ it('short contact names are not truncated; long refs are', () => {
54
+ expect(confirmReadback({ name: 'arkade_send', arguments: { amount_sats: 100, to: 'mum' } }))
55
+ .toBe('Send 100 sats to mum over Arkade. Confirm?');
56
+ });
57
+ });