@kaleidorg/mind 0.5.0 → 0.6.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/autonomy/index.d.ts +21 -0
- package/dist/autonomy/index.d.ts.map +1 -0
- package/dist/autonomy/index.js +16 -0
- package/dist/autonomy/index.js.map +1 -0
- package/dist/autonomy/prompt.d.ts +21 -0
- package/dist/autonomy/prompt.d.ts.map +1 -0
- package/dist/autonomy/prompt.js +37 -0
- package/dist/autonomy/prompt.js.map +1 -0
- package/dist/autonomy/risk.d.ts +53 -0
- package/dist/autonomy/risk.d.ts.map +1 -0
- package/dist/autonomy/risk.js +74 -0
- package/dist/autonomy/risk.js.map +1 -0
- package/dist/autonomy/run-state.d.ts +39 -0
- package/dist/autonomy/run-state.d.ts.map +1 -0
- package/dist/autonomy/run-state.js +118 -0
- package/dist/autonomy/run-state.js.map +1 -0
- package/dist/autonomy/scheduler.d.ts +18 -0
- package/dist/autonomy/scheduler.d.ts.map +1 -0
- package/dist/autonomy/scheduler.js +113 -0
- package/dist/autonomy/scheduler.js.map +1 -0
- package/dist/autonomy/task-store.d.ts +44 -0
- package/dist/autonomy/task-store.d.ts.map +1 -0
- package/dist/autonomy/task-store.js +139 -0
- package/dist/autonomy/task-store.js.map +1 -0
- package/dist/autonomy/types.d.ts +164 -0
- package/dist/autonomy/types.d.ts.map +1 -0
- package/dist/autonomy/types.js +20 -0
- package/dist/autonomy/types.js.map +1 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +12 -0
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +2 -2
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/qvac/index.d.ts +1 -1
- package/dist/qvac/index.d.ts.map +1 -1
- package/dist/qvac/index.js.map +1 -1
- package/dist/qvac/parse.d.ts +33 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +69 -5
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/provider.d.ts +16 -0
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +17 -1
- package/dist/qvac/provider.js.map +1 -1
- package/dist/qvac/stream.d.ts +16 -0
- package/dist/qvac/stream.d.ts.map +1 -1
- package/dist/qvac/stream.js +21 -1
- package/dist/qvac/stream.js.map +1 -1
- package/dist/qvac/text.d.ts.map +1 -1
- package/dist/qvac/text.js +4 -0
- package/dist/qvac/text.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +1 -1
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -1
- package/dist/recipe/buy-asset-channel.js +4 -3
- package/dist/recipe/buy-asset-channel.js.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.d.ts +1 -1
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +5 -4
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +38 -0
- package/dist/recipe/runner.js.map +1 -1
- package/dist/tools/mcp.d.ts +19 -0
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mcp.js +51 -9
- package/dist/tools/mcp.js.map +1 -1
- package/package.json +2 -1
- package/skills/channel-manager/SKILL.md +59 -0
- package/skills/dca/SKILL.md +48 -0
- package/skills/kaleido-lsps/SKILL.md +12 -12
- package/skills/kaleido-trading/SKILL.md +1 -1
- package/skills/liquidity-optimizer/SKILL.md +91 -0
- package/skills/merchant-finder/SKILL.md +1 -1
- package/skills/portfolio-manager/SKILL.md +67 -0
- package/skills/rgb-lightning-node/SKILL.md +3 -3
- package/skills/wallet-assistant/SKILL.md +1 -1
- package/src/autonomy/autonomy.test.ts +348 -0
- package/src/autonomy/index.ts +50 -0
- package/src/autonomy/prompt.ts +48 -0
- package/src/autonomy/risk.ts +139 -0
- package/src/autonomy/run-state.ts +144 -0
- package/src/autonomy/scheduler.ts +120 -0
- package/src/autonomy/task-store.ts +167 -0
- package/src/autonomy/types.ts +186 -0
- package/src/funnel.mind.test.ts +390 -0
- package/src/funnel.ts +14 -0
- package/src/index.ts +41 -0
- package/src/knowledge/bitcoin-copilot.ts +2 -2
- package/src/qvac/index.ts +1 -0
- package/src/qvac/parse.test.ts +70 -1
- package/src/qvac/parse.ts +91 -5
- package/src/qvac/provider.test.ts +17 -0
- package/src/qvac/provider.ts +37 -1
- package/src/qvac/stream.test.ts +25 -0
- package/src/qvac/stream.ts +38 -1
- package/src/qvac/text.ts +4 -0
- package/src/recipe/buy-asset-channel.test.ts +5 -0
- package/src/recipe/buy-asset-channel.ts +6 -3
- package/src/recipe/kaleidoswap-atomic.test.ts +3 -3
- package/src/recipe/kaleidoswap-atomic.ts +5 -4
- package/src/recipe/recipe.test.ts +16 -0
- package/src/recipe/runner.ts +41 -0
- package/src/tools/mcp.live.test.ts +116 -0
- package/src/tools/mcp.parse.test.ts +37 -0
- package/src/tools/mcp.ts +55 -9
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/** Autonomy tests — deterministic: injected clock + timer, no real wallet/LLM. */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { InMemoryTaskStore, defaultTaskSeeds } from './task-store.js';
|
|
5
|
+
import { createTaskScheduler } from './scheduler.js';
|
|
6
|
+
import { TaskRunLog } from './run-state.js';
|
|
7
|
+
import { evaluateSpend, DEFAULT_RISK_LIMITS } from './risk.js';
|
|
8
|
+
import { buildTaskPrompt } from './prompt.js';
|
|
9
|
+
import type { AgentTask, RunLogSnapshot, RunLogIO, TaskStoreIO } from './types.js';
|
|
10
|
+
|
|
11
|
+
const SEC = 1000;
|
|
12
|
+
|
|
13
|
+
/** Drain pending microtasks (real timer — these tests don't fake setTimeout). */
|
|
14
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
15
|
+
|
|
16
|
+
describe('InMemoryTaskStore', () => {
|
|
17
|
+
it('creates with defaults and lists', async () => {
|
|
18
|
+
const store = new InMemoryTaskStore({ now: () => 1 });
|
|
19
|
+
const t = await store.create({
|
|
20
|
+
name: 'Rebalance',
|
|
21
|
+
description: 'd',
|
|
22
|
+
skill: 'portfolio-manager',
|
|
23
|
+
scheduleSec: 3600,
|
|
24
|
+
enabled: true,
|
|
25
|
+
});
|
|
26
|
+
expect(t.id).toBe('task_1_1');
|
|
27
|
+
expect(t.runOnStartup).toBe(false);
|
|
28
|
+
expect(t.allocation).toEqual({ btcSat: 0, usdt: 0, xaut: 0 });
|
|
29
|
+
expect(t.lastRunAt).toBeNull();
|
|
30
|
+
expect(await store.list()).toHaveLength(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('update cannot mutate id/createdAt; remove works', async () => {
|
|
34
|
+
const store = new InMemoryTaskStore({ now: () => 5 });
|
|
35
|
+
const t = await store.create({ name: 'x', description: '', skill: 's', scheduleSec: 0, enabled: false });
|
|
36
|
+
const patched = await store.update(t.id, {
|
|
37
|
+
enabled: true,
|
|
38
|
+
lastRunAt: 99,
|
|
39
|
+
// @ts-expect-error — id is not patchable, proving the type guard
|
|
40
|
+
id: 'evil',
|
|
41
|
+
} as Partial<AgentTask>);
|
|
42
|
+
expect(patched?.id).toBe(t.id);
|
|
43
|
+
expect(patched?.createdAt).toBe(5);
|
|
44
|
+
expect(patched?.enabled).toBe(true);
|
|
45
|
+
expect(patched?.lastRunAt).toBe(99);
|
|
46
|
+
expect(await store.remove(t.id)).toBe(true);
|
|
47
|
+
expect(await store.remove(t.id)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('seedDefaults is idempotent by id', async () => {
|
|
51
|
+
const store = new InMemoryTaskStore({ now: () => 1 });
|
|
52
|
+
const first = await store.seedDefaults(defaultTaskSeeds());
|
|
53
|
+
expect(first.map((t) => t.id)).toEqual(['heartbeat', 'rebalance', 'daily_summary']);
|
|
54
|
+
const second = await store.seedDefaults(defaultTaskSeeds());
|
|
55
|
+
expect(second).toHaveLength(0); // already present
|
|
56
|
+
expect(await store.list()).toHaveLength(3);
|
|
57
|
+
// seeds disabled by default — an agent never auto-arms itself
|
|
58
|
+
expect((await store.list()).every((t) => !t.enabled)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('persists through injected IO and a fresh store hydrates', async () => {
|
|
62
|
+
let saved: AgentTask[] = [];
|
|
63
|
+
const io: TaskStoreIO = {
|
|
64
|
+
load: vi.fn(async () => [...saved]),
|
|
65
|
+
save: vi.fn(async (tasks) => {
|
|
66
|
+
saved = [...tasks];
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
const store = new InMemoryTaskStore({ io, now: () => 1 });
|
|
70
|
+
await store.create({ name: 'a', description: '', skill: 's', scheduleSec: 60, enabled: true });
|
|
71
|
+
expect(io.save).toHaveBeenCalled();
|
|
72
|
+
|
|
73
|
+
const store2 = new InMemoryTaskStore({ io, now: () => 2 });
|
|
74
|
+
expect(await store2.list()).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('createTaskScheduler', () => {
|
|
79
|
+
function fixtureTask(over: Partial<AgentTask> = {}): AgentTask {
|
|
80
|
+
return {
|
|
81
|
+
id: 't1',
|
|
82
|
+
name: 'Heartbeat',
|
|
83
|
+
description: '',
|
|
84
|
+
skill: 'channel-manager',
|
|
85
|
+
scheduleSec: 300,
|
|
86
|
+
runOnStartup: false,
|
|
87
|
+
allocation: { btcSat: 0, usdt: 0, xaut: 0 },
|
|
88
|
+
enabled: true,
|
|
89
|
+
createdAt: 0,
|
|
90
|
+
lastRunAt: null,
|
|
91
|
+
...over,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('runs a task only once its interval has elapsed since creation', async () => {
|
|
96
|
+
let t = 0;
|
|
97
|
+
const store = new InMemoryTaskStore({ now: () => t });
|
|
98
|
+
await store.create(fixtureTask());
|
|
99
|
+
const run = vi.fn(async () => ({ ok: true }));
|
|
100
|
+
const sched = createTaskScheduler({ store, run, now: () => t });
|
|
101
|
+
|
|
102
|
+
t = 200 * SEC; // < 300s since createdAt=0 → not due
|
|
103
|
+
await sched.tick();
|
|
104
|
+
expect(run).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
t = 300 * SEC; // exactly due
|
|
107
|
+
await sched.tick();
|
|
108
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
109
|
+
// lastRunAt stamped at run start
|
|
110
|
+
expect((await store.get('t1'))?.lastRunAt).toBe(300 * SEC);
|
|
111
|
+
|
|
112
|
+
t = 400 * SEC; // < 300s since last run → not due again
|
|
113
|
+
await sched.tick();
|
|
114
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
115
|
+
|
|
116
|
+
t = 600 * SEC; // 300s elapsed → due
|
|
117
|
+
await sched.tick();
|
|
118
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('skips disabled tasks and scheduleSec=0 (manual-only)', async () => {
|
|
122
|
+
const store = new InMemoryTaskStore({ now: () => 0 });
|
|
123
|
+
await store.create(fixtureTask({ id: 'off', enabled: false, createdAt: 0 }));
|
|
124
|
+
await store.create(fixtureTask({ id: 'manual', scheduleSec: 0, createdAt: 0 }));
|
|
125
|
+
const run = vi.fn(async () => ({ ok: true }));
|
|
126
|
+
const sched = createTaskScheduler({ store, run, now: () => 10 ** 9 });
|
|
127
|
+
await sched.tick();
|
|
128
|
+
expect(run).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('start() runs runOnStartup tasks immediately via injected timer', async () => {
|
|
132
|
+
const store = new InMemoryTaskStore({ now: () => 0 });
|
|
133
|
+
await store.create(fixtureTask({ id: 'boot', runOnStartup: true, createdAt: 0 }));
|
|
134
|
+
const run = vi.fn(async () => ({ ok: true }));
|
|
135
|
+
const setTimer = vi.fn(() => 'h');
|
|
136
|
+
const clearTimer = vi.fn();
|
|
137
|
+
const sched = createTaskScheduler({ store, run, now: () => 0, setTimer, clearTimer });
|
|
138
|
+
|
|
139
|
+
sched.start();
|
|
140
|
+
expect(setTimer).toHaveBeenCalledTimes(1);
|
|
141
|
+
await flush(); // let the async startup pass settle
|
|
142
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(sched.isRunning()).toBe(true);
|
|
144
|
+
|
|
145
|
+
sched.stop();
|
|
146
|
+
expect(clearTimer).toHaveBeenCalledWith('h');
|
|
147
|
+
expect(sched.isRunning()).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('runNow forces a run regardless of schedule/enabled and reports outcome', async () => {
|
|
151
|
+
const store = new InMemoryTaskStore({ now: () => 0 });
|
|
152
|
+
await store.create(fixtureTask({ id: 'x', enabled: false, scheduleSec: 0 }));
|
|
153
|
+
const run = vi.fn(async () => ({ ok: true, text: 'done', toolCalls: 2 }));
|
|
154
|
+
const sched = createTaskScheduler({ store, run, now: () => 42 });
|
|
155
|
+
const outcome = await sched.runNow('x');
|
|
156
|
+
expect(outcome).toEqual({ ok: true, text: 'done', toolCalls: 2 });
|
|
157
|
+
expect((await store.get('x'))?.lastRunAt).toBe(42);
|
|
158
|
+
expect(await sched.runNow('nope')).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('advances lastRunAt even when the run throws (no hot-loop)', async () => {
|
|
162
|
+
let t = 10 ** 6;
|
|
163
|
+
const store = new InMemoryTaskStore({ now: () => t });
|
|
164
|
+
await store.create(fixtureTask({ id: 'boom', createdAt: 0 }));
|
|
165
|
+
const onOutcome = vi.fn();
|
|
166
|
+
const run = vi.fn(async () => {
|
|
167
|
+
throw new Error('rpc down');
|
|
168
|
+
});
|
|
169
|
+
const sched = createTaskScheduler({ store, run, now: () => t, onOutcome });
|
|
170
|
+
await sched.tick();
|
|
171
|
+
expect((await store.get('boom'))?.lastRunAt).toBe(t);
|
|
172
|
+
expect(onOutcome).toHaveBeenCalledWith(
|
|
173
|
+
expect.objectContaining({ id: 'boom' }),
|
|
174
|
+
expect.objectContaining({ ok: false, error: 'rpc down' }),
|
|
175
|
+
expect.any(Number),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('honors concurrency=1 — a slow task does not overlap itself', async () => {
|
|
180
|
+
let t = 10 ** 6;
|
|
181
|
+
const store = new InMemoryTaskStore({ now: () => t });
|
|
182
|
+
await store.create(fixtureTask({ id: 'a', createdAt: 0 }));
|
|
183
|
+
await store.create(fixtureTask({ id: 'b', createdAt: 0 }));
|
|
184
|
+
let resolveA: (() => void) | null = null;
|
|
185
|
+
const run = vi.fn((task: AgentTask) =>
|
|
186
|
+
task.id === 'a'
|
|
187
|
+
? new Promise<{ ok: boolean }>((res) => {
|
|
188
|
+
resolveA = () => res({ ok: true });
|
|
189
|
+
})
|
|
190
|
+
: Promise.resolve({ ok: true }),
|
|
191
|
+
);
|
|
192
|
+
const sched = createTaskScheduler({ store, run, now: () => t, concurrency: 1 });
|
|
193
|
+
void sched.tick();
|
|
194
|
+
await flush();
|
|
195
|
+
// a is in-flight (its run never resolves); concurrency 1 holds b off this tick
|
|
196
|
+
expect(sched.active()).toEqual(['a']);
|
|
197
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
198
|
+
resolveA?.();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('TaskRunLog', () => {
|
|
203
|
+
it('aggregates stats, recent runs, and cumulative cost', async () => {
|
|
204
|
+
const log = new TaskRunLog({ now: () => 1, maxRecent: 2 });
|
|
205
|
+
await log.record({
|
|
206
|
+
taskId: 'r', taskName: 'Rebalance', startedAt: 100, durationMs: 5, toolCalls: 3,
|
|
207
|
+
ok: true, error: null, text: 'ok', cost: { usd: 0.01, inputTokens: 10, outputTokens: 5 },
|
|
208
|
+
});
|
|
209
|
+
await log.record({
|
|
210
|
+
taskId: 'r', taskName: 'Rebalance', startedAt: 200, durationMs: 7, toolCalls: 1,
|
|
211
|
+
ok: false, error: 'boom', text: '', cost: { usd: 0.02, inputTokens: 4, outputTokens: 2 },
|
|
212
|
+
});
|
|
213
|
+
const s = await log.statsFor('r');
|
|
214
|
+
expect(s?.runs).toBe(2);
|
|
215
|
+
expect(s?.errors).toBe(1);
|
|
216
|
+
expect(s?.lastError).toBe('boom');
|
|
217
|
+
expect(await log.totalCost()).toEqual({ usd: 0.03, inputTokens: 14, outputTokens: 7 });
|
|
218
|
+
expect((await log.recent()).map((r) => r.startedAt)).toEqual([200, 100]); // newest first
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('caps the recent ring buffer at maxRecent', async () => {
|
|
222
|
+
const log = new TaskRunLog({ now: () => 1, maxRecent: 2 });
|
|
223
|
+
for (let i = 0; i < 5; i++) {
|
|
224
|
+
await log.record({
|
|
225
|
+
taskId: 't', taskName: 'T', startedAt: i, durationMs: 1, toolCalls: 0,
|
|
226
|
+
ok: true, error: null, text: '', cost: { usd: 0, inputTokens: 0, outputTokens: 0 },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
expect(await log.recent()).toHaveLength(2);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('persists + hydrates through injected IO', async () => {
|
|
233
|
+
let snap: RunLogSnapshot | null = null;
|
|
234
|
+
const io: RunLogIO = {
|
|
235
|
+
load: vi.fn(async () => snap),
|
|
236
|
+
save: vi.fn(async (s) => {
|
|
237
|
+
snap = s;
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
const log = new TaskRunLog({ io, now: () => 1 });
|
|
241
|
+
await log.record({
|
|
242
|
+
taskId: 't', taskName: 'T', startedAt: 1, durationMs: 1, toolCalls: 0,
|
|
243
|
+
ok: true, error: null, text: 'hello', cost: { usd: 1, inputTokens: 0, outputTokens: 0 },
|
|
244
|
+
});
|
|
245
|
+
expect(io.save).toHaveBeenCalled();
|
|
246
|
+
const log2 = new TaskRunLog({ io, now: () => 2 });
|
|
247
|
+
expect((await log2.totalCost()).usd).toBe(1);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('evaluateSpend (risk guardrails)', () => {
|
|
252
|
+
const live = { ...DEFAULT_RISK_LIMITS, dryRun: false };
|
|
253
|
+
|
|
254
|
+
it('blocks every spend when dry-run is on', () => {
|
|
255
|
+
const v = evaluateSpend({ kind: 'swap', amountUsd: 1 }, DEFAULT_RISK_LIMITS);
|
|
256
|
+
expect(v.outcome).toBe('block');
|
|
257
|
+
expect(v.reason).toMatch(/dry-run/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('blocks at/below the stop-loss floor', () => {
|
|
261
|
+
const v = evaluateSpend(
|
|
262
|
+
{ kind: 'pay', amountSat: 1 },
|
|
263
|
+
{ ...live, stopLossBtcSat: 50_000 },
|
|
264
|
+
{ btcBalanceSat: 50_000 },
|
|
265
|
+
);
|
|
266
|
+
expect(v.outcome).toBe('block');
|
|
267
|
+
expect(v.reason).toMatch(/stop-loss/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('blocks a spend that would dip below the reserve', () => {
|
|
271
|
+
const v = evaluateSpend(
|
|
272
|
+
{ kind: 'send', amountSat: 60_000 },
|
|
273
|
+
{ ...live, minBtcReserveSat: 50_000, stopLossBtcSat: 0 },
|
|
274
|
+
{ btcBalanceSat: 100_000 },
|
|
275
|
+
);
|
|
276
|
+
expect(v.outcome).toBe('block');
|
|
277
|
+
expect(v.reason).toMatch(/reserve/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('blocks above the max single spend', () => {
|
|
281
|
+
const v = evaluateSpend({ kind: 'swap', amountUsd: 100 }, { ...live, maxSpendUsd: 50 });
|
|
282
|
+
expect(v.outcome).toBe('block');
|
|
283
|
+
expect(v.reason).toMatch(/exceeds/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('blocks a new swap when the open-order cap is reached', () => {
|
|
287
|
+
const v = evaluateSpend(
|
|
288
|
+
{ kind: 'swap', amountUsd: 1 },
|
|
289
|
+
{ ...live, maxOpenOrders: 3, autoApproveUnderUsd: 100 },
|
|
290
|
+
{ openOrders: 3 },
|
|
291
|
+
);
|
|
292
|
+
expect(v.outcome).toBe('block');
|
|
293
|
+
expect(v.reason).toMatch(/open orders/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('auto-approves a small spend under the threshold', () => {
|
|
297
|
+
const v = evaluateSpend(
|
|
298
|
+
{ kind: 'pay', amountUsd: 2, amountSat: 3000 },
|
|
299
|
+
{ ...live, autoApproveUnderUsd: 5, maxSpendUsd: 50, minBtcReserveSat: 0, stopLossBtcSat: 0 },
|
|
300
|
+
{ btcBalanceSat: 1_000_000 },
|
|
301
|
+
);
|
|
302
|
+
expect(v.outcome).toBe('allow');
|
|
303
|
+
expect(v.requiresConfirmation).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('requires confirmation above the auto-approve threshold', () => {
|
|
307
|
+
const v = evaluateSpend(
|
|
308
|
+
{ kind: 'swap', amountUsd: 20 },
|
|
309
|
+
{ ...live, autoApproveUnderUsd: 5, maxSpendUsd: 50 },
|
|
310
|
+
);
|
|
311
|
+
expect(v.outcome).toBe('confirm');
|
|
312
|
+
expect(v.requiresConfirmation).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('requires confirmation when the USD value is unknown (never spends blind)', () => {
|
|
316
|
+
const v = evaluateSpend({ kind: 'channel' }, { ...live, autoApproveUnderUsd: 100 });
|
|
317
|
+
expect(v.outcome).toBe('confirm');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('buildTaskPrompt', () => {
|
|
322
|
+
const task: AgentTask = {
|
|
323
|
+
id: 'rebalance',
|
|
324
|
+
name: 'Portfolio Rebalance',
|
|
325
|
+
description: 'detect drift',
|
|
326
|
+
skill: 'portfolio-manager',
|
|
327
|
+
scheduleSec: 3600,
|
|
328
|
+
runOnStartup: false,
|
|
329
|
+
allocation: { btcSat: 100, usdt: 5, xaut: 0 },
|
|
330
|
+
enabled: true,
|
|
331
|
+
createdAt: 0,
|
|
332
|
+
lastRunAt: null,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
it('embeds skill, dry-run flag, allocation, and the strict-JSON contract', () => {
|
|
336
|
+
const p = buildTaskPrompt(task, { dryRun: true, nowIso: '2026-06-19T00:00:00.000Z' });
|
|
337
|
+
expect(p).toMatch(/portfolio-manager/);
|
|
338
|
+
expect(p).toMatch(/dry_run: true/);
|
|
339
|
+
expect(p).toMatch(/Do NOT pay, send, swap/);
|
|
340
|
+
expect(p).toMatch(/"task":"rebalance"/);
|
|
341
|
+
expect(p).toMatch(/"btcSat":100/);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('switches guidance to fund-safety language when live', () => {
|
|
345
|
+
const p = buildTaskPrompt(task, { dryRun: false, nowIso: '2026-06-19T00:00:00.000Z' });
|
|
346
|
+
expect(p).toMatch(/reserve or stop-loss/);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autonomy — the agent's task brain: a registry of scheduled tasks (TaskStore),
|
|
3
|
+
* the record of what they did (TaskRunLog), an interval engine that fires them
|
|
4
|
+
* (createTaskScheduler), and enforced spend guardrails (evaluateSpend).
|
|
5
|
+
*
|
|
6
|
+
* This is the half of the agent's memory the MemoryStore (soul + facts) doesn't
|
|
7
|
+
* cover — the operational state nanobot kept in tasks.json + cron + run history.
|
|
8
|
+
* Storage and timers are injected; the logic is pure TS.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
TaskAllocation,
|
|
13
|
+
AgentTask,
|
|
14
|
+
NewTask,
|
|
15
|
+
TaskSeed,
|
|
16
|
+
TaskStore,
|
|
17
|
+
TaskStoreIO,
|
|
18
|
+
TaskRunCost,
|
|
19
|
+
TaskStats,
|
|
20
|
+
TaskRunRecord,
|
|
21
|
+
RunLogSnapshot,
|
|
22
|
+
RunLogIO,
|
|
23
|
+
TaskRunOutcome,
|
|
24
|
+
RunTask,
|
|
25
|
+
TimerHandle,
|
|
26
|
+
SchedulerOptions,
|
|
27
|
+
TaskScheduler,
|
|
28
|
+
} from './types.js';
|
|
29
|
+
export { ZERO_ALLOCATION } from './types.js';
|
|
30
|
+
|
|
31
|
+
export { InMemoryTaskStore, defaultTaskSeeds } from './task-store.js';
|
|
32
|
+
export type { TaskStoreOptions } from './task-store.js';
|
|
33
|
+
|
|
34
|
+
export { TaskRunLog } from './run-state.js';
|
|
35
|
+
export type { RunLogOptions } from './run-state.js';
|
|
36
|
+
|
|
37
|
+
export { createTaskScheduler } from './scheduler.js';
|
|
38
|
+
|
|
39
|
+
export { evaluateSpend, DEFAULT_RISK_LIMITS } from './risk.js';
|
|
40
|
+
export type {
|
|
41
|
+
SpendKind,
|
|
42
|
+
RiskLimits,
|
|
43
|
+
SpendAction,
|
|
44
|
+
RiskContext,
|
|
45
|
+
RiskOutcome,
|
|
46
|
+
RiskVerdict,
|
|
47
|
+
} from './risk.js';
|
|
48
|
+
|
|
49
|
+
export { buildTaskPrompt } from './prompt.js';
|
|
50
|
+
export type { TaskPromptOptions } from './prompt.js';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildTaskPrompt — turns an AgentTask into the instruction the agent runs on
|
|
3
|
+
* each scheduled fire. Port of kaleidoagent's nanobot-cron-sync.buildCronPrompt,
|
|
4
|
+
* de-nanobot'd: it targets the Funnel's skill-scoped agentic tier directly, so a
|
|
5
|
+
* host typically does `funnel.runTurn(buildTaskPrompt(task, opts), { ... })`.
|
|
6
|
+
*
|
|
7
|
+
* The strict-JSON return contract is preserved so the host can parse a run's
|
|
8
|
+
* action/portfolio summary back out (the RunLog stores the raw text; a host that
|
|
9
|
+
* wants structured snapshots parses the JSON).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AgentTask } from './types.js';
|
|
13
|
+
|
|
14
|
+
export interface TaskPromptOptions {
|
|
15
|
+
/** Clock — injectable for deterministic tests. Default: Date.now via new Date. */
|
|
16
|
+
nowIso?: string;
|
|
17
|
+
/** True forbids any live wallet action (passed through to the model + enforced by risk). */
|
|
18
|
+
dryRun: boolean;
|
|
19
|
+
/** Portfolio targets / risk params / allocations surfaced to the model. */
|
|
20
|
+
params?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildTaskPrompt(task: AgentTask, opts: TaskPromptOptions): string {
|
|
24
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
25
|
+
const params = {
|
|
26
|
+
allocation: task.allocation,
|
|
27
|
+
...opts.params,
|
|
28
|
+
};
|
|
29
|
+
return [
|
|
30
|
+
'You are operating as the KaleidoSwap autonomous background runtime.',
|
|
31
|
+
`Current time: ${nowIso}`,
|
|
32
|
+
`Task id: ${task.id}`,
|
|
33
|
+
`Task: ${task.name} — ${task.description}`,
|
|
34
|
+
`Primary skill: ${task.skill}`,
|
|
35
|
+
`dry_run: ${opts.dryRun}`,
|
|
36
|
+
`Parameters: ${JSON.stringify(params)}`,
|
|
37
|
+
'',
|
|
38
|
+
`Use the "${task.skill}" skill to complete this task with the available tools.`,
|
|
39
|
+
'Fetch every value (balances, quotes, asset ids) live from tools — never invent one.',
|
|
40
|
+
opts.dryRun
|
|
41
|
+
? 'dry_run is ON: describe what you WOULD do. Do NOT pay, send, swap, or open channels.'
|
|
42
|
+
: 'Respect the fund-safety limits: never breach the BTC reserve or stop-loss floor.',
|
|
43
|
+
'Return STRICT JSON only, no prose, with these fields:',
|
|
44
|
+
'{"task":"' + task.id + '","timestamp":"ISO8601","action":"...","dry_run":' +
|
|
45
|
+
String(opts.dryRun) +
|
|
46
|
+
',"reason":"...","details":{}}',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk guardrails — the spend safety from kaleidoagent's SOUL.md ("respect fund
|
|
3
|
+
* safety above all", "dry_run means dry_run", "enforce min_btc_reserve before any
|
|
4
|
+
* outbound") turned into an ENFORCED function instead of prompt text a small model
|
|
5
|
+
* might ignore.
|
|
6
|
+
*
|
|
7
|
+
* The host calls `evaluateSpend` before any autonomous spend (and inside the
|
|
8
|
+
* Funnel's `onConfirm`) to decide: allow silently, require user confirmation, or
|
|
9
|
+
* block outright. Pure function — trivially testable, no I/O.
|
|
10
|
+
*
|
|
11
|
+
* Order of checks is intentional: hard blocks (dry-run, stop-loss, reserve, size,
|
|
12
|
+
* order cap) come first; only a spend that clears all of them is sized against the
|
|
13
|
+
* auto-approve threshold.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type SpendKind = 'pay' | 'send' | 'swap' | 'channel';
|
|
17
|
+
|
|
18
|
+
export interface RiskLimits {
|
|
19
|
+
/** When true, NO spend executes — the agent describes what it WOULD do. */
|
|
20
|
+
dryRun: boolean;
|
|
21
|
+
/** Sats that must remain in BTC balance after any outbound. */
|
|
22
|
+
minBtcReserveSat: number;
|
|
23
|
+
/** Hard floor: if BTC balance is at/below this, block all spends. */
|
|
24
|
+
stopLossBtcSat: number;
|
|
25
|
+
/** Max USD value of a single autonomous spend. */
|
|
26
|
+
maxSpendUsd: number;
|
|
27
|
+
/** Spends at/under this USD value auto-approve; above need confirmation. */
|
|
28
|
+
autoApproveUnderUsd: number;
|
|
29
|
+
/** Block new swaps/channels once this many orders are already open. */
|
|
30
|
+
maxOpenOrders?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SpendAction {
|
|
34
|
+
kind: SpendKind;
|
|
35
|
+
/** Sats leaving the BTC balance (omit for pure asset sends). */
|
|
36
|
+
amountSat?: number;
|
|
37
|
+
/** USD value of the spend — used for the size + auto-approve gates. */
|
|
38
|
+
amountUsd?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RiskContext {
|
|
42
|
+
/** Spendable BTC right now (sats). */
|
|
43
|
+
btcBalanceSat?: number;
|
|
44
|
+
/** Currently open orders (for the order-cap gate). */
|
|
45
|
+
openOrders?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type RiskOutcome = 'allow' | 'confirm' | 'block';
|
|
49
|
+
|
|
50
|
+
export interface RiskVerdict {
|
|
51
|
+
outcome: RiskOutcome;
|
|
52
|
+
/** True when the host MUST gate on user confirmation before executing. */
|
|
53
|
+
requiresConfirmation: boolean;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Sensible defaults — conservative. Hosts override per user settings. */
|
|
58
|
+
export const DEFAULT_RISK_LIMITS: RiskLimits = {
|
|
59
|
+
dryRun: true,
|
|
60
|
+
minBtcReserveSat: 50_000,
|
|
61
|
+
stopLossBtcSat: 50_000,
|
|
62
|
+
maxSpendUsd: 50,
|
|
63
|
+
autoApproveUnderUsd: 0, // 0 = always confirm unless the host raises it
|
|
64
|
+
maxOpenOrders: 3,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function evaluateSpend(
|
|
68
|
+
action: SpendAction,
|
|
69
|
+
limits: RiskLimits,
|
|
70
|
+
ctx: RiskContext = {},
|
|
71
|
+
): RiskVerdict {
|
|
72
|
+
const block = (reason: string): RiskVerdict => ({
|
|
73
|
+
outcome: 'block',
|
|
74
|
+
requiresConfirmation: false,
|
|
75
|
+
reason,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 1. Dry-run: nothing moves, full stop.
|
|
79
|
+
if (limits.dryRun) {
|
|
80
|
+
return block(`dry-run is on — would ${action.kind}, but no funds move`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const balance = ctx.btcBalanceSat;
|
|
84
|
+
|
|
85
|
+
// 2. Stop-loss: balance already at/below the floor.
|
|
86
|
+
if (balance !== undefined && balance <= limits.stopLossBtcSat) {
|
|
87
|
+
return block(
|
|
88
|
+
`BTC balance ${balance} sat is at/below the stop-loss floor ${limits.stopLossBtcSat} sat`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Reserve: this spend would dip below the reserve.
|
|
93
|
+
if (action.amountSat !== undefined && balance !== undefined) {
|
|
94
|
+
const after = balance - action.amountSat;
|
|
95
|
+
if (after < limits.minBtcReserveSat) {
|
|
96
|
+
return block(
|
|
97
|
+
`would leave ${after} sat, below the ${limits.minBtcReserveSat} sat reserve`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. Size cap.
|
|
103
|
+
if (action.amountUsd !== undefined && action.amountUsd > limits.maxSpendUsd) {
|
|
104
|
+
return block(
|
|
105
|
+
`$${action.amountUsd} exceeds the max single spend $${limits.maxSpendUsd}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 5. Open-order cap (swaps/channels only).
|
|
110
|
+
if (
|
|
111
|
+
(action.kind === 'swap' || action.kind === 'channel') &&
|
|
112
|
+
limits.maxOpenOrders !== undefined &&
|
|
113
|
+
ctx.openOrders !== undefined &&
|
|
114
|
+
ctx.openOrders >= limits.maxOpenOrders
|
|
115
|
+
) {
|
|
116
|
+
return block(
|
|
117
|
+
`${ctx.openOrders} open orders ≥ cap ${limits.maxOpenOrders} — not opening another`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 6. Cleared all hard gates → size against the auto-approve threshold.
|
|
122
|
+
// An unknown USD value defaults to confirm (safe): never auto-spend blind.
|
|
123
|
+
if (action.amountUsd !== undefined && action.amountUsd <= limits.autoApproveUnderUsd) {
|
|
124
|
+
return {
|
|
125
|
+
outcome: 'allow',
|
|
126
|
+
requiresConfirmation: false,
|
|
127
|
+
reason: `$${action.amountUsd} ≤ auto-approve $${limits.autoApproveUnderUsd}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
outcome: 'confirm',
|
|
133
|
+
requiresConfirmation: true,
|
|
134
|
+
reason:
|
|
135
|
+
action.amountUsd !== undefined
|
|
136
|
+
? `$${action.amountUsd} above auto-approve $${limits.autoApproveUnderUsd} — needs confirmation`
|
|
137
|
+
: `unknown spend value — needs confirmation`,
|
|
138
|
+
};
|
|
139
|
+
}
|