@kaleidorg/mind 0.1.0 → 0.2.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 (135) hide show
  1. package/dist/capabilities.d.ts +34 -0
  2. package/dist/capabilities.d.ts.map +1 -0
  3. package/dist/capabilities.js +34 -0
  4. package/dist/capabilities.js.map +1 -0
  5. package/dist/context/budget.d.ts +29 -0
  6. package/dist/context/budget.d.ts.map +1 -0
  7. package/dist/context/budget.js +36 -0
  8. package/dist/context/budget.js.map +1 -0
  9. package/dist/context/builder.d.ts +39 -0
  10. package/dist/context/builder.d.ts.map +1 -0
  11. package/dist/context/builder.js +77 -0
  12. package/dist/context/builder.js.map +1 -0
  13. package/dist/fastpath/fastpath.d.ts +38 -0
  14. package/dist/fastpath/fastpath.d.ts.map +1 -0
  15. package/dist/fastpath/fastpath.js +52 -0
  16. package/dist/fastpath/fastpath.js.map +1 -0
  17. package/dist/funnel.d.ts +111 -0
  18. package/dist/funnel.d.ts.map +1 -0
  19. package/dist/funnel.js +175 -0
  20. package/dist/funnel.js.map +1 -0
  21. package/dist/index.d.ts +36 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +28 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/knowledge/bitcoin-copilot.d.ts +11 -0
  26. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -0
  27. package/dist/knowledge/bitcoin-copilot.js +155 -0
  28. package/dist/knowledge/bitcoin-copilot.js.map +1 -0
  29. package/dist/knowledge/merchants.d.ts +24 -0
  30. package/dist/knowledge/merchants.d.ts.map +1 -0
  31. package/dist/knowledge/merchants.js +34 -0
  32. package/dist/knowledge/merchants.js.map +1 -0
  33. package/dist/knowledge/wallet.d.ts +34 -0
  34. package/dist/knowledge/wallet.d.ts.map +1 -0
  35. package/dist/knowledge/wallet.js +63 -0
  36. package/dist/knowledge/wallet.js.map +1 -0
  37. package/dist/memory/store.d.ts +34 -0
  38. package/dist/memory/store.d.ts.map +1 -0
  39. package/dist/memory/store.js +103 -0
  40. package/dist/memory/store.js.map +1 -0
  41. package/dist/memory/tool.d.ts +9 -0
  42. package/dist/memory/tool.d.ts.map +1 -0
  43. package/dist/memory/tool.js +70 -0
  44. package/dist/memory/tool.js.map +1 -0
  45. package/dist/memory/types.d.ts +56 -0
  46. package/dist/memory/types.d.ts.map +1 -0
  47. package/dist/memory/types.js +14 -0
  48. package/dist/memory/types.js.map +1 -0
  49. package/dist/rag/retriever.d.ts +30 -0
  50. package/dist/rag/retriever.d.ts.map +1 -0
  51. package/dist/rag/retriever.js +72 -0
  52. package/dist/rag/retriever.js.map +1 -0
  53. package/dist/rag/tool.d.ts +15 -0
  54. package/dist/rag/tool.d.ts.map +1 -0
  55. package/dist/rag/tool.js +42 -0
  56. package/dist/rag/tool.js.map +1 -0
  57. package/dist/rag/types.d.ts +44 -0
  58. package/dist/rag/types.d.ts.map +1 -0
  59. package/dist/rag/types.js +11 -0
  60. package/dist/rag/types.js.map +1 -0
  61. package/dist/rag/vector-store.d.ts +23 -0
  62. package/dist/rag/vector-store.d.ts.map +1 -0
  63. package/dist/rag/vector-store.js +72 -0
  64. package/dist/rag/vector-store.js.map +1 -0
  65. package/dist/recipe/asset-send.d.ts +15 -0
  66. package/dist/recipe/asset-send.d.ts.map +1 -0
  67. package/dist/recipe/asset-send.js +83 -0
  68. package/dist/recipe/asset-send.js.map +1 -0
  69. package/dist/recipe/payments.d.ts +15 -0
  70. package/dist/recipe/payments.d.ts.map +1 -0
  71. package/dist/recipe/payments.js +119 -0
  72. package/dist/recipe/payments.js.map +1 -0
  73. package/dist/recipe/receive.d.ts +14 -0
  74. package/dist/recipe/receive.d.ts.map +1 -0
  75. package/dist/recipe/receive.js +109 -0
  76. package/dist/recipe/receive.js.map +1 -0
  77. package/dist/recipe/runner.d.ts +42 -0
  78. package/dist/recipe/runner.d.ts.map +1 -0
  79. package/dist/recipe/runner.js +94 -0
  80. package/dist/recipe/runner.js.map +1 -0
  81. package/dist/recipe/swap.d.ts +16 -0
  82. package/dist/recipe/swap.d.ts.map +1 -0
  83. package/dist/recipe/swap.js +73 -0
  84. package/dist/recipe/swap.js.map +1 -0
  85. package/dist/recipe/types.d.ts +71 -0
  86. package/dist/recipe/types.d.ts.map +1 -0
  87. package/dist/recipe/types.js +13 -0
  88. package/dist/recipe/types.js.map +1 -0
  89. package/dist/tools/cli.d.ts +43 -0
  90. package/dist/tools/cli.d.ts.map +1 -0
  91. package/dist/tools/cli.js +61 -0
  92. package/dist/tools/cli.js.map +1 -0
  93. package/dist/tools/mcp.d.ts +3 -2
  94. package/dist/tools/mcp.d.ts.map +1 -1
  95. package/dist/tools/mcp.js +3 -2
  96. package/dist/tools/mcp.js.map +1 -1
  97. package/dist/wallet/contract.d.ts +57 -0
  98. package/dist/wallet/contract.d.ts.map +1 -0
  99. package/dist/wallet/contract.js +113 -0
  100. package/dist/wallet/contract.js.map +1 -0
  101. package/package.json +9 -5
  102. package/src/capabilities.ts +67 -0
  103. package/src/context/budget.ts +46 -0
  104. package/src/context/builder.ts +100 -0
  105. package/src/context/context.test.ts +83 -0
  106. package/src/fastpath/fastpath.test.ts +34 -0
  107. package/src/fastpath/fastpath.ts +70 -0
  108. package/src/funnel.test.ts +207 -0
  109. package/src/funnel.ts +260 -0
  110. package/src/index.ts +85 -0
  111. package/src/knowledge/bitcoin-copilot.ts +177 -0
  112. package/src/knowledge/knowledge.test.ts +63 -0
  113. package/src/knowledge/merchants.ts +49 -0
  114. package/src/knowledge/wallet.ts +84 -0
  115. package/src/memory/memory.test.ts +85 -0
  116. package/src/memory/store.ts +129 -0
  117. package/src/memory/tool.ts +76 -0
  118. package/src/memory/types.ts +63 -0
  119. package/src/rag/rag.test.ts +85 -0
  120. package/src/rag/retriever.ts +94 -0
  121. package/src/rag/tool.ts +55 -0
  122. package/src/rag/types.ts +49 -0
  123. package/src/rag/vector-store.ts +78 -0
  124. package/src/recipe/asset-send.ts +79 -0
  125. package/src/recipe/payments.ts +116 -0
  126. package/src/recipe/receive.ts +98 -0
  127. package/src/recipe/recipe.test.ts +193 -0
  128. package/src/recipe/runner.ts +122 -0
  129. package/src/recipe/swap.ts +74 -0
  130. package/src/recipe/types.ts +76 -0
  131. package/src/tools/cli.test.ts +53 -0
  132. package/src/tools/cli.ts +98 -0
  133. package/src/tools/mcp.ts +3 -2
  134. package/src/wallet/contract.test.ts +89 -0
  135. package/src/wallet/contract.ts +157 -0
@@ -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
+ });
package/src/funnel.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Funnel — the tiered agent loop (T0 fast-path → T2 recipe → T1 agentic).
3
+ *
4
+ * This is the mobile-optimized funnel from the roadmap, lifted out of the
5
+ * hosts so every surface (rate chat + voice, desktop provider, agent) runs
6
+ * the SAME routing:
7
+ *
8
+ * request
9
+ * ├─ T0 deterministic fast-path (no LLM) balance / address / price
10
+ * ├─ T2 recipe multi-step (~1 inference) "pay bob 3 EUR"
11
+ * └─ T1 skill-scoped agentic loop everything else
12
+ *
13
+ * Hosts inject the provider, the tool registry, and a `getSettings` closure
14
+ * read fresh each turn — so user-tunable settings (persona, history length,
15
+ * memory/RAG toggles, disabled skills) never require rebuilding the funnel
16
+ * or dropping host state like an embedded RAG index.
17
+ *
18
+ * Safety is unchanged from the Engine: spend tools are confirmation-gated by
19
+ * the contract; with no `onConfirm` the gate fails closed.
20
+ */
21
+
22
+ import { Engine } from './engine.js';
23
+ import type { ToolRegistry } from './tools/registry.js';
24
+ import { FastPath, WALLET_FAST_INTENTS } from './fastpath/fastpath.js';
25
+ import type { FastIntent } from './fastpath/fastpath.js';
26
+ import { RecipeRegistry, runRecipe } from './recipe/runner.js';
27
+ import { paymentsRecipe } from './recipe/payments.js';
28
+ import { receiveRecipe } from './recipe/receive.js';
29
+ import { assetSendRecipe } from './recipe/asset-send.js';
30
+ import type { Recipe } from './recipe/types.js';
31
+ import { SkillRegistry } from './skills/registry.js';
32
+ import type { Skill } from './skills/types.js';
33
+ import type { LLMProvider } from './providers/types.js';
34
+ import type { ConfirmDecision, Message, ToolResult } from './types.js';
35
+
36
+ /** Base system prompt for the wallet assistant. Hosts may override. */
37
+ export const DEFAULT_WALLET_SYSTEM =
38
+ 'You are KaleidoSwap, a concise, privacy-first assistant running inside a ' +
39
+ 'non-custodial Bitcoin, Lightning and RGB wallet. Use the provided tools to ' +
40
+ 'take actions: pay invoices and contacts, create invoices, check balances. ' +
41
+ 'Never invent a balance, address, amount or result — always call the ' +
42
+ 'relevant tool and report what it returns. All BTC amounts are in satoshis. ' +
43
+ 'Keep replies short and friendly.';
44
+
45
+ /** Tools that stay available even when a skill narrows the set. */
46
+ const AMBIENT_MEMORY = ['remember', 'recall'];
47
+ const AMBIENT_RAG = ['search_knowledge'];
48
+
49
+ const DEFAULT_HISTORY = 8;
50
+
51
+ /** Per-user agent settings, read fresh each turn via `getSettings`. */
52
+ export interface FunnelSettings {
53
+ /** Extra instructions appended to the system prompt. */
54
+ persona?: string;
55
+ /** Most recent history messages to keep in the prompt (default 8). */
56
+ historyLength?: number;
57
+ /** Expose the remember/recall tools (default true). */
58
+ memoryEnabled?: boolean;
59
+ /** Expose the search_knowledge tool (default true). */
60
+ ragEnabled?: boolean;
61
+ /** Skill names the user turned off. */
62
+ disabledSkills?: string[];
63
+ }
64
+
65
+ export interface FunnelCallbacks {
66
+ history?: Message[];
67
+ /** The live requestId of the agentic run (so a stop button can cancel it). */
68
+ onStart?: (requestId: string) => void;
69
+ onToken?: (token: string, turn: number) => void;
70
+ /** A recipe step is executing (deterministic tier). */
71
+ onStep?: (name: string) => void;
72
+ /** The model requested a tool (agentic tier), before it executes. */
73
+ onToolCall?: (
74
+ call: { name: string; arguments: Record<string, unknown> },
75
+ info: { requiresConfirmation: boolean },
76
+ ) => void;
77
+ onConfirm?: (call: { name: string; arguments: Record<string, unknown> }) => Promise<ConfirmDecision>;
78
+ }
79
+
80
+ export interface FunnelResult {
81
+ text: string;
82
+ tier: 'fast' | 'recipe' | 'agentic';
83
+ /** Fast tier only: the matched intent + raw tool result (e.g. for a balance card). */
84
+ intent?: string;
85
+ data?: unknown;
86
+ /** Agentic tier only: executed tool calls + reasoning turns. */
87
+ toolCalls?: ToolResult[];
88
+ turns?: number;
89
+ }
90
+
91
+ export interface FunnelOptions {
92
+ provider: LLMProvider;
93
+ /** ALL tool sources merged — wallet, memory, RAG, merchant, L402, … */
94
+ tools: ToolRegistry;
95
+ /** Skills available to the agentic tier (disabled ones filtered per turn). */
96
+ skills?: Skill[];
97
+ /** Recipes for the T2 tier. Default: asset-send, payments, receive. */
98
+ recipes?: Recipe[];
99
+ /** Fast-path intents for the T0 tier. Default: WALLET_FAST_INTENTS. */
100
+ fastIntents?: FastIntent[];
101
+ /** Base system prompt (persona is appended). Default: DEFAULT_WALLET_SYSTEM. */
102
+ system?: string;
103
+ /** Max reasoning↔tool rounds in the agentic tier. Default 5. */
104
+ maxTurns?: number;
105
+ /** User settings, read fresh each turn. */
106
+ getSettings?: () => FunnelSettings;
107
+ /** Render a fast-path tool result as user-facing text. Default: built-in. */
108
+ renderFast?: (intent: string, result: unknown) => string;
109
+ /** Diagnostics sink (tier routing, tool calls). Default: silent. */
110
+ log?: (message: string) => void;
111
+ }
112
+
113
+ function defaultRenderFast(intent: string, r: any): string {
114
+ if (intent === 'balance') {
115
+ const sats = Number(r?.total_sats ?? 0);
116
+ const n = r?.layers?.length ?? 0;
117
+ return `You have ${sats.toLocaleString()} sats${n > 1 ? ` across ${n} layers` : ''}.`;
118
+ }
119
+ if (intent === 'address') {
120
+ return r?.address ? `Here's your receive address:\n\n\`${r.address}\`` : 'No address available right now.';
121
+ }
122
+ return `Bitcoin is $${Number(r?.price_usd ?? 0).toLocaleString()}.`;
123
+ }
124
+
125
+ export class Funnel {
126
+ private readonly provider: LLMProvider;
127
+ private readonly registry: ToolRegistry;
128
+ private readonly engine: Engine;
129
+ private readonly fastPath: FastPath;
130
+ private readonly recipes: RecipeRegistry;
131
+ private readonly allSkills: Skill[];
132
+ private readonly system: string;
133
+ private readonly getSettings: () => FunnelSettings;
134
+ private readonly renderFast: (intent: string, result: unknown) => string;
135
+ private readonly log: (message: string) => void;
136
+
137
+ /** Skill registry, rebuilt only when the disabled-skills set changes. */
138
+ private skillsCache: { key: string; reg: SkillRegistry } | null = null;
139
+
140
+ constructor(opts: FunnelOptions) {
141
+ this.provider = opts.provider;
142
+ this.registry = opts.tools;
143
+ this.engine = new Engine({
144
+ provider: opts.provider,
145
+ tools: opts.tools,
146
+ defaultMaxTurns: opts.maxTurns ?? 5,
147
+ });
148
+ this.fastPath = new FastPath(opts.fastIntents ?? WALLET_FAST_INTENTS);
149
+ this.recipes = new RecipeRegistry(opts.recipes ?? [assetSendRecipe, paymentsRecipe, receiveRecipe]);
150
+ this.allSkills = opts.skills ?? [];
151
+ this.system = opts.system ?? DEFAULT_WALLET_SYSTEM;
152
+ this.getSettings = opts.getSettings ?? (() => ({}));
153
+ this.renderFast = opts.renderFast ?? defaultRenderFast;
154
+ this.log = opts.log ?? (() => {});
155
+ }
156
+
157
+ /** Skills currently enabled (e.g. for a skills sheet). */
158
+ listSkills(): Skill[] {
159
+ return this.skillsFor(this.getSettings().disabledSkills).list();
160
+ }
161
+
162
+ private skillsFor(disabled: string[] = []): SkillRegistry {
163
+ const key = [...disabled].sort().join(',');
164
+ if (this.skillsCache?.key !== key) {
165
+ this.skillsCache = {
166
+ key,
167
+ reg: new SkillRegistry(this.allSkills.filter((s) => !disabled.includes(s.name))),
168
+ };
169
+ }
170
+ return this.skillsCache.reg;
171
+ }
172
+
173
+ async runTurn(text: string, cbs: FunnelCallbacks = {}): Promise<FunnelResult> {
174
+ const settings = this.getSettings();
175
+
176
+ // ── T0: deterministic fast-path (no LLM) ──
177
+ // Only fires when the host's registry actually implements the intent's
178
+ // tool — a partial tool surface (e.g. desktop without the core aggregate
179
+ // helpers) falls through to the agentic tier instead of erroring.
180
+ const fast = this.fastPath.select(text);
181
+ if (fast && (await this.registry.getDef(fast.tool))) {
182
+ this.log(`tier=fast-path → ${fast.tool}`);
183
+ const r = await this.registry.execute(fast.tool, fast.args);
184
+ return { text: this.renderFast(fast.intent.name, r), tier: 'fast', intent: fast.intent.name, data: r };
185
+ }
186
+
187
+ // ── T2: recipe multi-step — fires only when the recipe is confident given
188
+ // its extracted slots (payments need a recipient; receive always fires),
189
+ // and the registry implements the recipe's final action.
190
+ const recipe = this.recipes.select(text);
191
+ const slots = recipe?.extract?.(text) ?? null;
192
+ const fires =
193
+ !!recipe &&
194
+ !!slots &&
195
+ (recipe.confident ? recipe.confident(slots) : Object.keys(slots).length > 0) &&
196
+ !!(await this.registry.getDef(recipe.final.tool));
197
+ if (recipe && fires) {
198
+ this.log(`tier=recipe:${recipe.name} slots=${JSON.stringify(slots)}`);
199
+ const res = await runRecipe(recipe, text, {
200
+ provider: this.provider,
201
+ tools: this.registry,
202
+ onConfirm: cbs.onConfirm,
203
+ onStep: (name) => {
204
+ this.log(`step ${name}`);
205
+ cbs.onStep?.(name);
206
+ },
207
+ });
208
+ return { text: res.text, tier: 'recipe' };
209
+ }
210
+
211
+ // ── T1: skill-scoped agentic loop ──
212
+ const skills = this.skillsFor(settings.disabledSkills);
213
+ const skill = skills.select(text);
214
+ const base = settings.persona ? `${this.system}\n\n## Your persona\n${settings.persona}` : this.system;
215
+ const { system, allowedTools } = skills.compose(base, skill);
216
+
217
+ // Ambient tools stay available even when a skill narrows the set — gated
218
+ // by the user's memory/knowledge toggles (default on).
219
+ const memoryOn = settings.memoryEnabled !== false;
220
+ const ragOn = settings.ragEnabled !== false;
221
+ const ambient = [...(memoryOn ? AMBIENT_MEMORY : []), ...(ragOn ? AMBIENT_RAG : [])];
222
+ const disabledAmbient = [...(memoryOn ? [] : AMBIENT_MEMORY), ...(ragOn ? [] : AMBIENT_RAG)];
223
+ let scoped: string[] | undefined;
224
+ if (allowedTools) {
225
+ scoped = [...new Set([...allowedTools, ...ambient])];
226
+ } else if (disabledAmbient.length) {
227
+ // No skill matched but a toggle is off: expose everything except the
228
+ // disabled ambient tools (the sources stay mounted — no rebuild).
229
+ const all = (await this.registry.listTools()).map((t) => t.name);
230
+ scoped = all.filter((n) => !disabledAmbient.includes(n));
231
+ }
232
+
233
+ // Trim history so the prompt (system + skill + tools + history) stays
234
+ // within the small on-device model's context window.
235
+ const keep = settings.historyLength ?? DEFAULT_HISTORY;
236
+ const history = (cbs.history ?? []).slice(-keep);
237
+ const messages: Message[] = [
238
+ { role: 'system', content: system },
239
+ ...history,
240
+ { role: 'user', content: text },
241
+ ];
242
+
243
+ this.log(`tier=agentic skill=${skill?.name ?? 'none'} tools=[${(scoped ?? ['all']).join(',')}]`);
244
+ const res = await this.engine.runAgentic(messages, {
245
+ allowedTools: scoped,
246
+ onStart: (requestId) => cbs.onStart?.(requestId),
247
+ onToken: cbs.onToken,
248
+ onToolCall: (call) => {
249
+ this.log(`tool ${call.name} ${JSON.stringify(call.arguments)}`);
250
+ // getDef is async; fire-and-forget so the loop is never blocked on UI.
251
+ void this.registry
252
+ .getDef(call.name)
253
+ .then((def) => cbs.onToolCall?.(call, { requiresConfirmation: !!def?.requiresConfirmation }))
254
+ .catch(() => cbs.onToolCall?.(call, { requiresConfirmation: false }));
255
+ },
256
+ onConfirm: cbs.onConfirm,
257
+ });
258
+ return { text: res.text ?? '', tier: 'agentic', toolCalls: res.toolCalls, turns: res.turns };
259
+ }
260
+ }
package/src/index.ts CHANGED
@@ -29,10 +29,95 @@ export {
29
29
  bolt11AmountSats,
30
30
  } from './tools/l402.js';
31
31
  export type { L402Options, L402PayResult } from './tools/l402.js';
32
+ export { createCliToolSource, isAllowed } from './tools/cli.js';
33
+ export type { CliToolOptions, CommandRunner, CommandResult } from './tools/cli.js';
34
+
35
+ // ── Multi-L2 wallet tool contract (single source of truth) ─────────────────
36
+ export {
37
+ WALLET_TOOLS,
38
+ WALLET_LAYERS,
39
+ SPEND_TOOLS,
40
+ isSpendTool,
41
+ getWalletTool,
42
+ walletTools,
43
+ toToolDefs,
44
+ bindWalletTools,
45
+ } from './wallet/contract.js';
46
+ export type {
47
+ WalletLayer,
48
+ WalletToolDef,
49
+ WalletHandler,
50
+ BindWalletOptions,
51
+ } from './wallet/contract.js';
52
+
53
+ // ── Recipes (mobile multi-step: "recipes, not planning") ───────────────────
54
+ export { runRecipe, extractSlots, RecipeRegistry } from './recipe/runner.js';
55
+ export type { RunRecipeOptions } from './recipe/runner.js';
56
+ export { paymentsRecipe, extractPayment } from './recipe/payments.js';
57
+ export { swapRecipe, extractSwap } from './recipe/swap.js';
58
+ export { receiveRecipe, extractReceive } from './recipe/receive.js';
59
+ export { assetSendRecipe, extractAssetSend } from './recipe/asset-send.js';
60
+ export type { Recipe, RecipeStep, RecipeSlot, RecipeContext, RecipeResult, RecipeStatus } from './recipe/types.js';
61
+
62
+ // ── Tier-0 deterministic fast-path (no LLM) ────────────────────────────────
63
+ export { FastPath, WALLET_FAST_INTENTS } from './fastpath/fastpath.js';
64
+ export type { FastIntent, FastHit } from './fastpath/fastpath.js';
65
+
66
+ // ── Memory (soul + recall) ───────────────────────────────────────────────
67
+ export { InMemoryMemoryStore } from './memory/store.js';
68
+ export type { MemoryStoreOptions } from './memory/store.js';
69
+ export { createMemoryToolSource } from './memory/tool.js';
70
+ export type {
71
+ AgentProfile,
72
+ MemoryItem,
73
+ MemoryKind,
74
+ MemoryQuery,
75
+ MemoryStore,
76
+ MemoryIO,
77
+ NewMemory,
78
+ } from './memory/types.js';
79
+
80
+ // ── RAG ──────────────────────────────────────────────────────────────────
81
+ export { Retriever, chunkText } from './rag/retriever.js';
82
+ export type { RetrieverOptions } from './rag/retriever.js';
83
+ export { InMemoryVectorStore, cosineSimilarity } from './rag/vector-store.js';
84
+ export { createRagToolSource } from './rag/tool.js';
85
+ export type { RagToolOptions } from './rag/tool.js';
86
+ export type {
87
+ EmbeddingProvider,
88
+ Chunk,
89
+ RetrievedChunk,
90
+ RagDocument,
91
+ VectorStore,
92
+ VectorStoreIO,
93
+ } from './rag/types.js';
94
+
95
+ // ── Context assembly + hardware budget ─────────────────────────────────────
96
+ export { ContextBuilder } from './context/builder.js';
97
+ export type { ContextBuilderOptions, BuildInput } from './context/builder.js';
98
+ export {
99
+ estimateTokens,
100
+ clampToTokens,
101
+ contextBudgetTokens,
102
+ } from './context/budget.js';
103
+ export type { BudgetReserves } from './context/budget.js';
104
+ export { capabilityProfile } from './capabilities.js';
105
+ export type { CapabilityInput, MindCapabilities } from './capabilities.js';
106
+
107
+ // ── Knowledge packs + corpus adapters (for RAG) ────────────────────────────
108
+ export { BITCOIN_COPILOT_DOCS } from './knowledge/bitcoin-copilot.js';
109
+ export { walletHistoryToDocuments, contactsToDocuments } from './knowledge/wallet.js';
110
+ export type { WalletTx, Contact } from './knowledge/wallet.js';
111
+ export { merchantsToDocuments } from './knowledge/merchants.js';
112
+ export type { Merchant } from './knowledge/merchants.js';
32
113
 
33
114
  export { Engine } from './engine.js';
34
115
  export type { EngineOptions, AgenticOptions, AgenticResult } from './engine.js';
35
116
 
117
+ // ── Funnel (T0 fast-path → T2 recipe → T1 agentic — the tiered agent) ───────
118
+ export { Funnel, DEFAULT_WALLET_SYSTEM } from './funnel.js';
119
+ export type { FunnelOptions, FunnelSettings, FunnelCallbacks, FunnelResult } from './funnel.js';
120
+
36
121
  export {
37
122
  SkillRegistry,
38
123
  parseSkill,