simmer-automaton 0.6.7 → 0.7.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 CHANGED
@@ -46,6 +46,21 @@ export interface BriefingPosition {
46
46
  resolves_at: string | null;
47
47
  source: string | null;
48
48
  }
49
+ export interface PositionsPosition {
50
+ market_id: string;
51
+ question: string;
52
+ shares_yes: number;
53
+ shares_no: number;
54
+ current_price: number;
55
+ avg_cost: number;
56
+ pnl: number;
57
+ cost_basis: number;
58
+ status: string;
59
+ resolves_at: string | null;
60
+ venue: string;
61
+ currency: string;
62
+ sources: string[];
63
+ }
49
64
  export interface BriefingVenue {
50
65
  currency: string;
51
66
  balance: number | null;
@@ -84,9 +99,16 @@ export interface BriefingResponse {
84
99
  latest: string;
85
100
  message: string;
86
101
  } | null;
102
+ skill_updates: Array<{
103
+ slug: string;
104
+ current: string;
105
+ latest: string;
106
+ message: string;
107
+ }> | null;
87
108
  }
88
109
  export interface SkillOutcome {
89
110
  skill_slug: string;
111
+ venue: string;
90
112
  trades: number;
91
113
  total_cost: number;
92
114
  period_pnl: number;
@@ -140,9 +162,9 @@ export declare class SimmerApi {
140
162
  outcomes: SkillOutcome[];
141
163
  since: string;
142
164
  }>;
143
- getBriefing(): Promise<BriefingResponse>;
165
+ getBriefing(skillVersions?: Record<string, string>): Promise<BriefingResponse>;
144
166
  getPositions(): Promise<{
145
- positions: BriefingPosition[];
167
+ positions: PositionsPosition[];
146
168
  }>;
147
169
  postCycle(data: {
148
170
  active_skills: string[];
@@ -159,9 +181,29 @@ export declare class SimmerApi {
159
181
  new: any;
160
182
  reason: string;
161
183
  }>;
184
+ epsilon?: number;
185
+ bandit_arms?: BanditArmState[];
162
186
  }): Promise<{
163
187
  ok: boolean;
164
188
  active_skills: string[];
165
189
  cycle_number: number;
166
190
  }>;
191
+ getBanditState(): Promise<{
192
+ epsilon: number;
193
+ cycle_count: number;
194
+ arms: BanditArmState[];
195
+ } | null>;
196
+ resetBanditState(): Promise<void>;
197
+ }
198
+ /** Serialized form of a bandit arm written to / read from the API. */
199
+ export interface BanditArmState {
200
+ slug: string;
201
+ enabled: boolean;
202
+ timesSelected: number;
203
+ timesRewarded: number;
204
+ totalPnl: number;
205
+ consecutiveZeroSignals: number;
206
+ signalsFoundTotal: number;
207
+ tradesExecutedTotal: number;
208
+ ewmRoc: number;
167
209
  }
package/dist/api.js CHANGED
@@ -70,11 +70,16 @@ export class SimmerApi {
70
70
  const params = new URLSearchParams({ since });
71
71
  return this.request(`/api/sdk/automaton/outcomes?${params}`);
72
72
  }
73
- async getBriefing() {
74
- return this.request("/api/sdk/briefing");
73
+ async getBriefing(skillVersions) {
74
+ let url = "/api/sdk/briefing";
75
+ if (skillVersions && Object.keys(skillVersions).length > 0) {
76
+ const params = new URLSearchParams({ skill_versions: JSON.stringify(skillVersions) });
77
+ url += `?${params}`;
78
+ }
79
+ return this.request(url);
75
80
  }
76
81
  async getPositions() {
77
- return this.request("/api/sdk/positions");
82
+ return this.request("/api/sdk/positions?status=active");
78
83
  }
79
84
  async postCycle(data) {
80
85
  return this.request("/api/sdk/automaton/cycle", {
@@ -82,4 +87,20 @@ export class SimmerApi {
82
87
  body: JSON.stringify(data),
83
88
  });
84
89
  }
90
+ async getBanditState() {
91
+ try {
92
+ return await this.request("/api/sdk/automaton/bandit-state");
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ async resetBanditState() {
99
+ try {
100
+ await this.request("/api/sdk/automaton/bandit-state", { method: "DELETE" });
101
+ }
102
+ catch {
103
+ // Best-effort — don't block re-init if this fails
104
+ }
105
+ }
85
106
  }
package/dist/bandit.d.ts CHANGED
@@ -11,10 +11,15 @@ export interface SkillState {
11
11
  consecutiveZeroSignals: number;
12
12
  signalsFoundTotal: number;
13
13
  tradesExecutedTotal: number;
14
+ /** Exponentially weighted return-on-capital (period_pnl / total_cost per cycle).
15
+ * Alpha=0.1 gives ~7-cycle half-life. Primary bandit arm score. */
16
+ ewmRoc: number;
14
17
  lastCycle?: {
15
18
  skipCounts?: Record<string, number>;
16
19
  };
17
20
  }
21
+ /** EWM decay factor. Alpha=0.1 → ~7-cycle half-life. */
22
+ export declare const EWM_ALPHA = 0.1;
18
23
  export interface SelectionMeta {
19
24
  slug: string;
20
25
  reason: string;
package/dist/bandit.js CHANGED
@@ -2,15 +2,19 @@
2
2
  * Epsilon-greedy bandit for skill selection.
3
3
  * Ported from automaton.py — select_skills, _avg_reward, tier_max_skills, tier_effective_epsilon.
4
4
  */
5
+ /** EWM decay factor. Alpha=0.1 → ~7-cycle half-life. */
6
+ export const EWM_ALPHA = 0.1;
5
7
  function avgReward(skill) {
6
8
  if (skill.timesSelected === 0)
7
9
  return Infinity;
8
- // Use P&L as primary signal. When P&L is unavailable (0), fall back to
9
- // trade execution rate as bootstrap a skill that finds and executes trades
10
- // is better than one that finds nothing.
11
- if (skill.totalPnl !== 0)
12
- return skill.totalPnl / skill.timesSelected;
13
- return skill.tradesExecutedTotal / skill.timesSelected;
10
+ // Primary: exponentially weighted return-on-capital. Normalizes for position
11
+ // size and decays stale regime data (~7-cycle half-life at alpha=0.1).
12
+ if (skill.ewmRoc !== 0)
13
+ return skill.ewmRoc;
14
+ // Bootstrap: a skill that executes trades at all beats one that finds nothing.
15
+ if (skill.tradesExecutedTotal > 0)
16
+ return 0.001;
17
+ return 0;
14
18
  }
15
19
  export function tierMaxSkills(tier, maxConcurrent) {
16
20
  if (tier === "thriving" || tier === "normal")
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * API enforces hard spending limits on every trade.
8
8
  */
9
9
  import { SimmerApi } from "./api.js";
10
- import { selectSkills, tierMaxSkills } from "./bandit.js";
10
+ import { selectSkills, tierMaxSkills, EWM_ALPHA } from "./bandit.js";
11
11
  import { computeTier } from "./tiers.js";
12
12
  import { generateTuningHints, computeTuningChanges } from "./tuning.js";
13
13
  // Plugin-local state (in-memory, refreshed from API each cycle)
@@ -18,6 +18,9 @@ let cachedSkills = [];
18
18
  let banditState = [];
19
19
  let currentTier = "normal";
20
20
  let cycleCount = 0;
21
+ let lastUpdateCheckCycle = 0;
22
+ const UPDATE_CHECK_INTERVAL = 50; // cycles between version checks
23
+ const INSTALL_TIMEOUT_MS = 30_000; // 30s per skill install
21
24
  let currentSelectedMeta = [];
22
25
  let lastExecutionResults = [];
23
26
  let cachedPortfolio = null;
@@ -34,7 +37,7 @@ let config = {
34
37
  epsilonDecay: 0.995,
35
38
  minEpsilon: 0.05,
36
39
  maxConcurrent: 2,
37
- venue: "simmer",
40
+ venue: "sim",
38
41
  };
39
42
  function loadConfig(pluginConfig) {
40
43
  if (!pluginConfig)
@@ -75,9 +78,12 @@ async function refreshState(logger) {
75
78
  if (lastKnownStartedAt !== null) {
76
79
  logger.info(`[simmer] Detected re-init (started_at changed) — resetting cycle counter and bandit state`);
77
80
  cycleCount = 0;
81
+ config.epsilon = 0.2;
78
82
  banditState = [];
79
83
  currentSelectedMeta = [];
80
84
  lastCycleTimestamp = new Date().toISOString();
85
+ // Delete persisted bandit state so the next startup doesn't restore stale arms
86
+ api.resetBanditState().catch((e) => logger.warn(`[simmer] Failed to reset bandit state: ${e}`));
81
87
  }
82
88
  lastKnownStartedAt = cachedState.started_at;
83
89
  }
@@ -102,6 +108,7 @@ async function refreshState(logger) {
102
108
  timesSelected: 0,
103
109
  timesRewarded: 0,
104
110
  totalPnl: 0,
111
+ ewmRoc: 0,
105
112
  consecutiveZeroSignals: 0,
106
113
  signalsFoundTotal: 0,
107
114
  tradesExecutedTotal: 0,
@@ -115,7 +122,7 @@ async function refreshState(logger) {
115
122
  }
116
123
  function fmtCurrency(amount) {
117
124
  const val = amount.toFixed(2);
118
- return config.venue === "simmer" ? `${val} $SIM` : `$${val}`;
125
+ return (config.venue === "sim" || config.venue === "simmer") ? `${val} $SIM` : `$${val}`;
119
126
  }
120
127
  function buildPromptContext() {
121
128
  if (!cachedState || !cachedState.initialized) {
@@ -216,9 +223,73 @@ function formatStatus() {
216
223
  `Cycles: ${cycleCount}`,
217
224
  `Skills available: ${cachedSkills.length}`,
218
225
  ];
226
+ if (cachedPortfolio) {
227
+ const pnlStr = cachedPortfolio.totalPnl >= 0
228
+ ? `+${fmtCurrency(cachedPortfolio.totalPnl)}`
229
+ : fmtCurrency(cachedPortfolio.totalPnl);
230
+ lines.push(`Positions: ${cachedPortfolio.positionCount} open | P&L: ${pnlStr} | Recent trades: ${cachedPortfolio.recentTradeCount}`);
231
+ const top = cachedPortfolio.positions.slice(0, 3);
232
+ for (const p of top) {
233
+ const pnl = p.pnl >= 0 ? `+${fmtCurrency(p.pnl)}` : fmtCurrency(p.pnl);
234
+ lines.push(` · ${p.question.slice(0, 55)} | ${p.side} | ${pnl}`);
235
+ }
236
+ if (cachedPortfolio.positions.length > 3) {
237
+ lines.push(` · ...and ${cachedPortfolio.positions.length - 3} more (use /simmer portfolio for full list)`);
238
+ }
239
+ }
219
240
  return lines.join("\n");
220
241
  }
221
242
  // =============================================================================
243
+ // Skill auto-update — keep installed skills at latest ClawHub version
244
+ // =============================================================================
245
+ /**
246
+ * Read installed skill versions from SKILL.md frontmatter.
247
+ * Returns { slug: version } for skills that have a parseable version.
248
+ */
249
+ function getLocalSkillVersions(workspaceDir) {
250
+ const fs = require("fs");
251
+ const cwd = workspaceDir || process.cwd();
252
+ const versions = {};
253
+ for (const skill of cachedSkills) {
254
+ const slug = skill.id;
255
+ try {
256
+ const skillMd = fs.readFileSync(`${cwd}/skills/${slug}/SKILL.md`, "utf-8");
257
+ const versionMatch = skillMd.match(/^\s*version:\s*["']?([^"'\n]+)["']?/m);
258
+ if (versionMatch) {
259
+ versions[slug] = versionMatch[1].trim();
260
+ }
261
+ }
262
+ catch {
263
+ // Skill not installed locally or no SKILL.md — skip
264
+ }
265
+ }
266
+ return versions;
267
+ }
268
+ /**
269
+ * Check for outdated skills and auto-install updates.
270
+ * Uses the skill_updates from the most recent briefing response.
271
+ */
272
+ async function checkAndUpdateSkills(skillUpdates, workspaceDir, logger) {
273
+ if (!runtime || skillUpdates.length === 0)
274
+ return;
275
+ const cwd = workspaceDir || process.cwd();
276
+ logger.info(`[simmer] ${skillUpdates.length} skill update(s) available: ${skillUpdates.map((u) => `${u.slug} ${u.current}→${u.latest}`).join(", ")}`);
277
+ for (const update of skillUpdates) {
278
+ try {
279
+ const result = await runtime.system.runCommandWithTimeout(["npx", "clawhub@latest", "install", update.slug], { timeoutMs: INSTALL_TIMEOUT_MS, cwd });
280
+ if (result.code === 0) {
281
+ logger.info(`[simmer] Updated ${update.slug} to ${update.latest}`);
282
+ }
283
+ else {
284
+ logger.warn(`[simmer] Failed to update ${update.slug}: exit ${result.code} — ${result.stderr.slice(0, 200)}`);
285
+ }
286
+ }
287
+ catch (e) {
288
+ logger.warn(`[simmer] Failed to update ${update.slug}: ${e}`);
289
+ }
290
+ }
291
+ }
292
+ // =============================================================================
222
293
  // Skill execution — deterministic, no LLM in the loop
223
294
  // =============================================================================
224
295
  async function executeSkills(selectedSlugs, workspaceDir, logger) {
@@ -324,8 +395,49 @@ export default function register(pluginApi) {
324
395
  start: async (ctx) => {
325
396
  ctx.logger.info("[simmer] Automaton service starting");
326
397
  serviceRunning = true;
327
- // Initial state fetch
398
+ // Initial state fetch (seeds banditState with current skills)
328
399
  await refreshState(ctx.logger);
400
+ // Restore persisted bandit arm statistics — overwrite in-memory state seeded above.
401
+ // This lets the bandit resume learning after a plugin restart without starting cold.
402
+ try {
403
+ const saved = await api.getBanditState();
404
+ if (saved && saved.arms.length > 0) {
405
+ const savedBySlug = new Map(saved.arms.map((a) => [a.slug, a]));
406
+ banditState = banditState.map((arm) => {
407
+ const s = savedBySlug.get(arm.slug);
408
+ if (s) {
409
+ // Restore learned stats; keep live enabled status from skills registry
410
+ return {
411
+ ...s,
412
+ enabled: arm.enabled,
413
+ };
414
+ }
415
+ return arm; // new skill not in saved state — start fresh
416
+ });
417
+ config.epsilon = saved.epsilon;
418
+ cycleCount = saved.cycle_count;
419
+ ctx.logger.info(`[simmer] Restored bandit state: ${saved.arms.length} arms, ε=${saved.epsilon.toFixed(3)}, cycle=${saved.cycle_count}`);
420
+ }
421
+ else {
422
+ ctx.logger.info("[simmer] No saved bandit state — starting fresh");
423
+ }
424
+ }
425
+ catch (e) {
426
+ ctx.logger.warn(`[simmer] Failed to restore bandit state: ${e}`);
427
+ }
428
+ // Check for skill updates on startup
429
+ try {
430
+ const skillVersions = getLocalSkillVersions(ctx.workspaceDir);
431
+ if (Object.keys(skillVersions).length > 0) {
432
+ const briefing = await api.getBriefing(skillVersions);
433
+ if (briefing.skill_updates && briefing.skill_updates.length > 0) {
434
+ await checkAndUpdateSkills(briefing.skill_updates, ctx.workspaceDir, ctx.logger);
435
+ }
436
+ }
437
+ }
438
+ catch (e) {
439
+ ctx.logger.warn(`[simmer] Startup skill update check failed: ${e}`);
440
+ }
329
441
  // Periodic refresh
330
442
  cycleTimer = setInterval(async () => {
331
443
  if (!serviceRunning)
@@ -336,20 +448,26 @@ export default function register(pluginApi) {
336
448
  lastCycleTimestamp = new Date().toISOString();
337
449
  await refreshState(ctx.logger);
338
450
  // Query outcomes since last cycle and update bandit reward data
451
+ // Filter to outcomes matching this plugin's configured venue
339
452
  try {
340
453
  const outcomeRes = await api.getOutcomes(cycleStarted);
341
- for (const o of outcomeRes.outcomes) {
454
+ const venueOutcomes = outcomeRes.outcomes.filter((o) => o.venue === config.venue);
455
+ for (const o of venueOutcomes) {
342
456
  const skill = banditState.find((s) => s.slug === o.skill_slug);
343
457
  if (skill) {
344
458
  skill.tradesExecutedTotal += o.trades;
345
- skill.timesRewarded += o.trades > 0 ? 1 : 0;
459
+ // Return-on-capital for this cycle (0 if no cost data)
460
+ const roc = o.total_cost > 0 ? o.period_pnl / o.total_cost : 0;
461
+ // Exponentially weighted RoC — decays stale data (~7-cycle half-life)
462
+ skill.ewmRoc = EWM_ALPHA * roc + (1 - EWM_ALPHA) * skill.ewmRoc;
463
+ skill.timesRewarded += roc > 0 ? 1 : 0;
346
464
  skill.totalPnl += o.period_pnl;
347
465
  skill.consecutiveZeroSignals = o.trades > 0 ? 0 : skill.consecutiveZeroSignals + 1;
348
466
  }
349
467
  }
350
468
  // Skills that were selected last cycle but had zero trades won't appear in outcomes
351
469
  // (GROUP BY only returns rows with trades). Increment consecutiveZeroSignals for them.
352
- const outcomeSkillSlugs = new Set(outcomeRes.outcomes.map((o) => o.skill_slug));
470
+ const outcomeSkillSlugs = new Set(venueOutcomes.map((o) => o.skill_slug));
353
471
  for (const m of currentSelectedMeta) {
354
472
  if (!outcomeSkillSlugs.has(m.slug)) {
355
473
  const skill = banditState.find((s) => s.slug === m.slug);
@@ -358,8 +476,8 @@ export default function register(pluginApi) {
358
476
  }
359
477
  }
360
478
  }
361
- if (outcomeRes.outcomes.length > 0) {
362
- ctx.logger.info(`[simmer] Outcomes: ${outcomeRes.outcomes.map((o) => `${o.skill_slug}:${o.trades}t`).join(", ")}`);
479
+ if (venueOutcomes.length > 0) {
480
+ ctx.logger.info(`[simmer] Outcomes (${config.venue}): ${venueOutcomes.map((o) => `${o.skill_slug}:${o.trades}t/$${o.period_pnl.toFixed(2)}pnl`).join(", ")}`);
363
481
  }
364
482
  }
365
483
  catch (e) {
@@ -403,6 +521,19 @@ export default function register(pluginApi) {
403
521
  cycle_number: thisCycleNumber,
404
522
  selection_meta: meta.map((m) => ({ slug: m.slug, reason: m.reason, score: m.score })),
405
523
  config_changes: configChangesPayload,
524
+ // Bandit persistence — write current arm stats so restarts resume where we left off
525
+ epsilon: parseFloat(config.epsilon.toFixed(6)),
526
+ bandit_arms: banditState.map((s) => ({
527
+ slug: s.slug,
528
+ enabled: s.enabled,
529
+ timesSelected: s.timesSelected,
530
+ timesRewarded: s.timesRewarded,
531
+ totalPnl: s.totalPnl,
532
+ consecutiveZeroSignals: s.consecutiveZeroSignals,
533
+ signalsFoundTotal: s.signalsFoundTotal,
534
+ tradesExecutedTotal: s.tradesExecutedTotal,
535
+ ewmRoc: s.ewmRoc,
536
+ })),
406
537
  });
407
538
  }
408
539
  catch (e) {
@@ -423,8 +554,9 @@ export default function register(pluginApi) {
423
554
  }
424
555
  // Fetch portfolio snapshot for prompt context
425
556
  try {
426
- const briefing = await api.getBriefing();
427
- const venue = briefing.venues?.simmer;
557
+ const skillVersions = getLocalSkillVersions(ctx.workspaceDir);
558
+ const briefing = await api.getBriefing(skillVersions);
559
+ const venue = briefing.venues?.[config.venue];
428
560
  const attentionPositions = venue?.positions_needing_attention || [];
429
561
  cachedPortfolio = {
430
562
  totalPnl: briefing.performance?.total_pnl ?? 0,
@@ -432,6 +564,12 @@ export default function register(pluginApi) {
432
564
  positions: attentionPositions,
433
565
  recentTradeCount: 0,
434
566
  };
567
+ // Periodic skill auto-update
568
+ if (briefing.skill_updates && briefing.skill_updates.length > 0 &&
569
+ thisCycleNumber - lastUpdateCheckCycle >= UPDATE_CHECK_INTERVAL) {
570
+ lastUpdateCheckCycle = thisCycleNumber;
571
+ await checkAndUpdateSkills(briefing.skill_updates, ctx.workspaceDir, ctx.logger);
572
+ }
435
573
  }
436
574
  catch (e) {
437
575
  ctx.logger.warn(`[simmer] Failed to fetch briefing: ${e}`);
@@ -629,8 +767,11 @@ export default function register(pluginApi) {
629
767
  return { text: "No open positions." };
630
768
  }
631
769
  const lines = positions.map((p) => {
770
+ const shares = p.shares_yes + p.shares_no;
771
+ const side = p.shares_yes > 0 && p.shares_no > 0 ? "both" : (p.shares_yes > 0 ? "yes" : "no");
632
772
  const pnlStr = p.pnl >= 0 ? `+${fmtCurrency(p.pnl)}` : fmtCurrency(p.pnl);
633
- return `${p.question.slice(0, 55)} | ${p.side} ${p.shares}sh @ ${p.avg_entry.toFixed(2)} → ${p.current_price.toFixed(2)} | ${pnlStr}${p.source ? ` [${p.source}]` : ""}`;
773
+ const src = p.sources?.length ? ` [${p.sources[0]}]` : "";
774
+ return `${p.question.slice(0, 55)} | ${side} ${shares.toFixed(1)}sh @ ${p.avg_cost.toFixed(2)} → ${p.current_price.toFixed(2)} | ${pnlStr}${src}`;
634
775
  });
635
776
  const totalPnl = positions.reduce((sum, p) => sum + p.pnl, 0);
636
777
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simmer-automaton",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "description": "Simmer Automaton plugin for OpenClaw — autonomous trading skill orchestration",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/api.ts CHANGED
@@ -51,6 +51,22 @@ export interface BriefingPosition {
51
51
  source: string | null;
52
52
  }
53
53
 
54
+ export interface PositionsPosition {
55
+ market_id: string;
56
+ question: string;
57
+ shares_yes: number;
58
+ shares_no: number;
59
+ current_price: number;
60
+ avg_cost: number;
61
+ pnl: number;
62
+ cost_basis: number;
63
+ status: string;
64
+ resolves_at: string | null;
65
+ venue: string;
66
+ currency: string;
67
+ sources: string[];
68
+ }
69
+
54
70
  export interface BriefingVenue {
55
71
  currency: string;
56
72
  balance: number | null;
@@ -83,10 +99,12 @@ export interface BriefingResponse {
83
99
  };
84
100
  checked_at: string;
85
101
  sdk_update: { current: string; latest: string; message: string } | null;
102
+ skill_updates: Array<{ slug: string; current: string; latest: string; message: string }> | null;
86
103
  }
87
104
 
88
105
  export interface SkillOutcome {
89
106
  skill_slug: string;
107
+ venue: string;
90
108
  trades: number;
91
109
  total_cost: number;
92
110
  period_pnl: number;
@@ -185,12 +203,17 @@ export class SimmerApi {
185
203
  return this.request(`/api/sdk/automaton/outcomes?${params}`);
186
204
  }
187
205
 
188
- async getBriefing(): Promise<BriefingResponse> {
189
- return this.request("/api/sdk/briefing");
206
+ async getBriefing(skillVersions?: Record<string, string>): Promise<BriefingResponse> {
207
+ let url = "/api/sdk/briefing";
208
+ if (skillVersions && Object.keys(skillVersions).length > 0) {
209
+ const params = new URLSearchParams({ skill_versions: JSON.stringify(skillVersions) });
210
+ url += `?${params}`;
211
+ }
212
+ return this.request(url);
190
213
  }
191
214
 
192
- async getPositions(): Promise<{ positions: BriefingPosition[] }> {
193
- return this.request("/api/sdk/positions");
215
+ async getPositions(): Promise<{ positions: PositionsPosition[] }> {
216
+ return this.request("/api/sdk/positions?status=active");
194
217
  }
195
218
 
196
219
  async postCycle(data: {
@@ -198,10 +221,41 @@ export class SimmerApi {
198
221
  cycle_number: number;
199
222
  selection_meta: Array<{ slug: string; reason: string; score: number | null }>;
200
223
  config_changes: Array<{ slug: string; env: string; old: any; new: any; reason: string }>;
224
+ epsilon?: number;
225
+ bandit_arms?: BanditArmState[];
201
226
  }): Promise<{ ok: boolean; active_skills: string[]; cycle_number: number }> {
202
227
  return this.request("/api/sdk/automaton/cycle", {
203
228
  method: "POST",
204
229
  body: JSON.stringify(data),
205
230
  });
206
231
  }
232
+
233
+ async getBanditState(): Promise<{ epsilon: number; cycle_count: number; arms: BanditArmState[] } | null> {
234
+ try {
235
+ return await this.request("/api/sdk/automaton/bandit-state");
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ async resetBanditState(): Promise<void> {
242
+ try {
243
+ await this.request("/api/sdk/automaton/bandit-state", { method: "DELETE" });
244
+ } catch {
245
+ // Best-effort — don't block re-init if this fails
246
+ }
247
+ }
248
+ }
249
+
250
+ /** Serialized form of a bandit arm written to / read from the API. */
251
+ export interface BanditArmState {
252
+ slug: string;
253
+ enabled: boolean;
254
+ timesSelected: number;
255
+ timesRewarded: number;
256
+ totalPnl: number;
257
+ consecutiveZeroSignals: number;
258
+ signalsFoundTotal: number;
259
+ tradesExecutedTotal: number;
260
+ ewmRoc: number;
207
261
  }
package/src/bandit.ts CHANGED
@@ -12,11 +12,17 @@ export interface SkillState {
12
12
  consecutiveZeroSignals: number;
13
13
  signalsFoundTotal: number;
14
14
  tradesExecutedTotal: number;
15
+ /** Exponentially weighted return-on-capital (period_pnl / total_cost per cycle).
16
+ * Alpha=0.1 gives ~7-cycle half-life. Primary bandit arm score. */
17
+ ewmRoc: number;
15
18
  lastCycle?: {
16
19
  skipCounts?: Record<string, number>;
17
20
  };
18
21
  }
19
22
 
23
+ /** EWM decay factor. Alpha=0.1 → ~7-cycle half-life. */
24
+ export const EWM_ALPHA = 0.1;
25
+
20
26
  export interface SelectionMeta {
21
27
  slug: string;
22
28
  reason: string;
@@ -25,11 +31,12 @@ export interface SelectionMeta {
25
31
 
26
32
  function avgReward(skill: SkillState): number {
27
33
  if (skill.timesSelected === 0) return Infinity;
28
- // Use P&L as primary signal. When P&L is unavailable (0), fall back to
29
- // trade execution rate as bootstrap a skill that finds and executes trades
30
- // is better than one that finds nothing.
31
- if (skill.totalPnl !== 0) return skill.totalPnl / skill.timesSelected;
32
- return skill.tradesExecutedTotal / skill.timesSelected;
34
+ // Primary: exponentially weighted return-on-capital. Normalizes for position
35
+ // size and decays stale regime data (~7-cycle half-life at alpha=0.1).
36
+ if (skill.ewmRoc !== 0) return skill.ewmRoc;
37
+ // Bootstrap: a skill that executes trades at all beats one that finds nothing.
38
+ if (skill.tradesExecutedTotal > 0) return 0.001;
39
+ return 0;
33
40
  }
34
41
 
35
42
  export function tierMaxSkills(tier: string, maxConcurrent: number): number {
package/src/index.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import { SimmerApi } from "./api.js";
11
- import type { AutomatonState, Skill, SkillOutcome, BriefingPosition } from "./api.js";
12
- import { selectSkills, tierMaxSkills, type SkillState } from "./bandit.js";
11
+ import type { AutomatonState, Skill, SkillOutcome, BriefingPosition, PositionsPosition, BanditArmState } from "./api.js";
12
+ import { selectSkills, tierMaxSkills, EWM_ALPHA, type SkillState } from "./bandit.js";
13
13
  import { computeTier, type Tier } from "./tiers.js";
14
14
  import { generateTuningHints, computeTuningChanges, type ConfigChange } from "./tuning.js";
15
15
 
@@ -56,6 +56,9 @@ let cachedSkills: Skill[] = [];
56
56
  let banditState: SkillState[] = [];
57
57
  let currentTier: Tier = "normal";
58
58
  let cycleCount = 0;
59
+ let lastUpdateCheckCycle = 0;
60
+ const UPDATE_CHECK_INTERVAL = 50; // cycles between version checks
61
+ const INSTALL_TIMEOUT_MS = 30_000; // 30s per skill install
59
62
  let currentSelectedMeta: Array<{ slug: string; reason: string; score: number | null }> = [];
60
63
  let lastExecutionResults: Array<{ slug: string; ok: boolean; detail: string }> = [];
61
64
  let cachedPortfolio: { totalPnl: number; positionCount: number; positions: BriefingPosition[]; recentTradeCount: number } | null = null;
@@ -73,7 +76,7 @@ let config = {
73
76
  epsilonDecay: 0.995,
74
77
  minEpsilon: 0.05,
75
78
  maxConcurrent: 2,
76
- venue: "simmer",
79
+ venue: "sim",
77
80
  };
78
81
 
79
82
  function loadConfig(pluginConfig?: Record<string, unknown>) {
@@ -88,7 +91,7 @@ function loadConfig(pluginConfig?: Record<string, unknown>) {
88
91
  if (pluginConfig.venue) config.venue = pluginConfig.venue as string;
89
92
  }
90
93
 
91
- async function refreshState(logger: { info: (m: string) => void; error: (m: string) => void }) {
94
+ async function refreshState(logger: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }) {
92
95
  try {
93
96
  cachedState = await api.getAutomatonState();
94
97
  // Use automaton skills endpoint (respects per-user enable/disable prefs)
@@ -107,9 +110,12 @@ async function refreshState(logger: { info: (m: string) => void; error: (m: stri
107
110
  if (lastKnownStartedAt !== null) {
108
111
  logger.info(`[simmer] Detected re-init (started_at changed) — resetting cycle counter and bandit state`);
109
112
  cycleCount = 0;
113
+ config.epsilon = 0.2;
110
114
  banditState = [];
111
115
  currentSelectedMeta = [];
112
116
  lastCycleTimestamp = new Date().toISOString();
117
+ // Delete persisted bandit state so the next startup doesn't restore stale arms
118
+ api.resetBanditState().catch((e) => logger.warn(`[simmer] Failed to reset bandit state: ${e}`));
113
119
  }
114
120
  lastKnownStartedAt = cachedState.started_at;
115
121
  }
@@ -135,6 +141,7 @@ async function refreshState(logger: { info: (m: string) => void; error: (m: stri
135
141
  timesSelected: 0,
136
142
  timesRewarded: 0,
137
143
  totalPnl: 0,
144
+ ewmRoc: 0,
138
145
  consecutiveZeroSignals: 0,
139
146
  signalsFoundTotal: 0,
140
147
  tradesExecutedTotal: 0,
@@ -148,7 +155,7 @@ async function refreshState(logger: { info: (m: string) => void; error: (m: stri
148
155
 
149
156
  function fmtCurrency(amount: number): string {
150
157
  const val = amount.toFixed(2);
151
- return config.venue === "simmer" ? `${val} $SIM` : `$${val}`;
158
+ return (config.venue === "sim" || config.venue === "simmer") ? `${val} $SIM` : `$${val}`;
152
159
  }
153
160
 
154
161
  function buildPromptContext(): string {
@@ -262,9 +269,83 @@ function formatStatus(): string {
262
269
  `Cycles: ${cycleCount}`,
263
270
  `Skills available: ${cachedSkills.length}`,
264
271
  ];
272
+
273
+ if (cachedPortfolio) {
274
+ const pnlStr = cachedPortfolio.totalPnl >= 0
275
+ ? `+${fmtCurrency(cachedPortfolio.totalPnl)}`
276
+ : fmtCurrency(cachedPortfolio.totalPnl);
277
+ lines.push(`Positions: ${cachedPortfolio.positionCount} open | P&L: ${pnlStr} | Recent trades: ${cachedPortfolio.recentTradeCount}`);
278
+ const top = cachedPortfolio.positions.slice(0, 3);
279
+ for (const p of top) {
280
+ const pnl = p.pnl >= 0 ? `+${fmtCurrency(p.pnl)}` : fmtCurrency(p.pnl);
281
+ lines.push(` · ${p.question.slice(0, 55)} | ${p.side} | ${pnl}`);
282
+ }
283
+ if (cachedPortfolio.positions.length > 3) {
284
+ lines.push(` · ...and ${cachedPortfolio.positions.length - 3} more (use /simmer portfolio for full list)`);
285
+ }
286
+ }
287
+
265
288
  return lines.join("\n");
266
289
  }
267
290
 
291
+ // =============================================================================
292
+ // Skill auto-update — keep installed skills at latest ClawHub version
293
+ // =============================================================================
294
+
295
+ /**
296
+ * Read installed skill versions from SKILL.md frontmatter.
297
+ * Returns { slug: version } for skills that have a parseable version.
298
+ */
299
+ function getLocalSkillVersions(workspaceDir: string | undefined): Record<string, string> {
300
+ const fs = require("fs");
301
+ const cwd = workspaceDir || process.cwd();
302
+ const versions: Record<string, string> = {};
303
+ for (const skill of cachedSkills) {
304
+ const slug = skill.id;
305
+ try {
306
+ const skillMd = fs.readFileSync(`${cwd}/skills/${slug}/SKILL.md`, "utf-8");
307
+ const versionMatch = skillMd.match(/^\s*version:\s*["']?([^"'\n]+)["']?/m);
308
+ if (versionMatch) {
309
+ versions[slug] = versionMatch[1].trim();
310
+ }
311
+ } catch {
312
+ // Skill not installed locally or no SKILL.md — skip
313
+ }
314
+ }
315
+ return versions;
316
+ }
317
+
318
+ /**
319
+ * Check for outdated skills and auto-install updates.
320
+ * Uses the skill_updates from the most recent briefing response.
321
+ */
322
+ async function checkAndUpdateSkills(
323
+ skillUpdates: Array<{ slug: string; current: string; latest: string; message: string }>,
324
+ workspaceDir: string | undefined,
325
+ logger: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void },
326
+ ): Promise<void> {
327
+ if (!runtime || skillUpdates.length === 0) return;
328
+
329
+ const cwd = workspaceDir || process.cwd();
330
+ logger.info(`[simmer] ${skillUpdates.length} skill update(s) available: ${skillUpdates.map((u) => `${u.slug} ${u.current}→${u.latest}`).join(", ")}`);
331
+
332
+ for (const update of skillUpdates) {
333
+ try {
334
+ const result = await runtime.system.runCommandWithTimeout(
335
+ ["npx", "clawhub@latest", "install", update.slug],
336
+ { timeoutMs: INSTALL_TIMEOUT_MS, cwd },
337
+ );
338
+ if (result.code === 0) {
339
+ logger.info(`[simmer] Updated ${update.slug} to ${update.latest}`);
340
+ } else {
341
+ logger.warn(`[simmer] Failed to update ${update.slug}: exit ${result.code} — ${result.stderr.slice(0, 200)}`);
342
+ }
343
+ } catch (e) {
344
+ logger.warn(`[simmer] Failed to update ${update.slug}: ${e}`);
345
+ }
346
+ }
347
+ }
348
+
268
349
  // =============================================================================
269
350
  // Skill execution — deterministic, no LLM in the loop
270
351
  // =============================================================================
@@ -389,9 +470,51 @@ export default function register(pluginApi: PluginApi) {
389
470
  ctx.logger.info("[simmer] Automaton service starting");
390
471
  serviceRunning = true;
391
472
 
392
- // Initial state fetch
473
+ // Initial state fetch (seeds banditState with current skills)
393
474
  await refreshState(ctx.logger);
394
475
 
476
+ // Restore persisted bandit arm statistics — overwrite in-memory state seeded above.
477
+ // This lets the bandit resume learning after a plugin restart without starting cold.
478
+ try {
479
+ const saved = await api.getBanditState();
480
+ if (saved && saved.arms.length > 0) {
481
+ const savedBySlug = new Map(saved.arms.map((a: BanditArmState) => [a.slug, a]));
482
+ banditState = banditState.map((arm) => {
483
+ const s = savedBySlug.get(arm.slug);
484
+ if (s) {
485
+ // Restore learned stats; keep live enabled status from skills registry
486
+ return {
487
+ ...s,
488
+ enabled: arm.enabled,
489
+ };
490
+ }
491
+ return arm; // new skill not in saved state — start fresh
492
+ });
493
+ config.epsilon = saved.epsilon;
494
+ cycleCount = saved.cycle_count;
495
+ ctx.logger.info(
496
+ `[simmer] Restored bandit state: ${saved.arms.length} arms, ε=${saved.epsilon.toFixed(3)}, cycle=${saved.cycle_count}`,
497
+ );
498
+ } else {
499
+ ctx.logger.info("[simmer] No saved bandit state — starting fresh");
500
+ }
501
+ } catch (e) {
502
+ ctx.logger.warn(`[simmer] Failed to restore bandit state: ${e}`);
503
+ }
504
+
505
+ // Check for skill updates on startup
506
+ try {
507
+ const skillVersions = getLocalSkillVersions(ctx.workspaceDir);
508
+ if (Object.keys(skillVersions).length > 0) {
509
+ const briefing = await api.getBriefing(skillVersions);
510
+ if (briefing.skill_updates && briefing.skill_updates.length > 0) {
511
+ await checkAndUpdateSkills(briefing.skill_updates, ctx.workspaceDir, ctx.logger);
512
+ }
513
+ }
514
+ } catch (e) {
515
+ ctx.logger.warn(`[simmer] Startup skill update check failed: ${e}`);
516
+ }
517
+
395
518
  // Periodic refresh
396
519
  cycleTimer = setInterval(async () => {
397
520
  if (!serviceRunning) return;
@@ -403,20 +526,28 @@ export default function register(pluginApi: PluginApi) {
403
526
  await refreshState(ctx.logger);
404
527
 
405
528
  // Query outcomes since last cycle and update bandit reward data
529
+ // Filter to outcomes matching this plugin's configured venue
406
530
  try {
407
531
  const outcomeRes = await api.getOutcomes(cycleStarted);
408
- for (const o of outcomeRes.outcomes) {
532
+ const venueOutcomes = outcomeRes.outcomes.filter(
533
+ (o: SkillOutcome) => o.venue === config.venue
534
+ );
535
+ for (const o of venueOutcomes) {
409
536
  const skill = banditState.find((s) => s.slug === o.skill_slug);
410
537
  if (skill) {
411
538
  skill.tradesExecutedTotal += o.trades;
412
- skill.timesRewarded += o.trades > 0 ? 1 : 0;
539
+ // Return-on-capital for this cycle (0 if no cost data)
540
+ const roc = o.total_cost > 0 ? o.period_pnl / o.total_cost : 0;
541
+ // Exponentially weighted RoC — decays stale data (~7-cycle half-life)
542
+ skill.ewmRoc = EWM_ALPHA * roc + (1 - EWM_ALPHA) * skill.ewmRoc;
543
+ skill.timesRewarded += roc > 0 ? 1 : 0;
413
544
  skill.totalPnl += o.period_pnl;
414
545
  skill.consecutiveZeroSignals = o.trades > 0 ? 0 : skill.consecutiveZeroSignals + 1;
415
546
  }
416
547
  }
417
548
  // Skills that were selected last cycle but had zero trades won't appear in outcomes
418
549
  // (GROUP BY only returns rows with trades). Increment consecutiveZeroSignals for them.
419
- const outcomeSkillSlugs = new Set(outcomeRes.outcomes.map((o: SkillOutcome) => o.skill_slug));
550
+ const outcomeSkillSlugs = new Set(venueOutcomes.map((o: SkillOutcome) => o.skill_slug));
420
551
  for (const m of currentSelectedMeta) {
421
552
  if (!outcomeSkillSlugs.has(m.slug)) {
422
553
  const skill = banditState.find((s) => s.slug === m.slug);
@@ -425,8 +556,8 @@ export default function register(pluginApi: PluginApi) {
425
556
  }
426
557
  }
427
558
  }
428
- if (outcomeRes.outcomes.length > 0) {
429
- ctx.logger.info(`[simmer] Outcomes: ${outcomeRes.outcomes.map((o: SkillOutcome) => `${o.skill_slug}:${o.trades}t`).join(", ")}`);
559
+ if (venueOutcomes.length > 0) {
560
+ ctx.logger.info(`[simmer] Outcomes (${config.venue}): ${venueOutcomes.map((o: SkillOutcome) => `${o.skill_slug}:${o.trades}t/$${o.period_pnl.toFixed(2)}pnl`).join(", ")}`);
430
561
  }
431
562
  } catch (e) {
432
563
  ctx.logger.error(`[simmer] Failed to fetch outcomes: ${e}`);
@@ -476,6 +607,19 @@ export default function register(pluginApi: PluginApi) {
476
607
  cycle_number: thisCycleNumber,
477
608
  selection_meta: meta.map((m) => ({ slug: m.slug, reason: m.reason, score: m.score })),
478
609
  config_changes: configChangesPayload,
610
+ // Bandit persistence — write current arm stats so restarts resume where we left off
611
+ epsilon: parseFloat(config.epsilon.toFixed(6)),
612
+ bandit_arms: banditState.map((s) => ({
613
+ slug: s.slug,
614
+ enabled: s.enabled,
615
+ timesSelected: s.timesSelected,
616
+ timesRewarded: s.timesRewarded,
617
+ totalPnl: s.totalPnl,
618
+ consecutiveZeroSignals: s.consecutiveZeroSignals,
619
+ signalsFoundTotal: s.signalsFoundTotal,
620
+ tradesExecutedTotal: s.tradesExecutedTotal,
621
+ ewmRoc: s.ewmRoc,
622
+ })),
479
623
  });
480
624
  } catch (e) {
481
625
  ctx.logger.warn(`[simmer] Failed to post cycle (active_skills may be stale): ${e}`);
@@ -495,8 +639,9 @@ export default function register(pluginApi: PluginApi) {
495
639
 
496
640
  // Fetch portfolio snapshot for prompt context
497
641
  try {
498
- const briefing = await api.getBriefing();
499
- const venue = briefing.venues?.simmer;
642
+ const skillVersions = getLocalSkillVersions(ctx.workspaceDir);
643
+ const briefing = await api.getBriefing(skillVersions);
644
+ const venue = (briefing.venues as Record<string, any>)?.[config.venue];
500
645
  const attentionPositions = venue?.positions_needing_attention || [];
501
646
  cachedPortfolio = {
502
647
  totalPnl: briefing.performance?.total_pnl ?? 0,
@@ -504,6 +649,13 @@ export default function register(pluginApi: PluginApi) {
504
649
  positions: attentionPositions,
505
650
  recentTradeCount: 0,
506
651
  };
652
+
653
+ // Periodic skill auto-update
654
+ if (briefing.skill_updates && briefing.skill_updates.length > 0 &&
655
+ thisCycleNumber - lastUpdateCheckCycle >= UPDATE_CHECK_INTERVAL) {
656
+ lastUpdateCheckCycle = thisCycleNumber;
657
+ await checkAndUpdateSkills(briefing.skill_updates, ctx.workspaceDir, ctx.logger);
658
+ }
507
659
  } catch (e) {
508
660
  ctx.logger.warn(`[simmer] Failed to fetch briefing: ${e}`);
509
661
  }
@@ -708,11 +860,14 @@ export default function register(pluginApi: PluginApi) {
708
860
  if (positions.length === 0) {
709
861
  return { text: "No open positions." };
710
862
  }
711
- const lines = positions.map((p: BriefingPosition) => {
863
+ const lines = positions.map((p: PositionsPosition) => {
864
+ const shares = p.shares_yes + p.shares_no;
865
+ const side = p.shares_yes > 0 && p.shares_no > 0 ? "both" : (p.shares_yes > 0 ? "yes" : "no");
712
866
  const pnlStr = p.pnl >= 0 ? `+${fmtCurrency(p.pnl)}` : fmtCurrency(p.pnl);
713
- return `${p.question.slice(0, 55)} | ${p.side} ${p.shares}sh @ ${p.avg_entry.toFixed(2)} → ${p.current_price.toFixed(2)} | ${pnlStr}${p.source ? ` [${p.source}]` : ""}`;
867
+ const src = p.sources?.length ? ` [${p.sources[0]}]` : "";
868
+ return `${p.question.slice(0, 55)} | ${side} ${shares.toFixed(1)}sh @ ${p.avg_cost.toFixed(2)} → ${p.current_price.toFixed(2)} | ${pnlStr}${src}`;
714
869
  });
715
- const totalPnl = positions.reduce((sum: number, p: BriefingPosition) => sum + p.pnl, 0);
870
+ const totalPnl = positions.reduce((sum: number, p: PositionsPosition) => sum + p.pnl, 0);
716
871
  return {
717
872
  text: `Positions (${positions.length}) | Total P&L: ${fmtCurrency(totalPnl)}\n\n${lines.join("\n")}`,
718
873
  };