@kaleidorg/mind 0.0.1 → 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 (191) 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/engine.d.ts +9 -0
  14. package/dist/engine.d.ts.map +1 -1
  15. package/dist/engine.js +18 -2
  16. package/dist/engine.js.map +1 -1
  17. package/dist/fastpath/fastpath.d.ts +38 -0
  18. package/dist/fastpath/fastpath.d.ts.map +1 -0
  19. package/dist/fastpath/fastpath.js +52 -0
  20. package/dist/fastpath/fastpath.js.map +1 -0
  21. package/dist/funnel.d.ts +111 -0
  22. package/dist/funnel.d.ts.map +1 -0
  23. package/dist/funnel.js +175 -0
  24. package/dist/funnel.js.map +1 -0
  25. package/dist/index.d.ts +43 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +32 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/knowledge/bitcoin-copilot.d.ts +11 -0
  30. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -0
  31. package/dist/knowledge/bitcoin-copilot.js +155 -0
  32. package/dist/knowledge/bitcoin-copilot.js.map +1 -0
  33. package/dist/knowledge/merchants.d.ts +24 -0
  34. package/dist/knowledge/merchants.d.ts.map +1 -0
  35. package/dist/knowledge/merchants.js +34 -0
  36. package/dist/knowledge/merchants.js.map +1 -0
  37. package/dist/knowledge/wallet.d.ts +34 -0
  38. package/dist/knowledge/wallet.d.ts.map +1 -0
  39. package/dist/knowledge/wallet.js +63 -0
  40. package/dist/knowledge/wallet.js.map +1 -0
  41. package/dist/memory/store.d.ts +34 -0
  42. package/dist/memory/store.d.ts.map +1 -0
  43. package/dist/memory/store.js +103 -0
  44. package/dist/memory/store.js.map +1 -0
  45. package/dist/memory/tool.d.ts +9 -0
  46. package/dist/memory/tool.d.ts.map +1 -0
  47. package/dist/memory/tool.js +70 -0
  48. package/dist/memory/tool.js.map +1 -0
  49. package/dist/memory/types.d.ts +56 -0
  50. package/dist/memory/types.d.ts.map +1 -0
  51. package/dist/memory/types.js +14 -0
  52. package/dist/memory/types.js.map +1 -0
  53. package/dist/rag/retriever.d.ts +30 -0
  54. package/dist/rag/retriever.d.ts.map +1 -0
  55. package/dist/rag/retriever.js +72 -0
  56. package/dist/rag/retriever.js.map +1 -0
  57. package/dist/rag/tool.d.ts +15 -0
  58. package/dist/rag/tool.d.ts.map +1 -0
  59. package/dist/rag/tool.js +42 -0
  60. package/dist/rag/tool.js.map +1 -0
  61. package/dist/rag/types.d.ts +44 -0
  62. package/dist/rag/types.d.ts.map +1 -0
  63. package/dist/rag/types.js +11 -0
  64. package/dist/rag/types.js.map +1 -0
  65. package/dist/rag/vector-store.d.ts +23 -0
  66. package/dist/rag/vector-store.d.ts.map +1 -0
  67. package/dist/rag/vector-store.js +72 -0
  68. package/dist/rag/vector-store.js.map +1 -0
  69. package/dist/recipe/asset-send.d.ts +15 -0
  70. package/dist/recipe/asset-send.d.ts.map +1 -0
  71. package/dist/recipe/asset-send.js +83 -0
  72. package/dist/recipe/asset-send.js.map +1 -0
  73. package/dist/recipe/payments.d.ts +15 -0
  74. package/dist/recipe/payments.d.ts.map +1 -0
  75. package/dist/recipe/payments.js +119 -0
  76. package/dist/recipe/payments.js.map +1 -0
  77. package/dist/recipe/receive.d.ts +14 -0
  78. package/dist/recipe/receive.d.ts.map +1 -0
  79. package/dist/recipe/receive.js +109 -0
  80. package/dist/recipe/receive.js.map +1 -0
  81. package/dist/recipe/runner.d.ts +42 -0
  82. package/dist/recipe/runner.d.ts.map +1 -0
  83. package/dist/recipe/runner.js +94 -0
  84. package/dist/recipe/runner.js.map +1 -0
  85. package/dist/recipe/swap.d.ts +16 -0
  86. package/dist/recipe/swap.d.ts.map +1 -0
  87. package/dist/recipe/swap.js +73 -0
  88. package/dist/recipe/swap.js.map +1 -0
  89. package/dist/recipe/types.d.ts +71 -0
  90. package/dist/recipe/types.d.ts.map +1 -0
  91. package/dist/recipe/types.js +13 -0
  92. package/dist/recipe/types.js.map +1 -0
  93. package/dist/skills/bundle.d.ts +30 -0
  94. package/dist/skills/bundle.d.ts.map +1 -0
  95. package/dist/skills/bundle.js +24 -0
  96. package/dist/skills/bundle.js.map +1 -0
  97. package/dist/skills/loader.d.ts +33 -0
  98. package/dist/skills/loader.d.ts.map +1 -0
  99. package/dist/skills/loader.js +59 -0
  100. package/dist/skills/loader.js.map +1 -0
  101. package/dist/skills/reference-source.d.ts +18 -0
  102. package/dist/skills/reference-source.d.ts.map +1 -0
  103. package/dist/skills/reference-source.js +53 -0
  104. package/dist/skills/reference-source.js.map +1 -0
  105. package/dist/skills/registry.d.ts +41 -0
  106. package/dist/skills/registry.d.ts.map +1 -0
  107. package/dist/skills/registry.js +167 -0
  108. package/dist/skills/registry.js.map +1 -0
  109. package/dist/skills/types.d.ts +53 -0
  110. package/dist/skills/types.d.ts.map +1 -0
  111. package/dist/skills/types.js +18 -0
  112. package/dist/skills/types.js.map +1 -0
  113. package/dist/tools/cli.d.ts +43 -0
  114. package/dist/tools/cli.d.ts.map +1 -0
  115. package/dist/tools/cli.js +61 -0
  116. package/dist/tools/cli.js.map +1 -0
  117. package/dist/tools/l402.d.ts +47 -0
  118. package/dist/tools/l402.d.ts.map +1 -0
  119. package/dist/tools/l402.js +84 -0
  120. package/dist/tools/l402.js.map +1 -0
  121. package/dist/tools/mcp.d.ts +3 -2
  122. package/dist/tools/mcp.d.ts.map +1 -1
  123. package/dist/tools/mcp.js +3 -2
  124. package/dist/tools/mcp.js.map +1 -1
  125. package/dist/wallet/contract.d.ts +57 -0
  126. package/dist/wallet/contract.d.ts.map +1 -0
  127. package/dist/wallet/contract.js +113 -0
  128. package/dist/wallet/contract.js.map +1 -0
  129. package/package.json +16 -5
  130. package/scripts/bundle-skills.mjs +84 -0
  131. package/skills/README.md +74 -0
  132. package/skills/bitrefill/SKILL.md +66 -0
  133. package/skills/bitrefill/references/api.md +99 -0
  134. package/skills/bitrefill/references/browse.md +71 -0
  135. package/skills/bitrefill/references/capability-matrix.md +115 -0
  136. package/skills/bitrefill/references/cli-headless-auth.md +133 -0
  137. package/skills/bitrefill/references/cli.md +237 -0
  138. package/skills/bitrefill/references/host-openclaw.md +167 -0
  139. package/skills/bitrefill/references/mcp.md +150 -0
  140. package/skills/bitrefill/references/safeguards.md +138 -0
  141. package/skills/bitrefill/references/troubleshooting.md +182 -0
  142. package/skills/kaleido-trading/SKILL.md +31 -0
  143. package/skills/kaleido-wallet/SKILL.md +28 -0
  144. package/src/capabilities.ts +67 -0
  145. package/src/context/budget.ts +46 -0
  146. package/src/context/builder.ts +100 -0
  147. package/src/context/context.test.ts +83 -0
  148. package/src/engine.test.ts +204 -0
  149. package/src/engine.ts +27 -2
  150. package/src/fastpath/fastpath.test.ts +34 -0
  151. package/src/fastpath/fastpath.ts +70 -0
  152. package/src/funnel.test.ts +207 -0
  153. package/src/funnel.ts +260 -0
  154. package/src/index.ts +102 -0
  155. package/src/knowledge/bitcoin-copilot.ts +177 -0
  156. package/src/knowledge/knowledge.test.ts +63 -0
  157. package/src/knowledge/merchants.ts +49 -0
  158. package/src/knowledge/wallet.ts +84 -0
  159. package/src/memory/memory.test.ts +85 -0
  160. package/src/memory/store.ts +129 -0
  161. package/src/memory/tool.ts +76 -0
  162. package/src/memory/types.ts +63 -0
  163. package/src/rag/rag.test.ts +85 -0
  164. package/src/rag/retriever.ts +94 -0
  165. package/src/rag/tool.ts +55 -0
  166. package/src/rag/types.ts +49 -0
  167. package/src/rag/vector-store.ts +78 -0
  168. package/src/recipe/asset-send.ts +79 -0
  169. package/src/recipe/payments.ts +116 -0
  170. package/src/recipe/receive.ts +98 -0
  171. package/src/recipe/recipe.test.ts +193 -0
  172. package/src/recipe/runner.ts +122 -0
  173. package/src/recipe/swap.ts +74 -0
  174. package/src/recipe/types.ts +76 -0
  175. package/src/skills/bundle.ts +42 -0
  176. package/src/skills/loader.ts +63 -0
  177. package/src/skills/reference-source.ts +60 -0
  178. package/src/skills/registry.ts +183 -0
  179. package/src/skills/skills.test.ts +191 -0
  180. package/src/skills/types.ts +55 -0
  181. package/src/tools/cli.test.ts +53 -0
  182. package/src/tools/cli.ts +98 -0
  183. package/src/tools/l402.test.ts +113 -0
  184. package/src/tools/l402.ts +122 -0
  185. package/src/tools/mcp.ts +3 -2
  186. package/src/wallet/contract.test.ts +89 -0
  187. package/src/wallet/contract.ts +157 -0
  188. package/dist/providers/qvac.d.ts +0 -89
  189. package/dist/providers/qvac.d.ts.map +0 -1
  190. package/dist/providers/qvac.js +0 -150
  191. package/dist/providers/qvac.js.map +0 -1
@@ -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,83 @@
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', () => {
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
+ });
69
+
70
+ it('desktop / delegated: RAG on', () => {
71
+ const c = capabilityProfile({ modelCtxTokens: 8192, hasEmbeddings: true, delegated: true });
72
+ expect(c.rag).toBe(true);
73
+ expect(c.topKRag).toBeGreaterThan(0);
74
+ expect(c.semanticMemory).toBe(true);
75
+ });
76
+
77
+ it('no embeddings → no RAG, no semantic memory', () => {
78
+ const c = capabilityProfile({ ramBytes: 16 * 1024 ** 3, modelCtxTokens: 8192, hasEmbeddings: false });
79
+ expect(c.rag).toBe(false);
80
+ expect(c.semanticMemory).toBe(false);
81
+ expect(c.memory).toBe(true);
82
+ });
83
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Engine + tool-calling tests.
3
+ *
4
+ * These exercise the full agentic loop deterministically — no model, no
5
+ * device — using a scripted mock provider and mock tool sources. This is the
6
+ * fastest way to verify tools are selected, executed, fed back, and gated by
7
+ * confirmation, exactly as they would be with a real QVAC model.
8
+ *
9
+ * pnpm --filter @kaleidorg/mind test
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest';
13
+ import { Engine } from './engine.js';
14
+ import { ToolRegistry } from './tools/registry.js';
15
+ import { InProcessToolSource } from './tools/in-process.js';
16
+ import type { LLMProvider, TurnInput, TurnOutput } from './providers/types.js';
17
+ import type { ToolCall } from './types.js';
18
+
19
+ /**
20
+ * A scripted provider: each entry in `script` is one turn's output. When it
21
+ * returns tool calls, the engine executes them and calls the provider again
22
+ * for the next scripted turn.
23
+ */
24
+ function scriptedProvider(script: Array<{ text: string; toolCalls?: ToolCall[] }>): LLMProvider {
25
+ let turn = 0;
26
+ return {
27
+ name: 'scripted',
28
+ async runTurn(input: TurnInput): Promise<TurnOutput> {
29
+ const step = script[Math.min(turn, script.length - 1)];
30
+ turn += 1;
31
+ // stream the text so onToken paths are exercised
32
+ input.onToken?.(step.text);
33
+ return {
34
+ text: step.text,
35
+ rawContent: step.text,
36
+ toolCalls: step.toolCalls ?? [],
37
+ requestId: `req-${turn}`,
38
+ };
39
+ },
40
+ };
41
+ }
42
+
43
+ const balanceTool = {
44
+ name: 'get_balance',
45
+ description: 'Get the wallet balance in sats',
46
+ parameters: {},
47
+ handler: vi.fn(async () => ({ sats: 50_000 })),
48
+ };
49
+
50
+ const payTool = {
51
+ name: 'pay_invoice',
52
+ description: 'Pay a Lightning invoice',
53
+ parameters: {},
54
+ requiresConfirmation: true,
55
+ handler: vi.fn(async (args: Record<string, unknown>) => ({ paid: true, to: args.invoice })),
56
+ };
57
+
58
+ function freshTools() {
59
+ balanceTool.handler.mockClear();
60
+ payTool.handler.mockClear();
61
+ return new ToolRegistry([new InProcessToolSource('wallet', [balanceTool, payTool])]);
62
+ }
63
+
64
+ describe('Engine agentic loop', () => {
65
+ it('calls a read tool, feeds the result back, and returns a final answer', async () => {
66
+ const engine = new Engine({
67
+ provider: scriptedProvider([
68
+ { text: '', toolCalls: [{ name: 'get_balance', arguments: {} }] }, // turn 1: call tool
69
+ { text: 'You have 50,000 sats.' }, // turn 2: final answer (sees the result)
70
+ ]),
71
+ tools: freshTools(),
72
+ });
73
+
74
+ const res = await engine.runAgentic([{ role: 'user', content: "what's my balance?" }]);
75
+
76
+ expect(balanceTool.handler).toHaveBeenCalledTimes(1);
77
+ expect(res.text).toBe('You have 50,000 sats.');
78
+ expect(res.turns).toBe(2);
79
+ expect(res.toolCalls).toHaveLength(1);
80
+ expect(res.toolCalls[0].result).toEqual({ sats: 50_000 });
81
+ });
82
+
83
+ it('pauses money tools for confirmation and executes on approval', async () => {
84
+ const onConfirm = vi.fn(async () => ({ approved: true }));
85
+ const engine = new Engine({
86
+ provider: scriptedProvider([
87
+ { text: '', toolCalls: [{ name: 'pay_invoice', arguments: { invoice: 'lnbc1' } }] },
88
+ { text: 'Sent ✅' },
89
+ ]),
90
+ tools: freshTools(),
91
+ });
92
+
93
+ const res = await engine.runAgentic([{ role: 'user', content: 'pay lnbc1' }], { onConfirm });
94
+
95
+ expect(onConfirm).toHaveBeenCalledTimes(1);
96
+ expect(payTool.handler).toHaveBeenCalledTimes(1);
97
+ expect(res.text).toBe('Sent ✅');
98
+ expect(res.toolCalls[0].result).toEqual({ paid: true, to: 'lnbc1' });
99
+ });
100
+
101
+ it('does NOT execute a money tool when the user declines', async () => {
102
+ const onConfirm = vi.fn(async () => ({ approved: false, reason: 'cancelled' }));
103
+ const engine = new Engine({
104
+ provider: scriptedProvider([
105
+ { text: '', toolCalls: [{ name: 'pay_invoice', arguments: { invoice: 'lnbc1' } }] },
106
+ { text: 'Okay, cancelled.' },
107
+ ]),
108
+ tools: freshTools(),
109
+ });
110
+
111
+ const res = await engine.runAgentic([{ role: 'user', content: 'pay lnbc1' }], { onConfirm });
112
+
113
+ expect(payTool.handler).not.toHaveBeenCalled();
114
+ expect(res.toolCalls[0].result).toMatchObject({ declined: true, reason: 'cancelled' });
115
+ expect(res.text).toBe('Okay, cancelled.');
116
+ });
117
+
118
+ it('chains multiple tool calls across turns', async () => {
119
+ const engine = new Engine({
120
+ provider: scriptedProvider([
121
+ { text: '', toolCalls: [{ name: 'get_balance', arguments: {} }] },
122
+ { text: '', toolCalls: [{ name: 'pay_invoice', arguments: { invoice: 'lnbc2' } }] },
123
+ { text: 'Checked balance, then paid.' },
124
+ ]),
125
+ tools: freshTools(),
126
+ });
127
+
128
+ const res = await engine.runAgentic([{ role: 'user', content: 'check then pay' }], {
129
+ onConfirm: async () => ({ approved: true }),
130
+ });
131
+
132
+ expect(balanceTool.handler).toHaveBeenCalledTimes(1);
133
+ expect(payTool.handler).toHaveBeenCalledTimes(1);
134
+ expect(res.turns).toBe(3);
135
+ expect(res.toolCalls.map((c) => c.name)).toEqual(['get_balance', 'pay_invoice']);
136
+ });
137
+
138
+ it('stops at maxTurns if the model never stops calling tools', async () => {
139
+ const engine = new Engine({
140
+ provider: scriptedProvider([
141
+ { text: 'loop', toolCalls: [{ name: 'get_balance', arguments: {} }] }, // always calls a tool
142
+ ]),
143
+ tools: freshTools(),
144
+ defaultMaxTurns: 3,
145
+ });
146
+
147
+ const res = await engine.runAgentic([{ role: 'user', content: 'go' }]);
148
+
149
+ expect(res.turns).toBe(3);
150
+ expect(balanceTool.handler).toHaveBeenCalledTimes(3);
151
+ });
152
+
153
+ it('surfaces a tool error as a result instead of throwing', async () => {
154
+ const boom = {
155
+ name: 'boom',
156
+ description: 'throws',
157
+ parameters: {},
158
+ handler: vi.fn(async () => {
159
+ throw new Error('kaboom');
160
+ }),
161
+ };
162
+ const engine = new Engine({
163
+ provider: scriptedProvider([
164
+ { text: '', toolCalls: [{ name: 'boom', arguments: {} }] },
165
+ { text: 'handled the error' },
166
+ ]),
167
+ tools: new ToolRegistry([new InProcessToolSource('x', [boom])]),
168
+ });
169
+
170
+ const res = await engine.runAgentic([{ role: 'user', content: 'go' }]);
171
+ expect(res.toolCalls[0].result).toMatchObject({ error: 'kaboom' });
172
+ expect(res.text).toBe('handled the error');
173
+ });
174
+ });
175
+
176
+ describe('ToolRegistry', () => {
177
+ it('merges tools from multiple sources and routes calls to the owner', async () => {
178
+ const a = new InProcessToolSource('a', [
179
+ { name: 'one', description: '', parameters: {}, handler: async () => 'from-a' },
180
+ ]);
181
+ const b = new InProcessToolSource('b', [
182
+ { name: 'two', description: '', parameters: {}, handler: async () => 'from-b' },
183
+ ]);
184
+ const reg = new ToolRegistry([a, b]);
185
+
186
+ const tools = await reg.listTools();
187
+ expect(tools.map((t) => t.name).sort()).toEqual(['one', 'two']);
188
+ expect(await reg.execute('one', {})).toBe('from-a');
189
+ expect(await reg.execute('two', {})).toBe('from-b');
190
+ });
191
+
192
+ it('first source wins on a name clash', async () => {
193
+ const a = new InProcessToolSource('a', [
194
+ { name: 'dup', description: 'A', parameters: {}, handler: async () => 'a' },
195
+ ]);
196
+ const b = new InProcessToolSource('b', [
197
+ { name: 'dup', description: 'B', parameters: {}, handler: async () => 'b' },
198
+ ]);
199
+ const reg = new ToolRegistry([a, b]);
200
+ const tools = await reg.listTools();
201
+ expect(tools).toHaveLength(1);
202
+ expect(await reg.execute('dup', {})).toBe('a');
203
+ });
204
+ });
package/src/engine.ts CHANGED
@@ -37,6 +37,11 @@ export interface AgenticOptions {
37
37
  onToolCall?: (call: { name: string; arguments: Record<string, unknown> }, turn: number) => void;
38
38
  /** Human-in-the-loop gate for tools flagged requiresConfirmation. */
39
39
  onConfirm?: (call: { name: string; arguments: Record<string, unknown> }) => Promise<ConfirmDecision>;
40
+ /**
41
+ * Restrict the tools exposed to the model this run (progressive disclosure).
42
+ * Typically the active skill's tool list — see SkillRegistry.compose().
43
+ */
44
+ allowedTools?: string[];
40
45
  signal?: AbortSignal;
41
46
  }
42
47
 
@@ -45,6 +50,10 @@ export interface AgenticResult {
45
50
  turns: number;
46
51
  toolCalls: ToolResult[];
47
52
  requestId?: string;
53
+ /** Full conversation incl. assistant/tool frames — for logging / datasets. */
54
+ messages: Message[];
55
+ /** Wall-clock duration of the whole agentic run, ms. */
56
+ latencyMs: number;
48
57
  }
49
58
 
50
59
  export class Engine {
@@ -65,8 +74,13 @@ export class Engine {
65
74
  const hasSystem = messages.some((m) => m.role === 'system');
66
75
  const system = hasSystem ? undefined : this.defaultSystem;
67
76
 
77
+ const startedAt = Date.now();
68
78
  const history: Message[] = [...messages];
69
- const allTools = await this.registry.listTools();
79
+ const registryTools = await this.registry.listTools();
80
+ // Progressive disclosure: expose only the active skill's tools when set.
81
+ const allTools = opts.allowedTools
82
+ ? registryTools.filter((t) => opts.allowedTools!.includes(t.name))
83
+ : registryTools;
70
84
  const executed: ToolResult[] = [];
71
85
  let lastRequestId: string | undefined;
72
86
  let finalText = '';
@@ -124,7 +138,18 @@ export class Engine {
124
138
  }
125
139
  }
126
140
 
127
- return { text: finalText, turns, toolCalls: executed, requestId: lastRequestId };
141
+ // Append the final answer so the returned conversation is complete (the
142
+ // loop breaks before pushing the no-tool-call turn).
143
+ if (finalText) history.push({ role: 'assistant', content: finalText });
144
+
145
+ return {
146
+ text: finalText,
147
+ turns,
148
+ toolCalls: executed,
149
+ requestId: lastRequestId,
150
+ messages: history,
151
+ latencyMs: Date.now() - startedAt,
152
+ };
128
153
  }
129
154
 
130
155
  async cancel(requestId: string): Promise<void> {
@@ -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
+ ];