@kaleidorg/mind 0.0.1
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 +61 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +99 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +74 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +63 -0
- package/dist/logger.js.map +1 -0
- package/dist/providers/qvac.d.ts +89 -0
- package/dist/providers/qvac.d.ts.map +1 -0
- package/dist/providers/qvac.js +150 -0
- package/dist/providers/qvac.js.map +1 -0
- package/dist/providers/types.d.ts +44 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +13 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/tools/in-process.d.ts +27 -0
- package/dist/tools/in-process.d.ts.map +1 -0
- package/dist/tools/in-process.js +34 -0
- package/dist/tools/in-process.js.map +1 -0
- package/dist/tools/mcp.d.ts +52 -0
- package/dist/tools/mcp.d.ts.map +1 -0
- package/dist/tools/mcp.js +81 -0
- package/dist/tools/mcp.js.map +1 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +49 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/source.d.ts +25 -0
- package/dist/tools/source.d.ts.map +1 -0
- package/dist/tools/source.js +15 -0
- package/dist/tools/source.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/engine.ts +141 -0
- package/src/index.ts +31 -0
- package/src/logger.ts +127 -0
- package/src/providers/types.ts +47 -0
- package/src/tools/in-process.ts +49 -0
- package/src/tools/mcp.ts +112 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/source.ts +26 -0
- package/src/types.ts +46 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine — the agentic loop, provider- and tool-source-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* This is the shared "brain" logic, lifted out of rate's QVACService so the
|
|
5
|
+
* mobile app, the desktop app and the agent all run the SAME loop:
|
|
6
|
+
*
|
|
7
|
+
* reason → (tool calls?) → execute / confirm → feed results back → repeat
|
|
8
|
+
* → natural-language answer.
|
|
9
|
+
*
|
|
10
|
+
* Follows the QVAC multi-turn pattern: push the raw assistant frame plus
|
|
11
|
+
* `{role:'tool'}` results into history each round, loop until the model stops
|
|
12
|
+
* calling tools. Money tools pause for an `onConfirm` gate; their handlers run
|
|
13
|
+
* wherever the ToolSource lives (on the phone for the wallet), even when
|
|
14
|
+
* inference is delegated to a remote provider.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ConfirmDecision, Message, ToolResult } from './types.js';
|
|
18
|
+
import type { LLMProvider } from './providers/types.js';
|
|
19
|
+
import type { ToolRegistry } from './tools/registry.js';
|
|
20
|
+
|
|
21
|
+
export interface EngineOptions {
|
|
22
|
+
provider: LLMProvider;
|
|
23
|
+
tools: ToolRegistry;
|
|
24
|
+
/** Prepended as a system message when the caller didn't supply one. */
|
|
25
|
+
defaultSystem?: string;
|
|
26
|
+
/** Max reasoning↔tool rounds before forcing a stop. Default 5. */
|
|
27
|
+
defaultMaxTurns?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AgenticOptions {
|
|
31
|
+
maxTurns?: number;
|
|
32
|
+
/** Visible content tokens as they stream, tagged with the current turn. */
|
|
33
|
+
onToken?: (token: string, turn: number) => void;
|
|
34
|
+
/** The live requestId for the current turn (so a stop button can cancel it). */
|
|
35
|
+
onStart?: (requestId: string, turn: number) => void;
|
|
36
|
+
/** Fired when the model requests a tool, before it executes. */
|
|
37
|
+
onToolCall?: (call: { name: string; arguments: Record<string, unknown> }, turn: number) => void;
|
|
38
|
+
/** Human-in-the-loop gate for tools flagged requiresConfirmation. */
|
|
39
|
+
onConfirm?: (call: { name: string; arguments: Record<string, unknown> }) => Promise<ConfirmDecision>;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgenticResult {
|
|
44
|
+
text: string;
|
|
45
|
+
turns: number;
|
|
46
|
+
toolCalls: ToolResult[];
|
|
47
|
+
requestId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class Engine {
|
|
51
|
+
private readonly provider: LLMProvider;
|
|
52
|
+
private readonly registry: ToolRegistry;
|
|
53
|
+
private readonly defaultSystem?: string;
|
|
54
|
+
private readonly defaultMaxTurns: number;
|
|
55
|
+
|
|
56
|
+
constructor(opts: EngineOptions) {
|
|
57
|
+
this.provider = opts.provider;
|
|
58
|
+
this.registry = opts.tools;
|
|
59
|
+
this.defaultSystem = opts.defaultSystem;
|
|
60
|
+
this.defaultMaxTurns = opts.defaultMaxTurns ?? 5;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async runAgentic(messages: Message[], opts: AgenticOptions = {}): Promise<AgenticResult> {
|
|
64
|
+
const maxTurns = opts.maxTurns ?? this.defaultMaxTurns;
|
|
65
|
+
const hasSystem = messages.some((m) => m.role === 'system');
|
|
66
|
+
const system = hasSystem ? undefined : this.defaultSystem;
|
|
67
|
+
|
|
68
|
+
const history: Message[] = [...messages];
|
|
69
|
+
const allTools = await this.registry.listTools();
|
|
70
|
+
const executed: ToolResult[] = [];
|
|
71
|
+
let lastRequestId: string | undefined;
|
|
72
|
+
let finalText = '';
|
|
73
|
+
let turns = 0;
|
|
74
|
+
|
|
75
|
+
for (let turn = 1; turn <= maxTurns; turn++) {
|
|
76
|
+
turns = turn;
|
|
77
|
+
if (opts.signal?.aborted) break;
|
|
78
|
+
|
|
79
|
+
const out = await this.provider.runTurn({
|
|
80
|
+
messages: history,
|
|
81
|
+
tools: allTools,
|
|
82
|
+
system,
|
|
83
|
+
onToken: opts.onToken ? (t) => opts.onToken!(t, turn) : undefined,
|
|
84
|
+
signal: opts.signal,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
lastRequestId = out.requestId;
|
|
88
|
+
if (out.requestId) opts.onStart?.(out.requestId, turn);
|
|
89
|
+
finalText = (out.text || '').trim();
|
|
90
|
+
|
|
91
|
+
// No tool calls ⇒ the model produced its final answer.
|
|
92
|
+
if (!out.toolCalls || out.toolCalls.length === 0) break;
|
|
93
|
+
|
|
94
|
+
// Anchor the next turn with the raw assistant frame.
|
|
95
|
+
history.push({ role: 'assistant', content: out.rawContent || finalText });
|
|
96
|
+
|
|
97
|
+
for (const call of out.toolCalls) {
|
|
98
|
+
opts.onToolCall?.({ name: call.name, arguments: call.arguments }, turn);
|
|
99
|
+
const def = await this.registry.getDef(call.name);
|
|
100
|
+
|
|
101
|
+
let result: unknown;
|
|
102
|
+
if (def?.requiresConfirmation) {
|
|
103
|
+
const decision = opts.onConfirm
|
|
104
|
+
? await opts.onConfirm({ name: call.name, arguments: call.arguments })
|
|
105
|
+
: { approved: false, reason: 'no confirmation handler available' };
|
|
106
|
+
if (decision.approved) {
|
|
107
|
+
result = await this.safeExecute(call.name, call.arguments);
|
|
108
|
+
} else {
|
|
109
|
+
result = { declined: true, reason: decision.reason ?? 'user declined' };
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
result = await this.safeExecute(call.name, call.arguments);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
executed.push({ name: call.name, arguments: call.arguments, result });
|
|
116
|
+
history.push({
|
|
117
|
+
role: 'tool',
|
|
118
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (turn === maxTurns && !finalText) {
|
|
123
|
+
finalText = 'I had to stop after several steps — please try a more specific request.';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { text: finalText, turns, toolCalls: executed, requestId: lastRequestId };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async cancel(requestId: string): Promise<void> {
|
|
131
|
+
await this.provider.cancel?.(requestId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async safeExecute(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
135
|
+
try {
|
|
136
|
+
return await this.registry.execute(name, args);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kaleido/mind — shared local-AI reasoning engine for KaleidoSwap.
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript, zero runtime dependencies. Hosts inject:
|
|
5
|
+
* - an LLMProvider (wrapping @qvac/sdk, Anthropic, …)
|
|
6
|
+
* - one or more ToolSources (in-process wallet tools, MCP servers, …)
|
|
7
|
+
*
|
|
8
|
+
* and get the shared agentic loop, identical on mobile / desktop / agent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
Role,
|
|
13
|
+
Message,
|
|
14
|
+
ToolDef,
|
|
15
|
+
ToolCall,
|
|
16
|
+
ToolResult,
|
|
17
|
+
ConfirmDecision,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
|
|
20
|
+
export type { LLMProvider, TurnInput, TurnOutput } from './providers/types.js';
|
|
21
|
+
|
|
22
|
+
export type { ToolSource } from './tools/source.js';
|
|
23
|
+
export { InProcessToolSource } from './tools/in-process.js';
|
|
24
|
+
export type { InProcessTool } from './tools/in-process.js';
|
|
25
|
+
export { ToolRegistry } from './tools/registry.js';
|
|
26
|
+
|
|
27
|
+
export { Engine } from './engine.js';
|
|
28
|
+
export type { EngineOptions, AgenticOptions, AgenticResult } from './engine.js';
|
|
29
|
+
|
|
30
|
+
export { TurnLogger, defaultMask } from './logger.js';
|
|
31
|
+
export type { TurnLog, Device, LoggerIO, LoggerOptions } from './logger.js';
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured turn logger — JSONL output, designed for future fine-tuning
|
|
3
|
+
* datasets. Format is compatible with Salesforce/APIGen-MT-5k so KaleidoMind
|
|
4
|
+
* records can be concatenated with public data and fed to SFT pipelines.
|
|
5
|
+
*
|
|
6
|
+
* Privacy posture: amounts/addresses/contacts are HASHED at log time.
|
|
7
|
+
* Raw values are kept in a separate local store and only re-attached at
|
|
8
|
+
* export with explicit --include-pii.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Message, ToolCall, ToolResult } from './types.js';
|
|
12
|
+
|
|
13
|
+
export type Device =
|
|
14
|
+
| 'rate-ios'
|
|
15
|
+
| 'rate-android'
|
|
16
|
+
| 'kaleido-agent'
|
|
17
|
+
| 'rate-extension'
|
|
18
|
+
| 'kaleido-cli'
|
|
19
|
+
| 'playground';
|
|
20
|
+
|
|
21
|
+
export interface TurnLog {
|
|
22
|
+
id: string;
|
|
23
|
+
ts: string;
|
|
24
|
+
session_id: string;
|
|
25
|
+
device: Device;
|
|
26
|
+
model: {
|
|
27
|
+
provider: string;
|
|
28
|
+
name: string;
|
|
29
|
+
version?: string;
|
|
30
|
+
};
|
|
31
|
+
/** Hash-based identifier for the system prompt — same hash = same prompt. */
|
|
32
|
+
system_hash: string;
|
|
33
|
+
/** Names + schema hashes only, never raw schemas with semantic data. */
|
|
34
|
+
tools: { name: string; schema_hash: string }[];
|
|
35
|
+
messages: Message[];
|
|
36
|
+
decision: {
|
|
37
|
+
tool_calls: ToolCall[];
|
|
38
|
+
final_text: string | null;
|
|
39
|
+
reasoning_tokens?: number;
|
|
40
|
+
};
|
|
41
|
+
results: ToolResult[];
|
|
42
|
+
feedback?: {
|
|
43
|
+
thumbs?: 'up' | 'down';
|
|
44
|
+
edited_args?: Record<string, unknown>;
|
|
45
|
+
retry_count?: number;
|
|
46
|
+
};
|
|
47
|
+
latency_ms: {
|
|
48
|
+
transcribe?: number;
|
|
49
|
+
reason: number;
|
|
50
|
+
tools?: number;
|
|
51
|
+
total: number;
|
|
52
|
+
};
|
|
53
|
+
meta?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface LoggerOptions {
|
|
57
|
+
/** Absolute path where YYYY-MM-DD/session-<id>.jsonl files are written. */
|
|
58
|
+
dir: string;
|
|
59
|
+
device: Device;
|
|
60
|
+
/** Pluggable IO so the same logger works in Node + RN + tests. */
|
|
61
|
+
io: LoggerIO;
|
|
62
|
+
/** PII masker — defaults to hashing common fields. */
|
|
63
|
+
mask?: (log: TurnLog) => TurnLog;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LoggerIO {
|
|
67
|
+
ensureDir(path: string): Promise<void>;
|
|
68
|
+
appendLine(filePath: string, line: string): Promise<void>;
|
|
69
|
+
hash(value: unknown): string;
|
|
70
|
+
now(): Date;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class TurnLogger {
|
|
74
|
+
constructor(private readonly opts: LoggerOptions) {}
|
|
75
|
+
|
|
76
|
+
async log(input: Omit<TurnLog, 'id' | 'ts' | 'device'>): Promise<void> {
|
|
77
|
+
const ts = this.opts.io.now().toISOString();
|
|
78
|
+
const id = `${input.session_id}-${this.opts.io.hash({ ts, n: Math.random() }).slice(0, 8)}`;
|
|
79
|
+
let entry: TurnLog = { ...input, id, ts, device: this.opts.device };
|
|
80
|
+
if (this.opts.mask) entry = this.opts.mask(entry);
|
|
81
|
+
|
|
82
|
+
const day = ts.slice(0, 10);
|
|
83
|
+
const dir = `${this.opts.dir}/${day}`;
|
|
84
|
+
await this.opts.io.ensureDir(dir);
|
|
85
|
+
await this.opts.io.appendLine(
|
|
86
|
+
`${dir}/session-${input.session_id}.jsonl`,
|
|
87
|
+
JSON.stringify(entry),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Default masking — hashes amounts, addresses, invoices, contact names.
|
|
94
|
+
* Tools without these fields pass through unchanged.
|
|
95
|
+
*/
|
|
96
|
+
export function defaultMask(io: LoggerIO): (log: TurnLog) => TurnLog {
|
|
97
|
+
const FIELDS_TO_HASH = new Set([
|
|
98
|
+
'amount', 'amount_msat', 'amount_sat',
|
|
99
|
+
'address', 'invoice', 'bolt11', 'pubkey', 'node_id',
|
|
100
|
+
'contact', 'contact_name', 'recipient',
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const walk = (v: unknown): unknown => {
|
|
104
|
+
if (v === null || v === undefined) return v;
|
|
105
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
106
|
+
if (typeof v === 'object') {
|
|
107
|
+
const out: Record<string, unknown> = {};
|
|
108
|
+
for (const [k, val] of Object.entries(v)) {
|
|
109
|
+
out[k] = FIELDS_TO_HASH.has(k) ? `h:${io.hash(val).slice(0, 8)}` : walk(val);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
return v;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (log) => ({
|
|
117
|
+
...log,
|
|
118
|
+
decision: {
|
|
119
|
+
...log.decision,
|
|
120
|
+
tool_calls: log.decision.tool_calls.map((c) => ({
|
|
121
|
+
...c,
|
|
122
|
+
arguments: walk(c.arguments) as Record<string, unknown>,
|
|
123
|
+
})),
|
|
124
|
+
},
|
|
125
|
+
results: log.results.map((r) => ({ ...r, result: walk(r.result) })),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLMProvider — the only thing the Engine talks to for inference.
|
|
3
|
+
*
|
|
4
|
+
* Each host implements this over its own LLM transport:
|
|
5
|
+
* - rate (mobile): wraps @qvac/sdk completion() (local or P2P-delegated)
|
|
6
|
+
* - desktop-app: wraps @qvac/sdk completion() in Node
|
|
7
|
+
* - kaleidoagent: could wrap Anthropic/OpenAI
|
|
8
|
+
*
|
|
9
|
+
* The core package never imports any LLM SDK — it only depends on this
|
|
10
|
+
* interface, so it stays pure TS and bundles anywhere.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Message, ToolCall, ToolDef } from '../types.js';
|
|
14
|
+
|
|
15
|
+
export interface TurnInput {
|
|
16
|
+
messages: Message[];
|
|
17
|
+
tools: ToolDef[];
|
|
18
|
+
/** System prompt, when not already present as a message. */
|
|
19
|
+
system?: string;
|
|
20
|
+
/** Visible content tokens as they stream. */
|
|
21
|
+
onToken?: (token: string) => void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TurnOutput {
|
|
26
|
+
/** Cleaned assistant content for display. */
|
|
27
|
+
text: string;
|
|
28
|
+
/**
|
|
29
|
+
* Raw assistant frame to push back into history for the next turn. For
|
|
30
|
+
* tool-calling models this includes the tool-call framing the model needs
|
|
31
|
+
* to anchor continuation (e.g. QVAC's `final.raw.fullText`). Falls back to
|
|
32
|
+
* `text` when a provider has no separate raw form.
|
|
33
|
+
*/
|
|
34
|
+
rawContent: string;
|
|
35
|
+
/** Tool calls the model requested this turn (empty ⇒ final answer). */
|
|
36
|
+
toolCalls: ToolCall[];
|
|
37
|
+
/** Provider request id, for cancellation. */
|
|
38
|
+
requestId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LLMProvider {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
/** Run one completion turn. */
|
|
44
|
+
runTurn(input: TurnInput): Promise<TurnOutput>;
|
|
45
|
+
/** Cancel an in-flight turn by request id, if the provider supports it. */
|
|
46
|
+
cancel?(requestId: string): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InProcessToolSource — tools whose handlers run in the same process.
|
|
3
|
+
*
|
|
4
|
+
* Used by the mobile wallet: the handlers call the device's wallet adapters
|
|
5
|
+
* (Spark / Arkade / RGB) directly, so signing happens on the phone even when
|
|
6
|
+
* the model's inference is delegated to a desktop over P2P.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolDef } from '../types.js';
|
|
10
|
+
import type { ToolSource } from './source.js';
|
|
11
|
+
|
|
12
|
+
export interface InProcessTool<Args = Record<string, unknown>> {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
/** Zod schema (or any shape the provider understands). */
|
|
16
|
+
parameters: unknown;
|
|
17
|
+
/** When true, the engine pauses for confirmation before executing. */
|
|
18
|
+
requiresConfirmation?: boolean;
|
|
19
|
+
handler: (args: Args) => Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class InProcessToolSource implements ToolSource {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
private readonly tools = new Map<string, InProcessTool>();
|
|
25
|
+
|
|
26
|
+
constructor(id: string, tools: InProcessTool[]) {
|
|
27
|
+
this.id = id;
|
|
28
|
+
for (const t of tools) this.tools.set(t.name, t as InProcessTool);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
listTools(): ToolDef[] {
|
|
32
|
+
return Array.from(this.tools.values()).map((t) => ({
|
|
33
|
+
name: t.name,
|
|
34
|
+
description: t.description,
|
|
35
|
+
parameters: t.parameters,
|
|
36
|
+
requiresConfirmation: t.requiresConfirmation,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
has(name: string): boolean {
|
|
41
|
+
return this.tools.has(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async execute(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
45
|
+
const tool = this.tools.get(name);
|
|
46
|
+
if (!tool) throw new Error(`Tool "${name}" not found in source "${this.id}"`);
|
|
47
|
+
return tool.handler(args);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/tools/mcp.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpToolSource — exposes an MCP server's tools to the engine. NODE ONLY.
|
|
3
|
+
*
|
|
4
|
+
* Not exported from the package's main entry (`@kaleido/mind`) — import it
|
|
5
|
+
* explicitly from `@kaleido/mind/mcp` on Node hosts (desktop-app, kaleidoagent)
|
|
6
|
+
* so React Native never bundles the MCP SDK or any subprocess machinery.
|
|
7
|
+
*
|
|
8
|
+
* Connects to a server like `kaleido-mcp` (Spark + RLN + KaleidoSwap DEX +
|
|
9
|
+
* MPP/L402 + market data, ~64 tools) over stdio or HTTP, lists its tools, and
|
|
10
|
+
* routes execute() calls through the MCP client.
|
|
11
|
+
*
|
|
12
|
+
* The `@modelcontextprotocol/sdk` dependency is imported dynamically so this
|
|
13
|
+
* file type-checks and ships even where the SDK isn't installed; constructing
|
|
14
|
+
* an McpToolSource without it throws a clear error.
|
|
15
|
+
*
|
|
16
|
+
* STATUS: skeleton for the desktop pass. The shape is final; the connect()
|
|
17
|
+
* body is wired when we integrate desktop-app.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ToolDef } from '../types.js';
|
|
21
|
+
import type { ToolSource } from './source.js';
|
|
22
|
+
|
|
23
|
+
export type McpTransport =
|
|
24
|
+
| { kind: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
25
|
+
| { kind: 'http'; url: string; headers?: Record<string, string> };
|
|
26
|
+
|
|
27
|
+
export interface McpToolSourceOptions {
|
|
28
|
+
id: string;
|
|
29
|
+
transport: McpTransport;
|
|
30
|
+
/** Optional allowlist — only expose these tool names if provided. */
|
|
31
|
+
allow?: string[];
|
|
32
|
+
/** Per-call timeout (ms). Default 60_000. */
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class McpToolSource implements ToolSource {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
private readonly opts: McpToolSourceOptions;
|
|
39
|
+
private client: any | null = null;
|
|
40
|
+
private tools: ToolDef[] = [];
|
|
41
|
+
|
|
42
|
+
constructor(opts: McpToolSourceOptions) {
|
|
43
|
+
this.id = opts.id;
|
|
44
|
+
this.opts = opts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Connect to the MCP server and cache its tool list. Call once at startup. */
|
|
48
|
+
async connect(): Promise<void> {
|
|
49
|
+
// Dynamic import keeps the MCP SDK out of bundles that never call connect().
|
|
50
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
51
|
+
const t = this.opts.transport;
|
|
52
|
+
|
|
53
|
+
let transport: any;
|
|
54
|
+
if (t.kind === 'stdio') {
|
|
55
|
+
const { StdioClientTransport } = await import(
|
|
56
|
+
'@modelcontextprotocol/sdk/client/stdio.js'
|
|
57
|
+
);
|
|
58
|
+
transport = new StdioClientTransport({ command: t.command, args: t.args ?? [], env: t.env });
|
|
59
|
+
} else {
|
|
60
|
+
const { StreamableHTTPClientTransport } = await import(
|
|
61
|
+
'@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
62
|
+
);
|
|
63
|
+
transport = new StreamableHTTPClientTransport(new URL(t.url), {
|
|
64
|
+
requestInit: t.headers ? { headers: t.headers } : undefined,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.client = new Client({ name: `kaleido-mind:${this.id}`, version: '0.0.1' }, { capabilities: {} });
|
|
69
|
+
await this.client.connect(transport);
|
|
70
|
+
|
|
71
|
+
const listed = await this.client.listTools();
|
|
72
|
+
const allow = this.opts.allow ? new Set(this.opts.allow) : null;
|
|
73
|
+
this.tools = (listed.tools ?? [])
|
|
74
|
+
.filter((t: any) => !allow || allow.has(t.name))
|
|
75
|
+
.map((t: any) => ({
|
|
76
|
+
name: t.name,
|
|
77
|
+
description: t.description ?? '',
|
|
78
|
+
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
listTools(): ToolDef[] {
|
|
83
|
+
return this.tools;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
has(name: string): boolean {
|
|
87
|
+
return this.tools.some((t) => t.name === name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async execute(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
91
|
+
if (!this.client) throw new Error(`McpToolSource "${this.id}" not connected — call connect() first`);
|
|
92
|
+
const res = await this.client.callTool(
|
|
93
|
+
{ name, arguments: args },
|
|
94
|
+
undefined,
|
|
95
|
+
{ timeout: this.opts.timeoutMs ?? 60_000 },
|
|
96
|
+
);
|
|
97
|
+
// MCP returns content blocks; surface text content as the tool result.
|
|
98
|
+
if (Array.isArray(res?.content)) {
|
|
99
|
+
const text = res.content
|
|
100
|
+
.filter((c: any) => c.type === 'text')
|
|
101
|
+
.map((c: any) => c.text)
|
|
102
|
+
.join('\n');
|
|
103
|
+
return text || res.content;
|
|
104
|
+
}
|
|
105
|
+
return res;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async close(): Promise<void> {
|
|
109
|
+
await this.client?.close?.();
|
|
110
|
+
this.client = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolRegistry — merges multiple ToolSources into one tool list for the model
|
|
3
|
+
* and routes each tool call back to the source that owns it.
|
|
4
|
+
*
|
|
5
|
+
* Name-clash policy: first source wins (sources are consulted in registration
|
|
6
|
+
* order), so a host can layer a high-priority source over a broader one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolDef } from '../types.js';
|
|
10
|
+
import type { ToolSource } from './source.js';
|
|
11
|
+
|
|
12
|
+
export class ToolRegistry {
|
|
13
|
+
private readonly sources: ToolSource[] = [];
|
|
14
|
+
|
|
15
|
+
constructor(sources: ToolSource[] = []) {
|
|
16
|
+
this.sources = [...sources];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
add(source: ToolSource): this {
|
|
20
|
+
this.sources.push(source);
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Merged, de-duplicated tool list across all sources. */
|
|
25
|
+
async listTools(): Promise<ToolDef[]> {
|
|
26
|
+
const out: ToolDef[] = [];
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
for (const source of this.sources) {
|
|
29
|
+
const tools = await source.listTools();
|
|
30
|
+
for (const t of tools) {
|
|
31
|
+
if (seen.has(t.name)) continue; // first source wins
|
|
32
|
+
seen.add(t.name);
|
|
33
|
+
out.push(t);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The first source that owns a tool by name. */
|
|
40
|
+
private ownerOf(name: string): ToolSource | undefined {
|
|
41
|
+
return this.sources.find((s) => s.has(name));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Execute a tool, routing to its owning source. */
|
|
45
|
+
async execute(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
46
|
+
const owner = this.ownerOf(name);
|
|
47
|
+
if (!owner) throw new Error(`No tool source owns "${name}"`);
|
|
48
|
+
return owner.execute(name, args);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Look up a tool definition (e.g. to check requiresConfirmation). */
|
|
52
|
+
async getDef(name: string): Promise<ToolDef | undefined> {
|
|
53
|
+
const tools = await this.listTools();
|
|
54
|
+
return tools.find((t) => t.name === name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolSource — anything that exposes a set of tools and can execute them.
|
|
3
|
+
*
|
|
4
|
+
* This is the seam that makes the engine modular. Two implementations cover
|
|
5
|
+
* every surface:
|
|
6
|
+
* - InProcessToolSource — Zod tools + local handlers (works on React Native;
|
|
7
|
+
* used by the mobile wallet — handlers run on-device so keys never leave)
|
|
8
|
+
* - McpToolSource — connects an MCP server over stdio/HTTP (Node only;
|
|
9
|
+
* used on desktop for the full kaleido-mcp toolset)
|
|
10
|
+
*
|
|
11
|
+
* The engine merges N sources into one tool list for the model and routes each
|
|
12
|
+
* tool call back to the source that owns it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ToolDef } from '../types.js';
|
|
16
|
+
|
|
17
|
+
export interface ToolSource {
|
|
18
|
+
/** Stable identifier (for logging / debugging). */
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** The tools this source exposes. May be async (e.g. MCP listTools). */
|
|
21
|
+
listTools(): ToolDef[] | Promise<ToolDef[]>;
|
|
22
|
+
/** Whether this source owns a tool by name. */
|
|
23
|
+
has(name: string): boolean;
|
|
24
|
+
/** Execute a tool this source owns. */
|
|
25
|
+
execute(name: string, args: Record<string, unknown>): Promise<unknown>;
|
|
26
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the kaleido-mind engine.
|
|
3
|
+
*
|
|
4
|
+
* Pure data shapes — no runtime dependencies. The engine, tool sources and
|
|
5
|
+
* providers are all defined in terms of these, so the core package bundles
|
|
6
|
+
* cleanly on any host (React Native / Node / browser) without dragging in
|
|
7
|
+
* @qvac/sdk or any native code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Role = 'system' | 'user' | 'assistant' | 'tool';
|
|
11
|
+
|
|
12
|
+
export interface Message {
|
|
13
|
+
role: Role;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A tool the model can call. `parameters` is intentionally `unknown` — it may
|
|
19
|
+
* be a Zod schema (in-process tools) or a JSON Schema (MCP tools). Each
|
|
20
|
+
* provider converts it to whatever its SDK expects.
|
|
21
|
+
*/
|
|
22
|
+
export interface ToolDef {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
parameters: unknown;
|
|
26
|
+
/** When true, the engine pauses for `onConfirm` before executing (e.g. payments). */
|
|
27
|
+
requiresConfirmation?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ToolCall {
|
|
31
|
+
/** Provider-assigned id, when available. */
|
|
32
|
+
id?: string;
|
|
33
|
+
name: string;
|
|
34
|
+
arguments: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ToolResult {
|
|
38
|
+
name: string;
|
|
39
|
+
arguments: Record<string, unknown>;
|
|
40
|
+
result: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ConfirmDecision {
|
|
44
|
+
approved: boolean;
|
|
45
|
+
reason?: string;
|
|
46
|
+
}
|