simmer-automaton 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/api.d.ts +40 -0
- package/dist/api.js +45 -0
- package/dist/bandit.d.ts +28 -0
- package/dist/bandit.js +97 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +218 -0
- package/dist/tiers.d.ts +7 -0
- package/dist/tiers.js +20 -0
- package/dist/tuning.d.ts +12 -0
- package/dist/tuning.js +68 -0
- package/openclaw.plugin.json +71 -0
- package/package.json +16 -0
- package/src/api.ts +77 -0
- package/src/bandit.ts +135 -0
- package/src/index.ts +262 -0
- package/src/tiers.ts +25 -0
- package/src/tuning.ts +91 -0
- package/tsconfig.json +14 -0
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simmer API client for the automaton plugin.
|
|
3
|
+
* Thin wrapper around fetch — reads automaton state, skills registry.
|
|
4
|
+
*/
|
|
5
|
+
export interface AutomatonState {
|
|
6
|
+
initialized: boolean;
|
|
7
|
+
budget_usd: number;
|
|
8
|
+
spent_usd: number;
|
|
9
|
+
remaining_usd: number;
|
|
10
|
+
halted: boolean;
|
|
11
|
+
tier: string;
|
|
12
|
+
horizon_days: number;
|
|
13
|
+
started_at: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface Skill {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
category: string;
|
|
20
|
+
tags: string[];
|
|
21
|
+
difficulty: string;
|
|
22
|
+
install: string;
|
|
23
|
+
clawhub_url: string;
|
|
24
|
+
requires: string[];
|
|
25
|
+
best_when: string | null;
|
|
26
|
+
}
|
|
27
|
+
export declare class SimmerApi {
|
|
28
|
+
private baseUrl;
|
|
29
|
+
private apiKey;
|
|
30
|
+
constructor(apiKey: string, baseUrl?: string);
|
|
31
|
+
private request;
|
|
32
|
+
getAutomatonState(): Promise<AutomatonState>;
|
|
33
|
+
initAutomaton(budgetUsd: number, horizonDays?: number): Promise<unknown>;
|
|
34
|
+
halt(): Promise<unknown>;
|
|
35
|
+
resume(): Promise<unknown>;
|
|
36
|
+
getSkills(): Promise<{
|
|
37
|
+
skills: Skill[];
|
|
38
|
+
total: number;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simmer API client for the automaton plugin.
|
|
3
|
+
* Thin wrapper around fetch — reads automaton state, skills registry.
|
|
4
|
+
*/
|
|
5
|
+
export class SimmerApi {
|
|
6
|
+
baseUrl;
|
|
7
|
+
apiKey;
|
|
8
|
+
constructor(apiKey, baseUrl = "https://api.simmer.markets") {
|
|
9
|
+
this.apiKey = apiKey;
|
|
10
|
+
this.baseUrl = baseUrl;
|
|
11
|
+
}
|
|
12
|
+
async request(path, opts) {
|
|
13
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
14
|
+
...opts,
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
...(opts?.headers || {}),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const body = await res.text().catch(() => "");
|
|
23
|
+
throw new Error(`Simmer API ${res.status}: ${body}`);
|
|
24
|
+
}
|
|
25
|
+
return res.json();
|
|
26
|
+
}
|
|
27
|
+
async getAutomatonState() {
|
|
28
|
+
return this.request("/api/sdk/automaton/state");
|
|
29
|
+
}
|
|
30
|
+
async initAutomaton(budgetUsd, horizonDays = 30) {
|
|
31
|
+
return this.request("/api/sdk/automaton/init", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: JSON.stringify({ budget_usd: budgetUsd, horizon_days: horizonDays }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async halt() {
|
|
37
|
+
return this.request("/api/sdk/automaton/halt", { method: "POST" });
|
|
38
|
+
}
|
|
39
|
+
async resume() {
|
|
40
|
+
return this.request("/api/sdk/automaton/resume", { method: "POST" });
|
|
41
|
+
}
|
|
42
|
+
async getSkills() {
|
|
43
|
+
return this.request("/api/sdk/skills");
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/bandit.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epsilon-greedy bandit for skill selection.
|
|
3
|
+
* Ported from automaton.py — select_skills, _avg_reward, tier_max_skills, tier_effective_epsilon.
|
|
4
|
+
*/
|
|
5
|
+
export interface SkillState {
|
|
6
|
+
slug: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
timesSelected: number;
|
|
9
|
+
timesRewarded: number;
|
|
10
|
+
totalPnl: number;
|
|
11
|
+
consecutiveZeroSignals: number;
|
|
12
|
+
signalsFoundTotal: number;
|
|
13
|
+
tradesExecutedTotal: number;
|
|
14
|
+
lastCycle?: {
|
|
15
|
+
skipCounts?: Record<string, number>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface SelectionMeta {
|
|
19
|
+
slug: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
score: number | null;
|
|
22
|
+
}
|
|
23
|
+
export declare function tierMaxSkills(tier: string, maxConcurrent: number): number;
|
|
24
|
+
export declare function tierEffectiveEpsilon(tier: string, epsilon: number): number;
|
|
25
|
+
export declare function selectSkills(skills: SkillState[], n: number, tier: string, epsilon: number): {
|
|
26
|
+
selected: string[];
|
|
27
|
+
meta: SelectionMeta[];
|
|
28
|
+
};
|
package/dist/bandit.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epsilon-greedy bandit for skill selection.
|
|
3
|
+
* Ported from automaton.py — select_skills, _avg_reward, tier_max_skills, tier_effective_epsilon.
|
|
4
|
+
*/
|
|
5
|
+
function avgReward(skill) {
|
|
6
|
+
if (skill.timesSelected === 0)
|
|
7
|
+
return Infinity;
|
|
8
|
+
return skill.totalPnl / skill.timesSelected;
|
|
9
|
+
}
|
|
10
|
+
export function tierMaxSkills(tier, maxConcurrent) {
|
|
11
|
+
if (tier === "thriving" || tier === "normal")
|
|
12
|
+
return maxConcurrent;
|
|
13
|
+
if (tier === "conserving" || tier === "critical")
|
|
14
|
+
return 1;
|
|
15
|
+
return 0; // dead
|
|
16
|
+
}
|
|
17
|
+
export function tierEffectiveEpsilon(tier, epsilon) {
|
|
18
|
+
if (tier === "thriving" || tier === "normal")
|
|
19
|
+
return epsilon;
|
|
20
|
+
if (tier === "conserving")
|
|
21
|
+
return epsilon * 0.5;
|
|
22
|
+
return 0.0; // critical, dead — pure exploit or no runs
|
|
23
|
+
}
|
|
24
|
+
export function selectSkills(skills, n, tier, epsilon) {
|
|
25
|
+
const enabled = skills.filter((s) => s.enabled);
|
|
26
|
+
if (enabled.length === 0)
|
|
27
|
+
return { selected: [], meta: [] };
|
|
28
|
+
const effectiveEpsilon = tierEffectiveEpsilon(tier, epsilon);
|
|
29
|
+
const count = Math.min(n, enabled.length);
|
|
30
|
+
// Unplayed skills get priority
|
|
31
|
+
const unplayed = enabled.filter((s) => s.timesSelected === 0);
|
|
32
|
+
if (unplayed.length > 0) {
|
|
33
|
+
const shuffled = [...unplayed].sort(() => Math.random() - 0.5);
|
|
34
|
+
const picks = shuffled.slice(0, count);
|
|
35
|
+
const meta = picks.map((s) => ({
|
|
36
|
+
slug: s.slug,
|
|
37
|
+
reason: "unplayed",
|
|
38
|
+
score: null,
|
|
39
|
+
}));
|
|
40
|
+
if (picks.length < count) {
|
|
41
|
+
const remaining = enabled.filter((s) => !picks.includes(s));
|
|
42
|
+
const extra = remaining
|
|
43
|
+
.sort(() => Math.random() - 0.5)
|
|
44
|
+
.slice(0, count - picks.length);
|
|
45
|
+
picks.push(...extra);
|
|
46
|
+
meta.push(...extra.map((s) => ({
|
|
47
|
+
slug: s.slug,
|
|
48
|
+
reason: "backfill",
|
|
49
|
+
score: Math.round(avgReward(s) * 10000) / 10000,
|
|
50
|
+
})));
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
selected: picks.map((s) => s.slug),
|
|
54
|
+
meta,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Critical tier: always pick best
|
|
58
|
+
if (tier === "critical") {
|
|
59
|
+
const ranked = [...enabled].sort((a, b) => avgReward(b) - avgReward(a));
|
|
60
|
+
const picks = ranked.slice(0, count);
|
|
61
|
+
return {
|
|
62
|
+
selected: picks.map((s) => s.slug),
|
|
63
|
+
meta: picks.map((s) => ({
|
|
64
|
+
slug: s.slug,
|
|
65
|
+
reason: "critical_exploit",
|
|
66
|
+
score: Math.round(avgReward(s) * 10000) / 10000,
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Epsilon-greedy
|
|
71
|
+
const selected = [];
|
|
72
|
+
const meta = [];
|
|
73
|
+
const available = [...enabled];
|
|
74
|
+
for (let i = 0; i < count && available.length > 0; i++) {
|
|
75
|
+
let pick;
|
|
76
|
+
let reason;
|
|
77
|
+
if (Math.random() < effectiveEpsilon) {
|
|
78
|
+
// Explore: random
|
|
79
|
+
const idx = Math.floor(Math.random() * available.length);
|
|
80
|
+
pick = available[idx];
|
|
81
|
+
reason = `explore (ε=${effectiveEpsilon.toFixed(2)})`;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Exploit: best avg reward
|
|
85
|
+
pick = available.reduce((best, s) => avgReward(s) > avgReward(best) ? s : best);
|
|
86
|
+
reason = "exploit";
|
|
87
|
+
}
|
|
88
|
+
selected.push(pick);
|
|
89
|
+
meta.push({
|
|
90
|
+
slug: pick.slug,
|
|
91
|
+
reason,
|
|
92
|
+
score: Math.round(avgReward(pick) * 10000) / 10000,
|
|
93
|
+
});
|
|
94
|
+
available.splice(available.indexOf(pick), 1);
|
|
95
|
+
}
|
|
96
|
+
return { selected: selected.map((s) => s.slug), meta };
|
|
97
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simmer Automaton — OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Budget-enforced skill orchestration with survival tiers.
|
|
5
|
+
* Algorithm decides skill selection (bandit), budget, circuit breakers.
|
|
6
|
+
* LLM decides market-level trades within selected skills.
|
|
7
|
+
* API enforces hard spending limits on every trade.
|
|
8
|
+
*/
|
|
9
|
+
interface PluginApi {
|
|
10
|
+
pluginConfig?: Record<string, unknown>;
|
|
11
|
+
logger: {
|
|
12
|
+
info: (msg: string) => void;
|
|
13
|
+
warn: (msg: string) => void;
|
|
14
|
+
error: (msg: string) => void;
|
|
15
|
+
};
|
|
16
|
+
on: (hook: string, handler: (...args: unknown[]) => unknown) => void;
|
|
17
|
+
registerService: (service: {
|
|
18
|
+
id: string;
|
|
19
|
+
start: (ctx: ServiceCtx) => Promise<void>;
|
|
20
|
+
stop?: (ctx: ServiceCtx) => Promise<void>;
|
|
21
|
+
}) => void;
|
|
22
|
+
registerCommand: (cmd: {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
acceptsArgs?: boolean;
|
|
26
|
+
handler: (ctx: CommandCtx) => Promise<{
|
|
27
|
+
text: string;
|
|
28
|
+
}>;
|
|
29
|
+
}) => void;
|
|
30
|
+
}
|
|
31
|
+
interface ServiceCtx {
|
|
32
|
+
stateDir: string;
|
|
33
|
+
logger: {
|
|
34
|
+
info: (msg: string) => void;
|
|
35
|
+
warn: (msg: string) => void;
|
|
36
|
+
error: (msg: string) => void;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
interface CommandCtx {
|
|
40
|
+
args?: string;
|
|
41
|
+
}
|
|
42
|
+
export default function register(pluginApi: PluginApi): void;
|
|
43
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simmer Automaton — OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Budget-enforced skill orchestration with survival tiers.
|
|
5
|
+
* Algorithm decides skill selection (bandit), budget, circuit breakers.
|
|
6
|
+
* LLM decides market-level trades within selected skills.
|
|
7
|
+
* API enforces hard spending limits on every trade.
|
|
8
|
+
*/
|
|
9
|
+
import { SimmerApi } from "./api.js";
|
|
10
|
+
import { selectSkills, tierMaxSkills } from "./bandit.js";
|
|
11
|
+
import { computeTier } from "./tiers.js";
|
|
12
|
+
import { generateTuningHints } from "./tuning.js";
|
|
13
|
+
// Plugin-local state (in-memory, refreshed from API each cycle)
|
|
14
|
+
let api;
|
|
15
|
+
let cachedState = null;
|
|
16
|
+
let cachedSkills = [];
|
|
17
|
+
let banditState = [];
|
|
18
|
+
let currentTier = "normal";
|
|
19
|
+
let cycleCount = 0;
|
|
20
|
+
let serviceRunning = false;
|
|
21
|
+
let cycleTimer = null;
|
|
22
|
+
// Config defaults
|
|
23
|
+
let config = {
|
|
24
|
+
apiKey: "",
|
|
25
|
+
apiUrl: "https://api.simmer.markets",
|
|
26
|
+
cycleIntervalMs: 300_000,
|
|
27
|
+
epsilon: 0.2,
|
|
28
|
+
epsilonDecay: 0.995,
|
|
29
|
+
minEpsilon: 0.05,
|
|
30
|
+
maxConcurrent: 2,
|
|
31
|
+
venue: "simmer",
|
|
32
|
+
};
|
|
33
|
+
function loadConfig(pluginConfig) {
|
|
34
|
+
if (!pluginConfig)
|
|
35
|
+
return;
|
|
36
|
+
if (pluginConfig.apiKey)
|
|
37
|
+
config.apiKey = pluginConfig.apiKey;
|
|
38
|
+
if (pluginConfig.apiUrl)
|
|
39
|
+
config.apiUrl = pluginConfig.apiUrl;
|
|
40
|
+
if (pluginConfig.cycleIntervalMs)
|
|
41
|
+
config.cycleIntervalMs = pluginConfig.cycleIntervalMs;
|
|
42
|
+
if (pluginConfig.epsilon)
|
|
43
|
+
config.epsilon = pluginConfig.epsilon;
|
|
44
|
+
if (pluginConfig.epsilonDecay)
|
|
45
|
+
config.epsilonDecay = pluginConfig.epsilonDecay;
|
|
46
|
+
if (pluginConfig.minEpsilon)
|
|
47
|
+
config.minEpsilon = pluginConfig.minEpsilon;
|
|
48
|
+
if (pluginConfig.maxConcurrent)
|
|
49
|
+
config.maxConcurrent = pluginConfig.maxConcurrent;
|
|
50
|
+
if (pluginConfig.venue)
|
|
51
|
+
config.venue = pluginConfig.venue;
|
|
52
|
+
}
|
|
53
|
+
async function refreshState(logger) {
|
|
54
|
+
try {
|
|
55
|
+
cachedState = await api.getAutomatonState();
|
|
56
|
+
const skillsRes = await api.getSkills();
|
|
57
|
+
cachedSkills = skillsRes.skills;
|
|
58
|
+
if (cachedState.initialized) {
|
|
59
|
+
// Compute tier (totalPnl = 0 for now, will be enriched when P&L tracking is added)
|
|
60
|
+
currentTier = computeTier(cachedState, 0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
logger.error(`[simmer] Failed to refresh state: ${e}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function buildPromptContext() {
|
|
68
|
+
if (!cachedState || !cachedState.initialized) {
|
|
69
|
+
return "[Simmer Automaton] Not initialized. Run: POST /api/sdk/automaton/init";
|
|
70
|
+
}
|
|
71
|
+
const lines = [
|
|
72
|
+
"## Simmer Automaton — Survival Context (read-only)",
|
|
73
|
+
"",
|
|
74
|
+
`**Tier:** ${currentTier} | **Venue:** ${config.venue}`,
|
|
75
|
+
`**Budget:** $${cachedState.budget_usd.toFixed(2)} | **Spent:** $${cachedState.spent_usd.toFixed(2)} | **Remaining:** $${cachedState.remaining_usd.toFixed(2)}`,
|
|
76
|
+
`**Halted:** ${cachedState.halted ? "YES — all trades blocked" : "no"}`,
|
|
77
|
+
`**Horizon:** ${cachedState.horizon_days} days`,
|
|
78
|
+
"",
|
|
79
|
+
];
|
|
80
|
+
// Skill picks from bandit (if any)
|
|
81
|
+
if (banditState.length > 0) {
|
|
82
|
+
const n = tierMaxSkills(currentTier, config.maxConcurrent);
|
|
83
|
+
const { selected, meta } = selectSkills(banditState, n, currentTier, config.epsilon);
|
|
84
|
+
if (selected.length > 0) {
|
|
85
|
+
lines.push("**Selected skills this cycle:**");
|
|
86
|
+
for (const m of meta) {
|
|
87
|
+
lines.push(`- ${m.slug} (${m.reason}${m.score !== null ? `, score=${m.score}` : ""})`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Tuning hints
|
|
93
|
+
const hints = generateTuningHints(banditState, cachedState.budget_usd);
|
|
94
|
+
if (hints.length > 0) {
|
|
95
|
+
lines.push("**Tuning hints:**");
|
|
96
|
+
for (const h of hints) {
|
|
97
|
+
lines.push(`- [${h.skill}] ${h.issue}: ${h.suggestion}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push("");
|
|
100
|
+
}
|
|
101
|
+
if (currentTier === "critical") {
|
|
102
|
+
lines.push("**WARNING:** Critical tier — only exploiting best skill, no exploration.");
|
|
103
|
+
}
|
|
104
|
+
else if (currentTier === "dead") {
|
|
105
|
+
lines.push("**DEAD:** Budget exhausted or horizon expired. No skills will run.");
|
|
106
|
+
}
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
|
109
|
+
function formatStatus() {
|
|
110
|
+
if (!cachedState || !cachedState.initialized) {
|
|
111
|
+
return "Automaton not initialized. Use POST /api/sdk/automaton/init to start.";
|
|
112
|
+
}
|
|
113
|
+
const lines = [
|
|
114
|
+
`Tier: ${currentTier}`,
|
|
115
|
+
`Venue: ${config.venue}`,
|
|
116
|
+
`Budget: $${cachedState.budget_usd.toFixed(2)}`,
|
|
117
|
+
`Spent: $${cachedState.spent_usd.toFixed(2)}`,
|
|
118
|
+
`Remaining: $${cachedState.remaining_usd.toFixed(2)}`,
|
|
119
|
+
`Halted: ${cachedState.halted ? "YES" : "no"}`,
|
|
120
|
+
`Horizon: ${cachedState.horizon_days} days`,
|
|
121
|
+
`Cycles: ${cycleCount}`,
|
|
122
|
+
`Skills available: ${cachedSkills.length}`,
|
|
123
|
+
];
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
// =============================================================================
|
|
127
|
+
// Plugin registration
|
|
128
|
+
// =============================================================================
|
|
129
|
+
export default function register(pluginApi) {
|
|
130
|
+
loadConfig(pluginApi.pluginConfig);
|
|
131
|
+
if (!config.apiKey) {
|
|
132
|
+
pluginApi.logger.error("[simmer] No apiKey configured — plugin disabled");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
api = new SimmerApi(config.apiKey, config.apiUrl);
|
|
136
|
+
const logger = pluginApi.logger;
|
|
137
|
+
// --- Hook: before_prompt_build ---
|
|
138
|
+
// Inject survival context into every LLM prompt
|
|
139
|
+
pluginApi.on("before_prompt_build", async () => {
|
|
140
|
+
if (!cachedState) {
|
|
141
|
+
await refreshState(logger);
|
|
142
|
+
}
|
|
143
|
+
return { prependContext: buildPromptContext() };
|
|
144
|
+
});
|
|
145
|
+
// --- Service: background bandit cycle ---
|
|
146
|
+
pluginApi.registerService({
|
|
147
|
+
id: "simmer-automaton",
|
|
148
|
+
start: async (ctx) => {
|
|
149
|
+
ctx.logger.info("[simmer] Automaton service starting");
|
|
150
|
+
serviceRunning = true;
|
|
151
|
+
// Initial state fetch
|
|
152
|
+
await refreshState(ctx.logger);
|
|
153
|
+
// Periodic refresh
|
|
154
|
+
cycleTimer = setInterval(async () => {
|
|
155
|
+
if (!serviceRunning)
|
|
156
|
+
return;
|
|
157
|
+
cycleCount++;
|
|
158
|
+
await refreshState(ctx.logger);
|
|
159
|
+
// Decay epsilon
|
|
160
|
+
config.epsilon = Math.max(config.minEpsilon, config.epsilon * config.epsilonDecay);
|
|
161
|
+
ctx.logger.info(`[simmer] Cycle ${cycleCount} | tier=${currentTier} | ε=${config.epsilon.toFixed(3)} | skills=${cachedSkills.length}`);
|
|
162
|
+
}, config.cycleIntervalMs);
|
|
163
|
+
},
|
|
164
|
+
stop: async (ctx) => {
|
|
165
|
+
ctx.logger.info("[simmer] Automaton service stopping");
|
|
166
|
+
serviceRunning = false;
|
|
167
|
+
if (cycleTimer) {
|
|
168
|
+
clearInterval(cycleTimer);
|
|
169
|
+
cycleTimer = null;
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
// --- Commands ---
|
|
174
|
+
pluginApi.registerCommand({
|
|
175
|
+
name: "simmer",
|
|
176
|
+
description: "Show Simmer automaton status",
|
|
177
|
+
acceptsArgs: true,
|
|
178
|
+
handler: async (ctx) => {
|
|
179
|
+
const subcommand = ctx.args?.trim().split(/\s+/)[0] || "status";
|
|
180
|
+
if (subcommand === "status") {
|
|
181
|
+
await refreshState(logger);
|
|
182
|
+
return { text: formatStatus() };
|
|
183
|
+
}
|
|
184
|
+
if (subcommand === "halt") {
|
|
185
|
+
try {
|
|
186
|
+
await api.halt();
|
|
187
|
+
await refreshState(logger);
|
|
188
|
+
return { text: "Automaton halted. All trades will be rejected." };
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return { text: `Failed to halt: ${e}` };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (subcommand === "resume") {
|
|
195
|
+
try {
|
|
196
|
+
await api.resume();
|
|
197
|
+
await refreshState(logger);
|
|
198
|
+
return { text: "Automaton resumed. Trading is active." };
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
return { text: `Failed to resume: ${e}` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (subcommand === "skills") {
|
|
205
|
+
await refreshState(logger);
|
|
206
|
+
if (cachedSkills.length === 0) {
|
|
207
|
+
return { text: "No skills in registry." };
|
|
208
|
+
}
|
|
209
|
+
const lines = cachedSkills.map((s) => `- ${s.name} (${s.id}) — ${s.category}, ${s.difficulty}`);
|
|
210
|
+
return { text: `Skills (${cachedSkills.length}):\n${lines.join("\n")}` };
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
text: "Usage: /simmer [status|halt|resume|skills]",
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
logger.info("[simmer] Plugin registered");
|
|
218
|
+
}
|
package/dist/tiers.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survival tier computation.
|
|
3
|
+
* Ported from automaton.py — compute_tier.
|
|
4
|
+
*/
|
|
5
|
+
import type { AutomatonState } from "./api.js";
|
|
6
|
+
export type Tier = "thriving" | "normal" | "conserving" | "critical" | "dead";
|
|
7
|
+
export declare function computeTier(state: AutomatonState, totalPnl: number): Tier;
|
package/dist/tiers.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survival tier computation.
|
|
3
|
+
* Ported from automaton.py — compute_tier.
|
|
4
|
+
*/
|
|
5
|
+
export function computeTier(state, totalPnl) {
|
|
6
|
+
if (state.budget_usd <= 0)
|
|
7
|
+
return "dead";
|
|
8
|
+
const budgetRemainingPct = (state.budget_usd - state.spent_usd + totalPnl) / state.budget_usd;
|
|
9
|
+
const startedAt = state.started_at ? new Date(state.started_at) : new Date();
|
|
10
|
+
const daysElapsed = (Date.now() - startedAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
11
|
+
if (budgetRemainingPct <= 0 || daysElapsed >= state.horizon_days)
|
|
12
|
+
return "dead";
|
|
13
|
+
if (budgetRemainingPct < 0.1)
|
|
14
|
+
return "critical";
|
|
15
|
+
if (budgetRemainingPct < 0.3)
|
|
16
|
+
return "conserving";
|
|
17
|
+
if (totalPnl > 0 && budgetRemainingPct > 0.7)
|
|
18
|
+
return "thriving";
|
|
19
|
+
return "normal";
|
|
20
|
+
}
|
package/dist/tuning.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuning hints for the Clawbot LLM.
|
|
3
|
+
* Ported from automaton.py — generate_tuning_hints.
|
|
4
|
+
*/
|
|
5
|
+
import type { SkillState } from "./bandit.js";
|
|
6
|
+
export interface TuningHint {
|
|
7
|
+
skill: string;
|
|
8
|
+
issue: string;
|
|
9
|
+
suggestion: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare function generateTuningHints(skills: SkillState[], budgetUsd: number): TuningHint[];
|
package/dist/tuning.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuning hints for the Clawbot LLM.
|
|
3
|
+
* Ported from automaton.py — generate_tuning_hints.
|
|
4
|
+
*/
|
|
5
|
+
export function generateTuningHints(skills, budgetUsd) {
|
|
6
|
+
const hints = [];
|
|
7
|
+
for (const sk of skills) {
|
|
8
|
+
if (!sk.enabled || sk.timesSelected === 0)
|
|
9
|
+
continue;
|
|
10
|
+
// 1. Zero signals streak
|
|
11
|
+
if (sk.consecutiveZeroSignals >= 5) {
|
|
12
|
+
hints.push({
|
|
13
|
+
skill: sk.slug,
|
|
14
|
+
issue: "zero_signals_streak",
|
|
15
|
+
cycles: sk.consecutiveZeroSignals,
|
|
16
|
+
suggestion: `0 signals for ${sk.consecutiveZeroSignals} cycles — loosen thresholds or widen time windows`,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// 2. Concentrated loss
|
|
20
|
+
if (sk.totalPnl < 0 && budgetUsd > 0) {
|
|
21
|
+
const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
|
|
22
|
+
if (lossPct > 20) {
|
|
23
|
+
hints.push({
|
|
24
|
+
skill: sk.slug,
|
|
25
|
+
issue: "concentrated_loss",
|
|
26
|
+
pnl: Math.round(sk.totalPnl * 100) / 100,
|
|
27
|
+
pct_of_budget: Math.round(lossPct * 10) / 10,
|
|
28
|
+
suggestion: `Lost $${Math.abs(sk.totalPnl).toFixed(2)} (${lossPct.toFixed(0)}% of budget) — consider disabling or reducing max bet`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// 3. Inert — finds signals but never executes
|
|
33
|
+
if (sk.signalsFoundTotal > 50 && sk.tradesExecutedTotal === 0) {
|
|
34
|
+
hints.push({
|
|
35
|
+
skill: sk.slug,
|
|
36
|
+
issue: "inert",
|
|
37
|
+
signals: sk.signalsFoundTotal,
|
|
38
|
+
suggestion: `${sk.signalsFoundTotal} signals found, 0 executed — execution thresholds likely too tight`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// 4. Win rate collapse
|
|
42
|
+
if (sk.timesSelected >= 10) {
|
|
43
|
+
const winRate = sk.timesRewarded / sk.timesSelected;
|
|
44
|
+
if (winRate < 0.2) {
|
|
45
|
+
hints.push({
|
|
46
|
+
skill: sk.slug,
|
|
47
|
+
issue: "win_rate_collapse",
|
|
48
|
+
win_rate: Math.round(winRate * 1000) / 10,
|
|
49
|
+
runs: sk.timesSelected,
|
|
50
|
+
suggestion: `Win rate ${(winRate * 100).toFixed(0)}% over ${sk.timesSelected} cycles — strategy may not suit current markets`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// 5. Safeguard dominant
|
|
55
|
+
const skipCounts = sk.lastCycle?.skipCounts || {};
|
|
56
|
+
const totalSkips = Object.values(skipCounts).reduce((a, b) => a + b, 0);
|
|
57
|
+
const safeguardSkips = skipCounts["safeguard"] || 0;
|
|
58
|
+
if (totalSkips >= 3 && safeguardSkips / totalSkips > 0.8) {
|
|
59
|
+
hints.push({
|
|
60
|
+
skill: sk.slug,
|
|
61
|
+
issue: "safeguard_dominant",
|
|
62
|
+
safeguard_pct: Math.round((safeguardSkips / totalSkips) * 100),
|
|
63
|
+
suggestion: "Most skips are safeguard blocks — markets may be too volatile or near resolution",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return hints;
|
|
68
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "simmer-automaton",
|
|
3
|
+
"name": "Simmer Automaton",
|
|
4
|
+
"description": "Budget-enforced skill orchestration with survival tiers and bandit selection",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"apiKey": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Simmer API key (sk_live_...)"
|
|
13
|
+
},
|
|
14
|
+
"apiUrl": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"default": "https://api.simmer.markets"
|
|
17
|
+
},
|
|
18
|
+
"cycleIntervalMs": {
|
|
19
|
+
"type": "number",
|
|
20
|
+
"default": 300000,
|
|
21
|
+
"description": "Bandit cycle interval in milliseconds (default: 5 min)"
|
|
22
|
+
},
|
|
23
|
+
"epsilon": {
|
|
24
|
+
"type": "number",
|
|
25
|
+
"default": 0.2,
|
|
26
|
+
"description": "Exploration rate for epsilon-greedy bandit (0-1)"
|
|
27
|
+
},
|
|
28
|
+
"epsilonDecay": {
|
|
29
|
+
"type": "number",
|
|
30
|
+
"default": 0.995,
|
|
31
|
+
"description": "Epsilon decay per cycle"
|
|
32
|
+
},
|
|
33
|
+
"minEpsilon": {
|
|
34
|
+
"type": "number",
|
|
35
|
+
"default": 0.05,
|
|
36
|
+
"description": "Minimum exploration rate"
|
|
37
|
+
},
|
|
38
|
+
"maxConcurrent": {
|
|
39
|
+
"type": "number",
|
|
40
|
+
"default": 2,
|
|
41
|
+
"description": "Max skills to run per cycle"
|
|
42
|
+
},
|
|
43
|
+
"venue": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"default": "simmer",
|
|
46
|
+
"description": "Trading venue (simmer for paper, polymarket for live)"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"required": ["apiKey"]
|
|
50
|
+
},
|
|
51
|
+
"uiHints": {
|
|
52
|
+
"apiKey": {
|
|
53
|
+
"label": "Simmer API Key",
|
|
54
|
+
"sensitive": true,
|
|
55
|
+
"placeholder": "sk_live_...",
|
|
56
|
+
"help": "Get from simmer.markets/dashboard"
|
|
57
|
+
},
|
|
58
|
+
"venue": {
|
|
59
|
+
"label": "Trading Venue",
|
|
60
|
+
"help": "Use 'simmer' for paper trading, 'polymarket' for live"
|
|
61
|
+
},
|
|
62
|
+
"epsilon": {
|
|
63
|
+
"label": "Exploration Rate",
|
|
64
|
+
"advanced": true
|
|
65
|
+
},
|
|
66
|
+
"cycleIntervalMs": {
|
|
67
|
+
"label": "Cycle Interval (ms)",
|
|
68
|
+
"advanced": true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simmer-automaton",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Simmer Automaton plugin for OpenClaw — budget-enforced skill orchestration",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["openclaw", "plugin", "simmer", "prediction-markets", "trading"],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"typescript": "^5.4.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simmer API client for the automaton plugin.
|
|
3
|
+
* Thin wrapper around fetch — reads automaton state, skills registry.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface AutomatonState {
|
|
7
|
+
initialized: boolean;
|
|
8
|
+
budget_usd: number;
|
|
9
|
+
spent_usd: number;
|
|
10
|
+
remaining_usd: number;
|
|
11
|
+
halted: boolean;
|
|
12
|
+
tier: string;
|
|
13
|
+
horizon_days: number;
|
|
14
|
+
started_at: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Skill {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
category: string;
|
|
22
|
+
tags: string[];
|
|
23
|
+
difficulty: string;
|
|
24
|
+
install: string;
|
|
25
|
+
clawhub_url: string;
|
|
26
|
+
requires: string[];
|
|
27
|
+
best_when: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SimmerApi {
|
|
31
|
+
private baseUrl: string;
|
|
32
|
+
private apiKey: string;
|
|
33
|
+
|
|
34
|
+
constructor(apiKey: string, baseUrl = "https://api.simmer.markets") {
|
|
35
|
+
this.apiKey = apiKey;
|
|
36
|
+
this.baseUrl = baseUrl;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async request<T>(path: string, opts?: RequestInit): Promise<T> {
|
|
40
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
41
|
+
...opts,
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
...(opts?.headers || {}),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const body = await res.text().catch(() => "");
|
|
50
|
+
throw new Error(`Simmer API ${res.status}: ${body}`);
|
|
51
|
+
}
|
|
52
|
+
return res.json() as Promise<T>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getAutomatonState(): Promise<AutomatonState> {
|
|
56
|
+
return this.request("/api/sdk/automaton/state");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async initAutomaton(budgetUsd: number, horizonDays = 30) {
|
|
60
|
+
return this.request("/api/sdk/automaton/init", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: JSON.stringify({ budget_usd: budgetUsd, horizon_days: horizonDays }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async halt() {
|
|
67
|
+
return this.request("/api/sdk/automaton/halt", { method: "POST" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async resume() {
|
|
71
|
+
return this.request("/api/sdk/automaton/resume", { method: "POST" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getSkills(): Promise<{ skills: Skill[]; total: number }> {
|
|
75
|
+
return this.request("/api/sdk/skills");
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/bandit.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epsilon-greedy bandit for skill selection.
|
|
3
|
+
* Ported from automaton.py — select_skills, _avg_reward, tier_max_skills, tier_effective_epsilon.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SkillState {
|
|
7
|
+
slug: string;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
timesSelected: number;
|
|
10
|
+
timesRewarded: number;
|
|
11
|
+
totalPnl: number;
|
|
12
|
+
consecutiveZeroSignals: number;
|
|
13
|
+
signalsFoundTotal: number;
|
|
14
|
+
tradesExecutedTotal: number;
|
|
15
|
+
lastCycle?: {
|
|
16
|
+
skipCounts?: Record<string, number>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SelectionMeta {
|
|
21
|
+
slug: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
score: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function avgReward(skill: SkillState): number {
|
|
27
|
+
if (skill.timesSelected === 0) return Infinity;
|
|
28
|
+
return skill.totalPnl / skill.timesSelected;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function tierMaxSkills(tier: string, maxConcurrent: number): number {
|
|
32
|
+
if (tier === "thriving" || tier === "normal") return maxConcurrent;
|
|
33
|
+
if (tier === "conserving" || tier === "critical") return 1;
|
|
34
|
+
return 0; // dead
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function tierEffectiveEpsilon(tier: string, epsilon: number): number {
|
|
38
|
+
if (tier === "thriving" || tier === "normal") return epsilon;
|
|
39
|
+
if (tier === "conserving") return epsilon * 0.5;
|
|
40
|
+
return 0.0; // critical, dead — pure exploit or no runs
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function selectSkills(
|
|
44
|
+
skills: SkillState[],
|
|
45
|
+
n: number,
|
|
46
|
+
tier: string,
|
|
47
|
+
epsilon: number,
|
|
48
|
+
): { selected: string[]; meta: SelectionMeta[] } {
|
|
49
|
+
const enabled = skills.filter((s) => s.enabled);
|
|
50
|
+
if (enabled.length === 0) return { selected: [], meta: [] };
|
|
51
|
+
|
|
52
|
+
const effectiveEpsilon = tierEffectiveEpsilon(tier, epsilon);
|
|
53
|
+
const count = Math.min(n, enabled.length);
|
|
54
|
+
|
|
55
|
+
// Unplayed skills get priority
|
|
56
|
+
const unplayed = enabled.filter((s) => s.timesSelected === 0);
|
|
57
|
+
if (unplayed.length > 0) {
|
|
58
|
+
const shuffled = [...unplayed].sort(() => Math.random() - 0.5);
|
|
59
|
+
const picks = shuffled.slice(0, count);
|
|
60
|
+
const meta: SelectionMeta[] = picks.map((s) => ({
|
|
61
|
+
slug: s.slug,
|
|
62
|
+
reason: "unplayed",
|
|
63
|
+
score: null,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
if (picks.length < count) {
|
|
67
|
+
const remaining = enabled.filter((s) => !picks.includes(s));
|
|
68
|
+
const extra = remaining
|
|
69
|
+
.sort(() => Math.random() - 0.5)
|
|
70
|
+
.slice(0, count - picks.length);
|
|
71
|
+
picks.push(...extra);
|
|
72
|
+
meta.push(
|
|
73
|
+
...extra.map((s) => ({
|
|
74
|
+
slug: s.slug,
|
|
75
|
+
reason: "backfill",
|
|
76
|
+
score: Math.round(avgReward(s) * 10000) / 10000,
|
|
77
|
+
})),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
selected: picks.map((s) => s.slug),
|
|
83
|
+
meta,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Critical tier: always pick best
|
|
88
|
+
if (tier === "critical") {
|
|
89
|
+
const ranked = [...enabled].sort(
|
|
90
|
+
(a, b) => avgReward(b) - avgReward(a),
|
|
91
|
+
);
|
|
92
|
+
const picks = ranked.slice(0, count);
|
|
93
|
+
return {
|
|
94
|
+
selected: picks.map((s) => s.slug),
|
|
95
|
+
meta: picks.map((s) => ({
|
|
96
|
+
slug: s.slug,
|
|
97
|
+
reason: "critical_exploit",
|
|
98
|
+
score: Math.round(avgReward(s) * 10000) / 10000,
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Epsilon-greedy
|
|
104
|
+
const selected: SkillState[] = [];
|
|
105
|
+
const meta: SelectionMeta[] = [];
|
|
106
|
+
const available = [...enabled];
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < count && available.length > 0; i++) {
|
|
109
|
+
let pick: SkillState;
|
|
110
|
+
let reason: string;
|
|
111
|
+
|
|
112
|
+
if (Math.random() < effectiveEpsilon) {
|
|
113
|
+
// Explore: random
|
|
114
|
+
const idx = Math.floor(Math.random() * available.length);
|
|
115
|
+
pick = available[idx];
|
|
116
|
+
reason = `explore (ε=${effectiveEpsilon.toFixed(2)})`;
|
|
117
|
+
} else {
|
|
118
|
+
// Exploit: best avg reward
|
|
119
|
+
pick = available.reduce((best, s) =>
|
|
120
|
+
avgReward(s) > avgReward(best) ? s : best,
|
|
121
|
+
);
|
|
122
|
+
reason = "exploit";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
selected.push(pick);
|
|
126
|
+
meta.push({
|
|
127
|
+
slug: pick.slug,
|
|
128
|
+
reason,
|
|
129
|
+
score: Math.round(avgReward(pick) * 10000) / 10000,
|
|
130
|
+
});
|
|
131
|
+
available.splice(available.indexOf(pick), 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { selected: selected.map((s) => s.slug), meta };
|
|
135
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simmer Automaton — OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Budget-enforced skill orchestration with survival tiers.
|
|
5
|
+
* Algorithm decides skill selection (bandit), budget, circuit breakers.
|
|
6
|
+
* LLM decides market-level trades within selected skills.
|
|
7
|
+
* API enforces hard spending limits on every trade.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { SimmerApi } from "./api.js";
|
|
11
|
+
import type { AutomatonState, Skill } from "./api.js";
|
|
12
|
+
import { selectSkills, tierMaxSkills, type SkillState } from "./bandit.js";
|
|
13
|
+
import { computeTier, type Tier } from "./tiers.js";
|
|
14
|
+
import { generateTuningHints } from "./tuning.js";
|
|
15
|
+
|
|
16
|
+
// OpenClaw types — we declare minimal interfaces to avoid requiring the SDK as a dependency
|
|
17
|
+
interface PluginApi {
|
|
18
|
+
pluginConfig?: Record<string, unknown>;
|
|
19
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
20
|
+
on: (hook: string, handler: (...args: unknown[]) => unknown) => void;
|
|
21
|
+
registerService: (service: { id: string; start: (ctx: ServiceCtx) => Promise<void>; stop?: (ctx: ServiceCtx) => Promise<void> }) => void;
|
|
22
|
+
registerCommand: (cmd: { name: string; description: string; acceptsArgs?: boolean; handler: (ctx: CommandCtx) => Promise<{ text: string }> }) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ServiceCtx {
|
|
26
|
+
stateDir: string;
|
|
27
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CommandCtx {
|
|
31
|
+
args?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Plugin-local state (in-memory, refreshed from API each cycle)
|
|
35
|
+
let api: SimmerApi;
|
|
36
|
+
let cachedState: AutomatonState | null = null;
|
|
37
|
+
let cachedSkills: Skill[] = [];
|
|
38
|
+
let banditState: SkillState[] = [];
|
|
39
|
+
let currentTier: Tier = "normal";
|
|
40
|
+
let cycleCount = 0;
|
|
41
|
+
let serviceRunning = false;
|
|
42
|
+
let cycleTimer: ReturnType<typeof setInterval> | null = null;
|
|
43
|
+
|
|
44
|
+
// Config defaults
|
|
45
|
+
let config = {
|
|
46
|
+
apiKey: "",
|
|
47
|
+
apiUrl: "https://api.simmer.markets",
|
|
48
|
+
cycleIntervalMs: 300_000,
|
|
49
|
+
epsilon: 0.2,
|
|
50
|
+
epsilonDecay: 0.995,
|
|
51
|
+
minEpsilon: 0.05,
|
|
52
|
+
maxConcurrent: 2,
|
|
53
|
+
venue: "simmer",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function loadConfig(pluginConfig?: Record<string, unknown>) {
|
|
57
|
+
if (!pluginConfig) return;
|
|
58
|
+
if (pluginConfig.apiKey) config.apiKey = pluginConfig.apiKey as string;
|
|
59
|
+
if (pluginConfig.apiUrl) config.apiUrl = pluginConfig.apiUrl as string;
|
|
60
|
+
if (pluginConfig.cycleIntervalMs) config.cycleIntervalMs = pluginConfig.cycleIntervalMs as number;
|
|
61
|
+
if (pluginConfig.epsilon) config.epsilon = pluginConfig.epsilon as number;
|
|
62
|
+
if (pluginConfig.epsilonDecay) config.epsilonDecay = pluginConfig.epsilonDecay as number;
|
|
63
|
+
if (pluginConfig.minEpsilon) config.minEpsilon = pluginConfig.minEpsilon as number;
|
|
64
|
+
if (pluginConfig.maxConcurrent) config.maxConcurrent = pluginConfig.maxConcurrent as number;
|
|
65
|
+
if (pluginConfig.venue) config.venue = pluginConfig.venue as string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function refreshState(logger: { info: (m: string) => void; error: (m: string) => void }) {
|
|
69
|
+
try {
|
|
70
|
+
cachedState = await api.getAutomatonState();
|
|
71
|
+
const skillsRes = await api.getSkills();
|
|
72
|
+
cachedSkills = skillsRes.skills;
|
|
73
|
+
|
|
74
|
+
if (cachedState.initialized) {
|
|
75
|
+
// Compute tier (totalPnl = 0 for now, will be enriched when P&L tracking is added)
|
|
76
|
+
currentTier = computeTier(cachedState, 0);
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
logger.error(`[simmer] Failed to refresh state: ${e}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildPromptContext(): string {
|
|
84
|
+
if (!cachedState || !cachedState.initialized) {
|
|
85
|
+
return "[Simmer Automaton] Not initialized. Run: POST /api/sdk/automaton/init";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const lines = [
|
|
89
|
+
"## Simmer Automaton — Survival Context (read-only)",
|
|
90
|
+
"",
|
|
91
|
+
`**Tier:** ${currentTier} | **Venue:** ${config.venue}`,
|
|
92
|
+
`**Budget:** $${cachedState.budget_usd.toFixed(2)} | **Spent:** $${cachedState.spent_usd.toFixed(2)} | **Remaining:** $${cachedState.remaining_usd.toFixed(2)}`,
|
|
93
|
+
`**Halted:** ${cachedState.halted ? "YES — all trades blocked" : "no"}`,
|
|
94
|
+
`**Horizon:** ${cachedState.horizon_days} days`,
|
|
95
|
+
"",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Skill picks from bandit (if any)
|
|
99
|
+
if (banditState.length > 0) {
|
|
100
|
+
const n = tierMaxSkills(currentTier, config.maxConcurrent);
|
|
101
|
+
const { selected, meta } = selectSkills(banditState, n, currentTier, config.epsilon);
|
|
102
|
+
if (selected.length > 0) {
|
|
103
|
+
lines.push("**Selected skills this cycle:**");
|
|
104
|
+
for (const m of meta) {
|
|
105
|
+
lines.push(`- ${m.slug} (${m.reason}${m.score !== null ? `, score=${m.score}` : ""})`);
|
|
106
|
+
}
|
|
107
|
+
lines.push("");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Tuning hints
|
|
112
|
+
const hints = generateTuningHints(banditState, cachedState.budget_usd);
|
|
113
|
+
if (hints.length > 0) {
|
|
114
|
+
lines.push("**Tuning hints:**");
|
|
115
|
+
for (const h of hints) {
|
|
116
|
+
lines.push(`- [${h.skill}] ${h.issue}: ${h.suggestion}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (currentTier === "critical") {
|
|
122
|
+
lines.push("**WARNING:** Critical tier — only exploiting best skill, no exploration.");
|
|
123
|
+
} else if (currentTier === "dead") {
|
|
124
|
+
lines.push("**DEAD:** Budget exhausted or horizon expired. No skills will run.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatStatus(): string {
|
|
131
|
+
if (!cachedState || !cachedState.initialized) {
|
|
132
|
+
return "Automaton not initialized. Use POST /api/sdk/automaton/init to start.";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const lines = [
|
|
136
|
+
`Tier: ${currentTier}`,
|
|
137
|
+
`Venue: ${config.venue}`,
|
|
138
|
+
`Budget: $${cachedState.budget_usd.toFixed(2)}`,
|
|
139
|
+
`Spent: $${cachedState.spent_usd.toFixed(2)}`,
|
|
140
|
+
`Remaining: $${cachedState.remaining_usd.toFixed(2)}`,
|
|
141
|
+
`Halted: ${cachedState.halted ? "YES" : "no"}`,
|
|
142
|
+
`Horizon: ${cachedState.horizon_days} days`,
|
|
143
|
+
`Cycles: ${cycleCount}`,
|
|
144
|
+
`Skills available: ${cachedSkills.length}`,
|
|
145
|
+
];
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// =============================================================================
|
|
150
|
+
// Plugin registration
|
|
151
|
+
// =============================================================================
|
|
152
|
+
|
|
153
|
+
export default function register(pluginApi: PluginApi) {
|
|
154
|
+
loadConfig(pluginApi.pluginConfig);
|
|
155
|
+
|
|
156
|
+
if (!config.apiKey) {
|
|
157
|
+
pluginApi.logger.error("[simmer] No apiKey configured — plugin disabled");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
api = new SimmerApi(config.apiKey, config.apiUrl);
|
|
162
|
+
const logger = pluginApi.logger;
|
|
163
|
+
|
|
164
|
+
// --- Hook: before_prompt_build ---
|
|
165
|
+
// Inject survival context into every LLM prompt
|
|
166
|
+
pluginApi.on("before_prompt_build", async () => {
|
|
167
|
+
if (!cachedState) {
|
|
168
|
+
await refreshState(logger);
|
|
169
|
+
}
|
|
170
|
+
return { prependContext: buildPromptContext() };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// --- Service: background bandit cycle ---
|
|
174
|
+
pluginApi.registerService({
|
|
175
|
+
id: "simmer-automaton",
|
|
176
|
+
start: async (ctx: ServiceCtx) => {
|
|
177
|
+
ctx.logger.info("[simmer] Automaton service starting");
|
|
178
|
+
serviceRunning = true;
|
|
179
|
+
|
|
180
|
+
// Initial state fetch
|
|
181
|
+
await refreshState(ctx.logger);
|
|
182
|
+
|
|
183
|
+
// Periodic refresh
|
|
184
|
+
cycleTimer = setInterval(async () => {
|
|
185
|
+
if (!serviceRunning) return;
|
|
186
|
+
cycleCount++;
|
|
187
|
+
await refreshState(ctx.logger);
|
|
188
|
+
|
|
189
|
+
// Decay epsilon
|
|
190
|
+
config.epsilon = Math.max(
|
|
191
|
+
config.minEpsilon,
|
|
192
|
+
config.epsilon * config.epsilonDecay,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
ctx.logger.info(
|
|
196
|
+
`[simmer] Cycle ${cycleCount} | tier=${currentTier} | ε=${config.epsilon.toFixed(3)} | skills=${cachedSkills.length}`,
|
|
197
|
+
);
|
|
198
|
+
}, config.cycleIntervalMs);
|
|
199
|
+
},
|
|
200
|
+
stop: async (ctx: ServiceCtx) => {
|
|
201
|
+
ctx.logger.info("[simmer] Automaton service stopping");
|
|
202
|
+
serviceRunning = false;
|
|
203
|
+
if (cycleTimer) {
|
|
204
|
+
clearInterval(cycleTimer);
|
|
205
|
+
cycleTimer = null;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// --- Commands ---
|
|
211
|
+
|
|
212
|
+
pluginApi.registerCommand({
|
|
213
|
+
name: "simmer",
|
|
214
|
+
description: "Show Simmer automaton status",
|
|
215
|
+
acceptsArgs: true,
|
|
216
|
+
handler: async (ctx: CommandCtx) => {
|
|
217
|
+
const subcommand = ctx.args?.trim().split(/\s+/)[0] || "status";
|
|
218
|
+
|
|
219
|
+
if (subcommand === "status") {
|
|
220
|
+
await refreshState(logger);
|
|
221
|
+
return { text: formatStatus() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (subcommand === "halt") {
|
|
225
|
+
try {
|
|
226
|
+
await api.halt();
|
|
227
|
+
await refreshState(logger);
|
|
228
|
+
return { text: "Automaton halted. All trades will be rejected." };
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return { text: `Failed to halt: ${e}` };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (subcommand === "resume") {
|
|
235
|
+
try {
|
|
236
|
+
await api.resume();
|
|
237
|
+
await refreshState(logger);
|
|
238
|
+
return { text: "Automaton resumed. Trading is active." };
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return { text: `Failed to resume: ${e}` };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (subcommand === "skills") {
|
|
245
|
+
await refreshState(logger);
|
|
246
|
+
if (cachedSkills.length === 0) {
|
|
247
|
+
return { text: "No skills in registry." };
|
|
248
|
+
}
|
|
249
|
+
const lines = cachedSkills.map(
|
|
250
|
+
(s) => `- ${s.name} (${s.id}) — ${s.category}, ${s.difficulty}`,
|
|
251
|
+
);
|
|
252
|
+
return { text: `Skills (${cachedSkills.length}):\n${lines.join("\n")}` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
text: "Usage: /simmer [status|halt|resume|skills]",
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
logger.info("[simmer] Plugin registered");
|
|
262
|
+
}
|
package/src/tiers.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survival tier computation.
|
|
3
|
+
* Ported from automaton.py — compute_tier.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AutomatonState } from "./api.js";
|
|
7
|
+
|
|
8
|
+
export type Tier = "thriving" | "normal" | "conserving" | "critical" | "dead";
|
|
9
|
+
|
|
10
|
+
export function computeTier(state: AutomatonState, totalPnl: number): Tier {
|
|
11
|
+
if (state.budget_usd <= 0) return "dead";
|
|
12
|
+
|
|
13
|
+
const budgetRemainingPct =
|
|
14
|
+
(state.budget_usd - state.spent_usd + totalPnl) / state.budget_usd;
|
|
15
|
+
|
|
16
|
+
const startedAt = state.started_at ? new Date(state.started_at) : new Date();
|
|
17
|
+
const daysElapsed =
|
|
18
|
+
(Date.now() - startedAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
19
|
+
|
|
20
|
+
if (budgetRemainingPct <= 0 || daysElapsed >= state.horizon_days) return "dead";
|
|
21
|
+
if (budgetRemainingPct < 0.1) return "critical";
|
|
22
|
+
if (budgetRemainingPct < 0.3) return "conserving";
|
|
23
|
+
if (totalPnl > 0 && budgetRemainingPct > 0.7) return "thriving";
|
|
24
|
+
return "normal";
|
|
25
|
+
}
|
package/src/tuning.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuning hints for the Clawbot LLM.
|
|
3
|
+
* Ported from automaton.py — generate_tuning_hints.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SkillState } from "./bandit.js";
|
|
7
|
+
|
|
8
|
+
export interface TuningHint {
|
|
9
|
+
skill: string;
|
|
10
|
+
issue: string;
|
|
11
|
+
suggestion: string;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateTuningHints(
|
|
16
|
+
skills: SkillState[],
|
|
17
|
+
budgetUsd: number,
|
|
18
|
+
): TuningHint[] {
|
|
19
|
+
const hints: TuningHint[] = [];
|
|
20
|
+
|
|
21
|
+
for (const sk of skills) {
|
|
22
|
+
if (!sk.enabled || sk.timesSelected === 0) continue;
|
|
23
|
+
|
|
24
|
+
// 1. Zero signals streak
|
|
25
|
+
if (sk.consecutiveZeroSignals >= 5) {
|
|
26
|
+
hints.push({
|
|
27
|
+
skill: sk.slug,
|
|
28
|
+
issue: "zero_signals_streak",
|
|
29
|
+
cycles: sk.consecutiveZeroSignals,
|
|
30
|
+
suggestion: `0 signals for ${sk.consecutiveZeroSignals} cycles — loosen thresholds or widen time windows`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Concentrated loss
|
|
35
|
+
if (sk.totalPnl < 0 && budgetUsd > 0) {
|
|
36
|
+
const lossPct = (Math.abs(sk.totalPnl) / budgetUsd) * 100;
|
|
37
|
+
if (lossPct > 20) {
|
|
38
|
+
hints.push({
|
|
39
|
+
skill: sk.slug,
|
|
40
|
+
issue: "concentrated_loss",
|
|
41
|
+
pnl: Math.round(sk.totalPnl * 100) / 100,
|
|
42
|
+
pct_of_budget: Math.round(lossPct * 10) / 10,
|
|
43
|
+
suggestion: `Lost $${Math.abs(sk.totalPnl).toFixed(2)} (${lossPct.toFixed(0)}% of budget) — consider disabling or reducing max bet`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Inert — finds signals but never executes
|
|
49
|
+
if (sk.signalsFoundTotal > 50 && sk.tradesExecutedTotal === 0) {
|
|
50
|
+
hints.push({
|
|
51
|
+
skill: sk.slug,
|
|
52
|
+
issue: "inert",
|
|
53
|
+
signals: sk.signalsFoundTotal,
|
|
54
|
+
suggestion: `${sk.signalsFoundTotal} signals found, 0 executed — execution thresholds likely too tight`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 4. Win rate collapse
|
|
59
|
+
if (sk.timesSelected >= 10) {
|
|
60
|
+
const winRate = sk.timesRewarded / sk.timesSelected;
|
|
61
|
+
if (winRate < 0.2) {
|
|
62
|
+
hints.push({
|
|
63
|
+
skill: sk.slug,
|
|
64
|
+
issue: "win_rate_collapse",
|
|
65
|
+
win_rate: Math.round(winRate * 1000) / 10,
|
|
66
|
+
runs: sk.timesSelected,
|
|
67
|
+
suggestion: `Win rate ${(winRate * 100).toFixed(0)}% over ${sk.timesSelected} cycles — strategy may not suit current markets`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 5. Safeguard dominant
|
|
73
|
+
const skipCounts = sk.lastCycle?.skipCounts || {};
|
|
74
|
+
const totalSkips = Object.values(skipCounts).reduce(
|
|
75
|
+
(a, b) => a + b,
|
|
76
|
+
0,
|
|
77
|
+
);
|
|
78
|
+
const safeguardSkips = skipCounts["safeguard"] || 0;
|
|
79
|
+
if (totalSkips >= 3 && safeguardSkips / totalSkips > 0.8) {
|
|
80
|
+
hints.push({
|
|
81
|
+
skill: sk.slug,
|
|
82
|
+
issue: "safeguard_dominant",
|
|
83
|
+
safeguard_pct: Math.round((safeguardSkips / totalSkips) * 100),
|
|
84
|
+
suggestion:
|
|
85
|
+
"Most skips are safeguard blocks — markets may be too volatile or near resolution",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return hints;
|
|
91
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|