@kaleidorg/mind 0.0.1 → 0.1.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/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +18 -2
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/skills/bundle.d.ts +30 -0
- package/dist/skills/bundle.d.ts.map +1 -0
- package/dist/skills/bundle.js +24 -0
- package/dist/skills/bundle.js.map +1 -0
- package/dist/skills/loader.d.ts +33 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +59 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/reference-source.d.ts +18 -0
- package/dist/skills/reference-source.d.ts.map +1 -0
- package/dist/skills/reference-source.js +53 -0
- package/dist/skills/reference-source.js.map +1 -0
- package/dist/skills/registry.d.ts +41 -0
- package/dist/skills/registry.d.ts.map +1 -0
- package/dist/skills/registry.js +167 -0
- package/dist/skills/registry.js.map +1 -0
- package/dist/skills/types.d.ts +53 -0
- package/dist/skills/types.d.ts.map +1 -0
- package/dist/skills/types.js +18 -0
- package/dist/skills/types.js.map +1 -0
- package/dist/tools/l402.d.ts +47 -0
- package/dist/tools/l402.d.ts.map +1 -0
- package/dist/tools/l402.js +84 -0
- package/dist/tools/l402.js.map +1 -0
- package/package.json +9 -2
- package/scripts/bundle-skills.mjs +84 -0
- package/skills/README.md +74 -0
- package/skills/bitrefill/SKILL.md +66 -0
- package/skills/bitrefill/references/api.md +99 -0
- package/skills/bitrefill/references/browse.md +71 -0
- package/skills/bitrefill/references/capability-matrix.md +115 -0
- package/skills/bitrefill/references/cli-headless-auth.md +133 -0
- package/skills/bitrefill/references/cli.md +237 -0
- package/skills/bitrefill/references/host-openclaw.md +167 -0
- package/skills/bitrefill/references/mcp.md +150 -0
- package/skills/bitrefill/references/safeguards.md +138 -0
- package/skills/bitrefill/references/troubleshooting.md +182 -0
- package/skills/kaleido-trading/SKILL.md +31 -0
- package/skills/kaleido-wallet/SKILL.md +28 -0
- package/src/engine.test.ts +204 -0
- package/src/engine.ts +27 -2
- package/src/index.ts +17 -0
- package/src/skills/bundle.ts +42 -0
- package/src/skills/loader.ts +63 -0
- package/src/skills/reference-source.ts +60 -0
- package/src/skills/registry.ts +183 -0
- package/src/skills/skills.test.ts +191 -0
- package/src/skills/types.ts +55 -0
- package/src/tools/l402.test.ts +113 -0
- package/src/tools/l402.ts +122 -0
- package/dist/providers/qvac.d.ts +0 -89
- package/dist/providers/qvac.d.ts.map +0 -1
- package/dist/providers/qvac.js +0 -150
- package/dist/providers/qvac.js.map +0 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kaleido-wallet
|
|
3
|
+
description: "Manage a KaleidoSwap Lightning + RGB wallet: check BTC and asset balances, get a receive address, create or pay Lightning invoices, send on-chain BTC, open or list channels. Triggers when the user asks about their balance, wants to receive or send funds, pay an invoice, or manage Lightning channels."
|
|
4
|
+
tools: wdk_get_balances, wdk_get_asset_balance, wdk_get_address, wdk_create_ln_invoice, wdk_pay_invoice, wdk_send_btc, wdk_list_channels, wdk_open_channel, wdk_get_node_info
|
|
5
|
+
triggers: balance, receive, address, send, pay, invoice, channel, deposit, withdraw, funds
|
|
6
|
+
metadata:
|
|
7
|
+
author: kaleidoswap
|
|
8
|
+
version: "0.1.0"
|
|
9
|
+
surface: "kaleido-mcp (WDK node)"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# KaleidoSwap wallet
|
|
13
|
+
|
|
14
|
+
Operate the user's KaleidoSwap node (Lightning + RGB assets) through the
|
|
15
|
+
`wdk_*` MCP tools.
|
|
16
|
+
|
|
17
|
+
## Rules
|
|
18
|
+
|
|
19
|
+
- **Read before you write.** Check the balance (`wdk_get_balances`) before any
|
|
20
|
+
send or channel open, and confirm the node is healthy (`wdk_get_node_info`).
|
|
21
|
+
- **Confirm every spend.** State the amount, destination, and resulting balance,
|
|
22
|
+
then wait for explicit approval before calling `wdk_send_btc`,
|
|
23
|
+
`wdk_pay_invoice`, or `wdk_open_channel`.
|
|
24
|
+
- **Match the rail to the asset.** Lightning for fast BTC/asset payments,
|
|
25
|
+
on-chain for settlement or channel funding. Use `wdk_get_asset_balance` for
|
|
26
|
+
RGB assets (USDT, XAUT, …).
|
|
27
|
+
- Never reveal seeds or private keys — this skill operates a node, it is not a
|
|
28
|
+
key vault.
|
|
@@ -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
|
|
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
|
-
|
|
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> {
|
package/src/index.ts
CHANGED
|
@@ -23,9 +23,26 @@ export type { ToolSource } from './tools/source.js';
|
|
|
23
23
|
export { InProcessToolSource } from './tools/in-process.js';
|
|
24
24
|
export type { InProcessTool } from './tools/in-process.js';
|
|
25
25
|
export { ToolRegistry } from './tools/registry.js';
|
|
26
|
+
export {
|
|
27
|
+
createL402ToolSource,
|
|
28
|
+
parseL402Challenge,
|
|
29
|
+
bolt11AmountSats,
|
|
30
|
+
} from './tools/l402.js';
|
|
31
|
+
export type { L402Options, L402PayResult } from './tools/l402.js';
|
|
26
32
|
|
|
27
33
|
export { Engine } from './engine.js';
|
|
28
34
|
export type { EngineOptions, AgenticOptions, AgenticResult } from './engine.js';
|
|
29
35
|
|
|
36
|
+
export {
|
|
37
|
+
SkillRegistry,
|
|
38
|
+
parseSkill,
|
|
39
|
+
keywordSelector,
|
|
40
|
+
READ_REFERENCE_TOOL,
|
|
41
|
+
} from './skills/registry.js';
|
|
42
|
+
export { createSkillReferenceToolSource } from './skills/reference-source.js';
|
|
43
|
+
export { skillsFromBundle } from './skills/bundle.js';
|
|
44
|
+
export type { SkillBundle, BundledSkill } from './skills/bundle.js';
|
|
45
|
+
export type { Skill, SkillReference, SkillSelector } from './skills/types.js';
|
|
46
|
+
|
|
30
47
|
export { TurnLogger, defaultMask } from './logger.js';
|
|
31
48
|
export type { TurnLog, Device, LoggerIO, LoggerOptions } from './logger.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill bundle — the RN-safe counterpart to the Node fs loader.
|
|
3
|
+
*
|
|
4
|
+
* React Native has no filesystem, so a skill folder can't be read at runtime.
|
|
5
|
+
* Instead a build step serialises the skills into a `SkillBundle` (plain JSON:
|
|
6
|
+
* each skill's raw SKILL.md text + its reference files), and the app rehydrates
|
|
7
|
+
* them here with `skillsFromBundle()`. Same skills, same SKILL.md authoring —
|
|
8
|
+
* just delivered as data instead of files.
|
|
9
|
+
*
|
|
10
|
+
* Pure, dependency-free, no fs/url imports — safe to import from the package's
|
|
11
|
+
* main entry on any host.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Skill, SkillReference } from './types.js';
|
|
15
|
+
import { parseSkill } from './registry.js';
|
|
16
|
+
|
|
17
|
+
/** One serialised skill: the SKILL.md text + its reference files. */
|
|
18
|
+
export interface BundledSkill {
|
|
19
|
+
/** Folder name (informational; the real name comes from the frontmatter). */
|
|
20
|
+
dir?: string;
|
|
21
|
+
/** Raw SKILL.md contents. */
|
|
22
|
+
markdown: string;
|
|
23
|
+
/** references/*.md files. */
|
|
24
|
+
references?: SkillReference[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A bundle of skills produced by the bundler script. */
|
|
28
|
+
export interface SkillBundle {
|
|
29
|
+
version: 1;
|
|
30
|
+
skills: BundledSkill[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Rehydrate Skills from a bundle (RN-safe — no filesystem). */
|
|
34
|
+
export function skillsFromBundle(bundle: SkillBundle): Skill[] {
|
|
35
|
+
if (!bundle || bundle.version !== 1 || !Array.isArray(bundle.skills)) {
|
|
36
|
+
throw new Error('skillsFromBundle: not a valid v1 SkillBundle');
|
|
37
|
+
}
|
|
38
|
+
return bundle.skills.map((b) => ({
|
|
39
|
+
...parseSkill(b.markdown, b.references),
|
|
40
|
+
dir: b.dir,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill loader — reads Claude-style Agent Skill folders from disk. NODE ONLY.
|
|
3
|
+
*
|
|
4
|
+
* Import from `@kaleidorg/mind/skills` on Node hosts (desktop sidecar,
|
|
5
|
+
* kaleidoagent). React Native has no filesystem — there, build skills with
|
|
6
|
+
* `SkillRegistry.addMarkdown(text, references)` from bundled strings instead.
|
|
7
|
+
*
|
|
8
|
+
* Layout (Anthropic Agent Skills spec, e.g. bitrefill/agents):
|
|
9
|
+
*
|
|
10
|
+
* skills/
|
|
11
|
+
* bitrefill/
|
|
12
|
+
* SKILL.md
|
|
13
|
+
* references/
|
|
14
|
+
* mcp.md
|
|
15
|
+
* cli.md
|
|
16
|
+
* …
|
|
17
|
+
*
|
|
18
|
+
* `loadSkillsDir(root)` returns one Skill per SKILL.md found, with every
|
|
19
|
+
* reference markdown read into `skill.references` for progressive disclosure.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import type { Skill, SkillReference } from './types.js';
|
|
26
|
+
import { parseSkill } from './registry.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Absolute path to the skills shipped inside this package
|
|
30
|
+
* (`@kaleidorg/mind/skills`). Resolves relative to the compiled loader, so it
|
|
31
|
+
* works from any host that installs the package. Override with an explicit dir
|
|
32
|
+
* when you keep skills elsewhere.
|
|
33
|
+
*/
|
|
34
|
+
export function packagedSkillsDir(): string {
|
|
35
|
+
// dist/skills/loader.js → ../../skills == <package root>/skills
|
|
36
|
+
return fileURLToPath(new URL('../../skills/', import.meta.url));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Load one skill folder containing a SKILL.md (+ optional references/). */
|
|
40
|
+
export function loadSkillFromDir(dir: string): Skill {
|
|
41
|
+
const skillFile = join(dir, 'SKILL.md');
|
|
42
|
+
if (!existsSync(skillFile)) throw new Error(`No SKILL.md in ${dir}`);
|
|
43
|
+
const markdown = readFileSync(skillFile, 'utf8');
|
|
44
|
+
|
|
45
|
+
const refDir = join(dir, 'references');
|
|
46
|
+
const references: SkillReference[] = existsSync(refDir)
|
|
47
|
+
? readdirSync(refDir)
|
|
48
|
+
.filter((f) => f.endsWith('.md'))
|
|
49
|
+
.sort()
|
|
50
|
+
.map((name) => ({ name, content: readFileSync(join(refDir, name), 'utf8') }))
|
|
51
|
+
: [];
|
|
52
|
+
|
|
53
|
+
return { ...parseSkill(markdown, references), dir };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Load every skill folder under `root` (each a dir with a SKILL.md). */
|
|
57
|
+
export function loadSkillsDir(root: string): Skill[] {
|
|
58
|
+
if (!existsSync(root)) return [];
|
|
59
|
+
return readdirSync(root, { withFileTypes: true })
|
|
60
|
+
.filter((e) => e.isDirectory() && existsSync(join(root, e.name, 'SKILL.md')))
|
|
61
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
62
|
+
.map((e) => loadSkillFromDir(join(root, e.name)));
|
|
63
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill reference tool source — the read-side of Agent-Skills progressive
|
|
3
|
+
* disclosure.
|
|
4
|
+
*
|
|
5
|
+
* Exposes one tool, `read_skill_reference({ file, skill? })`, that returns the
|
|
6
|
+
* contents of a `references/*.md` file bundled with a skill. The brain enters a
|
|
7
|
+
* skill (its SKILL.md playbook lists the reference files), then pulls in only
|
|
8
|
+
* the reference it needs for the current step — instead of every doc being in
|
|
9
|
+
* context at once.
|
|
10
|
+
*
|
|
11
|
+
* Pure in-process: it reads from the SkillRegistry's already-loaded reference
|
|
12
|
+
* strings, so it works on every host (React Native included) once the skills
|
|
13
|
+
* are loaded. No filesystem, no network.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ToolDef } from '../types.js';
|
|
17
|
+
import type { ToolSource } from '../tools/source.js';
|
|
18
|
+
import { SkillRegistry, READ_REFERENCE_TOOL } from './registry.js';
|
|
19
|
+
|
|
20
|
+
export function createSkillReferenceToolSource(registry: SkillRegistry): ToolSource {
|
|
21
|
+
const tool: ToolDef = {
|
|
22
|
+
name: READ_REFERENCE_TOOL,
|
|
23
|
+
description:
|
|
24
|
+
'Read a reference document bundled with the active skill (its SKILL.md ' +
|
|
25
|
+
'lists the available files). Use this to pull in the detailed instructions ' +
|
|
26
|
+
'for a step — e.g. the MCP, CLI, or API guide — before acting.',
|
|
27
|
+
parameters: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
file: { type: 'string', description: 'Reference filename, e.g. "mcp.md"' },
|
|
31
|
+
skill: { type: 'string', description: 'Optional skill name to scope the lookup' },
|
|
32
|
+
},
|
|
33
|
+
required: ['file'],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function execute(_name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
38
|
+
const file = String(args.file ?? '').trim();
|
|
39
|
+
if (!file) throw new Error('read_skill_reference: file is required');
|
|
40
|
+
const skill = args.skill ? String(args.skill) : undefined;
|
|
41
|
+
const ref = registry.reference(file, skill);
|
|
42
|
+
if (!ref) {
|
|
43
|
+
const available = registry
|
|
44
|
+
.references()
|
|
45
|
+
.map((r) => `${r.skill}/${r.name}`)
|
|
46
|
+
.join(', ');
|
|
47
|
+
throw new Error(
|
|
48
|
+
`read_skill_reference: "${file}" not found. Available: ${available || '(none)'}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return ref.content;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
id: 'skill-references',
|
|
56
|
+
listTools: () => [tool],
|
|
57
|
+
has: (name) => name === READ_REFERENCE_TOOL,
|
|
58
|
+
execute,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillRegistry — holds skills, parses SKILL.md files, selects per query, and
|
|
3
|
+
* composes the system prompt for the selected skill.
|
|
4
|
+
*
|
|
5
|
+
* Selection is pluggable. The default is a fast keyword heuristic (no model
|
|
6
|
+
* call); a host can inject a model-driven or embedding selector instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Skill, SkillReference, SkillSelector } from './types.js';
|
|
10
|
+
|
|
11
|
+
/** Tool name the reference source exposes for progressive disclosure. */
|
|
12
|
+
export const READ_REFERENCE_TOOL = 'read_skill_reference';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a SKILL.md file: a YAML-ish frontmatter block (name/description/tools/
|
|
16
|
+
* triggers) followed by the instruction body.
|
|
17
|
+
*
|
|
18
|
+
* ---
|
|
19
|
+
* name: portfolio-manager
|
|
20
|
+
* description: Rebalance BTC/USDT/XAUT to target allocations.
|
|
21
|
+
* tools: get_balance, kaleidoswap_get_quote, kaleidoswap_place_order
|
|
22
|
+
* triggers: rebalance, allocation, portfolio
|
|
23
|
+
* ---
|
|
24
|
+
* <instructions…>
|
|
25
|
+
*/
|
|
26
|
+
/** Strip wrapping single/double quotes from a frontmatter value. */
|
|
27
|
+
function unquote(v: string): string {
|
|
28
|
+
const t = v.trim();
|
|
29
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
30
|
+
return t.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
return t;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseSkill(markdown: string, references?: SkillReference[]): Skill {
|
|
36
|
+
const fm = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
|
|
37
|
+
const meta: Record<string, string> = {};
|
|
38
|
+
let body = markdown;
|
|
39
|
+
if (fm) {
|
|
40
|
+
body = fm[2] ?? '';
|
|
41
|
+
for (const line of (fm[1] ?? '').split('\n')) {
|
|
42
|
+
// Flat `key: value` lines (incl. indented keys under a nested `metadata:`
|
|
43
|
+
// block, which fold into the same map — we don't need YAML nesting here).
|
|
44
|
+
const m = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/);
|
|
45
|
+
if (m && m[1]) meta[m[1].toLowerCase()] = unquote(m[2] ?? '');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const list = (v?: string) =>
|
|
49
|
+
v
|
|
50
|
+
? v.split(',').map((s) => s.trim()).filter(Boolean)
|
|
51
|
+
: undefined;
|
|
52
|
+
|
|
53
|
+
if (!meta.name) throw new Error('SKILL.md missing `name` in frontmatter');
|
|
54
|
+
|
|
55
|
+
// Everything that isn't a first-class field becomes metadata.
|
|
56
|
+
const KNOWN = new Set(['name', 'description', 'tools', 'triggers']);
|
|
57
|
+
const metadata: Record<string, string> = {};
|
|
58
|
+
for (const [k, v] of Object.entries(meta)) if (!KNOWN.has(k)) metadata[k] = v;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name: meta.name,
|
|
62
|
+
description: meta.description ?? '',
|
|
63
|
+
instructions: body.trim(),
|
|
64
|
+
tools: list(meta.tools),
|
|
65
|
+
triggers: list(meta.triggers),
|
|
66
|
+
metadata: Object.keys(metadata).length ? metadata : undefined,
|
|
67
|
+
references: references && references.length ? references : undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Common words that shouldn't count toward a skill match.
|
|
72
|
+
const STOPWORDS = new Set([
|
|
73
|
+
'the', 'and', 'for', 'you', 'your', 'what', 'this', 'that', 'with', 'from',
|
|
74
|
+
'have', 'has', 'are', 'was', 'can', 'will', 'please', 'today', 'now', 'get',
|
|
75
|
+
'show', 'tell', 'how', 'much', 'many', 'about', 'into', 'over',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/** Default selector: score by meaningful keyword overlap; triggers weigh most. */
|
|
79
|
+
export const keywordSelector: SkillSelector = {
|
|
80
|
+
select(query, skills) {
|
|
81
|
+
const q = query.toLowerCase();
|
|
82
|
+
const words = new Set(
|
|
83
|
+
q.split(/\W+/).filter((w) => w.length > 2 && !STOPWORDS.has(w)),
|
|
84
|
+
);
|
|
85
|
+
let best: Skill | null = null;
|
|
86
|
+
let bestScore = 0;
|
|
87
|
+
for (const skill of skills) {
|
|
88
|
+
const haystack = `${skill.description} ${(skill.triggers ?? []).join(' ')}`.toLowerCase();
|
|
89
|
+
const hayWords = haystack.split(/\W+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
|
|
90
|
+
let score = 0;
|
|
91
|
+
for (const w of hayWords) if (words.has(w)) score += 1;
|
|
92
|
+
// Strong boost for an explicit trigger appearing in the query.
|
|
93
|
+
for (const t of skill.triggers ?? []) if (q.includes(t.toLowerCase())) score += 3;
|
|
94
|
+
if (score > bestScore) {
|
|
95
|
+
bestScore = score;
|
|
96
|
+
best = skill;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Require a real signal, not a single incidental word overlap.
|
|
100
|
+
return bestScore >= 2 ? best : null;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export class SkillRegistry {
|
|
105
|
+
private readonly skills: Skill[] = [];
|
|
106
|
+
private readonly selector: SkillSelector;
|
|
107
|
+
|
|
108
|
+
constructor(skills: Skill[] = [], selector: SkillSelector = keywordSelector) {
|
|
109
|
+
this.skills = [...skills];
|
|
110
|
+
this.selector = selector;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
add(skill: Skill): this {
|
|
114
|
+
this.skills.push(skill);
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Add a skill from raw SKILL.md text (+ optional reference files). */
|
|
119
|
+
addMarkdown(markdown: string, references?: SkillReference[]): this {
|
|
120
|
+
return this.add(parseSkill(markdown, references));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** All reference files across skills, tagged with their owning skill. */
|
|
124
|
+
references(): Array<SkillReference & { skill: string }> {
|
|
125
|
+
return this.skills.flatMap((s) =>
|
|
126
|
+
(s.references ?? []).map((r) => ({ ...r, skill: s.name })),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Look up a reference file by name (optionally scoped to one skill). */
|
|
131
|
+
reference(file: string, skill?: string): SkillReference | undefined {
|
|
132
|
+
const base = file.replace(/^references\//, '');
|
|
133
|
+
for (const s of this.skills) {
|
|
134
|
+
if (skill && s.name !== skill) continue;
|
|
135
|
+
const hit = (s.references ?? []).find((r) => r.name === base || r.name === file);
|
|
136
|
+
if (hit) return hit;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
list(): Skill[] {
|
|
142
|
+
return [...this.skills];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get(name: string): Skill | undefined {
|
|
146
|
+
return this.skills.find((s) => s.name === name);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pick the most relevant skill for a query (null = none). */
|
|
150
|
+
select(query: string): Skill | null {
|
|
151
|
+
return this.selector.select(query, this.skills);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compose the effective system prompt for a skill: the base prompt + the
|
|
156
|
+
* skill's playbook. The returned `allowedTools` should be passed to
|
|
157
|
+
* `engine.runAgentic(..., { allowedTools })` for progressive tool disclosure.
|
|
158
|
+
*/
|
|
159
|
+
compose(base: string, skill: Skill | null): { system: string; allowedTools?: string[] } {
|
|
160
|
+
if (!skill) return { system: base };
|
|
161
|
+
|
|
162
|
+
let system = `${base}\n\n## Active skill: ${skill.name}\n${skill.instructions}`.trim();
|
|
163
|
+
|
|
164
|
+
// Progressive disclosure: tell the model the reference files exist and how
|
|
165
|
+
// to pull one in, rather than dumping them all into context.
|
|
166
|
+
const refs = skill.references ?? [];
|
|
167
|
+
if (refs.length) {
|
|
168
|
+
const names = refs.map((r) => r.name).join(', ');
|
|
169
|
+
system +=
|
|
170
|
+
`\n\n## Reference files\nThis skill has detailed reference docs: ${names}. ` +
|
|
171
|
+
`When you need the detail for a step, call \`${READ_REFERENCE_TOOL}\` with the ` +
|
|
172
|
+
`filename (e.g. {"file":"${refs[0]!.name}"}) to read it before acting.`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// When the skill scopes tools, keep the reference reader reachable too.
|
|
176
|
+
const allowedTools = skill.tools
|
|
177
|
+
? refs.length
|
|
178
|
+
? [...skill.tools, READ_REFERENCE_TOOL]
|
|
179
|
+
: skill.tools
|
|
180
|
+
: undefined;
|
|
181
|
+
return { system, allowedTools };
|
|
182
|
+
}
|
|
183
|
+
}
|