@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.
- package/dist/capabilities.d.ts +4 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +7 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/funnel.d.ts +6 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +26 -6
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +72 -0
- package/dist/kaleidoswap/contract.d.ts.map +1 -0
- package/dist/kaleidoswap/contract.js +125 -0
- package/dist/kaleidoswap/contract.js.map +1 -0
- package/dist/knowledge/btc-map.d.ts +87 -0
- package/dist/knowledge/btc-map.d.ts.map +1 -0
- package/dist/knowledge/btc-map.js +365 -0
- package/dist/knowledge/btc-map.js.map +1 -0
- package/dist/lsps1/contract.d.ts +55 -0
- package/dist/lsps1/contract.d.ts.map +1 -0
- package/dist/lsps1/contract.js +91 -0
- package/dist/lsps1/contract.js.map +1 -0
- package/dist/memory/store.d.ts +7 -1
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.js +43 -3
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/types.d.ts +12 -0
- package/dist/memory/types.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.d.ts +27 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.js +111 -0
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -0
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +13 -1
- package/dist/recipe/runner.js.map +1 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +20 -2
- package/dist/skills/registry.js.map +1 -1
- package/dist/wallet/confirm.d.ts +12 -0
- package/dist/wallet/confirm.d.ts.map +1 -0
- package/dist/wallet/confirm.js +67 -0
- package/dist/wallet/confirm.js.map +1 -0
- package/package.json +2 -1
- package/skills/README.md +6 -1
- package/skills/kaleido-lsps/SKILL.md +56 -0
- package/skills/kaleido-trading/SKILL.md +85 -18
- package/skills/merchant-finder/SKILL.md +87 -0
- package/skills/paid-data/SKILL.md +12 -0
- package/skills/wallet-assistant/SKILL.md +38 -0
- package/src/capabilities.ts +12 -0
- package/src/context/context.test.ts +6 -2
- package/src/engine.ts +6 -0
- package/src/funnel.ts +32 -7
- package/src/index.ts +43 -0
- package/src/kaleidoswap/contract.test.ts +147 -0
- package/src/kaleidoswap/contract.ts +212 -0
- package/src/knowledge/btc-map.test.ts +188 -0
- package/src/knowledge/btc-map.ts +446 -0
- package/src/lsps1/contract.test.ts +81 -0
- package/src/lsps1/contract.ts +132 -0
- package/src/memory/memory.test.ts +55 -0
- package/src/memory/store.ts +49 -4
- package/src/memory/types.ts +13 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +138 -0
- package/src/recipe/kaleidoswap-atomic.ts +117 -0
- package/src/recipe/runner.ts +13 -1
- package/src/skills/registry.ts +21 -2
- package/src/skills/skills.test.ts +42 -0
- package/src/wallet/confirm.test.ts +57 -0
- package/src/wallet/confirm.ts +74 -0
- 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', () => {
|
package/src/memory/store.ts
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
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
|
|
103
|
+
text,
|
|
66
104
|
kind: item.kind,
|
|
67
|
-
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
|
+
}
|
package/src/memory/types.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/recipe/runner.ts
CHANGED
|
@@ -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);
|
package/src/skills/registry.ts
CHANGED
|
@@ -75,6 +75,23 @@ const STOPWORDS = new Set([
|
|
|
75
75
|
'show', 'tell', 'how', 'much', 'many', 'about', 'into', 'over',
|
|
76
76
|
]);
|
|
77
77
|
|
|
78
|
+
/** Escape a string for safe inclusion in a regex. */
|
|
79
|
+
function reEscape(s: string): string {
|
|
80
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Match a trigger phrase in the query with WORD BOUNDARIES — so the
|
|
85
|
+
* `usd` trigger on a wallet skill doesn't fire on `usdt`/`usdc`, and
|
|
86
|
+
* `cafe` doesn't fire on `cafeteria`. Multi-word triggers ("near me")
|
|
87
|
+
* still work because spaces are already word boundaries.
|
|
88
|
+
*/
|
|
89
|
+
function triggerMatches(query: string, trigger: string): boolean {
|
|
90
|
+
const t = trigger.toLowerCase().trim();
|
|
91
|
+
if (!t) return false;
|
|
92
|
+
return new RegExp(`\\b${reEscape(t)}\\b`).test(query);
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
/** Default selector: score by meaningful keyword overlap; triggers weigh most. */
|
|
79
96
|
export const keywordSelector: SkillSelector = {
|
|
80
97
|
select(query, skills) {
|
|
@@ -89,8 +106,10 @@ export const keywordSelector: SkillSelector = {
|
|
|
89
106
|
const hayWords = haystack.split(/\W+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
|
|
90
107
|
let score = 0;
|
|
91
108
|
for (const w of hayWords) if (words.has(w)) score += 1;
|
|
92
|
-
// Strong boost for an explicit trigger appearing in the query
|
|
93
|
-
|
|
109
|
+
// Strong boost for an explicit trigger appearing in the query — at a
|
|
110
|
+
// word boundary, so short triggers (`usd`, `eur`, `cafe`) don't leak
|
|
111
|
+
// into longer words (`usdt`, `europe`, `cafeteria`).
|
|
112
|
+
for (const t of skill.triggers ?? []) if (triggerMatches(q, t)) score += 3;
|
|
94
113
|
if (score > bestScore) {
|
|
95
114
|
bestScore = score;
|
|
96
115
|
best = skill;
|
|
@@ -75,6 +75,48 @@ Manage Lightning channels via LSPS1.`);
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
describe('SkillRegistry selection — trigger word boundaries', () => {
|
|
79
|
+
// Regression: short triggers must NOT match inside longer words.
|
|
80
|
+
// Bug observed in the CLI: a wallet skill with `usd` as a trigger was
|
|
81
|
+
// picked for "what is the quote of usdt to btc" because the old
|
|
82
|
+
// q.includes("usd") was true for "usdt", outranking the trading skill.
|
|
83
|
+
const reg = new SkillRegistry();
|
|
84
|
+
reg.addMarkdown(`---
|
|
85
|
+
name: wallet-fiat
|
|
86
|
+
description: Check the BTC price and convert fiat to sats. Fiat support: usd, eur, gbp.
|
|
87
|
+
triggers: price, eur, usd, gbp
|
|
88
|
+
---
|
|
89
|
+
Wallet — fiat conversion.`);
|
|
90
|
+
reg.addMarkdown(`---
|
|
91
|
+
name: trading
|
|
92
|
+
description: Quote and execute swaps between BTC, USDT and XAUT on KaleidoSwap.
|
|
93
|
+
triggers: quote, swap, trade, usdt, xaut
|
|
94
|
+
---
|
|
95
|
+
Trading on the maker.`);
|
|
96
|
+
|
|
97
|
+
it("doesn't fire the `usd` trigger inside `usdt`", () => {
|
|
98
|
+
const sel = reg.select('what is the quote of usdt to btc')?.name;
|
|
99
|
+
expect(sel).toBe('trading'); // not wallet-fiat
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('still fires the `usd` trigger when the user actually said `usd`', () => {
|
|
103
|
+
const sel = reg.select('how many sats is 30 usd')?.name;
|
|
104
|
+
expect(sel).toBe('wallet-fiat');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("doesn't fire a short trigger inside a longer word — `cafe` not in `cafeteria`", () => {
|
|
108
|
+
const r2 = new SkillRegistry();
|
|
109
|
+
r2.addMarkdown(`---
|
|
110
|
+
name: merchants
|
|
111
|
+
description: Find merchants that accept Bitcoin.
|
|
112
|
+
triggers: cafe, restaurant, bar
|
|
113
|
+
---
|
|
114
|
+
Merchant finder.`);
|
|
115
|
+
expect(r2.select('the cafeteria menu')).toBeNull(); // no match
|
|
116
|
+
expect(r2.select('any bitcoin cafe nearby')?.name).toBe('merchants');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
78
120
|
describe('parseSkill — real Agent-Skills spec', () => {
|
|
79
121
|
it('unquotes the description, captures metadata, tolerates no tools', () => {
|
|
80
122
|
const s = parseSkill(BITREFILL_SKILL);
|
|
@@ -0,0 +1,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
|
+
});
|