@kaleidorg/mind 0.1.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 +38 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +41 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/context/budget.d.ts +29 -0
- package/dist/context/budget.d.ts.map +1 -0
- package/dist/context/budget.js +36 -0
- package/dist/context/budget.js.map +1 -0
- package/dist/context/builder.d.ts +39 -0
- package/dist/context/builder.d.ts.map +1 -0
- package/dist/context/builder.js +77 -0
- package/dist/context/builder.js.map +1 -0
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/fastpath/fastpath.d.ts +38 -0
- package/dist/fastpath/fastpath.d.ts.map +1 -0
- package/dist/fastpath/fastpath.js +52 -0
- package/dist/fastpath/fastpath.js.map +1 -0
- package/dist/funnel.d.ts +117 -0
- package/dist/funnel.d.ts.map +1 -0
- package/dist/funnel.js +195 -0
- package/dist/funnel.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -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/bitcoin-copilot.d.ts +11 -0
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -0
- package/dist/knowledge/bitcoin-copilot.js +155 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -0
- package/dist/knowledge/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/knowledge/merchants.d.ts +24 -0
- package/dist/knowledge/merchants.d.ts.map +1 -0
- package/dist/knowledge/merchants.js +34 -0
- package/dist/knowledge/merchants.js.map +1 -0
- package/dist/knowledge/wallet.d.ts +34 -0
- package/dist/knowledge/wallet.d.ts.map +1 -0
- package/dist/knowledge/wallet.js +63 -0
- package/dist/knowledge/wallet.js.map +1 -0
- package/dist/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 +40 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +143 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/memory/tool.d.ts +9 -0
- package/dist/memory/tool.d.ts.map +1 -0
- package/dist/memory/tool.js +70 -0
- package/dist/memory/tool.js.map +1 -0
- package/dist/memory/types.d.ts +68 -0
- package/dist/memory/types.d.ts.map +1 -0
- package/dist/memory/types.js +14 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/rag/retriever.d.ts +30 -0
- package/dist/rag/retriever.d.ts.map +1 -0
- package/dist/rag/retriever.js +72 -0
- package/dist/rag/retriever.js.map +1 -0
- package/dist/rag/tool.d.ts +15 -0
- package/dist/rag/tool.d.ts.map +1 -0
- package/dist/rag/tool.js +42 -0
- package/dist/rag/tool.js.map +1 -0
- package/dist/rag/types.d.ts +44 -0
- package/dist/rag/types.d.ts.map +1 -0
- package/dist/rag/types.js +11 -0
- package/dist/rag/types.js.map +1 -0
- package/dist/rag/vector-store.d.ts +23 -0
- package/dist/rag/vector-store.d.ts.map +1 -0
- package/dist/rag/vector-store.js +72 -0
- package/dist/rag/vector-store.js.map +1 -0
- package/dist/recipe/asset-send.d.ts +15 -0
- package/dist/recipe/asset-send.d.ts.map +1 -0
- package/dist/recipe/asset-send.js +83 -0
- package/dist/recipe/asset-send.js.map +1 -0
- package/dist/recipe/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/payments.d.ts +15 -0
- package/dist/recipe/payments.d.ts.map +1 -0
- package/dist/recipe/payments.js +119 -0
- package/dist/recipe/payments.js.map +1 -0
- package/dist/recipe/receive.d.ts +14 -0
- package/dist/recipe/receive.d.ts.map +1 -0
- package/dist/recipe/receive.js +109 -0
- package/dist/recipe/receive.js.map +1 -0
- package/dist/recipe/runner.d.ts +42 -0
- package/dist/recipe/runner.d.ts.map +1 -0
- package/dist/recipe/runner.js +106 -0
- package/dist/recipe/runner.js.map +1 -0
- package/dist/recipe/swap.d.ts +16 -0
- package/dist/recipe/swap.d.ts.map +1 -0
- package/dist/recipe/swap.js +73 -0
- package/dist/recipe/swap.js.map +1 -0
- package/dist/recipe/types.d.ts +71 -0
- package/dist/recipe/types.d.ts.map +1 -0
- package/dist/recipe/types.js +13 -0
- package/dist/recipe/types.js.map +1 -0
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +20 -2
- package/dist/skills/registry.js.map +1 -1
- package/dist/tools/cli.d.ts +43 -0
- package/dist/tools/cli.d.ts.map +1 -0
- package/dist/tools/cli.js +61 -0
- package/dist/tools/cli.js.map +1 -0
- package/dist/tools/mcp.d.ts +3 -2
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mcp.js +3 -2
- package/dist/tools/mcp.js.map +1 -1
- package/dist/wallet/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/dist/wallet/contract.d.ts +57 -0
- package/dist/wallet/contract.d.ts.map +1 -0
- package/dist/wallet/contract.js +113 -0
- package/dist/wallet/contract.js.map +1 -0
- package/package.json +10 -5
- 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 +79 -0
- package/src/context/budget.ts +46 -0
- package/src/context/builder.ts +100 -0
- package/src/context/context.test.ts +87 -0
- package/src/engine.ts +6 -0
- package/src/fastpath/fastpath.test.ts +34 -0
- package/src/fastpath/fastpath.ts +70 -0
- package/src/funnel.test.ts +207 -0
- package/src/funnel.ts +285 -0
- package/src/index.ts +128 -0
- package/src/kaleidoswap/contract.test.ts +147 -0
- package/src/kaleidoswap/contract.ts +212 -0
- package/src/knowledge/bitcoin-copilot.ts +177 -0
- package/src/knowledge/btc-map.test.ts +188 -0
- package/src/knowledge/btc-map.ts +446 -0
- package/src/knowledge/knowledge.test.ts +63 -0
- package/src/knowledge/merchants.ts +49 -0
- package/src/knowledge/wallet.ts +84 -0
- package/src/lsps1/contract.test.ts +81 -0
- package/src/lsps1/contract.ts +132 -0
- package/src/memory/memory.test.ts +140 -0
- package/src/memory/store.ts +174 -0
- package/src/memory/tool.ts +76 -0
- package/src/memory/types.ts +76 -0
- package/src/rag/rag.test.ts +85 -0
- package/src/rag/retriever.ts +94 -0
- package/src/rag/tool.ts +55 -0
- package/src/rag/types.ts +49 -0
- package/src/rag/vector-store.ts +78 -0
- package/src/recipe/asset-send.ts +79 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +138 -0
- package/src/recipe/kaleidoswap-atomic.ts +117 -0
- package/src/recipe/payments.ts +116 -0
- package/src/recipe/receive.ts +98 -0
- package/src/recipe/recipe.test.ts +193 -0
- package/src/recipe/runner.ts +134 -0
- package/src/recipe/swap.ts +74 -0
- package/src/recipe/types.ts +76 -0
- package/src/skills/registry.ts +21 -2
- package/src/skills/skills.test.ts +42 -0
- package/src/tools/cli.test.ts +53 -0
- package/src/tools/cli.ts +98 -0
- package/src/tools/mcp.ts +3 -2
- package/src/wallet/confirm.test.ts +57 -0
- package/src/wallet/confirm.ts +74 -0
- package/src/wallet/contract.test.ts +89 -0
- package/src/wallet/contract.ts +157 -0
- package/skills/kaleido-wallet/SKILL.md +0 -28
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextBuilder — assembles the system prompt for a turn from, in priority
|
|
3
|
+
* order: the agent's identity (soul) → operating instructions → the active
|
|
4
|
+
* skill playbook → auto-recalled memories → auto-retrieved knowledge, trimmed
|
|
5
|
+
* to a token budget so it never overflows a small model.
|
|
6
|
+
*
|
|
7
|
+
* Memory and RAG can ALSO be tools (the model pulls them in itself). Use this
|
|
8
|
+
* for the always-on slice (identity + the most relevant memory/snippet); use
|
|
9
|
+
* the tools (`recall`, `search_knowledge`) for deeper, on-demand lookups.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AgentProfile, MemoryStore } from '../memory/types.js';
|
|
13
|
+
import type { Retriever } from '../rag/retriever.js';
|
|
14
|
+
import { clampToTokens, estimateTokens } from './budget.js';
|
|
15
|
+
|
|
16
|
+
export interface ContextBuilderOptions {
|
|
17
|
+
profile: AgentProfile;
|
|
18
|
+
/** Auto-recall relevant memories into context. */
|
|
19
|
+
memory?: MemoryStore;
|
|
20
|
+
/** Auto-retrieve relevant knowledge into context. */
|
|
21
|
+
retriever?: Retriever;
|
|
22
|
+
/** Max tokens for the whole assembled system prompt (see contextBudgetTokens). */
|
|
23
|
+
budgetTokens?: number;
|
|
24
|
+
/** Memories to recall (default 3). */
|
|
25
|
+
topKMemory?: number;
|
|
26
|
+
/** Knowledge chunks to retrieve (default 0 — prefer the search_knowledge tool). */
|
|
27
|
+
topKRag?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuildInput {
|
|
31
|
+
/** The user's message — drives memory recall + retrieval. */
|
|
32
|
+
query: string;
|
|
33
|
+
/** A composed skill playbook to splice in (from SkillRegistry.compose). */
|
|
34
|
+
skillSystem?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ContextBuilder {
|
|
38
|
+
constructor(private readonly opts: ContextBuilderOptions) {}
|
|
39
|
+
|
|
40
|
+
async build(input: BuildInput): Promise<{ system: string }> {
|
|
41
|
+
const { profile } = this.opts;
|
|
42
|
+
const budget = this.opts.budgetTokens ?? 1024;
|
|
43
|
+
const sections: string[] = [];
|
|
44
|
+
let used = 0;
|
|
45
|
+
|
|
46
|
+
const add = (text: string, { force = false } = {}): void => {
|
|
47
|
+
const t = text.trim();
|
|
48
|
+
if (!t) return;
|
|
49
|
+
const cost = estimateTokens(t) + 1;
|
|
50
|
+
if (!force && used + cost > budget) {
|
|
51
|
+
// Try to fit a trimmed version of optional sections.
|
|
52
|
+
const room = budget - used - 1;
|
|
53
|
+
if (room < 40) return;
|
|
54
|
+
const trimmed = clampToTokens(t, room);
|
|
55
|
+
sections.push(trimmed);
|
|
56
|
+
used += estimateTokens(trimmed) + 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
sections.push(t);
|
|
60
|
+
used += cost;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// 1. Identity (always) — name + soul.
|
|
64
|
+
add(`# ${profile.name}\n${profile.soul}`, { force: true });
|
|
65
|
+
|
|
66
|
+
// 2. Operating instructions (always, if any).
|
|
67
|
+
if (profile.instructions) add(`## Instructions\n${profile.instructions}`, { force: true });
|
|
68
|
+
|
|
69
|
+
// 3. Active skill playbook.
|
|
70
|
+
if (input.skillSystem) add(input.skillSystem);
|
|
71
|
+
|
|
72
|
+
// 4. Auto-recalled memory.
|
|
73
|
+
const kMem = this.opts.topKMemory ?? 3;
|
|
74
|
+
if (this.opts.memory && kMem > 0) {
|
|
75
|
+
try {
|
|
76
|
+
const mems = await this.opts.memory.search({ text: input.query, limit: kMem });
|
|
77
|
+
if (mems.length) {
|
|
78
|
+
add(`## What you remember\n${mems.map((m) => `- (${m.kind}) ${m.text}`).join('\n')}`);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* memory is best-effort */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 5. Auto-retrieved knowledge (opt-in; default prefers the tool).
|
|
86
|
+
const kRag = this.opts.topKRag ?? 0;
|
|
87
|
+
if (this.opts.retriever && kRag > 0) {
|
|
88
|
+
try {
|
|
89
|
+
const hits = await this.opts.retriever.search(input.query, kRag);
|
|
90
|
+
if (hits.length) {
|
|
91
|
+
add(`## Relevant context\n${hits.map((h) => h.text).join('\n\n')}`);
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* retrieval is best-effort */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { system: sections.join('\n\n') };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Context budget + builder + capability tests. */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { estimateTokens, clampToTokens, contextBudgetTokens } from './budget.js';
|
|
5
|
+
import { ContextBuilder } from './builder.js';
|
|
6
|
+
import { capabilityProfile } from '../capabilities.js';
|
|
7
|
+
import { InMemoryMemoryStore } from '../memory/store.js';
|
|
8
|
+
import type { AgentProfile } from '../memory/types.js';
|
|
9
|
+
|
|
10
|
+
const profile: AgentProfile = {
|
|
11
|
+
name: 'KaleidoMind',
|
|
12
|
+
soul: 'You are a sovereign, local-first Bitcoin assistant. Calm, precise, private.',
|
|
13
|
+
instructions: 'Never reveal seeds. Confirm spends.',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('budget helpers', () => {
|
|
17
|
+
it('estimates + clamps by ~4 chars/token', () => {
|
|
18
|
+
expect(estimateTokens('a'.repeat(40))).toBe(10);
|
|
19
|
+
const clamped = clampToTokens('word '.repeat(100), 10);
|
|
20
|
+
expect(clamped.length).toBeLessThanOrEqual(10 * 4 + 1);
|
|
21
|
+
expect(clamped.endsWith('…')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('reserves output/tools/conversation from the window', () => {
|
|
25
|
+
expect(contextBudgetTokens(8192)).toBe(8192 - 512 - 600 - 768);
|
|
26
|
+
expect(contextBudgetTokens(2048)).toBeGreaterThanOrEqual(256); // floor
|
|
27
|
+
expect(contextBudgetTokens(512)).toBe(256); // clamped to floor
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('ContextBuilder', () => {
|
|
32
|
+
it('always includes identity + instructions, then memory', async () => {
|
|
33
|
+
const memory = new InMemoryMemoryStore({ now: () => 1 });
|
|
34
|
+
await memory.add({ text: 'user prefers sats over USD', kind: 'preference' });
|
|
35
|
+
const builder = new ContextBuilder({ profile, memory, topKMemory: 3, budgetTokens: 1024 });
|
|
36
|
+
|
|
37
|
+
const { system } = await builder.build({ query: 'show my balance in sats' });
|
|
38
|
+
expect(system).toMatch(/# KaleidoMind/);
|
|
39
|
+
expect(system).toMatch(/Never reveal seeds/);
|
|
40
|
+
expect(system).toMatch(/What you remember/);
|
|
41
|
+
expect(system).toMatch(/prefers sats/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps identity even under a tiny budget (drops optional sections)', async () => {
|
|
45
|
+
const memory = new InMemoryMemoryStore({ now: () => 1 });
|
|
46
|
+
await memory.add({ text: 'a'.repeat(400), kind: 'note' });
|
|
47
|
+
const builder = new ContextBuilder({ profile, memory, topKMemory: 3, budgetTokens: 40 });
|
|
48
|
+
const { system } = await builder.build({ query: 'x' });
|
|
49
|
+
expect(system).toMatch(/# KaleidoMind/); // identity survives
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('splices in a composed skill playbook', async () => {
|
|
53
|
+
const builder = new ContextBuilder({ profile, budgetTokens: 2048 });
|
|
54
|
+
const { system } = await builder.build({
|
|
55
|
+
query: 'buy a gift card',
|
|
56
|
+
skillSystem: '## Active skill: bitrefill\nRoute the purchase.',
|
|
57
|
+
});
|
|
58
|
+
expect(system).toMatch(/Active skill: bitrefill/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('capabilityProfile', () => {
|
|
63
|
+
it('low-end phone: memory yes, RAG no, dedup yes but no LLM merge', () => {
|
|
64
|
+
const c = capabilityProfile({ ramBytes: 2 * 1024 ** 3, modelCtxTokens: 2048, hasEmbeddings: true });
|
|
65
|
+
expect(c.memory).toBe(true);
|
|
66
|
+
expect(c.rag).toBe(false); // ctx too small + low RAM
|
|
67
|
+
expect(c.topKRag).toBe(0);
|
|
68
|
+
expect(c.dedupeMemory).toBe(true); // embedding-only dedup is mobile-safe
|
|
69
|
+
expect(c.mergeMemory).toBe(false); // never run merge inference on a tiny phone
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('desktop / delegated: RAG on, memory merge on', () => {
|
|
73
|
+
const c = capabilityProfile({ modelCtxTokens: 8192, hasEmbeddings: true, delegated: true });
|
|
74
|
+
expect(c.rag).toBe(true);
|
|
75
|
+
expect(c.topKRag).toBeGreaterThan(0);
|
|
76
|
+
expect(c.semanticMemory).toBe(true);
|
|
77
|
+
expect(c.dedupeMemory).toBe(true);
|
|
78
|
+
expect(c.mergeMemory).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('no embeddings → no RAG, no semantic memory', () => {
|
|
82
|
+
const c = capabilityProfile({ ramBytes: 16 * 1024 ** 3, modelCtxTokens: 8192, hasEmbeddings: false });
|
|
83
|
+
expect(c.rag).toBe(false);
|
|
84
|
+
expect(c.semanticMemory).toBe(false);
|
|
85
|
+
expect(c.memory).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/engine.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface AgenticOptions {
|
|
|
35
35
|
onStart?: (requestId: string, turn: number) => void;
|
|
36
36
|
/** Fired when the model requests a tool, before it executes. */
|
|
37
37
|
onToolCall?: (call: { name: string; arguments: Record<string, unknown> }, turn: number) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Fired after a tool returns (success OR error — errors arrive as `{error}`).
|
|
40
|
+
* Useful for surfacing the raw response back to the user in a debug UI.
|
|
41
|
+
*/
|
|
42
|
+
onToolResult?: (event: { name: string; arguments: Record<string, unknown>; result: unknown }, turn: number) => void;
|
|
38
43
|
/** Human-in-the-loop gate for tools flagged requiresConfirmation. */
|
|
39
44
|
onConfirm?: (call: { name: string; arguments: Record<string, unknown> }) => Promise<ConfirmDecision>;
|
|
40
45
|
/**
|
|
@@ -127,6 +132,7 @@ export class Engine {
|
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
executed.push({ name: call.name, arguments: call.arguments, result });
|
|
135
|
+
opts.onToolResult?.({ name: call.name, arguments: call.arguments, result }, turn);
|
|
130
136
|
history.push({
|
|
131
137
|
role: 'tool',
|
|
132
138
|
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FastPath, WALLET_FAST_INTENTS } from './fastpath.js';
|
|
3
|
+
|
|
4
|
+
const fp = new FastPath(WALLET_FAST_INTENTS);
|
|
5
|
+
|
|
6
|
+
describe('FastPath (Tier-0)', () => {
|
|
7
|
+
it('routes balance asks → get_balances', () => {
|
|
8
|
+
expect(fp.select("what's my balance")?.tool).toBe('get_balances');
|
|
9
|
+
expect(fp.select('how much do i have')?.tool).toBe('get_balances');
|
|
10
|
+
expect(fp.select('show my funds')?.tool).toBe('get_balances');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('routes receive-address asks → spark_get_address', () => {
|
|
14
|
+
expect(fp.select('give me a receive address')?.tool).toBe('spark_get_address');
|
|
15
|
+
expect(fp.select('what is my address')?.tool).toBe('spark_get_address');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('routes price asks → get_price', () => {
|
|
19
|
+
expect(fp.select('btc price')?.tool).toBe('get_price');
|
|
20
|
+
expect(fp.select('how much is bitcoin')?.tool).toBe('get_price');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('does NOT fire on spend / compound requests', () => {
|
|
24
|
+
expect(fp.select('pay bob 3 eur')).toBeNull();
|
|
25
|
+
expect(fp.select('send 5000 sats to alice')).toBeNull();
|
|
26
|
+
expect(fp.select('check my balance and then send 1000 to bob')).toBeNull(); // compound → LLM
|
|
27
|
+
expect(fp.select('swap 10 usdt for btc')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does NOT fire on unrelated chatter', () => {
|
|
31
|
+
expect(fp.select('hello there')).toBeNull();
|
|
32
|
+
expect(fp.select('what can you do')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-0 deterministic fast-path — answer the common, unambiguous wallet asks
|
|
3
|
+
* with NO LLM at all. "balance", "receive address", "btc price" map straight to
|
|
4
|
+
* a single tool call. The model is reserved for genuine ambiguity.
|
|
5
|
+
*
|
|
6
|
+
* This is the biggest mobile UX lever: ~60-80% of wallet requests are simple,
|
|
7
|
+
* and our eval showed tiny models are slow + weak at args — so skip them here.
|
|
8
|
+
*
|
|
9
|
+
* Matchers are intentionally CONSERVATIVE: when in doubt, return null and let
|
|
10
|
+
* the recipe / agentic loop handle it. Under-firing is fine; mis-firing is not.
|
|
11
|
+
*
|
|
12
|
+
* Pure data — no deps. The host executes the returned tool + renders the result.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface FastIntent {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Contract tool to call when this intent matches. */
|
|
18
|
+
tool: string;
|
|
19
|
+
/** True only when this intent UNAMBIGUOUSLY matches the text. */
|
|
20
|
+
match: (text: string) => boolean;
|
|
21
|
+
/** Optional args derived from the text (default: none). */
|
|
22
|
+
args?: (text: string) => Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FastHit {
|
|
26
|
+
intent: FastIntent;
|
|
27
|
+
tool: string;
|
|
28
|
+
args: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class FastPath {
|
|
32
|
+
private intents: FastIntent[];
|
|
33
|
+
constructor(intents: FastIntent[] = []) {
|
|
34
|
+
this.intents = [...intents];
|
|
35
|
+
}
|
|
36
|
+
add(intent: FastIntent): void {
|
|
37
|
+
this.intents.push(intent);
|
|
38
|
+
}
|
|
39
|
+
list(): FastIntent[] {
|
|
40
|
+
return [...this.intents];
|
|
41
|
+
}
|
|
42
|
+
/** The first unambiguously-matching intent, or null. */
|
|
43
|
+
select(text: string): FastHit | null {
|
|
44
|
+
const intent = this.intents.find((i) => i.match(text));
|
|
45
|
+
return intent ? { intent, tool: intent.tool, args: intent.args?.(text) ?? {} } : null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A "spend or compound" guard — never fast-path anything that moves money or
|
|
50
|
+
// chains another action ("send", "pay", "and then", "swap").
|
|
51
|
+
const ACTIONY = /\b(send|pay|transfer|swap|buy|sell|then|after that)\b/i;
|
|
52
|
+
|
|
53
|
+
/** Default wallet read intents (balance / receive address / price). */
|
|
54
|
+
export const WALLET_FAST_INTENTS: FastIntent[] = [
|
|
55
|
+
{
|
|
56
|
+
name: 'balance',
|
|
57
|
+
tool: 'get_balances',
|
|
58
|
+
match: (t) => !ACTIONY.test(t) && /\b(balance|funds|how much (do i|have i|i have)|how much.* (do i have|in my wallet))\b/i.test(t),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'address',
|
|
62
|
+
tool: 'spark_get_address',
|
|
63
|
+
match: (t) => !ACTIONY.test(t) && /\b(receive address|deposit address|my address|an address|get .*address|where.* receive)\b/i.test(t),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'price',
|
|
67
|
+
tool: 'get_price',
|
|
68
|
+
match: (t) => !ACTIONY.test(t) && /\b(btc price|bitcoin price|price of (btc|bitcoin)|how much is (a |one )?(btc|bitcoin))\b/i.test(t),
|
|
69
|
+
},
|
|
70
|
+
];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ToolRegistry } from './tools/registry.js';
|
|
3
|
+
import { InProcessToolSource } from './tools/in-process.js';
|
|
4
|
+
import type { LLMProvider, TurnInput, TurnOutput } from './providers/types.js';
|
|
5
|
+
import { Funnel, DEFAULT_WALLET_SYSTEM } from './funnel.js';
|
|
6
|
+
import { parseSkill } from './skills/registry.js';
|
|
7
|
+
|
|
8
|
+
// ── Fixtures ──────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** Stub contract tools across all three tiers + ambient memory/RAG. */
|
|
11
|
+
function stubTools(spy?: { send?: (a: any) => void }) {
|
|
12
|
+
const wallet = new InProcessToolSource('wallet', [
|
|
13
|
+
{ name: 'get_balances', description: 'balances', parameters: {}, handler: async () => ({ total_sats: 4000, layers: [{ layer: 'spark' }, { layer: 'rln' }] }) },
|
|
14
|
+
{ name: 'get_price', description: 'price', parameters: {}, handler: async () => ({ price_usd: 100000 }) },
|
|
15
|
+
{ name: 'resolve_contact', description: '', parameters: {}, handler: async ({ name }) => ({ name, ln_address: `${name}@kaleidoswap.com` }) },
|
|
16
|
+
{ name: 'fiat_to_sats', description: '', parameters: {}, handler: async ({ amount }) => ({ sats: Math.round(Number(amount) * 1000) }) },
|
|
17
|
+
{ name: 'send_payment', description: '', parameters: {}, requiresConfirmation: true, handler: async (a) => { spy?.send?.(a); return { status: 'SUCCESS' }; } },
|
|
18
|
+
{ name: 'list_channels', description: 'channels', parameters: {}, handler: async () => ({ channels: [] }) },
|
|
19
|
+
]);
|
|
20
|
+
const ambient = new InProcessToolSource('ambient', [
|
|
21
|
+
{ name: 'remember', description: '', parameters: {}, handler: async () => ({ ok: true }) },
|
|
22
|
+
{ name: 'recall', description: '', parameters: {}, handler: async () => ({ items: [] }) },
|
|
23
|
+
{ name: 'search_knowledge', description: '', parameters: {}, handler: async () => ({ chunks: [] }) },
|
|
24
|
+
]);
|
|
25
|
+
return new ToolRegistry([wallet, ambient]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Provider that replays scripted turns and records every TurnInput. */
|
|
29
|
+
function scriptedProvider(turns: Array<Partial<TurnOutput>>): LLMProvider & { inputs: TurnInput[] } {
|
|
30
|
+
const inputs: TurnInput[] = [];
|
|
31
|
+
let i = 0;
|
|
32
|
+
return {
|
|
33
|
+
name: 'scripted',
|
|
34
|
+
inputs,
|
|
35
|
+
async runTurn(input: TurnInput): Promise<TurnOutput> {
|
|
36
|
+
// Snapshot — the Engine mutates its messages array across turns.
|
|
37
|
+
inputs.push({ ...input, messages: [...input.messages] });
|
|
38
|
+
const t = turns[Math.min(i++, turns.length - 1)] ?? {};
|
|
39
|
+
return { text: t.text ?? '', rawContent: t.rawContent ?? t.text ?? '', toolCalls: t.toolCalls ?? [] };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TEST_SKILL = parseSkill(
|
|
45
|
+
[
|
|
46
|
+
'---',
|
|
47
|
+
'name: channels',
|
|
48
|
+
'description: Inspect Lightning channels.',
|
|
49
|
+
'triggers: channels, channel, liquidity',
|
|
50
|
+
'tools: list_channels',
|
|
51
|
+
'---',
|
|
52
|
+
'When asked about channels, call list_channels and summarise.',
|
|
53
|
+
].join('\n'),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ── T0: fast-path ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('Funnel — T0 fast-path', () => {
|
|
59
|
+
it('answers balance with zero inferences and returns the card data', async () => {
|
|
60
|
+
const provider = scriptedProvider([]);
|
|
61
|
+
const funnel = new Funnel({ provider, tools: stubTools() });
|
|
62
|
+
|
|
63
|
+
const res = await funnel.runTurn("what's my balance?");
|
|
64
|
+
|
|
65
|
+
expect(res.tier).toBe('fast');
|
|
66
|
+
expect(res.intent).toBe('balance');
|
|
67
|
+
expect(res.text).toContain('4,000 sats');
|
|
68
|
+
expect(res.data).toMatchObject({ total_sats: 4000 });
|
|
69
|
+
expect(provider.inputs).toHaveLength(0); // no LLM
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── T2: recipes ───────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe('Funnel — T2 recipes', () => {
|
|
76
|
+
it('runs a confident payment deterministically, confirm-gated', async () => {
|
|
77
|
+
const sent: any[] = [];
|
|
78
|
+
const provider = scriptedProvider([]);
|
|
79
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
80
|
+
const onStep = vi.fn();
|
|
81
|
+
const funnel = new Funnel({ provider, tools: stubTools({ send: (a) => sent.push(a) }) });
|
|
82
|
+
|
|
83
|
+
const res = await funnel.runTurn('pay bob 3 eur', { onConfirm, onStep });
|
|
84
|
+
|
|
85
|
+
expect(res.tier).toBe('recipe');
|
|
86
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
87
|
+
expect(sent[0]).toEqual({ to: 'bob@kaleidoswap.com', amount_sats: 3000 });
|
|
88
|
+
expect(onStep).toHaveBeenCalled();
|
|
89
|
+
expect(provider.inputs).toHaveLength(0); // deterministic extraction
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('fails closed: a recipe spend with no onConfirm sends nothing', async () => {
|
|
93
|
+
const sent: any[] = [];
|
|
94
|
+
const funnel = new Funnel({ provider: scriptedProvider([]), tools: stubTools({ send: (a) => sent.push(a) }) });
|
|
95
|
+
|
|
96
|
+
const res = await funnel.runTurn('pay bob 3 eur');
|
|
97
|
+
|
|
98
|
+
expect(res.tier).toBe('recipe');
|
|
99
|
+
expect(sent).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('Funnel — partial tool surfaces fall through to agentic', () => {
|
|
104
|
+
it('skips T0 and T2 when the registry lacks their tools', async () => {
|
|
105
|
+
// A host (e.g. desktop MCP) that implements none of the contract helpers.
|
|
106
|
+
const bare = new ToolRegistry([
|
|
107
|
+
new InProcessToolSource('other', [
|
|
108
|
+
{ name: 'unrelated_tool', description: '', parameters: {}, handler: async () => ({}) },
|
|
109
|
+
]),
|
|
110
|
+
]);
|
|
111
|
+
const provider = scriptedProvider([{ text: 'answered by the model' }]);
|
|
112
|
+
const funnel = new Funnel({ provider, tools: bare });
|
|
113
|
+
|
|
114
|
+
const fast = await funnel.runTurn("what's my balance?");
|
|
115
|
+
expect(fast.tier).toBe('agentic'); // no get_balances → no T0
|
|
116
|
+
|
|
117
|
+
const pay = await funnel.runTurn('pay bob 3 eur');
|
|
118
|
+
expect(pay.tier).toBe('agentic'); // no send_payment → no T2
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── T1: agentic ───────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe('Funnel — T1 agentic', () => {
|
|
125
|
+
it('routes unmatched queries to the engine and returns tool calls', async () => {
|
|
126
|
+
const provider = scriptedProvider([
|
|
127
|
+
{ toolCalls: [{ name: 'get_price', arguments: {} }] },
|
|
128
|
+
{ text: 'BTC is at $100,000.' },
|
|
129
|
+
]);
|
|
130
|
+
const onToolCall = vi.fn();
|
|
131
|
+
const funnel = new Funnel({ provider, tools: stubTools() });
|
|
132
|
+
|
|
133
|
+
const res = await funnel.runTurn('should I buy more sats this week?', { onToolCall });
|
|
134
|
+
|
|
135
|
+
expect(res.tier).toBe('agentic');
|
|
136
|
+
expect(res.text).toBe('BTC is at $100,000.');
|
|
137
|
+
expect(res.toolCalls).toHaveLength(1);
|
|
138
|
+
expect(res.toolCalls?.[0]).toMatchObject({ name: 'get_price', result: { price_usd: 100000 } });
|
|
139
|
+
// requiresConfirmation enrichment is async (getDef) — let it flush.
|
|
140
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
141
|
+
expect(onToolCall).toHaveBeenCalledWith({ name: 'get_price', arguments: {} }, { requiresConfirmation: false });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('appends the persona and trims history per settings', async () => {
|
|
145
|
+
const provider = scriptedProvider([{ text: 'ok' }]);
|
|
146
|
+
const funnel = new Funnel({
|
|
147
|
+
provider,
|
|
148
|
+
tools: stubTools(),
|
|
149
|
+
getSettings: () => ({ persona: 'Speak like a pirate.', historyLength: 2 }),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const history = Array.from({ length: 6 }, (_, i) => ({ role: 'user' as const, content: `m${i}` }));
|
|
153
|
+
await funnel.runTurn('tell me something', { history });
|
|
154
|
+
|
|
155
|
+
const messages = provider.inputs[0].messages;
|
|
156
|
+
expect(messages[0].content).toContain(DEFAULT_WALLET_SYSTEM);
|
|
157
|
+
expect(messages[0].content).toContain('Speak like a pirate.');
|
|
158
|
+
// system + 2 kept history + the new user message
|
|
159
|
+
expect(messages).toHaveLength(4);
|
|
160
|
+
expect(messages[1].content).toBe('m4');
|
|
161
|
+
expect(messages[2].content).toBe('m5');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('scopes tools to the matched skill plus ambient tools', async () => {
|
|
165
|
+
const provider = scriptedProvider([{ text: 'ok' }]);
|
|
166
|
+
const funnel = new Funnel({ provider, tools: stubTools(), skills: [TEST_SKILL] });
|
|
167
|
+
|
|
168
|
+
await funnel.runTurn('how are my channels doing?');
|
|
169
|
+
|
|
170
|
+
const names = provider.inputs[0].tools.map((t) => t.name);
|
|
171
|
+
expect(names).toContain('list_channels');
|
|
172
|
+
expect(names).toContain('remember');
|
|
173
|
+
expect(names).toContain('search_knowledge');
|
|
174
|
+
expect(names).not.toContain('send_payment'); // narrowed out by the skill
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('hides ambient tools when their toggles are off (no skill matched)', async () => {
|
|
178
|
+
const provider = scriptedProvider([{ text: 'ok' }]);
|
|
179
|
+
const funnel = new Funnel({
|
|
180
|
+
provider,
|
|
181
|
+
tools: stubTools(),
|
|
182
|
+
getSettings: () => ({ memoryEnabled: false, ragEnabled: false }),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await funnel.runTurn('tell me something interesting');
|
|
186
|
+
|
|
187
|
+
const names = provider.inputs[0].tools.map((t) => t.name);
|
|
188
|
+
expect(names).not.toContain('remember');
|
|
189
|
+
expect(names).not.toContain('recall');
|
|
190
|
+
expect(names).not.toContain('search_knowledge');
|
|
191
|
+
expect(names).toContain('get_balances'); // everything else stays
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('listSkills honors disabledSkills and tracks settings changes', () => {
|
|
195
|
+
let disabled: string[] = [];
|
|
196
|
+
const funnel = new Funnel({
|
|
197
|
+
provider: scriptedProvider([]),
|
|
198
|
+
tools: stubTools(),
|
|
199
|
+
skills: [TEST_SKILL],
|
|
200
|
+
getSettings: () => ({ disabledSkills: disabled }),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(funnel.listSkills().map((s) => s.name)).toEqual(['channels']);
|
|
204
|
+
disabled = ['channels'];
|
|
205
|
+
expect(funnel.listSkills()).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
});
|